Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:54:45 +02:00
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
CI / Check (gateway, backend, frontend) (push) Successful in 1m12s
Release / Images (push) Successful in 2m54s
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
227 changed files with 12533 additions and 718 deletions

No files matched your search

+3
View File
@@ -37,6 +37,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: gateway
build-args: VERSION=${{ gitea.ref_name }}
platforms: linux/arm64,linux/amd64
push: true
tags: ${{ env.IMAGES }}-gateway:${{ gitea.ref_name }},${{ env.IMAGES }}-gateway:latest
@@ -44,6 +45,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: backend
build-args: VERSION=${{ gitea.ref_name }}
platforms: linux/amd64
push: true
tags: ${{ env.IMAGES }}-backend:${{ gitea.ref_name }},${{ env.IMAGES }}-backend:latest
@@ -51,6 +53,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: frontend
build-args: VERSION=${{ gitea.ref_name }}
platforms: linux/amd64
push: true
tags: ${{ env.IMAGES }}-frontend:${{ gitea.ref_name }},${{ env.IMAGES }}-frontend:latest
+3
View File
@@ -8,6 +8,9 @@ COPY src src
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
+57 -1
View File
@@ -3,6 +3,43 @@
The phone API in Kraków (ADR-008): live state from the gateway, history from InfluxDB, setpoint
changes passed to the gateway. JSON under `/api/` only; the web UI is the frontend's (ADR-010).
The records in `home.backend.domain` and the examples in `src/test/resources/api/` are the contract.
`GET /api/energy/details` lists the Deye, Fronius and house meter values in labelled groups (a stale device has none); the label, unit and place of every field are the catalog in `EnergyDetailsService`: a new field is one line there.
`GET /api/pi-sensor` is the BME280 sensor on the Raspberry Pi (the living room), example `src/test/resources/api/pi-sensor.json`:
`time`, `stale`, `temperature` (°C), `humidity` (%), `pressure` (hPa). When it is stale (device down, no reading or older than 10 min)
the three values are null (`pi-sensor-stale.json`).
`GET /api/stats?range=7d|30d` (example `src/test/resources/api/stats.json`; 400 otherwise, 502 when InfluxDB is down) is the heat pump per local
day (Europe/Warsaw, today included) from the raw `heat_pump` polls: `days[]` with `date`, `compressorHours`, `compressorStarts`, `cwuHours`, `coHours`,
`heaterHours`, `avgOutdoorTemp`, `minCwuTemp`, `maxCwuTemp` (all null on a day without polls) and `totals` over the range. A poll holds its outputs
until the next one, at most 600 s, so gaps add no hours; a start is a poll above 0 after a poll at 0, so the 300 s polling misses short cycles.
`GET /api/battery` is the Deye battery, example `src/test/resources/api/battery.json`: live `socPct`, `powerW` (+ discharge / − charge), `voltageV`,
`currentA`, `temperatureC`, `today` and `total` energy (`chargeKwh`, `dischargeKwh`), null when the Deye is stale (older than 10 min or down).
The settings come from the gateway's slower `deye_settings` reading and are stale after 30 min (`battery-settings-stale.json`):
`mode` (`touEnabled` with a Polish sentence), `schedule` (six slots, each from its start to the next one, the last wraps past midnight),
`limits`, and `capacityKwh` = capacity in Ah x a nominal 51.2 V, since the inverter reports Ah only. When stale they are null (`schedule` empty).
`/api/battery` also has `reserve` (`pct`, `floorPct`: the backup reserve of the app and the lowest the user may set) and `editable`:
true when the gateway answers its `/battery/reserve`, i.e. it has battery commands on and `GATEWAY_TOKEN` is right; else `reserve` is null
and `editable` false. `POST /api/battery/schedule` (examples `battery-schedule-request.json`, `battery-failed.json`) takes
`{"enabled"?, "slots": [{"slot": 1-6, "from"?: "HH:MM", "socPct"?, "gridCharge"?}]}` and `POST /api/battery/reserve` takes `{"pct": 60}`.
Same Origin, header and JSON rules as the other writes; `Remote-User` goes on as the requester. The gateway's verdict comes back as
`{status, message, read}`: 200 applied, 422 rejected, 409 settings unknown or changed meanwhile, 429 too soon, 502 failed (`read` is what the
inverter holds), 400 bad body, 503 gateway unreachable or without battery commands (ADR-013).
`GET /api/audit?range=7d` (`24h`, `7d` default, `30d`; 400 otherwise, 502 when InfluxDB fails) is the change history, example
`src/test/resources/api/audit.json`: the gateway's audit rows of the heat pump (`Dom_Pompa.setpoint_audit`) and the battery
(`electricity.deye_settings_audit`), read only, as `entries` newest first: `time`, `source` (`heat_pump`, `battery`), `what` (Polish label;
the Deye registers are named in `DeyeSettingLabels`), `oldValue`/`newValue` (whole numbers, null when not recorded), `unit` (`°C`, `%`,
`HHMM`, `0/1`), `who` (`Remote-User` of the request; a restore is "automatycznie (przywrócenie)") and `status` (the gateway's: APPLIED, FAILED, REJECTED, CONFLICT, INVALID).
`GET /api/weather` (example `src/test/resources/api/weather.json`) is the forecast of Open-Meteo for the house and the IMGW warnings that name
Krosno (TERYT 1862 or the powiat 1807): `today`, `tomorrow`, `dayAfter` (`date`, `weatherCode` WMO, Polish `description`, `tMax`, `tMin`,
`precipitationMm`, `sunshineHours`, `pvForecastKwh`), `now.temperature`, `warnings` (`event`, `level` 1-3, `from`, `to`, `text`; the ones that
ended are dropped). `pvFactor` is kWh per MJ/m^2: the Deye plus Fronius energy of the past 14 complete days (from InfluxDB, daily maximum of
`pv_today_kwh` and `energy_day_wh`) over the forecast radiation of the same days, so no tilt or azimuth is needed; `pvForecastKwh` is the factor
times the day's radiation, and both are null with fewer than 5 days that have both values. The forecast is kept 30 min and the warnings 10 min;
when a refresh fails the older answer is served with `stale` true. 503 only when there never was a forecast. The sources are public and need no key.
Settings are environment variables (`.env` next to `compose.yaml`); the backend refuses to start
without the two URLs.
@@ -16,7 +53,9 @@ Setpoint changes go only to the gateway; the user name from Traefik's `Remote-Us
| `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 |
| `PRESETS_FILE` | built-in | JSON file replacing the presets: `[{"id", "name", "changes": [{"setpoint", "value", "hours"}]}]`, `hours` null means until changed; a bad file stops the start |
| `WEATHER_LAT`, `WEATHER_LON` | `49.684`, `21.770` | where the house is (Krosno, ul. Szczepańcowa), for the forecast |
| `WEATHER_TERYT` | `1862,1807` | comma-separated TERYT powiat codes whose IMGW warnings count |
| `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
@@ -25,6 +64,23 @@ GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example \
docker compose -f backend/compose.yaml up --build # with backend/.env; network home-pump
```
`POST /api/presets/{id}` takes no body for an ordinary preset. A preset with `"until": "chosen"` (the built-in "Wyjazd")
needs `{"until": "2026-11-02T18:00:00+01:00"}` (ISO-8601 with offset, example `src/test/resources/api/preset-away-request.json`):
400 when it is missing, invalid, or sent to a preset that has none. Any preset also takes optional `"values"`
(example `preset-away-values-request.json`): a whole number per setpoint of that preset, replacing the preset's value;
the others keep it. 400 for a setpoint the preset does not change or a value outside the gateway's min, max and step
(from `/commands`), checked before anything is sent; a held value that is not below the current one is 422. The gateway holds those changes until then without
renewals (ADR-012) and rejects an end that is past or over 30 days away, or a change that does not lower the value (422).
`POST /api/setpoints/{name}/restore` (same Origin, header and JSON rules as the other writes; send `{}`) ends the
pending change of that setpoint now and answers with the gateway's verdict, example
`src/test/resources/api/setpoint-restored.json`: 200 restored, 404 nothing pending, 409 the value was changed
meanwhile (left alone, the restore is dropped), 429 too soon after the last write, 502 failed (the restore stays
pending), 503 gateway unreachable.
Publish no host port in production: only the frontend, behind Traefik, may reach the backend. It
trusts `Remote-User` and has no login of its own, so a direct port would bypass Traefik's access
rules (ADR-009).
`/api/health` also carries `backendVersion` (the `VERSION` the image was built with, `dev` locally) and
`gatewayVersion` (from the gateway's `/health`; null when it is unreachable or too old to report it).
@@ -4,20 +4,39 @@ import home.backend.adapters.config.PresetFile;
import home.backend.adapters.gateway.GatewayHttpClient;
import home.backend.adapters.gateway.GatewayHttpCommands;
import home.backend.adapters.http.ApiServer;
import home.backend.adapters.influx.InfluxAuditRepository;
import home.backend.adapters.influx.InfluxHistoryRepository;
import home.backend.adapters.influx.InfluxPvEnergy;
import home.backend.adapters.influx.InfluxSettings;
import home.backend.adapters.weather.ImgwClient;
import home.backend.adapters.weather.OpenMeteoClient;
import home.backend.adapters.weather.WeatherSettings;
import home.backend.application.AuditService;
import home.backend.application.BatteryService;
import home.backend.application.EnergyDetailsService;
import home.backend.application.EnergyService;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService;
import home.backend.application.StatsService;
import home.backend.application.LeaseRenewer;
import home.backend.application.PiSensorService;
import home.backend.application.SetpointService;
import home.backend.application.WeatherService;
import java.io.IOException;
import java.net.URI;
import java.time.Clock;
import java.time.Duration;
import java.util.Objects;
/** Wires the adapters and services together; the only place that knows all of them. */
final class Backend {
private static final Duration WEATHER_TIMEOUT = Duration.ofSeconds(10);
/** Warnings are optional, so a slow IMGW must not hold the forecast. */
private static final Duration WARNINGS_TIMEOUT = Duration.ofSeconds(3);
private Backend() {
}
@@ -28,14 +47,35 @@ final class Backend {
*/
static ApiServer assemble(Settings settings, Clock clock) throws IOException {
var gateway = new GatewayHttpClient(settings.gatewayUrl(), settings.gatewayTimeout());
var influx = new InfluxHistoryRepository(new InfluxSettings(settings.influxUrl(), settings.influxTimeout()));
var commands = new GatewayHttpCommands(settings.gatewayUrl(), settings.gatewayToken(),
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 HealthService(gateway, clock),
new HistoryService(influx, clock), setpoints);
new EnergyDetailsService(gateway, clock),
new HealthService(gateway, clock, Objects.requireNonNullElse(System.getenv("VERSION"), "dev")),
new HistoryService(influx, clock), setpoints, new PiSensorService(gateway, clock),
new BatteryService(gateway, commands, clock),
new AuditService(new InfluxAuditRepository(influxSettings), clock), new StatsService(influx, clock),
weather(influxSettings, clock));
}
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());
}
}
+5 -1
View File
@@ -21,10 +21,14 @@ public final class Main {
System.exit(2);
return;
}
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();
}
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
server.start();
System.out.println("home-pump-backend listening on port " + server.port());
}
@@ -23,7 +23,7 @@ import java.util.stream.Collectors;
/** Reads the gateway's {@code /health} and {@code /snapshot} over HTTP. */
public class GatewayHttpClient implements GatewayClient {
private record Health(List<DeviceHealth> devices) {
private record Health(String version, List<DeviceHealth> devices) {
}
private record DeviceHealth(String name, String status, Instant lastSuccess, String lastError) {
@@ -61,7 +61,7 @@ public class GatewayHttpClient implements GatewayClient {
device.lastSuccess(), device.lastError(),
measurements.getOrDefault(device.name(), List.of())))
.toList();
return new GatewaySnapshot(devices);
return new GatewaySnapshot(health.version(), devices);
}
private static List<Measurement> measurements(DeviceReadings device) {
@@ -3,10 +3,16 @@ 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.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.RestoreResult;
import home.backend.domain.SetpointState;
import java.io.IOException;
@@ -15,23 +21,53 @@ 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;
/** Sends commands to the gateway's {@code /commands} with the shared token (S6). */
public class GatewayHttpCommands implements GatewayCommands {
/** Sends commands to the gateway's {@code /commands} and {@code /battery} with the shared token (S6). */
public class GatewayHttpCommands implements GatewayCommands, GatewayBattery {
/** 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 Request(String setpoint, int value, Long minutes) {
private record ScheduleRequest(Boolean enabled, List<SlotRequest> slots) {
}
@JsonInclude(JsonInclude.Include.NON_NULL)
private record SlotRequest(int slot, Integer start, Integer soc, Boolean gridCharge) {
}
private record ReserveRequest(int pct) {
}
@JsonInclude(JsonInclude.Include.NON_NULL)
private record Request(String setpoint, int value, Long minutes, Instant until) {
}
private record RestoreRequest(String setpoint) {
}
private record Renewal(int renewed) {
}
private static final ObjectMapper JSON = new ObjectMapper()
.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 reserveEndpoint;
private final URI scheduleEndpoint;
private final String token;
private final Duration timeout;
private final HttpClient http;
@@ -39,6 +75,10 @@ public class GatewayHttpCommands implements GatewayCommands {
/** @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.reserveEndpoint = baseUri.resolve("/battery/reserve");
this.scheduleEndpoint = baseUri.resolve("/battery/schedule");
this.token = token;
this.timeout = timeout;
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
@@ -47,35 +87,78 @@ public class GatewayHttpCommands implements GatewayCommands {
@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));
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 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(endpoint).timeout(timeout).header("Authorization", "Bearer " + token);
return HttpRequest.newBuilder(uri).timeout(timeout).header("Authorization", "Bearer " + token);
}
private byte[] send(HttpRequest.Builder request, Set<Integer> accepted) throws IOException {
try {
var response = http.send(request.build(), HttpResponse.BodyHandlers.ofByteArray());
var built = request.build();
var response = http.send(built, HttpResponse.BodyHandlers.ofByteArray());
if (!accepted.contains(response.statusCode())) {
throw new IOException("gateway /commands answered " + 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 gateway /commands", e);
throw new IOException("interrupted while calling the gateway", e);
}
}
}
@@ -9,15 +9,26 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import home.backend.application.AuditService;
import home.backend.application.BatteryService;
import home.backend.application.EnergyDetailsService;
import home.backend.application.EnergyService;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService;
import home.backend.application.StatsService;
import home.backend.application.PiSensorService;
import home.backend.application.SetpointService;
import home.backend.application.WeatherService;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.CommandResult.Status;
import home.backend.domain.PresetResult;
import home.backend.domain.RestoreResult;
import java.io.IOException;
import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
@@ -37,12 +48,30 @@ public class ApiServer {
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS);
private record ReserveRequest(Integer pct) {
}
private record SetpointRequest(String setpoint, Integer value, Double hours) {
}
/** {@code values}: the user's own value per setpoint of the preset, by setpoint name. */
private record PresetRequest(String until, Map<String, Integer> values) {
PresetRequest {
if (values == null) {
values = Map.of();
} else if (values.containsValue(null)) {
throw new IllegalArgumentException("a value must be a number");
}
}
}
private static final String UNREACHABLE = "gateway unreachable";
private static final String UNAVAILABLE = "battery changes are not available: gateway unreachable or not enabled";
private static final String WRITE_MARKER = "home-pump";
private static final String PRESETS_PATH = "/api/presets/";
private static final String SETPOINTS_PATH = "/api/setpoints/";
private static final Pattern RESTORE_PATH = Pattern.compile("/api/setpoints/([a-z_]{1,32})/restore");
/** The user name Traefik's auth forwards; anything odd falls back to the default. */
private static final String USER_HEADER = "Remote-User";
private static final String DEFAULT_USER = "phone";
@@ -58,8 +87,11 @@ public class ApiServer {
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
/** Binds the port now; 0 picks a free one. */
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy, HealthService health,
HistoryService history, SetpointService setpoints) throws IOException {
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
EnergyDetailsService energyDetails, HealthService health,
HistoryService history, SetpointService setpoints, PiSensorService piSensor,
BatteryService battery, AuditService audit, StatsService stats, WeatherService weather)
throws IOException {
server = HttpServer.create(new InetSocketAddress(port), 0);
server.setExecutor(executor);
// Everything outside the API, with the security headers too: the web UI is the frontend's (ADR-010).
@@ -70,13 +102,34 @@ public class ApiServer {
});
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)))));
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/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, setpoints)))));
"POST", () -> guarded(exchange, body -> postPreset(exchange, body, setpoints)))));
}
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
@@ -175,15 +228,63 @@ public class ApiServer {
}
}
private static void postPreset(HttpExchange exchange, SetpointService setpoints) throws IOException {
private static void postSchedule(HttpExchange exchange, byte[] body, BatteryService battery) throws IOException {
try {
var change = JSON.readValue(body, BatteryChange.class);
if (change == null) {
throw new IllegalArgumentException("a change is required");
}
var result = battery.changeSchedule(change, user(exchange));
send(exchange, batteryHttpStatus(result.status()), result);
} catch (JsonProcessingException | IllegalArgumentException e) {
send(exchange, 400, error("expected {\"enabled\"?, \"slots\": [{\"slot\", \"from\"?: \"HH:MM\", "
+ "\"socPct\"?, \"gridCharge\"?}]}"));
} catch (IOException e) {
send(exchange, 503, error(UNAVAILABLE));
}
}
private static void postReserve(HttpExchange exchange, byte[] body, BatteryService battery) throws IOException {
try {
var request = JSON.readValue(body, ReserveRequest.class);
if (request == null || request.pct() == null) {
throw new IllegalArgumentException("pct is required");
}
var result = battery.changeReserve(request.pct(), user(exchange));
send(exchange, batteryHttpStatus(result.status()), result);
} catch (JsonProcessingException | IllegalArgumentException e) {
send(exchange, 400, error("expected {\"pct\"}"));
} catch (IOException e) {
send(exchange, 503, error(UNAVAILABLE));
}
}
private static void 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 result = setpoints.applyPreset(id, user(exchange));
var request = body.length == 0 ? new PresetRequest(null, null) : JSON.readValue(body, PresetRequest.class);
var until = request.until() == null ? null : OffsetDateTime.parse(request.until()).toInstant();
var result = setpoints.applyPreset(id, until, request.values(), user(exchange));
if (result.isPresent()) {
send(exchange, presetHttpStatus(result.get().status()), result.get());
} else {
send(exchange, 404, error("no such preset"));
}
} catch (JsonProcessingException | DateTimeParseException | IllegalArgumentException e) {
send(exchange, 400, error("expected {\"until\": \"2026-11-02T18:00:00+01:00\"} for a preset that needs "
+ "an end time, else no body; optional \"values\": {\"cwu_comfort\": 12} within the limits "
+ "for setpoints of the preset"));
} catch (IOException e) {
send(exchange, 503, error(UNREACHABLE));
}
@@ -197,6 +298,26 @@ public class ApiServer {
};
}
private static int batteryHttpStatus(BatteryResult.Status status) {
return switch (status) {
case APPLIED -> 200;
case REJECTED -> 422;
case STALE -> 409;
case TOO_SOON -> 429;
case FAILED -> 502;
};
}
private static int restoreHttpStatus(RestoreResult.Outcome outcome) {
return switch (outcome) {
case RESTORED -> 200;
case NOT_PENDING -> 404;
case CONFLICT -> 409;
case TOO_SOON -> 429;
case FAILED -> 502;
};
}
private static int presetHttpStatus(PresetResult.Status status) {
return switch (status) {
case APPLIED -> 200;
@@ -223,6 +344,31 @@ public class ApiServer {
}));
}
private void handleAudit(HttpExchange exchange, AuditService audit) throws IOException {
handle(exchange, Map.of("GET", () -> {
Map<String, String> query = parseQuery(exchange.getRequestURI().getRawQuery());
try {
send(exchange, 200, audit.audit(query.get("range")));
} catch (IllegalArgumentException e) {
send(exchange, 400, error(e.getMessage()));
} catch (IOException e) {
send(exchange, 502, error("audit unavailable"));
}
}));
}
private void handleStats(HttpExchange exchange, StatsService stats) throws IOException {
handle(exchange, Map.of("GET", () -> {
try {
send(exchange, 200, stats.stats(parseQuery(exchange.getRequestURI().getRawQuery()).get("range")));
} catch (IllegalArgumentException e) {
send(exchange, 400, error(e.getMessage()));
} catch (IOException e) {
send(exchange, 502, error("stats unavailable"));
}
}));
}
private interface Action {
void run() throws IOException;
}
@@ -0,0 +1,88 @@
package home.backend.adapters.influx;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.backend.application.AuditRepository;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** The gateway's audit measurements (S3) read from InfluxDB; never written. */
public final class InfluxAuditRepository implements AuditRepository {
private static final ObjectMapper JSON = new ObjectMapper();
private final InfluxClient influx;
public InfluxAuditRepository(InfluxSettings settings) {
this.influx = new InfluxClient(settings);
}
@Override
public List<Row> heatPump(Instant from, Instant to) throws IOException {
return rows("Dom_Pompa", "setpoint_audit", "setpoint", from, to);
}
@Override
public List<Row> battery(Instant from, Instant to) throws IOException {
return rows("electricity", "deye_settings_audit", "register", from, to);
}
private List<Row> rows(String database, String measurement, String targetTag, Instant from, Instant to)
throws IOException {
String query = "SELECT * FROM \"" + measurement + "\" WHERE time >= " + from.toEpochMilli()
+ "ms AND time < " + to.toEpochMilli() + "ms ORDER BY time DESC";
var rows = new ArrayList<Row>();
for (JsonNode result : results(influx.query(database, query))) {
for (JsonNode series : result.path("series")) {
for (JsonNode values : series.path("values")) {
rows.add(row(series.path("columns"), values, targetTag));
}
}
}
return rows;
}
private static JsonNode results(String body) throws IOException {
JsonNode root;
try {
root = JSON.readTree(body);
} catch (JsonProcessingException e) {
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
}
failOnError(root);
for (JsonNode result : root.path("results")) {
failOnError(result);
}
return root.path("results");
}
private static void failOnError(JsonNode node) throws IOException {
if (node.hasNonNull("error")) {
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
}
}
private static Row row(JsonNode columns, JsonNode values, String targetTag) {
Map<String, JsonNode> byName = new HashMap<>();
for (int i = 0; i < columns.size(); i++) {
byName.put(columns.get(i).asText(), values.get(i));
}
return new Row(Instant.ofEpochMilli(byName.get("time").asLong()), text(byName.get(targetTag)),
text(byName.get("requested_by")), text(byName.get("status")),
number(byName.get("old_value")), number(byName.get("new_value")));
}
private static String text(JsonNode node) {
return node == null || node.isNull() ? "" : node.asText();
}
private static Double number(JsonNode node) {
return node == null || !node.isNumber() ? null : node.asDouble();
}
}
@@ -0,0 +1,47 @@
package home.backend.adapters.influx;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
/** Read-only queries to InfluxDB 1.x over its HTTP {@code /query} endpoint; times come back in epoch ms. */
final class InfluxClient {
private final InfluxSettings settings;
private final HttpClient http;
InfluxClient(InfluxSettings settings) {
this.settings = settings;
this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build();
}
String query(String database, String query) throws IOException {
HttpRequest request = HttpRequest.newBuilder(queryUri(database, query))
.timeout(settings.requestTimeout())
.GET()
.build();
try {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("InfluxDB answered HTTP " + response.statusCode());
}
return response.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("InfluxDB request interrupted", e);
}
}
private URI queryUri(String database, String query) {
String base = settings.baseUri().toString().replaceAll("/+$", "");
return URI.create(base + "/query?db=" + encode(database) + "&epoch=ms&q=" + encode(query));
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}
@@ -1,16 +1,12 @@
package home.backend.adapters.influx;
import home.backend.application.HeatPumpSamples;
import home.backend.application.HistoryRepository;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
@@ -20,14 +16,12 @@ import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
/** History from InfluxDB 1.x over its HTTP {@code /query} endpoint (ADR-005, ADR-008). */
public final class InfluxHistoryRepository implements HistoryRepository {
public final class InfluxHistoryRepository implements HistoryRepository, HeatPumpSamples {
private final InfluxSettings settings;
private final HttpClient http;
private final InfluxClient influx;
public InfluxHistoryRepository(InfluxSettings settings) {
this.settings = settings;
this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build();
this.influx = new InfluxClient(settings);
}
@Override
@@ -57,39 +51,19 @@ public final class InfluxHistoryRepository implements HistoryRepository {
.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(fetch(source.database(), query));
return InfluxResponse.parse(influx.query(source.database(), query));
}
private static long stepMinutes(Instant from, Instant to, int maxPoints) {
long stepMillis = Math.ceilDiv(Duration.between(from, to).toMillis(), maxPoints);
return Math.max(1, Math.ceilDiv(stepMillis, Duration.ofMinutes(1).toMillis()));
}
private String fetch(String database, String query) throws IOException {
HttpRequest request = HttpRequest.newBuilder(queryUri(database, query))
.timeout(settings.requestTimeout())
.GET()
.build();
try {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("InfluxDB answered HTTP " + response.statusCode());
}
return response.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("InfluxDB request interrupted", e);
}
}
private URI queryUri(String database, String query) {
String base = settings.baseUri().toString().replaceAll("/+$", "");
return URI.create(base + "/query?db=" + encode(database) + "&epoch=ms&q=" + encode(query));
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,51 @@
package home.backend.adapters.influx;
import home.backend.application.PvEnergyRepository;
import java.io.IOException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Map;
import java.util.TreeMap;
/** Daily PV energy: the Deye's and the Fronius's own day counters, each read as the day's maximum. */
public final class InfluxPvEnergy implements PvEnergyRepository {
private static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
private static final String DATABASE = "electricity";
private final InfluxClient influx;
public InfluxPvEnergy(InfluxSettings settings) {
this.influx = new InfluxClient(settings);
}
@Override
public Map<LocalDate, Double> dailyKwh(LocalDate from, LocalDate to) throws IOException {
Map<LocalDate, Double> deye = dailyMax("deye", "pv_today_kwh", from, to);
Map<LocalDate, Double> fronius = dailyMax("fronius", "energy_day_wh", from, to);
Map<LocalDate, Double> total = new TreeMap<>();
deye.forEach((day, kwh) -> {
Double wh = fronius.get(day);
if (wh != null) {
total.put(day, kwh + wh / 1000);
}
});
return total;
}
private Map<LocalDate, Double> dailyMax(String measurement, String field, LocalDate from, LocalDate to)
throws IOException {
String query = "SELECT max(\"" + field + "\") FROM \"" + measurement + "\" WHERE time >= "
+ millis(from) + "ms AND time < " + millis(to) + "ms GROUP BY time(1d) fill(none) tz('Europe/Warsaw')";
Map<LocalDate, Double> days = new TreeMap<>();
InfluxResponse.parse(influx.query(DATABASE, query)).forEach((start, max) ->
days.put(Instant.ofEpochMilli(start).atZone(ZONE).toLocalDate(), max));
return days;
}
private static long millis(LocalDate day) {
return day.atStartOfDay(ZONE).toInstant().toEpochMilli();
}
}
@@ -0,0 +1,58 @@
package home.backend.adapters.influx;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.backend.domain.HeatPumpSample;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
/** The raw {@code heat_pump} rows of an InfluxDB 1.x query. */
final class InfluxSamples {
private static final ObjectMapper JSON = new ObjectMapper();
private InfluxSamples() {
}
static String query(long fromMs, long toMs) {
return "SELECT output_compressor, output_cwu, output_co, output_heater, outdoor_temp, cwu_temp"
+ " FROM \"heat_pump\" WHERE time >= " + fromMs + "ms AND time < " + toMs + "ms";
}
/** The rows of {@link #query}, as returned (ascending); empty when there is no data. */
static List<HeatPumpSample> parse(String body) throws IOException {
JsonNode root;
try {
root = JSON.readTree(body);
} catch (JsonProcessingException e) {
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
}
failOnError(root);
List<HeatPumpSample> samples = new ArrayList<>();
for (JsonNode result : root.path("results")) {
failOnError(result);
for (JsonNode series : result.path("series")) {
for (JsonNode row : series.path("values")) {
samples.add(new HeatPumpSample(Instant.ofEpochMilli(row.get(0).asLong()),
number(row, 1), number(row, 2), number(row, 3), number(row, 4),
number(row, 5), number(row, 6)));
}
}
}
return samples;
}
private static Double number(JsonNode row, int column) {
return row.get(column).isNumber() ? row.get(column).asDouble() : null;
}
private static void failOnError(JsonNode node) throws IOException {
if (node.hasNonNull("error")) {
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
}
}
}
@@ -8,7 +8,7 @@ import java.util.Map;
/** Where each series lives in InfluxDB. A fixed table: no query is built from user input. */
final class SeriesSources {
private static final String LEGACY = "Dom_Pompa";
static final String LEGACY = "Dom_Pompa";
private static final String ELECTRICITY = "electricity";
private static final Map<Series, List<Source>> SOURCES = Map.ofEntries(
@@ -0,0 +1,42 @@
package home.backend.adapters.weather;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
/** GET of a public JSON document, with a timeout on connecting and on the whole request. */
final class HttpJson {
private static final ObjectMapper JSON = new ObjectMapper();
private final HttpClient http;
private final Duration timeout;
HttpJson(Duration timeout) {
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
this.timeout = timeout;
}
JsonNode get(URI uri) throws IOException {
HttpRequest request = HttpRequest.newBuilder(uri).timeout(timeout).GET().build();
try {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException(uri.getHost() + " answered HTTP " + response.statusCode());
}
return JSON.readTree(response.body());
} catch (JsonProcessingException e) {
throw new IOException(uri.getHost() + " returned invalid JSON: " + e.getOriginalMessage(), e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("request to " + uri.getHost() + " interrupted", e);
}
}
}
@@ -0,0 +1,76 @@
package home.backend.adapters.weather;
import com.fasterxml.jackson.databind.JsonNode;
import home.backend.application.WarningSource;
import home.backend.domain.WeatherView.Warning;
import java.io.IOException;
import java.net.URI;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* IMGW's public list of meteorological warnings, kept to those naming one of our TERYT areas. A warning
* that is malformed (no degree or times) is left out rather than failing the others.
*/
public final class ImgwClient implements WarningSource {
private static final ZoneId POLAND = ZoneId.of("Europe/Warsaw");
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final HttpJson http;
private final URI uri;
private final List<String> teryt;
public ImgwClient(URI baseUri, WeatherSettings place, Duration timeout) {
this.http = new HttpJson(timeout);
this.uri = URI.create(baseUri.toString().replaceAll("/+$", "") + "/api/data/warningsmeteo");
this.teryt = place.teryt();
}
@Override
public List<Warning> warnings() throws IOException {
JsonNode root = http.get(uri);
if (!root.isArray()) {
throw new IOException("IMGW returned no list of warnings");
}
List<Warning> ours = new ArrayList<>();
for (JsonNode item : root) {
if (covers(item.path("teryt"))) {
warning(item).ifPresent(ours::add);
}
}
return ours;
}
private boolean covers(JsonNode codes) {
for (JsonNode code : codes) {
if (teryt.contains(code.asText())) {
return true;
}
}
return false;
}
private static Optional<Warning> warning(JsonNode item) {
try {
return Optional.of(new Warning(item.path("nazwa_zdarzenia").asText(),
Integer.parseInt(item.path("stopien").asText()),
time(item.path("obowiazuje_od")), time(item.path("obowiazuje_do")),
item.path("tresc").asText()));
} catch (NumberFormatException | DateTimeException e) {
return Optional.empty();
}
}
private static OffsetDateTime time(JsonNode text) {
return LocalDateTime.parse(text.asText(), TIME).atZone(POLAND).toOffsetDateTime();
}
}
@@ -0,0 +1,68 @@
package home.backend.adapters.weather;
import com.fasterxml.jackson.databind.JsonNode;
import home.backend.application.ForecastSource;
import home.backend.domain.Forecast;
import home.backend.domain.Forecast.DailyForecast;
import java.io.IOException;
import java.net.URI;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.LocalDate;
import java.util.Map;
import java.util.TreeMap;
/** Open-Meteo (free, no key): daily values for the past 14 days and the next 4, and the temperature now. */
public final class OpenMeteoClient implements ForecastSource {
private static final String DAILY = "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,"
+ "shortwave_radiation_sum,sunshine_duration";
private final HttpJson http;
private final URI uri;
public OpenMeteoClient(URI baseUri, WeatherSettings place, Duration timeout) {
this.http = new HttpJson(timeout);
this.uri = URI.create(baseUri.toString().replaceAll("/+$", "") + "/v1/forecast?latitude="
+ place.latitude() + "&longitude=" + place.longitude() + "&daily=" + DAILY
+ "&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,96 @@
package home.backend.application;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.StatsView.Day;
import home.backend.domain.StatsView.Totals;
import java.time.Duration;
import java.time.LocalDate;
/** What the heat pump did over some span of polls; days are added up into the totals. */
final class DayTally {
private double compressorSeconds;
private double cwuSeconds;
private double coSeconds;
private double heaterSeconds;
private int starts;
private int samples;
private double outdoorSum;
private int outdoorCount;
private Double minCwu;
private Double maxCwu;
void sample(HeatPumpSample s) {
samples++;
if (s.outdoorTemp() != null) {
outdoorSum += s.outdoorTemp();
outdoorCount++;
}
if (s.cwuTemp() != null) {
minCwu = lower(minCwu, s.cwuTemp());
maxCwu = higher(maxCwu, s.cwuTemp());
}
}
/** The outputs of {@code s} held for {@code held}. */
void hold(HeatPumpSample s, Duration held) {
double seconds = held.toMillis() / 1000.0;
compressorSeconds += on(s.compressor()) ? seconds : 0;
cwuSeconds += on(s.cwu()) ? seconds : 0;
coSeconds += on(s.co()) ? seconds : 0;
heaterSeconds += on(s.heater()) ? seconds : 0;
}
void start() {
starts++;
}
void add(DayTally other) {
compressorSeconds += other.compressorSeconds;
cwuSeconds += other.cwuSeconds;
coSeconds += other.coSeconds;
heaterSeconds += other.heaterSeconds;
starts += other.starts;
samples += other.samples;
outdoorSum += other.outdoorSum;
outdoorCount += other.outdoorCount;
if (other.minCwu != null) {
minCwu = lower(minCwu, other.minCwu);
maxCwu = higher(maxCwu, other.maxCwu);
}
}
Day day(LocalDate date) {
if (samples == 0) {
return new Day(date, null, null, null, null, null, null, null, null);
}
return new Day(date, hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
hours(heaterSeconds), avgOutdoor(), minCwu, maxCwu);
}
Totals totals() {
return new Totals(hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
hours(heaterSeconds), avgOutdoor(), minCwu, maxCwu);
}
static boolean on(Double output) {
return output != null && output > 0;
}
private static Double lower(Double known, double value) {
return known == null ? value : Math.min(known, value);
}
private static Double higher(Double known, double value) {
return known == null ? value : Math.max(known, value);
}
private Double avgOutdoor() {
return outdoorCount == 0 ? null : Math.round(outdoorSum / outdoorCount * 10) / 10.0;
}
private static double hours(double seconds) {
return Math.round(seconds / 3600 * 100) / 100.0;
}
}
@@ -0,0 +1,48 @@
package home.backend.application;
import java.util.Optional;
/** Polish name and unit of the Deye program registers and of the app's reserve, as the gateway audits them. */
final class DeyeSettingLabels {
record Label(String what, String unit) {
}
private static final int TOU_ENABLE = 146;
private static final int FIRST_START = 148;
private static final int FIRST_SOC = 166;
private static final int FIRST_GRID_CHARGE = 172;
private static final int SLOTS = 6;
private DeyeSettingLabels() {
}
static Label of(String register) {
if ("reserve".equals(register)) {
return new Label("Rezerwa baterii", "%");
}
int address;
try {
address = Integer.parseInt(register);
} catch (NumberFormatException e) {
return unknown(register);
}
if (address == TOU_ENABLE) {
return new Label("Harmonogram baterii", "0/1");
}
return slot(address, FIRST_START, "start", "HHMM")
.or(() -> slot(address, FIRST_SOC, "poziom", "%"))
.or(() -> slot(address, FIRST_GRID_CHARGE, "ładowanie z sieci", "0/1"))
.orElseGet(() -> unknown(register));
}
private static Label unknown(String register) {
return new Label("Rejestr " + register, "");
}
private static Optional<Label> slot(int address, int first, String what, String unit) {
return address >= first && address < first + SLOTS
? Optional.of(new Label("Przedział " + (address - first + 1) + ": " + what, unit))
: Optional.empty();
}
}
@@ -0,0 +1,157 @@
package home.backend.application;
import home.backend.application.SnapshotFields.Live;
import home.backend.domain.EnergyDetails;
import home.backend.domain.EnergyDetails.Device;
import home.backend.domain.EnergyDetails.Group;
import home.backend.domain.EnergyDetails.Item;
import home.backend.domain.GatewaySnapshot;
import java.io.IOException;
import java.time.Clock;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.IntStream;
/**
* F7 details: the Deye, Fronius and house meter fields worth a look, grouped and labelled. The
* catalog below is the only place that knows a field's label, unit and place; showing a new field
* is one line here.
*/
public class EnergyDetailsService {
/** A gateway field shown as {@code label}; {@code scale} turns the stored unit into {@code unit}. */
private record Field(String key, String label, String unit, int digits, double scale) {
}
private record Spec(String id, String name, String measurement, List<GroupSpec> groups) {
}
/** {@code measurement} is null for the device's own one. */
private record GroupSpec(String title, String measurement, List<Field> fields) {
}
// Each helper returns the fields it stands for, so a group can mix single fields and phase triples.
private static Field[] field(String key, String label, String unit, int digits) {
return new Field[] {new Field(key, label, unit, digits, 1)};
}
private static Field[] kwhFromWh(String key, String label, int digits) {
return new Field[] {new Field(key, label, "kWh", digits, 0.001)};
}
/** L1 to L3 of one quantity; {@code key} holds %d for the phase number. */
private static Field[] phases(String key, String label, String unit, int digits) {
return IntStream.rangeClosed(1, 3)
.mapToObj(n -> new Field(key.formatted(n), label + " L" + n, unit, digits, 1)).toArray(Field[]::new);
}
private static GroupSpec group(String title, Field[]... parts) {
return groupOf(null, title, parts);
}
/** A group whose fields come from another measurement of the same device. */
private static GroupSpec groupOf(String measurement, String title, Field[]... parts) {
return new GroupSpec(title, measurement, Arrays.stream(parts).flatMap(Arrays::stream).toList());
}
private static final List<Spec> CATALOG = List.of(
new Spec("deye", "Falownik Deye", "deye", List.of(
group("Bateria",
field("battery_soc_pct", "Naładowanie", "%", 0),
field("battery_power_w", "Moc", "W", 0),
field("battery_voltage_v", "Napięcie", "V", 1),
field("battery_current_a", "Prąd", "A", 1),
field("battery_temperature_c", "Temperatura", "°C", 1)),
group("Sieć",
field("grid_power_w", "Moc razem", "W", 0),
phases("grid_power_l%d_w", "Moc", "W", 0),
phases("grid_voltage_l%d_v", "Napięcie", "V", 1),
field("grid_frequency_hz", "Częstotliwość", "Hz", 2)),
group("Odbiór domu",
field("load_power_w", "Moc razem", "W", 0),
phases("load_power_l%d_w", "Moc", "W", 0)),
group("Panele PV",
field("pv1_power_w", "PV1 moc", "W", 0),
field("pv1_voltage_v", "PV1 napięcie", "V", 1),
field("pv1_current_a", "PV1 prąd", "A", 1),
field("pv2_power_w", "PV2 moc", "W", 0),
field("pv2_voltage_v", "PV2 napięcie", "V", 1),
field("pv2_current_a", "PV2 prąd", "A", 1)),
group("Dziś",
field("pv_today_kwh", "Produkcja PV", "kWh", 1),
field("load_today_kwh", "Zużycie domu", "kWh", 1),
field("grid_bought_today_kwh", "Pobrano z sieci", "kWh", 1),
field("grid_sold_today_kwh", "Oddano do sieci", "kWh", 1),
field("battery_charge_today_kwh", "Bateria naładowana", "kWh", 1),
field("battery_discharge_today_kwh", "Bateria rozładowana", "kWh", 1)),
group("Od początku",
field("pv_total_kwh", "Produkcja PV", "kWh", 0),
field("load_total_kwh", "Zużycie domu", "kWh", 0),
field("grid_bought_total_kwh", "Pobrano z sieci", "kWh", 0),
field("grid_sold_total_kwh", "Oddano do sieci", "kWh", 0),
field("battery_charge_total_kwh", "Bateria naładowana", "kWh", 0),
field("battery_discharge_total_kwh", "Bateria rozładowana", "kWh", 0)),
group("Falownik",
field("inverter_power_w", "Moc wyjściowa", "W", 0),
field("inverter_temperature_c", "Temperatura", "°C", 1),
field("heatsink_temperature_c", "Radiator", "°C", 1)))),
new Spec("fronius", "Falownik Fronius", "fronius", List.of(
group("Teraz", field("pv_power_w", "Moc PV", "W", 0)),
group("Produkcja",
kwhFromWh("energy_day_wh", "Dziś", 1),
kwhFromWh("energy_year_wh", "Ten rok", 0),
kwhFromWh("energy_total_wh", "Od początku", 0)))),
new Spec("house-meter", "Licznik domu", "PRAD2", List.of(
group("Moc czynna",
field("total_power_active", "Razem", "W", 0),
phases("p%d_power_active", "Moc", "W", 0)),
group("Napięcie", phases("p%d_voltage", "Napięcie", "V", 1)),
group("Prąd", phases("p%d_current", "Prąd", "A", 2)),
group("Współczynnik mocy", phases("p%d_power_factor", "cos φ", "", 2)),
group("Sieć", field("frequency", "Częstotliwość", "Hz", 2)),
group("Energia od początku",
field("import_energy_active", "Pobrana", "kWh", 0),
field("export_energy_active", "Oddana", "kWh", 0)),
groupOf("house_meter_phases", "Energia na fazę",
phases("l%d_import_energy_active_kwh", "Pobrana", "kWh", 0),
phases("l%d_export_energy_active_kwh", "Oddana", "kWh", 0)))));
private final GatewayClient gateway;
private final Clock clock;
public EnergyDetailsService(GatewayClient gateway, Clock clock) {
this.gateway = gateway;
this.clock = clock;
}
public EnergyDetails current() throws IOException {
GatewaySnapshot snapshot = gateway.snapshot();
return new EnergyDetails(CATALOG.stream().map(spec -> device(spec, snapshot)).toList());
}
private Device device(Spec spec, GatewaySnapshot snapshot) {
Live live = SnapshotFields.live(snapshot, spec.id(), spec.measurement(), clock.instant());
List<Group> groups = spec.groups().stream()
.map(g -> group(g, g.measurement() == null ? live.fields() : fieldsOf(snapshot, spec.id(), g)))
.filter(g -> !g.items().isEmpty()).toList();
return new Device(spec.id(), spec.name(), live.source().time(), live.source().stale(), groups);
}
private Map<String, Double> fieldsOf(GatewaySnapshot snapshot, String device, GroupSpec group) {
return SnapshotFields.live(snapshot, device, group.measurement(), clock.instant()).fields();
}
private static Group group(GroupSpec spec, Map<String, Double> fields) {
List<Item> items = spec.fields().stream().filter(f -> fields.containsKey(f.key()))
.map(f -> new Item(f.label(), scaled(fields.get(f.key()), f), f.unit(), f.digits())).toList();
return new Group(spec.title(), items);
}
/** Rounded to the shown precision, so a conversion leaves no binary noise such as 4.3999999. */
private static double scaled(double value, Field f) {
double factor = Math.pow(10, f.digits());
return Math.round(value * f.scale() * factor) / factor;
}
}
@@ -1,26 +1,21 @@
package home.backend.application;
import home.backend.application.SnapshotFields.Live;
import home.backend.domain.EnergyView;
import home.backend.domain.EnergyView.Source;
import home.backend.domain.EnergyView.Today;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
/** F7: house electricity from the Deye inverter and the Fronius PV inverter. */
public class EnergyService {
/** One inverter's fields; empty when it is stale, so nothing old is shown as current. */
private record Inverter(Source source, Map<String, Double> fields) {
}
private final GatewayClient gateway;
private final Clock clock;
@@ -31,8 +26,8 @@ public class EnergyService {
public EnergyView current() throws IOException {
GatewaySnapshot snapshot = gateway.snapshot();
Inverter deye = inverter(snapshot, "deye");
Inverter fronius = inverter(snapshot, "fronius");
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();
@@ -46,13 +41,6 @@ public class EnergyService {
d.get("load_power_w"), today);
}
private Inverter inverter(GatewaySnapshot snapshot, String name) {
Optional<Measurement> reading = SnapshotFields.find(snapshot, name, name);
boolean stale = SnapshotFields.stale(SnapshotFields.device(snapshot, name), reading, clock.instant());
Source source = new Source(name, reading.map(Measurement::time).orElse(null), stale);
return new Inverter(source, stale ? Map.of() : reading.get().fields());
}
private static Instant oldest(List<Source> sources) {
return sources.stream().map(Source::time).flatMap(Stream::ofNullable).min(Instant::compareTo).orElse(null);
}
@@ -0,0 +1,11 @@
package home.backend.application;
import home.backend.domain.Forecast;
import java.io.IOException;
/** The weather forecast service: the past 14 days and the next 3. */
public interface ForecastSource {
Forecast forecast() throws IOException;
}
@@ -0,0 +1,19 @@
package home.backend.application;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView.Reserve;
import java.io.IOException;
/** Battery settings through the gateway, which checks every change (ADR-013). */
public interface GatewayBattery {
/** @throws IOException when the gateway has no battery commands, or cannot be reached */
Reserve reserve() throws IOException;
/** A verdict, even a negative one; IOException only when no verdict could be obtained. */
BatteryResult changeSchedule(BatteryChange change, String requestedBy) throws IOException;
BatteryResult changeReserve(int pct, String requestedBy) throws IOException;
}
@@ -2,6 +2,7 @@ package home.backend.application;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.RestoreResult;
import home.backend.domain.SetpointState;
import java.io.IOException;
@@ -12,5 +13,11 @@ public interface GatewayCommands {
/** A verdict, even a negative one; IOException only when no verdict could be obtained. */
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;
}
@@ -1,5 +1,6 @@
package home.backend.application;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.HealthView;
import home.backend.domain.HealthView.DeviceHealth;
@@ -13,19 +14,21 @@ public class HealthService {
private final GatewayClient gateway;
private final Clock clock;
private final String version;
public HealthService(GatewayClient gateway, Clock clock) {
public HealthService(GatewayClient gateway, Clock clock, String version) {
this.gateway = gateway;
this.clock = clock;
this.version = version;
}
public HealthView current() {
try {
List<DeviceHealth> devices = gateway.snapshot().devices().stream()
.map(HealthService::health).toList();
return new HealthView(clock.instant(), true, devices);
GatewaySnapshot snapshot = gateway.snapshot();
List<DeviceHealth> devices = snapshot.devices().stream().map(HealthService::health).toList();
return new HealthView(clock.instant(), true, devices, version, snapshot.version());
} catch (IOException e) {
return new HealthView(clock.instant(), false, List.of());
return new HealthView(clock.instant(), false, List.of(), version, null);
}
}
@@ -0,0 +1,13 @@
package home.backend.application;
import home.backend.domain.HeatPumpSample;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
/** The heat pump polls stored in InfluxDB, oldest first. */
public interface HeatPumpSamples {
List<HeatPumpSample> samples(Instant from, Instant to) throws IOException;
}
@@ -0,0 +1,53 @@
package home.backend.application;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* Tells the gateway now and then that we are still here (ADR-011): while it hears us, our setpoint
* changes stay; once we or the tunnel are gone for 15 minutes, it restores them on its own.
*/
public class LeaseRenewer implements AutoCloseable {
/** A third of the gateway's lease, so two renewals in a row may fail. */
public static final Duration PERIOD = Duration.ofMinutes(5);
private static final Logger LOGGER = LoggerFactory.getLogger(LeaseRenewer.class);
private final GatewayCommands gateway;
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("lease-renewal").factory());
public LeaseRenewer(GatewayCommands gateway) {
this.gateway = gateway;
}
public void start() {
start(PERIOD);
}
void start(Duration period) {
scheduler.scheduleWithFixedDelay(this::renew, 0, period.toMillis(), TimeUnit.MILLISECONDS);
}
/** A failure is logged and retried next cycle; an exception would end the schedule for good. */
void renew() {
try {
int renewed = gateway.renewLeases();
LOGGER.debug("renewed {} lease(s)", renewed);
} catch (IOException | RuntimeException e) {
LOGGER.warn("could not renew the gateway leases, retrying in {}: {}", PERIOD, e.toString());
}
}
@Override
public void close() {
scheduler.shutdownNow();
}
}
@@ -0,0 +1,27 @@
package home.backend.application;
import home.backend.application.SnapshotFields.Live;
import home.backend.domain.PiSensorView;
import java.io.IOException;
import java.time.Clock;
import java.util.Map;
/** The BME280 on the Pi, read by the gateway every 5 minutes. */
public class PiSensorService {
private final GatewayClient gateway;
private final Clock clock;
public PiSensorService(GatewayClient gateway, Clock clock) {
this.gateway = gateway;
this.clock = clock;
}
public PiSensorView current() throws IOException {
Live live = SnapshotFields.live(gateway.snapshot(), "bme280", "MEASUREMENT", clock.instant());
Map<String, Double> f = live.fields();
return new PiSensorView(live.source().time(), live.source().stale(),
f.get("temperature"), f.get("humidity"), f.get("pressure"));
}
}
@@ -0,0 +1,12 @@
package home.backend.application;
import java.io.IOException;
import java.time.LocalDate;
import java.util.Map;
/** What the panels produced, from InfluxDB. */
public interface PvEnergyRepository {
/** Deye plus Fronius kWh per local day from {@code from} up to {@code to}; days missing either are absent. */
Map<LocalDate, Double> dailyKwh(LocalDate from, LocalDate to) throws IOException;
}
@@ -7,15 +7,20 @@ import home.backend.domain.Preset.Change;
import home.backend.domain.PresetResult;
import home.backend.domain.PresetResult.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 {
@@ -38,48 +43,86 @@ public class SetpointService {
return gateway.apply(new Command(setpoint, value, duration(hours), user));
}
/** Puts the value from before the pending change back now, instead of waiting for its end. */
public RestoreResult restore(String setpoint, String user) throws IOException {
return gateway.restore(setpoint, user);
}
/**
* Empty if there is no such preset. A preset that does not fit the gateway's limits writes nothing.
* IOException only when no change was attempted; once writing starts, trouble is reported per change.
*
* @param until the end the user chose; required by a held preset, not allowed for any other
* @param values the user's own value for some of the preset's setpoints (any preset); the rest keep the
* preset's value
* @throws IllegalArgumentException when {@code until} does not match what the preset needs, or a value is
* for a setpoint the preset does not change or outside the gateway's limits
*/
public Optional<PresetResult> applyPreset(String id, String user) throws IOException {
public Optional<PresetResult> applyPreset(String id, Instant until, Map<String, Integer> values, String user)
throws IOException {
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
if (preset.isEmpty()) {
return Optional.empty();
}
List<Change> changes = preset.get().changes();
if (preset.get().isHeld() != (until != null)) {
throw new IllegalArgumentException(preset.get().isHeld() ? "preset " + id + " needs an end time"
: "preset " + id + " has no end time to choose");
}
List<Change> changes = withValues(preset.get().changes(), values);
List<Setpoint> limits = gateway.setpoints().setpoints();
List<ChangeResult> checked = changes.stream().map(c -> check(c, limits)).toList();
requireWithinLimits(values, limits);
List<ChangeResult> checked = changes.stream().map(c -> check(c, preset.get().isHeld(), limits)).toList();
if (checked.stream().anyMatch(r -> r.status() == ChangeStatus.REJECTED)) {
return Optional.of(PresetResult.of(checked));
}
return Optional.of(PresetResult.of(run(changes, user)));
return Optional.of(PresetResult.of(run(changes, until, user)));
}
private List<ChangeResult> run(List<Change> changes, String user) {
private static List<Change> withValues(List<Change> changes, Map<String, Integer> values) {
Set<String> own = changes.stream().map(Change::setpoint).collect(Collectors.toSet());
if (!own.containsAll(values.keySet())) {
throw new IllegalArgumentException("values may only be for the setpoints the preset changes: " + own);
}
return changes.stream().map(c -> new Change(c.setpoint(), values.getOrDefault(c.setpoint(), c.value()),
c.hours())).toList();
}
private static void requireWithinLimits(Map<String, Integer> values, List<Setpoint> limits) {
for (Setpoint limit : limits) {
Integer value = values.get(limit.name());
if (value != null && !limit.allows(value)) {
throw new IllegalArgumentException(limit.name() + " must be " + limit.min() + "–" + limit.max()
+ " in steps of " + limit.step());
}
}
}
private List<ChangeResult> run(List<Change> changes, Instant until, String user) {
List<ChangeResult> results = new ArrayList<>();
for (Change change : changes) {
boolean stopped = !results.isEmpty() && results.getLast().status() != ChangeStatus.APPLIED;
results.add(stopped ? ChangeResult.notAttempted() : attempt(change, user));
results.add(stopped ? ChangeResult.notAttempted() : attempt(change, until, user));
}
return results;
}
private ChangeResult attempt(Change change, String user) {
private ChangeResult attempt(Change change, Instant until, String user) {
try {
return ChangeResult.of(apply(change.setpoint(), change.value(), change.hours(), user));
var command = until == null ? new Command(change.setpoint(), change.value(), duration(change.hours()), user)
: new Command(change.setpoint(), change.value(), null, until, user);
return ChangeResult.of(gateway.apply(command));
} catch (IOException e) {
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, List<Setpoint> limits) {
String problem = problem(change, limits);
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, List<Setpoint> limits) {
private static String problem(Change change, boolean held, List<Setpoint> limits) {
Optional<Setpoint> known = limits.stream().filter(s -> s.name().equals(change.setpoint())).findFirst();
if (known.isEmpty()) {
return "preset sets " + change.setpoint() + ", which the gateway does not know";
@@ -88,6 +131,11 @@ public class SetpointService {
return "preset sets " + change.setpoint() + " to " + change.value() + ", outside the gateway's limits "
+ 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;
}
@@ -1,5 +1,6 @@
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;
@@ -25,8 +26,28 @@ final class SnapshotFields {
/** A reading is stale when the device link is down, it is missing, or it is older than twice a cycle. */
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(MAX_AGE) > 0;
|| Duration.between(reading.get().time(), now).compareTo(maxAge) > 0;
}
/** One device's fields; empty when it is stale, so nothing old is shown as current. */
record Live(Source source, Map<String, Double> fields) {
}
static Live live(GatewaySnapshot snapshot, String device, String measurement, Instant now) {
return live(snapshot, device, measurement, now, MAX_AGE);
}
static Live live(GatewaySnapshot snapshot, String device, String measurement, Instant now, Duration maxAge) {
Optional<Measurement> reading = find(snapshot, device, measurement);
boolean stale = stale(device(snapshot, device), reading, now, maxAge);
Source source = new Source(device, reading.map(Measurement::time).orElse(null), stale);
return new Live(source, stale ? Map.of() : reading.get().fields());
}
static Optional<Measurement> find(GatewaySnapshot snapshot, String device, String measurement) {
@@ -0,0 +1,89 @@
package home.backend.application;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.StatsView;
import home.backend.domain.StatsView.Day;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/**
* F2: the heat pump's work per local day over the last 7 or 30 days. A poll is taken to hold its
* outputs until the next poll, but at most {@link #MAX_HOLD}, so a gap in the data adds no hours.
*/
public class StatsService {
static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
static final Duration MAX_HOLD = Duration.ofSeconds(600);
private static final Map<String, Integer> RANGE_DAYS = Map.of("7d", 7, "30d", 30);
private final HeatPumpSamples repository;
private final Clock clock;
public StatsService(HeatPumpSamples repository, Clock clock) {
this.repository = repository;
this.clock = clock;
}
/**
* @param range {@code 7d} or {@code 30d}
* @throws IllegalArgumentException for an unknown range
*/
public StatsView stats(String range) throws IOException {
Integer length = range == null ? null : RANGE_DAYS.get(range);
if (length == null) {
throw new IllegalArgumentException("unknown range: " + range);
}
Instant now = clock.instant();
LocalDate first = now.atZone(ZONE).toLocalDate().minusDays(length - 1);
Map<LocalDate, DayTally> tallies = new TreeMap<>();
for (int i = 0; i < length; i++) {
tallies.put(first.plusDays(i), new DayTally());
}
tally(repository.samples(first.atStartOfDay(ZONE).toInstant(), now), now, tallies);
DayTally all = new DayTally();
List<Day> days = new ArrayList<>();
tallies.forEach((date, tally) -> {
days.add(tally.day(date));
all.add(tally);
});
return new StatsView(range, days, all.totals());
}
private static void tally(List<HeatPumpSample> samples, Instant now, Map<LocalDate, DayTally> tallies) {
HeatPumpSample previous = null;
for (int i = 0; i < samples.size(); i++) {
HeatPumpSample s = samples.get(i);
DayTally day = tallies.get(s.t().atZone(ZONE).toLocalDate());
day.sample(s);
if (previous != null && previous.compressor() != null && previous.compressor() == 0
&& DayTally.on(s.compressor())) {
day.start();
}
previous = s;
hold(s, i + 1 < samples.size() ? samples.get(i + 1).t() : now, tallies);
}
}
/** Adds the time {@code s} was in force to the days it falls in; a stretch over midnight is split. */
private static void hold(HeatPumpSample s, Instant next, Map<LocalDate, DayTally> tallies) {
Instant end = Duration.between(s.t(), next).compareTo(MAX_HOLD) > 0 ? s.t().plus(MAX_HOLD) : next;
Instant at = s.t();
while (at.isBefore(end)) {
LocalDate date = at.atZone(ZONE).toLocalDate();
Instant midnight = date.plusDays(1).atStartOfDay(ZONE).toInstant();
Instant stop = midnight.isBefore(end) ? midnight : end;
tallies.get(date).hold(s, Duration.between(at, stop));
at = stop;
}
}
}
@@ -0,0 +1,12 @@
package home.backend.application;
import home.backend.domain.WeatherView.Warning;
import java.io.IOException;
import java.util.List;
/** Weather warnings that cover the house, whether in force now or announced. */
public interface WarningSource {
List<Warning> warnings() throws IOException;
}
@@ -0,0 +1,98 @@
package home.backend.application;
import home.backend.application.Cached.Entry;
import home.backend.domain.Forecast;
import home.backend.domain.Forecast.DailyForecast;
import home.backend.domain.PvCalibration;
import home.backend.domain.WeatherCodes;
import home.backend.domain.WeatherView;
import home.backend.domain.WeatherView.Day;
import home.backend.domain.WeatherView.Now;
import home.backend.domain.WeatherView.Warning;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** Forecast, PV estimate and warnings, each kept for a while so the phones do not load the sources. */
public class WeatherService {
private static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
private static final int CALIBRATION_DAYS = 14;
private record Calibrated(Forecast forecast, Double factor) {
}
private final Clock clock;
private final PvEnergyRepository pv;
private final Cached<Calibrated> forecast;
private final Cached<List<Warning>> warnings;
public WeatherService(ForecastSource forecastSource, WarningSource warningSource, PvEnergyRepository pv,
Clock clock) {
this.clock = clock;
this.pv = pv;
this.forecast = new Cached<>(Duration.ofMinutes(30), ZONE, clock, () -> calibrated(forecastSource.forecast()));
this.warnings = new Cached<>(Duration.ofMinutes(10), clock, warningSource::warnings);
}
/** @throws IOException when there is no forecast at all; warnings alone may be missing. */
public WeatherView current() throws IOException {
Entry<Calibrated> weather = forecast.get();
Entry<List<Warning>> warned = warnings();
LocalDate today = LocalDate.now(clock.withZone(ZONE));
Calibrated w = weather.value();
return new WeatherView(clock.instant(),
day(w, today), day(w, today.plusDays(1)), day(w, today.plusDays(2)),
new Now(w.forecast().temperature()), inForce(warned.value()), w.factor(),
weather.stale() || warned.stale());
}
private Entry<List<Warning>> warnings() {
try {
return warnings.get();
} catch (IOException e) {
return new Entry<>(List.of(), true);
}
}
private List<Warning> inForce(List<Warning> all) {
return all.stream().filter(w -> w.to().toInstant().isAfter(clock.instant())).toList();
}
private Calibrated calibrated(Forecast fetched) {
LocalDate today = LocalDate.now(clock.withZone(ZONE));
Map<LocalDate, Double> radiation = new HashMap<>();
fetched.days().forEach((date, day) -> {
if (day.radiationMj() != null) {
radiation.put(date, day.radiationMj());
}
});
try {
var measured = pv.dailyKwh(today.minusDays(CALIBRATION_DAYS), today);
return new Calibrated(fetched, PvCalibration.factor(measured, radiation));
} catch (IOException e) {
return new Calibrated(fetched, null);
}
}
private static Day day(Calibrated w, LocalDate date) throws IOException {
DailyForecast d = w.forecast().days().get(date);
if (d == null) {
throw new IOException("forecast has no " + date);
}
Double kwh = w.factor() == null || d.radiationMj() == null ? null : round(w.factor() * d.radiationMj());
Double sunshine = d.sunshineSeconds() == null ? null : round(d.sunshineSeconds() / 3600);
return new Day(date, d.weatherCode(), WeatherCodes.describe(d.weatherCode()), d.tMax(), d.tMin(),
d.precipitationMm(), sunshine, kwh);
}
private static double round(double value) {
return Math.round(value * 10) / 10.0;
}
}
@@ -0,0 +1,14 @@
package home.backend.domain;
import java.time.Instant;
/**
* One audited write. {@code unit} is "°C", "%", "HHMM" (a clock time written as 1830) or "0/1" (off/on);
* the values are null when the gateway did not record them.
*/
public record AuditEntry(Instant time, String source, String what, Integer oldValue, Integer newValue,
String unit, String who, String status) {
public static final String HEAT_PUMP = "heat_pump";
public static final String BATTERY = "battery";
}
@@ -0,0 +1,7 @@
package home.backend.domain;
import java.util.List;
/** The audited writes of the heat pump and the battery, newest first. */
public record AuditView(List<AuditEntry> entries) {
}
@@ -0,0 +1,19 @@
package home.backend.domain;
import java.time.LocalTime;
import java.util.List;
/**
* A change of the Time of Use switch and of single program slots (1 to 6); a null field stays as it is.
* The gateway checks every rule (ADR-013).
*/
public record BatteryChange(Boolean enabled, List<Slot> slots) {
public BatteryChange {
slots = slots == null ? List.of() : List.copyOf(slots);
}
/** {@code from} is the slot's start, to the minute. */
public record Slot(int slot, LocalTime from, Integer socPct, Boolean gridCharge) {
}
}
@@ -0,0 +1,13 @@
package home.backend.domain;
import java.util.Map;
/**
* The gateway's verdict on a battery change, passed on unchanged. REJECTED: refused by the rules, nothing
* written. STALE: the inverter's settings are unknown or changed meanwhile, nothing written. TOO_SOON: S4.
* FAILED: a write or its read-back went wrong; {@code read} has the registers as read back.
*/
public record BatteryResult(Status status, String message, Map<Integer, Integer> read) {
public enum Status { APPLIED, REJECTED, STALE, TOO_SOON, FAILED }
}
@@ -0,0 +1,39 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/**
* The Deye's battery (F7), read-only. Power in W, + discharge / − charge. The live values
* ({@code time}, {@code stale}) come from the Deye's readings and are null when stale; the settings
* ({@code settingsTime}, {@code settingsStale}: mode, schedule, limits, capacity) come from their own,
* slower reading and are null (empty schedule) when stale. The capacity assumes the nominal 51.2 V.
* {@code reserve} is the app's backup reserve (ADR-013) and {@code editable} says the gateway accepts
* changes; they are null and false when it does not.
*/
public record BatteryView(Instant time, boolean stale, Double socPct, Double powerW, Double voltageV,
Double currentA, Double temperatureC, Double capacityKwh, Energy today, Energy total, Mode mode,
List<Slot> schedule, Limits limits, Instant settingsTime, boolean settingsStale, Reserve reserve,
boolean editable) {
/** The share of the battery kept for a power cut, and the lowest the user may set. */
public record Reserve(int pct, int floorPct) {
}
/** Battery energy in kWh. */
public record Energy(Double chargeKwh, Double dischargeKwh) {
}
/** Time of use on means the battery follows the schedule; off means backup only. */
public record Mode(boolean touEnabled, String description) {
}
/** A program slot, {@code from} and {@code to} as HH:MM; it runs until the next slot starts. */
public record Slot(String from, String to, int powerW, int socPct, boolean gridCharge) {
}
public record Limits(Integer maxChargeA, Integer maxDischargeA, Integer shutdownSocPct,
Integer lowSocPct, Integer restartSocPct, boolean gridChargeEnabled, Integer gridChargeCurrentA,
Integer gridChargeStartSocPct) {
}
}
@@ -1,9 +1,18 @@
package home.backend.domain;
import java.time.Duration;
import java.time.Instant;
/** Set a setpoint; with a duration the gateway restores the old value afterwards, else it stays. */
public record Command(String setpoint, int value, Duration duration, String requestedBy) {
/**
* Set a setpoint. With a duration the gateway restores the old value afterwards; with
* {@code heldUntil} it holds a lowering value until then without renewals (ADR-012); else it stays
* while the backend renews the lease (ADR-011).
*/
public record Command(String setpoint, int value, Duration duration, Instant heldUntil, String requestedBy) {
public Command(String setpoint, int value, Duration duration, String requestedBy) {
this(setpoint, value, duration, null, requestedBy);
}
/** The gateway refuses longer boosts too; we refuse first, so the phone gets a clear 400. */
public static final int MAX_HOURS = 72;
@@ -0,0 +1,22 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/**
* Every value the energy devices report, grouped for a details panel (F7). A device without a
* fresh reading has {@code stale} true and no groups; a field the device did not report is absent.
*/
public record EnergyDetails(List<Device> devices) {
/** One device; {@code id} is the gateway's device name. */
public record Device(String id, String name, Instant time, boolean stale, List<Group> groups) {
}
public record Group(String title, List<Item> items) {
}
/** A value in {@code unit}, to be shown with {@code digits} decimals. */
public record Item(String label, double value, String unit, int digits) {
}
}
@@ -0,0 +1,13 @@
package home.backend.domain;
import java.time.LocalDate;
import java.util.Map;
/** What the forecast service says: the temperature now and a record per calendar day, past days included. */
public record Forecast(Double temperature, Map<LocalDate, DailyForecast> days) {
/** Radiation in MJ/m² per day, sunshine in seconds; any value may be missing. */
public record DailyForecast(Integer weatherCode, Double tMax, Double tMin, Double precipitationMm,
Double radiationMj, Double sunshineSeconds) {
}
}
@@ -4,8 +4,9 @@ import java.time.Instant;
import java.util.List;
import java.util.Map;
/** The gateway's latest state of every device, as the backend sees it. */
public record GatewaySnapshot(List<DeviceSnapshot> devices) {
/** The gateway's latest state of every device, as the backend sees it.
* The version is the gateway's release tag, null for a gateway that does not report it yet. */
public record GatewaySnapshot(String version, List<DeviceSnapshot> devices) {
public record DeviceSnapshot(String name, boolean up, Instant lastSuccess, String lastError,
List<Measurement> measurements) {
@@ -3,8 +3,10 @@ package home.backend.domain;
import java.time.Instant;
import java.util.List;
/** Health of the chain phone → backend → gateway → devices (F5). */
public record HealthView(Instant time, boolean gatewayReachable, List<DeviceHealth> devices) {
/** Health of the chain phone → backend → gateway → devices (F5), with the deployed versions.
* The gateway's version is null when it is unreachable or too old to report it. */
public record HealthView(Instant time, boolean gatewayReachable, List<DeviceHealth> devices,
String backendVersion, String gatewayVersion) {
public record DeviceHealth(String name, boolean up, Instant lastSuccess, String lastError) {
}
@@ -0,0 +1,8 @@
package home.backend.domain;
import java.time.Instant;
/** One poll of the heat pump from InfluxDB; a field the poll did not carry is null. */
public record HeatPumpSample(Instant t, Double compressor, Double cwu, Double co, Double heater,
Double outdoorTemp, Double cwuTemp) {
}
@@ -0,0 +1,11 @@
package home.backend.domain;
import java.time.Instant;
/**
* The BME280 sensor on the Raspberry Pi: °C, % and hPa. When it is stale (device down, no reading
* or older than 2 × 300 s) the values are null and {@code stale} is true; {@code time} is null
* without any reading.
*/
public record PiSensorView(Instant time, boolean stale, Double temperature, Double humidity, Double pressure) {
}
@@ -1,9 +1,19 @@
package home.backend.domain;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;
/** A named one-tap bundle of setpoint changes (F3), e.g. boost or away. */
public record Preset(String id, String name, List<Change> changes) {
/**
* A named one-tap bundle of setpoint changes (F3), e.g. boost or away. {@code until} is null, or
* {@link #CHOSEN}: the user picks the end on a calendar and the gateway holds the changes until
* then without renewals (ADR-012).
*/
public record Preset(String id, String name, @JsonInclude(JsonInclude.Include.NON_NULL) String until,
List<Change> changes) {
public static final String CHOSEN = "chosen";
/** {@code hours} null means "until changed back". */
public record Change(String setpoint, int value, Double hours) {
@@ -18,6 +28,11 @@ public record Preset(String id, String name, List<Change> changes) {
}
}
@JsonIgnore
public boolean isHeld() {
return CHOSEN.equals(until);
}
public Preset {
if (id == null || id.isBlank() || name == null || name.isBlank()) {
throw new IllegalArgumentException("a preset needs an id and a name");
@@ -25,5 +40,11 @@ public record Preset(String id, String name, List<Change> changes) {
if (changes == null || changes.isEmpty()) {
throw new IllegalArgumentException("preset " + id + " has no changes");
}
if (until != null && !until.equals(CHOSEN)) {
throw new IllegalArgumentException("preset " + id + ": until can only be \"" + CHOSEN + "\"");
}
if (CHOSEN.equals(until) && changes.stream().anyMatch(change -> change.hours() != null)) {
throw new IllegalArgumentException("preset " + id + " is held until a chosen time, so no hours");
}
}
}
@@ -0,0 +1,32 @@
package home.backend.domain;
import java.time.LocalDate;
import java.util.Map;
/**
* The PV output per unit of radiation, found from past days instead of the panels' tilt and azimuth:
* measured kWh over forecast MJ/m², summed over the days that have both.
*/
public final class PvCalibration {
static final int MIN_DAYS = 5;
private PvCalibration() {
}
/** Null with fewer than {@value MIN_DAYS} days having both values or no radiation at all. */
public static Double factor(Map<LocalDate, Double> measuredKwh, Map<LocalDate, Double> radiationMj) {
double kwh = 0;
double mj = 0;
int days = 0;
for (var day : measuredKwh.entrySet()) {
Double radiation = radiationMj.get(day.getKey());
if (radiation != null) {
kwh += day.getValue();
mj += radiation;
days++;
}
}
return days < MIN_DAYS || mj <= 0 ? null : kwh / mj;
}
}
@@ -2,6 +2,9 @@ package home.backend.domain;
import java.time.Instant;
/** A boost the gateway will undo at {@code due}, unless someone changed the register meanwhile. */
/**
* A change the gateway will undo at {@code due} (null: until changed), unless someone changed the
* register meanwhile. It also undoes it when we stop renewing the lease (ADR-011).
*/
public record Restore(String setpoint, int register, int originalValue, int appliedValue, Instant due) {
}
@@ -0,0 +1,11 @@
package home.backend.domain;
/** The gateway's answer to "restore now", passed on to the phone unchanged. */
public record RestoreResult(Outcome outcome, String message) {
/**
* NOT_PENDING: nothing to restore. CONFLICT: the register was changed meanwhile, so it was left alone.
* TOO_SOON: the gateway's rate limit. FAILED: the restore stays pending and is retried.
*/
public enum Outcome { RESTORED, NOT_PENDING, CONFLICT, TOO_SOON, FAILED }
}
@@ -0,0 +1,19 @@
package home.backend.domain;
import java.time.LocalDate;
import java.util.List;
/**
* Heat pump work per local day (F2). Hours are approximated from the polls, starts are counted
* between polls, so short cycles are missed. A day without polls has null values.
*/
public record StatsView(String range, List<Day> days, Totals totals) {
public record Day(LocalDate date, Double compressorHours, Integer compressorStarts, Double cwuHours,
Double coHours, Double heaterHours, Double avgOutdoorTemp, Double minCwuTemp, Double maxCwuTemp) {
}
public record Totals(double compressorHours, int compressorStarts, double cwuHours, double coHours,
double heaterHours, Double avgOutdoorTemp, Double minCwuTemp, Double maxCwuTemp) {
}
}
@@ -0,0 +1,37 @@
package home.backend.domain;
/** WMO weather interpretation codes as Polish text. */
public final class WeatherCodes {
private WeatherCodes() {
}
public static String describe(Integer code) {
if (code == null) {
return "Brak danych";
}
return switch (code) {
case 0 -> "Bezchmurnie";
case 1 -> "Przeważnie słonecznie";
case 2 -> "Częściowe zachmurzenie";
case 3 -> "Pochmurno";
case 45, 48 -> "Mgła";
case 51, 53, 55 -> "Mżawka";
case 56, 57 -> "Marznąca mżawka";
case 61 -> "Słaby deszcz";
case 63 -> "Deszcz";
case 65 -> "Silny deszcz";
case 66, 67 -> "Marznący deszcz";
case 71 -> "Słaby śnieg";
case 73 -> "Śnieg";
case 75 -> "Intensywny śnieg";
case 77 -> "Ziarna śnieżne";
case 80, 81 -> "Przelotny deszcz";
case 82 -> "Ulewa";
case 85, 86 -> "Przelotny śnieg";
case 95 -> "Burza";
case 96, 99 -> "Burza z gradem";
default -> "Brak danych";
};
}
}
@@ -0,0 +1,27 @@
package home.backend.domain;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.List;
/**
* Weather at the house: three days, the temperature now and the IMGW warnings in force or announced.
* {@code pvFactor} (kWh per MJ/m² of forecast radiation) and every {@code pvForecastKwh} are null while
* there are too few measured days to calibrate. {@code stale} means part of it is an older answer
* because a source could not be reached.
*/
public record WeatherView(Instant time, Day today, Day tomorrow, Day dayAfter, Now now,
List<Warning> warnings, Double pvFactor, boolean stale) {
public record Day(LocalDate date, Integer weatherCode, String description, Double tMax, Double tMin,
Double precipitationMm, Double sunshineHours, Double pvForecastKwh) {
}
public record Now(Double temperature) {
}
/** {@code level} is IMGW's degree 1 to 3. */
public record Warning(String event, int level, OffsetDateTime from, OffsetDateTime to, String text) {
}
}
@@ -7,6 +7,7 @@
{
"id": "away",
"name": "Wyjazd",
"until": "chosen",
"changes": [
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
{ "setpoint": "co_comfort", "value": 15, "hours": null }
@@ -151,6 +151,11 @@ class BackendTest {
assertServesExactly("/api/heat-pump", "heat-pump");
}
@Test
void servesPiSensorFromTheGatewayReadings() throws Exception {
assertServesExactly("/api/pi-sensor", "pi-sensor");
}
@Test
void servesEnergyFromTheGatewayReadings() throws Exception {
assertServesExactly("/api/energy", "energy");
@@ -28,8 +28,8 @@ class PresetFileTest {
var presets = PresetFile.load(null);
assertEquals(List.of(
new Preset("cwu-now", "Ciepła woda teraz", List.of(new Change("cwu_economy", 45, 2.0))),
new Preset("away", "Wyjazd", List.of(new Change("cwu_comfort", 10, null),
new Preset("cwu-now", "Ciepła woda teraz", null, List.of(new Change("cwu_economy", 45, 2.0))),
new Preset("away", "Wyjazd", "chosen", List.of(new Change("cwu_comfort", 10, null),
new Change("co_comfort", 15, null)))), presets);
}
@@ -39,7 +39,7 @@ class PresetFileTest {
[{"id": "bath", "name": "Kąpiel", "changes": [{"setpoint": "cwu_economy", "value": 48, "hours": 1.5}]}]
"""));
assertEquals(List.of(new Preset("bath", "Kąpiel", List.of(new Change("cwu_economy", 48, 1.5)))), presets);
assertEquals(List.of(new Preset("bath", "Kąpiel", null, List.of(new Change("cwu_economy", 48, 1.5)))), presets);
}
@Test
@@ -51,6 +51,8 @@ class PresetFileTest {
"[{\"id\": \"\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 0}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 73}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"until\": \"tomorrow\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"until\": \"chosen\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 2}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]},"
+ "{\"id\": \"a\", \"name\": \"B\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 16}]}]")) {
Path file = file(json);
@@ -86,6 +86,20 @@ class GatewayHttpClientTest {
assertEquals(645.0, device(devices, "deye").measurements().get(0).fields().get("grid_power_w"));
}
@Test
void readsTheGatewayVersion() throws Exception {
serveFiles(200, "health-all-up.json", "snapshot-all-up.json");
assertEquals("v1.2.1", client.snapshot().version());
}
@Test
void anOldGatewayWithoutVersionHasNullVersion() throws Exception {
serveFiles(503, "health-one-down.json", "snapshot-one-down.json");
assertNull(client.snapshot().version());
}
@Test
void aDownDeviceAnswering503IsStillASnapshot() throws Exception {
serveFiles(503, "health-one-down.json", "snapshot-one-down.json");
@@ -2,9 +2,14 @@ package home.backend.adapters.gateway;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView;
import home.backend.domain.Command;
import home.backend.domain.CommandResult.Status;
import home.backend.domain.RestoreResult.Outcome;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -18,6 +23,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.List;
@@ -32,7 +38,7 @@ class GatewayHttpCommandsTest {
private static final ObjectMapper JSON = new ObjectMapper();
private static final Path CONTRACT = Path.of("../gateway/src/test/resources/commands");
private record Seen(String method, String authorization, String requestedBy, String body) {
private record Seen(String method, String path, String authorization, String requestedBy, String body) {
}
private final List<Seen> seen = new ArrayList<>();
@@ -59,16 +65,20 @@ class GatewayHttpCommandsTest {
}
private void answerCommands() {
server.createContext("/commands", exchange -> {
seen.add(new Seen(exchange.getRequestMethod(),
exchange.getRequestHeaders().getFirst("Authorization"),
exchange.getRequestHeaders().getFirst("X-Requested-By"),
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
for (String path : List.of("/commands", "/battery")) {
server.createContext(path, this::answer);
}
}
private void answer(HttpExchange exchange) throws IOException {
seen.add(new Seen(exchange.getRequestMethod(), exchange.getRequestURI().getPath(),
exchange.getRequestHeaders().getFirst("Authorization"),
exchange.getRequestHeaders().getFirst("X-Requested-By"),
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
}
private GatewayHttpCommands commands(String token) {
@@ -95,6 +105,15 @@ class GatewayHttpCommandsTest {
assertEquals(contractJson("request-boost.json"), contractJson(seen.getFirst().body()));
}
@Test
void aHeldChangeSendsTheEndAsUntil() throws Exception {
serve(200, contract("response-applied.json"));
commands("s3cret-token").apply(new Command("cwu_comfort", 10, null, Instant.parse("2026-11-02T17:00:00Z"), "phone"));
assertEquals(contractJson("request-held.json"), contractJson(seen.getFirst().body()));
}
@Test
void awayHasNoMinutes() throws Exception {
serve(200, contract("response-applied.json"));
@@ -136,6 +155,149 @@ class GatewayHttpCommandsTest {
assertEquals(8, state.restores().getFirst().originalValue());
}
@Test
void anUntilChangedRestoreHasNoDue() throws Exception {
serve(200, "{\"setpoints\":[],\"restores\":[{\"setpoint\":\"cwu_comfort\",\"register\":4144,"
+ "\"originalValue\":45,\"appliedValue\":10,\"leaseUntil\":\"2026-10-24T10:15:00Z\"}]}");
assertNull(commands("s3cret-token").setpoints().restores().getFirst().due());
}
@Test
void restorePostsTheSetpointWithTokenAndUser() throws Exception {
serve(200, contract("response-restored.json"));
var result = commands("s3cret-token").restore("cwu_economy", "anna");
assertEquals(Outcome.RESTORED, result.outcome());
assertEquals("POST", seen.getFirst().method());
assertEquals("/commands/restore", seen.getFirst().path());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
assertEquals("anna", seen.getFirst().requestedBy());
assertEquals(contractJson("request-restore.json"), contractJson(seen.getFirst().body()));
}
@Test
void aConflictOnRestoreIsAVerdictNotAnError() throws Exception {
serve(409, contract("response-restore-conflict.json"));
assertEquals(Outcome.CONFLICT, commands("s3cret-token").restore("cwu_economy", "anna").outcome());
}
@Test
void everyRestoreStatusOfTheGatewayIsAVerdict() throws Exception {
var expected = java.util.Map.of(404, Outcome.NOT_PENDING, 429, Outcome.TOO_SOON, 502, Outcome.FAILED);
for (var entry : expected.entrySet()) {
serve(entry.getKey(), "{\"outcome\":\"" + entry.getValue() + "\",\"message\":\"x\"}");
assertEquals(entry.getValue(), commands("s3cret-token").restore("cwu_economy", "anna").outcome());
}
}
@Test
void restoreFailsWithoutAVerdictOrToken() {
serve(503, "{\"error\":\"x\"}");
assertThrows(IOException.class, () -> commands("s3cret-token").restore("cwu_economy", "anna"));
var seenBefore = seen.size();
assertThrows(IOException.class, () -> commands(null).restore("cwu_economy", "anna"));
assertEquals(seenBefore, seen.size());
}
@Test
void renewPostsToRenewWithTheTokenAndReturnsHowManyWereExtended() throws Exception {
serve(200, "{\"renewed\":2}");
assertEquals(2, commands("s3cret-token").renewLeases());
assertEquals("POST", seen.getFirst().method());
assertEquals("/commands/renew", seen.getFirst().path());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
}
@Test
void renewFailsOnAnyOtherStatusOrWithoutToken() {
serve(503, "{\"error\":\"x\"}");
assertThrows(IOException.class, () -> commands("s3cret-token").renewLeases());
var seenBefore = seen.size();
assertThrows(IOException.class, () -> commands(null).renewLeases());
assertEquals(seenBefore, seen.size());
}
@Test
void readsTheBatteryReserveWithTheToken() throws Exception {
serve(200, contract("battery-reserve.json"));
var reserve = commands("s3cret-token").reserve();
assertEquals(new BatteryView.Reserve(50, 30), reserve);
assertEquals("GET", seen.getFirst().method());
assertEquals("/battery/reserve", seen.getFirst().path());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
}
@Test
void sendsAScheduleChangeAsTheGatewayExpectsIt() throws Exception {
serve(200, contract("response-battery-applied.json"));
var change = new BatteryChange(true, List.of(new BatteryChange.Slot(2, LocalTime.of(9, 30), 90, true),
new BatteryChange.Slot(3, null, 85, null)));
var result = commands("s3cret-token").changeSchedule(change, "anna");
assertEquals(BatteryResult.Status.APPLIED, result.status());
assertEquals(java.util.Map.of(167, 90, 172, 1), result.read());
assertEquals("POST", seen.getFirst().method());
assertEquals("/battery/schedule", seen.getFirst().path());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
assertEquals("anna", seen.getFirst().requestedBy());
assertEquals(contractJson("request-battery-schedule.json"), contractJson(seen.getFirst().body()));
}
@Test
void sendsAReserveChange() throws Exception {
serve(200, contract("response-battery-applied.json"));
commands("s3cret-token").changeReserve(60, "anna");
assertEquals("/battery/reserve", seen.getFirst().path());
assertEquals(contractJson("{\"pct\":60}"), contractJson(seen.getFirst().body()));
}
@Test
void everyBatteryStatusOfTheGatewayIsAVerdict() throws Exception {
var expected = java.util.Map.of(422, BatteryResult.Status.REJECTED, 409, BatteryResult.Status.STALE,
429, BatteryResult.Status.TOO_SOON, 502, BatteryResult.Status.FAILED);
for (var entry : expected.entrySet()) {
serve(entry.getKey(), "{\"status\":\"" + entry.getValue() + "\",\"message\":\"x\",\"read\":{}}");
assertEquals(entry.getValue(), commands("s3cret-token").changeReserve(60, "anna").status());
}
}
@Test
void aFailedBatteryWriteCarriesWhatWasRead() throws Exception {
serve(502, contract("response-battery-failed.json"));
var result = commands("s3cret-token").changeSchedule(new BatteryChange(null, null), "anna");
assertEquals(BatteryResult.Status.FAILED, result.status());
assertEquals(java.util.Map.of(167, 80), result.read());
}
@Test
void batteryCallsFailWithoutAVerdictOrToken() {
for (int status : List.of(400, 401, 404, 503)) {
serve(status, "{\"error\":\"x\"}");
assertThrows(IOException.class, () -> commands("s3cret-token").reserve(), "GET " + status);
assertThrows(IOException.class, () -> commands("s3cret-token").changeReserve(60, "anna"), "POST " + status);
}
var seenBefore = seen.size();
assertThrows(IOException.class, () -> commands(null).reserve());
assertThrows(IOException.class, () -> commands(null).changeReserve(60, "anna"));
assertEquals(seenBefore, seen.size());
}
@Test
void aSetpointNotReadYetStaysNullInsteadOfZero() throws Exception {
serve(200, "{\"setpoints\":[{\"name\":\"co_comfort\",\"register\":4132,\"min\":5,\"max\":25,\"step\":1,"
@@ -2,24 +2,41 @@ package home.backend.adapters.http;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.backend.application.AuditRepository;
import home.backend.application.AuditService;
import home.backend.application.BatteryService;
import home.backend.application.EnergyDetailsService;
import home.backend.application.EnergyService;
import home.backend.adapters.config.PresetFile;
import home.backend.application.GatewayClient;
import home.backend.application.GatewayBattery;
import home.backend.application.GatewayCommands;
import home.backend.application.SetpointService;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView.Reserve;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.CommandResult.Status;
import home.backend.domain.RestoreResult;
import home.backend.domain.RestoreResult.Outcome;
import home.backend.domain.Restore;
import home.backend.domain.Setpoint;
import home.backend.domain.SetpointState;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryRepository;
import home.backend.application.HeatPumpSamples;
import home.backend.application.HistoryService;
import home.backend.application.StatsService;
import home.backend.application.PiSensorService;
import home.backend.application.WeatherService;
import home.backend.domain.Forecast;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import home.backend.domain.WeatherView.Warning;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.HistoryView.Point;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -35,11 +52,14 @@ import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -54,10 +74,17 @@ class ApiServerTest {
private GatewaySnapshot snapshot;
private boolean gatewayDown;
private boolean historyDown;
private boolean auditDown;
private HeatPumpSamples samples;
private RuntimeException gatewayBug;
private final List<Command> sent = new ArrayList<>();
private Status verdict;
private Outcome restoreOutcome;
private final List<String> restored = new ArrayList<>();
private boolean commandsDown;
private boolean batteryDown;
private BatteryResult.Status batteryVerdict;
private final List<String> batterySent = new ArrayList<>();
private int answersBeforeDrop;
private ApiServer server;
@@ -81,7 +108,18 @@ class ApiServerTest {
new Point(Instant.parse("2026-10-23T11:00:00Z"), 38.5),
new Point(Instant.parse("2026-10-23T12:00:00Z"), 44.2));
};
samples = (from, to) -> {
if (historyDown) {
throw new IOException("influx down");
}
return List.of(
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 0.0, 0.0, 0.0, 0.0, 8.0, 44.0),
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 100.0, 0.0, 0.0, 8.5, 43.0),
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 100.0, 0.0, 0.0, 100.0, 9.0, 45.0),
new HeatPumpSample(Instant.parse("2026-10-23T10:15:00Z"), 0.0, 0.0, 0.0, 0.0, 9.0, 46.0));
};
verdict = Status.APPLIED;
restoreOutcome = Outcome.RESTORED;
answersBeforeDrop = Integer.MAX_VALUE;
GatewayCommands commands = new GatewayCommands() {
@Override
@@ -98,6 +136,15 @@ class ApiServerTest {
};
}
@Override
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
if (commandsDown) {
throw new IOException("tunnel down");
}
restored.add(setpoint + " by " + requestedBy);
return new RestoreResult(restoreOutcome, "restored to 8");
}
@Override
public SetpointState setpoints() throws IOException {
if (commandsDown) {
@@ -109,11 +156,55 @@ class ApiServerTest {
new Setpoint("cwu_comfort", 4144, 5, 48, 1, 45)),
List.of(new Restore("cwu_economy", 4143, 8, 45, Instant.parse("2026-10-24T12:00:00Z"))));
}
@Override
public int renewLeases() {
return 0;
}
};
batteryDown = false;
batteryVerdict = BatteryResult.Status.APPLIED;
GatewayBattery batteryCommands = new GatewayBattery() {
@Override
public Reserve reserve() throws IOException {
if (batteryDown) {
throw new IOException("gateway /battery/reserve answered 404");
}
return new Reserve(50, 30);
}
@Override
public BatteryResult changeSchedule(BatteryChange change, String requestedBy) throws IOException {
return answer("schedule " + change + " by " + requestedBy);
}
@Override
public BatteryResult changeReserve(int pct, String requestedBy) throws IOException {
return answer("reserve " + pct + " by " + requestedBy);
}
private BatteryResult answer(String what) throws IOException {
if (batteryDown) {
throw new IOException("tunnel down");
}
batterySent.add(what);
return switch (batteryVerdict) {
case APPLIED -> new BatteryResult(batteryVerdict, "applied", Map.of(167, 90));
case FAILED -> new BatteryResult(batteryVerdict,
"registers not as written: [167]; not retried", Map.of(167, 80));
default -> new BatteryResult(batteryVerdict, "nope", Map.of());
};
}
};
Clock clock = Clock.fixed(HEALTH_TIME, ZoneOffset.UTC);
server = new ApiServer(0, new HeatPumpService(gateway, clock), new EnergyService(gateway, clock),
new HealthService(gateway, clock), new HistoryService(history, clock),
new SetpointService(commands, PresetFile.load(null)));
new EnergyDetailsService(gateway, clock),
new HealthService(gateway, clock, "v1.3.0"), new HistoryService(history, clock),
new SetpointService(commands, PresetFile.load(null)), new PiSensorService(gateway, clock),
new BatteryService(gateway, batteryCommands, clock),
new AuditService(auditRows(), clock), new StatsService(samples, clock),
new WeatherService(() -> exampleForecast(), () -> List.of(exampleWarning()),
(from, to) -> exampleMeasuredPv(), clock));
server.start();
}
@@ -122,6 +213,27 @@ class ApiServerTest {
server.stop();
}
private AuditRepository auditRows() {
return new AuditRepository() {
@Override
public List<Row> heatPump(Instant from, Instant to) throws IOException {
if (auditDown) {
throw new IOException("influx down");
}
return List.of(
new Row(Instant.parse("2026-10-24T08:00:00Z"), "cwu_economy", "restore", "APPLIED", 45.0, 8.0),
new Row(Instant.parse("2026-10-23T07:00:00Z"), "co_comfort", "mariusz", "REJECTED", null, 22.0));
}
@Override
public List<Row> battery(Instant from, Instant to) {
return List.of(
new Row(Instant.parse("2026-10-24T09:30:00Z"), "reserve", "mariusz", "APPLIED", 50.0, 60.0),
new Row(Instant.parse("2026-10-23T18:00:00Z"), "174", "mariusz", "FAILED", 0.0, 1.0));
}
};
}
private static GatewaySnapshot exampleSnapshot() {
Map<String, Double> pump = new HashMap<>(Map.of("outdoor_temp", 4.3, "indoor_temp", 21.2,
"co_buffer_temp", 16.9, "cwu_temp", 13.6, "ground_in_temp", 16.0, "ground_out_temp", 14.3,
@@ -133,18 +245,46 @@ class ApiServerTest {
"output_circulation", 0.0, "output_ground", 0.0, "output_cwu", 0.0));
Instant pumpTime = Instant.parse("2026-10-23T22:13:35Z");
Instant energyTime = Instant.parse("2026-10-23T22:26:00Z");
Map<String, Double> deye = Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
Map<String, Double> deye = new HashMap<>(Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
"grid_power_w", 645.0, "battery_power_w", -24.0, "battery_soc_pct", 99.0,
"load_power_w", 570.0, "grid_bought_today_kwh", 16.8, "grid_sold_today_kwh", 22.3,
"load_today_kwh", 24.2);
return new GatewaySnapshot(List.of(
"load_today_kwh", 24.2));
deye.putAll(Map.of("battery_voltage_v", 56.49, "battery_current_a", -0.05, "battery_temperature_c", 19.2,
"battery_charge_today_kwh", 0.5, "battery_discharge_today_kwh", 0.1,
"battery_charge_total_kwh", 453.7, "battery_discharge_total_kwh", 162.1));
return new GatewaySnapshot("v1.2.1", List.of(
new DeviceSnapshot("heat-pump", true, pumpTime, null,
List.of(new Measurement("Dom_Pompa", "heat_pump", pump, energyTime))),
new DeviceSnapshot("deye", true, energyTime, null,
List.of(new Measurement("home", "deye", deye, energyTime))),
new DeviceSnapshot("fronius", true, energyTime, null,
List.of(new Measurement("home", "fronius",
Map.of("pv_power_w", 0.0, "energy_day_wh", 4400.0), energyTime)))));
Map.of("pv_power_w", 0.0, "energy_day_wh", 4400.0), energyTime))),
new DeviceSnapshot("house-meter", true, energyTime, null,
List.of(new Measurement("electricity", "PRAD2", Map.of("p1_voltage", 235.69,
"p1_current", 0.32, "p1_power_active", 55.94, "p1_power_factor", 0.75,
"frequency", 50.01, "import_energy_active", 67514.27), energyTime))),
new DeviceSnapshot("deye-settings", true, energyTime, null,
List.of(new Measurement("electricity", "deye_settings", deyeSettings(), energyTime))),
new DeviceSnapshot("bme280", true, energyTime, null,
List.of(new Measurement("measurement", "MEASUREMENT",
Map.of("temperature", 20.17, "humidity", 45.82, "pressure", 985.8), energyTime)))));
}
/** The settings read from the inverter on 2026-10-24: time of use off, six 4-hour slots. */
private static Map<String, Double> deyeSettings() {
Map<String, Double> settings = new HashMap<>(Map.of("battery_capacity_ah", 280.0, "tou_enabled", 0.0,
"max_charge_current_a", 80.0, "max_discharge_current_a", 120.0, "shutdown_soc_pct", 20.0,
"low_soc_pct", 35.0, "restart_soc_pct", 50.0, "grid_charge_enabled", 1.0,
"grid_charge_current_a", 40.0, "grid_charge_start_soc_pct", 30.0));
double[] starts = {100, 500, 900, 1300, 1700, 2100};
for (int slot = 1; slot <= 6; slot++) {
settings.put("prog" + slot + "_start_hhmm", starts[slot - 1]);
settings.put("prog" + slot + "_power_w", 10000.0);
settings.put("prog" + slot + "_soc_pct", 80.0);
settings.put("prog" + slot + "_grid_charge", 0.0);
}
return settings;
}
private HttpResponse<String> get(String path) throws Exception {
@@ -153,6 +293,10 @@ class ApiServerTest {
return client.send(request, HttpResponse.BodyHandlers.ofString());
}
private static String awayRequest() throws IOException {
return example("preset-away-request").toString();
}
private static JsonNode example(String name) throws IOException {
try (InputStream in = ApiServerTest.class.getResourceAsStream("/api/" + name + ".json")) {
return JSON.readTree(in);
@@ -178,16 +322,21 @@ class ApiServerTest {
assertMatchesExample("/api/energy", "energy");
}
@Test
void energyDetailsMatchExample() throws Exception {
assertMatchesExample("/api/energy/details", "energy-details");
}
@Test
void healthMatchesExample() throws Exception {
snapshot = new GatewaySnapshot(List.of(snapshot.devices().get(0),
snapshot = new GatewaySnapshot("v1.2.1", List.of(snapshot.devices().get(0),
new DeviceSnapshot("deye", false, null, "connect timed out", List.of())));
assertMatchesExample("/api/health", "health");
}
@Test
void heatPumpWithoutReadingMatchesUnreadExample() throws Exception {
snapshot = new GatewaySnapshot(List.of());
snapshot = new GatewaySnapshot("v1.2.1", List.of());
assertMatchesExample("/api/heat-pump", "heat-pump-unread");
}
@@ -198,25 +347,205 @@ class ApiServerTest {
Measurement deye = devices.get(1).measurements().get(0);
devices.set(1, new DeviceSnapshot("deye", true, old, null,
List.of(new Measurement("home", "deye", deye.fields(), old))));
snapshot = new GatewaySnapshot(devices);
snapshot = new GatewaySnapshot("v1.2.1", devices);
assertMatchesExample("/api/energy", "energy-deye-stale");
}
@Test
void batteryMatchesExample() throws Exception {
assertMatchesExample("/api/battery", "battery");
}
@Test
void batteryWithStaleSettingsMatchesExample() throws Exception {
Instant old = Instant.parse("2026-10-23T21:00:00Z");
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
devices.set(devices.size() - 2, new DeviceSnapshot("deye-settings", true, old, null,
List.of(new Measurement("electricity", "deye_settings", deyeSettings(), old))));
snapshot = new GatewaySnapshot("v1.2.1", devices);
assertMatchesExample("/api/battery", "battery-settings-stale");
}
@Test
void batteryWithoutGatewayCommandsIsNotEditable() throws Exception {
batteryDown = true;
var body = JSON.readTree(get("/api/battery").body());
assertTrue(body.get("reserve").isNull());
assertEquals(false, body.get("editable").asBoolean());
assertEquals(99.0, body.get("socPct").asDouble());
}
@Test
void scheduleChangeGoesToTheGatewayWithTheUserAndAnswersWithTheVerdict() throws Exception {
var response = post("/api/battery/schedule", example("battery-schedule-request").toString(),
"Remote-User", "anna");
assertEquals(200, response.statusCode());
assertEquals(List.of("schedule " + new BatteryChange(true, List.of(
new BatteryChange.Slot(2, java.time.LocalTime.of(9, 30), 90, true),
new BatteryChange.Slot(3, null, 85, null))) + " by anna"), batterySent);
assertEquals(JSON.readTree("{\"status\":\"APPLIED\",\"message\":\"applied\",\"read\":{\"167\":90}}"),
JSON.readTree(response.body()));
}
@Test
void reserveChangeGoesToTheGateway() throws Exception {
var response = post("/api/battery/reserve", example("battery-reserve-request").toString());
assertEquals(200, response.statusCode());
assertEquals(List.of("reserve 60 by phone"), batterySent);
}
@Test
void batteryStatusFollowsTheGatewaysVerdict() throws Exception {
var expected = Map.of(BatteryResult.Status.REJECTED, 422, BatteryResult.Status.STALE, 409,
BatteryResult.Status.TOO_SOON, 429, BatteryResult.Status.FAILED, 502);
for (var entry : expected.entrySet()) {
batteryVerdict = entry.getKey();
assertEquals(entry.getValue(), post("/api/battery/reserve", "{\"pct\":60}").statusCode());
assertEquals(entry.getValue(), post("/api/battery/schedule", "{\"enabled\":false}").statusCode());
}
batteryVerdict = BatteryResult.Status.FAILED;
assertEquals(example("battery-failed"),
JSON.readTree(post("/api/battery/schedule", "{\"enabled\":false}").body()));
}
@Test
void batteryChangeWithTheGatewayDownIs503() throws Exception {
batteryDown = true;
assertEquals(503, post("/api/battery/reserve", "{\"pct\":60}").statusCode());
assertEquals(503, post("/api/battery/schedule", "{\"enabled\":false}").statusCode());
}
@Test
void badBatteryRequestsAre400AndSendNothing() throws Exception {
for (String body : List.of("", "not json", "[]", "{}", "{\"pct\":\"60\"}", "{\"pct\":60.5}", "{\"pct\":null}",
"{\"pct\":60,\"x\":1}")) {
assertEquals(400, post("/api/battery/reserve", body).statusCode(), body);
}
for (String body : List.of("", "not json", "[]", "{\"enabled\":\"yes\"}", "{\"slots\":[{\"slot\":1,\"from\":\"9h\"}]}",
"{\"slots\":[{\"slot\":1,\"from\":\"25:00\"}]}", "{\"slots\":[{\"slot\":1,\"socPct\":\"9\"}]}",
"{\"slots\":[{\"slot\":1,\"soc\":9}]}", "{\"slots\":[{\"slot\":1,\"gridCharge\":1}]}")) {
assertEquals(400, post("/api/battery/schedule", body).statusCode(), body);
}
assertTrue(batterySent.isEmpty());
}
@Test
void batteryWritesAreGuardedLikeEveryWrite() throws Exception {
for (String path : List.of("/api/battery/reserve", "/api/battery/schedule")) {
assertEquals(415, rawPost(path, "{}", "X-Requested-With", "home-pump").statusCode(), path);
assertEquals(403, rawPost(path, "{}", "Content-Type", "application/json").statusCode(), path);
assertEquals(403, post(path, "{}", "Origin", "https://evil.example").statusCode(), path);
assertEquals(405, get(path).statusCode(), path);
}
assertEquals(404, post("/api/battery/other", "{}").statusCode());
assertTrue(batterySent.isEmpty());
}
private static Forecast exampleForecast() {
Map<LocalDate, Forecast.DailyForecast> days = new TreeMap<>();
for (int i = 0; i < 5; i++) {
days.put(LocalDate.parse("2026-10-19").plusDays(i), new Forecast.DailyForecast(3, 12.0, 4.0, 0.0, 10.0, 7200.0));
}
days.put(LocalDate.parse("2026-10-24"), new Forecast.DailyForecast(3, 10.9, 3.8, 0.0, 9.0, 7151.0));
days.put(LocalDate.parse("2026-10-25"), new Forecast.DailyForecast(51, 13.2, 4.0, 0.3, 4.5, 28314.0));
days.put(LocalDate.parse("2026-10-26"), new Forecast.DailyForecast(61, 9.6, 4.1, 8.4, 2.1, 0.0));
return new Forecast(10.6, days);
}
/** Twice the forecast radiation of the same days, so the factor is 2 kWh per MJ/m². */
private static Map<LocalDate, Double> exampleMeasuredPv() {
Map<LocalDate, Double> measured = new HashMap<>();
for (int i = 0; i < 5; i++) {
measured.put(LocalDate.parse("2026-10-19").plusDays(i), 20.0);
}
return measured;
}
private static Warning exampleWarning() {
return new Warning("Silny wiatr", 2, OffsetDateTime.parse("2026-10-25T08:00:00+01:00"),
OffsetDateTime.parse("2026-10-25T18:00:00+01:00"),
"Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 45 km/h.");
}
@Test
void weatherMatchesExample() throws Exception {
assertMatchesExample("/api/weather", "weather");
}
@Test
void piSensorMatchesExample()throws Exception {
assertMatchesExample("/api/pi-sensor", "pi-sensor");
}
@Test
void stalePiSensorMatchesExample() throws Exception {
Instant old = Instant.parse("2026-10-23T21:00:00Z");
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
Measurement bme = devices.getLast().measurements().get(0);
devices.set(devices.size() - 1, new DeviceSnapshot("bme280", true, old, null,
List.of(new Measurement(bme.database(), bme.measurement(), bme.fields(), old))));
snapshot = new GatewaySnapshot("v1.2.1", devices);
assertMatchesExample("/api/pi-sensor", "pi-sensor-stale");
}
@Test
void historyMatchesExample() throws Exception {
assertMatchesExample("/api/history?series=cwu_temp&range=24h", "history");
}
@Test
void statsMatchExample() throws Exception {
assertMatchesExample("/api/stats?range=7d", "stats");
}
@Test
void statsNeedAKnownRange() throws Exception {
assertEquals(400, get("/api/stats").statusCode());
assertEquals(400, get("/api/stats?range=24h").statusCode());
assertEquals(200, get("/api/stats?range=30d").statusCode());
}
@Test
void statsAre502WhenInfluxIsDown() throws Exception {
historyDown = true;
assertEquals(502, get("/api/stats?range=7d").statusCode());
}
@Test
void historyRangeDefaultsTo24Hours() throws Exception {
assertEquals(200, get("/api/history?series=cwu_temp").statusCode());
}
@Test
void auditMatchesExample() throws Exception {
assertMatchesExample("/api/audit?range=7d", "audit");
}
@Test
void auditRangeDefaultsAndIsChecked() throws Exception {
assertEquals(200, get("/api/audit").statusCode());
assertEquals(400, get("/api/audit?range=90d").statusCode());
}
@Test
void auditInfluxFailureIs502() throws Exception {
auditDown = true;
assertEquals(502, get("/api/audit?range=7d").statusCode());
}
@Test
void unreachableGatewayIs503WithJsonError() throws Exception {
gatewayDown = true;
for (String path : List.of("/api/heat-pump", "/api/energy")) {
for (String path : List.of("/api/heat-pump", "/api/energy", "/api/energy/details")) {
HttpResponse<String> response = get(path);
assertEquals(503, response.statusCode());
assertEquals("gateway unreachable", JSON.readTree(response.body()).get("error").asText());
@@ -301,6 +630,52 @@ class ApiServerTest {
assertEquals(List.of(new Command("cwu_economy", 45, java.time.Duration.ofHours(2), "phone")), sent);
}
@Test
void restoreMatchesExampleAndAsksForTheUsersSetpoint() throws Exception {
HttpResponse<String> response = post("/api/setpoints/cwu_economy/restore", "{}", "Remote-User", "anna");
assertEquals(200, response.statusCode());
assertEquals(example("setpoint-restored"), JSON.readTree(response.body()));
assertEquals(List.of("cwu_economy by anna"), restored);
}
@Test
void restoreStatusFollowsTheGatewaysVerdict() throws Exception {
var expected = Map.of(Outcome.NOT_PENDING, 404, Outcome.CONFLICT, 409, Outcome.TOO_SOON, 429,
Outcome.FAILED, 502);
for (var entry : expected.entrySet()) {
restoreOutcome = entry.getKey();
assertEquals(entry.getValue(), post("/api/setpoints/cwu_economy/restore", "{}").statusCode());
}
}
@Test
void restoreWithTheGatewayDownIs503() throws Exception {
commandsDown = true;
assertEquals(503, post("/api/setpoints/cwu_economy/restore", "{}").statusCode());
}
@Test
void restoreIsGuardedLikeEveryWrite() throws Exception {
String path = "/api/setpoints/cwu_economy/restore";
assertEquals(415, rawPost(path, "{}", "X-Requested-With", "home-pump").statusCode());
assertEquals(403, rawPost(path, "{}", "Content-Type", "application/json").statusCode());
assertEquals(403, post(path, "{}", "Origin", "https://evil.example").statusCode());
assertEquals(405, get(path).statusCode());
assertTrue(restored.isEmpty());
}
@Test
void otherPathsUnderSetpointsAre404() throws Exception {
for (String path : List.of("/api/setpoints/", "/api/setpoints/cwu_economy",
"/api/setpoints/cwu_economy/restore/x", "/api/setpoints/CWU/restore", "/api/setpoints/a-b/restore")) {
assertEquals(404, post(path, "{}").statusCode(), path);
}
assertTrue(restored.isEmpty());
}
@Test
void rejectedIs422AndMatchesExample() throws Exception {
verdict = Status.REJECTED;
@@ -343,7 +718,7 @@ class ApiServerTest {
@Test
void presetResultsMatchExample() throws Exception {
HttpResponse<String> response = post("/api/presets/away", "");
HttpResponse<String> response = post("/api/presets/away", awayRequest());
assertEquals(200, response.statusCode());
JsonNode body = JSON.readTree(response.body());
@@ -356,12 +731,66 @@ class ApiServerTest {
void presetCutShortByALostLinkKeepsTheResultsSoFar() throws Exception {
answersBeforeDrop = 1;
HttpResponse<String> response = post("/api/presets/away", "{}");
HttpResponse<String> response = post("/api/presets/away", awayRequest());
assertEquals(502, response.statusCode());
assertEquals(example("setpoints-preset-partial"), JSON.readTree(response.body()));
}
@Test
void awayPresetSendsTheChosenEndHeldInEachChange() throws Exception {
post("/api/presets/away", awayRequest());
var end = java.time.Instant.parse("2026-11-02T17:00:00Z");
assertEquals(List.of(new Command("cwu_comfort", 10, null, end, "phone"),
new Command("co_comfort", 15, null, end, "phone")), sent);
}
@Test
void awayWithOwnValuesSendsThem() throws Exception {
var response = post("/api/presets/away", example("preset-away-values-request").toString());
assertEquals(200, response.statusCode());
var end = java.time.Instant.parse("2026-11-02T17:00:00Z");
assertEquals(List.of(new Command("cwu_comfort", 12, null, end, "phone"),
new Command("co_comfort", 16, null, end, "phone")), sent);
}
@Test
void ownValuesForOtherSetpointsOrOutsideTheLimitsAre400AndSendNothing() throws Exception {
String until = "\"until\":\"2026-11-02T18:00:00Z\"";
for (String values : List.of("{\"cwu_economy\":6}", "{\"co_comfort\":4}", "{\"co_comfort\":26}",
"{\"co_comfort\":null}", "{\"co_comfort\":15.5}", "{\"co_comfort\":\"15\"}", "[]")) {
assertEquals(400, post("/api/presets/away", "{" + until + ",\"values\":" + values + "}").statusCode(),
values);
}
assertTrue(sent.isEmpty());
}
@Test
void ownValueThatDoesNotLowerAHeldSetpointIs422() throws Exception {
var body = "{\"until\":\"2026-11-02T18:00:00Z\",\"values\":{\"co_comfort\":19}}";
assertEquals(422, post("/api/presets/away", body).statusCode());
assertTrue(sent.isEmpty());
}
@Test
void badOrMissingEndTimesAre400AndSendNothing() throws Exception {
for (String body : List.of("", "{}", "{\"until\":null}", "{\"until\":\"tomorrow\"}", "{\"until\":5}",
"{\"until\":\"2026-11-02T18:00:00\"}", "not json", "{\"until\":\"2026-11-02T18:00:00Z\",\"x\":1}")) {
assertEquals(400, post("/api/presets/away", body).statusCode(), body);
}
assertEquals(400, post("/api/presets/cwu-now", awayRequest()).statusCode());
assertTrue(sent.isEmpty());
}
@Test
void presetWithoutAnEndStillTakesNoBodyOrAnEmptyObject() throws Exception {
assertEquals(200, post("/api/presets/cwu-now", "").statusCode());
assertEquals(200, post("/api/presets/cwu-now", "{}").statusCode());
}
@Test
void unknownPresetIs404() throws Exception {
assertEquals(404, post("/api/presets/nope", "").statusCode());
@@ -372,7 +801,7 @@ class ApiServerTest {
void presetOutsideLimitsIs422() throws Exception {
verdict = Status.REJECTED;
assertEquals(422, post("/api/presets/away", "").statusCode());
assertEquals(422, post("/api/presets/away", awayRequest()).statusCode());
}
@Test
@@ -381,7 +810,7 @@ class ApiServerTest {
assertEquals(503, get("/api/setpoints").statusCode());
assertEquals(503, post("/api/setpoints", BOOST).statusCode());
assertEquals(503, post("/api/presets/away", "").statusCode());
assertEquals(503, post("/api/presets/away", awayRequest()).statusCode());
}
@Test
@@ -0,0 +1,105 @@
package home.backend.adapters.influx;
import com.sun.net.httpserver.HttpServer;
import home.backend.application.AuditRepository.Row;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InfluxAuditRepositoryTest {
private static final Instant FROM = Instant.parse("2026-10-17T12:00:00Z");
private static final Instant TO = Instant.parse("2026-10-24T12:00:00Z");
private HttpServer server;
private final List<String> queries = Collections.synchronizedList(new ArrayList<>());
private String body;
@BeforeEach
void startServer() throws IOException {
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/query", exchange -> {
queries.add(URLDecoder.decode(exchange.getRequestURI().getRawQuery(), StandardCharsets.UTF_8));
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
server.start();
}
@AfterEach
void stopServer() {
server.stop(0);
}
@Test
void readsHeatPumpRowsFromTheLegacyDatabase() throws IOException {
body = fixture("influx-audit-setpoints.json");
List<Row> rows = repository().heatPump(FROM, TO);
assertEquals(2, rows.size());
assertEquals(new Row(Instant.ofEpochMilli(1761292800000L), "cwu_economy", "restore", "APPLIED", 45.0, 8.0),
rows.get(0));
assertNull(rows.get(1).oldValue());
assertEquals("co_comfort", rows.get(1).target());
assertTrue(queries.getFirst().startsWith("db=Dom_Pompa&epoch=ms&q=SELECT * FROM \"setpoint_audit\" "
+ "WHERE time >= " + FROM.toEpochMilli() + "ms AND time < " + TO.toEpochMilli() + "ms"),
queries.getFirst());
}
@Test
void readsBatteryRowsByRegisterTag() throws IOException {
body = fixture("influx-audit-battery.json");
List<Row> rows = repository().battery(FROM, TO);
assertEquals(List.of("reserve", "174"), rows.stream().map(Row::target).toList());
assertEquals(60.0, rows.getFirst().newValue());
assertTrue(queries.getFirst().startsWith("db=electricity&"), queries.getFirst());
assertTrue(queries.getFirst().contains("\"deye_settings_audit\""), queries.getFirst());
}
@Test
void noRowsIsAnEmptyList() throws IOException {
body = fixture("influx-query-empty.json");
assertTrue(repository().battery(FROM, TO).isEmpty());
}
@Test
void anInfluxErrorIsAnIOException() throws IOException {
body = fixture("influx-query-error.json");
assertThrows(IOException.class, () -> repository().heatPump(FROM, TO));
}
private InfluxAuditRepository repository() {
URI base = URI.create("http://127.0.0.1:" + server.getAddress().getPort());
return new InfluxAuditRepository(new InfluxSettings(base, Duration.ofSeconds(5)));
}
private static String fixture(String name) throws IOException {
return Files.readString(Path.of("src/test/resources/influx", name));
}
}
@@ -1,6 +1,7 @@
package home.backend.adapters.influx;
import com.sun.net.httpserver.HttpServer;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
import org.junit.jupiter.api.AfterEach;
@@ -208,6 +209,35 @@ class InfluxHistoryRepositoryTest {
() -> repository(Duration.ofMillis(200)).history(Series.CWU_TEMP, FROM, TO, 72));
}
@Test
void readsRawHeatPumpPollsFromTheLegacyDatabase() throws IOException {
defaultBody = fixture("influx-query-heat-pump.json");
List<HeatPumpSample> samples = repository(TIMEOUT).samples(FROM, TO);
assertEquals("Dom_Pompa", requests.get(0).get("db"));
assertEquals("SELECT output_compressor, output_cwu, output_co, output_heater, outdoor_temp, cwu_temp"
+ " FROM \"heat_pump\" WHERE time >= 1792800000000ms AND time < 1792821600000ms",
requests.get(0).get("q"));
assertEquals(3, samples.size());
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771500000L), 100.0, 100.0, 0.0, 0.0, 8.5, 43.9),
samples.get(1));
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771800000L), 50.0, 0.0, 100.0, 100.0, null, null),
samples.get(2));
}
@Test
void noHeatPumpPollsIsAnEmptyList() throws IOException {
assertTrue(repository(TIMEOUT).samples(FROM, TO).isEmpty());
}
@Test
void heatPumpQueryErrorIsAnIoException() throws IOException {
defaultBody = fixture("influx-query-error.json");
assertThrows(IOException.class, () -> repository(TIMEOUT).samples(FROM, TO));
}
@Test
void rejectsAnEmptyRange() {
assertThrows(IllegalArgumentException.class,
@@ -0,0 +1,89 @@
package home.backend.adapters.influx;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InfluxPvEnergyTest {
private static final LocalDate FROM = LocalDate.parse("2026-10-19");
private static final LocalDate TO = LocalDate.parse("2026-10-24");
private HttpServer server;
private InfluxPvEnergy pv;
private final List<String> queries = new ArrayList<>();
private int status = 200;
/** Day buckets start at Warsaw midnight: 2026-10-19 is 2026-10-18T22:00Z. */
private String deye = rows("1792360800000", 22.5, "1792447200000", 8.0);
private String fronius = rows("1792360800000", 4400.0, "1792533600000", 100.0);
@BeforeEach
void start() throws IOException {
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/query", exchange -> {
String query = URLDecoder.decode(exchange.getRequestURI().getRawQuery(), StandardCharsets.UTF_8);
queries.add(query);
byte[] bytes = (query.contains("\"deye\"") ? deye : fronius).getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
server.start();
var settings = new InfluxSettings(URI.create("http://127.0.0.1:" + server.getAddress().getPort()),
Duration.ofSeconds(2));
pv = new InfluxPvEnergy(settings);
}
@AfterEach
void stop() {
server.stop(0);
}
private static String rows(String t1, double v1, String t2, double v2) {
return "{\"results\":[{\"statement_id\":0,\"series\":[{\"name\":\"m\",\"columns\":[\"time\",\"max\"],"
+ "\"values\":[[" + t1 + "," + v1 + "],[" + t2 + "," + v2 + "]]}]}]}";
}
@Test
void addsTheDeyeAndFroniusDayCountersPerLocalDay() throws IOException {
Map<LocalDate, Double> days = pv.dailyKwh(FROM, TO);
assertEquals(Map.of(FROM, 22.5 + 4.4), days);
}
@Test
void asksForTheDailyMaximumOfEachCounterInWarsawDays() throws IOException {
pv.dailyKwh(FROM, TO);
assertEquals(2, queries.size());
String deyeQuery = queries.stream().filter(q -> q.contains("\"deye\"")).findFirst().orElseThrow();
assertTrue(deyeQuery.contains("db=electricity"), deyeQuery);
assertTrue(deyeQuery.contains("SELECT max(\"pv_today_kwh\") FROM \"deye\" WHERE time >= 1792360800000ms"
+ " AND time < 1792792800000ms GROUP BY time(1d) fill(none) tz('Europe/Warsaw')"), deyeQuery);
assertTrue(queries.stream().anyMatch(q -> q.contains("max(\"energy_day_wh\") FROM \"fronius\"")), queries.toString());
}
@Test
void failureIsAnIoError() {
status = 500;
assertThrows(IOException.class, () -> pv.dailyKwh(FROM, TO));
}
}
@@ -0,0 +1,185 @@
package home.backend.adapters.weather;
import com.sun.net.httpserver.HttpServer;
import home.backend.domain.Forecast;
import home.backend.domain.WeatherView.Warning;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
/** The two clients against a local server that answers with responses recorded from the real services. */
class WeatherClientsTest {
private static final WeatherSettings PLACE = WeatherSettings.fromEnvironment(Map.of());
private static final Duration TIMEOUT = Duration.ofSeconds(2);
private HttpServer server;
private URI base;
private final List<String> requests = new ArrayList<>();
private int status = 200;
private String body = "";
@BeforeEach
void start() throws IOException {
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/", exchange -> {
requests.add(exchange.getRequestURI().toString());
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
server.start();
base = URI.create("http://127.0.0.1:" + server.getAddress().getPort());
}
@AfterEach
void stop() {
server.stop(0);
}
private static String fixture(String name) throws IOException {
return Files.readString(Path.of("src/test/resources/weather", name));
}
@Test
void readsTheOpenMeteoForecastForTheHouse() throws IOException {
body = fixture("open-meteo.json");
Forecast forecast = new OpenMeteoClient(base, PLACE, TIMEOUT).forecast();
assertEquals(10.6, forecast.temperature());
assertEquals(17, forecast.days().size());
var today = forecast.days().get(LocalDate.parse("2026-10-24"));
assertEquals(3, today.weatherCode());
assertEquals(10.9, today.tMax());
assertEquals(3.8, today.tMin());
assertEquals(6.88, today.radiationMj());
assertEquals(7151.32, today.sunshineSeconds());
assertEquals(8.4, forecast.days().get(LocalDate.parse("2026-10-26")).precipitationMm());
String request = requests.getFirst();
for (String part : List.of("latitude=49.684", "longitude=21.77", "timezone=Europe%2FWarsaw",
"past_days=14", "shortwave_radiation_sum", "sunshine_duration")) {
assertEquals(true, request.contains(part), part + " in " + request);
}
}
@Test
void aMissingValueInTheForecastIsNull() throws IOException {
body = """
{"daily":{"time":["2026-10-24"],"weather_code":[null],"temperature_2m_max":[1.5]}}""";
var day = new OpenMeteoClient(base, PLACE, TIMEOUT).forecast().days().get(LocalDate.parse("2026-10-24"));
assertNull(day.weatherCode());
assertEquals(1.5, day.tMax());
assertNull(day.tMin());
assertNull(day.radiationMj());
}
@Test
void anOpenMeteoFailureIsAnIoError() {
body = "{\"error\":true}";
status = 500;
var client = new OpenMeteoClient(base, PLACE, TIMEOUT);
assertThrows(IOException.class, client::forecast);
status = 200;
body = "not json";
assertThrows(IOException.class, client::forecast);
body = "{\"daily\":{}}";
assertThrows(IOException.class, client::forecast);
}
@Test
void keepsWarningsThatCoverTheCityOrThePowiatAndMapsThem() throws IOException {
body = fixture("imgw-warnings.json");
List<Warning> warnings = new ImgwClient(base, PLACE, TIMEOUT).warnings();
assertEquals("/api/data/warningsmeteo", requests.getFirst());
assertEquals(2, warnings.size());
Warning city = warnings.get(0);
assertEquals("Silny wiatr", city.event());
assertEquals(2, city.level());
assertEquals(OffsetDateTime.parse("2026-10-25T08:00:00+01:00"), city.from());
assertEquals(OffsetDateTime.parse("2026-10-25T18:00:00+01:00"), city.to());
assertEquals("Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 45 km/h, "
+ "w porywach do 90 km/h, z południa.", city.text());
assertEquals(1, warnings.get(1).level());
}
@Test
void otherAreasAreIgnoredAndTerytIsConfigurable() throws IOException {
body = fixture("imgw-warnings.json");
var cityOnly = new WeatherSettings(PLACE.latitude(), PLACE.longitude(), List.of("1862"));
var elsewhere = new WeatherSettings(PLACE.latitude(), PLACE.longitude(), List.of("2804"));
assertEquals(1, new ImgwClient(base, cityOnly, TIMEOUT).warnings().size());
assertEquals(1, new ImgwClient(base, elsewhere, TIMEOUT).warnings().size());
assertEquals(0, new ImgwClient(base, new WeatherSettings(0, 0, List.of("0000")), TIMEOUT).warnings().size());
}
@Test
void aMalformedWarningIsSkippedNotFatal() throws IOException {
body = """
[{"nazwa_zdarzenia":"Burze","stopien":"x","obowiazuje_od":"2026-10-25 08:00:00",
"obowiazuje_do":"2026-10-25 18:00:00","tresc":"a","teryt":["1862"]},
{"nazwa_zdarzenia":"Burze","stopien":"3","obowiazuje_od":"soon",
"obowiazuje_do":"2026-10-25 18:00:00","tresc":"b","teryt":["1862"]},
{"nazwa_zdarzenia":"Burze","stopien":"3","obowiazuje_od":"2026-10-25 08:00:00",
"obowiazuje_do":"2026-10-25 18:00:00","tresc":"c","teryt":["1862"]}]""";
List<Warning> warnings = new ImgwClient(base, PLACE, TIMEOUT).warnings();
assertEquals(1, warnings.size());
assertEquals("c", warnings.getFirst().text());
}
@Test
void anImgwFailureIsAnIoError() {
body = "{}";
var client = new ImgwClient(base, PLACE, TIMEOUT);
assertThrows(IOException.class, client::warnings);
status = 503;
assertThrows(IOException.class, client::warnings);
}
@Test
void settingsDefaultToKrosnoAndRejectNonsense() {
assertEquals(49.684, PLACE.latitude());
assertEquals(21.770, PLACE.longitude());
assertEquals(List.of("1862", "1807"), PLACE.teryt());
var custom = WeatherSettings.fromEnvironment(
Map.of("WEATHER_LAT", "50.1", "WEATHER_LON", "-20", "WEATHER_TERYT", "1465, 1401"));
assertEquals(50.1, custom.latitude());
assertEquals(-20.0, custom.longitude());
assertEquals(List.of("1465", "1401"), custom.teryt());
assertThrows(IllegalArgumentException.class,
() -> WeatherSettings.fromEnvironment(Map.of("WEATHER_LAT", "north")));
assertThrows(IllegalArgumentException.class,
() -> WeatherSettings.fromEnvironment(Map.of("WEATHER_LON", "200")));
}
}
@@ -0,0 +1,108 @@
package home.backend.application;
import home.backend.application.AuditRepository.Row;
import home.backend.domain.AuditEntry;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
class AuditServiceTest {
private static final Instant NOW = Instant.parse("2026-10-24T12:00:00Z");
private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
private List<Row> heatPump = List.of();
private List<Row> battery = List.of();
private final List<Instant> asked = new ArrayList<>();
private final AuditRepository repository = new AuditRepository() {
@Override
public List<Row> heatPump(Instant from, Instant to) {
asked.add(from);
return heatPump;
}
@Override
public List<Row> battery(Instant from, Instant to) throws IOException {
if (battery == null) {
throw new IOException("influx down");
}
return battery;
}
};
private final AuditService service = new AuditService(repository, CLOCK);
private static Row row(String time, String target, String by, Double old, Double value) {
return new Row(Instant.parse(time), target, by, "APPLIED", old, value);
}
@Test
void mergesBothSourcesNewestFirst() throws IOException {
heatPump = List.of(row("2026-10-24T08:00:00Z", "cwu_economy", "a", 8.0, 45.0),
row("2026-10-22T08:00:00Z", "co_comfort", "a", 19.0, 20.0));
battery = List.of(row("2026-10-23T08:00:00Z", "reserve", "b", 50.0, 60.0));
var entries = service.audit(null).entries();
assertEquals(List.of("Ciepła woda – oszczędnie", "Rezerwa baterii", "Ogrzewanie – komfort"),
entries.stream().map(AuditEntry::what).toList());
assertEquals(List.of("heat_pump", "battery", "heat_pump"),
entries.stream().map(AuditEntry::source).toList());
}
@Test
void namesTheDeyeRegisters() throws IOException {
battery = List.of("146", "148", "153", "166", "171", "172", "177", "reserve", "999").stream()
.map(register -> row("2026-10-24T08:00:00Z", register, "a", 0.0, 1.0)).toList();
var entries = service.audit("24h").entries();
assertEquals(List.of("Harmonogram baterii/0/1", "Przedział 1: start/HHMM", "Przedział 6: start/HHMM",
"Przedział 1: poziom/%", "Przedział 6: poziom/%", "Przedział 1: ładowanie z sieci/0/1",
"Przedział 6: ładowanie z sieci/0/1", "Rezerwa baterii/%", "Rejestr 999/"),
entries.stream().map(e -> e.what() + "/" + e.unit()).toList());
}
@Test
void showsARestoreAsAutomaticAndKeepsMissingValuesNull() throws IOException {
heatPump = List.of(row("2026-10-24T08:00:00Z", "cwu_economy", "restore", null, 8.4));
var entry = service.audit("7d").entries().getFirst();
assertEquals("automatycznie (przywrócenie)", entry.who());
assertNull(entry.oldValue());
assertEquals(8, entry.newValue());
assertEquals("°C", entry.unit());
}
@Test
void defaultsToSevenDaysAndAllowsUpToThirty() throws IOException {
service.audit(null);
service.audit("30d");
assertEquals(List.of(NOW.minus(Duration.ofDays(7)), NOW.minus(Duration.ofDays(30))), asked);
}
@Test
void rejectsAnUnknownRange() {
assertThrows(IllegalArgumentException.class, () -> service.audit("90d"));
assertThrows(IllegalArgumentException.class, () -> service.audit(""));
}
@Test
void failsWhenOneSourceFails() {
battery = null;
assertThrows(IOException.class, () -> service.audit("7d"));
}
}
@@ -0,0 +1,92 @@
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 org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LeaseRenewerTest {
private final AtomicInteger calls = new AtomicInteger();
private final CountDownLatch threeCalls = new CountDownLatch(3);
private Exception failure;
private final GatewayCommands gateway = new GatewayCommands() {
@Override
public CommandResult apply(Command command) {
throw new UnsupportedOperationException();
}
@Override
public RestoreResult restore(String setpoint, String requestedBy) {
throw new UnsupportedOperationException();
}
@Override
public SetpointState setpoints() {
throw new UnsupportedOperationException();
}
@Override
public int renewLeases() throws IOException {
calls.incrementAndGet();
threeCalls.countDown();
if (failure instanceof IOException e) {
throw e;
}
if (failure instanceof RuntimeException e) {
throw e;
}
return 1;
}
};
@Test
void renewsAtOnceAndThenEveryPeriod() throws Exception {
try (var renewer = new LeaseRenewer(gateway)) {
renewer.start(Duration.ofMillis(10));
assertTrue(threeCalls.await(5, TimeUnit.SECONDS));
}
}
@Test
void aFailedRenewalDoesNotStopTheSchedule() throws Exception {
failure = new IOException("tunnel down");
try (var renewer = new LeaseRenewer(gateway)) {
renewer.start(Duration.ofMillis(10));
assertTrue(threeCalls.await(5, TimeUnit.SECONDS));
}
}
@Test
void anUnexpectedErrorDoesNotStopTheScheduleEither() throws Exception {
failure = new IllegalStateException("bad answer");
try (var renewer = new LeaseRenewer(gateway)) {
renewer.start(Duration.ofMillis(10));
assertTrue(threeCalls.await(5, TimeUnit.SECONDS));
}
}
@Test
void renewDoesNotThrowOutOfTheScheduler() {
failure = new IOException("tunnel down");
try (var renewer = new LeaseRenewer(gateway)) {
renewer.renew();
}
assertEquals(1, calls.get());
}
}
@@ -1,5 +1,12 @@
package home.backend.application;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView;
import home.backend.domain.EnergyDetails;
import home.backend.domain.EnergyDetails.Device;
import home.backend.domain.EnergyDetails.Group;
import home.backend.domain.EnergyDetails.Item;
import home.backend.domain.EnergyView;
import home.backend.domain.EnergyView.Source;
import home.backend.domain.GatewaySnapshot;
@@ -9,6 +16,7 @@ import home.backend.domain.HealthView;
import home.backend.domain.HeatPumpView;
import home.backend.domain.HeatPumpView.State;
import home.backend.domain.HistoryView;
import home.backend.domain.PiSensorView;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
import org.junit.jupiter.api.Test;
@@ -18,6 +26,7 @@ import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -40,7 +49,7 @@ class ServicesTest {
}
private static GatewayClient gateway(DeviceSnapshot... devices) {
return () -> new GatewaySnapshot(List.of(devices));
return () -> new GatewaySnapshot("v1.2.1", List.of(devices));
}
private static GatewayClient unreachable() {
@@ -166,6 +175,197 @@ class ServicesTest {
assertThrows(IOException.class, () -> new HeatPumpService(unreachable(), CLOCK).current());
}
private static Map<String, Double> settings(double touEnabled) {
Map<String, Double> fields = new HashMap<>(Map.of("battery_capacity_ah", 280.0,
"tou_enabled", touEnabled, "grid_charge_enabled", 0.0));
double[] starts = {100, 500, 900, 1300, 1700, 2100};
for (int n = 1; n <= 6; n++) {
fields.putAll(Map.of("prog" + n + "_start_hhmm", starts[n - 1], "prog" + n + "_power_w", 8000.0,
"prog" + n + "_soc_pct", 60.0, "prog" + n + "_grid_charge", n == 2 ? 1.0 : 0.0));
}
return fields;
}
private static BatteryView battery(DeviceSnapshot... devices) throws IOException {
return new BatteryService(gateway(devices), new FakeBattery(), CLOCK).current();
}
private static final class FakeBattery implements GatewayBattery {
static final List<String> SENT = new java.util.ArrayList<>();
final boolean enabled;
FakeBattery() {
this(true);
}
FakeBattery(boolean enabled) {
this.enabled = enabled;
}
@Override
public BatteryView.Reserve reserve() throws IOException {
if (!enabled) {
throw new IOException("gateway /battery/reserve answered 404");
}
return new BatteryView.Reserve(50, 30);
}
@Override
public BatteryResult changeSchedule(BatteryChange change, String requestedBy) {
SENT.add("schedule by " + requestedBy);
return new BatteryResult(BatteryResult.Status.APPLIED, "applied", Map.of());
}
@Override
public BatteryResult changeReserve(int pct, String requestedBy) {
SENT.add("reserve " + pct + " by " + requestedBy);
return new BatteryResult(BatteryResult.Status.APPLIED, "applied", Map.of());
}
}
@Test
void batteryShowsTheReserveAndIsEditableWhenTheGatewayAcceptsChanges() throws IOException {
BatteryView view = battery();
assertEquals(new BatteryView.Reserve(50, 30), view.reserve());
assertTrue(view.editable());
}
@Test
void batteryIsNotEditableWithoutGatewayCommandsButStillShowsTheLiveState() throws IOException {
BatteryView view = new BatteryService(gateway(device("deye", "home", "deye",
Map.of("battery_soc_pct", 80.0))), new FakeBattery(false), CLOCK).current();
assertNull(view.reserve());
assertFalse(view.editable());
assertEquals(80.0, view.socPct());
}
@Test
void batteryChangesAreHandedToTheGatewayWithTheUser() throws IOException {
var service = new BatteryService(gateway(), new FakeBattery(), CLOCK);
assertEquals(BatteryResult.Status.APPLIED, service.changeReserve(60, "anna").status());
assertEquals(BatteryResult.Status.APPLIED,
service.changeSchedule(new BatteryChange(true, null), "ola").status());
assertEquals(List.of("reserve 60 by anna", "schedule by ola"), FakeBattery.SENT);
}
private static DeviceSnapshot deyeSettings(Map<String, Double> fields, Instant time) {
return new DeviceSnapshot("deye-settings", true, time, null,
List.of(new Measurement("electricity", "deye_settings", fields, time)));
}
@Test
void batteryReadsLiveValuesAndComputesCapacityAtNominalVoltage() throws IOException {
BatteryView view = battery(device("deye", "home", "deye", Map.of("battery_soc_pct", 80.0,
"battery_power_w", 1500.0, "battery_charge_today_kwh", 2.5)),
deyeSettings(settings(0), READ));
assertFalse(view.stale());
assertEquals(80.0, view.socPct());
assertEquals(1500.0, view.powerW());
assertEquals(2.5, view.today().chargeKwh());
assertEquals(14.336, view.capacityKwh(), 1e-9);
}
@Test
void batteryScheduleRunsToTheNextStartAndWrapsAroundMidnight() throws IOException {
List<BatteryView.Slot> schedule = battery(deyeSettings(settings(1), READ)).schedule();
assertEquals(6, schedule.size());
assertEquals(new BatteryView.Slot("01:00", "05:00", 8000, 60, false), schedule.get(0));
assertEquals(new BatteryView.Slot("05:00", "09:00", 8000, 60, true), schedule.get(1));
assertEquals(new BatteryView.Slot("21:00", "01:00", 8000, 60, false), schedule.get(5));
}
@Test
void batteryScheduleIsEmptyWhenASlotStartIsMissing() throws IOException {
Map<String, Double> fields = settings(1);
fields.remove("prog3_start_hhmm");
assertTrue(battery(deyeSettings(fields, READ)).schedule().isEmpty());
}
@Test
void batteryModeFollowsTimeOfUse() throws IOException {
BatteryView backup = battery(deyeSettings(settings(0), READ));
BatteryView scheduled = battery(deyeSettings(settings(1), READ));
assertFalse(backup.mode().touEnabled());
assertEquals("Rezerwa na awarię: bateria nie rozładowuje się na co dzień", backup.mode().description());
assertTrue(scheduled.mode().touEnabled());
assertEquals("Harmonogram: bateria pracuje według przedziałów", scheduled.mode().description());
}
@Test
void batterySettingsAreStaleAfterThirtyMinutesButLiveValuesStayCurrent() throws IOException {
var live = device("deye", "home", "deye", Map.of("battery_soc_pct", 80.0));
BatteryView fresh = battery(live, deyeSettings(settings(0), NOW.minusSeconds(1800)));
BatteryView old = battery(live, deyeSettings(settings(0), NOW.minusSeconds(1801)));
assertFalse(fresh.settingsStale());
assertEquals(6, fresh.schedule().size());
assertTrue(old.settingsStale());
assertEquals(NOW.minusSeconds(1801), old.settingsTime());
assertTrue(old.schedule().isEmpty());
assertNull(old.mode());
assertNull(old.limits());
assertNull(old.capacityKwh());
assertEquals(80.0, old.socPct());
}
@Test
void staleDeyeShowsNoLiveBatteryValues() throws IOException {
var old = new Measurement("home", "deye", Map.of("battery_soc_pct", 80.0), NOW.minusSeconds(601));
BatteryView view = battery(new DeviceSnapshot("deye", true, old.time(), null, List.of(old)),
deyeSettings(settings(0), READ));
assertTrue(view.stale());
assertNull(view.socPct());
assertNull(view.powerW());
assertFalse(view.settingsStale());
}
@Test
void batteryWithoutAnyReadingIsStaleTwice() throws IOException {
BatteryView view = battery();
assertTrue(view.stale());
assertTrue(view.settingsStale());
assertNull(view.time());
assertNull(view.settingsTime());
}
private static PiSensorView piSensor(DeviceSnapshot... devices) throws IOException {
return new PiSensorService(gateway(devices), CLOCK).current();
}
@Test
void piSensorReadsTemperatureHumidityAndPressure() throws IOException {
PiSensorView view = piSensor(device("bme280", "measurement", "MEASUREMENT",
Map.of("temperature", 20.17, "humidity", 45.82, "pressure", 985.8)));
assertEquals(READ, view.time());
assertFalse(view.stale());
assertEquals(20.17, view.temperature());
assertEquals(45.82, view.humidity());
assertEquals(985.8, view.pressure());
}
@Test
void stalePiSensorShowsNoValues() throws IOException {
var old = new Measurement("measurement", "MEASUREMENT", Map.of("temperature", 20.0), NOW.minusSeconds(601));
PiSensorView view = piSensor(new DeviceSnapshot("bme280", true, old.time(), null, List.of(old)));
assertTrue(view.stale());
assertNull(view.temperature());
assertTrue(piSensor().stale());
assertNull(piSensor().time());
}
private static EnergyView energy(DeviceSnapshot... devices) throws IOException {
return new EnergyService(gateway(devices), CLOCK).current();
}
@@ -215,6 +415,80 @@ class ServicesTest {
assertEquals(List.of(new Source("deye", null, true), new Source("fronius", null, true)), view.sources());
}
private static EnergyDetails details(DeviceSnapshot... devices) throws IOException {
return new EnergyDetailsService(gateway(devices), CLOCK).current();
}
private static EnergyDetails.Device detailsOf(EnergyDetails details, String id) {
return details.devices().stream().filter(d -> d.id().equals(id)).findFirst().orElseThrow();
}
@Test
void detailsShowOnlyReportedFieldsAndDropEmptyGroups() throws IOException {
EnergyDetails.Device deye = detailsOf(details(device("deye", "home", "deye",
Map.of("battery_soc_pct", 80.0, "grid_power_l2_w", 52.0, "pv1_power_w", 10.0))), "deye");
assertFalse(deye.stale());
assertEquals(READ, deye.time());
assertEquals(List.of("Bateria", "Sieć", "Panele PV"), deye.groups().stream().map(Group::title).toList());
assertEquals(List.of(new Item("Moc L2", 52.0, "W", 0)), deye.groups().get(1).items());
}
@Test
void detailsOfAStaleDeviceHaveNoValues() throws IOException {
Instant old = NOW.minusSeconds(3600);
var stale = new DeviceSnapshot("deye", true, old, null, List.of(new Measurement("home", "deye", DEYE, old)));
EnergyDetails.Device deye = detailsOf(details(stale), "deye");
assertTrue(deye.stale());
assertEquals(old, deye.time());
assertTrue(deye.groups().isEmpty());
}
@Test
void detailsListEveryDeviceEvenWhenTheGatewayKnowsNone() throws IOException {
EnergyDetails all = details();
assertEquals(List.of("deye", "fronius", "house-meter"), all.devices().stream().map(Device::id).toList());
assertTrue(all.devices().stream().allMatch(d -> d.stale() && d.groups().isEmpty()));
}
@Test
void detailsConvertFroniusWhToKwhAndRoundThem() throws IOException {
EnergyDetails.Device fronius = detailsOf(details(device("fronius", "home", "fronius",
Map.of("energy_day_wh", 4399.99, "energy_total_wh", 2.90713E7))), "fronius");
assertEquals(List.of(new Item("Dziś", 4.4, "kWh", 1), new Item("Od początku", 29071.0, "kWh", 0)),
fronius.groups().get(0).items());
}
@Test
void detailsReadTheHouseMeterPerPhase() throws IOException {
EnergyDetails.Device meter = detailsOf(details(device("house-meter", "electricity", "PRAD2",
Map.of("p1_voltage", 235.69, "p3_voltage", 232.2))), "house-meter");
assertEquals(List.of(new Item("Napięcie L1", 235.7, "V", 1), new Item("Napięcie L3", 232.2, "V", 1)),
meter.groups().get(0).items());
}
@Test
void detailsTakeThePhaseEnergyFromItsOwnMeasurement() throws IOException {
var meter = new DeviceSnapshot("house-meter", true, READ, null, List.of(
new Measurement("electricity", "PRAD2", Map.of("frequency", 50.01), READ),
new Measurement("electricity", "house_meter_phases", Map.of("l2_import_energy_active_kwh", 7012.4), READ)));
Group phases = detailsOf(details(meter), "house-meter").groups().getLast();
assertEquals("Energia na fazę", phases.title());
assertEquals(List.of(new Item("Pobrana L2", 7012.0, "kWh", 0)), phases.items());
}
@Test
void detailsFailWhenTheGatewayIsUnreachable() {
assertThrows(IOException.class, () -> new EnergyDetailsService(unreachable(), CLOCK).current());
}
private static DeviceSnapshot deyeRead(boolean up, Instant time) {
return new DeviceSnapshot("deye", up, time, null, List.of(new Measurement("home", "deye", DEYE, time)));
}
@@ -269,7 +543,7 @@ class ServicesTest {
DeviceSnapshot down = new DeviceSnapshot("deye", false, null, "connect timed out", List.of());
HealthView view = new HealthService(gateway(device("heat-pump", "Dom_Pompa", "heat_pump", Map.of()),
down), CLOCK).current();
down), CLOCK, "v1.3.0").current();
assertTrue(view.gatewayReachable());
assertEquals(NOW, view.time());
@@ -279,12 +553,22 @@ class ServicesTest {
assertEquals(READ, view.devices().get(0).lastSuccess());
}
@Test
void healthReportsBothVersions() {
HealthView view = new HealthService(gateway(), CLOCK, "v1.3.0").current();
assertEquals("v1.3.0", view.backendVersion());
assertEquals("v1.2.1", view.gatewayVersion());
}
@Test
void healthWithUnreachableGatewayHasNoDevices() {
HealthView view = new HealthService(unreachable(), CLOCK).current();
HealthView view = new HealthService(unreachable(), CLOCK, "v1.3.0").current();
assertFalse(view.gatewayReachable());
assertTrue(view.devices().isEmpty());
assertEquals("v1.3.0", view.backendVersion());
assertNull(view.gatewayVersion());
}
/** Remembers the request and answers with one point. */
@@ -3,6 +3,8 @@ package home.backend.application;
import home.backend.adapters.config.PresetFile;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.RestoreResult;
import home.backend.domain.RestoreResult.Outcome;
import home.backend.domain.CommandResult.Status;
import home.backend.domain.Preset;
import home.backend.domain.Preset.Change;
@@ -16,8 +18,10 @@ import org.junit.jupiter.api.Test;
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 static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -25,6 +29,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class SetpointServiceTest {
private static final Instant RETURN = Instant.parse("2026-11-02T17:00:00Z");
private static final SetpointState STATE = new SetpointState(List.of(
new Setpoint("co_comfort", 4132, 5, 25, 1, 19),
new Setpoint("cwu_economy", 4143, 5, 48, 1, 8),
@@ -33,6 +38,7 @@ class SetpointServiceTest {
private final List<Command> sent = new ArrayList<>();
/** What the gateway does on the next calls to apply, in order: a Status, or an IOException; then APPLIED. */
private final List<Object> script = new ArrayList<>();
private final List<String> restored = new ArrayList<>();
private boolean down;
private final GatewayCommands gateway = new GatewayCommands() {
@Override
@@ -48,6 +54,15 @@ class SetpointServiceTest {
return new CommandResult((Status) next, command.setpoint(), null);
}
@Override
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
if (down) {
throw new IOException("tunnel down");
}
restored.add(setpoint + " by " + requestedBy);
return new RestoreResult(Outcome.RESTORED, "restored");
}
@Override
public SetpointState setpoints() throws IOException {
if (down) {
@@ -55,10 +70,29 @@ class SetpointServiceTest {
}
return STATE;
}
@Override
public int renewLeases() {
return 0;
}
};
private final List<Preset> presets = PresetFile.load(null);
private final SetpointService service = new SetpointService(gateway, presets);
@Test
void restoreAsksTheGatewayInTheUsersName() throws Exception {
assertEquals(Outcome.RESTORED, service.restore("cwu_economy", "anna").outcome());
assertEquals(List.of("cwu_economy by anna"), restored);
}
@Test
void restoreWithTheGatewayDownIsAnIoError() {
down = true;
assertThrows(IOException.class, () -> service.restore("cwu_economy", "anna"));
}
@Test
void viewJoinsGatewayStateAndPresets() throws Exception {
var view = service.current();
@@ -93,33 +127,93 @@ class SetpointServiceTest {
@Test
void boostPresetSendsOneTimedCommand() throws Exception {
var result = service.applyPreset("cwu-now", "anna").orElseThrow();
var result = service.applyPreset("cwu-now", null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.APPLIED, result.status());
assertEquals(List.of(new Command("cwu_economy", 45, Duration.ofHours(2), "anna")), sent);
}
@Test
void awayPresetSendsBothChangesUntilChanged() throws Exception {
var result = service.applyPreset("away", "anna").orElseThrow();
void awayPresetSendsBothChangesHeldUntilTheChosenTime() throws Exception {
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
assertEquals(2, result.results().size());
assertEquals(List.of(new Command("cwu_comfort", 10, null, "anna"), new Command("co_comfort", 15, null, "anna")),
sent);
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
new Command("co_comfort", 15, null, RETURN, "anna")), sent);
}
@Test
void ownValuesReplaceTheDefaultsAndTheRestKeepThem() throws Exception {
service.applyPreset("away", RETURN, Map.of("co_comfort", 16), "anna");
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
new Command("co_comfort", 16, null, RETURN, "anna")), sent);
}
@Test
void ownValuesAreAllowedForAnOrdinaryPresetToo() throws Exception {
service.applyPreset("cwu-now", null, Map.of("cwu_economy", 40), "anna");
assertEquals(List.of(new Command("cwu_economy", 40, Duration.ofHours(2), "anna")), sent);
}
@Test
void aValueForASetpointOutsideThePresetIsRefusedBeforeAnythingIsSent() {
assertThrows(IllegalArgumentException.class,
() -> service.applyPreset("away", RETURN, Map.of("cwu_economy", 6), "anna"));
assertTrue(sent.isEmpty());
}
@Test
void aValueOutsideTheGatewayLimitsIsRefusedBeforeAnythingIsSent() {
for (int value : List.of(4, 26, 0)) {
assertThrows(IllegalArgumentException.class,
() -> service.applyPreset("away", RETURN, Map.of("co_comfort", value), "anna"));
}
assertTrue(sent.isEmpty());
}
@Test
void aHeldValueThatDoesNotLowerTheCurrentOneWritesNothing() throws Exception {
var result = service.applyPreset("away", RETURN, Map.of("co_comfort", 19), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
assertTrue(sent.isEmpty());
}
@Test
void aHeldPresetNeedsAnEndTimeAndOthersRefuseOne() {
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("away", null, Map.of(), "anna"));
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("cwu-now", RETURN, Map.of(), "anna"));
assertTrue(sent.isEmpty());
}
@Test
void aHeldPresetThatWouldNotLowerAnySetpointWritesNothing() throws Exception {
var cold = new Preset("cold", "Zimno", "chosen", List.of(new Change("cwu_comfort", 10, null),
new Change("co_comfort", 19, null)));
var result = new SetpointService(gateway, List.of(cold)).applyPreset("cold", RETURN, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
assertTrue(result.results().getLast().message().contains("not below the current 19"));
assertTrue(sent.isEmpty());
}
@Test
void unknownPresetIsEmpty() throws Exception {
assertTrue(service.applyPreset("nope", "anna").isEmpty());
assertTrue(service.applyPreset("nope", null, Map.of(), "anna").isEmpty());
assertTrue(sent.isEmpty());
}
@Test
void presetOutsideGatewayLimitsWritesNothing() throws Exception {
var tooHot = new Preset("hot", "Za gorąco", List.of(new Change("co_comfort", 15, null),
var tooHot = new Preset("hot", "Za gorąco", null, List.of(new Change("co_comfort", 15, null),
new Change("cwu_economy", 60, 2.0)));
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", "anna").orElseThrow();
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
@@ -129,9 +223,9 @@ class SetpointServiceTest {
@Test
void presetWithUnknownSetpointWritesNothing() throws Exception {
var odd = new Preset("odd", "Dziwny", List.of(new Change("garage_heater", 1, null)));
var odd = new Preset("odd", "Dziwny", null, List.of(new Change("garage_heater", 1, null)));
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", "anna").orElseThrow();
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertTrue(sent.isEmpty());
@@ -145,7 +239,7 @@ class SetpointServiceTest {
void presetReportsEveryChangeAndStopsAfterTheFirstThatIsNotApplied() throws Exception {
script.add(Status.FAILED);
var result = service.applyPreset("away", "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.FAILED, result.status());
assertEquals(List.of(ChangeStatus.FAILED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
@@ -156,7 +250,7 @@ class SetpointServiceTest {
void secondChangeRejectedLeavesThePresetPartial() throws Exception {
script.addAll(List.of(Status.APPLIED, Status.REJECTED));
var result = service.applyPreset("away", "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.PARTIAL, result.status());
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.REJECTED), statuses(result));
@@ -166,7 +260,7 @@ class SetpointServiceTest {
void firstChangeRejectedByTheGatewayIsRejectedAsAWhole() throws Exception {
script.add(Status.REJECTED);
var result = service.applyPreset("away", "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.REJECTED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
@@ -176,7 +270,7 @@ class SetpointServiceTest {
void lostAnswerOnTheSecondChangeKeepsTheFirstResult() throws Exception {
script.addAll(List.of(Status.APPLIED, new IOException("tunnel dropped")));
var result = service.applyPreset("away", "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.PARTIAL, result.status());
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.UNKNOWN), statuses(result));
@@ -186,7 +280,7 @@ class SetpointServiceTest {
void lostAnswerOnTheFirstChangeIsFailedNotUnreachable() throws Exception {
script.add(new IOException("tunnel dropped"));
var result = service.applyPreset("away", "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.FAILED, result.status());
assertEquals(List.of(ChangeStatus.UNKNOWN, ChangeStatus.NOT_ATTEMPTED), statuses(result));
@@ -198,7 +292,7 @@ class SetpointServiceTest {
assertThrows(IOException.class, service::current);
assertThrows(IOException.class, () -> service.apply("cwu_economy", 45, null, "anna"));
assertThrows(IOException.class, () -> service.applyPreset("away", "anna"));
assertThrows(IOException.class, () -> service.applyPreset("away", RETURN, Map.of(), "anna"));
assertTrue(sent.isEmpty());
}
}
@@ -0,0 +1,214 @@
package home.backend.application;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.StatsView;
import home.backend.domain.StatsView.Day;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
class StatsServiceTest {
/** Monday 2026-10-26 11:00 in Warsaw; the week before it holds the change to winter time. */
private static final Instant NOW = Instant.parse("2026-10-26T10:00:00Z");
private Instant askedFrom;
private Instant askedTo;
private StatsView stats(String range, Instant now, HeatPumpSample... samples) throws IOException {
HeatPumpSamples repository = (from, to) -> {
askedFrom = from;
askedTo = to;
return List.of(samples);
};
return new StatsService(repository, Clock.fixed(now, ZoneOffset.UTC)).stats(range);
}
private static HeatPumpSample compressor(String time, double stage) {
return new HeatPumpSample(Instant.parse(time), stage, 0.0, 0.0, 0.0, null, null);
}
private static Day day(StatsView view, String date) {
return view.days().stream().filter(d -> d.date().equals(LocalDate.parse(date))).findFirst().orElseThrow();
}
@Test
void asksForWholeLocalDaysUpToNow() throws IOException {
stats("7d", NOW);
assertEquals(Instant.parse("2026-10-19T22:00:00Z"), askedFrom);
assertEquals(NOW, askedTo);
}
@Test
void listsOneDayPerLocalDateOldestFirst() throws IOException {
StatsView view = stats("30d", NOW);
assertEquals(30, view.days().size());
assertEquals(LocalDate.parse("2026-09-27"), view.days().getFirst().date());
assertEquals(LocalDate.parse("2026-10-26"), view.days().getLast().date());
}
@Test
void noDataGivesEmptyDaysAndZeroTotals() throws IOException {
StatsView view = stats("7d", NOW);
assertEquals(7, view.days().size());
for (Day d : view.days()) {
assertNull(d.compressorHours());
assertNull(d.compressorStarts());
assertNull(d.avgOutdoorTemp());
}
assertEquals(0.0, view.totals().compressorHours());
assertEquals(0, view.totals().compressorStarts());
assertNull(view.totals().avgOutdoorTemp());
assertNull(view.totals().minCwuTemp());
}
@Test
void aGapIsCappedAtTenMinutesPerPoll() throws IOException {
StatsView view = stats("7d", NOW,
compressor("2026-10-23T10:00:00Z", 100),
compressor("2026-10-23T10:05:00Z", 100),
compressor("2026-10-23T11:00:00Z", 0));
assertEquals(0.25, day(view, "2026-10-23").compressorHours());
}
@Test
void theLastPollHoldsUntilNowAtMostTenMinutes() throws IOException {
StatsView recent = stats("7d", NOW, compressor("2026-10-26T09:58:00Z", 50));
StatsView old = stats("7d", NOW, compressor("2026-10-26T08:00:00Z", 50));
assertEquals(0.03, day(recent, "2026-10-26").compressorHours());
assertEquals(0.17, day(old, "2026-10-26").compressorHours());
}
@Test
void countsOnlyTransitionsFromOffToOn() throws IOException {
StatsView view = stats("7d", NOW,
compressor("2026-10-23T10:00:00Z", 100),
compressor("2026-10-23T10:05:00Z", 100),
compressor("2026-10-23T10:10:00Z", 0),
compressor("2026-10-23T10:15:00Z", 0),
compressor("2026-10-23T10:20:00Z", 50),
compressor("2026-10-23T10:25:00Z", 100),
compressor("2026-10-23T10:30:00Z", 0),
compressor("2026-10-23T10:35:00Z", 100));
assertEquals(2, day(view, "2026-10-23").compressorStarts());
assertEquals(2, view.totals().compressorStarts());
}
@Test
void aStartAtMidnightBelongsToTheNewDay() throws IOException {
StatsView view = stats("7d", NOW,
compressor("2026-10-24T21:55:00Z", 0),
compressor("2026-10-24T22:00:00Z", 100));
assertEquals(0, day(view, "2026-10-24").compressorStarts());
assertEquals(1, day(view, "2026-10-25").compressorStarts());
}
@Test
void splitsAStretchAtLocalMidnightInSummerTime() throws IOException {
StatsView view = stats("7d", NOW,
compressor("2026-10-24T21:58:00Z", 100),
compressor("2026-10-24T22:03:00Z", 0));
assertEquals(0.03, day(view, "2026-10-24").compressorHours());
assertEquals(0.05, day(view, "2026-10-25").compressorHours());
}
@Test
void splitsAStretchAtLocalMidnightInWinterTime() throws IOException {
StatsView view = stats("7d", NOW,
compressor("2026-10-25T22:58:00Z", 100),
compressor("2026-10-25T23:03:00Z", 0));
assertEquals(0.03, day(view, "2026-10-25").compressorHours());
assertEquals(0.05, day(view, "2026-10-26").compressorHours());
}
@Test
void theDayOfTheClockChangeLastsTwentyFiveHours() throws IOException {
List<HeatPumpSample> samples = new ArrayList<>();
Instant at = Instant.parse("2026-10-24T22:00:00Z");
while (at.isBefore(Instant.parse("2026-10-25T23:00:00Z"))) {
samples.add(compressor(at.toString(), 100));
at = at.plusSeconds(300);
}
StatsView view = stats("7d", NOW, samples.toArray(HeatPumpSample[]::new));
assertEquals(25.0, day(view, "2026-10-25").compressorHours());
assertNull(day(view, "2026-10-26").compressorHours());
}
@Test
void sumsEachOutputSeparately() throws IOException {
StatsView view = stats("7d", NOW,
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 100.0, 0.0, 0.0, 10.0, 40.0),
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 0.0, 100.0, 100.0, 12.0, 38.0),
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 0.0, 0.0, 0.0, 0.0, null, null));
Day d = day(view, "2026-10-23");
assertEquals(0.17, d.compressorHours());
assertEquals(0.08, d.cwuHours());
assertEquals(0.08, d.coHours());
assertEquals(0.08, d.heaterHours());
assertEquals(11.0, d.avgOutdoorTemp());
assertEquals(38.0, d.minCwuTemp());
assertEquals(40.0, d.maxCwuTemp());
}
@Test
void totalsCoverTheWholeRangeWithTheTemperatureOfAllPolls() throws IOException {
StatsView view = stats("7d", NOW,
new HeatPumpSample(Instant.parse("2026-10-22T10:00:00Z"), 100.0, 0.0, 0.0, 0.0, 10.0, 30.0),
new HeatPumpSample(Instant.parse("2026-10-22T10:05:00Z"), 0.0, 0.0, 0.0, 0.0, 10.0, 31.0),
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 0.0, 0.0, 100.0, 4.0, 45.0),
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 0.0, 0.0, 0.0, 0.0, 4.0, 44.0));
assertEquals(0.17, view.totals().compressorHours());
assertEquals(0.08, view.totals().heaterHours());
assertEquals(7.0, view.totals().avgOutdoorTemp());
assertEquals(30.0, view.totals().minCwuTemp());
assertEquals(45.0, view.totals().maxCwuTemp());
}
@Test
void aMissingFieldCountsAsOffAndUnknownStateIsNoStart() throws IOException {
StatsView view = stats("7d", NOW,
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), null, null, null, null, null, null),
compressor("2026-10-23T10:05:00Z", 100));
assertEquals(0, day(view, "2026-10-23").compressorStarts());
assertNull(day(view, "2026-10-23").avgOutdoorTemp());
}
@Test
void rejectsAnUnknownRange() {
assertThrows(IllegalArgumentException.class, () -> stats("24h", NOW));
assertThrows(IllegalArgumentException.class, () -> stats(null, NOW));
}
@Test
void propagatesRepositoryFailure() {
StatsService service = new StatsService((from, to) -> {
throw new IOException("influx down");
}, Clock.fixed(NOW, ZoneOffset.UTC));
assertThrows(IOException.class, () -> service.stats("7d"));
}
}
@@ -0,0 +1,274 @@
package home.backend.application;
import home.backend.domain.Forecast;
import home.backend.domain.Forecast.DailyForecast;
import home.backend.domain.WeatherView;
import home.backend.domain.WeatherView.Warning;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class WeatherServiceTest {
private static final Instant START = Instant.parse("2026-10-24T10:00:00Z");
private static final LocalDate TODAY = LocalDate.parse("2026-10-24");
private Instant now = START;
private final Clock clock = new Clock() {
@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(ZoneId zone) {
return Clock.fixed(now, zone);
}
@Override
public Instant instant() {
return now;
}
};
private final AtomicInteger forecastCalls = new AtomicInteger();
private final AtomicInteger warningCalls = new AtomicInteger();
private boolean forecastDown;
private boolean warningsDown;
private boolean influxDown;
private int measuredDays = 5;
private LocalDate today = TODAY;
private List<Warning> warned = List.of(warning(START.plus(Duration.ofHours(8))));
private final WeatherService service = new WeatherService(
() -> {
forecastCalls.incrementAndGet();
if (forecastDown) {
throw new IOException("open-meteo down");
}
return forecast();
},
() -> {
warningCalls.incrementAndGet();
if (warningsDown) {
throw new IOException("imgw down");
}
return warned;
},
(from, to) -> {
if (influxDown) {
throw new IOException("influx down");
}
assertEquals(today.minusDays(14), from);
assertEquals(today, to);
Map<LocalDate, Double> measured = new HashMap<>();
for (int i = 1; i <= measuredDays; i++) {
measured.put(today.minusDays(i), 15.0);
}
return measured;
},
clock);
private Forecast forecast() {
Map<LocalDate, DailyForecast> days = new TreeMap<>();
for (int i = 1; i <= 14; i++) {
days.put(today.minusDays(i), new DailyForecast(3, 10.0, 2.0, 0.0, 5.0, 3600.0));
}
days.put(today, new DailyForecast(0, 12.34, 3.0, 0.0, 6.0, 5400.0));
days.put(today.plusDays(1), new DailyForecast(63, 9.0, 4.0, 5.5, null, null));
days.put(today.plusDays(2), new DailyForecast(null, null, null, null, 1.0, 0.0));
return new Forecast(8.5, days);
}
private static Warning warning(Instant until) {
return new Warning("Burza", 2, OffsetDateTime.ofInstant(START, ZoneOffset.UTC),
OffsetDateTime.ofInstant(until, ZoneOffset.UTC), "text");
}
@Test
void shapesThreeDaysAndEstimatesPvFromTheFactor() throws IOException {
WeatherView view = service.current();
assertEquals(START, view.time());
assertEquals(8.5, view.now().temperature());
assertEquals(3.0, view.pvFactor(), 1e-9);
assertEquals(TODAY, view.today().date());
assertEquals("Bezchmurnie", view.today().description());
assertEquals(12.34, view.today().tMax());
assertEquals(1.5, view.today().sunshineHours());
assertEquals(18.0, view.today().pvForecastKwh());
assertEquals(TODAY.plusDays(2), view.dayAfter().date());
assertEquals("Deszcz", view.tomorrow().description());
assertEquals(5.5, view.tomorrow().precipitationMm());
assertNull(view.tomorrow().pvForecastKwh());
assertEquals(3.0, view.dayAfter().pvForecastKwh());
assertEquals("Brak danych", view.dayAfter().description());
assertFalse(view.stale());
}
@Test
void usesTheWarsawDateNearMidnight() throws IOException {
now = Instant.parse("2026-10-23T22:30:00Z");
assertEquals(TODAY, service.current().today().date());
}
@Test
void aForecastFetchedBeforeLocalMidnightIsNotUsedAfterIt() throws IOException {
now = Instant.parse("2026-10-24T21:50:00Z");
service.current();
now = Instant.parse("2026-10-24T22:05:00Z");
today = TODAY.plusDays(1);
WeatherView view = service.current();
assertEquals(2, forecastCalls.get());
assertEquals(today, view.today().date());
assertEquals(today.plusDays(2), view.dayAfter().date());
}
@Test
void aFailedRefreshServesTheOlderWarningsWithoutAskingAgainFor60Seconds() throws IOException {
service.current();
warningsDown = true;
now = START.plus(Duration.ofMinutes(11));
service.current();
assertEquals(2, warningCalls.get());
now = now.plusSeconds(59);
assertTrue(service.current().stale());
assertEquals(2, warningCalls.get());
now = now.plusSeconds(1);
service.current();
assertEquals(3, warningCalls.get());
}
@Test
void withoutAnyForecastItFailsFastDuringTheBackOff() {
forecastDown = true;
assertThrows(IOException.class, service::current);
now = START.plusSeconds(30);
assertThrows(IOException.class, service::current);
assertEquals(1, forecastCalls.get());
}
@Test
void fewerThanFiveMeasuredDaysGiveNoEstimate() throws IOException {
measuredDays = 4;
WeatherView view = service.current();
assertNull(view.pvFactor());
assertNull(view.today().pvForecastKwh());
assertEquals(12.34, view.today().tMax());
}
@Test
void anUnreachableInfluxGivesNoEstimateButStillTheWeather() throws IOException {
influxDown = true;
WeatherView view = service.current();
assertNull(view.pvFactor());
assertEquals(0, view.today().weatherCode());
assertFalse(view.stale());
}
@Test
void keepsTheForecastFor30MinutesAndWarningsFor10() throws IOException {
service.current();
now = START.plus(Duration.ofMinutes(9));
service.current();
assertEquals(1, warningCalls.get());
now = START.plus(Duration.ofMinutes(10));
service.current();
assertEquals(2, warningCalls.get());
assertEquals(1, forecastCalls.get());
now = START.plus(Duration.ofMinutes(30));
service.current();
assertEquals(2, forecastCalls.get());
}
@Test
void servesTheOlderForecastMarkedStaleWhenTheRefreshFails() throws IOException {
service.current();
forecastDown = true;
now = START.plus(Duration.ofMinutes(31));
WeatherView view = service.current();
assertTrue(view.stale());
assertEquals(8.5, view.now().temperature());
assertEquals(3.0, view.pvFactor(), 1e-9);
}
@Test
void servesTheOlderWarningsMarkedStaleWhenTheRefreshFails() throws IOException {
service.current();
warningsDown = true;
now = START.plus(Duration.ofMinutes(11));
WeatherView view = service.current();
assertTrue(view.stale());
assertEquals(1, view.warnings().size());
}
@Test
void withoutAnyForecastItFails() {
forecastDown = true;
assertThrows(IOException.class, service::current);
}
@Test
void withoutAnyWarningsItStillAnswersMarkedStale() throws IOException {
warningsDown = true;
WeatherView view = service.current();
assertTrue(view.warnings().isEmpty());
assertTrue(view.stale());
assertEquals(12.34, view.today().tMax());
}
@Test
void dropsWarningsThatHaveEnded() throws IOException {
warned = List.of(warning(START.plus(Duration.ofMinutes(5))), warning(START.plus(Duration.ofHours(8))));
assertEquals(2, service.current().warnings().size());
now = START.plus(Duration.ofMinutes(6));
assertEquals(1, service.current().warnings().size());
}
@Test
void aForecastWithoutTodayFails() {
var short_ = new WeatherService(() -> new Forecast(null, Map.of()), List::of, (from, to) -> Map.of(), clock);
assertThrows(IOException.class, short_::current);
}
}
@@ -0,0 +1,84 @@
package home.backend.domain;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class WeatherDomainTest {
private static final LocalDate DAY = LocalDate.parse("2026-10-10");
@Test
void describesWmoCodesInPolish() {
assertEquals("Bezchmurnie", WeatherCodes.describe(0));
assertEquals("Pochmurno", WeatherCodes.describe(3));
assertEquals("Mgła", WeatherCodes.describe(45));
assertEquals("Mżawka", WeatherCodes.describe(53));
assertEquals("Deszcz", WeatherCodes.describe(63));
assertEquals("Śnieg", WeatherCodes.describe(73));
assertEquals("Przelotny deszcz", WeatherCodes.describe(81));
assertEquals("Burza", WeatherCodes.describe(95));
assertEquals("Burza z gradem", WeatherCodes.describe(99));
}
@Test
void anUnknownOrMissingCodeIsNoData() {
assertEquals("Brak danych", WeatherCodes.describe(42));
assertEquals("Brak danych", WeatherCodes.describe(null));
}
@Test
void calibratesAsTotalMeasuredOverTotalRadiation() {
var measured = new HashMap<LocalDate, Double>();
var radiation = new HashMap<LocalDate, Double>();
for (int i = 0; i < 5; i++) {
measured.put(DAY.plusDays(i), 10.0 + i);
radiation.put(DAY.plusDays(i), 5.0 + i);
}
// 60 kWh over 35 MJ/m², not the mean of the daily ratios.
assertEquals(60.0 / 35.0, PvCalibration.factor(measured, radiation), 1e-9);
}
@Test
void daysMissingEitherValueDoNotCount() {
Map<LocalDate, Double> measured = new HashMap<>();
Map<LocalDate, Double> radiation = new HashMap<>();
for (int i = 0; i < 5; i++) {
measured.put(DAY.plusDays(i), 10.0);
radiation.put(DAY.plusDays(i), 5.0);
}
measured.put(DAY.plusDays(9), 1000.0);
radiation.put(DAY.plusDays(8), 1000.0);
assertEquals(2.0, PvCalibration.factor(measured, radiation), 1e-9);
}
@Test
void fewerThanFiveDaysGiveNoFactor() {
Map<LocalDate, Double> measured = new HashMap<>();
Map<LocalDate, Double> radiation = new HashMap<>();
for (int i = 0; i < 4; i++) {
measured.put(DAY.plusDays(i), 10.0);
radiation.put(DAY.plusDays(i), 5.0);
}
assertNull(PvCalibration.factor(measured, radiation));
}
@Test
void noRadiationGivesNoFactor() {
Map<LocalDate, Double> days = new HashMap<>();
for (int i = 0; i < 5; i++) {
days.put(DAY.plusDays(i), 0.0);
}
assertNull(PvCalibration.factor(Map.of(), days));
assertNull(PvCalibration.factor(days, days));
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"entries": [
{ "time": "2026-10-24T09:30:00Z", "source": "battery", "what": "Rezerwa baterii", "oldValue": 50, "newValue": 60,
"unit": "%", "who": "mariusz", "status": "APPLIED" },
{ "time": "2026-10-24T08:00:00Z", "source": "heat_pump", "what": "Ciepła woda – oszczędnie", "oldValue": 45, "newValue": 8,
"unit": "°C", "who": "automatycznie (przywrócenie)", "status": "APPLIED" },
{ "time": "2026-10-23T18:00:00Z", "source": "battery", "what": "Przedział 3: ładowanie z sieci", "oldValue": 0,
"newValue": 1, "unit": "0/1", "who": "mariusz", "status": "FAILED" },
{ "time": "2026-10-23T07:00:00Z", "source": "heat_pump", "what": "Ogrzewanie – komfort", "oldValue": null, "newValue": 22,
"unit": "°C", "who": "mariusz", "status": "REJECTED" }
]
}
@@ -0,0 +1 @@
{ "status": "FAILED", "message": "registers not as written: [167]; not retried", "read": { "167": 80 } }
@@ -0,0 +1 @@
{ "pct": 60 }
@@ -0,0 +1,7 @@
{
"enabled": true,
"slots": [
{ "slot": 2, "from": "09:30", "socPct": 90, "gridCharge": true },
{ "slot": 3, "socPct": 85 }
]
}
@@ -0,0 +1,19 @@
{
"time": "2026-10-23T22:26:00Z",
"stale": false,
"socPct": 99.0,
"powerW": -24.0,
"voltageV": 56.49,
"currentA": -0.05,
"temperatureC": 19.2,
"capacityKwh": null,
"today": { "chargeKwh": 0.5, "dischargeKwh": 0.1 },
"total": { "chargeKwh": 453.7, "dischargeKwh": 162.1 },
"mode": null,
"schedule": [],
"limits": null,
"settingsTime": "2026-10-23T21:00:00Z",
"settingsStale": true,
"reserve": { "pct": 50, "floorPct": 30 },
"editable": true
}
@@ -0,0 +1,38 @@
{
"time": "2026-10-23T22:26:00Z",
"stale": false,
"socPct": 99.0,
"powerW": -24.0,
"voltageV": 56.49,
"currentA": -0.05,
"temperatureC": 19.2,
"capacityKwh": 14.336,
"today": { "chargeKwh": 0.5, "dischargeKwh": 0.1 },
"total": { "chargeKwh": 453.7, "dischargeKwh": 162.1 },
"mode": {
"touEnabled": false,
"description": "Rezerwa na awarię: bateria nie rozładowuje się na co dzień"
},
"schedule": [
{ "from": "01:00", "to": "05:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
{ "from": "05:00", "to": "09:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
{ "from": "09:00", "to": "13:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
{ "from": "13:00", "to": "17:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
{ "from": "17:00", "to": "21:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
{ "from": "21:00", "to": "01:00", "powerW": 10000, "socPct": 80, "gridCharge": false }
],
"limits": {
"maxChargeA": 80,
"maxDischargeA": 120,
"shutdownSocPct": 20,
"lowSocPct": 35,
"restartSocPct": 50,
"gridChargeEnabled": true,
"gridChargeCurrentA": 40,
"gridChargeStartSocPct": 30
},
"settingsTime": "2026-10-23T22:26:00Z",
"settingsStale": false,
"reserve": { "pct": 50, "floorPct": 30 },
"editable": true
}
@@ -0,0 +1,72 @@
{
"devices": [
{
"id": "deye", "name": "Falownik Deye", "time": "2026-10-23T22:26:00Z", "stale": false,
"groups": [
{ "title": "Bateria", "items": [
{ "label": "Naładowanie", "value": 99.0, "unit": "%", "digits": 0 },
{ "label": "Moc", "value": -24.0, "unit": "W", "digits": 0 },
{ "label": "Napięcie", "value": 56.5, "unit": "V", "digits": 1 },
{ "label": "Prąd", "value": 0.0, "unit": "A", "digits": 1 },
{ "label": "Temperatura", "value": 19.2, "unit": "°C", "digits": 1 }
] },
{ "title": "Sieć", "items": [
{ "label": "Moc razem", "value": 645.0, "unit": "W", "digits": 0 }
] },
{ "title": "Odbiór domu", "items": [
{ "label": "Moc razem", "value": 570.0, "unit": "W", "digits": 0 }
] },
{ "title": "Panele PV", "items": [
{ "label": "PV1 moc", "value": 0.0, "unit": "W", "digits": 0 },
{ "label": "PV2 moc", "value": 0.0, "unit": "W", "digits": 0 }
] },
{ "title": "Dziś", "items": [
{ "label": "Produkcja PV", "value": 29.9, "unit": "kWh", "digits": 1 },
{ "label": "Zużycie domu", "value": 24.2, "unit": "kWh", "digits": 1 },
{ "label": "Pobrano z sieci", "value": 16.8, "unit": "kWh", "digits": 1 },
{ "label": "Oddano do sieci", "value": 22.3, "unit": "kWh", "digits": 1 },
{ "label": "Bateria naładowana", "value": 0.5, "unit": "kWh", "digits": 1 },
{ "label": "Bateria rozładowana", "value": 0.1, "unit": "kWh", "digits": 1 }
] },
{ "title": "Od początku", "items": [
{ "label": "Bateria naładowana", "value": 454.0, "unit": "kWh", "digits": 0 },
{ "label": "Bateria rozładowana", "value": 162.0, "unit": "kWh", "digits": 0 }
] }
]
},
{
"id": "fronius", "name": "Falownik Fronius", "time": "2026-10-23T22:26:00Z", "stale": false,
"groups": [
{ "title": "Teraz", "items": [
{ "label": "Moc PV", "value": 0.0, "unit": "W", "digits": 0 }
] },
{ "title": "Produkcja", "items": [
{ "label": "Dziś", "value": 4.4, "unit": "kWh", "digits": 1 }
] }
]
},
{
"id": "house-meter", "name": "Licznik domu", "time": "2026-10-23T22:26:00Z", "stale": false,
"groups": [
{ "title": "Moc czynna", "items": [
{ "label": "Moc L1", "value": 56.0, "unit": "W", "digits": 0 }
] },
{ "title": "Napięcie", "items": [
{ "label": "Napięcie L1", "value": 235.7, "unit": "V", "digits": 1 }
] },
{ "title": "Prąd", "items": [
{ "label": "Prąd L1", "value": 0.32, "unit": "A", "digits": 2 }
] },
{ "title": "Współczynnik mocy", "items": [
{ "label": "cos φ L1", "value": 0.75, "unit": "", "digits": 2 }
] },
{ "title": "Sieć", "items": [
{ "label": "Częstotliwość", "value": 50.01, "unit": "Hz", "digits": 2 }
] },
{ "title": "Energia od początku", "items": [
{ "label": "Pobrana", "value": 67514.0, "unit": "kWh", "digits": 0 }
] }
]
}
]
}
@@ -1,6 +1,8 @@
{
"time": "2026-10-23T22:14:00Z",
"gatewayReachable": true,
"backendVersion": "v1.3.0",
"gatewayVersion": "v1.2.1",
"devices": [
{ "name": "heat-pump", "up": true, "lastSuccess": "2026-10-23T22:13:35Z", "lastError": null },
{ "name": "deye", "up": false, "lastSuccess": null, "lastError": "connect timed out" }
@@ -0,0 +1,7 @@
{
"time": "2026-10-23T21:00:00Z",
"stale": true,
"temperature": null,
"humidity": null,
"pressure": null
}
@@ -0,0 +1,7 @@
{
"time": "2026-10-23T22:26:00Z",
"stale": false,
"temperature": 20.17,
"humidity": 45.82,
"pressure": 985.8
}
@@ -0,0 +1 @@
{ "until": "2026-11-02T18:00:00+01:00" }
@@ -0,0 +1 @@
{ "until": "2026-11-02T18:00:00+01:00", "values": { "cwu_comfort": 12, "co_comfort": 16 } }
@@ -0,0 +1 @@
{ "outcome": "RESTORED", "message": "restored to 8" }
@@ -17,6 +17,7 @@
{
"id": "away",
"name": "Wyjazd",
"until": "chosen",
"changes": [
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
{ "setpoint": "co_comfort", "value": 15, "hours": null }
+92
View File
@@ -0,0 +1,92 @@
{
"range": "7d",
"days": [
{
"date": "2026-10-18",
"compressorHours": null,
"compressorStarts": null,
"cwuHours": null,
"coHours": null,
"heaterHours": null,
"avgOutdoorTemp": null,
"minCwuTemp": null,
"maxCwuTemp": null
},
{
"date": "2026-10-19",
"compressorHours": null,
"compressorStarts": null,
"cwuHours": null,
"coHours": null,
"heaterHours": null,
"avgOutdoorTemp": null,
"minCwuTemp": null,
"maxCwuTemp": null
},
{
"date": "2026-10-20",
"compressorHours": null,
"compressorStarts": null,
"cwuHours": null,
"coHours": null,
"heaterHours": null,
"avgOutdoorTemp": null,
"minCwuTemp": null,
"maxCwuTemp": null
},
{
"date": "2026-10-21",
"compressorHours": null,
"compressorStarts": null,
"cwuHours": null,
"coHours": null,
"heaterHours": null,
"avgOutdoorTemp": null,
"minCwuTemp": null,
"maxCwuTemp": null
},
{
"date": "2026-10-22",
"compressorHours": null,
"compressorStarts": null,
"cwuHours": null,
"coHours": null,
"heaterHours": null,
"avgOutdoorTemp": null,
"minCwuTemp": null,
"maxCwuTemp": null
},
{
"date": "2026-10-23",
"compressorHours": 0.17,
"compressorStarts": 1,
"cwuHours": 0.08,
"coHours": 0.0,
"heaterHours": 0.08,
"avgOutdoorTemp": 8.6,
"minCwuTemp": 43.0,
"maxCwuTemp": 46.0
},
{
"date": "2026-10-24",
"compressorHours": null,
"compressorStarts": null,
"cwuHours": null,
"coHours": null,
"heaterHours": null,
"avgOutdoorTemp": null,
"minCwuTemp": null,
"maxCwuTemp": null
}
],
"totals": {
"compressorHours": 0.17,
"compressorStarts": 1,
"cwuHours": 0.08,
"coHours": 0.0,
"heaterHours": 0.08,
"avgOutdoorTemp": 8.6,
"minCwuTemp": 43.0,
"maxCwuTemp": 46.0
}
}
@@ -0,0 +1,14 @@
{
"time": "2026-10-23T22:14:00Z",
"today": {"date": "2026-10-24", "weatherCode": 3, "description": "Pochmurno", "tMax": 10.9, "tMin": 3.8,
"precipitationMm": 0.0, "sunshineHours": 2.0, "pvForecastKwh": 18.0},
"tomorrow": {"date": "2026-10-25", "weatherCode": 51, "description": "Mżawka", "tMax": 13.2, "tMin": 4.0,
"precipitationMm": 0.3, "sunshineHours": 7.9, "pvForecastKwh": 9.0},
"dayAfter": {"date": "2026-10-26", "weatherCode": 61, "description": "Słaby deszcz", "tMax": 9.6, "tMin": 4.1,
"precipitationMm": 8.4, "sunshineHours": 0.0, "pvForecastKwh": 4.2},
"now": {"temperature": 10.6},
"warnings": [{"event": "Silny wiatr", "level": 2, "from": "2026-10-25T08:00:00+01:00",
"to": "2026-10-25T18:00:00+01:00", "text": "Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 45 km/h."}],
"pvFactor": 2.0,
"stale": false
}
@@ -1 +1 @@
{"status":"UP","devices":[{"name":"bme280","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"deye","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"fronius","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"heat-pump","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"house-meter","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null}]}
{"status":"UP","version":"v1.2.1","devices":[{"name":"bme280","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"deye","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"fronius","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"heat-pump","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"house-meter","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null}]}
@@ -0,0 +1 @@
{"results":[{"statement_id":0,"series":[{"name":"deye_settings_audit","columns":["time","new_value","old_value","register","requested_by","status"],"values":[[1761298200000,60,50,"reserve","mariusz","APPLIED"],[1761242400000,1,0,"174","mariusz","FAILED"]]}]}]}
@@ -0,0 +1 @@
{"results":[{"statement_id":0,"series":[{"name":"setpoint_audit","columns":["time","new_value","old_value","register","requested_by","setpoint","status"],"values":[[1761292800000,8,45,4143,"restore","cwu_economy","APPLIED"],[1761202800000,22,null,4146,"mariusz","co_comfort","REJECTED"]]}]}]}
@@ -0,0 +1 @@
{"results":[{"statement_id":0,"series":[{"name":"heat_pump","columns":["time","output_compressor","output_cwu","output_co","output_heater","outdoor_temp","cwu_temp"],"values":[[1792771200000,0,0,0,0,8.5,44.2],[1792771500000,100,100,0,0,8.5,43.9],[1792771800000,50,0,100,100,null,null]]}]}]}
@@ -0,0 +1,80 @@
[
{
"id": "Sk20261024094211181",
"nazwa_zdarzenia": "Silny wiatr",
"stopien": "2",
"prawdopodobienstwo": "75",
"obowiazuje_do": "2026-10-25 18:00:00",
"obowiazuje_od": "2026-10-25 08:00:00",
"opublikowano": "2026-10-24 11:42:00",
"tresc": "Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 45 km/h, w porywach do 90 km/h, z południa.",
"komentarz": "Brak.",
"biuro": "Centralne Biuro Prognoz Meteorologicznych w Warszawie",
"teryt": [
"0221",
"0207",
"0206",
"0212",
"0261",
"0265",
"1862"
]
},
{
"id": "Sk20261024094212544",
"nazwa_zdarzenia": "Silny wiatr",
"stopien": "1",
"prawdopodobienstwo": "75",
"obowiazuje_do": "2026-10-25 18:00:00",
"obowiazuje_od": "2026-10-25 08:00:00",
"opublikowano": "2026-10-24 11:42:00",
"tresc": "Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 40 km/h, w porywach do 85 km/h, z południa.",
"komentarz": "Brak.",
"biuro": "Centralne Biuro Prognoz Meteorologicznych w Warszawie",
"teryt": [
"1807"
]
},
{
"id": "Sk20261024094213861",
"nazwa_zdarzenia": "Silny wiatr",
"stopien": "1",
"prawdopodobienstwo": "75",
"obowiazuje_do": "2026-10-25 19:00:00",
"obowiazuje_od": "2026-10-25 12:00:00",
"opublikowano": "2026-10-24 11:42:00",
"tresc": "Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 40 km/h, w porywach do 75 km/h, z południa.",
"komentarz": "Brak.",
"biuro": "Centralne Biuro Prognoz Meteorologicznych w Warszawie",
"teryt": [
"2804",
"2861",
"2802"
]
},
{
"id": "Sk20261024094216930",
"nazwa_zdarzenia": "Silny wiatr",
"stopien": "1",
"prawdopodobienstwo": "80",
"obowiazuje_do": "2026-10-25 21:00:00",
"obowiazuje_od": "2026-10-25 10:00:00",
"opublikowano": "2026-10-24 11:42:00",
"tresc": "Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 40 km/h, w porywach do 80 km/h, z południa.",
"komentarz": "Brak.",
"biuro": "Centralne Biuro Prognoz Meteorologicznych w Warszawie",
"teryt": [
"1205",
"2402",
"1210",
"1217",
"1207",
"2461",
"2403",
"1211",
"1215",
"1262",
"2417"
]
}
]
@@ -0,0 +1 @@
{"latitude":49.68106,"longitude":21.75624,"generationtime_ms":0.3905296325683594,"utc_offset_seconds":7200,"timezone":"Europe/Warsaw","timezone_abbreviation":"GMT+2","elevation":282.0,"current_units":{"time":"iso8601","interval":"seconds","temperature_2m":"°C"},"current":{"time":"2026-10-24T16:45","interval":900,"temperature_2m":10.6},"daily_units":{"time":"iso8601","weather_code":"wmo code","temperature_2m_max":"°C","temperature_2m_min":"°C","precipitation_sum":"mm","shortwave_radiation_sum":"MJ/m²","sunshine_duration":"s"},"daily":{"time":["2026-10-10","2026-10-11","2026-10-12","2026-10-13","2026-10-14","2026-10-15","2026-10-16","2026-10-17","2026-10-18","2026-10-19","2026-10-20","2026-10-21","2026-10-22","2026-10-23","2026-10-24","2026-10-25","2026-10-26"],"weather_code":[3,63,3,3,51,51,3,51,3,3,3,3,63,3,3,51,61],"temperature_2m_max":[17.8,14.4,12.6,10.3,13.3,11.9,12.8,11.0,13.0,10.6,15.5,19.1,11.1,13.5,10.9,13.2,9.6],"temperature_2m_min":[8.7,8.8,5.1,3.3,0.8,5.5,4.6,3.0,6.8,6.6,7.8,7.3,8.4,4.6,3.8,4.0,4.1],"precipitation_sum":[0.00,15.00,0.00,0.00,0.50,0.30,0.00,0.10,0.00,0.00,0.00,0.00,27.70,0.00,0.00,0.30,8.40],"shortwave_radiation_sum":[9.44,3.23,10.22,6.92,8.53,5.71,9.71,4.36,9.99,2.90,8.72,10.19,0.20,10.33,6.88,8.92,2.10],"sunshine_duration":[14400.00,0.00,12427.89,0.00,21009.37,355.43,7848.32,0.00,11931.89,0.00,11020.14,33520.17,0.00,34772.52,7151.32,28314.23,0.00]}}
+34
View File
@@ -0,0 +1,34 @@
# ADR-011: Setpoint changes are leases that the backend renews
Status: accepted · 2026-10-24
## Context
ADR-006 restores a timed boost by itself, but a change "until changed" (away: CWU 10, CO 15)
stays forever. If the backend, the tunnel or the Kraków server is lost for good, the house stays
on those settings until someone notices. The requirement: a missing command at the gateway
reverses the change.
## Decision
- Every applied change, timed or not, keeps a restore with a **lease** of 15 minutes
(`leaseUntil`). The restore runs at the earlier of its end time (`due`, null for "until
changed") and the end of the lease, through the existing path: conflict check, read-back,
backoff.
- `POST /commands/renew` (token, same throttle as `/commands`) extends the lease to 15 minutes
from now for every restore that has not reached its run time. A lapsed, due or failing restore
is never revived.
- The backend calls it every 5 minutes from its start while `GATEWAY_TOKEN` is set. A failed call
is logged and retried; two in a row may fail without effect.
- A restore file entry without `leaseUntil` (older file) counts as lapsed and is restored on
start. `leaseUntil` is saved atomically like the rest.
- Limits and presets stay as agreed: CWU at most 48 °C (code ceiling 50), CO 5–25, restores at
most 72 h.
## Consequences
- "Until changed" means "while the backend runs and reaches the gateway". The UI says so.
- After a backend or tunnel outage longer than 15 minutes the house returns to the original
values; the user sets them again. That is the intended safe direction.
- One extra small file write on the Pi every 5 minutes while a change is active.
- A backend that is up but wrong about the world keeps changes alive; the gateway cannot tell.
@@ -0,0 +1,38 @@
# ADR-012: Away is held until a chosen date, without renewals
Status: accepted · 2026-10-24
Amends [ADR-011](011-setpoint-leases.md) for held lowering changes; everything else there stands.
## Context
With ADR-011 the preset "Wyjazd" (CWU 10, CO 15) lasts only while the backend and the tunnel
renew its lease. A server or tunnel outage during a trip would end the away mode and heat an
empty house. The away values only lower temperatures, so keeping them is the safe direction.
## Decision
- The user picks a return date and time on a calendar. "Wyjazd" is held until then and the
gateway restores the previous values by itself, with no renewals.
- `POST /commands` takes an optional `until` (ISO-8601 with offset), exclusive with `minutes`.
The change is stored as a restore with `leaseUntil = due = until`: no new file field, `runAt`
is the end, and a restart keeps it. Backend: preset field `"until": "chosen"` and
`POST /api/presets/{id}` with `{"until": …}`; 400 when it is missing, invalid or not wanted.
- The gateway enforces the safety, not the backend. A held change is rejected, with no write,
unless the end is in the future and at most 30 days ahead (`Command.MAX_HOLD`), and the new
value is lower than the register's current value and lower than the original that will come
back. Raising stays on renewed leases.
- `renewLeases` only extends: `leaseUntil = max(leaseUntil, now + 15 min)`. A held change keeps
its end, and a restore that is due, lapsed or failing is still never revived.
- A pending boost: a held change may replace it, keeping the first original. Because it must be
below that original, nothing above the original outlives the boost. A later boost on a held
change is an ordinary lease again, which can only shorten the hold.
- The restore at the end uses the usual path: conflict check, read-back, backoff.
## Consequences
- During a trip the house stays on away values even if the backend, tunnel or server are lost.
If the gateway or the Pi is lost, the heat pump keeps the away values until someone
intervenes; the 30 days and the lowering-only rule bound the cost.
- A held away cannot be cancelled by lapsing; the user sets the values back, which replaces it.
- Wyjazd is refused when a value is already at or below the away value (nothing to lower).
@@ -0,0 +1,36 @@
# ADR-013: Battery settings are changed from the app
Status: accepted · 2026-10-24
## Context
The Deye battery is the house's UPS: Time of Use is off and the battery stays full. My father wants to
change its program from our app instead of the Solarman cloud. A wrong table can empty the battery
before a power cut, so the write path is narrow and refuses when unsure.
## Decision
- **Allowed registers only** (FC16, through the one logger link): 146 bit 0 (Time of Use switch; the days
in bits 1–7 stay as read), 148–153 slot starts, 166–171 slot SOC, 172–177 grid charge (0 or 1, never the
generator values 2 and 3). Nothing else is written.
- **Rules**: SOC from the reserve to 100; starts HHMM, strictly increasing from slot 1 to 6; an enabled
schedule needs every slot SOC at or above the reserve. The whole table is checked before any write.
- **Reserve**: an app setting in `reserve.json` on the Pi, default 50 %, never below the configured floor
(30) nor below the inverter's shutdown SOC + 10. It is only raised above a slot SOC while the schedule is
off; the user lifts the slots first. Lowering is always fine.
- **Refuse on unknown state**: no reading of the settings in the last 30 minutes, or the registers read just
before writing differ from that reading (the Solarman app changed them): 409, nothing written.
- **Verify, never retry**: neighbouring registers go in one FC16, the switch last; all are read back; any
mismatch is FAILED with what was read. The user decides what to do next.
- **No restore, no lease**: these are persistent settings as in the Solarman app; the reserve is what
protects the UPS role (ADR-011 stays for the heat pump).
- **Audit and limits**: S3 into `electricity.deye_settings_audit`, S4 per register, token and throttle as
`/commands`. Off unless `deyeCommands.enabled`, which needs the Deye.
## Consequences
- The gateway writes to the inverter for the first time; registers beyond the list stay read-only.
- A change made in the Solarman app while the app shows the old table is refused, not overwritten.
- The reserve is enforced only through the slot SOC rule; the inverter does not know it.
- The shutdown SOC comes from the last settings reading; a change of it applies from the next poll.
- The Solarman cloud stays outside our system; the logger link has one writer at a time.
Loaded 100 of 227 files, more files were not shown because too many files have changed in this diff. Show more