Compare 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
Release / Image (backend) (push) Successful in 1m8s
Release / Image (frontend) (push) Successful in 50s
Release / Image (gateway) (push) Successful in 29s
CI / Check (gateway, backend, frontend) (push) Successful in 1m9s
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 (gateway) (push) Successful in 1m15s
Release / Image (frontend) (push) Successful in 48s
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
CI / Check (gateway, backend, frontend) (push) Successful in 1m30s
Release / Images (push) Successful in 3m18s
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
Release / Images (push) Successful in 3m1s
CI / Check (gateway, backend, frontend) (push) Successful in 1m23s
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
root b6997560d2 Merge PR #75: feat: show a change at once and restore a setpoint now
CI / Check (gateway, backend, frontend) (push) Successful in 1m18s
Release / Images (push) Successful in 2m58s
2026-10-24 13:37:34 +02:00
rootandClaude Opus 5.5 09042c6338 feat(frontend): Przywróć teraz for pending changes in Ustawienia
CI / Check (gateway, backend, frontend) (push) Successful in 1m19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:34:34 +02:00
rootandClaude Opus 5.5 f9fbff4eea feat(backend): POST /api/setpoints/{name}/restore
Forwards to the gateway's restore now and passes its verdict on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:34:34 +02:00
rootandClaude Opus 5.5 7e50d1203b feat(gateway): show the verified value and restore a setpoint on request
GET /commands showed the last poll for up to 5 minutes after a write.
It now shows the value read back after our own write or restore when
that is newer than the poll. POST /commands/restore runs a pending
restore at once through the normal path (conflict check, read-back,
audit with the requester, rate limit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:34:34 +02:00
root 934330e157 Merge PR #74: feat: away holds until a chosen return date (ADR-012)
CI / Check (gateway, backend, frontend) (push) Successful in 1m18s
2026-10-24 13:28:16 +02:00
rootandClaude Opus 5.5 2e273f05c2 feat(frontend): pick the return date for Wyjazd
CI / Check (gateway, backend, frontend) (push) Successful in 1m18s
Tapping Wyjazd opens a form with a datetime-local picker (from one hour
to 30 days ahead, default in a week at 18:00) and sends the chosen time
with the local offset. A held restore shows its end date, for example
"do 2 lis, 18:00, potem 43 °C".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:25:10 +02:00
rootandClaude Opus 5.5 9a48442220 feat(backend): presets held until a date the user chooses
A preset with "until": "chosen" needs {"until": ...} in POST
/api/presets/{id} and sends each change to the gateway as a held change
(ADR-012). Wyjazd becomes such a preset. A held preset that would not
lower a setpoint is refused up front, so it never stops half way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:22:50 +02:00
rootandClaude Opus 5.5 b3038aeef7 feat(gateway): hold a lowering change until a chosen end without renewals
A command may carry "until" (ISO-8601 with offset). The change is stored
as a restore with leaseUntil = due = until, so it survives a restart and
runs at its end. Held changes must lower the value below both the current
value and the original, end in the future and within 30 days; otherwise
they are rejected without a write. Renewals now only extend a lease.
ADR-012 amends ADR-011 for held changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:22:02 +02:00
root 9e997e43f3 Merge PR #73: feat: show the deployed version of each part in Stan
Release / Images (push) Successful in 2m54s
CI / Check (gateway, backend, frontend) (push) Successful in 1m12s
2026-10-24 12:55:37 +02:00
root 8ae6cea536 Merge master into feat/show-versions
CI / Check (gateway, backend, frontend) (push) Successful in 1m16s
# Conflicts:
#	backend/src/main/java/home/backend/Backend.java
#	backend/src/test/java/home/backend/adapters/http/ApiServerTest.java
#	gateway/README.md
2026-10-24 12:53:16 +02:00
root 604ef4e6d9 Merge PR #71: feat(energy): device details panels for Deye, Fronius and the house meter
CI / Check (gateway, backend, frontend) (push) Successful in 1m15s
2026-10-24 12:50:10 +02:00
rootandClaude Opus 5.5 b0735471b4 feat(status): show deployed versions of app, server and gateway
CI / Check (gateway, backend, frontend) (push) Successful in 1m15s
Each image gets the release tag as the VERSION build arg. The gateway
reports it in /health, the backend adds its own and the gateway's to
/api/health, and the Stan screen lists all three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:50:03 +02:00
rootandClaude Opus 5.5 6f50f5b7b9 feat(energy): show Deye totals, inverter and per-phase meter energy
CI / Check (gateway, backend, frontend) (push) Successful in 1m16s
The gateway now reads the Deye lifetime counters, inverter power and
temperatures, and the meter's per-phase energy into house_meter_phases.
A group can take its fields from another measurement of the same device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:47:55 +02:00
root bad67b9538 Merge PR #72: feat(commands): setpoint changes are leases the backend renews 2026-10-24 12:46:08 +02:00
root ce00d0e81b Merge PR #70: feat(deye): read totals, PV voltage and current, temperatures, frequency 2026-10-24 12:46:08 +02:00
root 0eebba072e Merge PR #69: feat(meter): write per-phase energy counters as house_meter_phases 2026-10-24 12:46:08 +02:00
rootandClaude Opus 5.5 ea83255b91 perf(deye): read the registers in ten blocks as before
CI / Check (gateway, backend, frontend) (push) Successful in 1m17s
The new fields added seven requests per poll over the Solarman logger.
Neighbouring registers now share one request, so each poll again makes
ten round trips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:41:56 +02:00
rootandClaude Opus 5.5 ba8631b3c2 docs: ADR-011 setpoint leases, spec F3 limits decided, roadmap M4
CI / Check (gateway, backend, frontend) (push) Successful in 1m15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:40:36 +02:00
rootandClaude Opus 5.5 6cae491b1c feat(web): show a change without end time as lasting while the server runs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:40:36 +02:00
rootandClaude Opus 5.5 83333a19bf docs(meter): wrap the register list comment
CI / Check (gateway, backend, frontend) (push) Successful in 1m16s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:39:45 +02:00
rootandClaude Opus 5.5 0072017966 feat(backend): renew the gateway's setpoint leases every 5 minutes
Without renewals the gateway restores every change after 15 minutes, so a
lost backend or tunnel never leaves the house on boost or away settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:39:10 +02:00
rootandClaude Opus 5.5 35b3f8e1e0 feat(web): open device details from the energy screen
CI / Check (gateway, backend, frontend) (push) Successful in 1m17s
A "Urządzenia" card lists the Deye, Fronius and house meter as rows with
a "Szczegóły ›" chevron, 56 px high. Tapping one opens a bottom sheet on
a phone (a dialog on wide screens) with the grouped values; it closes by
button, backdrop or Escape and returns focus to the row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:38:45 +02:00
rootandClaude Opus 5.5 7a17bbbcdb feat(energy): serve device details at /api/energy/details
Groups of labelled values for the Deye, Fronius and house meter, so the
energy screen can show more without crowding it. A stale device has no
groups; a field the device did not report is left out.

The catalog of labels, units and groups lives in the backend
(EnergyDetailsService): the frontend renders whatever groups arrive, so
a new field is one line in one file and needs no UI release. It also
keeps field names out of TypeScript and lets the backend round values
(Wh to kWh) once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:38:45 +02:00
rootandClaude Opus 5.5 f0c974c82a feat(deye): read totals, PV voltage and current, temperatures, frequency
CI / Check (gateway, backend, frontend) (push) Successful in 1m14s
Verified live on 2026-10-24 (read only, two dumps a minute apart): totals
are 32-bit low word first and stay above the today counters, PV V x A
matches PV W within the 0.1 A resolution, per-phase inverter power sums
to 636, battery current agrees with battery power, frequency is 49.95 Hz.
Rejected as unclear: alarm words 552-558, inverter and load currents
(scale does not fit), registers 592 and 604-607.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:37:33 +02:00
rootandClaude Opus 5.5 d557f98df6 feat(gateway): restore every change when its lease lapses, renew over HTTP
A change now keeps a restore that runs at the earlier of its end time and a
15 minute lease; POST /commands/renew extends live leases. A lost backend or
tunnel can no longer leave boost or away settings on the controller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:37:25 +02:00
rootandClaude Opus 5.5 9f33f5a94c feat(meter): write per-phase energy counters as house_meter_phases
CI / Check (gateway, backend, frontend) (push) Successful in 1m17s
One extra FC4 request per poll; PRAD2 is unchanged and still written when
the extra request fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:36:22 +02:00
root 798f98b69d Merge PR #68: docs(roadmap): meter and BME280 run in the gateway since v1.2.1
CI / Check (gateway, backend, frontend) (push) Successful in 1m15s
2026-10-24 12:21:13 +02:00
rootandClaude Opus 5.5 e65cb5837f docs(roadmap): meter and BME280 run in the gateway since v1.2.1
CI / Check (gateway, backend, frontend) (push) Successful in 1m15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:18:46 +02:00
root 0b3197ac55 Merge PR #67: fix(meter): speak RTU over TCP to the house meter's converter
CI / Check (gateway, backend, frontend) (push) Successful in 1m14s
Release / Images (push) Successful in 2m51s
2026-10-24 12:09:59 +02:00
rootandClaude Opus 5.5 5513c09c51 fix(meter): speak RTU over TCP to the house meter's converter
CI / Check (gateway, backend, frontend) (push) Successful in 1m17s
The RS485 converter at 192.168.0.10 forwards raw RTU frames (unit, PDU,
CRC-16), as sdm_modbus does with ModbusRtuFramer. The gateway sent Modbus
TCP with an MBAP header, which the converter never answers, so every poll
timed out. TcpTransport now takes a framing: MBAP for MK01, RTU for the
meter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:07:19 +02:00
root 697b68ec39 Merge PR #66: feat(web): name the house Szczepańcowa instead of Krosno
CI / Check (gateway, backend, frontend) (push) Successful in 1m18s
Release / Images (push) Successful in 2m51s
2026-10-24 11:29:22 +02:00
root 645bbc4b88 Merge PR #65: docs(roadmap): the new gateway runs on the Pi with the inverters; D7 fixed 2026-10-24 11:29:22 +02:00
rootandClaude Opus 5.5 2d7899ebc8 feat(web): name the house Szczepańcowa instead of Krosno
CI / Check (gateway, backend, frontend) (push) Successful in 1m14s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:26:55 +02:00
rootandClaude Opus 5.5 c34a9da0d7 docs(roadmap): the new gateway runs on the Pi with the inverters; D7 fixed
CI / Check (gateway, backend, frontend) (push) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:25:28 +02:00
root a8f6384e04 Merge PR #64: fix(modbus): close an idle connection, so no poll meets a reset socket
CI / Check (gateway, backend, frontend) (push) Successful in 1m14s
Release / Images (push) Successful in 2m53s
2026-10-24 11:13:20 +02:00
rootandClaude Opus 5.5 3c68dc3621 fix(modbus): close an idle connection, so no poll meets a reset socket
CI / Check (gateway, backend, frontend) (push) Successful in 1m17s
On the Pi every other heat pump poll failed with 'Connection reset by
peer': MK01 drops a connection left idle for minutes, and the gateway
kept one open between polls (300 s). The socket now closes after 5 idle
seconds and the next poll connects anew, which also frees the single
MK01 connection for the vendor's app between polls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:11:09 +02:00
root 93a3582c45 Merge PR #63: fix(release): install the Docker CLI in the job when the image lacks it
CI / Check (gateway, backend, frontend) (push) Successful in 1m16s
Release / Images (push) Successful in 6m7s
2026-10-24 10:23:00 +02:00
rootandClaude Opus 5.5 649751a0d3 fix(release): install the Docker CLI in the job when the image lacks it
CI / Check (gateway, backend, frontend) (push) Successful in 1m15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 10:21:00 +02:00
root 1e5c40b5d0 Merge PR #62: refactor: gateway, backend and frontend as three images with compose and tag releases
CI / Check (gateway, backend, frontend) (push) Successful in 1m18s
2026-10-24 10:18:44 +02:00
rootandClaude Opus 5.5 b2b946d4bc docs: one readme per part, ADR-010, release and update instead of deploy
CI / Check (gateway, backend, frontend) (push) Successful in 2m2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 10:14:54 +02:00
rootandClaude Opus 5.5 4afc1c9ed0 feat(release): publish three images by tag, update the Pi with update.sh
Each part gets a Dockerfile and a compose.yaml. The Dockerfiles build on
the runner's own platform and only copy into the final stage, so CI
makes the Pi's arm64 gateway image without emulation. A pushed tag
(scripts/release.sh) publishes them in the Gitea registry; on the Pi
update.sh pulls a release with its compose.yaml and goes back to the
previous one when /health fails or the container restarts. nginx in the
frontend image serves the UI and passes /api/ to the backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 10:14:54 +02:00
rootandClaude Opus 5.5 5d8d31c8e1 refactor: split into gateway/, backend/ and frontend/; the backend serves only the API
The gateway moves from the root to gateway/, the web UI from backend/web
to frontend/ with its own build into dist/. The backend drops static
files and the frontend-maven-plugin and answers 404 outside /api/, still
with the security headers. check.sh builds and tests all three; CI sets
up Node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 10:14:44 +02:00
root 3d47764c2f Merge PR #53: feat(gateway): wire the setpoint write path, off unless enabled
CI / Backend tests (Maven / Java 21) (push) Successful in 1m18s
2026-10-24 09:56:13 +02:00
root 36093eb8dc Merge PR #42: feat(gateway): config file outside the jar, Main wired, baseline removed 2026-10-24 09:56:13 +02:00
root 074bd7b78e Merge PR #61: docs(adr): open the web UI to Krosno only, without a login for now
CI / Backend tests (Maven / Java 21) (push) Successful in 1m38s
2026-10-24 09:25:46 +02:00
rootandClaude Opus 5.5 d4a9f2fa25 docs(adr): open the web UI to Krosno only, without a login for now
CI / Backend tests (Maven / Java 21) (push) Successful in 1m35s
The user chose to defer the login: Traefik admits only Krosno and clears
a client's Remote-User. A later ADR picks the login before access from
outside.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 09:22:48 +02:00
rootandClaude Opus 5.5 49b5689b96 fix(deploy): check health on the address set in http.bind
CI / Backend tests (Maven / Java 21) (push) Successful in 1m23s
With http.bind on the WireGuard address the gateway no longer answers on
localhost, so every deploy would have rolled back. The script now reads
http.bind and http.port from gateway.json on the Pi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 09:17:27 +02:00
root b17a84e3dd Merge branch 'feat/gateway-main' into feat/m4-wiring 2026-10-24 09:15:47 +02:00
root 7402a1d6a2 Merge remote-tracking branch 'origin/master' into feat/gateway-main
CI / Backend tests (Maven / Java 21) (push) Successful in 1m20s
2026-10-24 09:15:41 +02:00
root ab4fdef6c8 Merge PR #60: feat(web): rewrite the UI in React with an animated picture of the installation
CI / Backend tests (Maven / Java 21) (push) Successful in 1m34s
2026-10-24 09:11:22 +02:00
root f504df7e88 Merge PR #59: fix(backend): review fixes for stale data, headers, errors and HTTP limits
CI / Backend tests (Maven / Java 21) (push) Successful in 1m11s
2026-10-24 09:09:40 +02:00
rootandClaude Opus 5.5 ce1c733b9f feat(web): draw the overview over a picture of our installation
CI / Backend tests (Maven / Java 21) (push) Successful in 1m38s
The hand-drawn scene showed an outdoor unit and no photovoltaics; ours is
a ground-source pump in the boiler room. The overview now shows a
generated cutaway of the house (research/ui/house-cutaway.webp) with an
SVG layer on top: each pipe circuit, found by colour into a mask by
scripts/house-assets.py, flows while its pump runs and is grey while it
stands; the heat pump and tanks glow while they work, warmth rises from
the floor, light sweeps over the PV panels, and callouts carry the live
values. The backend serves .webp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 08:51:43 +02:00
rootandClaude Opus 5.5 00755cc560 feat(web): let the viewer pick a light, dark or system theme
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 08:42:54 +02:00
rootandClaude Opus 5.5 f7c36ab1f6 feat(web): rewrite the UI in React with an animated installation picture
CI / Backend tests (Maven / Java 21) (push) Successful in 1m37s
The plain-JS UI was too weak to grow into the reference dashboard. The new
UI (React, TypeScript, Vite, Tailwind, Recharts) follows research/ui/: a
side bar on large screens, tabs on a phone, and an SVG picture of the
installation whose pipes flow, compressor turns and tanks glow while the
matching pump runs. Energy flows are animated the same way.

Maven builds and tests the UI (frontend-maven-plugin, Node per project),
so check.sh and CI need only Java. The fixes of the web review (stale and
missing readings, silent inverters, outages, Warsaw time, timeouts,
out-of-order answers, restore values) carry over; tests read the API
contract examples. The CSP now allows the self-hosted font.

Supersedes fix/web-review. ADR-008 records the new UI stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 08:39:17 +02:00
rootandClaude Opus 5.5 c4b4715699 docs(backend): publish no host port, only Traefik reaches the backend
CI / Backend tests (Maven / Java 21) (push) Successful in 1m11s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 08:12:45 +02:00
rootandClaude Opus 5.5 bc47260e86 perf(history): ask InfluxDB for the PV parts at the same time
The PV series sums three measurements; asking one after another tripled
the wait on the energy screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 08:12:45 +02:00
rootandClaude Sonnet 5.5 2d62ac1d87 fix(http): match API paths exactly, cap request bodies, stop gracefully, reject URL paths
CI / Backend tests (Maven / Java 21) (push) Successful in 1m12s
GATEWAY_URL and INFLUX_URL with a path now fail at start instead of
having the path silently dropped.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:39:45 +02:00
rootandClaude Sonnet 5.5 fce624b822 fix(api): answer 502 instead of dropping the connection on a bad gateway payload
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:38:22 +02:00
rootandClaude Sonnet 5.5 af51ed0626 feat(web): send security headers on every response
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:36:42 +02:00
rootandClaude Sonnet 5.5 088de5ac1d feat(api): mark stale readings and never date a missing one with the clock
HeatPumpView gets stale and a null time without a reading; EnergyView
lists its sources and nulls the values of a stale one.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:35:36 +02:00
root 6ee291b094 Merge PR #58: docs(energy): record the SDM630 power sign, + import / − export
CI / Backend tests (Maven / Java 21) (push) Successful in 1m11s
2026-10-24 05:34:48 +02:00
rootandClaude Opus 5.5 e95f9b89f1 docs(energy): record the SDM630 power sign, + import / − export
CI / Backend tests (Maven / Java 21) (push) Successful in 1m10s
The backend's grid history uses PRAD2 while the live tile uses Deye; a
review asked whether both use the same sign. They do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 05:33:18 +02:00
rootandClaude Sonnet 5.5 43e9086689 docs(readme): list the preconditions for enabling commands
CI / Backend tests (Maven / Java 21) (push) Successful in 56s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:27:10 +02:00
rootandClaude Sonnet 5.5 d74fdbde34 fix(restore): run the overdue first tick off the start path
A dead controller no longer delays Gateway start; commands answer 503 until the first tick ends.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:26:26 +02:00
rootandClaude Sonnet 5.5 e3c27475ba feat(http): listen on a configured address with http.bind
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:25:23 +02:00
rootandClaude Sonnet 5.5 85600d70ef fix(gateway): keep running with commands disabled on a corrupt restore file
Polling, health and the watchdog continue; /commands answers 503 until a restart and never writes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:23:48 +02:00
rootandClaude Sonnet 5.5 b1fce8ad31 fix(gateway): adapt wiring to the hardened CommandService
Pass System::nanoTime and a fixed plausible-clock floor (the jar has no build time).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:22:22 +02:00
root 39b682f2ff Merge master into feat/gateway-main
CI / Backend tests (Maven / Java 21) (push) Successful in 53s
2026-10-24 05:20:46 +02:00
root 80c66461bf Merge remote-tracking branch 'origin/master' into feat/m4-wiring 2026-10-24 05:20:41 +02:00
root d7f27af997 Merge PR #57: fix(store): keep the strict restore file format with an optional otherApplied
CI / Backend tests (Maven / Java 21) (push) Successful in 1m11s
2026-10-24 05:19:47 +02:00
rootandClaude Opus 5.5 7cbbc788ef fix(store): keep the strict restore file format with an optional otherApplied
CI / Backend tests (Maven / Java 21) (push) Successful in 1m10s
The strict reading from the store review rejected every file without
the new otherApplied field, so after both fixes every restore would
have gone to quarantine. A file record now marks the five real fields
required and leaves otherApplied optional; a null setpoint or due time
is still corrupt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 05:18:18 +02:00
root e901d4c351 Merge master into fix/m4-command-safety 2026-10-24 05:16:15 +02:00
rootandClaude Sonnet 5.5 7263c9e3c3 fix(restore): retry a failed restore-list save on the next tick
If forget() could not save after a successful restore, the file kept the
done record and a restart would reload it. The failed save is now
remembered and retried each tick until it goes through.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:15:43 +02:00
root 5dacbfd45e Merge PR #56: fix(web): do not name a restore time from the browser clock
CI / Backend tests (Maven / Java 21) (push) Successful in 1m9s
2026-10-24 05:15:07 +02:00
rootandClaude Sonnet 5.5 b06dde4587 test(commands): second boost keeps the first original after a panel change
ADR-006 rule, now pinned by a test; the explaining comment is in
CommandService.write.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:14:58 +02:00
rootandClaude Sonnet 5.5 2c214c889d fix(commands): rate limit on a monotonic clock, refuse commands before the clock is set
The Pi has no RTC, so an NTP step made Duration.between negative and
commands were refused as too soon. The limit now uses the monotonic
source. While the wall clock is before a floor given by the caller
(e.g. the build date) commands are rejected with 'clock not set'.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:14:28 +02:00
root 0c0ca68fbf Merge PR #55: feat(http): answer 429 after repeated failed token checks on /commands
CI / Backend tests (Maven / Java 21) (push) Successful in 1m9s
2026-10-24 05:14:01 +02:00
rootandClaude Sonnet 5.5 17b65dc1c0 fix(restore): back off failed restores and respect the write rate limit
A failed restore was retried every tick, writing the EEPROM every 30 s.
Retries now wait 1, 2, 5, 15 minutes, then 15 minutes each; every
failure is audited and logged, the restore stays pending. Restore
writes count as last writes and wait for the per-register minimum
interval. CommandService takes a monotonic time source for this.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:13:44 +02:00
rootandClaude Sonnet 5.5 65f96d3f41 fix(web): do not name a restore time from the browser clock
CI / Backend tests (Maven / Java 21) (push) Successful in 1m8s
The confirm text now says "Za 2 godz. wrócimy do X"; the exact time
comes from the gateway's restoreAt in the result after the write.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:13:03 +02:00
rootandClaude Sonnet 5.5 a1a0853d65 fix(backend): report every preset change, also when the link drops
A tunnel drop on change 2 made the API answer 503 and lose change 1's
result, and a failed change hid the later ones. Now every change has an
entry (APPLIED, REJECTED, FAILED, UNKNOWN, NOT_ATTEMPTED) and the preset
status is APPLIED, PARTIAL, FAILED or REJECTED; 503 only when nothing
was attempted. The UI lists each change and says to check the values.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:12:26 +02:00
rootandClaude Sonnet 5.5 5f2dc81107 feat(http): answer 429 after repeated failed token checks on /commands
CI / Backend tests (Maven / Java 21) (push) Successful in 1m9s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:11:54 +02:00
rootandClaude Sonnet 5.5 6d7704964a fix(commands): hard ceilings and a 72 h cap on boosts
Ceilings live in code (CWU 4143/4144 max 50, CO 4131/4132 max 25, min 5)
so a config typo cannot allow an unsafe value; the service refuses to
start with a wider or unknown setpoint. A boost longer than 72 h is
rejected. Service test fixtures now use real registers and whole degrees.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:11:44 +02:00
rootandClaude Sonnet 5.5 0f7939fa6e fix(http): validate X-Requested-By and keep line breaks out of line protocol
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:10:39 +02:00
rootandClaude Sonnet 5.5 a36ef14f0b fix(restore): validate every restore against the allowlist before writing
A corrupt or old restore file could write any value to any register, and
a setpoint missing from the config threw and stopped all later restores.
Records are checked on load and before use (name, register, min..max); a
bad one is dropped with an error and an audit line, never written, and
never blocks the others.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:10:37 +02:00
rootandClaude Sonnet 5.5 a336851cd7 fix(web): show "brak odczytu" for a setpoint the gateway has not read
Setpoint.value is now nullable; a null used to deserialize to 0 and the UI
showed 0 °C and built a wrong "back to" sentence. The setpoint and the
presets that touch it are disabled until a value is known.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:10:09 +02:00
rootandClaude Sonnet 5.5 b74ada8cb6 fix(restore): keep the first boost's restore when a second boost fails
A failed second boost used to replace the pending restore, so the later
restore saw the first boost's value, called it a conflict and left the
pump boosted. The previous restore is now put back (also on disk) and
also accepts the new value, since a lost reply may have applied it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:09:38 +02:00
rootandClaude Sonnet 5.5 070b61c785 fix(http): cap the command body at 1 KB and require application/json
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:09:35 +02:00
rootandClaude Sonnet 5.5 b58fe88ee0 fix(backend): cap boost duration at 72 hours
Matches the gateway's new cap: a longer request or preset is refused
(400 / startup error) before anything is sent.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:09:14 +02:00
rootandClaude Sonnet 5.5 f29728cb9f fix(store): reject incomplete restore files and move corrupt ones aside
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:08:48 +02:00
rootandClaude Sonnet 5.5 b7f62d95aa fix(web): refuse cross-site writes to setpoints and presets
A simple no-cors POST from another page carries the Traefik cookie.
Writes now need Content-Type application/json (415), the custom header
X-Requested-With: home-pump (403) and, when present, an Origin that
matches Host (403). api.js sends the header on every POST.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:08:04 +02:00
rootandClaude Sonnet 5.5 d010c51e2a fix(store): fsync the restore file before and after the atomic move
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:07:32 +02:00
root 5073e0a1b3 Merge PR #54: docs(roadmap): M4 pieces on master, wiring waits with #42
CI / Backend tests (Maven / Java 21) (push) Successful in 1m6s
2026-10-24 05:05:20 +02:00
rootandClaude Opus 5.5 377349cf0e docs(roadmap): M4 pieces on master, wiring waits with #42
CI / Backend tests (Maven / Java 21) (push) Successful in 1m7s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 05:03:45 +02:00
rootandClaude Sonnet 5.5 2b6cfd3496 feat(gateway): wire the setpoint write path, off unless enabled
CI / Backend tests (Maven / Java 21) (push) Successful in 52s
Commands share the heat pump's single Modbus client (the MK01 takes one
TCP connection), audit into Influx and restore on a schedule.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 05:02:41 +02:00
root 79cf69eed3 Merge PR #52: feat(web): name the fault in the heat pump alarm banner
CI / Backend tests (Maven / Java 21) (push) Successful in 1m8s
2026-10-24 05:01:35 +02:00
rootandClaude Opus 5.5 af6f829da9 feat(web): name the fault in the heat pump alarm banner
CI / Backend tests (Maven / Java 21) (push) Successful in 1m9s
The backend now sends a Polish alarmText for the controller's fault code
(register 4011), so the banner can say which sensor or the pressure
switch tripped instead of only "alarm".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 05:00:05 +02:00
rootandClaude Opus 5.5 6f2f7cdfa0 test(config): compare the default blocks with the production list
CI / Backend tests (Maven / Java 21) (push) Successful in 51s
The block count changed when 4011 was added; comparing with
PRODUCTION_BLOCKS keeps the test about the default, not a number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 04:57:43 +02:00
root daa8de3235 Merge master into feat/gateway-main
CI / Backend tests (Maven / Java 21) (push) Failing after 34s
# Conflicts:
#	src/test/java/home/gateway/adapters/modbus/MockModbusServer.java
2026-10-24 04:56:56 +02:00
root cfabd96bb2 Merge PR #51: fix(web): never say a lost write changed nothing
CI / Backend tests (Maven / Java 21) (push) Successful in 1m7s
2026-10-24 04:55:38 +02:00
root 5e0939b171 Merge PR #50: feat(backend): show the heat pump alarm code and its Polish text 2026-10-24 04:55:38 +02:00
root ff1dce8588 Merge PR #45: test(http): name the test bearer so the secret scan does not flag it 2026-10-24 04:55:37 +02:00
rootandClaude Opus 5.5 8829372f63 test(http): name the test bearer so the secret scan does not flag it
CI / Backend tests (Maven / Java 21) (push) Successful in 1m5s
scripts/check.sh refused 'TOKEN = "…"' in CommandsEndpointTest as a
possible secret, which failed CI; the value is a test fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 04:53:22 +02:00
rootandClaude Opus 5.5 38dbcfe1db fix(web): never say a lost write changed nothing
CI / Backend tests (Maven / Java 21) (push) Successful in 1m5s
The backend answers 503 also when the link drops in the middle of a
preset, after some changes were applied; the screen now says it is not
known whether the change reached the pump.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 04:51:43 +02:00
root da2d2e30c6 Merge PR #49: feat(backend): change setpoints and apply presets through the gateway
CI / Backend tests (Maven / Java 21) (push) Successful in 1m6s
2026-10-24 04:51:27 +02:00
root 70dabd6bce Merge PR #48: fix(gateway): lock the command service with ReentrantLock, not a monitor 2026-10-24 04:51:26 +02:00
rootandClaude Sonnet 5.5 1c623d496c fix(web): align the Ustawienia tab with the setpoints API contract
Presets show name and a sentence built from their changes, preset results
list one line per change, 400/404/503 get their own Polish messages.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:51:07 +02:00
rootandClaude Sonnet 5.5 3603491617 feat(backend): show the heat pump alarm code and its Polish text
CI / Backend tests (Maven / Java 21) (push) Successful in 1m4s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:49:56 +02:00
rootandClaude Sonnet 5.5 09fb9ad6e4 feat(heatpump): read alarm code register 4011
The vendor app reports faults by code in 4011, not through 4221.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:49:34 +02:00
root ef3662df1b Merge PR #47: docs(frisko): add vendor app and map findings for the RX910-SE21 2026-10-24 04:48:24 +02:00
rootandClaude Sonnet 5.5 38c023c493 feat(web): add Ustawienia screen with presets and setpoint steppers
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:48:16 +02:00
root 422258de72 Merge master into feat/m4-commands-http to re-run CI on current master
CI / Backend tests (Maven / Java 21) (push) Failing after 12s
2026-10-24 04:47:31 +02:00
rootandClaude Sonnet 5.5 404aa3c367 feat(backend): change setpoints and apply presets through the gateway
CI / Backend tests (Maven / Java 21) (push) Successful in 1m5s
The backend sends commands only to the gateway with the shared token.
Without GATEWAY_TOKEN it still serves live state; setpoint endpoints
answer 503. Presets validate against the gateway's limits at use time.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:46:25 +02:00
rootandClaude Sonnet 5.5 1027410383 fix(gateway): lock the command service with ReentrantLock, not a monitor
CI / Backend tests (Maven / Java 21) (push) Successful in 1m3s
Controller I/O and the read-back pause ran inside synchronized, which pins
the carrier of a virtual thread on Java 21. A test shows a restore tick
waits while a command sits between its write and read-back.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:46:19 +02:00
root 6348726b82 Merge PR #46: feat(heatpump): add ModbusSetpointRegisters
CI / Backend tests (Maven / Java 21) (push) Successful in 1m3s
2026-10-24 04:45:52 +02:00
rootandClaude Sonnet 5.5 cd4259b175 feat(gateway): add the command service and the restore scheduler
CommandService is the only writer: allowlist, rate limit, read-back, audit
and the timed restores of ADR-006. The scheduler runs overdue restores at
start and then on a schedule, so a boost ends even with the tunnel down.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:44:24 +02:00
rootandClaude Sonnet 5.5 a7a6dd2caf feat(store): keep pending restores in one atomically written JSON file
A restore must survive a restart without the network (ADR-005). A corrupt
file fails loudly instead of silently dropping restores.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:44:24 +02:00
rootandClaude Sonnet 5.5 e56c1cdad6 docs(frisko): add vendor app and map findings for the RX910-SE21
CI / Backend tests (Maven / Java 21) (push) Successful in 1m5s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:44:18 +02:00
root 13770b44ce Merge PR #44: feat(gateway): add the Commands port for the setpoint service
CI / Backend tests (Maven / Java 21) (push) Successful in 1m3s
2026-10-24 04:43:00 +02:00
rootandClaude Sonnet 5.5 82ff6565ad feat(heatpump): add ModbusSetpointRegisters
CI / Backend tests (Maven / Java 21) (push) Successful in 1m6s
Adapter for the SetpointRegisters port: FC3 read and FC6 write of whole
values in the controller's sign-magnitude words. Negative values encode
correctly and values over 15 bits are rejected; limits stay with the service.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:43:00 +02:00
rootandClaude Sonnet 5.5 a6e6f36e87 feat(modbus): add FC6 write single register
The setpoint service (M4) must write one controller register and know it
landed. The write succeeds only when the device echoes address and value
exactly; exception responses surface as ModbusException like for reads.
MockModbusServer gets FC6 with a write log, wrong-echo and exception modes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:43:00 +02:00
rootandClaude Sonnet 5.5 e835f1adfa feat(http): add token-protected /commands endpoints
CI / Backend tests (Maven / Java 21) (push) Failing after 13s
POST applies a setpoint change (200 applied, 422 rejected, 502 failed),
GET lists setpoints with polled values and pending restores. Every
request needs the shared bearer token (S6).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:42:56 +02:00
rootandClaude Sonnet 5.5 098be6af19 docs(backend): add setpoint API examples for the UI
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:42:19 +02:00
rootandClaude Opus 5.5 2343fe410f feat(gateway): add the Commands port for the setpoint service
CI / Backend tests (Maven / Java 21) (push) Successful in 1m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 04:40:09 +02:00
rootandClaude Opus 5.5 e2f3b58777 feat(gateway): add setpoint, command and restore types for M4
CI / Backend tests (Maven / Java 21) (push) Successful in 1m7s
Setpoint changes (F3) are built in parallel: the command service, the
FC6 write, the HTTP endpoint and the backend need one contract first.
Example JSON in src/test/resources/commands is that contract. CWU max
is 48 °C, the margin the spec proposes; still to confirm in M2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 04:39:35 +02:00
root 322f4834a8 Merge PR #43: docs(roadmap): M1 code merged except Main; M3 backend and UI started
CI / Backend tests (Maven / Java 21) (push) Successful in 1m5s
2026-10-24 04:39:20 +02:00
rootandClaude Opus 5.5 d8a9716192 docs(roadmap): M1 code merged except Main; M3 backend and UI started
CI / Backend tests (Maven / Java 21) (push) Successful in 1m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 04:37:05 +02:00
root 1cc8a3b545 Merge PR #41: fix(web): enlarge schematic, chart and tab text
CI / Backend tests (Maven / Java 21) (push) Successful in 1m5s
2026-10-24 04:36:14 +02:00
root 9ec0ae2425 Merge PR #40: test(backend): use real gateway output for the end-to-end tests 2026-10-24 04:36:14 +02:00
root f84bf5a953 Merge PR #39: fix(backend): round heat pump temperatures to the controller's 0.1 2026-10-24 04:36:14 +02:00
rootandClaude Opus 5.5 d6531054ec fix(config): default the spool cap to 20 MB per database
CI / Backend tests (Maven / Java 21) (push) Successful in 49s
The review found that draining a large spool rewrites a lot on the Pi's
SD card; 20 MB still holds days of readings during an outage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 04:36:00 +02:00
rootandClaude Sonnet 5.5 01fb331e22 chore(deploy): pass the config path and wait 60 s before a restart
A watchdog exit must not hammer the controller, which takes one client at a time. README describes the config file.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:34:49 +02:00
rootandClaude Sonnet 5.5 15a3fc953e refactor: remove the baseline and its libraries
The gateway replaces it: its own Modbus core drops modbus4j, the JDK-based writer drops influxdb-java and guava, lombok was only used by the baseline. Fixes D4 and D5; the logging and the one-point-per-poll writer fix D2 and D8. The jar keeps its name and now starts home.gateway.Main.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:34:49 +02:00
rootandClaude Sonnet 5.5 e591c125cc test(gateway): run the assembled gateway against a fake controller and Influx
Asserts the POMPA point keeps exactly the 8 legacy field names with their values, heat_pump is written, and /health and /snapshot answer. MockModbusServer becomes public to be used from another package.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:34:49 +02:00
rootandClaude Sonnet 5.5 5b75c3b181 feat(gateway): assemble devices, writer, poller, watchdog and HTTP in Main
Gateway builds every part from the config and closes them in reverse, flushing the spool once more after the pollers stop. It refuses to start with no device or with a critical device that is not enabled.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:34:49 +02:00
rootandClaude Sonnet 5.5 08650bd0f7 feat(config): read the gateway config file with fail-fast validation
The baseline packaged its config in the jar (D3). The gateway reads JSON from a path given on the command line; disabled devices are not validated, and the minimum pause of Modbus links is required.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:34:14 +02:00
rootandClaude Sonnet 5.5 4446499996 fix(web): enlarge schematic, chart and tab text
CI / Backend tests (Maven / Java 21) (push) Successful in 1m4s
Small labels were hard to read; the solar collector box is shortened so its text no longer touches the border.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:55:21 +02:00
rootandClaude Sonnet 5.5 3813e010ae fix(web): explain an empty offline screen and an empty device list
With no cached data the screen showed only the banner; say what happens next. Also stops the favicon 404.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:55:20 +02:00
rootandClaude Sonnet 5.5 72960e74d0 fix(web): show grid power unsigned and say the direction in words
A minus before the grid power is confusing; pobor / oddawanie says it, and the battery text is a short sentence.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:55:20 +02:00
rootandClaude Sonnet 5.5 ceae74efd1 fix(web): put history axes on full hours and days, with the unit
The old ticks fell on odd times (06:48) and 7-day labels skipped days. Ticks are now every 6 full hours or local midnights with day names between the lines, and the unit is shown above the axis.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:55:20 +02:00
rootandClaude Sonnet 5.5 dd02947bb2 fix(web): show negative numbers with a real minus sign
A hyphen before a digit reads as a dash; the typographic minus is clearer for temperatures below zero.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:55:20 +02:00
rootandClaude Sonnet 5.5 fb72010a12 test(backend): use real gateway output for the end-to-end tests
CI / Backend tests (Maven / Java 21) (push) Successful in 1m2s
The gateway fixtures were written before its measurement names were
known (POMPA only), so the e2e test never saw a heat_pump or deye value.
They are now /health and /snapshot bodies produced by the real devices
(HeatPumpDevice, DeyeDevice, FroniusDevice, HouseMeterDevice,
Bme280Device on fake registers), Snapshot and GatewayHttpServer. The
backend test compares /api/heat-pump and /api/energy to the documented
examples value by value and expects every device in /api/health.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:52:36 +02:00
rootandClaude Sonnet 5.5 860aaa00e0 fix(backend): round heat pump temperatures to the controller's 0.1
CI / Backend tests (Maven / Java 21) (push) Successful in 1m3s
The gateway decodes 212 as 212 * 0.1 = 21.200000000000003 on purpose (bit
for bit parity with the old Python). The API must not leak that noise to
the phone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:52:36 +02:00
root a22788ef48 Merge PR #38: fix(influx): drain the spool forward and compact once per flush
CI / Backend tests (Maven / Java 21) (push) Successful in 1m3s
2026-10-24 00:49:56 +02:00
root 1c74044ad0 Merge PR #37: fix(fronius): write the values present when counters are null 2026-10-24 00:48:55 +02:00
root 1f114367b6 Merge PR #36: fix(watchdog): fire once and log the device's last error 2026-10-24 00:47:54 +02:00
root cf75f55c6b Merge PR #35: feat(backend): assemble and run the backend from environment settings
CI / Backend tests (Maven / Java 21) (push) Successful in 1m2s
2026-10-24 00:46:38 +02:00
root 7cf4576e49 fix(influx): drain the spool forward and compact once per flush
CI / Backend tests (Maven / Java 21) (push) Successful in 1m2s
Every batch re-read and rewrote the whole spool, which is quadratic on a
long outage and holds the file in heap. Draining now remembers a delivered
offset and cuts the head once per flush. The cap cuts to 90% so it does
not rewrite on every append. 413 now halves the batch (and the batch size
for later flushes) instead of dropping; a single line too big stays queued.
Drop warnings: the first of a flush in full, then one count.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
(cherry picked from commit 1c795a84b98175ebf89998ee52ef5c175017ac4b)
2026-10-24 00:46:27 +02:00
root a1e749e4ad fix(fronius): write the values present when counters are null
CI / Backend tests (Maven / Java 21) (push) Successful in 1m0s
A sleeping inverter returns null energy counters; requiring them marked
Fronius DOWN every night. Fail only when no value at all is available.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
(cherry picked from commit 594501e232c7cfaa6f03ca707b2dff44e40cfaa1)
2026-10-24 00:46:27 +02:00
rootandClaude Sonnet 5.5 1a8e8e8585 fix(watchdog): fire once and log the device's last error
CI / Backend tests (Maven / Java 21) (push) Successful in 1m3s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:46:02 +02:00
rootandClaude Sonnet 5.5 eb0b23637b fix(poller): no catch-up burst, sink errors do not mark devices down
After a stall the deadline stayed in the past and several polls ran
back to back; it now resets so one poll follows. A ReadingSink failure
was counted as a device failure; it is now logged once per outage.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:46:02 +02:00
rootandClaude Sonnet 5.5 0db35d06f8 fix(http): judge /health status by critical devices only
The deploy script rolls back on a non-200, so a down BME280 or Deye
must not fail a deploy while the heat pump is fine. The body keeps
every device and reports UP, DEGRADED or DOWN.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:46:02 +02:00
root 5be8ce49ef Merge PR #34: fix(solarman): skip unsolicited V5 frames and use a lock 2026-10-24 00:45:37 +02:00
rootandClaude Sonnet 5.5 f0cbcb00f5 feat(backend): assemble and run the backend from environment settings
CI / Backend tests (Maven / Java 21) (push) Successful in 1m2s
Main reads GATEWAY_URL, INFLUX_URL, PORT and the two timeouts, fails fast
with a clear message, and wires adapters and services. The end-to-end test
runs it against stub gateway and InfluxDB servers on localhost.

The test-only web/index.html is removed: it shadowed the real UI on the
test classpath, so ApiServerTest now checks the real index.html.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:45:10 +02:00
rootandClaude Sonnet 5.5 e8aa40f93f fix(solarman): skip unsolicited V5 frames and use a lock
CI / Backend tests (Maven / Java 21) (push) Successful in 1m2s
The logger sends heartbeats and late answers on port 8899. The read loop
now discards frames with another control code or sequence until the
answer arrives or the read times out. Replace synchronized with a lock
to avoid pinning virtual threads.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:43:58 +02:00
rootandClaude Sonnet 5.5 cac625e0e9 fix(modbus): serialize TcpTransport with a lock, not synchronized
A virtual thread blocked in a monitor pins its carrier thread, so one
stalled device could freeze the others on a small machine.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:43:58 +02:00
root a025bf8298 Merge PR #33: feat(web): add screen router with 30 s refresh and offline notice
CI / Backend tests (Maven / Java 21) (push) Successful in 1m3s
2026-10-24 00:41:03 +02:00
root ae5f2536c8 Merge PR #32: feat(backend): serve the JSON API and the static web UI 2026-10-24 00:39:22 +02:00
root f71d112df7 Merge PR #31: feat(backend): read history from InfluxDB 1.x 2026-10-24 00:38:42 +02:00
root a713147a30 Merge PR #30: feat(backend): add GatewayHttpClient for /health and /snapshot 2026-10-24 00:37:41 +02:00
root 1f9e245f2e Merge PR #28: feat(backend): add the backend project with the API contract 2026-10-24 00:37:40 +02:00
root da461bdf27 Merge PR #29: feat(gateway): serve /health and /snapshot over JDK HttpServer 2026-10-24 00:37:40 +02:00
rootandClaude Sonnet 5.5 e90f28dbc3 feat(web): add screen router with 30 s refresh and offline notice
CI / Backend tests (Maven / Java 21) (push) Successful in 49s
Keeps the last data per screen so a failed call shows its age instead of a blank page.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:36:58 +02:00
rootandClaude Sonnet 5.5 d5328e40fc feat(web): add history screen with touch line chart
Hand-written SVG; nice axis steps, min/max and a readout for the touched point.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:36:58 +02:00
rootandClaude Sonnet 5.5 df96cb73d1 feat(web): add energy screen with flow picture
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:36:58 +02:00
rootandClaude Sonnet 5.5 58ffc2e056 feat(web): add heat pump schematic and device status screens
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:36:58 +02:00
rootandClaude Sonnet 5.5 649d2e9cdd feat(web): add page shell, theme and Polish formatting
Mobile-first light and dark theme, 4-tab bar, comma decimals and ages in minutes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:36:58 +02:00
root ae2e0ae896 Merge PR #27: feat(influx): write readings through a file spool
CI / Backend tests (Maven / Java 21) (push) Successful in 47s
2026-10-24 00:35:14 +02:00
rootandClaude Sonnet 5.5 4c4b2143ac feat(backend): serve the JSON API and the static web UI
CI / Backend tests (Maven / Java 21) (push) Successful in 52s
JDK HttpServer on virtual threads. Gateway down gives 503, bad series or
range 400, Influx failure 502; static files only of known types and
never outside web/.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:34:50 +02:00
rootandClaude Sonnet 5.5 996b6805a4 feat(backend): add use cases mapping the gateway snapshot to views
Heat pump, energy (Deye plus Fronius PV), health and history views,
built from the ports so the HTTP layer stays thin.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:34:50 +02:00
root 6c6f071a80 Merge PR #26: feat(solarman): add Solarman V5 transport for Modbus RTU 2026-10-24 00:34:33 +02:00
rootandClaude Sonnet 5.5 722b67b252 feat(backend): read history from InfluxDB 1.x
CI / Backend tests (Maven / Java 21) (push) Successful in 53s
Series map to a fixed table of database, measurement and field; PV is
the sum of three fields. Temperatures use the legacy Dom_Pompa.POMPA
measurement, which already holds years of data (ADR-008).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:33:53 +02:00
root 98fe849254 Merge PR #25: feat(heatpump): add HeatPumpDevice with register catalog and legacy POMPA point 2026-10-24 00:33:53 +02:00
rootandClaude Sonnet 5.5 08f7bb8a8f feat(backend): add GatewayHttpClient for /health and /snapshot
CI / Backend tests (Maven / Java 21) (push) Successful in 54s
The backend reads live state from the gateway over HTTP. /health says
which device is up (503 is a valid body), /snapshot carries the
readings; both are merged per device. Unknown JSON fields are ignored.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:33:42 +02:00
root 3705a57155 Merge PR #24: feat(meter): read the SDM630 house meter into electricity.PRAD2 2026-10-24 00:33:12 +02:00
root 3b15451874 Merge PR #23: chore: remove old scripts and the outdated README 2026-10-24 00:32:11 +02:00
root 4db8520303 Merge PR #22: feat(fronius): read PV power and energy from the Solar API 2026-10-24 00:31:31 +02:00
root e9a3c45d89 Merge PR #21: feat(deye): add DeyeDevice reading the hybrid inverter over Modbus 2026-10-24 00:30:50 +02:00
rootandClaude Opus 5.5 51a68f0d44 feat(backend): add the backend project with the API contract
CI / Backend tests (Maven / Java 21) (push) Successful in 50s
The phone API and the web UI are built in parallel, so they need one
contract first: view records, the history allowlist, the gateway and
history ports, and example JSON. ADR-008 records the stack (proposed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:30:35 +02:00
rootandClaude Sonnet 5.5 15c5b7a7af feat(gateway): serve /health and /snapshot over JDK HttpServer
CI / Backend tests (Maven / Java 21) (push) Successful in 45s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:30:21 +02:00
rootandClaude Sonnet 5.5 d2873efc7c feat(gateway): add watchdog independent of HTTP (fixes D1)
Self-restart no longer depends on something polling /health.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:30:21 +02:00
rootandClaude Sonnet 5.5 3be02ed3a7 feat(gateway): poll each device on its own virtual thread
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:30:21 +02:00
rootandClaude Sonnet 5.5 6a10fd5ccb feat(gateway): add Snapshot of the latest state per device
DeviceState judges a never-succeeded device from the start of polling, which gives health and the watchdog one shared rule and a startup grace period.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:30:21 +02:00
root 4e92d16f41 Merge PR #20: feat(bme280): read the BME280 through the kernel IIO driver 2026-10-24 00:30:09 +02:00
root 5d3201f7e3 Merge PR #19: feat(modbus): add TcpTransport with MBAP framing and a mock server 2026-10-24 00:29:29 +02:00
root 821f818b1e Merge PR #18: feat(modbus): add PDU client for FC3 and FC4 with exception decoding 2026-10-24 00:28:48 +02:00
root b9c401f077 Merge PR #17: chore(build): send test output to report files 2026-10-24 00:28:48 +02:00
rootandClaude Opus 5.5 f6f07c4225 chore: remove old scripts and the outdated README
CI / Backend tests (Maven / Java 21) (push) Successful in 45s
The cron health monitor and its deprecated twin, the manual install
script, the write-API test script and the example config belonged to
earlier attempts; deploy-gateway.sh and the docs replace them. The README
pointed to a DEPLOYMENT.md that no longer exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:28:20 +02:00
rootandClaude Sonnet 5.5 2d61ce19f4 feat(influx): write readings through a file spool
CI / Backend tests (Maven / Java 21) (push) Successful in 43s
write() only appends to a per-database spool file, flush() delivers it in
batches, so an Influx or tunnel outage or a restart loses nothing (D2, D8).
A rejected batch is split to drop only the bad line.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:28:06 +02:00
rootandClaude Sonnet 5.5 80873d748c feat(influx): encode a Reading as InfluxDB 1.x line protocol
Floats are rendered as plain decimals with a dot, so InfluxDB never sees an
exponent or an integer; non-finite values are left out because it rejects them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:28:06 +02:00
rootandClaude Sonnet 5.5 df2d588e33 feat(solarman): add Solarman V5 transport for Modbus RTU
CI / Backend tests (Maven / Java 21) (push) Successful in 43s
The Deye logger on port 8899 wraps Modbus RTU in V5 frames. One socket is
kept open and closed on any error so the next call reconnects.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:28:04 +02:00
rootandClaude Sonnet 5.5 04a17041ec feat(solarman): add Modbus RTU frame with CRC16
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:28:04 +02:00
rootandClaude Sonnet 5.5 45ab01cdd0 feat(heatpump): add HeatPumpDevice with register catalog and legacy POMPA point
CI / Backend tests (Maven / Java 21) (push) Successful in 43s
One request per explicit block; sign-magnitude decoding. Emits the 8
legacy POMPA fields so Grafana is unchanged, plus a heat_pump point with
every catalog register. Any failed block fails the whole read.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:27:37 +02:00
rootandClaude Sonnet 5.5 797920bb82 feat(meter): read the SDM630 house meter into electricity.PRAD2
CI / Backend tests (Maven / Java 21) (push) Successful in 43s
Same 71 input registers, batches, float32 decoding and 2-decimal rounding
as sdm_modbus 0.4.4, so the Grafana panels see no change. NaN and
infinite values are skipped instead of losing the whole point.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:27:35 +02:00
rootandClaude Sonnet 5.5 77e3e0cb2a feat(modbus): add TcpTransport with MBAP framing and a mock server
CI / Backend tests (Maven / Java 21) (push) Successful in 44s
One socket is kept open and reused because the MK01 gateway drops the
old client when a new one connects. Any I/O error closes the socket so
the next exchange reconnects.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:26:54 +02:00
rootandClaude Sonnet 5.5 162e557334 feat(fronius): read PV power and energy from the Solar API
CI / Backend tests (Maven / Java 21) (push) Successful in 44s
One power-flow GET gives P_PV and day/year/total energy, so the
inverter endpoint is skipped. A null P_PV (inverter asleep) is 0 W.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:26:43 +02:00
rootandClaude Sonnet 5.5 2b06a5f344 feat(deye): add DeyeDevice reading the hybrid inverter over Modbus
CI / Backend tests (Maven / Java 21) (push) Successful in 43s
Explicit small FC3 blocks, decoding per the verified register table
(signed battery and grid power, battery temperature offset), one
electricity reading per poll; any failed block fails the poll.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:26:43 +02:00
rootandClaude Sonnet 5.5 ce95b7786a feat(bme280): read the BME280 through the kernel IIO driver
CI / Backend tests (Maven / Java 21) (push) Successful in 44s
The Python script is replaced by plain file reads under /sys/bus/iio.
The driver exposes only processed in_*_input values, so no raw/scale
handling: milli-degC, kPa and milli-%RH become degC, hPa and %RH.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:26:31 +02:00
rootandClaude Sonnet 5.5 4669f430ec feat(modbus): add PDU client for FC3 and FC4 with exception decoding
CI / Backend tests (Maven / Java 21) (push) Successful in 42s
Own codec per ADR-007; reads only, writes come in a later milestone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 00:26:31 +02:00
rootandClaude Opus 5.5 b328848b87 chore(build): send test output to report files
CI / Backend tests (Maven / Java 21) (push) Successful in 44s
Every push and CI run printed pages of baseline test logs and stack
traces from expected failures, hiding the result. Reports keep them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:26:29 +02:00
root b99414d829 Merge PR #16: refactor(gateway): add Reading and the ports for M1 adapters
CI / Backend tests (Maven / Java 21) (push) Successful in 44s
2026-10-24 00:21:42 +02:00
root 79c67f334c Merge PR #15: docs(roadmap): close M0, plan M1 as PR-sized steps 2026-10-24 00:21:42 +02:00
rootandClaude Opus 5.5 60f35b01a6 refactor(gateway): add Reading and the ports for M1 adapters
CI / Backend tests (Maven / Java 21) (push) Successful in 41s
Adapters for every device, the Influx writer and the poller are built
in parallel against these types. The baseline keeps running unchanged
until Main is wired to them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:19:47 +02:00
rootandClaude Opus 5.5 b0c4961cda docs(roadmap): close M0, plan M1 as PR-sized steps
CI / Backend tests (Maven / Java 21) (push) Successful in 44s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:19:18 +02:00
root 7f2e98a781 Merge PR #14: docs(roadmap): first deploy v1.0.0 verified; add D8
CI / Backend tests (Maven / Java 21) (push) Successful in 45s
2026-10-24 00:15:31 +02:00
rootandClaude Opus 5.5 1e43d9d7fb docs(roadmap): first deploy v1.0.0 verified; add D8
CI / Backend tests (Maven / Java 21) (push) Successful in 44s
The deploy script ran on the Pi: healthy restart, a Modbus read and an
Influx write within 20 s, the next poll on time. The first write split
into two points 1 ms apart, which the baseline already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:14:06 +02:00
root 0a5d961113 Merge PR #13: docs(ship): AGit PRs merged by hand stay open; deploy host override
CI / Backend tests (Maven / Java 21) (push) Successful in 44s
2026-10-24 00:00:38 +02:00
root 6828049404 Merge PR #12: fix(deploy): use the jar path and unit file that run on the Pi
CI / Backend tests (Maven / Java 21) (push) Successful in 43s
2026-10-23 23:59:59 +02:00
rootandClaude Opus 5.5 b224ffa9dc docs(ship): AGit PRs merged by hand stay open; deploy host override
CI / Backend tests (Maven / Java 21) (push) Successful in 46s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:59:15 +02:00
rootandClaude Opus 5.5 1cae89f634 fix(deploy): use the jar path and unit file that run on the Pi
CI / Backend tests (Maven / Java 21) (push) Successful in 44s
The Pi runs /home/pi/home-pump/home-pump-1.0.0.jar with Java 21 from
sdkman; the repo still described target/ and /usr/bin/java (Java 11 on
the Pi). PI can now name another ssh host: while D7 routes the LAN into
the tunnel, the Pi answers only over IPv6 link-local here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:58:43 +02:00
root 5c1787de76 Merge PR #11: chore: M0 foundation - check script, hook, CI, deploy, skills
CI / Backend tests (Maven / Java 21) (push) Successful in 44s
2026-10-23 23:52:45 +02:00
rootandClaude Opus 5.5 bfecae8b1f chore(ship): note one-line PR description and updating an AGit PR
CI / Backend tests (Maven / Java 21) (push) Successful in 42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:51:19 +02:00
rootandClaude Opus 5.5 dc5efc07e1 docs(roadmap): M0 progress; backend image moves to M3
CI / Backend tests (Maven / Java 21) (push) Successful in 44s
The backend does not exist yet, so CI has no image to build; it comes
with the backend in M3. The deploy item stays open until the script has
run once on the Pi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:50:32 +02:00
rootandClaude Opus 5.5 585c1d4e3d chore: add /ship and /close-milestone skills
Both workflows repeat for every PR and milestone; written down once they
follow CONTRIBUTING.md the same way each time, including when a deploy
needs the user's approval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:50:19 +02:00
rootandClaude Opus 5.5 b47bcf7a18 chore: add scripts/deploy-gateway.sh with rollback
Deploys only a clean master that matches origin, checks ssh and sudo on
the Pi before touching anything, swaps the jar, restarts the service and
keeps the previous jar. /health reports UP for minutes after a start even
without a read, so the script also requires the process to survive 30 s
without systemd restarting it; otherwise it restores the previous jar.
The version is tagged only after a healthy start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:49:42 +02:00
rootandClaude Opus 5.5 2eaddcc414 chore: remove Drone pipeline and Dockerfile
Gitea Actions replaced Drone, and the Dockerfile packaged a Java 11 image
of a jar that runs on the Pi, not in Docker. The backend image comes with
the backend itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:48:18 +02:00
rootandClaude Opus 5.5 6a3dfdf651 chore: run check.sh in CI and in a pre-push hook
Both gates now run the same script instead of CI running only the tests.
The hook lives in .githooks so it is versioned; each clone enables it once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:48:15 +02:00
rootandClaude Opus 5.5 c2c63918ab chore: add scripts/check.sh as the single quality gate
Runs doc line limits from CONTRIBUTING.md, a secret scan over tracked
files and the Maven build with tests. The pre-push hook and CI will both
call it, so a push that passes locally passes in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:47:53 +02:00
root f7136c7ddb Merge remote master (PR #10) into docs/spec-and-rules history
CI / Backend tests (Maven / Java 21) (push) Successful in 41s
2026-10-23 23:41:05 +02:00
rootandClaude Opus 5.5 6a293ee15e docs: BME280 through the kernel IIO driver (approved)
CI / Backend tests (Maven / Java 21) (push) Successful in 44s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U8EHjVrf5ffw1Vjn5bPGJu
2026-10-23 23:40:32 +02:00
rootandClaude Opus 5.5 408bba73ac docs: ADR-007 gateway stack, JDK first with our own Modbus core
CI / Backend tests (Maven / Java 21) (push) Successful in 41s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U8EHjVrf5ffw1Vjn5bPGJu
2026-10-23 23:38:59 +02:00
rootandClaude Opus 5.5 c73059eef7 docs: C4 setpoint restored to 8
CI / Backend tests (Maven / Java 21) (push) Successful in 40s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U8EHjVrf5ffw1Vjn5bPGJu
2026-10-23 23:25:46 +02:00
rootandClaude Opus 5.5 3839668348 docs: settings backup and first verified write (C3, C4)
CI / Backend tests (Maven / Java 21) (push) Successful in 41s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U8EHjVrf5ffw1Vjn5bPGJu
2026-10-23 23:23:29 +02:00
rootandClaude Opus 5.5 0286c4c18d docs: BME280 moves into the gateway too; Pi OS stays as is
CI / Backend tests (Maven / Java 21) (push) Successful in 43s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U8EHjVrf5ffw1Vjn5bPGJu
2026-10-23 23:21:25 +02:00
rootandClaude Opus 5.5 656b581812 docs: gateway collects all devices into Influx; first result is our own UI
CI / Backend tests (Maven / Java 21) (push) Successful in 41s
- ADR-006: gateway writes readings to InfluxDB with a buffer; backend reads them
- S6: commands only from the backend (token and firewall)
- M1 gateway reads heat pump, Deye, Fronius and the house meter (moved from
  rpi-measurements); D7 subnet fix first
- M3 read-only backend and web UI with pump and energy; Grafana step dropped

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U8EHjVrf5ffw1Vjn5bPGJu
2026-10-23 23:18:31 +02:00
rootandClaude Opus 5.5 162a25eceb docs: ADR-006 thin gateway on the Pi, backend in Kraków; energy earlier
CI / Backend tests (Maven / Java 21) (push) Successful in 44s
- ADR-006 supersedes ADR-002: the Pi gateway owns device links, checks commands
  and runs timed restores; the backend runs in Docker in Kraków
- restore value is read from the controller before the first write
- roadmap: energy view moves to M4 (my father wants it), setpoints to M5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U8EHjVrf5ffw1Vjn5bPGJu
2026-10-23 23:11:05 +02:00
rootandClaude Opus 5.5 225648db81 docs: correct D7, DNS errors were one outage; note subnet overlap
CI / Backend tests (Maven / Java 21) (push) Successful in 42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U8EHjVrf5ffw1Vjn5bPGJu
2026-10-23 22:53:20 +02:00
rootandClaude Opus 5.5 365d0b124a docs: consolidate goal and scope, move controller knowledge, add storage ADR
CI / Backend tests (Maven / Java 21) (push) Successful in 41s
- spec: goal covers heat pump first, energy second; explicit in/out scope
- docs/frisko/rx910-se21.md: controller facts moved out of the roadmap
- roadmap: M6 energy, M7 native decision, C6 energy feasibility done
- ADR-005: InfluxDB plus one local file, no other database

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U8EHjVrf5ffw1Vjn5bPGJu
2026-10-23 22:49:47 +02:00
rootandClaude Opus 5.5 e5571548aa research: keep local polling, reject logger push to Server B
CI / Backend tests (Maven / Java 21) (push) Successful in 40s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U8EHjVrf5ffw1Vjn5bPGJu
2026-10-23 22:35:36 +02:00
rootandClaude Opus 5.5 887806d329 research: battery is backup-only by design
CI / Backend tests (Maven / Java 21) (push) Successful in 41s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U8EHjVrf5ffw1Vjn5bPGJu
2026-10-23 22:32:58 +02:00
rootandClaude Opus 5.5 607058e42b research: verified Deye register map via the Solarman logger
CI / Backend tests (Maven / Java 21) (push) Successful in 41s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U8EHjVrf5ffw1Vjn5bPGJu
2026-10-23 22:31:41 +02:00
rootandClaude Opus 5.5 1676b46b33 docs: identify the Deye hybrid inverter
CI / Backend tests (Maven / Java 21) (push) Successful in 42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U8EHjVrf5ffw1Vjn5bPGJu
2026-10-23 22:20:34 +02:00
rootandClaude Opus 5.5 9f795485ee docs: energy sources in Krosno (meter, Fronius, second inverter)
CI / Backend tests (Maven / Java 21) (push) Successful in 42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 22:12:58 +02:00
rootandClaude Opus 5.5 e35dee9b4f research: add UI reference mockup with installation differences
CI / Backend tests (Maven / Java 21) (push) Successful in 44s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 22:02:18 +02:00
rootandClaude Opus 5.5 4642408039 docs: heat pump protection limits and CWU history
CI / Backend tests (Maven / Java 21) (push) Successful in 42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:53:32 +02:00
rootandClaude Opus 5.5 a59a5494ff docs: user-chosen boost duration; CWU limit 50 °C
CI / Backend tests (Maven / Java 21) (push) Successful in 42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:50:11 +02:00
rootandClaude Opus 5.5 7bbda72e59 docs: free setpoint choice within limits plus presets
CI / Backend tests (Maven / Java 21) (push) Successful in 40s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:47:44 +02:00
rootandClaude Opus 5.5 26f7b30abe docs: live pump state as part of F1 and M1
CI / Backend tests (Maven / Java 21) (push) Successful in 42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:47:11 +02:00
rootandClaude Opus 5.5 13bc0955f0 docs: limit first commands to four setpoints (boost, away)
CI / Backend tests (Maven / Java 21) (push) Successful in 40s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:45:36 +02:00
rootandClaude Opus 5.5 d230d86250 docs: boost via economy temperature as the first command
CI / Backend tests (Maven / Java 21) (push) Successful in 40s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:39:52 +02:00
rootandClaude Opus 5.5 484186e2dc docs: solar collector and observed CWU heating pattern
CI / Backend tests (Maven / Java 21) (push) Successful in 39s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:34:19 +02:00
rootandClaude Opus 5.5 fe8257b96c docs: first command is a hot-water (CWU) override
CI / Backend tests (Maven / Java 21) (push) Successful in 41s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:32:42 +02:00
rootandClaude Opus 5.5 2544421c72 docs: schedule override as first command; weekly program selector
CI / Backend tests (Maven / Java 21) (push) Successful in 43s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:30:08 +02:00
rootandClaude Opus 5.5 17d326fd5f docs: record MK01 single-client gateway and feasibility checks
CI / Backend tests (Maven / Java 21) (push) Successful in 42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:27:26 +02:00
mariusz.pacek 32424a118c Merge pull request 'docs: add specification, repository rules and Frisko sources' (#10) from docs/spec-and-rules into master
CI / Backend tests (Maven / Java 21) (push) Successful in 43s
Reviewed-on: #10
2026-10-23 19:18:40 +00:00
rootandClaude Opus 5.5 c309916736 docs: add specification, repository rules and Frisko sources
CI / Backend tests (Maven / Java 21) (push) Successful in 41s
Restart from the version running on the Pi (e345063) with written goals and rules:
- docs/spec.md: goal, principles, architecture, requirements
- docs/roadmap.md: bottom-up milestones M0-M6, baseline defects, controller facts
- docs/decisions/: ADR-001..004 (evolve baseline, backend on Pi, single Modbus
  owner, web UI first)
- CONTRIBUTING.md and CLAUDE.md: workflow, safety and context-size rules
- research/frisko/: RX910-SE21 register map, phone-app XML maps and APK,
  MR208-PC+ reference docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:13:05 +02:00
2560 changed files with 40233 additions and 58952 deletions

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
---
name: close-milestone
description: Close the current milestone in docs/roadmap.md, remove its working notes and start the next one. Use when every item of the current milestone is done.
---
# Close milestone
1. Read `docs/roadmap.md`. Every item of "Current" is checked and its "Done when" holds.
Anything you cannot verify yourself (e.g. "a week on the Pi") you ask the user about.
Stop and report if something is open.
2. Branch `docs/close-<milestone>` (e.g. `docs/close-m1`).
3. Under "Done", add one line: `- M<n> <name> · <tag or commit>: <what it delivered>`.
Remove the "Current" checklist and any placeholder line.
4. Remove the milestone's working notes: fixed defects (D…), consumed feasibility checks,
notes that served only this milestone. Verified knowledge moves first: controller facts
to `docs/frisko/`, scope or architecture to `docs/spec.md`, decisions to a new ADR.
5. Make the next row of "Next" the new "Current": a checklist of PR-sized items in build
order, keeping its "Done when". Ask the user when the split is not obvious.
6. `scripts/check.sh` (roadmap ≤ 80 lines), then `/ship`.
+32
View File
@@ -0,0 +1,32 @@
---
name: ship
description: Take the current topic branch to master through a Gitea PR with green CI, then optionally release it and update the Pi. Use when a change is ready to merge or release.
---
# Ship
Repo rules: `CONTRIBUTING.md`. Gitea: https://git.mariuszpacek.pl/mariusz.pacek/home-pump
(no API token here; reads need none).
1. On a topic branch (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), clean tree, commits
in Conventional Commits. Run `scripts/check.sh`.
2. `git push -u origin <branch>`; CI runs on every pushed branch.
3. Open the PR by push (Gitea AGit), title in Conventional Commits, description says why:
`git push origin HEAD:refs/for/master -o topic=<branch> -o title="…" -o description="…"`.
The description is one line (push options cannot hold newlines). After later fixes push
both the branch and `refs/for/master` with the same topic, so CI and the PR both update.
If that is refused, give the user the compare link `…/compare/master...<branch>`.
4. Wait for CI on the head commit, polling every 30 s:
`curl -s …/api/v1/repos/mariusz.pacek/home-pump/commits/<sha>/status` → `state`.
On `failure`, read the run under `…/actions`, fix, push again. Never merge red.
5. Merge only if the user asked for it in this task:
`git switch master && git pull --ff-only && git merge --no-ff <branch> -m "Merge PR #N: <title>"`,
then `git push origin master` and delete the branch locally and on origin. Confirm CI is
green on master. Gitea does not close an AGit PR merged by hand: ask the user to close it.
6. Release and update only with the user's explicit approval for this one:
`scripts/release.sh vMAJOR.MINOR.PATCH` (patch for fixes, minor for features) waits for green
CI on master's head (no local check run), then tags master;
wait until the Release run under `…/actions` is green (it publishes the images). Then on the
Pi (`ssh <host>`, an alias while D7 is open): `cd /home/pi/home-pump && ./update.sh <version>`,
watch `journalctl -t home-pump-gateway -f` and Grafana for one polling cycle (300 s) and
report what you saw. Backend and frontend reach Kraków through Arcane (homelab-compose).
-18
View File
@@ -1,18 +0,0 @@
kind: pipeline
type: docker
name: default
steps:
- name: test
image: maven:3-jdk-11
commands:
- mvn install -DskipTests=true -Dmaven.javadoc.skip=true -B -V
- mvn test -B
- name: docker
image: plugins/docker
settings:
repo: registry.mariuszpacek.pl/home-pump
tags: latest
when:
branch:
- master
+17 -16
View File
@@ -1,32 +1,33 @@
# One job: check.sh builds the three parts in parallel itself. The runner runs one job at a time
# and its actions cache server is unreachable from job containers (ETIMEDOUT), so separate jobs
# or setup-java/setup-node caches would only add waiting.
name: CI name: CI
on: on:
push: push:
branches: branches:
- "**" - "**"
pull_request:
branches:
- "**"
jobs: jobs:
backend: check:
name: Backend tests (Maven / Java 21) name: Check (gateway, backend, frontend)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Java 21
- name: Show runner info
run: |
echo "Runner name: ${{ runner.name }}"
echo "Runner labels: ${{ toJSON(runner.labels) }}"
- name: Set up Java 21 + Maven
uses: actions/setup-java@v5 uses: actions/setup-java@v5
with: with:
distribution: temurin distribution: temurin
java-version: "21" java-version: '21'
cache: maven - name: Set up Maven
- name: Run Maven tests (parallel) uses: stCarolas/setup-maven@v5
run: mvn -B -T 1C test with:
maven-version: 3.9.6
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Check
run: scripts/check.sh
+56
View File
@@ -0,0 +1,56 @@
# A pushed tag vMAJOR.MINOR.PATCH (scripts/release.sh) publishes the three images under that name
# and as latest. The Pi pulls the gateway with gateway/update.sh; Kraków runs backend and frontend.
name: Release
on:
push:
tags:
- "v*.*.*"
env:
REGISTRY: git.mariuszpacek.pl
IMAGES: git.mariuszpacek.pl/mariusz.pacek/home-pump
jobs:
images:
name: Image (${{ matrix.part }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# The Pi is arm64. Each Dockerfile builds on the runner's platform and only copies into
# the final stage, so the arm64 image needs no emulation.
- part: gateway
platforms: linux/arm64,linux/amd64
- part: backend
platforms: linux/amd64
- part: frontend
platforms: linux/amd64
steps:
- name: Checkout
uses: actions/checkout@v4
# As in homelab-upgrade: the job talks to VM103's daemon through the mounted socket.
- name: Docker CLI
run: command -v docker >/dev/null || (apt-get update -qq && apt-get install -y -qq docker.io >/dev/null); docker version --format '{{.Client.Version}} -> {{.Server.Version}}'
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.repository_owner }}
# The owner's token with package write access (the secret homelab-upgrade uses too).
password: ${{ secrets.REGISTRY_TOKEN }}
# The registry cache keeps the Maven and npm layers between releases.
- name: Build and push
uses: docker/build-push-action@v6
with:
context: ${{ matrix.part }}
build-args: VERSION=${{ gitea.ref_name }}
platforms: ${{ matrix.platforms }}
push: true
tags: ${{ env.IMAGES }}-${{ matrix.part }}:${{ gitea.ref_name }},${{ env.IMAGES }}-${{ matrix.part }}:latest
cache-from: type=registry,ref=${{ env.IMAGES }}-${{ matrix.part }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGES }}-${{ matrix.part }}:buildcache,mode=max
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Enabled once per clone: git config core.hooksPath .githooks
exec "$(git rev-parse --show-toplevel)/scripts/check.sh"
-81
View File
@@ -1,81 +0,0 @@
# Copilot instructions for `home-pump`
## Build and test commands
### Backend (repo root)
- `mvn clean package` builds the Spring Boot jar at `target/home-pump-1.0.0.jar`.
- `mvn test` runs the current backend test suite. CI (`.drone.yml`) uses plain `mvn test`.
- `mvn -Dtest=ControlLoopUseCaseTests test` runs a single unit test class.
- `mvn -Dtest=ControlLoopUseCaseTests#shouldApplyCwuPriorityBeforeSendingCommands test` runs a single JUnit test method.
- `mvn -Dtest=ControlApiContractIntegrationTests test` runs one tagged integration test class.
- `java -jar target/home-pump-1.0.0.jar` starts the packaged backend.
### Frontend (`frontend/`)
- `npm install`
- `npm run dev`
- `npm run build`
- `npm run build:ssr`
## High-level architecture
- The backend is a Spring Boot application (`HomePumpApplication`) with most runtime behavior driven by `ScheduledTasks`. The scheduler runs five separate loops: the control loop, live telemetry polling, legacy Influx publishing, schedule application, and health-based shutdown/restart.
- Configuration is split across two sources and both matter:
- `src/main/resources/application.yml` contains Spring-managed control settings, schedule/poll intervals, SQLite path, heating-circuit strategy definitions, and optional Modbus command register overrides.
- `src/main/resources/registryMapping.json` is loaded from the classpath by `JsonFileBasedConfiguration` and carries device address/port, retry settings, `useMock`, live polling window, Influx connection info, and the register metadata used by the Modbus layer. `registryMapping.example.json` is only a sample; the loader looks for `registryMapping.json`.
- The backend is organized as a ports-and-adapters style application, but it still contains legacy integration packages:
- `domain`: control models, temperature strategies, and rules like CWU priority, anti-freeze, disinfection, and pump runtime protection.
- `application`: use cases and output ports. `ControlLoopUseCase` decides commands from live readings; `ManualControlUseCase` owns operator-controlled state such as AUTO/MANUAL, ECO/COMFORT, SUMMER/WINTER, per-circuit override mode, and the weekly schedule.
- `infrastructure`: REST controllers, Modbus/Influx adapters, Frisko services, scheduler wiring, and SQLite persistence.
- `configuration`, `modbus`, `influxdb`, and `health` still contain important lower-level classes used by the Spring wiring.
- `ManualControlUseCase` is the source of truth for mutable control state. It persists state through `SqliteControlStateStore` to `data/control-state.db`, including operating mode, season/program mode, circuit overrides, control policy settings, and the program schedule.
- The Modbus side has two distinct responsibilities:
- `ModbusDeviceAdapter` is the bridge used by the control loop for current readings and write commands.
- `ModbusPollingService` builds the rolling telemetry/history used by `/api/telemetry` and contributes the Modbus component state to `/health`.
- Frisko support is driven by `frisko/mr208_m3p.csv`. `FriskoRegisterCatalogService` categorizes symbols from that CSV, and `FriskoRuntimeService` exposes runtime read/write, controller time sync, alarms, schedules, and thresholds through `/api/v1/frisko/*`.
- There are currently two Influx write paths:
- `ControlLoopUseCase` publishes through `MetricsPublisher`, which is wired to `InfluxMetricsAdapter`.
- `ScheduledTasks.sendLegacyInfluxMetrics()` still runs `InfluxMetricModbusSender`.
Any Influx-related change needs to account for both paths.
- The frontend is a Vite + React + TypeScript dashboard under `frontend/`. `App.tsx` composes feature panels for health, control, telemetry, Frisko analysis/runtime, thresholds, schedule editing, and the schematic view. API access lives in `services/*`, React Query polling/mutations live in `hooks/*`, and `lib/http.ts` centralizes Axios setup plus normalized API errors.
- Workspace-level browser automation is configured through `.vscode/mcp.json` with the Playwright MCP server. Prefer that path for UI verification when you need to inspect the dashboard behavior end-to-end.
## Key conventions
- Treat circuit IDs as uppercase domain identifiers (`CO1`, `CO2`, `CO3`, `CWU`). The backend normalizes them to uppercase in multiple places (`ManualControlUseCase`, `InMemoryHeatingCircuitRepository`), and frontend services/UI use the same IDs.
- Heating-circuit behavior is declarative in `application.yml` under `app.heating.circuits`. `InMemoryHeatingCircuitRepository` turns each definition into a `HeatingCircuit` with either a weather-compensated or constant strategy, and wraps it in `ClampedTemperatureStrategy` when both min/max temperatures are provided.
- Real hardware writes are disabled by default. `ModbusDeviceAdapter.applyCommands()` only writes when `app.modbus.commands.write-enabled=true`. If you change control flows or manual actions, remember the UI can still update persisted logical state even when physical writes are skipped.
- Command-register mapping is symbol-driven, not just hard-coded register-driven. `FriskoCircuitRegisterMappingService` builds default mappings from the Frisko CSV, preferring `WY_*` symbols and falling back to `TEST_*`, with `application.yml` only overriding specific circuit registers when needed.
- Telemetry and device-state extraction are also symbol/description driven. `ModbusDeviceAdapter` and `ModbusPollingService` search registry descriptions using Frisko symbols first and older human-readable descriptions as fallbacks. When adding a new signal, update the matching candidates and the related tests instead of relying on a single label spelling.
- `SystemReadings` may contain `NaN` internally for missing numeric values. API-facing controllers such as `SystemController` sanitize those values to `null` before returning JSON. Keep that distinction when extending the model or frontend types.
- Local backend development can avoid a real device by setting top-level `useMock: true` in `registryMapping.json`. `AppConfig` swaps the Modbus repository to `MockRepository` in that mode.
- The REST API is intentionally split by bounded context:
- legacy monitoring endpoints: `/health`, `/api/telemetry`
- control endpoints: `/api/v1/control/*`
- current system snapshot endpoints: `/api/v1/system/*`
- Frisko analysis/runtime endpoints: `/api/v1/frisko/*`
Frontend service modules mirror these base paths; follow that pattern instead of calling Axios directly from panels.
- Frontend polling and error handling live in hooks, not components. Typical pattern: `services/*` perform the request, `hooks/*` wrap them with React Query and toast handling, and panels stay mostly presentational.
- The telemetry UI can show mock data even when the backend telemetry endpoint is unavailable. `frontend/src/services/telemetryService.ts` falls back to `mocks/telemetryMock.ts` after a failed `/api/telemetry` request, so do not treat rendered charts as proof that live telemetry is healthy.
- Integration tests are tagged with `@Tag("integration")`, but the current repo and CI still run plain `mvn test`. Do not assume tagged tests are separated into a dedicated Maven lifecycle unless you also wire Surefire/Failsafe to do it.
- Treat Java 21 as the backend build target. The active `maven-compiler-plugin` is set to `release 21`, even though some legacy files in the repo still reference older Java versions.
+4 -2
View File
@@ -18,7 +18,7 @@ target/
/.idea/ /.idea/
/.vscode/ /.vscode/
/.java-version /.java-version
/dependency-reduced-pom.xml dependency-reduced-pom.xml
/workspace.code-workspace /workspace.code-workspace
/target/ /target/
@@ -26,7 +26,6 @@ target/
/node_modules/ /node_modules/
/frontend/node_modules/ /frontend/node_modules/
/frontend/dist/ /frontend/dist/
/frontend/.env*
/dist/ /dist/
# Vite # Vite
@@ -56,3 +55,6 @@ coverage/
/apk/ /apk/
/old/ /old/
/old_backup/ /old_backup/
# Agent worktrees
/.claude/worktrees/
+16
View File
@@ -0,0 +1,16 @@
Backend for controlling the heat pump in Krosno (Frisko controller, Modbus TCP) from a phone.
@CONTRIBUTING.md
## For agents
- Start from `docs/roadmap.md`; stay within the current milestone unless asked.
- Read `docs/spec.md` and a decision in `docs/decisions/` only when the task touches it.
- Production: `pi@raspberrypi.local`, the gateway in Docker (`gateway/compose.yaml`; until it is
installed, the old `home-pump.service`). Read-only inspection is fine; installing, updating,
restarting or changing files on the Pi needs explicit approval each time.
- The Pi also runs `rpi-measurements.service` (house meter and BME280 into Influx); do not break
it. It moves into the gateway in M1; never run both against the meter at once. Do not upgrade
or reinstall the Pi's OS.
- Never write to the controller `192.168.0.150` and never to the `Dom_Pompa` database.
- Ask before force-pushing or rewriting history on Gitea.
+62
View File
@@ -0,0 +1,62 @@
# Repository rules
What we build: [docs/spec.md](docs/spec.md). Plan: [docs/roadmap.md](docs/roadmap.md).
Reasons: [docs/decisions/](docs/decisions/).
## Branches and pull requests
- `master` is always deployable and matches what runs on the Pi.
- Every change goes through a pull request on Gitea; no direct commits to `master`.
- Branch names: `feat/…`, `fix/…`, `docs/…`, `chore/…`, `refactor/…`.
- One topic per PR, small enough to review in one sitting. Merge only with green CI.
- `archive/spring-boot-attempt` is reference only. Never merge it.
## Commits
- English, Conventional Commits: `fix(health): restart without /health polling`.
- Imperative subject ≤ 72 characters; the body says why when it is not obvious.
- Each commit builds and passes tests.
## Code
- Three parts, each with its `Dockerfile` and `compose.yaml` (ADR-010): `gateway/` and `backend/`
in Java 21 with Maven and no framework (ADR-001), `frontend/` in React and TypeScript (ADR-008).
- `scripts/check.sh` is the gate: doc limits, secret scan, build and tests. CI runs it, and so
does the pre-push hook once enabled with `git config core.hooksPath .githooks`.
- Readable first: small classes and methods, names from the domain, comments only for the why.
- No dead code, no unused config, no duplicated logic, no speculative abstractions.
- Every behaviour change has a test. Modbus is tested against `MockModbusServer` only.
## Safety
- Tests and local runs never use the real controller or the `Dom_Pompa` database.
- A register is written only if it is on the allowlist (min, max, step) and verified in
`docs/frisko/`.
- Changes to how or when we write to the controller need a test of the failure case.
## Knowledge and docs
Docs are written so an agent needs little context: read only what the task touches.
| File | Limit | Changes |
|---|---|---|
| `CLAUDE.md` | 40 lines | rarely |
| `docs/spec.md` | 120 lines | when scope or architecture changes |
| `docs/roadmap.md` | 80 lines | every milestone |
| `docs/decisions/NNN-*.md` | 40 lines | never after acceptance; supersede with a new ADR |
| `docs/frisko/<topic>.md` | 120 lines | when knowledge is verified |
| `<package>/CLAUDE.md` | 30 lines | rules of that layer |
| `research/<source>/` | none | raw vendor material, kept as found; read only on demand |
- Limits are checked by `scripts/check.sh`.
- No status, audit or log documents: the state is git history and the roadmap.
- Closing a milestone compresses it to one line in the roadmap and removes its working notes.
- English everywhere; the UI is Polish.
## Configuration, secrets, releases
- No secrets in git. Production config lives on the Pi and next to the Kraków stacks, outside
the images.
- Release only from `master` with `scripts/release.sh vMAJOR.MINOR.PATCH`: it tags master and
CI publishes the three images. On the Pi `./update.sh <version>` goes back if it is unhealthy.
- After an update, watch the journal and Grafana for at least one polling cycle.
-4
View File
@@ -1,4 +0,0 @@
FROM amazoncorretto:11.0.17
COPY target/test*.jar app/home-pump.jar
CMD ["java", "-jar", "app/home-pump.jar"]
+17 -192
View File
@@ -1,200 +1,25 @@
# Home Pump Monitor # home-pump
A Java application that reads data from a heat pump via Modbus TCP and sends metrics to InfluxDB. Heat pump (Frisko RX910-SE21) and home energy for the house in Krosno, from a phone.
## Features - What we build: [docs/spec.md](docs/spec.md)
- Plan and current milestone: [docs/roadmap.md](docs/roadmap.md)
- Rules for changes: [CONTRIBUTING.md](CONTRIBUTING.md)
- Controller knowledge: [docs/frisko/](docs/frisko/rx910-se21.md)
- Reads Modbus holding registers from a heat pump | Part | Runs | Readme |
- Configurable send interval to InfluxDB (default: 5 minutes) |---|---|---|
- Automatic buffering when InfluxDB is unavailable (up to 50,000 data points) | `gateway/` | Pi in Krosno, Docker: devices to InfluxDB, `/commands` | [gateway/README.md](gateway/README.md) |
- Health check endpoint for monitoring application status | `backend/` | Kraków, Docker: phone API | [backend/README.md](backend/README.md) |
- Automatic restart on Modbus connection failures | `frontend/` | Kraków, Docker: web UI with nginx | [frontend/README.md](frontend/README.md) |
- Graceful shutdown with InfluxDB buffer flush
- Systemd service with automatic recovery
- Configurable via JSON file
## Quick Start Each part has its `Dockerfile` and `compose.yaml` (ADR-010).
### Local Development
```bash
mvn clean package
java -jar target/home-pump-1.0.0.jar
```
### Production Deployment (Raspberry Pi / Ubuntu)
See [DEPLOYMENT.md](DEPLOYMENT.md) for complete installation and configuration guide.
```bash ```bash
# Build scripts/check.sh # docs, secret scan, build and tests of all three parts
mvn clean package git config core.hooksPath .githooks # once per clone: check before every push
scripts/release.sh vMAJOR.MINOR.PATCH # tag master; CI publishes the three images in Gitea
# Install as systemd service
sudo scripts/install-service.sh
# Check status
systemctl status home-pump.service
curl http://localhost:8080/health
```
## Configuration
Edit `src/main/resources/registryMapping.json` to configure:
- Modbus TCP connection (address, port)
- InfluxDB connection (address, username, password)
- Send interval (influxSendIntervalSeconds, default: 300)
- Connection timeouts and retry settings
- List of registers to read with descriptions
Additional mapping fields (optional):
- `useMock` (Boolean, top-level): When `true`, the application runs with a local `MockRepository` instead of attempting to connect to a Modbus device. Useful for local development when a device is not available (default: false).
- `unitId` (Integer): Modbus slave/unit id for the register (default: 1)
- `scale` (Double): Multiply raw device value by this to obtain display value (default: 0.1)
- `dataType` (String): Data type used for the locator (e.g. `TWO_BYTE_INT_SIGNED`). Supported values: `TWO_BYTE_INT_SIGNED`, `TWO_BYTE_INT_UNSIGNED`, `FOUR_BYTE_INT_SIGNED`, `FOUR_BYTE_INT_UNSIGNED`, `FOUR_BYTE_FLOAT` (the latter reads two adjacent registers and interprets as IEEE754 float). For devices with non-standard word order, the following swapped variants are supported: `FOUR_BYTE_INT_SWAPPED`, `FOUR_BYTE_INT_UNSIGNED_SWAPPED`, `FOUR_BYTE_FLOAT_SWAPPED` (word order low/high).
- # Example Configuration
```json
{
"address": "192.168.0.150",
"port": 502,
"useMock": false,
"influxSendIntervalSeconds": 300,
"timeoutMs": 5000,
"maxRetries": 2,
"retryBackoffMs": 1000,
"influx_db": {
"address": "https://influxdb.example.com",
"username": "",
"password": ""
},
"registry_mapping": [
{
"registry": 4065,
"desc": "External temperature",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED"
}
]
}
```
## Monitoring & Health Check
The application exposes a health check endpoint on port 8080:
```bash
curl http://localhost:8080/health
```
**Healthy response (HTTP 200):**
```json
{
"status": "UP",
"lastSuccessfulRead": "2026-02-06T19:30:45Z",
"message": "Last successful read 15 seconds ago"
}
```
**Unhealthy response (HTTP 503):**
```json
{
"status": "DOWN",
"lastSuccessfulRead": "2026-02-06T19:20:45Z",
"message": "No successful read for 620 seconds. Last error: Connection timeout"
}
```
### Important Notes
- **InfluxDB failures do NOT trigger unhealthy status** - data is buffered in memory
- Only Modbus read failures trigger unhealthy status and potential restart
- Health check considers the application unhealthy if no successful Modbus read within 2× send interval
- Automatic monitoring script restarts the service on health check failures
## Production Deployment
For production use on Raspberry Pi or Ubuntu server:
1. **Install as systemd service** - Auto-start on boot with resource limits
2. **Enable health monitoring** - Cron job checks health every 2 minutes
3. **Automatic recovery** - Restarts service on Modbus failures (max 3 attempts per 10 minutes)
4. **Graceful shutdown** - Flushes buffered data to InfluxDB before exit
See [DEPLOYMENT.md](DEPLOYMENT.md) for detailed instructions.
## Data Buffering
When InfluxDB is unavailable, data is automatically buffered:
- **Buffer size**: 50,000 data points in memory
- **Batch size**: 50 points per flush
- **Flush interval**: 180 seconds (3 minutes)
- **No data loss**: Buffer persists during InfluxDB outages
- **Automatic retry**: Built-in retry mechanism in InfluxDB Java client
## Troubleshooting
### Check application logs
```bash
journalctl -u home-pump.service -f
```
### Test health endpoint
```bash
./scripts/test-health.sh
```
### View monitor logs
```bash
tail -f /var/log/home-pump-monitor.log
```
### Common Issues
1. **Service won't start**: Check JAR file exists and Java 21 is installed
2. **Health check fails**: Verify Modbus device is accessible
3. **InfluxDB connection errors**: Check network and credentials (data will be buffered)
4. **Too many restarts**: Check Modbus device connectivity and network stability clean package
java -jar target/home-pump-1.0.0.jar
```
## Requirements
- Java 21
- Modbus TCP accessible heat pump
- InfluxDB instance
## Frontend Dashboard (React + TypeScript + MUI)
Nowy frontend znajduje się w folderze `frontend` i zawiera:
- Vite + React + TypeScript
- Material UI w ciemnym stylu
- Recharts do wykresów operacyjnych
- Architektura isomorphic-ready (`entry-client.tsx` i `entry-server.tsx`)
- Reużywalne komponenty i warstwa usług gotowa pod rozbudowę API
### Uruchomienie frontendu
```bash
cd frontend
npm install
npm run dev
```
### Build produkcyjny
```bash
cd frontend
npm run build
```
### Build SSR (isomorphic)
```bash
cd frontend
npm run build:ssr
``` ```
After a release: on the Pi `./update.sh <version>` (gateway/README.md); in Kraków Arcane runs the
backend and frontend images.
+1
View File
@@ -0,0 +1 @@
target
+22
View File
@@ -0,0 +1,22 @@
# Built on the build machine's own platform; the image only holds the jar and a JRE.
FROM --platform=$BUILDPLATFORM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
RUN mvn -B -q dependency:go-offline
COPY src src
# Tests run in scripts/check.sh; some read contract files from gateway/, outside this context.
RUN mvn -B -q package -Dmaven.test.skip=true
FROM eclipse-temurin:21-jre-alpine
# The release tag, shown in /api/health; the release workflow passes it.
ARG VERSION=dev
ENV VERSION=$VERSION
USER 10001:10001
WORKDIR /app
COPY --from=build /src/target/home-pump-backend-1.0.0.jar app.jar
EXPOSE 8080
# /api/live needs no session and does not look at the gateway, so it only tells whether the backend
# itself serves requests. busybox wget ships with alpine.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/api/live" || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]
+157
View File
@@ -0,0 +1,157 @@
# Backend
The phone API in Kraków (ADR-008): live state from the gateway, history from InfluxDB, setpoint
changes passed to the gateway. JSON under `/api/` only; the web UI is the frontend's (ADR-010).
The records in `home.backend.domain` and the examples in `src/test/resources/api/` are the contract.
`GET /api/energy` (example `src/test/resources/api/energy.json`) is the house electricity now. `housePowerW` is the whole house by balance, because the Deye `load` registers cover only its backup output (`backupOutputW`): Deye PV1 + PV2 + Fronius PV + Deye battery (+discharge, − charge) + grid, the grid being the house meter's `total_power_active` when live, else the Deye `grid_power_w`; floored at 0, null when any of these readings is missing or stale. `today.houseKwh` is Deye PV + Fronius day energy + grid bought − grid sold + battery discharged − battery charged (Deye day counters), null when any is missing.
`GET /api/energy/details` lists the Deye, Fronius and house meter values in labelled groups (a stale device has none); the label, unit and place of every field are the catalog in `EnergyDetailsService`: a new field is one line there.
`GET /api/pi-sensor` is the BME280 sensor on the Raspberry Pi (the living room), example `src/test/resources/api/pi-sensor.json`:
`time`, `stale`, `temperature` (°C), `humidity` (%), `pressure` (hPa). When it is stale (device down, no reading or older than 10 min)
the three values are null (`pi-sensor-stale.json`).
`GET /api/stats?range=7d|30d` (example `src/test/resources/api/stats.json`; 400 otherwise, 502 when InfluxDB is down) is the heat pump per local
day (Europe/Warsaw, today included) from the raw `heat_pump` polls: `days[]` with `date`, `compressorHours`, `compressorStarts`, `cwuHours`, `coHours`,
`avgOutdoorTemp`, `minCwuTemp`, `maxCwuTemp` (all null on a day without polls) and `totals` over the range. A poll holds its outputs
until the next one, at most 600 s, so gaps add no hours; a start is a poll above 0 after a poll at 0, so the 300 s polling misses short cycles.
`GET /api/program?days=14` (7 to 30, default 14; 400 otherwise, 502 when InfluxDB is down; example `src/test/resources/api/program.json`) is the weekly
program as the history shows it, not read from the controller: `weekdays[]` from Monday (`day` 1) to Sunday, each with `cwu` and `room`:
`comfort` (intervals `from`/`to`, local "HH:MM", "24:00" at midnight), `observedDays`, `agreeingDays` and `confidence` (`confirmed`: at least
two days of that weekday agree within 5 minutes, `seen_once`, `no_data`). A boundary is the middle between the two polls where the period
changes (`Periods`), rounded to 5 minutes; an override, ambiguous setpoints (e.g. during our writes) or a gap over 600 s drop the interval.
Today is left out. `/api/heat-pump` has `periods` (`cwu`, `room`: `comfort`, `economy`, `unknown`, `override`; `program`, `programText`).
`GET /api/battery` is the Deye battery, example `src/test/resources/api/battery.json`: live `socPct`, `powerW` (+ discharge / − charge), `voltageV`,
`currentA`, `temperatureC`, `today` and `total` energy (`chargeKwh`, `dischargeKwh`), null when the Deye is stale (older than 10 min or down).
The settings come from the gateway's slower `deye_settings` reading and are stale after 30 min (`battery-settings-stale.json`):
`mode` (`touEnabled` with a Polish sentence), `schedule` (six slots, each from its start to the next one, the last wraps past midnight),
`limits`, and `capacityKwh` = capacity in Ah x a nominal 51.2 V, since the inverter reports Ah only. When stale they are null (`schedule` empty).
`/api/battery` also has `reserve` (`pct`, `floorPct`: the backup reserve of the app and the lowest the user may set) and `editable`:
true when the gateway answers its `/battery/reserve`, i.e. it has battery commands on and `GATEWAY_TOKEN` is right; else `reserve` is null
and `editable` false. `POST /api/battery/schedule` (examples `battery-schedule-request.json`, `battery-failed.json`) takes
`{"enabled"?, "slots": [{"slot": 1-6, "from"?: "HH:MM", "socPct"?, "gridCharge"?}]}` and `POST /api/battery/reserve` takes `{"pct": 60}`.
Same Origin, header and JSON rules as the other writes; the signed-in user goes on as the requester. The gateway's verdict comes back as
`{status, message, read}`: 200 applied, 422 rejected, 409 settings unknown or changed meanwhile, 429 too soon, 502 failed (`read` is what the
inverter holds), 400 bad body, 503 gateway unreachable or without battery commands (ADR-013).
`GET /api/audit?range=7d` (`24h`, `7d` default, `30d`; 400 otherwise, 502 when InfluxDB fails) is the change history, example
`src/test/resources/api/audit.json`: the gateway's audit rows of the heat pump (`Dom_Pompa.setpoint_audit`) and the battery
(`electricity.deye_settings_audit`), read only, as `entries` newest first: `time`, `source` (`heat_pump`, `battery`), `what` (Polish label;
the Deye registers are named in `DeyeSettingLabels`), `oldValue`/`newValue` (whole numbers, null when not recorded), `unit` (`°C`, `%`,
`HHMM`, `0/1`), `who` (the signed-in user; a restore is "automatycznie (przywrócenie)") and `status` (the gateway's: APPLIED, FAILED, REJECTED, CONFLICT, INVALID).
`GET /api/weather` (example `src/test/resources/api/weather.json`) is the forecast of Open-Meteo for the house and the IMGW warnings that name
Krosno (TERYT 1862 or the powiat 1807): `today`, `tomorrow`, `dayAfter` (`date`, `weatherCode` WMO, Polish `description`, `tMax`, `tMin`,
`precipitationMm`, `sunshineHours`, `pvForecastKwh`), `now.temperature`, `warnings` (`event`, `level` 1-3, `from`, `to`, `text`; the ones that
ended are dropped). `pvFactor` is kWh per MJ/m^2: the Deye plus Fronius energy of the past 14 complete days (from InfluxDB, daily maximum of
`pv_today_kwh` and `energy_day_wh`) over the forecast radiation of the same days, so no tilt or azimuth is needed; `pvForecastKwh` is the factor
times the day's radiation, and both are null with fewer than 5 days that have both values. The forecast is kept 30 min and the warnings 10 min;
when a refresh fails the older answer is served with `stale` true. 503 only when there never was a forecast. The sources are public and need no key.
Settings are environment variables (`.env` next to `compose.yaml`); the backend refuses to start
without the two URLs.
Setpoint changes go only to the gateway; the signed-in user is sent as `X-Requested-By`.
| Variable | Default | Meaning |
|---|---|---|
| `GATEWAY_URL` | required | the gateway on the Pi, e.g. `http://10.0.0.2:8080` |
| `INFLUX_URL` | required | InfluxDB 1.x, e.g. `https://influxdb.example` |
| `PORT` | `8080` | listening port |
| `GATEWAY_TIMEOUT` | `5` | seconds |
| `INFLUX_TIMEOUT` | `10` | seconds |
| `GATEWAY_TOKEN` | unset | shared token for the gateway's `/commands`; without it the backend runs but `/api/setpoints` and `/api/presets/{id}` answer 503 |
| `WEATHER_LAT`, `WEATHER_LON` | `49.684`, `21.770` | where the house is (Krosno, ul. Szczepańcowa), for the forecast |
| `WEATHER_TERYT` | `1862,1807` | comma-separated TERYT powiat codes whose IMGW warnings count |
| `USERS_FILE` | unset | local accounts, see Sign-in; outside the image like `PRESETS_FILE` |
| `OIDC_ISSUER` | unset | Keycloak realm URL, e.g. `https://login.mariuszpacek.pl/realms/home`; unset turns OIDC off |
| `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI` | required with `OIDC_ISSUER` | the confidential client; redirect `https://pompa.mariuszpacek.pl/api/auth/oidc/callback` |
| `OIDC_REQUIRED_ROLE` | unset | a role the user must have: realm role (`realm_access.roles`) or role of this client (`resource_access.<client>.roles`) in the ID token |
| `SESSION_SECRET` | required with `USERS_FILE` or `OIDC_ISSUER` | at least 32 bytes signing the cookies, e.g. `openssl rand -base64 48`; changing it signs everyone out |
| `SESSION_DAYS` | `30` | how long a session lasts |
| `TRUSTED_PROXY_HOPS` | `1` | proxies that append to `X-Forwarded-For` before the backend (Traefik: 1), for the login throttle |
| `AUTH_DISABLED` | unset | `true` runs without sign-in (every request is `phone`), for development; refused together with `USERS_FILE` or `OIDC_ISSUER` |
| `PRESETS_FILE` | built-in | JSON file replacing the presets: `[{"id", "name", "changes": [{"setpoint", "value", "hours"}]}]`, `hours` null means until changed; a bad file stops the start. A preset may also have `"until": "chosen"` (then no `hours`): the user picks the end, see below |
```bash
mvn -f backend/pom.xml clean package
GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example AUTH_DISABLED=true \
java -jar backend/target/home-pump-backend-1.0.0.jar # API at http://localhost:8080/api/
docker compose -f backend/compose.yaml up --build # with backend/.env; network home-pump
```
## Sign-in (ADR-014)
Without `USERS_FILE` or `OIDC_ISSUER` the backend refuses to start unless `AUTH_DISABLED=true`. The two ways in are
independent: local accounts keep working when Keycloak is down, and the backend never waits for Keycloak at start
(discovery and keys are fetched at the first sign-in and kept an hour). Every endpoint but those below answers
`401 {"error":"unauthenticated"}` without a valid session; the writes keep their Origin, header and JSON rules.
| Endpoint | Answer |
|---|---|
| `GET /api/auth/config` | `{"local", "oidc", "setup"}` booleans (`auth-config.json`); `setup` is true while `USERS_FILE` has no account |
| `POST /api/auth/setup` `{"username","password"}` | first account, once: 200 `{"user","method":"local"}` and the cookie; 409 `{"error":"setup_done"}` once any account exists; 400 `weak_password` (under 10 characters) or `invalid_username`; 404 without `USERS_FILE` |
| `GET /api/auth/users` | session needed: `[{"username"}]` |
| `POST /api/auth/users` `{"username","password"}` | session needed: adds a local account; 200 `{"username"}`, 409 `exists`, 400 as setup |
| `POST /api/auth/password` `{"current","new"}` | session needed, local sessions only (403 `local_only` otherwise): 204; 401 `invalid` (counts toward the throttle), 429, 400 `weak_password` |
| `POST /api/auth/users/{name}/delete` | session needed: 204 (removing yourself also clears your cookie); 404 `unknown_user`; 409 `last_user` |
| `POST /api/auth/login` `{"username","password"}` | 200 `{"user","method":"local"}` and the cookie; 401 `{"error":"invalid"}`; 429 `{"error":"throttled","retryAfterSeconds"}` with `Retry-After`; 404 without `USERS_FILE` |
| `POST /api/auth/logout` | 204, cookie cleared |
| `GET /api/auth/me` | 200 `{"user","method"}` (`auth-me.json`; `method` is `local`, `oidc`, or `none` with `AUTH_DISABLED`) or 401 |
| `GET /api/auth/oidc/start` | 302 to Keycloak; 404 without OIDC |
| `GET /api/auth/oidc/callback` | 302 to `/`, or to `/#login?error=oidc_unavailable` (Keycloak down), `oidc_denied` (refused or role missing), `oidc_failed` (bad state, token or code) |
| `GET /api/live` | 200 `ok`, for the container healthcheck and external monitors: `/api/health` needs a session now |
The session is a cookie `hp_session` (HttpOnly, Secure, SameSite=Lax, Path=/) signed with HMAC-SHA256 and holding user, method,
issue time and expiry; there is no server state, so a stolen cookie lives until it expires or the secret changes; a `local` session also ends when its account
is removed (it is checked on every request).
The OIDC flow is authorization code with PKCE S256, state and nonce, in a 10 minute signed cookie `hp_oidc`; the ID token is
verified here (RS256 against the JWKS key by `kid`, `iss`, `aud` and `azp`, `exp`/`iat` with 60 s skew, `nonce`).
The user name is `preferred_username`, else `email`, else `sub`.
**Local accounts**: `USERS_FILE` is JSON `[{"username": "tata", "passwordHash": "pbkdf2-sha256$600000$<salt>$<hash>"}]`, a username of
1 to 64 of `A-Za-z0-9_.@+-`, a bad file stops the start. The app edits the file: it rewrites it atomically (temp file in the same directory, fsync, rename, mode 600), so mount a **directory** (`./auth:/config/auth`, `USERS_FILE=/config/auth/users.json`) that uid 10001 owns: `chown 10001:10001 auth`. A missing file or one with no accounts is setup mode: the first visitor to the app creates the first account in the app (accepted risk, ADR-014: Traefik admits only the two home networks). Add, remove accounts and change a password in the app; each change is logged (who, what, never a password). A hash can still be made by hand, without echoing the password (asked twice on a terminal;
a pipe reads one line):
```bash
docker run --rm -it git.mariuszpacek.pl/mariusz.pacek/home-pump-backend:latest hash-password
java -jar backend/target/home-pump-backend-1.0.0.jar hash-password
```
Five login attempts in 15 minutes block that username and, separately, that client address (429, `Retry-After`; an attempt is
counted before the password is checked, so parallel guesses cannot outrun it, and a success clears the username); a blocked
username is refused even with the right password. At most 4 passwords are hashed at once, more get 429 at once. An unknown username costs one password hash like a wrong password.
The address is the entry `TRUSTED_PROXY_HOPS` from the right of `X-Forwarded-For` (Traefik appends the address it saw;
what a client put in front is ignored); without it the socket's address.
**Keycloak client** in the realm of `OIDC_ISSUER`: Client authentication on (confidential), only Standard flow, PKCE method
`S256`, valid redirect URI `https://pompa.mariuszpacek.pl/api/auth/oidc/callback`, web origin `https://pompa.mariuszpacek.pl`.
Its secret is `OIDC_CLIENT_SECRET`. Without `OIDC_REQUIRED_ROLE`, every user of the realm can sign in; the backend logs a warning at start. For `OIDC_REQUIRED_ROLE`, the client scope `roles` must put the roles into the ID token
(its realm and client role mappers: "Add to ID token" on).
`POST /api/presets/{id}` takes no body for an ordinary preset. A preset with `"until": "chosen"` (the built-in "Wyjazd")
needs `{"until": "2026-11-02T18:00:00+01:00"}` (ISO-8601 with offset, example `src/test/resources/api/preset-away-request.json`):
400 when it is missing, invalid, or sent to a preset that has none. Any preset also takes optional `"values"`
(example `preset-away-values-request.json`): a whole number per setpoint of that preset, replacing the preset's value;
the others keep it. 400 for a setpoint the preset does not change or a value outside the gateway's min, max and step
(from `/commands`), checked before anything is sent. A timed preset (built-in "Ciepła woda teraz") also takes optional
`"hours"` (example `preset-cwu-now-request.json`): a whole number from 1 to 4 replacing its duration; anything else, or
hours for a held preset, is 400 `{"error":"invalid_hours"}`. A held value that is not below the current one is 422. The gateway holds those changes until then without
renewals (ADR-012) and rejects an end that is past or over 30 days away, or a change that does not lower the value (422).
`POST /api/refresh` (same Origin, header and JSON rules; send `{}`) is the "Odśwież" button: it asks the gateway for one
extra poll of every device and passes its answer through, example `src/test/resources/api/refresh.json`
(`requested`, and `skipped` with `retryInSeconds` for devices polled within the last minute). Read-only; 503 when
`GATEWAY_TOKEN` is not set or the gateway is unreachable.
`POST /api/setpoints/{name}/restore` (same Origin, header and JSON rules as the other writes; send `{}`) ends the
pending change of that setpoint now and answers with the gateway's verdict, example
`src/test/resources/api/setpoint-restored.json`: 200 restored, 404 nothing pending, 409 the value was changed
meanwhile (left alone, the restore is dropped), 429 too soon after the last write, 502 failed (the restore stays
pending), 503 gateway unreachable.
Publish no host port in production: only the frontend, behind Traefik, may reach the backend, so Traefik's
access rules (ADR-009) stay in front of the sign-in.
`/api/health` also carries `backendVersion` (the `VERSION` the image was built with, `dev` locally) and
`gatewayVersion` (from the gateway's `/health`; null when it is unreachable or too old to report it).
+23
View File
@@ -0,0 +1,23 @@
# The backend in Kraków: the API only (ADR-010), reached by the frontend on the network home-pump
# and published on no host port (ADR-009). Settings in .env next to this file (README).
name: home-pump-backend
services:
backend:
image: git.mariuszpacek.pl/mariusz.pacek/home-pump-backend:${VERSION:-latest}
build: .
restart: unless-stopped
env_file: .env
environment:
TZ: Europe/Warsaw
# Sign-in (README): the accounts directory, writable by uid 10001 (chown 10001:10001 auth); the app rewrites users.json in it. In .env, for example:
# USERS_FILE=/config/auth/users.json SESSION_SECRET=<openssl rand -base64 48>
# OIDC_ISSUER=https://login.mariuszpacek.pl/realms/home OIDC_CLIENT_ID=home-pump
# OIDC_CLIENT_SECRET=<from Keycloak> OIDC_REQUIRED_ROLE=family
# OIDC_REDIRECT_URI=https://pompa.mariuszpacek.pl/api/auth/oidc/callback
volumes:
- ./auth:/config/auth
networks: [home-pump]
networks:
home-pump:
name: home-pump
+84
View File
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.home</groupId>
<artifactId>home-pump-backend</artifactId>
<version>1.0.0</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>21</maven.compiler.release>
<jackson.version>2.20.1</jackson.version>
</properties>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>1.7.32</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.8.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
<configuration>
<redirectTestOutputToFile>true</redirectTestOutputToFile>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.4.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>home.backend.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,85 @@
package home.backend;
import home.backend.adapters.auth.Authentication;
import home.backend.adapters.config.PresetFile;
import home.backend.adapters.gateway.GatewayHttpClient;
import home.backend.adapters.gateway.GatewayHttpCommands;
import home.backend.adapters.http.ApiServer;
import home.backend.adapters.influx.InfluxAuditRepository;
import home.backend.adapters.influx.InfluxHistoryRepository;
import home.backend.adapters.influx.InfluxPvEnergy;
import home.backend.adapters.influx.InfluxSettings;
import home.backend.adapters.weather.ImgwClient;
import home.backend.adapters.weather.OpenMeteoClient;
import home.backend.adapters.weather.WeatherSettings;
import home.backend.application.AuditService;
import home.backend.application.BatteryService;
import home.backend.application.EnergyDetailsService;
import home.backend.application.EnergyService;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService;
import home.backend.application.ProgramService;
import home.backend.application.StatsService;
import home.backend.application.LeaseRenewer;
import home.backend.application.PiSensorService;
import home.backend.application.SetpointService;
import home.backend.application.WeatherService;
import java.io.IOException;
import java.net.URI;
import java.time.Clock;
import java.time.Duration;
import java.util.Objects;
/** Wires the adapters and services together; the only place that knows all of them. */
final class Backend {
private static final Duration WEATHER_TIMEOUT = Duration.ofSeconds(10);
/** Warnings are optional, so a slow IMGW must not hold the forecast. */
private static final Duration WARNINGS_TIMEOUT = Duration.ofSeconds(3);
private Backend() {
}
/**
* Binds the port but does not start serving.
*
* @throws IllegalArgumentException when PRESETS_FILE or USERS_FILE is unusable
*/
static ApiServer assemble(Settings settings, Clock clock) throws IOException {
var gateway = new GatewayHttpClient(settings.gatewayUrl(), settings.gatewayTimeout());
var influxSettings = new InfluxSettings(settings.influxUrl(), settings.influxTimeout());
var influx = new InfluxHistoryRepository(influxSettings);
var commands = commands(settings);
var setpoints = new SetpointService(commands, PresetFile.load(settings.presetsFile()));
return new ApiServer(settings.port(),
new HeatPumpService(gateway, clock),
new EnergyService(gateway, clock),
new EnergyDetailsService(gateway, clock),
new HealthService(gateway, clock, Objects.requireNonNullElse(System.getenv("VERSION"), "dev")),
new HistoryService(influx, clock), setpoints, new PiSensorService(gateway, clock),
new BatteryService(gateway, commands, clock),
new AuditService(new InfluxAuditRepository(influxSettings), clock), new StatsService(influx, clock),
new ProgramService(influx, clock),
weather(influxSettings, clock), commands,
Authentication.create(settings.auth(), clock));
}
private static WeatherService weather(InfluxSettings influx, Clock clock) {
var place = WeatherSettings.fromEnvironment(System.getenv());
return new WeatherService(
new OpenMeteoClient(URI.create("https://api.open-meteo.com"), place, WEATHER_TIMEOUT),
new ImgwClient(URI.create("https://danepubliczne.imgw.pl"), place, WARNINGS_TIMEOUT),
new InfluxPvEnergy(influx), clock);
}
/** Keeps the gateway from restoring our setpoint changes while we are up (ADR-011). */
static LeaseRenewer leaseRenewer(Settings settings) {
return new LeaseRenewer(commands(settings));
}
private static GatewayHttpCommands commands(Settings settings) {
return new GatewayHttpCommands(settings.gatewayUrl(), settings.gatewayToken(), settings.gatewayTimeout());
}
}
@@ -0,0 +1,41 @@
package home.backend;
import home.backend.adapters.http.ApiServer;
import home.backend.adapters.auth.PasswordHashCli;
import java.io.IOException;
import java.time.Clock;
public final class Main {
private Main() {
}
public static void main(String[] args) throws IOException {
if (args.length == 1 && args[0].equals("hash-password")) {
System.exit(PasswordHashCli.run(System.console(), System.in, System.out, System.err));
}
Settings settings;
ApiServer server;
try {
settings = Settings.fromEnvironment(System.getenv());
server = Backend.assemble(settings, Clock.systemUTC());
} catch (IllegalArgumentException e) {
System.err.println("home-pump-backend: " + e.getMessage());
System.exit(2);
return;
}
settings.auth().warnings().forEach(w -> System.err.println("home-pump-backend: WARNING: " + w));
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
if (settings.gatewayToken() == null) {
System.err.println("home-pump-backend: GATEWAY_TOKEN is not set, setpoint changes are disabled");
} else {
var renewer = Backend.leaseRenewer(settings);
Runtime.getRuntime().addShutdownHook(new Thread(renewer::close));
renewer.start();
}
server.start();
System.out.println("home-pump-backend listening on port " + server.port());
}
}
@@ -0,0 +1,106 @@
package home.backend;
import home.backend.adapters.auth.AuthSettings;
import home.backend.adapters.auth.OidcSettings;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
/** Twelve-factor settings: everything comes from environment variables. */
public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gatewayTimeout, Duration influxTimeout,
String gatewayToken, Path presetsFile, AuthSettings auth) {
public static Settings fromEnvironment(Map<String, String> env) {
return new Settings(
url(env, "GATEWAY_URL"),
url(env, "INFLUX_URL"),
(int) number(env, "PORT", 8080, 0, 65535),
Duration.ofSeconds(number(env, "GATEWAY_TIMEOUT", 5, 1, 300)),
Duration.ofSeconds(number(env, "INFLUX_TIMEOUT", 10, 1, 300)),
optional(env, "GATEWAY_TOKEN"),
Optional.ofNullable(optional(env, "PRESETS_FILE")).map(Path::of).orElse(null),
auth(env));
}
private static final int MIN_SECRET_BYTES = 32;
/** Refuses to start open by accident: sign-in is configured, or AUTH_DISABLED=true says it is meant. */
private static AuthSettings auth(Map<String, String> env) {
Path usersFile = Optional.ofNullable(optional(env, "USERS_FILE")).map(Path::of).orElse(null);
OidcSettings oidc = optional(env, "OIDC_ISSUER") == null ? null : oidc(env);
String secret = optional(env, "SESSION_SECRET");
boolean disabled = "true".equals(optional(env, "AUTH_DISABLED"));
int days = (int) number(env, "SESSION_DAYS", 30, 1, 365);
int hops = (int) number(env, "TRUSTED_PROXY_HOPS", 1, 1, 10);
boolean configured = usersFile != null || oidc != null;
if (disabled && configured) {
throw new IllegalArgumentException("AUTH_DISABLED=true contradicts USERS_FILE and OIDC_ISSUER: unset one");
}
if (!disabled && !configured) {
throw new IllegalArgumentException("no sign-in configured: set USERS_FILE and/or OIDC_ISSUER, "
+ "or AUTH_DISABLED=true for development only");
}
if (configured && (secret == null || secret.getBytes(StandardCharsets.UTF_8).length < MIN_SECRET_BYTES)) {
throw new IllegalArgumentException("SESSION_SECRET is required and must be at least " + MIN_SECRET_BYTES
+ " bytes, e.g. from: openssl rand -base64 48");
}
return new AuthSettings(usersFile, oidc, secret, days, disabled, hops);
}
private static OidcSettings oidc(Map<String, String> env) {
for (String name : new String[] {"OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_REDIRECT_URI"}) {
if (optional(env, name) == null) {
throw new IllegalArgumentException(name + " is required when OIDC_ISSUER is set");
}
}
return new OidcSettings(URI.create(optional(env, "OIDC_ISSUER")), optional(env, "OIDC_CLIENT_ID"),
optional(env, "OIDC_CLIENT_SECRET"), URI.create(optional(env, "OIDC_REDIRECT_URI")),
optional(env, "OIDC_REQUIRED_ROLE"));
}
/** Null when unset or blank. */
private static String optional(Map<String, String> env, String name) {
String value = env.get(name);
return value == null || value.isBlank() ? null : value.strip();
}
private static URI url(Map<String, String> env, String name) {
String value = env.get(name);
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(name + " is required, e.g. " + name + "=http://host:8080");
}
URI uri = URI.create(value.strip());
if (uri.getHost() == null || !("http".equals(uri.getScheme()) || "https".equals(uri.getScheme()))) {
throw new IllegalArgumentException(name + " must be an http(s) URL with a host, got: " + value);
}
// The clients append their own paths to this base; a path here would be silently dropped or doubled.
boolean hasPath = !uri.getRawPath().isEmpty() && !uri.getRawPath().equals("/");
if (hasPath || uri.getRawQuery() != null || uri.getRawFragment() != null) {
throw new IllegalArgumentException(name + " must be only scheme, host and port, without a path, got: "
+ value);
}
return uri;
}
/** Timeouts are whole seconds. */
private static long number(Map<String, String> env, String name, long fallback, long min, long max) {
String value = env.get(name);
if (value == null || value.isBlank()) {
return fallback;
}
try {
long number = Long.parseLong(value.strip());
if (number >= min && number <= max) {
return number;
}
} catch (NumberFormatException e) {
// reported below with the same message
}
throw new IllegalArgumentException(name + " must be a whole number from " + min + " to " + max
+ ", got: " + value);
}
}
@@ -0,0 +1,20 @@
package home.backend.adapters.auth;
import java.nio.file.Path;
import java.util.List;
/**
* How users sign in. At least one of usersFile and oidc, or disabled; the secret signs the cookies.
* proxyHops is how many proxies append to X-Forwarded-For before the backend (Traefik: 1).
*/
public record AuthSettings(Path usersFile, OidcSettings oidc, String sessionSecret, int sessionDays,
boolean disabled, int proxyHops) {
/** Things worth saying at startup. */
public List<String> warnings() {
if (oidc != null && oidc.requiredRole() == null) {
return List.of("OIDC_REQUIRED_ROLE is not set: every user of the Keycloak realm can sign in");
}
return List.of();
}
}
@@ -0,0 +1,166 @@
package home.backend.adapters.auth;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.util.Locale;
import java.util.Optional;
import java.util.concurrent.Semaphore;
/** Who may use the API: local accounts and OIDC, independent of each other, both ending in a session cookie. */
public final class Authentication {
public static final String LOCAL = "local";
public enum Status { OK, INVALID, THROTTLED }
public record Login(Status status, long retryAfterSeconds) {
}
private static final int MAX_FAILURES = 5;
private static final Duration THROTTLE_WINDOW = Duration.ofMinutes(15);
/** Password hashing is slow on purpose; a flood must not queue up behind it. */
private static final int PARALLEL_HASHES = 4;
private static final int MIN_PASSWORD = 10;
private static final long BUSY_RETRY_SECONDS = 1;
private final Sessions sessions;
private final UserStore users;
private final LoginThrottle throttle;
private final OidcFlow oidc;
private final int proxyHops;
private final Semaphore hashing = new Semaphore(PARALLEL_HASHES);
private Authentication(Sessions sessions, UserStore users, LoginThrottle throttle, OidcFlow oidc, int proxyHops) {
this.sessions = sessions;
this.users = users;
this.throttle = throttle;
this.oidc = oidc;
this.proxyHops = proxyHops;
}
/** @throws IllegalArgumentException when USERS_FILE is unusable */
public static Authentication create(AuthSettings settings, Clock clock) {
if (settings.disabled()) {
return new Authentication(null, null, null, null, settings.proxyHops());
}
var tokens = new SignedTokens(settings.sessionSecret().getBytes(StandardCharsets.UTF_8), clock);
return new Authentication(new Sessions(tokens, clock, Duration.ofDays(settings.sessionDays())),
settings.usersFile() == null ? null : UserStore.load(settings.usersFile()),
new LoginThrottle(clock, MAX_FAILURES, THROTTLE_WINDOW),
settings.oidc() == null ? null : new OidcFlow(settings.oidc(), tokens, clock),
settings.proxyHops());
}
/** For development and tests: every request is the user "phone". */
public static Authentication disabled() {
return new Authentication(null, null, null, null, 1);
}
public boolean localEnabled() {
return users != null;
}
public boolean oidcEnabled() {
return oidc != null;
}
public OidcFlow oidc() {
return oidc;
}
public int proxyHops() {
return proxyHops;
}
public Optional<Session> session(String cookieHeader) {
if (sessions == null) {
return Optional.of(Session.OPEN);
}
// A removed account loses its sessions at once; OIDC sessions end with their expiry.
return sessions.read(cookieHeader).filter(s -> !LOCAL.equals(s.method()) || users != null && users.exists(s.user()));
}
/** Checks the password unless the user or the address is blocked; a blocked one learns nothing about it. */
public Login login(String username, char[] password, String clientIp) {
String userKey = "user:" + username.toLowerCase(Locale.ROOT);
String ipKey = "ip:" + clientIp;
long wait = throttle.tryAcquire(userKey, ipKey);
if (wait > 0) {
return new Login(Status.THROTTLED, wait);
}
if (!hashing.tryAcquire()) {
throttle.release(userKey, ipKey);
return new Login(Status.THROTTLED, BUSY_RETRY_SECONDS);
}
try {
if (users.authenticate(username, password)) {
throttle.cleared(userKey);
throttle.release(ipKey);
return new Login(Status.OK, 0);
}
return new Login(Status.INVALID, 0);
} finally {
hashing.release();
}
}
public boolean setupNeeded() {
return users != null && users.isEmpty();
}
public static boolean strongEnough(String password) {
return password.length() >= MIN_PASSWORD;
}
/** Creates the first account, once: OK for the winner, INVALID (setup is done) for everyone after. */
public Login setup(String username, char[] password, String clientIp) {
String ipKey = "ip:" + clientIp;
long wait = throttle.tryAcquire(ipKey);
if (wait > 0) {
return new Login(Status.THROTTLED, wait);
}
if (!users.addFirst(username, PasswordHash.create(password))) {
return new Login(Status.INVALID, 0);
}
System.err.println("home-pump-backend: setup created the first account " + username);
throttle.release(ipKey);
return new Login(Status.OK, 0);
}
public java.util.List<String> usernames() {
return users.usernames();
}
/** @return false when the name is taken */
public boolean addUser(String by, String username, char[] password) {
boolean added = users.add(username, PasswordHash.create(password));
System.err.println("home-pump-backend: " + by + " added account " + username + (added ? "" : " (refused, exists)"));
return added;
}
/** Checks the current password like a login does (throttled), then sets the new one. */
public Login changePassword(String username, char[] current, char[] next, String clientIp) {
Login login = login(username, current, clientIp);
if (login.status() == Status.OK) {
users.changePassword(username, PasswordHash.create(next));
System.err.println("home-pump-backend: " + username + " changed their password");
}
return login;
}
public UserStore.Removal removeUser(String by, String username) {
var removal = users.remove(username);
System.err.println("home-pump-backend: " + by + " removed account " + username + ": " + removal);
return removal;
}
/** The Set-Cookie header value that signs the user in. */
public String sessionCookie(String user, String method) {
return sessions.setCookie(user, method);
}
public String clearedSessionCookie() {
return sessions == null ? "" : sessions.clearCookie();
}
}
@@ -0,0 +1,18 @@
package home.backend.adapters.auth;
import java.util.Base64;
/** Unpadded base64url, as tokens and PKCE use it. */
final class Base64Url {
private Base64Url() {
}
static String encode(byte[] bytes) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
static byte[] decode(String text) {
return Base64.getUrlDecoder().decode(text);
}
}
@@ -0,0 +1,36 @@
package home.backend.adapters.auth;
import java.time.Duration;
import java.util.Optional;
/** Reads the Cookie header and builds Set-Cookie headers with the flags every cookie here carries. */
final class Cookies {
private Cookies() {
}
static Optional<String> read(String header, String name) {
if (header == null) {
return Optional.empty();
}
for (String pair : header.split(";")) {
String[] kv = pair.trim().split("=", 2);
if (kv.length == 2 && kv[0].equals(name)) {
return Optional.of(kv[1]);
}
}
return Optional.empty();
}
static String set(String name, String value, Duration maxAge, String path) {
return name + "=" + value + "; Max-Age=" + maxAge.toSeconds() + attributes(path);
}
static String clear(String name, String path) {
return name + "=; Max-Age=0" + attributes(path);
}
private static String attributes(String path) {
return "; Path=" + path + "; HttpOnly; Secure; SameSite=Lax";
}
}
@@ -0,0 +1,143 @@
package home.backend.adapters.auth;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.PublicKey;
import java.security.Signature;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import static home.backend.adapters.auth.OidcException.Reason.DENIED;
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
/** Checks an ID token with the JDK alone: RS256 signature, issuer, audience, times, nonce and the required role. */
final class IdTokenVerifier {
/** The signing keys of the identity provider. */
interface Keys {
PublicKey key(String kid) throws OidcException;
}
private static final ObjectMapper JSON = new ObjectMapper();
private static final Duration SKEW = Duration.ofSeconds(60);
private final OidcSettings settings;
private final Clock clock;
IdTokenVerifier(OidcSettings settings, Clock clock) {
this.settings = settings;
this.clock = clock;
}
/** @return the user name of the token */
String verify(String idToken, String nonce, Keys keys) throws OidcException {
String[] parts = idToken.split("\\.", -1);
if (parts.length != 3) {
throw new OidcException(FAILED, "the ID token is not a JWT");
}
JsonNode header = json(parts[0]);
if (!"RS256".equals(header.path("alg").asText())) {
throw new OidcException(FAILED, "the ID token is not signed with RS256");
}
verifySignature(parts[0] + "." + parts[1], parts[2], keys.key(header.path("kid").asText()));
JsonNode claims = json(parts[1]);
checkClaims(claims, nonce);
checkRole(claims);
return userName(claims);
}
private void checkClaims(JsonNode claims, String nonce) throws OidcException {
long now = clock.instant().getEpochSecond();
if (!settings.issuerBase().equals(claims.path("iss").asText("").replaceAll("/+$", ""))) {
throw new OidcException(FAILED, "wrong issuer");
}
if (!audiences(claims).contains(settings.clientId())) {
throw new OidcException(FAILED, "the token is for another client");
}
if (audiences(claims).size() > 1 && !settings.clientId().equals(claims.path("azp").asText())) {
throw new OidcException(FAILED, "wrong authorized party");
}
if (!claims.path("exp").canConvertToLong() || claims.get("exp").asLong() + SKEW.toSeconds() <= now) {
throw new OidcException(FAILED, "the token has expired");
}
if (!claims.path("iat").canConvertToLong() || claims.get("iat").asLong() - SKEW.toSeconds() > now) {
throw new OidcException(FAILED, "the token is issued in the future");
}
boolean sameNonce = MessageDigest.isEqual(nonce.getBytes(StandardCharsets.UTF_8),
claims.path("nonce").asText("").getBytes(StandardCharsets.UTF_8));
if (!sameNonce) {
throw new OidcException(FAILED, "wrong nonce");
}
}
/** A realm role or a role of this client, as Keycloak puts them in {@code realm_access} and {@code resource_access}. */
private void checkRole(JsonNode claims) throws OidcException {
String role = settings.requiredRole();
if (role == null) {
return;
}
boolean has = contains(claims.path("realm_access").path("roles"), role)
|| contains(claims.path("resource_access").path(settings.clientId()).path("roles"), role);
if (!has) {
throw new OidcException(DENIED, "the user lacks the role " + role);
}
}
private String userName(JsonNode claims) throws OidcException {
for (String claim : new String[] {"preferred_username", "email", "sub"}) {
String name = claims.path(claim).asText("");
if (Session.isValidUser(name)) {
return name;
}
}
throw new OidcException(FAILED, "the token has no usable user name");
}
private static List<String> audiences(JsonNode claims) {
JsonNode aud = claims.path("aud");
var all = new ArrayList<String>();
if (aud.isArray()) {
aud.forEach(a -> all.add(a.asText()));
} else if (aud.isTextual()) {
all.add(aud.asText());
}
return all;
}
private static boolean contains(JsonNode array, String value) {
for (JsonNode item : array) {
if (value.equals(item.asText())) {
return true;
}
}
return false;
}
private static void verifySignature(String signedPart, String signature, PublicKey key) throws OidcException {
try {
Signature rsa = Signature.getInstance("SHA256withRSA");
rsa.initVerify(key);
rsa.update(signedPart.getBytes(StandardCharsets.US_ASCII));
if (!rsa.verify(Base64Url.decode(signature))) {
throw new OidcException(FAILED, "the ID token signature is wrong");
}
} catch (GeneralSecurityException | IllegalArgumentException e) {
throw new OidcException(FAILED, "the ID token signature cannot be checked", e);
}
}
private static JsonNode json(String part) throws OidcException {
try {
return JSON.readTree(Base64Url.decode(part));
} catch (IOException | IllegalArgumentException e) {
throw new OidcException(FAILED, "the ID token is malformed", e);
}
}
}
@@ -0,0 +1,70 @@
package home.backend.adapters.auth;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.Map;
/** Blocks a key (a username or a client address) after too many failures within a window. */
public final class LoginThrottle {
private final Clock clock;
private final int maxFailures;
private final Duration window;
/** The newest failures of each key, at most {@code maxFailures}, oldest first. */
private final Map<String, ArrayDeque<Instant>> failures = new HashMap<>();
public LoginThrottle(Clock clock, int maxFailures, Duration window) {
this.clock = clock;
this.maxFailures = maxFailures;
this.window = window;
}
/**
* Counts an attempt against every key before the password is checked, so parallel guesses cannot
* outrun the limit; {@link #release} takes it back.
*
* @return 0 when the attempt may go on, else the seconds until the most blocked key may try again
*/
public synchronized long tryAcquire(String... keys) {
Instant now = clock.instant();
long wait = 0;
for (String key : keys) {
var times = failures.get(key);
if (times != null && times.size() >= maxFailures && times.peekFirst().plus(window).isAfter(now)) {
wait = Math.max(wait, Duration.between(now, times.peekFirst().plus(window)).toSeconds() + 1);
}
}
if (wait > 0) {
return wait;
}
failures.values().removeIf(times -> !times.peekLast().plus(window).isAfter(now));
for (String key : keys) {
var times = failures.computeIfAbsent(key, k -> new ArrayDeque<>());
times.addLast(now);
if (times.size() > maxFailures) {
times.removeFirst();
}
}
return 0;
}
/** Takes back the newest attempt of each key. */
public synchronized void release(String... keys) {
for (String key : keys) {
var times = failures.get(key);
if (times != null) {
times.pollLast();
if (times.isEmpty()) {
failures.remove(key);
}
}
}
}
public synchronized void cleared(String key) {
failures.remove(key);
}
}
@@ -0,0 +1,40 @@
package home.backend.adapters.auth;
/** Why an OIDC sign-in did not happen; the code is what the login screen is told. */
public final class OidcException extends Exception {
public enum Reason {
/** Keycloak did not answer. */
UNAVAILABLE("oidc_unavailable"),
/** The user said no, or lacks the required role. */
DENIED("oidc_denied"),
/** Anything else: bad state, a token that does not verify, a refused code. */
FAILED("oidc_failed");
private final String code;
Reason(String code) {
this.code = code;
}
public String code() {
return code;
}
}
private final Reason reason;
public OidcException(Reason reason, String message) {
super(message);
this.reason = reason;
}
public OidcException(Reason reason, String message, Throwable cause) {
super(message, cause);
this.reason = reason;
}
public Reason reason() {
return reason;
}
}
@@ -0,0 +1,113 @@
package home.backend.adapters.auth;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;
import static home.backend.adapters.auth.OidcException.Reason.DENIED;
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
/**
* Authorization code flow with PKCE (S256), state and nonce. What must survive the trip to the identity
* provider travels in a short-lived signed cookie, so the backend keeps no state.
*/
public final class OidcFlow {
/** Where to send the browser, and the cookie to set on that response. */
public record Start(URI location, String setCookie) {
}
private static final String COOKIE = "hp_oidc";
private static final String PURPOSE = "oidc-login";
private static final String PATH = "/api/auth/oidc";
private static final Duration LOGIN_TIME = Duration.ofMinutes(10);
private static final Duration TIMEOUT = Duration.ofSeconds(5);
private static final SecureRandom RANDOM = new SecureRandom();
private final OidcSettings settings;
private final OidcProvider provider;
private final IdTokenVerifier verifier;
private final SignedTokens tokens;
OidcFlow(OidcSettings settings, SignedTokens tokens, Clock clock) {
this.settings = settings;
this.provider = new OidcProvider(settings, TIMEOUT, clock);
this.verifier = new IdTokenVerifier(settings, clock);
this.tokens = tokens;
}
/** @throws OidcException UNAVAILABLE when the identity provider does not answer */
public Start start() throws OidcException {
String state = random();
String nonce = random();
String verifierText = random();
Map<String, String> query = new LinkedHashMap<>();
query.put("response_type", "code");
query.put("client_id", settings.clientId());
query.put("redirect_uri", settings.redirectUri().toString());
query.put("scope", "openid profile");
query.put("state", state);
query.put("nonce", nonce);
query.put("code_challenge", challenge(verifierText));
query.put("code_challenge_method", "S256");
URI endpoint = provider.endpoints().authorization();
String separator = endpoint.getRawQuery() == null ? "?" : "&";
String cookie = Cookies.set(COOKIE, tokens.issue(PURPOSE, LOGIN_TIME,
Map.of("state", state, "nonce", nonce, "verifier", verifierText)), LOGIN_TIME, PATH);
return new Start(URI.create(endpoint + separator + query.entrySet().stream()
.map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
.collect(Collectors.joining("&"))), cookie);
}
/** @return the signed-in user's name */
public String callback(Map<String, String> query, String cookieHeader) throws OidcException {
Map<String, String> login = Cookies.read(cookieHeader, COOKIE).flatMap(t -> tokens.read(PURPOSE, t))
.orElseThrow(() -> new OidcException(FAILED, "no login in progress, or it took too long"));
boolean sameState = MessageDigest.isEqual(login.get("state").getBytes(StandardCharsets.UTF_8),
query.getOrDefault("state", "").getBytes(StandardCharsets.UTF_8));
if (!sameState) {
throw new OidcException(FAILED, "wrong state");
}
if ("access_denied".equals(query.get("error"))) {
throw new OidcException(DENIED, "the user denied access");
}
String code = query.get("code");
if (code == null || code.isEmpty()) {
throw new OidcException(FAILED, "the identity provider answered with " + printable(query.get("error")));
}
return verifier.verify(provider.exchange(code, login.get("verifier")), login.get("nonce"), provider);
}
public String clearCookie() {
return Cookies.clear(COOKIE, PATH);
}
/** The provider's text goes into the log: letters, digits and a few separators only, and short. */
static String printable(String text) {
String safe = text == null ? "nothing" : text.replaceAll("[^A-Za-z0-9_. -]", "?");
return safe.length() > 40 ? safe.substring(0, 40) + "..." : safe;
}
private static String random() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return Base64Url.encode(bytes);
}
private static String challenge(String verifierText) {
try {
return Base64Url.encode(MessageDigest.getInstance("SHA-256")
.digest(verifierText.getBytes(StandardCharsets.US_ASCII)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 is part of every JDK", e);
}
}
}
@@ -0,0 +1,179 @@
package home.backend.adapters.auth;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.math.BigInteger;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.RSAPublicKeySpec;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
import static home.backend.adapters.auth.OidcException.Reason.FAILED;
import static home.backend.adapters.auth.OidcException.Reason.UNAVAILABLE;
/**
* The identity provider over HTTP: discovery, signing keys and the code exchange. Everything is fetched on first
* use and kept, so an identity provider that is down never delays the start and never affects local login.
*/
final class OidcProvider implements IdTokenVerifier.Keys {
record Endpoints(URI authorization, URI token, URI jwks) {
}
private static final ObjectMapper JSON = new ObjectMapper();
private static final Duration CACHE = Duration.ofHours(1);
/** An unknown key id may mean a rotation; look again, but not on every forged token. */
private static final Duration REFETCH_AFTER = Duration.ofMinutes(1);
private final OidcSettings settings;
private final HttpClient http;
private final Duration timeout;
private final Clock clock;
private Endpoints endpoints;
private Instant endpointsAt = Instant.MIN;
private Map<String, PublicKey> keys = Map.of();
private Instant keysAt = Instant.MIN;
OidcProvider(OidcSettings settings, Duration timeout, Clock clock) {
this.settings = settings;
this.timeout = timeout;
this.clock = clock;
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
}
/** The network calls run outside the lock, so a hanging identity provider holds up only its own callers. */
Endpoints endpoints() throws OidcException {
synchronized (this) {
if (endpoints != null && !endpointsAt.plus(CACHE).isBefore(clock.instant())) {
return endpoints;
}
}
String base = settings.issuerBase();
JsonNode doc = getJson(URI.create(base + "/.well-known/openid-configuration"));
if (!base.equals(doc.path("issuer").asText("").replaceAll("/+$", ""))) {
throw new OidcException(FAILED, "the discovery document names another issuer");
}
var fresh = new Endpoints(uri(doc, "authorization_endpoint"), uri(doc, "token_endpoint"), uri(doc, "jwks_uri"));
synchronized (this) {
endpoints = fresh;
endpointsAt = clock.instant();
}
return fresh;
}
@Override
public PublicKey key(String kid) throws OidcException {
synchronized (this) {
boolean stale = keysAt.plus(CACHE).isBefore(clock.instant());
boolean unknownAndRefetchable = !keys.containsKey(kid)
&& keysAt.plus(REFETCH_AFTER).isBefore(clock.instant());
if (!stale && !unknownAndRefetchable) {
return known(kid);
}
}
Map<String, PublicKey> fresh = parseKeys(getJson(endpoints().jwks()));
synchronized (this) {
keys = fresh;
keysAt = clock.instant();
return known(kid);
}
}
private PublicKey known(String kid) throws OidcException {
PublicKey key = keys.get(kid);
if (key == null) {
throw new OidcException(FAILED, "no signing key " + kid);
}
return key;
}
/** @return the ID token the identity provider issued for the code */
String exchange(String code, String verifier) throws OidcException {
var form = Map.of("grant_type", "authorization_code", "code", code,
"redirect_uri", settings.redirectUri().toString(), "code_verifier", verifier);
String credentials = encode(settings.clientId()) + ":" + encode(settings.clientSecret());
var request = HttpRequest.newBuilder(endpoints().token()).timeout(timeout)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Authorization", "Basic " + Base64.getEncoder()
.encodeToString(credentials.getBytes(StandardCharsets.UTF_8)))
.POST(HttpRequest.BodyPublishers.ofString(form.entrySet().stream()
.map(e -> e.getKey() + "=" + encode(e.getValue())).collect(Collectors.joining("&"))))
.build();
JsonNode answer = send(request, "the token endpoint");
String idToken = answer.path("id_token").asText("");
if (idToken.isEmpty()) {
throw new OidcException(FAILED, "the token endpoint sent no id_token");
}
return idToken;
}
private JsonNode getJson(URI uri) throws OidcException {
return send(HttpRequest.newBuilder(uri).timeout(timeout).GET().build(), uri.getPath());
}
private JsonNode send(HttpRequest request, String what) throws OidcException {
try {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 500) {
throw new OidcException(UNAVAILABLE, what + " answered " + response.statusCode());
}
if (response.statusCode() != 200) {
throw new OidcException(FAILED, what + " answered " + response.statusCode());
}
return JSON.readTree(response.body());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new OidcException(UNAVAILABLE, what + " was interrupted", e);
} catch (IOException e) {
throw new OidcException(UNAVAILABLE, what + " is unreachable: " + e, e);
}
}
/** A key that cannot be read is skipped, so one odd entry does not lock everyone out. */
private static Map<String, PublicKey> parseKeys(JsonNode jwks) {
Map<String, PublicKey> keys = new HashMap<>();
for (JsonNode jwk : jwks.path("keys")) {
boolean signingRsa = "RSA".equals(jwk.path("kty").asText()) && !"enc".equals(jwk.path("use").asText());
if (signingRsa && jwk.hasNonNull("kid")) {
try {
keys.put(jwk.get("kid").asText(), KeyFactory.getInstance("RSA").generatePublic(
new RSAPublicKeySpec(integer(jwk, "n"), integer(jwk, "e"))));
} catch (GeneralSecurityException | IllegalArgumentException e) {
System.err.println("home-pump-backend: skipping unusable signing key " + jwk.get("kid").asText());
}
}
}
return keys;
}
private static BigInteger integer(JsonNode jwk, String field) {
return new BigInteger(1, Base64Url.decode(jwk.path(field).asText()));
}
private static URI uri(JsonNode doc, String field) throws OidcException {
String value = doc.path(field).asText("");
if (value.isEmpty()) {
throw new OidcException(FAILED, "the discovery document has no " + field);
}
return URI.create(value);
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,12 @@
package home.backend.adapters.auth;
import java.net.URI;
/** requiredRole is null when any user of the realm may sign in. */
public record OidcSettings(URI issuer, String clientId, String clientSecret, URI redirectUri, String requiredRole) {
/** The issuer without a trailing slash, the form tokens and discovery are compared in. */
String issuerBase() {
return issuer.toString().replaceAll("/+$", "");
}
}
@@ -0,0 +1,78 @@
package home.backend.adapters.auth;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
/** Password hashes as {@code pbkdf2-sha256$<iterations>$<salt b64>$<hash b64>}: the cost travels with the hash. */
public final class PasswordHash {
private static final String SCHEME = "pbkdf2-sha256";
private static final int ITERATIONS = 600_000;
/** A file with a cheaper hash is refused, so a weak one cannot slip in. */
private static final int MIN_ITERATIONS = 310_000;
private static final int SALT_BYTES = 16;
private static final int HASH_BITS = 256;
private static final SecureRandom RANDOM = new SecureRandom();
private record Parsed(int iterations, byte[] salt, byte[] hash) {
}
private PasswordHash() {
}
public static String create(char[] password) {
byte[] salt = new byte[SALT_BYTES];
RANDOM.nextBytes(salt);
return String.join("$", SCHEME, String.valueOf(ITERATIONS), Base64.getEncoder().encodeToString(salt),
Base64.getEncoder().encodeToString(derive(password, salt, ITERATIONS, HASH_BITS)));
}
/** Compares in constant time. */
public static boolean matches(String stored, char[] password) {
Parsed parsed = parse(stored);
byte[] actual = derive(password, parsed.salt(), parsed.iterations(), parsed.hash().length * 8);
return MessageDigest.isEqual(parsed.hash(), actual);
}
/** @throws IllegalArgumentException when the text is not a hash this class made or accepts */
public static void requireValid(String stored) {
parse(stored);
}
private static Parsed parse(String stored) {
String[] parts = stored == null ? new String[0] : stored.split("\\$");
if (parts.length != 4 || !SCHEME.equals(parts[0])) {
throw new IllegalArgumentException("a password hash must look like " + SCHEME
+ "$<iterations>$<salt>$<hash>; create one with: hash-password");
}
try {
int iterations = Integer.parseInt(parts[1]);
byte[] salt = Base64.getDecoder().decode(parts[2]);
byte[] hash = Base64.getDecoder().decode(parts[3]);
if (iterations < MIN_ITERATIONS || salt.length < SALT_BYTES || hash.length < HASH_BITS / 8) {
throw new IllegalArgumentException("password hash is too weak: at least " + MIN_ITERATIONS
+ " iterations, 16 bytes of salt, a 32 byte hash");
}
return new Parsed(iterations, salt, hash);
} catch (IllegalArgumentException e) {
// NumberFormatException and a bad base64 are both IllegalArgumentException
throw new IllegalArgumentException("password hash is malformed or too weak (at least " + MIN_ITERATIONS
+ " iterations, 16 bytes of salt, a 32 byte hash)", e);
}
}
private static byte[] derive(char[] password, byte[] salt, int iterations, int bits) {
var spec = new PBEKeySpec(password, salt, iterations, bits);
try {
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded();
} catch (GeneralSecurityException e) {
throw new IllegalStateException("PBKDF2WithHmacSHA256 is part of every JDK", e);
} finally {
spec.clearPassword();
}
}
}
@@ -0,0 +1,40 @@
package home.backend.adapters.auth;
import java.io.BufferedReader;
import java.io.Console;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
/** {@code hash-password}: prints the hash for USERS_FILE; on a terminal the password is asked twice, unechoed. */
public final class PasswordHashCli {
private PasswordHashCli() {
}
/** @return the exit code */
public static int run(Console console, InputStream stdin, PrintStream out, PrintStream err) throws IOException {
char[] password;
if (console != null) {
password = console.readPassword("Password: ");
char[] again = console.readPassword("Again: ");
if (password == null || !Arrays.equals(password, again)) {
err.println("The passwords differ.");
return 1;
}
} else {
String line = new BufferedReader(new InputStreamReader(stdin, StandardCharsets.UTF_8)).readLine();
password = line == null ? null : line.toCharArray();
}
if (password == null || password.length == 0) {
err.println("No password given.");
return 1;
}
out.println(PasswordHash.create(password));
Arrays.fill(password, '\0');
return 0;
}
}
@@ -0,0 +1,19 @@
package home.backend.adapters.auth;
import java.time.Instant;
import java.util.regex.Pattern;
/** Who is signed in and how. {@code method} is {@code local}, {@code oidc} or {@code none} (AUTH_DISABLED). */
public record Session(String user, String method, Instant issuedAt) {
/** The name goes to the gateway's audit log as a header, so only plain characters are allowed. */
public static final String USER_PATTERN = "[\\w.@+-]{1,64}";
private static final Pattern USER = Pattern.compile(USER_PATTERN);
/** Every request is this one when authentication is switched off for development. */
static final Session OPEN = new Session("phone", "none", Instant.EPOCH);
public static boolean isValidUser(String name) {
return USER.matcher(name).matches();
}
}
@@ -0,0 +1,40 @@
package home.backend.adapters.auth;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
/** The session cookie: a signed token that holds the user, the method and when it was issued. */
final class Sessions {
static final String COOKIE = "hp_session";
private static final String PURPOSE = "session";
private final SignedTokens tokens;
private final Clock clock;
private final Duration lifetime;
Sessions(SignedTokens tokens, Clock clock, Duration lifetime) {
this.tokens = tokens;
this.clock = clock;
this.lifetime = lifetime;
}
String setCookie(String user, String method) {
String token = tokens.issue(PURPOSE, lifetime, Map.of("user", user, "method", method,
"iat", String.valueOf(clock.instant().getEpochSecond())));
return Cookies.set(COOKIE, token, lifetime, "/");
}
String clearCookie() {
return Cookies.clear(COOKIE, "/");
}
Optional<Session> read(String cookieHeader) {
return Cookies.read(cookieHeader, COOKIE).flatMap(token -> tokens.read(PURPOSE, token)).map(claims ->
new Session(claims.get("user"), claims.get("method"),
Instant.ofEpochSecond(Long.parseLong(claims.get("iat")))));
}
}
@@ -0,0 +1,71 @@
package home.backend.adapters.auth;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/** Tamper-proof, expiring text for cookies: {@code base64url(json).base64url(HMAC-SHA256)}. */
final class SignedTokens {
private static final ObjectMapper JSON = new ObjectMapper();
private static final TypeReference<Map<String, String>> CLAIMS = new TypeReference<>() {
};
private static final String HMAC = "HmacSHA256";
private final SecretKeySpec key;
private final Clock clock;
SignedTokens(byte[] secret, Clock clock) {
this.key = new SecretKeySpec(secret, HMAC);
this.clock = clock;
}
/** The purpose keeps a token made for one use (a session) from passing as another (a login in progress). */
String issue(String purpose, Duration lifetime, Map<String, String> claims) {
var all = new HashMap<>(claims);
all.put("purpose", purpose);
all.put("exp", String.valueOf(clock.instant().plus(lifetime).getEpochSecond()));
try {
String payload = Base64Url.encode(JSON.writeValueAsBytes(all));
return payload + "." + Base64Url.encode(sign(payload));
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
Optional<Map<String, String>> read(String purpose, String token) {
try {
String[] parts = token.split("\\.", -1);
if (parts.length != 2 || !MessageDigest.isEqual(sign(parts[0]), Base64Url.decode(parts[1]))) {
return Optional.empty();
}
Map<String, String> claims = JSON.readValue(Base64Url.decode(parts[0]), CLAIMS);
boolean valid = purpose.equals(claims.get("purpose"))
&& Long.parseLong(claims.get("exp")) > clock.instant().getEpochSecond();
return valid ? Optional.of(claims) : Optional.empty();
} catch (IOException | IllegalArgumentException e) {
return Optional.empty();
}
}
private byte[] sign(String payload) {
try {
Mac mac = Mac.getInstance(HMAC);
mac.init(key);
return mac.doFinal(payload.getBytes(StandardCharsets.US_ASCII));
} catch (GeneralSecurityException e) {
throw new IllegalStateException(HMAC + " is part of every JDK", e);
}
}
}
@@ -0,0 +1,151 @@
package home.backend.adapters.auth;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* The local accounts of USERS_FILE: {@code [{"username", "passwordHash"}]}. A missing file is an empty store
* (setup mode). Every change is written back atomically before it counts, under one lock.
*/
public final class UserStore {
public enum Removal { REMOVED, UNKNOWN, LAST }
private record Entry(String username, String passwordHash) {
}
private static final ObjectMapper JSON = new ObjectMapper();
/** What an unknown user is checked against, so that it costs as much as a wrong password. */
private static final String DECOY = PasswordHash.create(randomPassword());
private final Path file;
private Map<String, String> hashes;
UserStore(Path file, Map<String, String> hashes) {
this.file = file;
this.hashes = new LinkedHashMap<>(hashes);
}
/** @throws IllegalArgumentException when the file exists but is unreadable or holds a bad account */
public static UserStore load(Path file) {
Map<String, String> hashes = new LinkedHashMap<>();
if (Files.exists(file)) {
List<Entry> entries;
try {
entries = JSON.readValue(file.toFile(), new TypeReference<>() {
});
} catch (IOException e) {
throw new IllegalArgumentException("USERS_FILE " + file + " is not usable: " + e.getMessage(), e);
}
for (Entry entry : entries) {
if (entry.username() == null || !Session.isValidUser(entry.username())) {
throw new IllegalArgumentException("USERS_FILE: a username must match " + Session.USER_PATTERN);
}
PasswordHash.requireValid(entry.passwordHash());
if (hashes.put(entry.username(), entry.passwordHash()) != null) {
throw new IllegalArgumentException("USERS_FILE: duplicate username " + entry.username());
}
}
}
return new UserStore(file, hashes);
}
public synchronized boolean isEmpty() {
return hashes.isEmpty();
}
public synchronized boolean exists(String username) {
return hashes.containsKey(username);
}
public synchronized List<String> usernames() {
return List.copyOf(hashes.keySet());
}
public boolean authenticate(String username, char[] password) {
String stored;
synchronized (this) {
stored = hashes.get(username);
}
boolean matches = PasswordHash.matches(stored == null ? DECOY : stored, password);
return stored != null && matches;
}
/** The first account; false when there already is one, so only one setup can win. */
public synchronized boolean addFirst(String username, String hash) {
return hashes.isEmpty() && put(username, hash);
}
/** @return false when the name is taken */
public synchronized boolean add(String username, String hash) {
return !hashes.containsKey(username) && put(username, hash);
}
/** @return false when there is no such account */
public synchronized boolean changePassword(String username, String hash) {
return hashes.containsKey(username) && put(username, hash);
}
public synchronized Removal remove(String username) {
if (!hashes.containsKey(username)) {
return Removal.UNKNOWN;
}
if (hashes.size() == 1) {
return Removal.LAST;
}
var next = new LinkedHashMap<>(hashes);
next.remove(username);
write(next);
hashes = next;
return Removal.REMOVED;
}
private boolean put(String username, String hash) {
var next = new LinkedHashMap<>(hashes);
next.put(username, hash);
write(next);
hashes = next;
return true;
}
/** Temp file next to the target, synced, then renamed over it; the old file stays if anything fails. */
private void write(Map<String, String> accounts) {
Path directory = file.toAbsolutePath().getParent();
Path temp = null;
try {
temp = Files.createTempFile(directory, "users", ".tmp",
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
Files.write(temp, JSON.writerWithDefaultPrettyPrinter().writeValueAsBytes(accounts.entrySet().stream()
.map(e -> new Entry(e.getKey(), e.getValue())).toList()));
try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.WRITE)) {
channel.force(true);
}
Files.move(temp, file, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) {
if (temp != null) {
temp.toFile().delete();
}
throw new UncheckedIOException("cannot write USERS_FILE " + file, e);
}
}
private static char[] randomPassword() {
byte[] bytes = new byte[24];
new SecureRandom().nextBytes(bytes);
return Base64.getEncoder().encodeToString(bytes).toCharArray();
}
}
@@ -0,0 +1,41 @@
package home.backend.adapters.config;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.backend.domain.Preset;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
/** Reads the presets (F3) from the optional PRESETS_FILE, else the built-in list. */
public final class PresetFile {
private static final ObjectMapper JSON = new ObjectMapper();
private static final TypeReference<List<Preset>> PRESETS = new TypeReference<>() {
};
private PresetFile() {
}
/** @param file null for the built-in defaults; IllegalArgumentException when it is unusable */
public static List<Preset> load(Path file) {
String source = file == null ? "built-in presets" : "PRESETS_FILE " + file;
try (InputStream in = file == null ? defaults() : Files.newInputStream(file)) {
List<Preset> presets = JSON.readValue(in, PRESETS);
if (new HashSet<>(presets.stream().map(Preset::id).toList()).size() != presets.size()) {
throw new IllegalArgumentException("preset ids must be unique");
}
return presets;
} catch (IOException | IllegalArgumentException e) {
throw new IllegalArgumentException(source + " is not usable: " + e.getMessage(), e);
}
}
private static InputStream defaults() {
return PresetFile.class.getResourceAsStream("/presets-default.json");
}
}
@@ -0,0 +1,88 @@
package home.backend.adapters.gateway;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import home.backend.application.GatewayClient;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/** Reads the gateway's {@code /health} and {@code /snapshot} over HTTP. */
public class GatewayHttpClient implements GatewayClient {
private record Health(String version, List<DeviceHealth> devices) {
}
private record DeviceHealth(String name, String status, Instant lastSuccess, String lastError) {
}
private record DeviceReadings(String name, List<Reading> readings) {
}
private record Reading(String database, String measurement, Map<String, Double> fields, Instant time) {
}
// Unknown properties are ignored so the gateway can add fields without breaking us.
private static final ObjectMapper JSON = new ObjectMapper()
.registerModule(new JavaTimeModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private final URI baseUri;
private final Duration timeout;
private final HttpClient http;
public GatewayHttpClient(URI baseUri, Duration timeout) {
this.baseUri = baseUri;
this.timeout = timeout;
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
}
@Override
public GatewaySnapshot snapshot() throws IOException {
var health = JSON.readValue(get("/health", true), Health.class);
var readings = JSON.readValue(get("/snapshot", false), DeviceReadings[].class);
Map<String, List<Measurement>> measurements = Arrays.stream(readings)
.collect(Collectors.toMap(DeviceReadings::name, GatewayHttpClient::measurements));
var devices = health.devices().stream()
.map(device -> new DeviceSnapshot(device.name(), device.status().equals("UP"),
device.lastSuccess(), device.lastError(),
measurements.getOrDefault(device.name(), List.of())))
.toList();
return new GatewaySnapshot(health.version(), devices);
}
private static List<Measurement> measurements(DeviceReadings device) {
return device.readings().stream()
.map(r -> new Measurement(r.database(), r.measurement(), r.fields(), r.time()))
.toList();
}
/** A gateway with a device down answers /health with 503 and a valid body. */
private byte[] get(String path, boolean downIsValid) throws IOException {
var request = HttpRequest.newBuilder(baseUri.resolve(path)).timeout(timeout).GET().build();
try {
var response = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
int status = response.statusCode();
if (status != 200 && !(downIsValid && status == 503)) {
throw new IOException("gateway " + path + " answered " + status);
}
return response.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("interrupted while calling gateway " + path, e);
}
}
}
@@ -0,0 +1,174 @@
package home.backend.adapters.gateway;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import home.backend.application.GatewayBattery;
import home.backend.application.GatewayCommands;
import home.backend.application.GatewayRefresh;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView.Reserve;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.RefreshResult;
import home.backend.domain.RestoreResult;
import home.backend.domain.SetpointState;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Set;
/** Calls the gateway's {@code /commands}, {@code /battery} and {@code /refresh} with the shared token (S6). */
public class GatewayHttpCommands implements GatewayCommands, GatewayBattery, GatewayRefresh {
/** The gateway answers a verdict with 200 (applied), 422 (rejected) or 502 (failed). */
private static final Set<Integer> VERDICTS = Set.of(200, 422, 502);
/** Restore now: 200 restored, 404 nothing pending, 409 changed meanwhile, 429 too soon, 502 failed. */
private static final Set<Integer> RESTORE_VERDICTS = Set.of(200, 404, 409, 429, 502);
/** Battery change: 200 applied, 422 rejected, 409 stale or changed meanwhile, 429 too soon, 502 failed. */
private static final Set<Integer> BATTERY_VERDICTS = Set.of(200, 422, 409, 429, 502);
@JsonInclude(JsonInclude.Include.NON_NULL)
private record ScheduleRequest(Boolean enabled, List<SlotRequest> slots) {
}
@JsonInclude(JsonInclude.Include.NON_NULL)
private record SlotRequest(int slot, Integer start, Integer soc, Boolean gridCharge) {
}
private record ReserveRequest(int pct) {
}
@JsonInclude(JsonInclude.Include.NON_NULL)
private record Request(String setpoint, int value, Long minutes, Instant until) {
}
private record RestoreRequest(String setpoint) {
}
private record Renewal(int renewed) {
}
private static final ObjectMapper JSON = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private final URI endpoint;
private final URI renewEndpoint;
private final URI restoreEndpoint;
private final URI refreshEndpoint;
private final URI reserveEndpoint;
private final URI scheduleEndpoint;
private final String token;
private final Duration timeout;
private final HttpClient http;
/** @param token null when not configured: every call then fails without touching the network */
public GatewayHttpCommands(URI baseUri, String token, Duration timeout) {
this.endpoint = baseUri.resolve("/commands");
this.renewEndpoint = baseUri.resolve("/commands/renew");
this.restoreEndpoint = baseUri.resolve("/commands/restore");
this.refreshEndpoint = baseUri.resolve("/refresh");
this.reserveEndpoint = baseUri.resolve("/battery/reserve");
this.scheduleEndpoint = baseUri.resolve("/battery/schedule");
this.token = token;
this.timeout = timeout;
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
}
@Override
public CommandResult apply(Command command) throws IOException {
Long minutes = command.duration() == null ? null : command.duration().toMinutes();
byte[] body = JSON.writeValueAsBytes(new Request(command.setpoint(), command.value(), minutes, command.heldUntil()));
HttpRequest.Builder request = request().header("Content-Type", "application/json")
.header("X-Requested-By", command.requestedBy())
.POST(HttpRequest.BodyPublishers.ofByteArray(body));
return JSON.readValue(send(request, VERDICTS), CommandResult.class);
}
@Override
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
byte[] body = JSON.writeValueAsBytes(new RestoreRequest(setpoint));
var request = request(restoreEndpoint).header("Content-Type", "application/json")
.header("X-Requested-By", requestedBy).POST(HttpRequest.BodyPublishers.ofByteArray(body));
return JSON.readValue(send(request, RESTORE_VERDICTS), RestoreResult.class);
}
@Override
public SetpointState setpoints() throws IOException {
return JSON.readValue(send(request().GET(), Set.of(200)), SetpointState.class);
}
@Override
public int renewLeases() throws IOException {
var request = request(renewEndpoint).POST(HttpRequest.BodyPublishers.noBody());
return JSON.readValue(send(request, Set.of(200)), Renewal.class).renewed();
}
@Override
public RefreshResult refresh() throws IOException {
var request = request(refreshEndpoint).POST(HttpRequest.BodyPublishers.noBody());
return JSON.readValue(send(request, Set.of(200)), RefreshResult.class);
}
@Override
public Reserve reserve() throws IOException {
return JSON.readValue(send(request(reserveEndpoint).GET(), Set.of(200)), Reserve.class);
}
@Override
public BatteryResult changeSchedule(BatteryChange change, String requestedBy) throws IOException {
var slots = change.slots().stream().map(slot -> new SlotRequest(slot.slot(),
slot.from() == null ? null : slot.from().getHour() * 100 + slot.from().getMinute(),
slot.socPct(), slot.gridCharge())).toList();
return battery(scheduleEndpoint, new ScheduleRequest(change.enabled(), slots), requestedBy);
}
@Override
public BatteryResult changeReserve(int pct, String requestedBy) throws IOException {
return battery(reserveEndpoint, new ReserveRequest(pct), requestedBy);
}
private BatteryResult battery(URI uri, Object body, String requestedBy) throws IOException {
var request = request(uri).header("Content-Type", "application/json").header("X-Requested-By", requestedBy)
.POST(HttpRequest.BodyPublishers.ofByteArray(JSON.writeValueAsBytes(body)));
return JSON.readValue(send(request, BATTERY_VERDICTS), BatteryResult.class);
}
private HttpRequest.Builder request() throws IOException {
return request(endpoint);
}
private HttpRequest.Builder request(URI uri) throws IOException {
if (token == null) {
throw new IOException("GATEWAY_TOKEN is not set, commands are disabled");
}
return HttpRequest.newBuilder(uri).timeout(timeout).header("Authorization", "Bearer " + token);
}
private byte[] send(HttpRequest.Builder request, Set<Integer> accepted) throws IOException {
try {
var built = request.build();
var response = http.send(built, HttpResponse.BodyHandlers.ofByteArray());
if (!accepted.contains(response.statusCode())) {
throw new IOException("gateway " + built.uri().getPath() + " answered " + response.statusCode());
}
return response.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("interrupted while calling the gateway", e);
}
}
}
@@ -0,0 +1,487 @@
package home.backend.adapters.http;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import home.backend.adapters.auth.Authentication;
import home.backend.application.AuditService;
import home.backend.application.BatteryService;
import home.backend.application.EnergyDetailsService;
import home.backend.application.EnergyService;
import home.backend.application.GatewayRefresh;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService;
import home.backend.application.ProgramService;
import home.backend.application.StatsService;
import home.backend.application.PiSensorService;
import home.backend.application.SetpointService;
import home.backend.application.WeatherService;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.CommandResult.Status;
import home.backend.domain.PresetResult;
import home.backend.domain.RestoreResult;
import java.io.IOException;
import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/** The phone-facing JSON API under {@code /api/} and the static web UI. */
public class ApiServer {
private static final ObjectMapper JSON = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
// 45.5 or "45" must not silently become 45 in a request that writes to a heat pump.
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS);
private record ReserveRequest(Integer pct) {
}
private record SetpointRequest(String setpoint, Integer value, Double hours) {
}
/**
* {@code hours}: how long a timed preset lasts; {@code values}: the user's own value per setpoint of the
* preset, by setpoint name.
*/
private record PresetRequest(String until, Integer hours, Map<String, Integer> values) {
PresetRequest {
if (values == null) {
values = Map.of();
} else if (values.containsValue(null)) {
throw new IllegalArgumentException("a value must be a number");
}
}
}
private static final String UNREACHABLE = "gateway unreachable";
private static final String UNAVAILABLE = "battery changes are not available: gateway unreachable or not enabled";
private static final String WRITE_MARKER = "home-pump";
private static final String PRESETS_PATH = "/api/presets/";
private static final String SETPOINTS_PATH = "/api/setpoints/";
private static final Pattern RESTORE_PATH = Pattern.compile("/api/setpoints/([a-z_]{1,32})/restore");
private static final SecurityHeaders SECURITY_HEADERS = new SecurityHeaders();
/** A setpoint request is a few dozen bytes; anything near this is not from our UI. */
private static final int MAX_BODY_BYTES = 4096;
private static final int STOP_GRACE_SECONDS = 5;
private final HttpServer server;
private final SessionFilter sessionFilter;
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
/** Binds the port now; 0 picks a free one. */
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
EnergyDetailsService energyDetails, HealthService health,
HistoryService history, SetpointService setpoints, PiSensorService piSensor,
BatteryService battery, AuditService audit, StatsService stats, ProgramService program, WeatherService weather,
GatewayRefresh refresh, Authentication auth) throws IOException {
server = HttpServer.create(new InetSocketAddress(port), 0);
server.setExecutor(executor);
sessionFilter = new SessionFilter(auth);
// Everything outside the API, with the security headers too: the web UI is the frontend's (ADR-010).
open("/", exchange -> {
try (exchange) {
exchange.sendResponseHeaders(404, -1);
}
});
var signIn = new AuthEndpoints(auth);
openRoute("/api/auth/config", signIn::config);
openRoute("/api/auth/login", signIn::login);
openRoute("/api/auth/logout", signIn::logout);
openRoute("/api/auth/me", signIn::me);
openRoute("/api/auth/setup", signIn::setup);
openRoute("/api/auth/users", signIn::users);
openRoute("/api/auth/password", signIn::password);
open("/api/auth/users/", signIn::deleteUser);
openRoute("/api/auth/oidc/start", signIn::oidcStart);
openRoute("/api/auth/oidc/callback", signIn::oidcCallback);
// The container healthcheck: no session, nothing in the answer.
openRoute("/api/live", exchange -> handle(exchange, Map.of("GET", () -> {
exchange.sendResponseHeaders(200, 2);
exchange.getResponseBody().write("ok".getBytes(StandardCharsets.US_ASCII));
})));
api("/api/heat-pump", query -> heatPump.current());
api("/api/energy", query -> energy.current());
api("/api/energy/details", query -> energyDetails.current());
api("/api/battery", query -> battery.current());
route("/api/battery/schedule", exchange -> handle(exchange, Map.of(
"POST", () -> guarded(exchange, body -> postSchedule(exchange, body, battery)))));
route("/api/battery/reserve", exchange -> handle(exchange, Map.of(
"POST", () -> guarded(exchange, body -> postReserve(exchange, body, battery)))));
route("/api/refresh", exchange -> handle(exchange, Map.of(
"POST", () -> guarded(exchange, body -> postRefresh(exchange, refresh)))));
api("/api/pi-sensor", query -> piSensor.current());
api("/api/weather", query -> weather.current());
api("/api/health", query -> health.current());
route("/api/history", exchange -> handleHistory(exchange, history));
route("/api/audit", exchange -> handleAudit(exchange, audit));
route("/api/stats", exchange -> handleStats(exchange, stats));
route("/api/program", exchange -> handleProgram(exchange, program));
route("/api/setpoints", exchange -> handle(exchange, Map.of(
"GET", () -> getSetpoints(exchange, setpoints),
"POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints)))));
context(SETPOINTS_PATH, exchange -> {
var restore = RESTORE_PATH.matcher(exchange.getRequestURI().getPath());
if (restore.matches()) {
handle(exchange, Map.of("POST", () -> guarded(exchange,
body -> postRestore(exchange, restore.group(1), setpoints))));
} else {
try (exchange) {
exchange.sendResponseHeaders(404, -1);
}
}
});
context(PRESETS_PATH, exchange -> handle(exchange, Map.of(
"POST", () -> guarded(exchange, body -> postPreset(exchange, body, setpoints)))));
}
private void route(String path, HttpHandler handler) {
context(path, exactly(path, handler));
}
private void openRoute(String path, HttpHandler handler) {
open(path, exactly(path, handler));
}
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
private static HttpHandler exactly(String path, HttpHandler handler) {
return exchange -> {
if (path.equals(exchange.getRequestURI().getPath())) {
handler.handle(exchange);
} else {
try (exchange) {
exchange.sendResponseHeaders(404, -1);
}
}
};
}
/** Needs a session. */
private void context(String path, HttpHandler handler) {
var context = server.createContext(path, handler);
context.getFilters().add(SECURITY_HEADERS);
context.getFilters().add(sessionFilter);
}
/** Answers without a session. */
private void open(String path, HttpHandler handler) {
server.createContext(path, handler).getFilters().add(SECURITY_HEADERS);
}
public void start() {
server.start();
}
public void stop() {
server.stop(STOP_GRACE_SECONDS);
executor.shutdown();
}
public int port() {
return server.getAddress().getPort();
}
private interface Gateway {
Object get(Map<String, String> query) throws IOException;
}
/** Gateway trouble on a live-state endpoint is the gateway being unreachable: 503. */
private void api(String path, Gateway gateway) {
route(path, exchange -> handle(exchange, Map.of("GET", () -> {
try {
send(exchange, 200, gateway.get(Map.of()));
} catch (IOException e) {
send(exchange, 503, error(UNREACHABLE));
}
})));
}
/**
* A page on another site can make the browser send a simple POST with the user's cookies. It cannot
* add a custom header, set a JSON content type or fake the Origin, so a write needs all three.
*/
static void guarded(HttpExchange exchange, Write write) throws IOException {
var headers = exchange.getRequestHeaders();
String origin = headers.getFirst("Origin");
if (origin != null && !origin.endsWith("//" + headers.getFirst("Host"))) {
send(exchange, 403, error("cross-site request"));
} else if (!WRITE_MARKER.equals(headers.getFirst("X-Requested-With"))) {
send(exchange, 403, error("missing X-Requested-With: " + WRITE_MARKER));
} else if (!isJson(headers.getFirst("Content-Type"))) {
send(exchange, 415, error("expected Content-Type: application/json"));
} else {
byte[] body = exchange.getRequestBody().readNBytes(MAX_BODY_BYTES + 1);
if (body.length > MAX_BODY_BYTES) {
send(exchange, 413, error("request body over " + MAX_BODY_BYTES + " bytes"));
} else {
write.run(body);
}
}
}
private static boolean isJson(String contentType) {
return contentType != null && contentType.split(";")[0].trim().equalsIgnoreCase("application/json");
}
private static void getSetpoints(HttpExchange exchange, SetpointService setpoints) throws IOException {
try {
send(exchange, 200, setpoints.current());
} catch (IOException e) {
send(exchange, 503, error(UNREACHABLE));
}
}
private static void postSetpoint(HttpExchange exchange, byte[] body, SetpointService setpoints)
throws IOException {
try {
var request = JSON.readValue(body, SetpointRequest.class);
if (request == null || request.setpoint() == null || request.value() == null) {
throw new IllegalArgumentException("setpoint and value are required");
}
var result = setpoints.apply(request.setpoint(), request.value(), request.hours(), user(exchange));
send(exchange, httpStatus(result.status()), result);
} catch (JsonProcessingException | IllegalArgumentException e) {
send(exchange, 400, error("expected {\"setpoint\", \"value\", \"hours\"?}"));
} catch (IOException e) {
send(exchange, 503, error(UNREACHABLE));
}
}
private static void postSchedule(HttpExchange exchange, byte[] body, BatteryService battery) throws IOException {
try {
var change = JSON.readValue(body, BatteryChange.class);
if (change == null) {
throw new IllegalArgumentException("a change is required");
}
var result = battery.changeSchedule(change, user(exchange));
send(exchange, batteryHttpStatus(result.status()), result);
} catch (JsonProcessingException | IllegalArgumentException e) {
send(exchange, 400, error("expected {\"enabled\"?, \"slots\": [{\"slot\", \"from\"?: \"HH:MM\", "
+ "\"socPct\"?, \"gridCharge\"?}]}"));
} catch (IOException e) {
send(exchange, 503, error(UNAVAILABLE));
}
}
private static void postReserve(HttpExchange exchange, byte[] body, BatteryService battery) throws IOException {
try {
var request = JSON.readValue(body, ReserveRequest.class);
if (request == null || request.pct() == null) {
throw new IllegalArgumentException("pct is required");
}
var result = battery.changeReserve(request.pct(), user(exchange));
send(exchange, batteryHttpStatus(result.status()), result);
} catch (JsonProcessingException | IllegalArgumentException e) {
send(exchange, 400, error("expected {\"pct\"}"));
} catch (IOException e) {
send(exchange, 503, error(UNAVAILABLE));
}
}
private static void postRefresh(HttpExchange exchange, GatewayRefresh refresh) throws IOException {
try {
send(exchange, 200, refresh.refresh());
} catch (IOException e) {
send(exchange, 503, error(UNREACHABLE));
}
}
private static void postRestore(HttpExchange exchange, String setpoint, SetpointService setpoints)
throws IOException {
try {
var result = setpoints.restore(setpoint, user(exchange));
send(exchange, restoreHttpStatus(result.outcome()), result);
} catch (IOException e) {
send(exchange, 503, error(UNREACHABLE));
}
}
private static void postPreset(HttpExchange exchange, byte[] body, SetpointService setpoints)
throws IOException {
String id = exchange.getRequestURI().getPath().substring(PRESETS_PATH.length());
try {
var request = body.length == 0 ? new PresetRequest(null, null, null) : JSON.readValue(body, PresetRequest.class);
var until = request.until() == null ? null : OffsetDateTime.parse(request.until()).toInstant();
var result = setpoints.applyPreset(id, until, request.hours(), request.values(),
user(exchange));
if (result.isPresent()) {
send(exchange, presetHttpStatus(result.get().status()), result.get());
} else {
send(exchange, 404, error("no such preset"));
}
} catch (SetpointService.InvalidHoursException e) {
send(exchange, 400, error("invalid_hours"));
} catch (JsonProcessingException | DateTimeParseException | IllegalArgumentException e) {
send(exchange, 400, error("expected {\"until\": \"2026-11-02T18:00:00+01:00\"} for a preset that needs "
+ "an end time, else no body; optional \"values\": {\"cwu_comfort\": 12} within the limits "
+ "for setpoints of the preset"));
} catch (IOException e) {
send(exchange, 503, error(UNREACHABLE));
}
}
private static int httpStatus(Status status) {
return switch (status) {
case APPLIED -> 200;
case REJECTED -> 422;
case FAILED -> 502;
};
}
private static int batteryHttpStatus(BatteryResult.Status status) {
return switch (status) {
case APPLIED -> 200;
case REJECTED -> 422;
case STALE -> 409;
case TOO_SOON -> 429;
case FAILED -> 502;
};
}
private static int restoreHttpStatus(RestoreResult.Outcome outcome) {
return switch (outcome) {
case RESTORED -> 200;
case NOT_PENDING -> 404;
case CONFLICT -> 409;
case TOO_SOON -> 429;
case FAILED -> 502;
};
}
private static int presetHttpStatus(PresetResult.Status status) {
return switch (status) {
case APPLIED -> 200;
case REJECTED -> 422;
case FAILED, PARTIAL -> 502;
};
}
/** The signed-in user, set by the session filter. */
private static String user(HttpExchange exchange) {
return (String) exchange.getAttribute(SessionFilter.USER_ATTRIBUTE);
}
private void handleHistory(HttpExchange exchange, HistoryService history) throws IOException {
handle(exchange, Map.of("GET", () -> {
Map<String, String> query = parseQuery(exchange.getRequestURI().getRawQuery());
try {
send(exchange, 200, history.history(query.get("series"), query.get("range")));
} catch (IllegalArgumentException e) {
send(exchange, 400, error(e.getMessage()));
} catch (IOException e) {
send(exchange, 502, error("history unavailable"));
}
}));
}
private void handleAudit(HttpExchange exchange, AuditService audit) throws IOException {
handle(exchange, Map.of("GET", () -> {
Map<String, String> query = parseQuery(exchange.getRequestURI().getRawQuery());
try {
send(exchange, 200, audit.audit(query.get("range")));
} catch (IllegalArgumentException e) {
send(exchange, 400, error(e.getMessage()));
} catch (IOException e) {
send(exchange, 502, error("audit unavailable"));
}
}));
}
private void handleStats(HttpExchange exchange, StatsService stats) throws IOException {
handle(exchange, Map.of("GET", () -> {
try {
send(exchange, 200, stats.stats(parseQuery(exchange.getRequestURI().getRawQuery()).get("range")));
} catch (IllegalArgumentException e) {
send(exchange, 400, error(e.getMessage()));
} catch (IOException e) {
send(exchange, 502, error("stats unavailable"));
}
}));
}
private void handleProgram(HttpExchange exchange, ProgramService program) throws IOException {
handle(exchange, Map.of("GET", () -> {
try {
send(exchange, 200, program.program(parseQuery(exchange.getRequestURI().getRawQuery()).get("days")));
} catch (IllegalArgumentException e) {
send(exchange, 400, error(e.getMessage()));
} catch (IOException e) {
send(exchange, 502, error("program unavailable"));
}
}));
}
interface Action {
void run() throws IOException;
}
interface Write {
void run(byte[] body) throws IOException;
}
static void handle(HttpExchange exchange, Map<String, Action> byMethod) throws IOException {
try (exchange) {
Action action = byMethod.get(exchange.getRequestMethod());
if (action == null) {
exchange.sendResponseHeaders(405, -1);
return;
}
try {
action.run();
} catch (RuntimeException e) {
// A gateway payload the client could not map (duplicate device, missing status): answer, do not drop.
System.err.println("home-pump-backend: " + exchange.getRequestURI().getPath() + ": " + e);
if (exchange.getResponseCode() == -1) {
send(exchange, 502, error("bad gateway response"));
}
}
}
}
static void send(HttpExchange exchange, int status, Object body) throws IOException {
byte[] bytes = JSON.writeValueAsBytes(body);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.getResponseHeaders().set("Cache-Control", "no-store");
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
}
private static Map<String, String> error(String message) {
return Map.of("error", message);
}
static Map<String, String> parseQuery(String raw) {
Map<String, String> query = new HashMap<>();
if (raw == null) {
return query;
}
for (String pair : raw.split("&")) {
String[] kv = pair.split("=", 2);
query.put(decode(kv[0]), kv.length == 2 ? decode(kv[1]) : "");
}
return query;
}
private static String decode(String s) {
return URLDecoder.decode(s, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,284 @@
package home.backend.adapters.http;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import home.backend.adapters.auth.Authentication;
import home.backend.adapters.auth.OidcException;
import home.backend.adapters.auth.Session;
import java.io.IOException;
import java.util.Map;
import java.util.regex.Pattern;
/** The endpoints under {@code /api/auth/}: sign-in needs no session, managing accounts does. */
final class AuthEndpoints {
private record LoginRequest(String username, String password) {
}
private record PasswordRequest(String current, @JsonProperty("new") String next) {
}
private static final Pattern DELETE_PATH = Pattern.compile("/api/auth/users/([^/]{1,64})/delete");
private static final ObjectMapper JSON = new ObjectMapper();
private static final int MAX_CREDENTIAL_LENGTH = 256;
private final Authentication auth;
AuthEndpoints(Authentication auth) {
this.auth = auth;
}
void config(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("GET", () -> ApiServer.send(exchange, 200,
Map.of("local", auth.localEnabled(), "oidc", auth.oidcEnabled(),
"setup", auth.setupNeeded()))));
}
void login(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> postLogin(exchange, body))));
}
void logout(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> {
String cookie = auth.clearedSessionCookie();
if (!cookie.isEmpty()) {
exchange.getResponseHeaders().add("Set-Cookie", cookie);
}
exchange.sendResponseHeaders(204, -1);
})));
}
void me(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("GET", () -> {
var session = auth.session(SessionFilter.cookies(exchange));
if (session.isPresent()) {
ApiServer.send(exchange, 200, user(session.get()));
} else {
ApiServer.send(exchange, 401, Map.of("error", "unauthenticated"));
}
}));
}
void oidcStart(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("GET", () -> {
if (!auth.oidcEnabled()) {
exchange.sendResponseHeaders(404, -1);
return;
}
try {
var start = auth.oidc().start();
exchange.getResponseHeaders().add("Set-Cookie", start.setCookie());
redirect(exchange, start.location().toString());
} catch (OidcException e) {
redirectWithError(exchange, e);
}
}));
}
void oidcCallback(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("GET", () -> {
if (!auth.oidcEnabled()) {
exchange.sendResponseHeaders(404, -1);
return;
}
exchange.getResponseHeaders().add("Set-Cookie", auth.oidc().clearCookie());
try {
String name = auth.oidc().callback(ApiServer.parseQuery(exchange.getRequestURI().getRawQuery()),
SessionFilter.cookies(exchange));
exchange.getResponseHeaders().add("Set-Cookie", auth.sessionCookie(name, "oidc"));
redirect(exchange, "/");
} catch (OidcException e) {
redirectWithError(exchange, e);
}
}));
}
private void postLogin(HttpExchange exchange, byte[] body) throws IOException {
if (!auth.localEnabled()) {
ApiServer.send(exchange, 404, Map.of("error", "local login is not enabled"));
return;
}
LoginRequest request = credentials(exchange, body);
if (request == null) {
return;
}
String ip = ClientIp.of(exchange, auth.proxyHops());
var login = auth.login(request.username(), request.password().toCharArray(), ip);
switch (login.status()) {
case OK -> {
exchange.getResponseHeaders().add("Set-Cookie", auth.sessionCookie(request.username(), "local"));
ApiServer.send(exchange, 200, Map.of("user", request.username(), "method", "local"));
}
case INVALID -> {
System.err.println("home-pump-backend: failed login from " + ip);
ApiServer.send(exchange, 401, Map.of("error", "invalid"));
}
case THROTTLED -> throttled(exchange, login);
}
}
/** The username and password of the body, or null after answering 400. */
private static LoginRequest credentials(HttpExchange exchange, byte[] body) throws IOException {
LoginRequest request;
try {
request = JSON.readValue(body, LoginRequest.class);
} catch (JsonProcessingException e) {
request = null;
}
if (request == null || request.username() == null || request.password() == null
|| request.username().length() > MAX_CREDENTIAL_LENGTH
|| request.password().length() > MAX_CREDENTIAL_LENGTH) {
ApiServer.send(exchange, 400, Map.of("error", "expected {\"username\", \"password\"}"));
return null;
}
return request;
}
private static void throttled(HttpExchange exchange, Authentication.Login login) throws IOException {
exchange.getResponseHeaders().set("Retry-After", String.valueOf(login.retryAfterSeconds()));
ApiServer.send(exchange, 429, Map.of("error", "throttled", "retryAfterSeconds", login.retryAfterSeconds()));
}
/** True when the name and password may become an account; else answers 400. */
private static boolean acceptable(HttpExchange exchange, String username, String password) throws IOException {
if (!Session.isValidUser(username)) {
ApiServer.send(exchange, 400, Map.of("error", "invalid_username"));
return false;
}
if (!Authentication.strongEnough(password)) {
ApiServer.send(exchange, 400, Map.of("error", "weak_password"));
return false;
}
return true;
}
void setup(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> {
if (!auth.localEnabled()) {
ApiServer.send(exchange, 404, Map.of("error", "local login is not enabled"));
return;
}
LoginRequest request = credentials(exchange, body);
if (request == null) {
return;
}
if (!auth.setupNeeded()) {
ApiServer.send(exchange, 409, Map.of("error", "setup_done"));
return;
}
if (!acceptable(exchange, request.username(), request.password())) {
return;
}
var result = auth.setup(request.username(), request.password().toCharArray(),
ClientIp.of(exchange, auth.proxyHops()));
switch (result.status()) {
case OK -> {
exchange.getResponseHeaders().add("Set-Cookie", auth.sessionCookie(request.username(), "local"));
ApiServer.send(exchange, 200, Map.of("user", request.username(), "method", "local"));
}
case INVALID -> ApiServer.send(exchange, 409, Map.of("error", "setup_done"));
case THROTTLED -> throttled(exchange, result);
}
})));
}
/** Runs the action for a signed-in user of a configured local store; else answers 401 or 404. */
private void asUser(HttpExchange exchange, SignedIn action) throws IOException {
var session = auth.session(SessionFilter.cookies(exchange));
if (session.isEmpty()) {
ApiServer.send(exchange, 401, Map.of("error", "unauthenticated"));
} else if (!auth.localEnabled()) {
ApiServer.send(exchange, 404, Map.of("error", "local login is not enabled"));
} else {
action.run(session.get());
}
}
private interface SignedIn {
void run(Session session) throws IOException;
}
void users(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of(
"GET", () -> asUser(exchange, session -> ApiServer.send(exchange, 200,
auth.usernames().stream().map(name -> Map.of("username", name)).toList())),
"POST", () -> ApiServer.guarded(exchange, body -> asUser(exchange, session -> {
LoginRequest request = credentials(exchange, body);
if (request == null || !acceptable(exchange, request.username(), request.password())) {
return;
}
if (auth.addUser(session.user(), request.username(), request.password().toCharArray())) {
ApiServer.send(exchange, 200, Map.of("username", request.username()));
} else {
ApiServer.send(exchange, 409, Map.of("error", "exists"));
}
}))));
}
void password(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> asUser(exchange, session -> {
PasswordRequest request;
try {
request = JSON.readValue(body, PasswordRequest.class);
} catch (JsonProcessingException e) {
request = null;
}
if (request == null || request.current() == null || request.next() == null
|| request.current().length() > MAX_CREDENTIAL_LENGTH
|| request.next().length() > MAX_CREDENTIAL_LENGTH) {
ApiServer.send(exchange, 400, Map.of("error", "expected {\"current\", \"new\"}"));
} else if (!Authentication.LOCAL.equals(session.method())) {
ApiServer.send(exchange, 403, Map.of("error", "local_only"));
} else if (!Authentication.strongEnough(request.next())) {
ApiServer.send(exchange, 400, Map.of("error", "weak_password"));
} else {
var result = auth.changePassword(session.user(), request.current().toCharArray(),
request.next().toCharArray(), ClientIp.of(exchange, auth.proxyHops()));
switch (result.status()) {
case OK -> exchange.sendResponseHeaders(204, -1);
case INVALID -> ApiServer.send(exchange, 401, Map.of("error", "invalid"));
case THROTTLED -> throttled(exchange, result);
}
}
}))));
}
void deleteUser(HttpExchange exchange) throws IOException {
ApiServer.handle(exchange, Map.of("POST", () -> ApiServer.guarded(exchange, body -> asUser(exchange, session -> {
var path = DELETE_PATH.matcher(exchange.getRequestURI().getRawPath());
if (!path.matches()) {
exchange.sendResponseHeaders(404, -1);
return;
}
String name = path.group(1);
switch (auth.removeUser(session.user(), name)) {
case UNKNOWN -> ApiServer.send(exchange, 404, Map.of("error", "unknown_user"));
case LAST -> ApiServer.send(exchange, 409, Map.of("error", "last_user"));
case REMOVED -> {
if (name.equals(session.user())) {
exchange.getResponseHeaders().add("Set-Cookie", auth.clearedSessionCookie());
}
exchange.sendResponseHeaders(204, -1);
}
}
}))));
}
private static Map<String, String> user(Session session) {
return Map.of("user", session.user(), "method", session.method());
}
private static void redirectWithError(HttpExchange exchange, OidcException e) throws IOException {
System.err.println("home-pump-backend: OIDC sign-in failed: " + e.getMessage());
redirect(exchange, "/#login?error=" + e.reason().code());
}
private static void redirect(HttpExchange exchange, String location) throws IOException {
exchange.getResponseHeaders().set("Location", location);
exchange.getResponseHeaders().set("Cache-Control", "no-store");
exchange.sendResponseHeaders(302, -1);
}
}
@@ -0,0 +1,29 @@
package home.backend.adapters.http;
import com.sun.net.httpserver.HttpExchange;
import java.util.Arrays;
import java.util.List;
/**
* The client's address for the login throttle. Each trusted proxy appends the address it saw to
* X-Forwarded-For, so the entry {@code hops} from the right is the first one no trusted proxy vouches for
* beyond that; anything the client put in front of it is ignored. Without enough entries the request did not
* come through the proxies, and the socket's address is used.
*/
final class ClientIp {
private ClientIp() {
}
static String of(HttpExchange exchange, int hops) {
List<String> values = exchange.getRequestHeaders().get("X-Forwarded-For");
if (values != null) {
List<String> chain = Arrays.stream(String.join(",", values).split(",")).map(String::trim).toList();
if (chain.size() >= hops && !chain.get(chain.size() - hops).isEmpty()) {
return chain.get(chain.size() - hops);
}
}
return exchange.getRemoteAddress().getAddress().getHostAddress();
}
}
@@ -0,0 +1,28 @@
package home.backend.adapters.http;
import com.sun.net.httpserver.Filter;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
/** Sets the browser hardening headers on every response, whatever handler answers. */
final class SecurityHeaders extends Filter {
private static final String POLICY = "default-src 'none'; script-src 'self'; style-src 'self'; "
+ "img-src 'self' data:; font-src 'self'; connect-src 'self'; base-uri 'none'; "
+ "form-action 'self'; frame-ancestors 'none'";
@Override
public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
var headers = exchange.getResponseHeaders();
headers.set("Content-Security-Policy", POLICY);
headers.set("X-Content-Type-Options", "nosniff");
headers.set("Referrer-Policy", "no-referrer");
chain.doFilter(exchange);
}
@Override
public String description() {
return "security headers";
}
}
@@ -0,0 +1,45 @@
package home.backend.adapters.http;
import com.sun.net.httpserver.Filter;
import com.sun.net.httpserver.HttpExchange;
import home.backend.adapters.auth.Authentication;
import home.backend.adapters.auth.Session;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;
/** Lets a request through only with a valid session, and hands the user's name on to the handlers. */
final class SessionFilter extends Filter {
static final String USER_ATTRIBUTE = "user";
private final Authentication auth;
SessionFilter(Authentication auth) {
this.auth = auth;
}
@Override
public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
Optional<Session> session = auth.session(cookies(exchange));
if (session.isPresent()) {
exchange.setAttribute(USER_ATTRIBUTE, session.get().user());
chain.doFilter(exchange);
} else {
try (exchange) {
ApiServer.send(exchange, 401, Map.of("error", "unauthenticated"));
}
}
}
static String cookies(HttpExchange exchange) {
var values = exchange.getRequestHeaders().get("Cookie");
return values == null ? null : String.join("; ", values);
}
@Override
public String description() {
return "session check";
}
}
@@ -0,0 +1,88 @@
package home.backend.adapters.influx;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.backend.application.AuditRepository;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** The gateway's audit measurements (S3) read from InfluxDB; never written. */
public final class InfluxAuditRepository implements AuditRepository {
private static final ObjectMapper JSON = new ObjectMapper();
private final InfluxClient influx;
public InfluxAuditRepository(InfluxSettings settings) {
this.influx = new InfluxClient(settings);
}
@Override
public List<Row> heatPump(Instant from, Instant to) throws IOException {
return rows("Dom_Pompa", "setpoint_audit", "setpoint", from, to);
}
@Override
public List<Row> battery(Instant from, Instant to) throws IOException {
return rows("electricity", "deye_settings_audit", "register", from, to);
}
private List<Row> rows(String database, String measurement, String targetTag, Instant from, Instant to)
throws IOException {
String query = "SELECT * FROM \"" + measurement + "\" WHERE time >= " + from.toEpochMilli()
+ "ms AND time < " + to.toEpochMilli() + "ms ORDER BY time DESC";
var rows = new ArrayList<Row>();
for (JsonNode result : results(influx.query(database, query))) {
for (JsonNode series : result.path("series")) {
for (JsonNode values : series.path("values")) {
rows.add(row(series.path("columns"), values, targetTag));
}
}
}
return rows;
}
private static JsonNode results(String body) throws IOException {
JsonNode root;
try {
root = JSON.readTree(body);
} catch (JsonProcessingException e) {
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
}
failOnError(root);
for (JsonNode result : root.path("results")) {
failOnError(result);
}
return root.path("results");
}
private static void failOnError(JsonNode node) throws IOException {
if (node.hasNonNull("error")) {
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
}
}
private static Row row(JsonNode columns, JsonNode values, String targetTag) {
Map<String, JsonNode> byName = new HashMap<>();
for (int i = 0; i < columns.size(); i++) {
byName.put(columns.get(i).asText(), values.get(i));
}
return new Row(Instant.ofEpochMilli(byName.get("time").asLong()), text(byName.get(targetTag)),
text(byName.get("requested_by")), text(byName.get("status")),
number(byName.get("old_value")), number(byName.get("new_value")));
}
private static String text(JsonNode node) {
return node == null || node.isNull() ? "" : node.asText();
}
private static Double number(JsonNode node) {
return node == null || !node.isNumber() ? null : node.asDouble();
}
}
@@ -0,0 +1,47 @@
package home.backend.adapters.influx;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
/** Read-only queries to InfluxDB 1.x over its HTTP {@code /query} endpoint; times come back in epoch ms. */
final class InfluxClient {
private final InfluxSettings settings;
private final HttpClient http;
InfluxClient(InfluxSettings settings) {
this.settings = settings;
this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build();
}
String query(String database, String query) throws IOException {
HttpRequest request = HttpRequest.newBuilder(queryUri(database, query))
.timeout(settings.requestTimeout())
.GET()
.build();
try {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("InfluxDB answered HTTP " + response.statusCode());
}
return response.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("InfluxDB request interrupted", e);
}
}
private URI queryUri(String database, String query) {
String base = settings.baseUri().toString().replaceAll("/+$", "");
return URI.create(base + "/query?db=" + encode(database) + "&epoch=ms&q=" + encode(query));
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,69 @@
package home.backend.adapters.influx;
import home.backend.application.HeatPumpSamples;
import home.backend.application.HistoryRepository;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
/** History from InfluxDB 1.x over its HTTP {@code /query} endpoint (ADR-005, ADR-008). */
public final class InfluxHistoryRepository implements HistoryRepository, HeatPumpSamples {
private final InfluxClient influx;
public InfluxHistoryRepository(InfluxSettings settings) {
this.influx = new InfluxClient(settings);
}
@Override
public List<Point> history(Series series, Instant from, Instant to, int maxPoints) throws IOException {
if (maxPoints < 1 || !from.isBefore(to)) {
throw new IllegalArgumentException("need maxPoints >= 1 and from < to");
}
long stepMinutes = stepMinutes(from, to, maxPoints);
Map<Long, Double> summed = new TreeMap<>();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var parts = SeriesSources.of(series).stream()
.map(source -> executor.submit(() -> part(source, from, to, stepMinutes)))
.toList();
// Summed in source order, so the result does not depend on which answer comes first.
for (var part : parts) {
// A part without data is absent, not zero, so a bucket is kept if any part has it.
part.get().forEach((t, v) -> summed.merge(t, v, Double::sum));
}
} catch (ExecutionException e) {
throw e.getCause() instanceof IOException io ? io : new IOException(e.getCause());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("InfluxDB request interrupted", e);
}
return summed.entrySet().stream()
.map(e -> new Point(Instant.ofEpochMilli(e.getKey()), e.getValue()))
.toList();
}
@Override
public List<HeatPumpSample> samples(Instant from, Instant to) throws IOException {
String query = InfluxSamples.query(from.toEpochMilli(), to.toEpochMilli());
return InfluxSamples.parse(influx.query(SeriesSources.LEGACY, query));
}
private Map<Long, Double> part(Source source, Instant from, Instant to, long stepMinutes) throws IOException {
String query = source.query(from.toEpochMilli(), to.toEpochMilli(), stepMinutes);
return InfluxResponse.parse(influx.query(source.database(), query));
}
private static long stepMinutes(Instant from, Instant to, int maxPoints) {
long stepMillis = Math.ceilDiv(Duration.between(from, to).toMillis(), maxPoints);
return Math.max(1, Math.ceilDiv(stepMillis, Duration.ofMinutes(1).toMillis()));
}
}
@@ -0,0 +1,51 @@
package home.backend.adapters.influx;
import home.backend.application.PvEnergyRepository;
import java.io.IOException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Map;
import java.util.TreeMap;
/** Daily PV energy: the Deye's and the Fronius's own day counters, each read as the day's maximum. */
public final class InfluxPvEnergy implements PvEnergyRepository {
private static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
private static final String DATABASE = "electricity";
private final InfluxClient influx;
public InfluxPvEnergy(InfluxSettings settings) {
this.influx = new InfluxClient(settings);
}
@Override
public Map<LocalDate, Double> dailyKwh(LocalDate from, LocalDate to) throws IOException {
Map<LocalDate, Double> deye = dailyMax("deye", "pv_today_kwh", from, to);
Map<LocalDate, Double> fronius = dailyMax("fronius", "energy_day_wh", from, to);
Map<LocalDate, Double> total = new TreeMap<>();
deye.forEach((day, kwh) -> {
Double wh = fronius.get(day);
if (wh != null) {
total.put(day, kwh + wh / 1000);
}
});
return total;
}
private Map<LocalDate, Double> dailyMax(String measurement, String field, LocalDate from, LocalDate to)
throws IOException {
String query = "SELECT max(\"" + field + "\") FROM \"" + measurement + "\" WHERE time >= "
+ millis(from) + "ms AND time < " + millis(to) + "ms GROUP BY time(1d) fill(none) tz('Europe/Warsaw')";
Map<LocalDate, Double> days = new TreeMap<>();
InfluxResponse.parse(influx.query(DATABASE, query)).forEach((start, max) ->
days.put(Instant.ofEpochMilli(start).atZone(ZONE).toLocalDate(), max));
return days;
}
private static long millis(LocalDate day) {
return day.atStartOfDay(ZONE).toInstant().toEpochMilli();
}
}
@@ -0,0 +1,50 @@
package home.backend.adapters.influx;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.Map;
import java.util.TreeMap;
/** The {@code time, mean} rows of an InfluxDB 1.x query response. */
final class InfluxResponse {
private static final ObjectMapper JSON = new ObjectMapper();
private InfluxResponse() {
}
/** Mean per bucket start in epoch milliseconds, ascending; empty when there is no data. */
static Map<Long, Double> parse(String body) throws IOException {
JsonNode root = read(body);
failOnError(root);
Map<Long, Double> means = new TreeMap<>();
for (JsonNode result : root.path("results")) {
failOnError(result);
for (JsonNode series : result.path("series")) {
for (JsonNode row : series.path("values")) {
if (row.size() >= 2 && row.get(1).isNumber()) {
means.put(row.get(0).asLong(), row.get(1).asDouble());
}
}
}
}
return means;
}
private static JsonNode read(String body) throws IOException {
try {
return JSON.readTree(body);
} catch (JsonProcessingException e) {
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
}
}
private static void failOnError(JsonNode node) throws IOException {
if (node.hasNonNull("error")) {
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
}
}
}
@@ -0,0 +1,74 @@
package home.backend.adapters.influx;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.HeatPumpView.Setpoints;
import home.backend.domain.HeatPumpView.Targets;
import home.backend.domain.Periods;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
/** The raw {@code heat_pump} rows of an InfluxDB 1.x query. */
final class InfluxSamples {
private static final ObjectMapper JSON = new ObjectMapper();
private InfluxSamples() {
}
static String query(long fromMs, long toMs) {
return "SELECT output_compressor, output_cwu, output_co, outdoor_temp, cwu_temp,"
+ " program, target_room_temp, target_cwu_temp, setpoint_co_economy, setpoint_co_comfort,"
+ " setpoint_cwu_economy, setpoint_cwu_comfort"
+ " FROM \"heat_pump\" WHERE time >= " + fromMs + "ms AND time < " + toMs + "ms";
}
/** The rows of {@link #query}, as returned (ascending); empty when there is no data. */
static List<HeatPumpSample> parse(String body) throws IOException {
JsonNode root;
try {
root = JSON.readTree(body);
} catch (JsonProcessingException e) {
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
}
failOnError(root);
List<HeatPumpSample> samples = new ArrayList<>();
for (JsonNode result : root.path("results")) {
failOnError(result);
for (JsonNode series : result.path("series")) {
for (JsonNode row : series.path("values")) {
samples.add(new HeatPumpSample(Instant.ofEpochMilli(row.get(0).asLong()),
number(row, 1), number(row, 2), number(row, 3),
number(row, 4), number(row, 5), periods(row)));
}
}
}
return samples;
}
private static Periods periods(JsonNode row) {
var targets = new Targets(number(row, 7), null, number(row, 8));
var setpoints = new Setpoints(whole(row, 9), whole(row, 10), whole(row, 11), whole(row, 12));
return Periods.of(whole(row, 6), targets, setpoints);
}
private static Integer whole(JsonNode row, int column) {
Double value = number(row, column);
return value == null ? null : (int) Math.round(value);
}
private static Double number(JsonNode row, int column) {
return row.get(column).isNumber() ? row.get(column).asDouble() : null;
}
private static void failOnError(JsonNode node) throws IOException {
if (node.hasNonNull("error")) {
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
}
}
}
@@ -0,0 +1,7 @@
package home.backend.adapters.influx;
import java.net.URI;
import java.time.Duration;
public record InfluxSettings(URI baseUri, Duration requestTimeout) {
}
@@ -0,0 +1,44 @@
package home.backend.adapters.influx;
import home.backend.domain.Series;
import java.util.List;
import java.util.Map;
/** Where each series lives in InfluxDB. A fixed table: no query is built from user input. */
final class SeriesSources {
static final String LEGACY = "Dom_Pompa";
private static final String ELECTRICITY = "electricity";
private static final Map<Series, List<Source>> SOURCES = Map.ofEntries(
Map.entry(Series.OUTDOOR_TEMP, List.of(pompa("External temperature"))),
Map.entry(Series.INDOOR_TEMP, List.of(pompa("Temp wew"))),
Map.entry(Series.CO_BUFFER_TEMP, List.of(pompa("Temp co"))),
Map.entry(Series.CWU_TEMP, List.of(pompa("Temp cwu"))),
Map.entry(Series.GROUND_IN_TEMP, List.of(pompa("Temp in"))),
Map.entry(Series.GROUND_OUT_TEMP, List.of(pompa("Temp out"))),
Map.entry(Series.PUMP_OUTLET_TEMP, List.of(pompa("Temp pc out"))),
Map.entry(Series.GRID_POWER, List.of(electricity("PRAD2", "total_power_active"))),
Map.entry(Series.PV_POWER, List.of(
electricity("deye", "pv1_power_w"),
electricity("deye", "pv2_power_w"),
electricity("fronius", "pv_power_w"))),
Map.entry(Series.LOAD_POWER, List.of(electricity("deye", "load_power_w"))),
Map.entry(Series.BATTERY_SOC, List.of(electricity("deye", "battery_soc_pct"))));
private SeriesSources() {
}
static List<Source> of(Series series) {
return SOURCES.get(series);
}
private static Source pompa(String field) {
return new Source(LEGACY, "POMPA", field);
}
private static Source electricity(String measurement, String field) {
return new Source(ELECTRICITY, measurement, field);
}
}
@@ -0,0 +1,15 @@
package home.backend.adapters.influx;
/** One field of one measurement; the parts of a series are summed. */
record Source(String database, String measurement, String field) {
String query(long fromMs, long toMs, long stepMinutes) {
return "SELECT mean(" + quote(field) + ") FROM " + quote(measurement)
+ " WHERE time >= " + fromMs + "ms AND time < " + toMs + "ms"
+ " GROUP BY time(" + stepMinutes + "m) fill(none)";
}
private static String quote(String identifier) {
return '"' + identifier.replace("\\", "\\\\").replace("\"", "\\\"") + '"';
}
}
@@ -0,0 +1,42 @@
package home.backend.adapters.weather;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
/** GET of a public JSON document, with a timeout on connecting and on the whole request. */
final class HttpJson {
private static final ObjectMapper JSON = new ObjectMapper();
private final HttpClient http;
private final Duration timeout;
HttpJson(Duration timeout) {
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
this.timeout = timeout;
}
JsonNode get(URI uri) throws IOException {
HttpRequest request = HttpRequest.newBuilder(uri).timeout(timeout).GET().build();
try {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException(uri.getHost() + " answered HTTP " + response.statusCode());
}
return JSON.readTree(response.body());
} catch (JsonProcessingException e) {
throw new IOException(uri.getHost() + " returned invalid JSON: " + e.getOriginalMessage(), e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("request to " + uri.getHost() + " interrupted", e);
}
}
}
@@ -0,0 +1,76 @@
package home.backend.adapters.weather;
import com.fasterxml.jackson.databind.JsonNode;
import home.backend.application.WarningSource;
import home.backend.domain.WeatherView.Warning;
import java.io.IOException;
import java.net.URI;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* IMGW's public list of meteorological warnings, kept to those naming one of our TERYT areas. A warning
* that is malformed (no degree or times) is left out rather than failing the others.
*/
public final class ImgwClient implements WarningSource {
private static final ZoneId POLAND = ZoneId.of("Europe/Warsaw");
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final HttpJson http;
private final URI uri;
private final List<String> teryt;
public ImgwClient(URI baseUri, WeatherSettings place, Duration timeout) {
this.http = new HttpJson(timeout);
this.uri = URI.create(baseUri.toString().replaceAll("/+$", "") + "/api/data/warningsmeteo");
this.teryt = place.teryt();
}
@Override
public List<Warning> warnings() throws IOException {
JsonNode root = http.get(uri);
if (!root.isArray()) {
throw new IOException("IMGW returned no list of warnings");
}
List<Warning> ours = new ArrayList<>();
for (JsonNode item : root) {
if (covers(item.path("teryt"))) {
warning(item).ifPresent(ours::add);
}
}
return ours;
}
private boolean covers(JsonNode codes) {
for (JsonNode code : codes) {
if (teryt.contains(code.asText())) {
return true;
}
}
return false;
}
private static Optional<Warning> warning(JsonNode item) {
try {
return Optional.of(new Warning(item.path("nazwa_zdarzenia").asText(),
Integer.parseInt(item.path("stopien").asText()),
time(item.path("obowiazuje_od")), time(item.path("obowiazuje_do")),
item.path("tresc").asText()));
} catch (NumberFormatException | DateTimeException e) {
return Optional.empty();
}
}
private static OffsetDateTime time(JsonNode text) {
return LocalDateTime.parse(text.asText(), TIME).atZone(POLAND).toOffsetDateTime();
}
}
@@ -0,0 +1,68 @@
package home.backend.adapters.weather;
import com.fasterxml.jackson.databind.JsonNode;
import home.backend.application.ForecastSource;
import home.backend.domain.Forecast;
import home.backend.domain.Forecast.DailyForecast;
import java.io.IOException;
import java.net.URI;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.LocalDate;
import java.util.Map;
import java.util.TreeMap;
/** Open-Meteo (free, no key): daily values for the past 14 days and the next 4, and the temperature now. */
public final class OpenMeteoClient implements ForecastSource {
private static final String DAILY = "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,"
+ "shortwave_radiation_sum,sunshine_duration";
private final HttpJson http;
private final URI uri;
public OpenMeteoClient(URI baseUri, WeatherSettings place, Duration timeout) {
this.http = new HttpJson(timeout);
this.uri = URI.create(baseUri.toString().replaceAll("/+$", "") + "/v1/forecast?latitude="
+ place.latitude() + "&longitude=" + place.longitude() + "&daily=" + DAILY
+ "&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();
}
}
@@ -0,0 +1,157 @@
package home.backend.application;
import home.backend.application.SnapshotFields.Live;
import home.backend.domain.EnergyDetails;
import home.backend.domain.EnergyDetails.Device;
import home.backend.domain.EnergyDetails.Group;
import home.backend.domain.EnergyDetails.Item;
import home.backend.domain.GatewaySnapshot;
import java.io.IOException;
import java.time.Clock;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.IntStream;
/**
* F7 details: the Deye, Fronius and house meter fields worth a look, grouped and labelled. The
* catalog below is the only place that knows a field's label, unit and place; showing a new field
* is one line here.
*/
public class EnergyDetailsService {
/** A gateway field shown as {@code label}; {@code scale} turns the stored unit into {@code unit}. */
private record Field(String key, String label, String unit, int digits, double scale) {
}
private record Spec(String id, String name, String measurement, List<GroupSpec> groups) {
}
/** {@code measurement} is null for the device's own one. */
private record GroupSpec(String title, String measurement, List<Field> fields) {
}
// Each helper returns the fields it stands for, so a group can mix single fields and phase triples.
private static Field[] field(String key, String label, String unit, int digits) {
return new Field[] {new Field(key, label, unit, digits, 1)};
}
private static Field[] kwhFromWh(String key, String label, int digits) {
return new Field[] {new Field(key, label, "kWh", digits, 0.001)};
}
/** L1 to L3 of one quantity; {@code key} holds %d for the phase number. */
private static Field[] phases(String key, String label, String unit, int digits) {
return IntStream.rangeClosed(1, 3)
.mapToObj(n -> new Field(key.formatted(n), label + " L" + n, unit, digits, 1)).toArray(Field[]::new);
}
private static GroupSpec group(String title, Field[]... parts) {
return groupOf(null, title, parts);
}
/** A group whose fields come from another measurement of the same device. */
private static GroupSpec groupOf(String measurement, String title, Field[]... parts) {
return new GroupSpec(title, measurement, Arrays.stream(parts).flatMap(Arrays::stream).toList());
}
private static final List<Spec> CATALOG = List.of(
new Spec("deye", "Falownik Deye", "deye", List.of(
group("Bateria",
field("battery_soc_pct", "Naładowanie", "%", 0),
field("battery_power_w", "Moc", "W", 0),
field("battery_voltage_v", "Napięcie", "V", 1),
field("battery_current_a", "Prąd", "A", 1),
field("battery_temperature_c", "Temperatura", "°C", 1)),
group("Sieć",
field("grid_power_w", "Moc razem", "W", 0),
phases("grid_power_l%d_w", "Moc", "W", 0),
phases("grid_voltage_l%d_v", "Napięcie", "V", 1),
field("grid_frequency_hz", "Częstotliwość", "Hz", 2)),
group("Wyjście zapasowe falownika",
field("load_power_w", "Moc razem", "W", 0),
phases("load_power_l%d_w", "Moc", "W", 0)),
group("Panele PV",
field("pv1_power_w", "PV1 moc", "W", 0),
field("pv1_voltage_v", "PV1 napięcie", "V", 1),
field("pv1_current_a", "PV1 prąd", "A", 1),
field("pv2_power_w", "PV2 moc", "W", 0),
field("pv2_voltage_v", "PV2 napięcie", "V", 1),
field("pv2_current_a", "PV2 prąd", "A", 1)),
group("Dziś",
field("pv_today_kwh", "Produkcja PV", "kWh", 1),
field("load_today_kwh", "Wyjście zapasowe", "kWh", 1),
field("grid_bought_today_kwh", "Pobrano z sieci", "kWh", 1),
field("grid_sold_today_kwh", "Oddano do sieci", "kWh", 1),
field("battery_charge_today_kwh", "Bateria naładowana", "kWh", 1),
field("battery_discharge_today_kwh", "Bateria rozładowana", "kWh", 1)),
group("Od początku",
field("pv_total_kwh", "Produkcja PV", "kWh", 0),
field("load_total_kwh", "Wyjście zapasowe", "kWh", 0),
field("grid_bought_total_kwh", "Pobrano z sieci", "kWh", 0),
field("grid_sold_total_kwh", "Oddano do sieci", "kWh", 0),
field("battery_charge_total_kwh", "Bateria naładowana", "kWh", 0),
field("battery_discharge_total_kwh", "Bateria rozładowana", "kWh", 0)),
group("Falownik",
field("inverter_power_w", "Moc wyjściowa", "W", 0),
field("inverter_temperature_c", "Temperatura", "°C", 1),
field("heatsink_temperature_c", "Radiator", "°C", 1)))),
new Spec("fronius", "Falownik Fronius", "fronius", List.of(
group("Teraz", field("pv_power_w", "Moc PV", "W", 0)),
group("Produkcja",
kwhFromWh("energy_day_wh", "Dziś", 1),
kwhFromWh("energy_year_wh", "Ten rok", 0),
kwhFromWh("energy_total_wh", "Od początku", 0)))),
new Spec("house-meter", "Licznik domu", "PRAD2", List.of(
group("Moc czynna",
field("total_power_active", "Razem", "W", 0),
phases("p%d_power_active", "Moc", "W", 0)),
group("Napięcie", phases("p%d_voltage", "Napięcie", "V", 1)),
group("Prąd", phases("p%d_current", "Prąd", "A", 2)),
group("Współczynnik mocy", phases("p%d_power_factor", "cos φ", "", 2)),
group("Sieć", field("frequency", "Częstotliwość", "Hz", 2)),
group("Energia od początku",
field("import_energy_active", "Pobrana", "kWh", 0),
field("export_energy_active", "Oddana", "kWh", 0)),
groupOf("house_meter_phases", "Energia na fazę",
phases("l%d_import_energy_active_kwh", "Pobrana", "kWh", 0),
phases("l%d_export_energy_active_kwh", "Oddana", "kWh", 0)))));
private final GatewayClient gateway;
private final Clock clock;
public EnergyDetailsService(GatewayClient gateway, Clock clock) {
this.gateway = gateway;
this.clock = clock;
}
public EnergyDetails current() throws IOException {
GatewaySnapshot snapshot = gateway.snapshot();
return new EnergyDetails(CATALOG.stream().map(spec -> device(spec, snapshot)).toList());
}
private Device device(Spec spec, GatewaySnapshot snapshot) {
Live live = SnapshotFields.live(snapshot, spec.id(), spec.measurement(), clock.instant());
List<Group> groups = spec.groups().stream()
.map(g -> group(g, g.measurement() == null ? live.fields() : fieldsOf(snapshot, spec.id(), g)))
.filter(g -> !g.items().isEmpty()).toList();
return new Device(spec.id(), spec.name(), live.source().time(), live.source().stale(), groups);
}
private Map<String, Double> fieldsOf(GatewaySnapshot snapshot, String device, GroupSpec group) {
return SnapshotFields.live(snapshot, device, group.measurement(), clock.instant()).fields();
}
private static Group group(GroupSpec spec, Map<String, Double> fields) {
List<Item> items = spec.fields().stream().filter(f -> fields.containsKey(f.key()))
.map(f -> new Item(f.label(), scaled(fields.get(f.key()), f), f.unit(), f.digits())).toList();
return new Group(spec.title(), items);
}
/** Rounded to the shown precision, so a conversion leaves no binary noise such as 4.3999999. */
private static double scaled(double value, Field f) {
double factor = Math.pow(10, f.digits());
return Math.round(value * f.scale() * factor) / factor;
}
}
@@ -0,0 +1,93 @@
package home.backend.application;
import home.backend.application.SnapshotFields.Live;
import home.backend.domain.EnergyView;
import home.backend.domain.EnergyView.Source;
import home.backend.domain.EnergyView.Today;
import home.backend.domain.GatewaySnapshot;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Stream;
/**
* F7: house electricity from the Deye inverter, the Fronius PV inverter and the house meter.
* The Deye "load" registers cover only its backup output, so the house is a balance:
* <ul>
* <li>now = Deye PV1 + PV2 + Fronius PV + Deye battery (+discharge, − charge) + grid, where grid is the
* house meter's total active power when it is live, else the Deye's grid power; floored at 0, null when
* any of those readings is missing or stale;</li>
* <li>today = Deye PV + Fronius day energy + grid bought − grid sold + battery discharged − battery charged
* (Deye day counters), null when any is missing.</li>
* </ul>
*/
public class EnergyService {
private final GatewayClient gateway;
private final Clock clock;
public EnergyService(GatewayClient gateway, Clock clock) {
this.gateway = gateway;
this.clock = clock;
}
public EnergyView current() throws IOException {
GatewaySnapshot snapshot = gateway.snapshot();
Live deye = SnapshotFields.live(snapshot, "deye", "deye", clock.instant());
Live fronius = SnapshotFields.live(snapshot, "fronius", "fronius", clock.instant());
Map<String, Double> d = deye.fields();
Map<String, Double> f = fronius.fields();
Map<String, Double> meter = SnapshotFields.live(snapshot, "house-meter", "PRAD2", clock.instant()).fields();
Double froniusKwh = f.containsKey("energy_day_wh") ? f.get("energy_day_wh") / 1000 : null;
Today today = new Today(round(sum(d.get("pv_today_kwh"), froniusKwh)),
d.get("grid_bought_today_kwh"), d.get("grid_sold_today_kwh"), houseKwh(d, froniusKwh));
List<Source> sources = List.of(deye.source(), fronius.source());
return new EnergyView(oldest(sources), sources,
sum(d.get("pv1_power_w"), d.get("pv2_power_w"), f.get("pv_power_w")),
d.get("grid_power_w"), d.get("battery_power_w"), d.get("battery_soc_pct"),
housePowerW(d, f, meter.getOrDefault("total_power_active", d.get("grid_power_w"))),
d.get("load_power_w"), today);
}
private static Double housePowerW(Map<String, Double> deye, Map<String, Double> fronius, Double gridW) {
Double pv1 = deye.get("pv1_power_w");
Double pv2 = deye.get("pv2_power_w");
Double pvFronius = fronius.get("pv_power_w");
Double battery = deye.get("battery_power_w");
if (Stream.of(pv1, pv2, pvFronius, battery, gridW).anyMatch(Objects::isNull)) {
return null;
}
return Math.max(0, pv1 + pv2 + pvFronius + battery + gridW);
}
private static Double houseKwh(Map<String, Double> deye, Double froniusKwh) {
Double pv = deye.get("pv_today_kwh");
Double bought = deye.get("grid_bought_today_kwh");
Double sold = deye.get("grid_sold_today_kwh");
Double discharged = deye.get("battery_discharge_today_kwh");
Double charged = deye.get("battery_charge_today_kwh");
if (Stream.of(pv, froniusKwh, bought, sold, discharged, charged).anyMatch(Objects::isNull)) {
return null;
}
return round(Math.max(0, pv + froniusKwh + bought - sold + discharged - charged));
}
private static Instant oldest(List<Source> sources) {
return sources.stream().map(Source::time).flatMap(Stream::ofNullable).min(Instant::compareTo).orElse(null);
}
/** Sum of the values that exist; null when none does. */
private static Double sum(Double... values) {
return Stream.of(values).filter(v -> v != null).reduce(Double::sum).orElse(null);
}
/** Wh to kWh leaves binary noise such as 34.300000000000004. */
private static Double round(Double kwh) {
return kwh == null ? null : Math.round(kwh * 1000) / 1000.0;
}
}
@@ -0,0 +1,11 @@
package home.backend.application;
import home.backend.domain.Forecast;
import java.io.IOException;
/** The weather forecast service: the past 14 days and the next 3. */
public interface ForecastSource {
Forecast forecast() throws IOException;
}
@@ -0,0 +1,19 @@
package home.backend.application;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView.Reserve;
import java.io.IOException;
/** Battery settings through the gateway, which checks every change (ADR-013). */
public interface GatewayBattery {
/** @throws IOException when the gateway has no battery commands, or cannot be reached */
Reserve reserve() throws IOException;
/** A verdict, even a negative one; IOException only when no verdict could be obtained. */
BatteryResult changeSchedule(BatteryChange change, String requestedBy) throws IOException;
BatteryResult changeReserve(int pct, String requestedBy) throws IOException;
}
@@ -0,0 +1,11 @@
package home.backend.application;
import home.backend.domain.GatewaySnapshot;
import java.io.IOException;
/** The live state, read from the gateway on the Pi through the tunnel. */
public interface GatewayClient {
GatewaySnapshot snapshot() throws IOException;
}
@@ -0,0 +1,23 @@
package home.backend.application;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.RestoreResult;
import home.backend.domain.SetpointState;
import java.io.IOException;
/** The only way the backend writes anything: through the gateway, which checks every command. */
public interface GatewayCommands {
/** A verdict, even a negative one; IOException only when no verdict could be obtained. */
CommandResult apply(Command command) throws IOException;
/** Ends the pending change of a setpoint now; a verdict like {@link #apply}. */
RestoreResult restore(String setpoint, String requestedBy) throws IOException;
SetpointState setpoints() throws IOException;
/** Keeps the gateway's live restores alive; returns how many it extended. */
int renewLeases() throws IOException;
}
@@ -0,0 +1,11 @@
package home.backend.application;
import home.backend.domain.RefreshResult;
import java.io.IOException;
/** Asks the gateway for fresh readings; it decides which devices may be polled again yet. */
public interface GatewayRefresh {
RefreshResult refresh() throws IOException;
}
@@ -0,0 +1,38 @@
package home.backend.application;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.HealthView;
import home.backend.domain.HealthView.DeviceHealth;
import java.io.IOException;
import java.time.Clock;
import java.util.List;
/** F5: whether the gateway answers and which device links are up. */
public class HealthService {
private final GatewayClient gateway;
private final Clock clock;
private final String version;
public HealthService(GatewayClient gateway, Clock clock, String version) {
this.gateway = gateway;
this.clock = clock;
this.version = version;
}
public HealthView current() {
try {
GatewaySnapshot snapshot = gateway.snapshot();
List<DeviceHealth> devices = snapshot.devices().stream().map(HealthService::health).toList();
return new HealthView(clock.instant(), true, devices, version, snapshot.version());
} catch (IOException e) {
return new HealthView(clock.instant(), false, List.of(), version, null);
}
}
private static DeviceHealth health(DeviceSnapshot d) {
return new DeviceHealth(d.name(), d.up(), d.lastSuccess(), d.lastError());
}
}
@@ -0,0 +1,13 @@
package home.backend.application;
import home.backend.domain.HeatPumpSample;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
/** The heat pump polls stored in InfluxDB, oldest first. */
public interface HeatPumpSamples {
List<HeatPumpSample> samples(Instant from, Instant to) throws IOException;
}
@@ -0,0 +1,89 @@
package home.backend.application;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import home.backend.domain.HeatPumpView;
import home.backend.domain.HeatPumpView.Pumps;
import home.backend.domain.HeatPumpView.Setpoints;
import home.backend.domain.HeatPumpView.State;
import home.backend.domain.HeatPumpView.Targets;
import home.backend.domain.HeatPumpView.Temperatures;
import home.backend.domain.Periods;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import static home.backend.application.SnapshotFields.on;
import static home.backend.application.SnapshotFields.whole;
/** F1: the heat pump's live state from the gateway's {@code heat_pump} measurement. */
public class HeatPumpService {
private static final String DATABASE = "Dom_Pompa";
private static final State[] STATES = State.values();
private final GatewayClient gateway;
private final Clock clock;
public HeatPumpService(GatewayClient gateway, Clock clock) {
this.gateway = gateway;
this.clock = clock;
}
public HeatPumpView current() throws IOException {
GatewaySnapshot snapshot = gateway.snapshot();
Optional<Measurement> reading = SnapshotFields.find(snapshot, "heat-pump", "heat_pump")
.filter(m -> DATABASE.equals(m.database()));
boolean stale = SnapshotFields.stale(SnapshotFields.device(snapshot, "heat-pump"), reading, clock.instant());
return view(reading.map(Measurement::time).orElse(null), stale,
reading.map(Measurement::fields).orElse(Map.of()));
}
private static HeatPumpView view(Instant time, boolean stale, Map<String, Double> f) {
Integer alarmCode = whole(f, "alarm_code");
var targets = new Targets(tenths(f, "target_room_temp"), tenths(f, "target_co_temp"),
tenths(f, "target_cwu_temp"));
var setpoints = new Setpoints(whole(f, "setpoint_co_economy"), whole(f, "setpoint_co_comfort"),
whole(f, "setpoint_cwu_economy"), whole(f, "setpoint_cwu_comfort"));
return new HeatPumpView(time, stale, state(f), whole(f, "output_compressor"),
on(f, "alarm") || on(f, "alarm_code"), alarmCode, alarmText(alarmCode),
new Pumps(on(f, "output_co"), on(f, "output_buffer"), on(f, "output_circulation"),
on(f, "output_ground"), on(f, "output_cwu")),
new Temperatures(tenths(f, "outdoor_temp"), tenths(f, "indoor_temp"),
tenths(f, "co_buffer_temp"), tenths(f, "cwu_temp"), tenths(f, "ground_in_temp"),
tenths(f, "ground_out_temp"), tenths(f, "pump_outlet_temp"),
tenths(f, "outdoor_avg_temp")),
targets, setpoints, Periods.of(whole(f, "program"), targets, setpoints));
}
/** Codes of register 4011 as the vendor app names them; unverified on our device. */
private static String alarmText(Integer code) {
if (code == null || code == 0) {
return null;
}
return switch (code) {
case 2 -> "Usterka czujnika temperatury zewnętrznej";
case 3 -> "Usterka czujnika temperatury CO";
case 4 -> "Usterka czujnika temperatury wewnętrznej";
case 5 -> "Usterka czujnika temperatury CWU";
case 14 -> "Usterka czujnika temperatury wyjścia pompy";
case 15 -> "Usterka czujnika temperatury dolnego źródła";
case 1003 -> "Zadziałał presostat (ciśnienie)";
default -> "Kod usterki " + code;
};
}
/** The controller resolves 0.1 °C; the gateway's 212 * 0.1 is 21.200000000000003. */
private static Double tenths(Map<String, Double> fields, String name) {
Double value = fields.get(name);
return value == null ? null : Math.round(value * 10) / 10.0;
}
private static State state(Map<String, Double> f) {
Integer mode = whole(f, "pump_mode");
return mode == null || mode < 0 || mode >= STATES.length ? null : STATES[mode];
}
}
@@ -0,0 +1,14 @@
package home.backend.application;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
/** Past values from InfluxDB, averaged into about {@code maxPoints} steps. */
public interface HistoryRepository {
List<Point> history(Series series, Instant from, Instant to, int maxPoints) throws IOException;
}
@@ -0,0 +1,43 @@
package home.backend.application;
import home.backend.domain.HistoryView;
import home.backend.domain.Series;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
/** F2: one series over the last 24 hours or 7 days. */
public class HistoryService {
static final int MAX_POINTS = 300;
private static final String DEFAULT_RANGE = "24h";
private static final Map<String, Duration> RANGES =
Map.of("24h", Duration.ofHours(24), "7d", Duration.ofDays(7));
private final HistoryRepository repository;
private final Clock clock;
public HistoryService(HistoryRepository repository, Clock clock) {
this.repository = repository;
this.clock = clock;
}
/**
* @param range {@code 24h} or {@code 7d}; null means 24h
* @throws IllegalArgumentException for an unknown series or range
*/
public HistoryView history(String seriesId, String range) throws IOException {
Series series = Series.fromId(seriesId)
.orElseThrow(() -> new IllegalArgumentException("unknown series: " + seriesId));
Duration length = RANGES.get(range == null ? DEFAULT_RANGE : range);
if (length == null) {
throw new IllegalArgumentException("unknown range: " + range);
}
Instant to = clock.instant();
return new HistoryView(series.id(), series.unit(),
repository.history(series, to.minus(length), to, MAX_POINTS));
}
}
@@ -0,0 +1,53 @@
package home.backend.application;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* Tells the gateway now and then that we are still here (ADR-011): while it hears us, our setpoint
* changes stay; once we or the tunnel are gone for 15 minutes, it restores them on its own.
*/
public class LeaseRenewer implements AutoCloseable {
/** A third of the gateway's lease, so two renewals in a row may fail. */
public static final Duration PERIOD = Duration.ofMinutes(5);
private static final Logger LOGGER = LoggerFactory.getLogger(LeaseRenewer.class);
private final GatewayCommands gateway;
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("lease-renewal").factory());
public LeaseRenewer(GatewayCommands gateway) {
this.gateway = gateway;
}
public void start() {
start(PERIOD);
}
void start(Duration period) {
scheduler.scheduleWithFixedDelay(this::renew, 0, period.toMillis(), TimeUnit.MILLISECONDS);
}
/** A failure is logged and retried next cycle; an exception would end the schedule for good. */
void renew() {
try {
int renewed = gateway.renewLeases();
LOGGER.debug("renewed {} lease(s)", renewed);
} catch (IOException | RuntimeException e) {
LOGGER.warn("could not renew the gateway leases, retrying in {}: {}", PERIOD, e.toString());
}
}
@Override
public void close() {
scheduler.shutdownNow();
}
}
@@ -0,0 +1,27 @@
package home.backend.application;
import home.backend.application.SnapshotFields.Live;
import home.backend.domain.PiSensorView;
import java.io.IOException;
import java.time.Clock;
import java.util.Map;
/** The BME280 on the Pi, read by the gateway every 5 minutes. */
public class PiSensorService {
private final GatewayClient gateway;
private final Clock clock;
public PiSensorService(GatewayClient gateway, Clock clock) {
this.gateway = gateway;
this.clock = clock;
}
public PiSensorView current() throws IOException {
Live live = SnapshotFields.live(gateway.snapshot(), "bme280", "MEASUREMENT", clock.instant());
Map<String, Double> f = live.fields();
return new PiSensorView(live.source().time(), live.source().stale(),
f.get("temperature"), f.get("humidity"), f.get("pressure"));
}
}
@@ -0,0 +1,193 @@
package home.backend.application;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.Period;
import home.backend.domain.Periods;
import home.backend.domain.ProgramView;
import home.backend.domain.ProgramView.Circuit;
import home.backend.domain.ProgramView.Confidence;
import home.backend.domain.ProgramView.Interval;
import home.backend.domain.ProgramView.Weekday;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.function.Function;
/**
* F6: the weekly program as the history shows it, without asking the controller (its program
* registers sit behind a shared selector, so reading them would mean writing). A transition between
* two adjacent polls, economy to comfort or back, puts a comfort boundary halfway between them; a
* poll in another period (override, or setpoints that make the periods ambiguous, for instance while
* we write one) breaks the chain, as does a gap in the data. Times are local, so a day with a change
* of time needs no special case. Today is left out: it is not over.
*/
public class ProgramService {
private static final int DEFAULT_DAYS = 14;
private static final int MIN_DAYS = 7;
private static final int MAX_DAYS = 30;
private static final Duration MAX_GAP = StatsService.MAX_HOLD;
private static final int STEP_MINUTES = 5;
private static final int MINUTES_PER_DAY = 24 * 60;
private record Span(int from, int to) {
}
/** What the history says about one circuit: days with a known period and their comfort spans. */
private record Observed(Set<LocalDate> days, Map<LocalDate, List<Span>> comfort) {
}
private final HeatPumpSamples repository;
private final Clock clock;
public ProgramService(HeatPumpSamples repository, Clock clock) {
this.repository = repository;
this.clock = clock;
}
/**
* @param days how many past days to read, 7 to 30; null means 14
* @throws IllegalArgumentException for anything else
*/
public ProgramView program(String days) throws IOException {
int length = length(days);
LocalDate today = clock.instant().atZone(StatsService.ZONE).toLocalDate();
List<HeatPumpSample> samples = repository.samples(
today.minusDays(length).atStartOfDay(StatsService.ZONE).toInstant(),
today.atStartOfDay(StatsService.ZONE).toInstant());
Observed cwu = observe(samples, Periods::cwu);
Observed room = observe(samples, Periods::room);
List<Weekday> weekdays = new ArrayList<>();
for (int day = 1; day <= 7; day++) {
weekdays.add(new Weekday(day, circuit(cwu, day), circuit(room, day)));
}
return new ProgramView(length, weekdays);
}
private static int length(String days) {
if (days == null) {
return DEFAULT_DAYS;
}
try {
int length = Integer.parseInt(days);
if (length >= MIN_DAYS && length <= MAX_DAYS) {
return length;
}
} catch (NumberFormatException e) {
// reported below
}
throw new IllegalArgumentException("days must be " + MIN_DAYS + " to " + MAX_DAYS);
}
private static Observed observe(List<HeatPumpSample> samples, Function<Periods, Period> circuit) {
Set<LocalDate> days = new TreeSet<>();
Map<LocalDate, List<Span>> comfort = new TreeMap<>();
Period previous = null;
Instant previousTime = null;
Instant comfortStart = null;
for (HeatPumpSample sample : samples) {
Period period = circuit.apply(sample.periods());
if (period != Period.COMFORT && period != Period.ECONOMY) {
previous = null;
comfortStart = null;
continue;
}
days.add(sample.t().atZone(StatsService.ZONE).toLocalDate());
boolean adjacent = previous != null && Duration.between(previousTime, sample.t()).compareTo(MAX_GAP) <= 0;
if (!adjacent) {
comfortStart = null;
} else if (period != previous) {
Instant boundary = previousTime.plus(Duration.between(previousTime, sample.t()).dividedBy(2));
if (period == Period.COMFORT) {
comfortStart = boundary;
} else if (comfortStart != null) {
addSpans(comfort, comfortStart, boundary);
comfortStart = null;
}
}
previous = period;
previousTime = sample.t();
}
return new Observed(days, comfort);
}
/** Splits at local midnight; a start at 23:58 and an end at 00:03 are rounded before that. */
private static void addSpans(Map<LocalDate, List<Span>> comfort, Instant from, Instant to) {
LocalDateTime start = rounded(from.atZone(StatsService.ZONE).toLocalDateTime());
LocalDateTime end = rounded(to.atZone(StatsService.ZONE).toLocalDateTime());
for (LocalDate date = start.toLocalDate(); !date.isAfter(end.toLocalDate()); date = date.plusDays(1)) {
int spanStart = date.equals(start.toLocalDate()) ? minutes(start) : 0;
int spanEnd = date.equals(end.toLocalDate()) ? minutes(end) : MINUTES_PER_DAY;
if (spanEnd > spanStart) {
comfort.computeIfAbsent(date, d -> new ArrayList<>()).add(new Span(spanStart, spanEnd));
}
}
}
private static LocalDateTime rounded(LocalDateTime time) {
long steps = Math.round((time.getMinute() * 60 + time.getSecond()) / (STEP_MINUTES * 60.0));
return time.truncatedTo(ChronoUnit.HOURS).plusMinutes(steps * STEP_MINUTES);
}
private static int minutes(LocalDateTime time) {
return time.getHour() * 60 + time.getMinute();
}
/**
* The pattern most days of this weekday agree on (the latest wins a tie); {@code CONFIRMED} when at
* least two days show it.
*/
private static Circuit circuit(Observed observed, int weekday) {
List<List<Span>> patterns = observed.days().stream()
.filter(date -> date.getDayOfWeek().getValue() == weekday)
.map(date -> observed.comfort().getOrDefault(date, List.of()))
.toList();
if (patterns.isEmpty()) {
return new Circuit(Confidence.NO_DATA, 0, 0, List.of());
}
List<Span> best = null;
int bestCount = 0;
for (List<Span> pattern : patterns) {
int count = (int) patterns.stream().filter(other -> agree(pattern, other)).count();
if (count >= bestCount) {
best = pattern;
bestCount = count;
}
}
return new Circuit(bestCount >= 2 ? Confidence.CONFIRMED : Confidence.SEEN_ONCE, patterns.size(), bestCount,
best.stream().map(ProgramService::interval).toList());
}
/** Equal within one step at both ends of every span. */
private static boolean agree(List<Span> a, List<Span> b) {
if (a.size() != b.size()) {
return false;
}
for (int i = 0; i < a.size(); i++) {
if (Math.abs(a.get(i).from() - b.get(i).from()) > STEP_MINUTES
|| Math.abs(a.get(i).to() - b.get(i).to()) > STEP_MINUTES) {
return false;
}
}
return true;
}
private static Interval interval(Span span) {
return new Interval(hhmm(span.from()), hhmm(span.to()));
}
private static String hhmm(int minutes) {
return String.format("%02d:%02d", minutes / 60, minutes % 60);
}
}
@@ -0,0 +1,12 @@
package home.backend.application;
import java.io.IOException;
import java.time.LocalDate;
import java.util.Map;
/** What the panels produced, from InfluxDB. */
public interface PvEnergyRepository {
/** Deye plus Fronius kWh per local day from {@code from} up to {@code to}; days missing either are absent. */
Map<LocalDate, Double> dailyKwh(LocalDate from, LocalDate to) throws IOException;
}
@@ -0,0 +1,178 @@
package home.backend.application;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.Preset;
import home.backend.domain.Preset.Change;
import home.backend.domain.PresetResult;
import home.backend.domain.PresetResult.ChangeResult;
import home.backend.domain.PresetResult.ChangeStatus;
import home.backend.domain.RestoreResult;
import home.backend.domain.Setpoint;
import home.backend.domain.SetpointState;
import home.backend.domain.SetpointsView;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/** F3: change setpoints directly or through a preset; the gateway enforces the limits (S1). */
public class SetpointService {
/** Longest timed preset the user may choose; the gateway's own limit is higher. */
public static final int MAX_PRESET_HOURS = 4;
/** The chosen hours are not a whole number from 1 to {@link #MAX_PRESET_HOURS}, or the preset is not timed. */
public static class InvalidHoursException extends IllegalArgumentException {
InvalidHoursException(String message) {
super(message);
}
}
private final GatewayCommands gateway;
private final List<Preset> presets;
public SetpointService(GatewayCommands gateway, List<Preset> presets) {
this.gateway = gateway;
this.presets = List.copyOf(presets);
}
public SetpointsView current() throws IOException {
SetpointState state = gateway.setpoints();
return new SetpointsView(state.setpoints(), state.restores(), presets);
}
/** @param hours how long to keep the value, null until changed back */
public CommandResult apply(String setpoint, int value, Double hours, String user) throws IOException {
return gateway.apply(new Command(setpoint, value, duration(hours), user));
}
/** Puts the value from before the pending change back now, instead of waiting for its end. */
public RestoreResult restore(String setpoint, String user) throws IOException {
return gateway.restore(setpoint, user);
}
/**
* Empty if there is no such preset. A preset that does not fit the gateway's limits writes nothing.
* IOException only when no change was attempted; once writing starts, trouble is reported per change.
*
* @param until the end the user chose; required by a held preset, not allowed for any other
* @param hours how long a timed preset keeps its changes, 1 to {@link #MAX_PRESET_HOURS}; null keeps the preset's
* @param values the user's own value for some of the preset's setpoints (any preset); the rest keep the
* preset's value
* @throws InvalidHoursException when {@code hours} is out of range or the preset has no hours to choose
* @throws IllegalArgumentException when {@code until} does not match what the preset needs, or a value is
* for a setpoint the preset does not change or outside the gateway's limits
*/
public Optional<PresetResult> applyPreset(String id, Instant until, Integer hours, Map<String, Integer> values,
String user)
throws IOException {
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
if (preset.isEmpty()) {
return Optional.empty();
}
if (preset.get().isHeld() != (until != null)) {
throw new IllegalArgumentException(preset.get().isHeld() ? "preset " + id + " needs an end time"
: "preset " + id + " has no end time to choose");
}
List<Change> changes = withHours(withValues(preset.get().changes(), values), hours);
List<Setpoint> limits = gateway.setpoints().setpoints();
requireWithinLimits(values, limits);
List<ChangeResult> checked = changes.stream().map(c -> check(c, preset.get().isHeld(), limits)).toList();
if (checked.stream().anyMatch(r -> r.status() == ChangeStatus.REJECTED)) {
return Optional.of(PresetResult.of(checked));
}
return Optional.of(PresetResult.of(run(changes, until, user)));
}
private static List<Change> withValues(List<Change> changes, Map<String, Integer> values) {
Set<String> own = changes.stream().map(Change::setpoint).collect(Collectors.toSet());
if (!own.containsAll(values.keySet())) {
throw new IllegalArgumentException("values may only be for the setpoints the preset changes: " + own);
}
return changes.stream().map(c -> new Change(c.setpoint(), values.getOrDefault(c.setpoint(), c.value()),
c.hours())).toList();
}
private static List<Change> withHours(List<Change> changes, Integer hours) {
if (hours == null) {
return changes;
}
if (hours < 1 || hours > MAX_PRESET_HOURS) {
throw new InvalidHoursException("hours must be 1 to " + MAX_PRESET_HOURS);
}
if (changes.stream().anyMatch(c -> c.hours() == null)) {
throw new InvalidHoursException("this preset has no hours to choose");
}
return changes.stream().map(c -> new Change(c.setpoint(), c.value(), hours.doubleValue())).toList();
}
private static void requireWithinLimits(Map<String, Integer> values, List<Setpoint> limits) {
for (Setpoint limit : limits) {
Integer value = values.get(limit.name());
if (value != null && !limit.allows(value)) {
throw new IllegalArgumentException(limit.name() + " must be " + limit.min() + "–" + limit.max()
+ " in steps of " + limit.step());
}
}
}
private List<ChangeResult> run(List<Change> changes, Instant until, String user) {
List<ChangeResult> results = new ArrayList<>();
for (Change change : changes) {
boolean stopped = !results.isEmpty() && results.getLast().status() != ChangeStatus.APPLIED;
results.add(stopped ? ChangeResult.notAttempted() : attempt(change, until, user));
}
return results;
}
private ChangeResult attempt(Change change, Instant until, String user) {
try {
var command = until == null ? new Command(change.setpoint(), change.value(), duration(change.hours()), user)
: new Command(change.setpoint(), change.value(), null, until, user);
return ChangeResult.of(gateway.apply(command));
} catch (IOException e) {
return new ChangeResult(ChangeStatus.UNKNOWN, "no answer from the gateway", null);
}
}
/** REJECTED with the reason, or NOT_ATTEMPTED (nothing is sent if any change is rejected). */
private static ChangeResult check(Change change, boolean held, List<Setpoint> limits) {
String problem = problem(change, held, limits);
return problem == null ? ChangeResult.notAttempted() : new ChangeResult(ChangeStatus.REJECTED, problem, null);
}
private static String problem(Change change, boolean held, List<Setpoint> limits) {
Optional<Setpoint> known = limits.stream().filter(s -> s.name().equals(change.setpoint())).findFirst();
if (known.isEmpty()) {
return "preset sets " + change.setpoint() + ", which the gateway does not know";
}
if (!known.get().allows(change.value())) {
return "preset sets " + change.setpoint() + " to " + change.value() + ", outside the gateway's limits "
+ known.get().min() + "–" + known.get().max() + " step " + known.get().step();
}
// The gateway refuses a held raise too; asking first keeps a preset from stopping half way.
if (held && known.get().value() != null && change.value() >= known.get().value()) {
return "preset sets " + change.setpoint() + " to " + change.value() + ", which is not below the current "
+ known.get().value();
}
return null;
}
private static Duration duration(Double hours) {
if (hours == null) {
return null;
}
long minutes = Math.round(hours * 60);
if (minutes < 1 || minutes > Command.MAX_HOURS * 60L) {
throw new IllegalArgumentException("hours must be from one minute to " + Command.MAX_HOURS + " hours");
}
return Duration.ofMinutes(minutes);
}
}
@@ -0,0 +1,71 @@
package home.backend.application;
import home.backend.domain.EnergyView.Source;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/** Looks up one measurement of one device in a snapshot and reads typed fields. */
final class SnapshotFields {
private SnapshotFields() {
}
/** Twice the 300 s the UI treats as one polling cycle; older than that a reading is not live. */
private static final Duration MAX_AGE = Duration.ofSeconds(600);
static Optional<DeviceSnapshot> device(GatewaySnapshot snapshot, String name) {
return snapshot.devices().stream().filter(d -> d.name().equals(name)).findFirst();
}
/** A reading is stale when the device link is down, it is missing, or it is older than twice a cycle. */
static boolean stale(Optional<DeviceSnapshot> device, Optional<Measurement> reading, Instant now) {
return stale(device, reading, now, MAX_AGE);
}
static boolean stale(Optional<DeviceSnapshot> device, Optional<Measurement> reading, Instant now,
Duration maxAge) {
return device.isEmpty() || !device.get().up() || reading.isEmpty()
|| Duration.between(reading.get().time(), now).compareTo(maxAge) > 0;
}
/** One device's fields; empty when it is stale, so nothing old is shown as current. */
record Live(Source source, Map<String, Double> fields) {
}
static Live live(GatewaySnapshot snapshot, String device, String measurement, Instant now) {
return live(snapshot, device, measurement, now, MAX_AGE);
}
static Live live(GatewaySnapshot snapshot, String device, String measurement, Instant now, Duration maxAge) {
Optional<Measurement> reading = find(snapshot, device, measurement);
boolean stale = stale(device(snapshot, device), reading, now, maxAge);
Source source = new Source(device, reading.map(Measurement::time).orElse(null), stale);
return new Live(source, stale ? Map.of() : reading.get().fields());
}
static Optional<Measurement> find(GatewaySnapshot snapshot, String device, String measurement) {
return snapshot.devices().stream()
.filter(d -> d.name().equals(device))
.map(DeviceSnapshot::measurements)
.flatMap(List::stream)
.filter(m -> m.measurement().equals(measurement))
.findFirst();
}
static Integer whole(Map<String, Double> fields, String name) {
Double value = fields.get(name);
return value == null ? null : (int) Math.round(value);
}
static boolean on(Map<String, Double> fields, String name) {
Double value = fields.get(name);
return value != null && value != 0;
}
}
@@ -0,0 +1,89 @@
package home.backend.application;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.StatsView;
import home.backend.domain.StatsView.Day;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/**
* F2: the heat pump's work per local day over the last 7 or 30 days. A poll is taken to hold its
* outputs until the next poll, but at most {@link #MAX_HOLD}, so a gap in the data adds no hours.
*/
public class StatsService {
static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
static final Duration MAX_HOLD = Duration.ofSeconds(600);
private static final Map<String, Integer> RANGE_DAYS = Map.of("7d", 7, "30d", 30);
private final HeatPumpSamples repository;
private final Clock clock;
public StatsService(HeatPumpSamples repository, Clock clock) {
this.repository = repository;
this.clock = clock;
}
/**
* @param range {@code 7d} or {@code 30d}
* @throws IllegalArgumentException for an unknown range
*/
public StatsView stats(String range) throws IOException {
Integer length = range == null ? null : RANGE_DAYS.get(range);
if (length == null) {
throw new IllegalArgumentException("unknown range: " + range);
}
Instant now = clock.instant();
LocalDate first = now.atZone(ZONE).toLocalDate().minusDays(length - 1);
Map<LocalDate, DayTally> tallies = new TreeMap<>();
for (int i = 0; i < length; i++) {
tallies.put(first.plusDays(i), new DayTally());
}
tally(repository.samples(first.atStartOfDay(ZONE).toInstant(), now), now, tallies);
DayTally all = new DayTally();
List<Day> days = new ArrayList<>();
tallies.forEach((date, tally) -> {
days.add(tally.day(date));
all.add(tally);
});
return new StatsView(range, days, all.totals());
}
private static void tally(List<HeatPumpSample> samples, Instant now, Map<LocalDate, DayTally> tallies) {
HeatPumpSample previous = null;
for (int i = 0; i < samples.size(); i++) {
HeatPumpSample s = samples.get(i);
DayTally day = tallies.get(s.t().atZone(ZONE).toLocalDate());
day.sample(s);
if (previous != null && previous.compressor() != null && previous.compressor() == 0
&& DayTally.on(s.compressor())) {
day.start();
}
previous = s;
hold(s, i + 1 < samples.size() ? samples.get(i + 1).t() : now, tallies);
}
}
/** Adds the time {@code s} was in force to the days it falls in; a stretch over midnight is split. */
private static void hold(HeatPumpSample s, Instant next, Map<LocalDate, DayTally> tallies) {
Instant end = Duration.between(s.t(), next).compareTo(MAX_HOLD) > 0 ? s.t().plus(MAX_HOLD) : next;
Instant at = s.t();
while (at.isBefore(end)) {
LocalDate date = at.atZone(ZONE).toLocalDate();
Instant midnight = date.plusDays(1).atStartOfDay(ZONE).toInstant();
Instant stop = midnight.isBefore(end) ? midnight : end;
tallies.get(date).hold(s, Duration.between(at, stop));
at = stop;
}
}
}
@@ -0,0 +1,12 @@
package home.backend.application;
import home.backend.domain.WeatherView.Warning;
import java.io.IOException;
import java.util.List;
/** Weather warnings that cover the house, whether in force now or announced. */
public interface WarningSource {
List<Warning> warnings() throws IOException;
}
@@ -0,0 +1,98 @@
package home.backend.application;
import home.backend.application.Cached.Entry;
import home.backend.domain.Forecast;
import home.backend.domain.Forecast.DailyForecast;
import home.backend.domain.PvCalibration;
import home.backend.domain.WeatherCodes;
import home.backend.domain.WeatherView;
import home.backend.domain.WeatherView.Day;
import home.backend.domain.WeatherView.Now;
import home.backend.domain.WeatherView.Warning;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** Forecast, PV estimate and warnings, each kept for a while so the phones do not load the sources. */
public class WeatherService {
private static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
private static final int CALIBRATION_DAYS = 14;
private record Calibrated(Forecast forecast, Double factor) {
}
private final Clock clock;
private final PvEnergyRepository pv;
private final Cached<Calibrated> forecast;
private final Cached<List<Warning>> warnings;
public WeatherService(ForecastSource forecastSource, WarningSource warningSource, PvEnergyRepository pv,
Clock clock) {
this.clock = clock;
this.pv = pv;
this.forecast = new Cached<>(Duration.ofMinutes(30), ZONE, clock, () -> calibrated(forecastSource.forecast()));
this.warnings = new Cached<>(Duration.ofMinutes(10), clock, warningSource::warnings);
}
/** @throws IOException when there is no forecast at all; warnings alone may be missing. */
public WeatherView current() throws IOException {
Entry<Calibrated> weather = forecast.get();
Entry<List<Warning>> warned = warnings();
LocalDate today = LocalDate.now(clock.withZone(ZONE));
Calibrated w = weather.value();
return new WeatherView(clock.instant(),
day(w, today), day(w, today.plusDays(1)), day(w, today.plusDays(2)),
new Now(w.forecast().temperature()), inForce(warned.value()), w.factor(),
weather.stale() || warned.stale());
}
private Entry<List<Warning>> warnings() {
try {
return warnings.get();
} catch (IOException e) {
return new Entry<>(List.of(), true);
}
}
private List<Warning> inForce(List<Warning> all) {
return all.stream().filter(w -> w.to().toInstant().isAfter(clock.instant())).toList();
}
private Calibrated calibrated(Forecast fetched) {
LocalDate today = LocalDate.now(clock.withZone(ZONE));
Map<LocalDate, Double> radiation = new HashMap<>();
fetched.days().forEach((date, day) -> {
if (day.radiationMj() != null) {
radiation.put(date, day.radiationMj());
}
});
try {
var measured = pv.dailyKwh(today.minusDays(CALIBRATION_DAYS), today);
return new Calibrated(fetched, PvCalibration.factor(measured, radiation));
} catch (IOException e) {
return new Calibrated(fetched, null);
}
}
private static Day day(Calibrated w, LocalDate date) throws IOException {
DailyForecast d = w.forecast().days().get(date);
if (d == null) {
throw new IOException("forecast has no " + date);
}
Double kwh = w.factor() == null || d.radiationMj() == null ? null : round(w.factor() * d.radiationMj());
Double sunshine = d.sunshineSeconds() == null ? null : round(d.sunshineSeconds() / 3600);
return new Day(date, d.weatherCode(), WeatherCodes.describe(d.weatherCode()), d.tMax(), d.tMin(),
d.precipitationMm(), sunshine, kwh);
}
private static double round(double value) {
return Math.round(value * 10) / 10.0;
}
}
@@ -0,0 +1,14 @@
package home.backend.domain;
import java.time.Instant;
/**
* One audited write. {@code unit} is "°C", "%", "HHMM" (a clock time written as 1830) or "0/1" (off/on);
* the values are null when the gateway did not record them.
*/
public record AuditEntry(Instant time, String source, String what, Integer oldValue, Integer newValue,
String unit, String who, String status) {
public static final String HEAT_PUMP = "heat_pump";
public static final String BATTERY = "battery";
}
@@ -0,0 +1,7 @@
package home.backend.domain;
import java.util.List;
/** The audited writes of the heat pump and the battery, newest first. */
public record AuditView(List<AuditEntry> entries) {
}
@@ -0,0 +1,19 @@
package home.backend.domain;
import java.time.LocalTime;
import java.util.List;
/**
* A change of the Time of Use switch and of single program slots (1 to 6); a null field stays as it is.
* The gateway checks every rule (ADR-013).
*/
public record BatteryChange(Boolean enabled, List<Slot> slots) {
public BatteryChange {
slots = slots == null ? List.of() : List.copyOf(slots);
}
/** {@code from} is the slot's start, to the minute. */
public record Slot(int slot, LocalTime from, Integer socPct, Boolean gridCharge) {
}
}
@@ -0,0 +1,13 @@
package home.backend.domain;
import java.util.Map;
/**
* The gateway's verdict on a battery change, passed on unchanged. REJECTED: refused by the rules, nothing
* written. STALE: the inverter's settings are unknown or changed meanwhile, nothing written. TOO_SOON: S4.
* FAILED: a write or its read-back went wrong; {@code read} has the registers as read back.
*/
public record BatteryResult(Status status, String message, Map<Integer, Integer> read) {
public enum Status { APPLIED, REJECTED, STALE, TOO_SOON, FAILED }
}
@@ -0,0 +1,39 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/**
* The Deye's battery (F7), read-only. Power in W, + discharge / − charge. The live values
* ({@code time}, {@code stale}) come from the Deye's readings and are null when stale; the settings
* ({@code settingsTime}, {@code settingsStale}: mode, schedule, limits, capacity) come from their own,
* slower reading and are null (empty schedule) when stale. The capacity assumes the nominal 51.2 V.
* {@code reserve} is the app's backup reserve (ADR-013) and {@code editable} says the gateway accepts
* changes; they are null and false when it does not.
*/
public record BatteryView(Instant time, boolean stale, Double socPct, Double powerW, Double voltageV,
Double currentA, Double temperatureC, Double capacityKwh, Energy today, Energy total, Mode mode,
List<Slot> schedule, Limits limits, Instant settingsTime, boolean settingsStale, Reserve reserve,
boolean editable) {
/** The share of the battery kept for a power cut, and the lowest the user may set. */
public record Reserve(int pct, int floorPct) {
}
/** Battery energy in kWh. */
public record Energy(Double chargeKwh, Double dischargeKwh) {
}
/** Time of use on means the battery follows the schedule; off means backup only. */
public record Mode(boolean touEnabled, String description) {
}
/** A program slot, {@code from} and {@code to} as HH:MM; it runs until the next slot starts. */
public record Slot(String from, String to, int powerW, int socPct, boolean gridCharge) {
}
public record Limits(Integer maxChargeA, Integer maxDischargeA, Integer shutdownSocPct,
Integer lowSocPct, Integer restartSocPct, boolean gridChargeEnabled, Integer gridChargeCurrentA,
Integer gridChargeStartSocPct) {
}
}
@@ -0,0 +1,19 @@
package home.backend.domain;
import java.time.Duration;
import java.time.Instant;
/**
* Set a setpoint. With a duration the gateway restores the old value afterwards; with
* {@code heldUntil} it holds a lowering value until then without renewals (ADR-012); else it stays
* while the backend renews the lease (ADR-011).
*/
public record Command(String setpoint, int value, Duration duration, Instant heldUntil, String requestedBy) {
public Command(String setpoint, int value, Duration duration, String requestedBy) {
this(setpoint, value, duration, null, requestedBy);
}
/** The gateway refuses longer boosts too; we refuse first, so the phone gets a clear 400. */
public static final int MAX_HOURS = 72;
}
@@ -0,0 +1,10 @@
package home.backend.domain;
import java.time.Instant;
/** The gateway's verdict on a command, passed on to the phone unchanged. */
public record CommandResult(Status status, String message, Instant restoreAt) {
/** REJECTED: refused before any write. FAILED: the write or its read-back failed. */
public enum Status { APPLIED, REJECTED, FAILED }
}
@@ -0,0 +1,22 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/**
* Every value the energy devices report, grouped for a details panel (F7). A device without a
* fresh reading has {@code stale} true and no groups; a field the device did not report is absent.
*/
public record EnergyDetails(List<Device> devices) {
/** One device; {@code id} is the gateway's device name. */
public record Device(String id, String name, Instant time, boolean stale, List<Group> groups) {
}
public record Group(String title, List<Item> items) {
}
/** A value in {@code unit}, to be shown with {@code digits} decimals. */
public record Item(String label, double value, String unit, int digits) {
}
}
@@ -0,0 +1,22 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/**
* The house's electricity now (F7). Powers in W; grid + import / − export, battery + discharge /
* − charge. {@code housePowerW} is the whole house by balance; {@code backupOutputW} only the Deye's
* backup (EPS) output. Values that come only from a stale source are null. {@code time} is the oldest source
* time (null when none was read); {@code sources} lists Deye and Fronius with their own time and freshness.
*/
public record EnergyView(Instant time, List<Source> sources, Double pvPowerW, Double gridPowerW,
Double batteryPowerW, Double batterySocPct, Double housePowerW, Double backupOutputW, Today today) {
/** One inverter; time is null and stale true when the gateway has no reading of it. */
public record Source(String name, Instant time, boolean stale) {
}
/** Day totals in kWh since local midnight; PV adds Deye and Fronius, house is the energy balance. */
public record Today(Double pvKwh, Double gridBoughtKwh, Double gridSoldKwh, Double houseKwh) {
}
}
@@ -0,0 +1,13 @@
package home.backend.domain;
import java.time.LocalDate;
import java.util.Map;
/** What the forecast service says: the temperature now and a record per calendar day, past days included. */
public record Forecast(Double temperature, Map<LocalDate, DailyForecast> days) {
/** Radiation in MJ/m² per day, sunshine in seconds; any value may be missing. */
public record DailyForecast(Integer weatherCode, Double tMax, Double tMin, Double precipitationMm,
Double radiationMj, Double sunshineSeconds) {
}
}
@@ -0,0 +1,18 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
import java.util.Map;
/** The gateway's latest state of every device, as the backend sees it.
* The version is the gateway's release tag, null for a gateway that does not report it yet. */
public record GatewaySnapshot(String version, List<DeviceSnapshot> devices) {
public record DeviceSnapshot(String name, boolean up, Instant lastSuccess, String lastError,
List<Measurement> measurements) {
}
/** One reading the gateway wrote to InfluxDB, e.g. heat_pump or deye. */
public record Measurement(String database, String measurement, Map<String, Double> fields, Instant time) {
}
}
@@ -0,0 +1,13 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/** Health of the chain phone → backend → gateway → devices (F5), with the deployed versions.
* The gateway's version is null when it is unreachable or too old to report it. */
public record HealthView(Instant time, boolean gatewayReachable, List<DeviceHealth> devices,
String backendVersion, String gatewayVersion) {
public record DeviceHealth(String name, boolean up, Instant lastSuccess, String lastError) {
}
}
@@ -0,0 +1,8 @@
package home.backend.domain;
import java.time.Instant;
/** One poll of the heat pump from InfluxDB; a field the poll did not carry is null. */
public record HeatPumpSample(Instant t, Double compressor, Double cwu, Double co,
Double outdoorTemp, Double cwuTemp, Periods periods) {
}
@@ -0,0 +1,30 @@
package home.backend.domain;
import java.time.Instant;
/**
* What the heat pump does now (F1). Without a reading from the gateway {@code time} and the values are
* null; {@code stale} is true when the device is down or the reading is older than 2 × 300 s.
*/
public record HeatPumpView(Instant time, boolean stale, State state, Integer compressorPercent,
boolean alarm, Integer alarmCode, String alarmText, Pumps pumps, Temperatures temperatures,
Targets targets, Setpoints setpoints, Periods periods) {
/** From register 4227: 0 stop, 1 CO, 2 CWU, 3 both. */
public enum State { IDLE, HEATING_CO, HEATING_CWU, HEATING_BOTH }
public record Pumps(boolean co, boolean buffer, boolean circulation, boolean ground, boolean cwu) {
}
public record Temperatures(Double outdoor, Double indoor, Double coBuffer, Double cwu,
Double groundIn, Double groundOut, Double pumpOutlet, Double outdoorAvg) {
}
/** Targets the controller works to right now (economy or comfort period). */
public record Targets(Double room, Double co, Double cwu) {
}
/** The four setpoints F3 will change; whole °C. */
public record Setpoints(Integer coEconomy, Integer coComfort, Integer cwuEconomy, Integer cwuComfort) {
}
}
@@ -0,0 +1,11 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/** One series over a time range, already averaged into chart-sized steps. */
public record HistoryView(String series, String unit, List<Point> points) {
public record Point(Instant t, double v) {
}
}
@@ -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();
}
}
Loaded 100 of 2560 files, more files were not shown because too many files have changed in this diff. Show more