Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:14:06 +02:00
310 changed files with 22274 additions and 3421 deletions

No files matched your search

+7 -6
View File
@@ -1,6 +1,6 @@
---
name: ship
description: Take the current topic branch to master through a Gitea PR with green CI, then optionally deploy the gateway to the Pi. Use when a change is ready to merge or release.
description: Take the current topic branch to master through a Gitea PR with green CI, then optionally release it and update the Pi. Use when a change is ready to merge or release.
---
# Ship
@@ -23,8 +23,9 @@ Repo rules: `CONTRIBUTING.md`. Gitea: https://git.mariuszpacek.pl/mariusz.pacek/
`git switch master && git pull --ff-only && git merge --no-ff <branch> -m "Merge PR #N: <title>"`,
then `git push origin master` and delete the branch locally and on origin. Confirm CI is
green on master. Gitea does not close an AGit PR merged by hand: ask the user to close it.
6. Deploy only with the user's explicit approval for this deploy, and only for changes that
touch the gateway: `scripts/deploy-gateway.sh vMAJOR.MINOR.PATCH` (patch for fixes, minor
for features; `PI=<ssh host>` while D7 is open). Then watch
`ssh <host> journalctl -u home-pump.service -f`
and Grafana for one polling cycle (300 s) and report what you saw.
6. Release and update only with the user's explicit approval for this one:
`scripts/release.sh vMAJOR.MINOR.PATCH` (patch for fixes, minor for features) tags master;
wait until the Release run under `…/actions` is green (it publishes the images). Then on the
Pi (`ssh <host>`, an alias while D7 is open): `cd /home/pi/home-pump && ./update.sh <version>`,
watch `journalctl -t home-pump-gateway -f` and Grafana for one polling cycle (300 s) and
report what you saw. Backend and frontend reach Kraków through Arcane (homelab-compose).
+7 -9
View File
@@ -6,20 +6,14 @@ on:
- "**"
jobs:
backend:
name: Backend tests (Maven / Java 21)
check:
name: Check (gateway, backend, frontend)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Show runner info
run: |
echo "Runner name: ${{ runner.name }}"
echo "Runner labels: ${{ toJSON(runner.labels) }}"
- name: Set up Java 21 + Maven
- name: Set up Java 21
uses: actions/setup-java@v5
with:
distribution: temurin
@@ -28,5 +22,9 @@ jobs:
uses: stCarolas/setup-maven@v5
with:
maven-version: 3.9.6
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Check
run: scripts/check.sh
+59
View File
@@ -0,0 +1,59 @@
# A pushed tag vMAJOR.MINOR.PATCH (scripts/release.sh) publishes the three images under that name
# and as latest. The Pi pulls the gateway with gateway/update.sh; Kraków runs backend and frontend.
name: Release
on:
push:
tags:
- "v*.*.*"
env:
REGISTRY: git.mariuszpacek.pl
IMAGES: git.mariuszpacek.pl/mariusz.pacek/home-pump
jobs:
images:
name: Images
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# As in homelab-upgrade: the job talks to VM103's daemon through the mounted socket.
- name: Docker CLI
run: command -v docker >/dev/null || (apt-get update -qq && apt-get install -y -qq docker.io >/dev/null); docker version --format '{{.Client.Version}} -> {{.Server.Version}}'
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.repository_owner }}
# The owner's token with package write access (the secret homelab-upgrade uses too).
password: ${{ secrets.REGISTRY_TOKEN }}
# The Pi is arm64. Each Dockerfile builds on the runner's platform and only copies into the
# final stage, so the arm64 image needs no emulation.
- name: Gateway
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
- name: Backend
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
- name: Frontend
uses: docker/build-push-action@v6
with:
context: frontend
build-args: VERSION=${{ gitea.ref_name }}
platforms: linux/amd64
push: true
tags: ${{ env.IMAGES }}-frontend:${{ gitea.ref_name }},${{ env.IMAGES }}-frontend:latest
+4 -2
View File
@@ -18,7 +18,7 @@ target/
/.idea/
/.vscode/
/.java-version
/dependency-reduced-pom.xml
dependency-reduced-pom.xml
/workspace.code-workspace
/target/
@@ -26,7 +26,6 @@ target/
/node_modules/
/frontend/node_modules/
/frontend/dist/
/frontend/.env*
/dist/
# Vite
@@ -56,3 +55,6 @@ coverage/
/apk/
/old/
/old_backup/
# Agent worktrees
/.claude/worktrees/
+3 -2
View File
@@ -6,8 +6,9 @@ Backend for controlling the heat pump in Krosno (Frisko controller, Modbus TCP)
- Start from `docs/roadmap.md`; stay within the current milestone unless asked.
- Read `docs/spec.md` and a decision in `docs/decisions/` only when the task touches it.
- Production: `pi@raspberrypi.local`, service `home-pump.service`. Read-only inspection is fine;
deploy, restart or file changes on the Pi need explicit approval each time.
- Production: `pi@raspberrypi.local`, the gateway in Docker (`gateway/compose.yaml`; until it is
installed, the old `home-pump.service`). Read-only inspection is fine; installing, updating,
restarting or changing files on the Pi needs explicit approval each time.
- The Pi also runs `rpi-measurements.service` (house meter and BME280 into Influx); do not break
it. It moves into the gateway in M1; never run both against the meter at once. Do not upgrade
or reinstall the Pi's OS.
+7 -5
View File
@@ -19,7 +19,8 @@ Reasons: [docs/decisions/](docs/decisions/).
## Code
- Java 21, Maven, no framework (ADR-001). `mvn clean package`, `mvn test`.
- Three parts, each with its `Dockerfile` and `compose.yaml` (ADR-010): `gateway/` and `backend/`
in Java 21 with Maven and no framework (ADR-001), `frontend/` in React and TypeScript (ADR-008).
- `scripts/check.sh` is the gate: doc limits, secret scan, build and tests. CI runs it, and so
does the pre-push hook once enabled with `git config core.hooksPath .githooks`.
- Readable first: small classes and methods, names from the domain, comments only for the why.
@@ -54,7 +55,8 @@ Docs are written so an agent needs little context: read only what the task touch
## Configuration, secrets, releases
- No secrets in git. Production config lives on the Pi, outside the jar.
- Deploy only from `master` with `scripts/deploy-gateway.sh vMAJOR.MINOR.PATCH`; it tags the
deploy and rolls back if the service does not come up healthy.
- After a deploy, watch the journal and Grafana for at least one polling cycle.
- No secrets in git. Production config lives on the Pi and next to the Kraków stacks, outside
the images.
- Release only from `master` with `scripts/release.sh vMAJOR.MINOR.PATCH`: it tags master and
CI publishes the three images. On the Pi `./update.sh <version>` goes back if it is unhealthy.
- After an update, watch the journal and Grafana for at least one polling cycle.
+17 -160
View File
@@ -1,168 +1,25 @@
# Home Pump Monitor
# home-pump
A Java application that reads data from a heat pump via Modbus TCP and sends metrics to InfluxDB.
Heat pump (Frisko RX910-SE21) and home energy for the house in Krosno, from a phone.
## Features
- What we build: [docs/spec.md](docs/spec.md)
- Plan and current milestone: [docs/roadmap.md](docs/roadmap.md)
- Rules for changes: [CONTRIBUTING.md](CONTRIBUTING.md)
- Controller knowledge: [docs/frisko/](docs/frisko/rx910-se21.md)
- Reads Modbus holding registers from a heat pump
- Configurable send interval to InfluxDB (default: 5 minutes)
- Automatic buffering when InfluxDB is unavailable (up to 50,000 data points)
- Health check endpoint for monitoring application status
- Automatic restart on Modbus connection failures
- Graceful shutdown with InfluxDB buffer flush
- Systemd service with automatic recovery
- Configurable via JSON file
| Part | Runs | Readme |
|---|---|---|
| `gateway/` | Pi in Krosno, Docker: devices to InfluxDB, `/commands` | [gateway/README.md](gateway/README.md) |
| `backend/` | Kraków, Docker: phone API | [backend/README.md](backend/README.md) |
| `frontend/` | Kraków, Docker: web UI with nginx | [frontend/README.md](frontend/README.md) |
## Quick Start
### Local Development
```bash
mvn clean package
java -jar target/home-pump-1.0.0.jar
```
### Production Deployment (Raspberry Pi / Ubuntu)
See [DEPLOYMENT.md](DEPLOYMENT.md) for complete installation and configuration guide.
Each part has its `Dockerfile` and `compose.yaml` (ADR-010).
```bash
# Build
mvn clean package
# Install as systemd service
sudo scripts/install-service.sh
# Check status
systemctl status home-pump.service
curl http://localhost:8080/health
scripts/check.sh # docs, secret scan, build and tests of all three parts
git config core.hooksPath .githooks # once per clone: check before every push
scripts/release.sh vMAJOR.MINOR.PATCH # tag master; CI publishes the three images in Gitea
```
## Configuration
Edit `src/main/resources/registryMapping.json` to configure:
- Modbus TCP connection (address, port)
- InfluxDB connection (address, username, password)
- Send interval (influxSendIntervalSeconds, default: 300)
- Connection timeouts and retry settings
- List of registers to read with descriptions
Additional mapping fields (optional):
- `useMock` (Boolean, top-level): When `true`, the application runs with a local `MockRepository` instead of attempting to connect to a Modbus device. Useful for local development when a device is not available (default: false).
- `unitId` (Integer): Modbus slave/unit id for the register (default: 1)
- `scale` (Double): Multiply raw device value by this to obtain display value (default: 0.1)
- `dataType` (String): Data type used for the locator (e.g. `TWO_BYTE_INT_SIGNED`). Supported values: `TWO_BYTE_INT_SIGNED`, `TWO_BYTE_INT_UNSIGNED`, `FOUR_BYTE_INT_SIGNED`, `FOUR_BYTE_INT_UNSIGNED`, `FOUR_BYTE_FLOAT` (the latter reads two adjacent registers and interprets as IEEE754 float). For devices with non-standard word order, the following swapped variants are supported: `FOUR_BYTE_INT_SWAPPED`, `FOUR_BYTE_INT_UNSIGNED_SWAPPED`, `FOUR_BYTE_FLOAT_SWAPPED` (word order low/high).
- # Example Configuration
```json
{
"address": "192.168.0.150",
"port": 502,
"useMock": false,
"influxSendIntervalSeconds": 300,
"timeoutMs": 5000,
"maxRetries": 2,
"retryBackoffMs": 1000,
"influx_db": {
"address": "https://influxdb.example.com",
"username": "",
"password": ""
},
"registry_mapping": [
{
"registry": 4065,
"desc": "External temperature",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED"
}
]
}
```
## Monitoring & Health Check
The application exposes a health check endpoint on port 8080:
```bash
curl http://localhost:8080/health
```
**Healthy response (HTTP 200):**
```json
{
"status": "UP",
"lastSuccessfulRead": "2026-02-06T19:30:45Z",
"message": "Last successful read 15 seconds ago"
}
```
**Unhealthy response (HTTP 503):**
```json
{
"status": "DOWN",
"lastSuccessfulRead": "2026-02-06T19:20:45Z",
"message": "No successful read for 620 seconds. Last error: Connection timeout"
}
```
### Important Notes
- **InfluxDB failures do NOT trigger unhealthy status** - data is buffered in memory
- Only Modbus read failures trigger unhealthy status and potential restart
- Health check considers the application unhealthy if no successful Modbus read within 2× send interval
- Automatic monitoring script restarts the service on health check failures
## Production Deployment
For production use on Raspberry Pi or Ubuntu server:
1. **Install as systemd service** - Auto-start on boot with resource limits
2. **Enable health monitoring** - Cron job checks health every 2 minutes
3. **Automatic recovery** - Restarts service on Modbus failures (max 3 attempts per 10 minutes)
4. **Graceful shutdown** - Flushes buffered data to InfluxDB before exit
See [DEPLOYMENT.md](DEPLOYMENT.md) for detailed instructions.
## Data Buffering
When InfluxDB is unavailable, data is automatically buffered:
- **Buffer size**: 50,000 data points in memory
- **Batch size**: 50 points per flush
- **Flush interval**: 180 seconds (3 minutes)
- **No data loss**: Buffer persists during InfluxDB outages
- **Automatic retry**: Built-in retry mechanism in InfluxDB Java client
## Troubleshooting
### Check application logs
```bash
journalctl -u home-pump.service -f
```
### Test health endpoint
```bash
./scripts/test-health.sh
```
### View monitor logs
```bash
tail -f /var/log/home-pump-monitor.log
```
### Common Issues
1. **Service won't start**: Check JAR file exists and Java 21 is installed
2. **Health check fails**: Verify Modbus device is accessible
3. **InfluxDB connection errors**: Check network and credentials (data will be buffered)
4. **Too many restarts**: Check Modbus device connectivity and network stability clean package
java -jar target/home-pump-1.0.0.jar
```
## Requirements
- Java 21
- Modbus TCP accessible heat pump
- InfluxDB instance
After a release: on the Pi `./update.sh <version>` (gateway/README.md); in Kraków Arcane runs the
backend and frontend images.
+1
View File
@@ -0,0 +1 @@
target
+22
View File
@@ -0,0 +1,22 @@
# Built on the build machine's own platform; the image only holds the jar and a JRE.
FROM --platform=$BUILDPLATFORM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
RUN mvn -B -q dependency:go-offline
COPY src src
# Tests run in scripts/check.sh; some read contract files from gateway/, outside this context.
RUN mvn -B -q package -Dmaven.test.skip=true
FROM eclipse-temurin:21-jre-alpine
# The release tag, shown in /api/health; the release workflow passes it.
ARG VERSION=dev
ENV VERSION=$VERSION
USER 10001:10001
WORKDIR /app
COPY --from=build /src/target/home-pump-backend-1.0.0.jar app.jar
EXPOSE 8080
# /api/health answers 200 even while the gateway is unreachable (it reports that in the body),
# so this only tells whether the backend itself serves requests. busybox wget ships with alpine.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/api/health" || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]
+51
View File
@@ -0,0 +1,51 @@
# Backend
The phone API in Kraków (ADR-008): live state from the gateway, history from InfluxDB, setpoint
changes passed to the gateway. JSON under `/api/` only; the web UI is the frontend's (ADR-010).
The records in `home.backend.domain` and the examples in `src/test/resources/api/` are the contract.
`GET /api/energy/details` lists the Deye, Fronius and house meter values in labelled groups (a stale device has none); the label, unit and place of every field are the catalog in `EnergyDetailsService`: a new field is one line there.
`GET /api/pi-sensor` is the BME280 sensor on the Raspberry Pi (the living room), example `src/test/resources/api/pi-sensor.json`:
`time`, `stale`, `temperature` (°C), `humidity` (%), `pressure` (hPa). When it is stale (device down, no reading or older than 10 min)
the three values are null (`pi-sensor-stale.json`).
Settings are environment variables (`.env` next to `compose.yaml`); the backend refuses to start
without the two URLs.
Setpoint changes go only to the gateway; the user name from Traefik's `Remote-User` header (else `phone`) is sent as `X-Requested-By`.
| Variable | Default | Meaning |
|---|---|---|
| `GATEWAY_URL` | required | the gateway on the Pi, e.g. `http://10.0.0.2:8080` |
| `INFLUX_URL` | required | InfluxDB 1.x, e.g. `https://influxdb.example` |
| `PORT` | `8080` | listening port |
| `GATEWAY_TIMEOUT` | `5` | seconds |
| `INFLUX_TIMEOUT` | `10` | seconds |
| `GATEWAY_TOKEN` | unset | shared token for the gateway's `/commands`; without it the backend runs but `/api/setpoints` and `/api/presets/{id}` answer 503 |
| `PRESETS_FILE` | built-in | JSON file replacing the presets: `[{"id", "name", "changes": [{"setpoint", "value", "hours"}]}]`, `hours` null means until changed; a bad file stops the start. A preset may also have `"until": "chosen"` (then no `hours`): the user picks the end, see below |
```bash
mvn -f backend/pom.xml clean package
GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example \
java -jar backend/target/home-pump-backend-1.0.0.jar # API at http://localhost:8080/api/
docker compose -f backend/compose.yaml up --build # with backend/.env; network home-pump
```
`POST /api/presets/{id}` takes no body for an ordinary preset. A preset with `"until": "chosen"` (the built-in "Wyjazd")
needs `{"until": "2026-11-02T18:00:00+01:00"}` (ISO-8601 with offset, example `src/test/resources/api/preset-away-request.json`):
400 when it is missing, invalid, or sent to a preset that has none. Any preset also takes optional `"values"`
(example `preset-away-values-request.json`): a whole number per setpoint of that preset, replacing the preset's value;
the others keep it. 400 for a setpoint the preset does not change or a value outside the gateway's min, max and step
(from `/commands`), checked before anything is sent; a held value that is not below the current one is 422. The gateway holds those changes until then without
renewals (ADR-012) and rejects an end that is past or over 30 days away, or a change that does not lower the value (422).
`POST /api/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).
+16
View File
@@ -0,0 +1,16 @@
# The backend in Kraków: the API only (ADR-010), reached by the frontend on the network home-pump
# and published on no host port (ADR-009). Settings in .env next to this file (README).
name: home-pump-backend
services:
backend:
image: git.mariuszpacek.pl/mariusz.pacek/home-pump-backend:${VERSION:-latest}
build: .
restart: unless-stopped
env_file: .env
environment:
TZ: Europe/Warsaw
networks: [home-pump]
networks:
home-pump:
name: home-pump
+84
View File
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.home</groupId>
<artifactId>home-pump-backend</artifactId>
<version>1.0.0</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>21</maven.compiler.release>
<jackson.version>2.20.1</jackson.version>
</properties>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>1.7.32</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.8.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
<configuration>
<redirectTestOutputToFile>true</redirectTestOutputToFile>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.4.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>home.backend.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,53 @@
package home.backend;
import home.backend.adapters.config.PresetFile;
import home.backend.adapters.gateway.GatewayHttpClient;
import home.backend.adapters.gateway.GatewayHttpCommands;
import home.backend.adapters.http.ApiServer;
import home.backend.adapters.influx.InfluxHistoryRepository;
import home.backend.adapters.influx.InfluxSettings;
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.LeaseRenewer;
import home.backend.application.PiSensorService;
import home.backend.application.SetpointService;
import java.io.IOException;
import java.time.Clock;
import java.util.Objects;
/** Wires the adapters and services together; the only place that knows all of them. */
final class Backend {
private Backend() {
}
/**
* Binds the port but does not start serving.
*
* @throws IllegalArgumentException when PRESETS_FILE is unusable
*/
static ApiServer assemble(Settings settings, Clock clock) throws IOException {
var gateway = new GatewayHttpClient(settings.gatewayUrl(), settings.gatewayTimeout());
var influx = new InfluxHistoryRepository(new InfluxSettings(settings.influxUrl(), settings.influxTimeout()));
var setpoints = new SetpointService(commands(settings), PresetFile.load(settings.presetsFile()));
return new ApiServer(settings.port(),
new HeatPumpService(gateway, clock),
new EnergyService(gateway, clock),
new EnergyDetailsService(gateway, clock),
new HealthService(gateway, clock, Objects.requireNonNullElse(System.getenv("VERSION"), "dev")),
new HistoryService(influx, clock), setpoints, new PiSensorService(gateway, clock));
}
/** Keeps the gateway from restoring our setpoint changes while we are up (ADR-011). */
static LeaseRenewer leaseRenewer(Settings settings) {
return new LeaseRenewer(commands(settings));
}
private static GatewayHttpCommands commands(Settings settings) {
return new GatewayHttpCommands(settings.gatewayUrl(), settings.gatewayToken(), settings.gatewayTimeout());
}
}
@@ -0,0 +1,35 @@
package home.backend;
import home.backend.adapters.http.ApiServer;
import java.io.IOException;
import java.time.Clock;
public final class Main {
private Main() {
}
public static void main(String[] args) throws IOException {
Settings settings;
ApiServer server;
try {
settings = Settings.fromEnvironment(System.getenv());
server = Backend.assemble(settings, Clock.systemUTC());
} catch (IllegalArgumentException e) {
System.err.println("home-pump-backend: " + e.getMessage());
System.exit(2);
return;
}
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
if (settings.gatewayToken() == null) {
System.err.println("home-pump-backend: GATEWAY_TOKEN is not set, setpoint changes are disabled");
} else {
var renewer = Backend.leaseRenewer(settings);
Runtime.getRuntime().addShutdownHook(new Thread(renewer::close));
renewer.start();
}
server.start();
System.out.println("home-pump-backend listening on port " + server.port());
}
}
@@ -0,0 +1,65 @@
package home.backend;
import java.net.URI;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
/** Twelve-factor settings: everything comes from environment variables. */
public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gatewayTimeout, Duration influxTimeout,
String gatewayToken, Path presetsFile) {
public static Settings fromEnvironment(Map<String, String> env) {
return new Settings(
url(env, "GATEWAY_URL"),
url(env, "INFLUX_URL"),
(int) number(env, "PORT", 8080, 0, 65535),
Duration.ofSeconds(number(env, "GATEWAY_TIMEOUT", 5, 1, 300)),
Duration.ofSeconds(number(env, "INFLUX_TIMEOUT", 10, 1, 300)),
optional(env, "GATEWAY_TOKEN"),
Optional.ofNullable(optional(env, "PRESETS_FILE")).map(Path::of).orElse(null));
}
/** Null when unset or blank. */
private static String optional(Map<String, String> env, String name) {
String value = env.get(name);
return value == null || value.isBlank() ? null : value.strip();
}
private static URI url(Map<String, String> env, String name) {
String value = env.get(name);
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(name + " is required, e.g. " + name + "=http://host:8080");
}
URI uri = URI.create(value.strip());
if (uri.getHost() == null || !("http".equals(uri.getScheme()) || "https".equals(uri.getScheme()))) {
throw new IllegalArgumentException(name + " must be an http(s) URL with a host, got: " + value);
}
// The clients append their own paths to this base; a path here would be silently dropped or doubled.
boolean hasPath = !uri.getRawPath().isEmpty() && !uri.getRawPath().equals("/");
if (hasPath || uri.getRawQuery() != null || uri.getRawFragment() != null) {
throw new IllegalArgumentException(name + " must be only scheme, host and port, without a path, got: "
+ value);
}
return uri;
}
/** Timeouts are whole seconds. */
private static long number(Map<String, String> env, String name, long fallback, long min, long max) {
String value = env.get(name);
if (value == null || value.isBlank()) {
return fallback;
}
try {
long number = Long.parseLong(value.strip());
if (number >= min && number <= max) {
return number;
}
} catch (NumberFormatException e) {
// reported below with the same message
}
throw new IllegalArgumentException(name + " must be a whole number from " + min + " to " + max
+ ", got: " + value);
}
}
@@ -0,0 +1,41 @@
package home.backend.adapters.config;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.backend.domain.Preset;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
/** Reads the presets (F3) from the optional PRESETS_FILE, else the built-in list. */
public final class PresetFile {
private static final ObjectMapper JSON = new ObjectMapper();
private static final TypeReference<List<Preset>> PRESETS = new TypeReference<>() {
};
private PresetFile() {
}
/** @param file null for the built-in defaults; IllegalArgumentException when it is unusable */
public static List<Preset> load(Path file) {
String source = file == null ? "built-in presets" : "PRESETS_FILE " + file;
try (InputStream in = file == null ? defaults() : Files.newInputStream(file)) {
List<Preset> presets = JSON.readValue(in, PRESETS);
if (new HashSet<>(presets.stream().map(Preset::id).toList()).size() != presets.size()) {
throw new IllegalArgumentException("preset ids must be unique");
}
return presets;
} catch (IOException | IllegalArgumentException e) {
throw new IllegalArgumentException(source + " is not usable: " + e.getMessage(), e);
}
}
private static InputStream defaults() {
return PresetFile.class.getResourceAsStream("/presets-default.json");
}
}
@@ -0,0 +1,88 @@
package home.backend.adapters.gateway;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import home.backend.application.GatewayClient;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/** Reads the gateway's {@code /health} and {@code /snapshot} over HTTP. */
public class GatewayHttpClient implements GatewayClient {
private record Health(String version, List<DeviceHealth> devices) {
}
private record DeviceHealth(String name, String status, Instant lastSuccess, String lastError) {
}
private record DeviceReadings(String name, List<Reading> readings) {
}
private record Reading(String database, String measurement, Map<String, Double> fields, Instant time) {
}
// Unknown properties are ignored so the gateway can add fields without breaking us.
private static final ObjectMapper JSON = new ObjectMapper()
.registerModule(new JavaTimeModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private final URI baseUri;
private final Duration timeout;
private final HttpClient http;
public GatewayHttpClient(URI baseUri, Duration timeout) {
this.baseUri = baseUri;
this.timeout = timeout;
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
}
@Override
public GatewaySnapshot snapshot() throws IOException {
var health = JSON.readValue(get("/health", true), Health.class);
var readings = JSON.readValue(get("/snapshot", false), DeviceReadings[].class);
Map<String, List<Measurement>> measurements = Arrays.stream(readings)
.collect(Collectors.toMap(DeviceReadings::name, GatewayHttpClient::measurements));
var devices = health.devices().stream()
.map(device -> new DeviceSnapshot(device.name(), device.status().equals("UP"),
device.lastSuccess(), device.lastError(),
measurements.getOrDefault(device.name(), List.of())))
.toList();
return new GatewaySnapshot(health.version(), devices);
}
private static List<Measurement> measurements(DeviceReadings device) {
return device.readings().stream()
.map(r -> new Measurement(r.database(), r.measurement(), r.fields(), r.time()))
.toList();
}
/** A gateway with a device down answers /health with 503 and a valid body. */
private byte[] get(String path, boolean downIsValid) throws IOException {
var request = HttpRequest.newBuilder(baseUri.resolve(path)).timeout(timeout).GET().build();
try {
var response = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
int status = response.statusCode();
if (status != 200 && !(downIsValid && status == 503)) {
throw new IOException("gateway " + path + " answered " + status);
}
return response.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("interrupted while calling gateway " + path, e);
}
}
}
@@ -0,0 +1,116 @@
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.GatewayCommands;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.RestoreResult;
import home.backend.domain.SetpointState;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.Set;
/** Sends commands to the gateway's {@code /commands} with the shared token (S6). */
public class GatewayHttpCommands implements GatewayCommands {
/** 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);
@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 String token;
private final Duration timeout;
private final HttpClient http;
/** @param token null when not configured: every call then fails without touching the network */
public GatewayHttpCommands(URI baseUri, String token, Duration timeout) {
this.endpoint = baseUri.resolve("/commands");
this.renewEndpoint = baseUri.resolve("/commands/renew");
this.restoreEndpoint = baseUri.resolve("/commands/restore");
this.token = token;
this.timeout = timeout;
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
}
@Override
public CommandResult apply(Command command) throws IOException {
Long minutes = command.duration() == null ? null : command.duration().toMinutes();
byte[] body = JSON.writeValueAsBytes(new Request(command.setpoint(), command.value(), minutes, command.heldUntil()));
HttpRequest.Builder request = request().header("Content-Type", "application/json")
.header("X-Requested-By", command.requestedBy())
.POST(HttpRequest.BodyPublishers.ofByteArray(body));
return JSON.readValue(send(request, VERDICTS), CommandResult.class);
}
@Override
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
byte[] body = JSON.writeValueAsBytes(new RestoreRequest(setpoint));
var request = request(restoreEndpoint).header("Content-Type", "application/json")
.header("X-Requested-By", requestedBy).POST(HttpRequest.BodyPublishers.ofByteArray(body));
return JSON.readValue(send(request, RESTORE_VERDICTS), RestoreResult.class);
}
@Override
public SetpointState setpoints() throws IOException {
return JSON.readValue(send(request().GET(), Set.of(200)), SetpointState.class);
}
@Override
public int renewLeases() throws IOException {
var request = request(renewEndpoint).POST(HttpRequest.BodyPublishers.noBody());
return JSON.readValue(send(request, Set.of(200)), Renewal.class).renewed();
}
private HttpRequest.Builder request() throws IOException {
return request(endpoint);
}
private HttpRequest.Builder request(URI uri) throws IOException {
if (token == null) {
throw new IOException("GATEWAY_TOKEN is not set, commands are disabled");
}
return HttpRequest.newBuilder(uri).timeout(timeout).header("Authorization", "Bearer " + token);
}
private byte[] send(HttpRequest.Builder request, Set<Integer> accepted) throws IOException {
try {
var response = http.send(request.build(), HttpResponse.BodyHandlers.ofByteArray());
if (!accepted.contains(response.statusCode())) {
throw new IOException("gateway /commands answered " + response.statusCode());
}
return response.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("interrupted while calling gateway /commands", e);
}
}
}
@@ -0,0 +1,340 @@
package home.backend.adapters.http;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import home.backend.application.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.PiSensorService;
import home.backend.application.SetpointService;
import home.backend.domain.CommandResult.Status;
import home.backend.domain.PresetResult;
import home.backend.domain.RestoreResult;
import java.io.IOException;
import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/** The phone-facing JSON API under {@code /api/} and the static web UI. */
public class ApiServer {
private static final ObjectMapper JSON = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
// 45.5 or "45" must not silently become 45 in a request that writes to a heat pump.
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS);
private record 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 WRITE_MARKER = "home-pump";
private static final String PRESETS_PATH = "/api/presets/";
private static final String SETPOINTS_PATH = "/api/setpoints/";
private static final Pattern RESTORE_PATH = Pattern.compile("/api/setpoints/([a-z_]{1,32})/restore");
/** The user name Traefik's auth forwards; anything odd falls back to the default. */
private static final String USER_HEADER = "Remote-User";
private static final String DEFAULT_USER = "phone";
private static final Pattern USER_NAME = Pattern.compile("[\\w.@+-]{1,64}");
private static final SecurityHeaders SECURITY_HEADERS = new SecurityHeaders();
/** A setpoint request is a few dozen bytes; anything near this is not from our UI. */
private static final int MAX_BODY_BYTES = 4096;
private static final int STOP_GRACE_SECONDS = 5;
private final HttpServer server;
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
/** Binds the port now; 0 picks a free one. */
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
EnergyDetailsService energyDetails, HealthService health,
HistoryService history, SetpointService setpoints, PiSensorService piSensor) 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).
context("/", exchange -> {
try (exchange) {
exchange.sendResponseHeaders(404, -1);
}
});
api("/api/heat-pump", query -> heatPump.current());
api("/api/energy", query -> energy.current());
api("/api/energy/details", query -> energyDetails.current());
api("/api/pi-sensor", query -> piSensor.current());
api("/api/health", query -> health.current());
route("/api/history", exchange -> handleHistory(exchange, history));
route("/api/setpoints", exchange -> handle(exchange, Map.of(
"GET", () -> getSetpoints(exchange, setpoints),
"POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints)))));
context(SETPOINTS_PATH, exchange -> {
var restore = RESTORE_PATH.matcher(exchange.getRequestURI().getPath());
if (restore.matches()) {
handle(exchange, Map.of("POST", () -> guarded(exchange,
body -> postRestore(exchange, restore.group(1), setpoints))));
} else {
try (exchange) {
exchange.sendResponseHeaders(404, -1);
}
}
});
context(PRESETS_PATH, exchange -> handle(exchange, Map.of(
"POST", () -> guarded(exchange, body -> postPreset(exchange, body, setpoints)))));
}
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
private void route(String path, HttpHandler handler) {
context(path, exchange -> {
if (path.equals(exchange.getRequestURI().getPath())) {
handler.handle(exchange);
} else {
try (exchange) {
exchange.sendResponseHeaders(404, -1);
}
}
});
}
private void context(String path, HttpHandler handler) {
server.createContext(path, handler).getFilters().add(SECURITY_HEADERS);
}
public void start() {
server.start();
}
public void stop() {
server.stop(STOP_GRACE_SECONDS);
executor.shutdown();
}
public int port() {
return server.getAddress().getPort();
}
private interface Gateway {
Object get(Map<String, String> query) throws IOException;
}
/** Gateway trouble on a live-state endpoint is the gateway being unreachable: 503. */
private void api(String path, Gateway gateway) {
route(path, exchange -> handle(exchange, Map.of("GET", () -> {
try {
send(exchange, 200, gateway.get(Map.of()));
} catch (IOException e) {
send(exchange, 503, error(UNREACHABLE));
}
})));
}
/**
* A page on another site can make the browser send a simple POST with the user's cookies. It cannot
* add a custom header, set a JSON content type or fake the Origin, so a write needs all three.
*/
private static void guarded(HttpExchange exchange, Write write) throws IOException {
var headers = exchange.getRequestHeaders();
String origin = headers.getFirst("Origin");
if (origin != null && !origin.endsWith("//" + headers.getFirst("Host"))) {
send(exchange, 403, error("cross-site request"));
} else if (!WRITE_MARKER.equals(headers.getFirst("X-Requested-With"))) {
send(exchange, 403, error("missing X-Requested-With: " + WRITE_MARKER));
} else if (!isJson(headers.getFirst("Content-Type"))) {
send(exchange, 415, error("expected Content-Type: application/json"));
} else {
byte[] body = exchange.getRequestBody().readNBytes(MAX_BODY_BYTES + 1);
if (body.length > MAX_BODY_BYTES) {
send(exchange, 413, error("request body over " + MAX_BODY_BYTES + " bytes"));
} else {
write.run(body);
}
}
}
private static boolean isJson(String contentType) {
return contentType != null && contentType.split(";")[0].trim().equalsIgnoreCase("application/json");
}
private static void getSetpoints(HttpExchange exchange, SetpointService setpoints) throws IOException {
try {
send(exchange, 200, setpoints.current());
} catch (IOException e) {
send(exchange, 503, error(UNREACHABLE));
}
}
private static void postSetpoint(HttpExchange exchange, byte[] body, SetpointService setpoints)
throws IOException {
try {
var request = JSON.readValue(body, SetpointRequest.class);
if (request == null || request.setpoint() == null || request.value() == null) {
throw new IllegalArgumentException("setpoint and value are required");
}
var result = setpoints.apply(request.setpoint(), request.value(), request.hours(), user(exchange));
send(exchange, httpStatus(result.status()), result);
} catch (JsonProcessingException | IllegalArgumentException e) {
send(exchange, 400, error("expected {\"setpoint\", \"value\", \"hours\"?}"));
} catch (IOException e) {
send(exchange, 503, error(UNREACHABLE));
}
}
private static void postRestore(HttpExchange exchange, String setpoint, SetpointService setpoints)
throws IOException {
try {
var result = setpoints.restore(setpoint, user(exchange));
send(exchange, restoreHttpStatus(result.outcome()), result);
} catch (IOException e) {
send(exchange, 503, error(UNREACHABLE));
}
}
private static void postPreset(HttpExchange exchange, byte[] body, SetpointService setpoints)
throws IOException {
String id = exchange.getRequestURI().getPath().substring(PRESETS_PATH.length());
try {
var request = body.length == 0 ? new PresetRequest(null, null) : JSON.readValue(body, PresetRequest.class);
var until = request.until() == null ? null : OffsetDateTime.parse(request.until()).toInstant();
var result = setpoints.applyPreset(id, until, request.values(), user(exchange));
if (result.isPresent()) {
send(exchange, presetHttpStatus(result.get().status()), result.get());
} else {
send(exchange, 404, error("no such preset"));
}
} catch (JsonProcessingException | DateTimeParseException | IllegalArgumentException e) {
send(exchange, 400, error("expected {\"until\": \"2026-11-02T18:00:00+01:00\"} for a preset that needs "
+ "an end time, else no body; optional \"values\": {\"cwu_comfort\": 12} within the limits "
+ "for setpoints of the preset"));
} catch (IOException e) {
send(exchange, 503, error(UNREACHABLE));
}
}
private static int httpStatus(Status status) {
return switch (status) {
case APPLIED -> 200;
case REJECTED -> 422;
case FAILED -> 502;
};
}
private static int restoreHttpStatus(RestoreResult.Outcome outcome) {
return switch (outcome) {
case RESTORED -> 200;
case NOT_PENDING -> 404;
case CONFLICT -> 409;
case TOO_SOON -> 429;
case FAILED -> 502;
};
}
private static int presetHttpStatus(PresetResult.Status status) {
return switch (status) {
case APPLIED -> 200;
case REJECTED -> 422;
case FAILED, PARTIAL -> 502;
};
}
private static String user(HttpExchange exchange) {
String name = exchange.getRequestHeaders().getFirst(USER_HEADER);
return name != null && USER_NAME.matcher(name).matches() ? name : DEFAULT_USER;
}
private void handleHistory(HttpExchange exchange, HistoryService history) throws IOException {
handle(exchange, Map.of("GET", () -> {
Map<String, String> query = parseQuery(exchange.getRequestURI().getRawQuery());
try {
send(exchange, 200, history.history(query.get("series"), query.get("range")));
} catch (IllegalArgumentException e) {
send(exchange, 400, error(e.getMessage()));
} catch (IOException e) {
send(exchange, 502, error("history unavailable"));
}
}));
}
private interface Action {
void run() throws IOException;
}
private interface Write {
void run(byte[] body) throws IOException;
}
private static void handle(HttpExchange exchange, Map<String, Action> byMethod) throws IOException {
try (exchange) {
Action action = byMethod.get(exchange.getRequestMethod());
if (action == null) {
exchange.sendResponseHeaders(405, -1);
return;
}
try {
action.run();
} catch (RuntimeException e) {
// A gateway payload the client could not map (duplicate device, missing status): answer, do not drop.
System.err.println("home-pump-backend: " + exchange.getRequestURI().getPath() + ": " + e);
if (exchange.getResponseCode() == -1) {
send(exchange, 502, error("bad gateway response"));
}
}
}
}
private static void send(HttpExchange exchange, int status, Object body) throws IOException {
byte[] bytes = JSON.writeValueAsBytes(body);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.getResponseHeaders().set("Cache-Control", "no-store");
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
}
private static Map<String, String> error(String message) {
return Map.of("error", message);
}
private static Map<String, String> parseQuery(String raw) {
Map<String, String> query = new HashMap<>();
if (raw == null) {
return query;
}
for (String pair : raw.split("&")) {
String[] kv = pair.split("=", 2);
query.put(decode(kv[0]), kv.length == 2 ? decode(kv[1]) : "");
}
return query;
}
private static String decode(String s) {
return URLDecoder.decode(s, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,28 @@
package home.backend.adapters.http;
import com.sun.net.httpserver.Filter;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
/** Sets the browser hardening headers on every response, whatever handler answers. */
final class SecurityHeaders extends Filter {
private static final String POLICY = "default-src 'none'; script-src 'self'; style-src 'self'; "
+ "img-src 'self' data:; font-src 'self'; connect-src 'self'; base-uri 'none'; "
+ "form-action 'self'; frame-ancestors 'none'";
@Override
public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
var headers = exchange.getResponseHeaders();
headers.set("Content-Security-Policy", POLICY);
headers.set("X-Content-Type-Options", "nosniff");
headers.set("Referrer-Policy", "no-referrer");
chain.doFilter(exchange);
}
@Override
public String description() {
return "security headers";
}
}
@@ -0,0 +1,95 @@
package home.backend.adapters.influx;
import home.backend.application.HistoryRepository;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
/** History from InfluxDB 1.x over its HTTP {@code /query} endpoint (ADR-005, ADR-008). */
public final class InfluxHistoryRepository implements HistoryRepository {
private final InfluxSettings settings;
private final HttpClient http;
public InfluxHistoryRepository(InfluxSettings settings) {
this.settings = settings;
this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build();
}
@Override
public List<Point> history(Series series, Instant from, Instant to, int maxPoints) throws IOException {
if (maxPoints < 1 || !from.isBefore(to)) {
throw new IllegalArgumentException("need maxPoints >= 1 and from < to");
}
long stepMinutes = stepMinutes(from, to, maxPoints);
Map<Long, Double> summed = new TreeMap<>();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var parts = SeriesSources.of(series).stream()
.map(source -> executor.submit(() -> part(source, from, to, stepMinutes)))
.toList();
// Summed in source order, so the result does not depend on which answer comes first.
for (var part : parts) {
// A part without data is absent, not zero, so a bucket is kept if any part has it.
part.get().forEach((t, v) -> summed.merge(t, v, Double::sum));
}
} catch (ExecutionException e) {
throw e.getCause() instanceof IOException io ? io : new IOException(e.getCause());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("InfluxDB request interrupted", e);
}
return summed.entrySet().stream()
.map(e -> new Point(Instant.ofEpochMilli(e.getKey()), e.getValue()))
.toList();
}
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));
}
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,50 @@
package home.backend.adapters.influx;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.Map;
import java.util.TreeMap;
/** The {@code time, mean} rows of an InfluxDB 1.x query response. */
final class InfluxResponse {
private static final ObjectMapper JSON = new ObjectMapper();
private InfluxResponse() {
}
/** Mean per bucket start in epoch milliseconds, ascending; empty when there is no data. */
static Map<Long, Double> parse(String body) throws IOException {
JsonNode root = read(body);
failOnError(root);
Map<Long, Double> means = new TreeMap<>();
for (JsonNode result : root.path("results")) {
failOnError(result);
for (JsonNode series : result.path("series")) {
for (JsonNode row : series.path("values")) {
if (row.size() >= 2 && row.get(1).isNumber()) {
means.put(row.get(0).asLong(), row.get(1).asDouble());
}
}
}
}
return means;
}
private static JsonNode read(String body) throws IOException {
try {
return JSON.readTree(body);
} catch (JsonProcessingException e) {
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
}
}
private static void failOnError(JsonNode node) throws IOException {
if (node.hasNonNull("error")) {
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
}
}
}
@@ -0,0 +1,7 @@
package home.backend.adapters.influx;
import java.net.URI;
import java.time.Duration;
public record InfluxSettings(URI baseUri, Duration requestTimeout) {
}
@@ -0,0 +1,44 @@
package home.backend.adapters.influx;
import home.backend.domain.Series;
import java.util.List;
import java.util.Map;
/** Where each series lives in InfluxDB. A fixed table: no query is built from user input. */
final class SeriesSources {
private static final String LEGACY = "Dom_Pompa";
private static final String ELECTRICITY = "electricity";
private static final Map<Series, List<Source>> SOURCES = Map.ofEntries(
Map.entry(Series.OUTDOOR_TEMP, List.of(pompa("External temperature"))),
Map.entry(Series.INDOOR_TEMP, List.of(pompa("Temp wew"))),
Map.entry(Series.CO_BUFFER_TEMP, List.of(pompa("Temp co"))),
Map.entry(Series.CWU_TEMP, List.of(pompa("Temp cwu"))),
Map.entry(Series.GROUND_IN_TEMP, List.of(pompa("Temp in"))),
Map.entry(Series.GROUND_OUT_TEMP, List.of(pompa("Temp out"))),
Map.entry(Series.PUMP_OUTLET_TEMP, List.of(pompa("Temp pc out"))),
Map.entry(Series.GRID_POWER, List.of(electricity("PRAD2", "total_power_active"))),
Map.entry(Series.PV_POWER, List.of(
electricity("deye", "pv1_power_w"),
electricity("deye", "pv2_power_w"),
electricity("fronius", "pv_power_w"))),
Map.entry(Series.LOAD_POWER, List.of(electricity("deye", "load_power_w"))),
Map.entry(Series.BATTERY_SOC, List.of(electricity("deye", "battery_soc_pct"))));
private SeriesSources() {
}
static List<Source> of(Series series) {
return SOURCES.get(series);
}
private static Source pompa(String field) {
return new Source(LEGACY, "POMPA", field);
}
private static Source electricity(String measurement, String field) {
return new Source(ELECTRICITY, measurement, field);
}
}
@@ -0,0 +1,15 @@
package home.backend.adapters.influx;
/** One field of one measurement; the parts of a series are summed. */
record Source(String database, String measurement, String field) {
String query(long fromMs, long toMs, long stepMinutes) {
return "SELECT mean(" + quote(field) + ") FROM " + quote(measurement)
+ " WHERE time >= " + fromMs + "ms AND time < " + toMs + "ms"
+ " GROUP BY time(" + stepMinutes + "m) fill(none)";
}
private static String quote(String identifier) {
return '"' + identifier.replace("\\", "\\\\").replace("\"", "\\\"") + '"';
}
}
@@ -0,0 +1,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;
}
}
@@ -0,0 +1,57 @@
package home.backend.application;
import home.backend.application.SnapshotFields.Live;
import home.backend.domain.EnergyView;
import home.backend.domain.EnergyView.Source;
import home.backend.domain.EnergyView.Today;
import home.backend.domain.GatewaySnapshot;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
/** F7: house electricity from the Deye inverter and the Fronius PV inverter. */
public class EnergyService {
private final GatewayClient gateway;
private final Clock clock;
public EnergyService(GatewayClient gateway, Clock clock) {
this.gateway = gateway;
this.clock = clock;
}
public EnergyView current() throws IOException {
GatewaySnapshot snapshot = gateway.snapshot();
Live deye = SnapshotFields.live(snapshot, "deye", "deye", clock.instant());
Live fronius = SnapshotFields.live(snapshot, "fronius", "fronius", clock.instant());
Map<String, Double> d = deye.fields();
Map<String, Double> f = fronius.fields();
Double froniusKwh = f.containsKey("energy_day_wh") ? f.get("energy_day_wh") / 1000 : null;
Today today = new Today(round(sum(d.get("pv_today_kwh"), froniusKwh)),
d.get("grid_bought_today_kwh"), d.get("grid_sold_today_kwh"), d.get("load_today_kwh"));
List<Source> sources = List.of(deye.source(), fronius.source());
return new EnergyView(oldest(sources), sources,
sum(d.get("pv1_power_w"), d.get("pv2_power_w"), f.get("pv_power_w")),
d.get("grid_power_w"), d.get("battery_power_w"), d.get("battery_soc_pct"),
d.get("load_power_w"), today);
}
private static Instant oldest(List<Source> sources) {
return sources.stream().map(Source::time).flatMap(Stream::ofNullable).min(Instant::compareTo).orElse(null);
}
/** Sum of the values that exist; null when none does. */
private static Double sum(Double... values) {
return Stream.of(values).filter(v -> v != null).reduce(Double::sum).orElse(null);
}
/** Wh to kWh leaves binary noise such as 34.300000000000004. */
private static Double round(Double kwh) {
return kwh == null ? null : Math.round(kwh * 1000) / 1000.0;
}
}
@@ -0,0 +1,11 @@
package home.backend.application;
import home.backend.domain.GatewaySnapshot;
import java.io.IOException;
/** The live state, read from the gateway on the Pi through the tunnel. */
public interface GatewayClient {
GatewaySnapshot snapshot() throws IOException;
}
@@ -0,0 +1,23 @@
package home.backend.application;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.RestoreResult;
import home.backend.domain.SetpointState;
import java.io.IOException;
/** The only way the backend writes anything: through the gateway, which checks every command. */
public interface GatewayCommands {
/** A verdict, even a negative one; IOException only when no verdict could be obtained. */
CommandResult apply(Command command) throws IOException;
/** Ends the pending change of a setpoint now; a verdict like {@link #apply}. */
RestoreResult restore(String setpoint, String requestedBy) throws IOException;
SetpointState setpoints() throws IOException;
/** Keeps the gateway's live restores alive; returns how many it extended. */
int renewLeases() throws IOException;
}
@@ -0,0 +1,38 @@
package home.backend.application;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.HealthView;
import home.backend.domain.HealthView.DeviceHealth;
import java.io.IOException;
import java.time.Clock;
import java.util.List;
/** F5: whether the gateway answers and which device links are up. */
public class HealthService {
private final GatewayClient gateway;
private final Clock clock;
private final String version;
public HealthService(GatewayClient gateway, Clock clock, String version) {
this.gateway = gateway;
this.clock = clock;
this.version = version;
}
public HealthView current() {
try {
GatewaySnapshot snapshot = gateway.snapshot();
List<DeviceHealth> devices = snapshot.devices().stream().map(HealthService::health).toList();
return new HealthView(clock.instant(), true, devices, version, snapshot.version());
} catch (IOException e) {
return new HealthView(clock.instant(), false, List.of(), version, null);
}
}
private static DeviceHealth health(DeviceSnapshot d) {
return new DeviceHealth(d.name(), d.up(), d.lastSuccess(), d.lastError());
}
}
@@ -0,0 +1,87 @@
package home.backend.application;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import home.backend.domain.HeatPumpView;
import home.backend.domain.HeatPumpView.Pumps;
import home.backend.domain.HeatPumpView.Setpoints;
import home.backend.domain.HeatPumpView.State;
import home.backend.domain.HeatPumpView.Targets;
import home.backend.domain.HeatPumpView.Temperatures;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import static home.backend.application.SnapshotFields.on;
import static home.backend.application.SnapshotFields.whole;
/** F1: the heat pump's live state from the gateway's {@code heat_pump} measurement. */
public class HeatPumpService {
private static final String DATABASE = "Dom_Pompa";
private static final State[] STATES = State.values();
private final GatewayClient gateway;
private final Clock clock;
public HeatPumpService(GatewayClient gateway, Clock clock) {
this.gateway = gateway;
this.clock = clock;
}
public HeatPumpView current() throws IOException {
GatewaySnapshot snapshot = gateway.snapshot();
Optional<Measurement> reading = SnapshotFields.find(snapshot, "heat-pump", "heat_pump")
.filter(m -> DATABASE.equals(m.database()));
boolean stale = SnapshotFields.stale(SnapshotFields.device(snapshot, "heat-pump"), reading, clock.instant());
return view(reading.map(Measurement::time).orElse(null), stale,
reading.map(Measurement::fields).orElse(Map.of()));
}
private static HeatPumpView view(Instant time, boolean stale, Map<String, Double> f) {
Integer alarmCode = whole(f, "alarm_code");
return new HeatPumpView(time, stale, state(f), whole(f, "output_compressor"), on(f, "output_heater"),
on(f, "alarm") || on(f, "alarm_code"), alarmCode, alarmText(alarmCode),
new Pumps(on(f, "output_co"), on(f, "output_buffer"), on(f, "output_circulation"),
on(f, "output_ground"), on(f, "output_cwu")),
new Temperatures(tenths(f, "outdoor_temp"), tenths(f, "indoor_temp"),
tenths(f, "co_buffer_temp"), tenths(f, "cwu_temp"), tenths(f, "ground_in_temp"),
tenths(f, "ground_out_temp"), tenths(f, "pump_outlet_temp"),
tenths(f, "outdoor_avg_temp")),
new Targets(tenths(f, "target_room_temp"), tenths(f, "target_co_temp"),
tenths(f, "target_cwu_temp")),
new Setpoints(whole(f, "setpoint_co_economy"), whole(f, "setpoint_co_comfort"),
whole(f, "setpoint_cwu_economy"), whole(f, "setpoint_cwu_comfort")));
}
/** Codes of register 4011 as the vendor app names them; unverified on our device. */
private static String alarmText(Integer code) {
if (code == null || code == 0) {
return null;
}
return switch (code) {
case 2 -> "Usterka czujnika temperatury zewnętrznej";
case 3 -> "Usterka czujnika temperatury CO";
case 4 -> "Usterka czujnika temperatury wewnętrznej";
case 5 -> "Usterka czujnika temperatury CWU";
case 14 -> "Usterka czujnika temperatury wyjścia pompy";
case 15 -> "Usterka czujnika temperatury dolnego źródła";
case 1003 -> "Zadziałał presostat (ciśnienie)";
default -> "Kod usterki " + code;
};
}
/** The controller resolves 0.1 °C; the gateway's 212 * 0.1 is 21.200000000000003. */
private static Double tenths(Map<String, Double> fields, String name) {
Double value = fields.get(name);
return value == null ? null : Math.round(value * 10) / 10.0;
}
private static State state(Map<String, Double> f) {
Integer mode = whole(f, "pump_mode");
return mode == null || mode < 0 || mode >= STATES.length ? null : STATES[mode];
}
}
@@ -0,0 +1,14 @@
package home.backend.application;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
/** Past values from InfluxDB, averaged into about {@code maxPoints} steps. */
public interface HistoryRepository {
List<Point> history(Series series, Instant from, Instant to, int maxPoints) throws IOException;
}
@@ -0,0 +1,43 @@
package home.backend.application;
import home.backend.domain.HistoryView;
import home.backend.domain.Series;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
/** F2: one series over the last 24 hours or 7 days. */
public class HistoryService {
static final int MAX_POINTS = 300;
private static final String DEFAULT_RANGE = "24h";
private static final Map<String, Duration> RANGES =
Map.of("24h", Duration.ofHours(24), "7d", Duration.ofDays(7));
private final HistoryRepository repository;
private final Clock clock;
public HistoryService(HistoryRepository repository, Clock clock) {
this.repository = repository;
this.clock = clock;
}
/**
* @param range {@code 24h} or {@code 7d}; null means 24h
* @throws IllegalArgumentException for an unknown series or range
*/
public HistoryView history(String seriesId, String range) throws IOException {
Series series = Series.fromId(seriesId)
.orElseThrow(() -> new IllegalArgumentException("unknown series: " + seriesId));
Duration length = RANGES.get(range == null ? DEFAULT_RANGE : range);
if (length == null) {
throw new IllegalArgumentException("unknown range: " + range);
}
Instant to = clock.instant();
return new HistoryView(series.id(), series.unit(),
repository.history(series, to.minus(length), to, MAX_POINTS));
}
}
@@ -0,0 +1,53 @@
package home.backend.application;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* Tells the gateway now and then that we are still here (ADR-011): while it hears us, our setpoint
* changes stay; once we or the tunnel are gone for 15 minutes, it restores them on its own.
*/
public class LeaseRenewer implements AutoCloseable {
/** A third of the gateway's lease, so two renewals in a row may fail. */
public static final Duration PERIOD = Duration.ofMinutes(5);
private static final Logger LOGGER = LoggerFactory.getLogger(LeaseRenewer.class);
private final GatewayCommands gateway;
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("lease-renewal").factory());
public LeaseRenewer(GatewayCommands gateway) {
this.gateway = gateway;
}
public void start() {
start(PERIOD);
}
void start(Duration period) {
scheduler.scheduleWithFixedDelay(this::renew, 0, period.toMillis(), TimeUnit.MILLISECONDS);
}
/** A failure is logged and retried next cycle; an exception would end the schedule for good. */
void renew() {
try {
int renewed = gateway.renewLeases();
LOGGER.debug("renewed {} lease(s)", renewed);
} catch (IOException | RuntimeException e) {
LOGGER.warn("could not renew the gateway leases, retrying in {}: {}", PERIOD, e.toString());
}
}
@Override
public void close() {
scheduler.shutdownNow();
}
}
@@ -0,0 +1,27 @@
package home.backend.application;
import home.backend.application.SnapshotFields.Live;
import home.backend.domain.PiSensorView;
import java.io.IOException;
import java.time.Clock;
import java.util.Map;
/** The BME280 on the Pi, read by the gateway every 5 minutes. */
public class PiSensorService {
private final GatewayClient gateway;
private final Clock clock;
public PiSensorService(GatewayClient gateway, Clock clock) {
this.gateway = gateway;
this.clock = clock;
}
public PiSensorView current() throws IOException {
Live live = SnapshotFields.live(gateway.snapshot(), "bme280", "MEASUREMENT", clock.instant());
Map<String, Double> f = live.fields();
return new PiSensorView(live.source().time(), live.source().stale(),
f.get("temperature"), f.get("humidity"), f.get("pressure"));
}
}
@@ -0,0 +1,152 @@
package home.backend.application;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.Preset;
import home.backend.domain.Preset.Change;
import home.backend.domain.PresetResult;
import home.backend.domain.PresetResult.ChangeResult;
import home.backend.domain.PresetResult.ChangeStatus;
import home.backend.domain.RestoreResult;
import home.backend.domain.Setpoint;
import home.backend.domain.SetpointState;
import home.backend.domain.SetpointsView;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/** F3: change setpoints directly or through a preset; the gateway enforces the limits (S1). */
public class SetpointService {
private final GatewayCommands gateway;
private final List<Preset> presets;
public SetpointService(GatewayCommands gateway, List<Preset> presets) {
this.gateway = gateway;
this.presets = List.copyOf(presets);
}
public SetpointsView current() throws IOException {
SetpointState state = gateway.setpoints();
return new SetpointsView(state.setpoints(), state.restores(), presets);
}
/** @param hours how long to keep the value, null until changed back */
public CommandResult apply(String setpoint, int value, Double hours, String user) throws IOException {
return gateway.apply(new Command(setpoint, value, duration(hours), user));
}
/** Puts the value from before the pending change back now, instead of waiting for its end. */
public RestoreResult restore(String setpoint, String user) throws IOException {
return gateway.restore(setpoint, user);
}
/**
* Empty if there is no such preset. A preset that does not fit the gateway's limits writes nothing.
* IOException only when no change was attempted; once writing starts, trouble is reported per change.
*
* @param until the end the user chose; required by a held preset, not allowed for any other
* @param values the user's own value for some of the preset's setpoints (any preset); the rest keep the
* preset's value
* @throws IllegalArgumentException when {@code until} does not match what the preset needs, or a value is
* for a setpoint the preset does not change or outside the gateway's limits
*/
public Optional<PresetResult> applyPreset(String id, Instant until, Map<String, Integer> values, String user)
throws IOException {
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
if (preset.isEmpty()) {
return Optional.empty();
}
if (preset.get().isHeld() != (until != null)) {
throw new IllegalArgumentException(preset.get().isHeld() ? "preset " + id + " needs an end time"
: "preset " + id + " has no end time to choose");
}
List<Change> changes = withValues(preset.get().changes(), values);
List<Setpoint> limits = gateway.setpoints().setpoints();
requireWithinLimits(values, limits);
List<ChangeResult> checked = changes.stream().map(c -> check(c, preset.get().isHeld(), limits)).toList();
if (checked.stream().anyMatch(r -> r.status() == ChangeStatus.REJECTED)) {
return Optional.of(PresetResult.of(checked));
}
return Optional.of(PresetResult.of(run(changes, until, user)));
}
private static List<Change> withValues(List<Change> changes, Map<String, Integer> values) {
Set<String> own = changes.stream().map(Change::setpoint).collect(Collectors.toSet());
if (!own.containsAll(values.keySet())) {
throw new IllegalArgumentException("values may only be for the setpoints the preset changes: " + own);
}
return changes.stream().map(c -> new Change(c.setpoint(), values.getOrDefault(c.setpoint(), c.value()),
c.hours())).toList();
}
private static void requireWithinLimits(Map<String, Integer> values, List<Setpoint> limits) {
for (Setpoint limit : limits) {
Integer value = values.get(limit.name());
if (value != null && !limit.allows(value)) {
throw new IllegalArgumentException(limit.name() + " must be " + limit.min() + "–" + limit.max()
+ " in steps of " + limit.step());
}
}
}
private List<ChangeResult> run(List<Change> changes, Instant until, String user) {
List<ChangeResult> results = new ArrayList<>();
for (Change change : changes) {
boolean stopped = !results.isEmpty() && results.getLast().status() != ChangeStatus.APPLIED;
results.add(stopped ? ChangeResult.notAttempted() : attempt(change, until, user));
}
return results;
}
private ChangeResult attempt(Change change, Instant until, String user) {
try {
var command = until == null ? new Command(change.setpoint(), change.value(), duration(change.hours()), user)
: new Command(change.setpoint(), change.value(), null, until, user);
return ChangeResult.of(gateway.apply(command));
} catch (IOException e) {
return new ChangeResult(ChangeStatus.UNKNOWN, "no answer from the gateway", null);
}
}
/** REJECTED with the reason, or NOT_ATTEMPTED (nothing is sent if any change is rejected). */
private static ChangeResult check(Change change, boolean held, List<Setpoint> limits) {
String problem = problem(change, held, limits);
return problem == null ? ChangeResult.notAttempted() : new ChangeResult(ChangeStatus.REJECTED, problem, null);
}
private static String problem(Change change, boolean held, List<Setpoint> limits) {
Optional<Setpoint> known = limits.stream().filter(s -> s.name().equals(change.setpoint())).findFirst();
if (known.isEmpty()) {
return "preset sets " + change.setpoint() + ", which the gateway does not know";
}
if (!known.get().allows(change.value())) {
return "preset sets " + change.setpoint() + " to " + change.value() + ", outside the gateway's limits "
+ known.get().min() + "–" + known.get().max() + " step " + known.get().step();
}
// The gateway refuses a held raise too; asking first keeps a preset from stopping half way.
if (held && known.get().value() != null && change.value() >= known.get().value()) {
return "preset sets " + change.setpoint() + " to " + change.value() + ", which is not below the current "
+ known.get().value();
}
return null;
}
private static Duration duration(Double hours) {
if (hours == null) {
return null;
}
long minutes = Math.round(hours * 60);
if (minutes < 1 || minutes > Command.MAX_HOURS * 60L) {
throw new IllegalArgumentException("hours must be from one minute to " + Command.MAX_HOURS + " hours");
}
return Duration.ofMinutes(minutes);
}
}
@@ -0,0 +1,62 @@
package home.backend.application;
import home.backend.domain.EnergyView.Source;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/** Looks up one measurement of one device in a snapshot and reads typed fields. */
final class SnapshotFields {
private SnapshotFields() {
}
/** Twice the 300 s the UI treats as one polling cycle; older than that a reading is not live. */
private static final Duration MAX_AGE = Duration.ofSeconds(600);
static Optional<DeviceSnapshot> device(GatewaySnapshot snapshot, String name) {
return snapshot.devices().stream().filter(d -> d.name().equals(name)).findFirst();
}
/** A reading is stale when the device link is down, it is missing, or it is older than twice a cycle. */
static boolean stale(Optional<DeviceSnapshot> device, Optional<Measurement> reading, Instant now) {
return device.isEmpty() || !device.get().up() || reading.isEmpty()
|| Duration.between(reading.get().time(), now).compareTo(MAX_AGE) > 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) {
Optional<Measurement> reading = find(snapshot, device, measurement);
boolean stale = stale(device(snapshot, device), reading, now);
Source source = new Source(device, reading.map(Measurement::time).orElse(null), stale);
return new Live(source, stale ? Map.of() : reading.get().fields());
}
static Optional<Measurement> find(GatewaySnapshot snapshot, String device, String measurement) {
return snapshot.devices().stream()
.filter(d -> d.name().equals(device))
.map(DeviceSnapshot::measurements)
.flatMap(List::stream)
.filter(m -> m.measurement().equals(measurement))
.findFirst();
}
static Integer whole(Map<String, Double> fields, String name) {
Double value = fields.get(name);
return value == null ? null : (int) Math.round(value);
}
static boolean on(Map<String, Double> fields, String name) {
Double value = fields.get(name);
return value != null && value != 0;
}
}
@@ -0,0 +1,19 @@
package home.backend.domain;
import java.time.Duration;
import java.time.Instant;
/**
* Set a setpoint. With a duration the gateway restores the old value afterwards; with
* {@code heldUntil} it holds a lowering value until then without renewals (ADR-012); else it stays
* while the backend renews the lease (ADR-011).
*/
public record Command(String setpoint, int value, Duration duration, Instant heldUntil, String requestedBy) {
public Command(String setpoint, int value, Duration duration, String requestedBy) {
this(setpoint, value, duration, null, requestedBy);
}
/** The gateway refuses longer boosts too; we refuse first, so the phone gets a clear 400. */
public static final int MAX_HOURS = 72;
}
@@ -0,0 +1,10 @@
package home.backend.domain;
import java.time.Instant;
/** The gateway's verdict on a command, passed on to the phone unchanged. */
public record CommandResult(Status status, String message, Instant restoreAt) {
/** REJECTED: refused before any write. FAILED: the write or its read-back failed. */
public enum Status { APPLIED, REJECTED, FAILED }
}
@@ -0,0 +1,22 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/**
* Every value the energy devices report, grouped for a details panel (F7). A device without a
* fresh reading has {@code stale} true and no groups; a field the device did not report is absent.
*/
public record EnergyDetails(List<Device> devices) {
/** One device; {@code id} is the gateway's device name. */
public record Device(String id, String name, Instant time, boolean stale, List<Group> groups) {
}
public record Group(String title, List<Item> items) {
}
/** A value in {@code unit}, to be shown with {@code digits} decimals. */
public record Item(String label, double value, String unit, int digits) {
}
}
@@ -0,0 +1,21 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/**
* The house's electricity now (F7). Powers in W; grid + import / − export, battery + discharge /
* − charge. Values that come only from a stale source are null. {@code time} is the oldest source
* time (null when none was read); {@code sources} lists Deye and Fronius with their own time and freshness.
*/
public record EnergyView(Instant time, List<Source> sources, Double pvPowerW, Double gridPowerW,
Double batteryPowerW, Double batterySocPct, Double loadPowerW, Today today) {
/** One inverter; time is null and stale true when the gateway has no reading of it. */
public record Source(String name, Instant time, boolean stale) {
}
/** Day totals in kWh since local midnight; PV adds Deye and Fronius. */
public record Today(Double pvKwh, Double gridBoughtKwh, Double gridSoldKwh, Double loadKwh) {
}
}
@@ -0,0 +1,18 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
import java.util.Map;
/** The gateway's latest state of every device, as the backend sees it.
* The version is the gateway's release tag, null for a gateway that does not report it yet. */
public record GatewaySnapshot(String version, List<DeviceSnapshot> devices) {
public record DeviceSnapshot(String name, boolean up, Instant lastSuccess, String lastError,
List<Measurement> measurements) {
}
/** One reading the gateway wrote to InfluxDB, e.g. heat_pump or deye. */
public record Measurement(String database, String measurement, Map<String, Double> fields, Instant time) {
}
}
@@ -0,0 +1,13 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/** Health of the chain phone → backend → gateway → devices (F5), with the deployed versions.
* The gateway's version is null when it is unreachable or too old to report it. */
public record HealthView(Instant time, boolean gatewayReachable, List<DeviceHealth> devices,
String backendVersion, String gatewayVersion) {
public record DeviceHealth(String name, boolean up, Instant lastSuccess, String lastError) {
}
}
@@ -0,0 +1,30 @@
package home.backend.domain;
import java.time.Instant;
/**
* What the heat pump does now (F1). Without a reading from the gateway {@code time} and the values are
* null; {@code stale} is true when the device is down or the reading is older than 2 × 300 s.
*/
public record HeatPumpView(Instant time, boolean stale, State state, Integer compressorPercent, boolean heaterOn,
boolean alarm, Integer alarmCode, String alarmText, Pumps pumps, Temperatures temperatures,
Targets targets, Setpoints setpoints) {
/** From register 4227: 0 stop, 1 CO, 2 CWU, 3 both. */
public enum State { IDLE, HEATING_CO, HEATING_CWU, HEATING_BOTH }
public record Pumps(boolean co, boolean buffer, boolean circulation, boolean ground, boolean cwu) {
}
public record Temperatures(Double outdoor, Double indoor, Double coBuffer, Double cwu,
Double groundIn, Double groundOut, Double pumpOutlet, Double outdoorAvg) {
}
/** Targets the controller works to right now (economy or comfort period). */
public record Targets(Double room, Double co, Double cwu) {
}
/** The four setpoints F3 will change; whole °C. */
public record Setpoints(Integer coEconomy, Integer coComfort, Integer cwuEconomy, Integer cwuComfort) {
}
}
@@ -0,0 +1,11 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/** One series over a time range, already averaged into chart-sized steps. */
public record HistoryView(String series, String unit, List<Point> points) {
public record Point(Instant t, double v) {
}
}
@@ -0,0 +1,11 @@
package home.backend.domain;
import java.time.Instant;
/**
* The BME280 sensor on the Raspberry Pi: °C, % and hPa. When it is stale (device down, no reading
* or older than 2 × 300 s) the values are null and {@code stale} is true; {@code time} is null
* without any reading.
*/
public record PiSensorView(Instant time, boolean stale, Double temperature, Double humidity, Double pressure) {
}
@@ -0,0 +1,50 @@
package home.backend.domain;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;
/**
* A named one-tap bundle of setpoint changes (F3), e.g. boost or away. {@code until} is null, or
* {@link #CHOSEN}: the user picks the end on a calendar and the gateway holds the changes until
* then without renewals (ADR-012).
*/
public record Preset(String id, String name, @JsonInclude(JsonInclude.Include.NON_NULL) String until,
List<Change> changes) {
public static final String CHOSEN = "chosen";
/** {@code hours} null means "until changed back". */
public record Change(String setpoint, int value, Double hours) {
public Change {
if (setpoint == null || setpoint.isBlank()) {
throw new IllegalArgumentException("a preset change needs a setpoint");
}
if (hours != null && (hours <= 0 || hours > Command.MAX_HOURS)) {
throw new IllegalArgumentException("preset hours must be above 0 and at most " + Command.MAX_HOURS);
}
}
}
@JsonIgnore
public boolean isHeld() {
return CHOSEN.equals(until);
}
public Preset {
if (id == null || id.isBlank() || name == null || name.isBlank()) {
throw new IllegalArgumentException("a preset needs an id and a name");
}
if (changes == null || changes.isEmpty()) {
throw new IllegalArgumentException("preset " + id + " has no changes");
}
if (until != null && !until.equals(CHOSEN)) {
throw new IllegalArgumentException("preset " + id + ": until can only be \"" + CHOSEN + "\"");
}
if (CHOSEN.equals(until) && changes.stream().anyMatch(change -> change.hours() != null)) {
throw new IllegalArgumentException("preset " + id + " is held until a chosen time, so no hours");
}
}
}
@@ -0,0 +1,44 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/**
* What happened to each change of a preset, one entry per change in preset order. Changes run in
* order and stop at the first that is not applied; the rest are NOT_ATTEMPTED.
*
* <p>{@code status}: APPLIED when every change was; PARTIAL when some were and some were not;
* otherwise FAILED if any change failed or went unanswered (the pump may have changed), else
* REJECTED (nothing was written).
*/
public record PresetResult(Status status, List<ChangeResult> results) {
public enum Status { APPLIED, REJECTED, FAILED, PARTIAL }
/** UNKNOWN: the gateway did not answer, so the change may or may not have reached the pump. */
public enum ChangeStatus { APPLIED, REJECTED, FAILED, NOT_ATTEMPTED, UNKNOWN }
public record ChangeResult(ChangeStatus status, String message, Instant restoreAt) {
public static ChangeResult of(CommandResult verdict) {
return new ChangeResult(ChangeStatus.valueOf(verdict.status().name()), verdict.message(),
verdict.restoreAt());
}
public static ChangeResult notAttempted() {
return new ChangeResult(ChangeStatus.NOT_ATTEMPTED, null, null);
}
}
public static PresetResult of(List<ChangeResult> results) {
boolean applied = results.stream().anyMatch(r -> r.status() == ChangeStatus.APPLIED);
boolean all = results.stream().allMatch(r -> r.status() == ChangeStatus.APPLIED);
boolean maybeWritten = results.stream()
.anyMatch(r -> r.status() == ChangeStatus.FAILED || r.status() == ChangeStatus.UNKNOWN);
Status status = all ? Status.APPLIED
: applied ? Status.PARTIAL
: maybeWritten ? Status.FAILED
: Status.REJECTED;
return new PresetResult(status, results);
}
}
@@ -0,0 +1,10 @@
package home.backend.domain;
import java.time.Instant;
/**
* A change the gateway will undo at {@code due} (null: until changed), unless someone changed the
* register meanwhile. It also undoes it when we stop renewing the lease (ADR-011).
*/
public record Restore(String setpoint, int register, int originalValue, int appliedValue, Instant due) {
}
@@ -0,0 +1,11 @@
package home.backend.domain;
/** The gateway's answer to "restore now", passed on to the phone unchanged. */
public record RestoreResult(Outcome outcome, String message) {
/**
* NOT_PENDING: nothing to restore. CONFLICT: the register was changed meanwhile, so it was left alone.
* TOO_SOON: the gateway's rate limit. FAILED: the restore stays pending and is retried.
*/
public enum Outcome { RESTORED, NOT_PENDING, CONFLICT, TOO_SOON, FAILED }
}
@@ -0,0 +1,39 @@
package home.backend.domain;
import java.util.Arrays;
import java.util.Optional;
/** The history series the UI may ask for (F2); an allowlist, so no query is built from input. */
public enum Series {
OUTDOOR_TEMP("outdoor_temp", "°C"),
INDOOR_TEMP("indoor_temp", "°C"),
CO_BUFFER_TEMP("co_buffer_temp", "°C"),
CWU_TEMP("cwu_temp", "°C"),
GROUND_IN_TEMP("ground_in_temp", "°C"),
GROUND_OUT_TEMP("ground_out_temp", "°C"),
PUMP_OUTLET_TEMP("pump_outlet_temp", "°C"),
GRID_POWER("grid_power", "W"),
PV_POWER("pv_power", "W"),
LOAD_POWER("load_power", "W"),
BATTERY_SOC("battery_soc", "%");
private final String id;
private final String unit;
Series(String id, String unit) {
this.id = id;
this.unit = unit;
}
public String id() {
return id;
}
public String unit() {
return unit;
}
public static Optional<Series> fromId(String id) {
return Arrays.stream(values()).filter(series -> series.id.equals(id)).findFirst();
}
}
@@ -0,0 +1,9 @@
package home.backend.domain;
/** One of the four writable setpoints as the gateway reports it; {@code value} is null until it has been read. */
public record Setpoint(String name, int register, int min, int max, int step, Integer value) {
public boolean allows(int candidate) {
return candidate >= min && candidate <= max && (candidate - min) % step == 0;
}
}
@@ -0,0 +1,7 @@
package home.backend.domain;
import java.util.List;
/** What the gateway knows about the setpoints: their limits, values and the pending restores. */
public record SetpointState(List<Setpoint> setpoints, List<Restore> restores) {
}
@@ -0,0 +1,7 @@
package home.backend.domain;
import java.util.List;
/** The setpoints screen: gateway state plus the presets from the backend config. */
public record SetpointsView(List<Setpoint> setpoints, List<Restore> restores, List<Preset> presets) {
}
@@ -0,0 +1,16 @@
[
{
"id": "cwu-now",
"name": "Ciepła woda teraz",
"changes": [ { "setpoint": "cwu_economy", "value": 45, "hours": 2 } ]
},
{
"id": "away",
"name": "Wyjazd",
"until": "chosen",
"changes": [
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
{ "setpoint": "co_comfort", "value": 15, "hours": null }
]
}
]
@@ -0,0 +1,312 @@
package home.backend;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import home.backend.adapters.http.ApiServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** The assembled backend against a stub gateway and a stub InfluxDB, both on localhost. */
class BackendTest {
private static final ObjectMapper JSON = new ObjectMapper();
/** Just after the readings in the snapshot fixture, so they count as fresh. */
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-10-23T22:27:00Z"), ZoneOffset.UTC);
private final HttpClient client = HttpClient.newHttpClient();
private HttpServer gateway;
private HttpServer influx;
private ApiServer backend;
@BeforeEach
void start() throws IOException {
gateway = stub(Map.of(
"/health", "src/test/resources/gateway/health-all-up.json",
"/snapshot", "src/test/resources/gateway/snapshot-all-up.json"));
gateway.createContext("/commands", this::commands);
influx = stub(Map.of("/query", "src/test/resources/influx/influx-query-cwu-6h.json"));
var settings = new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2), Duration.ofSeconds(2),
"s3cret-token", null);
backend = Backend.assemble(settings, CLOCK);
backend.start();
}
private record Command(String authorization, String requestedBy, String body) {
}
private final List<Command> commandsSeen = new ArrayList<>();
/** The gateway's /commands, answering with the gateway's own contract examples. */
private void commands(HttpExchange exchange) throws IOException {
boolean post = exchange.getRequestMethod().equals("POST");
if (post) {
commandsSeen.add(new Command(exchange.getRequestHeaders().getFirst("Authorization"),
exchange.getRequestHeaders().getFirst("X-Requested-By"),
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
}
byte[] body = Files.readAllBytes(Path.of("../gateway/src/test/resources/commands/"
+ (post ? "response-applied.json" : "setpoints.json")));
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
}
private HttpResponse<String> post(String path, String body) throws Exception {
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path))
.header("Remote-User", "anna").header("Content-Type", "application/json")
.header("X-Requested-With", "home-pump")
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
}
@AfterEach
void stop() {
backend.stop();
gateway.stop(0);
influx.stop(0);
}
private static HttpServer stub(Map<String, String> bodies) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
bodies.forEach((path, file) -> server.createContext(path, exchange -> {
byte[] body = Files.readAllBytes(Path.of(file));
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
}));
server.start();
return server;
}
private static URI url(HttpServer server) {
return URI.create("http://127.0.0.1:" + server.getAddress().getPort());
}
private HttpResponse<String> get(String path) throws Exception {
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path)).build();
return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
}
/** The names of every field, nested ones as a.b; array elements are not descended into. */
private static TreeSet<String> fieldNames(JsonNode node, String prefix, TreeSet<String> names) {
node.fieldNames().forEachRemaining(name -> {
names.add(prefix + name);
fieldNames(node.get(name), prefix + name + ".", names);
});
return names;
}
private void assertServesApiShapeOf(String path, String example) throws Exception {
HttpResponse<String> response = get(path);
assertEquals(200, response.statusCode(), path);
assertEquals("application/json", response.headers().firstValue("Content-Type").orElseThrow());
JsonNode expected = JSON.readTree(Files.readString(Path.of("src/test/resources/api/" + example + ".json")));
assertEquals(fieldNames(expected, "", new TreeSet<>()),
fieldNames(JSON.readTree(response.body()), "", new TreeSet<>()), path);
}
/** The body equals the documented example, value by value. */
private void assertServesExactly(String path, String example) throws Exception {
HttpResponse<String> response = get(path);
assertEquals(200, response.statusCode(), path);
assertEquals(JSON.readTree(Files.readString(Path.of("src/test/resources/api/" + example + ".json"))),
JSON.readTree(response.body()), path);
}
@Test
void servesHeatPumpFromTheGatewayReadings() throws Exception {
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");
}
@Test
void servesHealthOfEveryGatewayDevice() throws Exception {
assertServesApiShapeOf("/api/health", "health");
JsonNode health = JSON.readTree(get("/api/health").body());
assertTrue(health.get("gatewayReachable").asBoolean());
List<String> names = new ArrayList<>();
health.get("devices").forEach(device -> {
names.add(device.get("name").asText());
assertTrue(device.get("up").asBoolean(), names.getLast());
});
assertEquals(List.of("bme280", "deye", "fronius", "heat-pump", "house-meter"), names);
}
@Test
void servesHistoryFromInflux() throws Exception {
assertServesApiShapeOf("/api/history?series=cwu_temp&range=24h", "history");
JsonNode points = JSON.readTree(get("/api/history?series=cwu_temp&range=24h").body()).get("points");
assertFalse(points.isEmpty());
}
@Test
void servesSetpointsWithTheBuiltInPresets() throws Exception {
JsonNode view = JSON.readTree(get("/api/setpoints").body());
assertEquals(JSON.readTree(Files.readString(Path.of("src/test/resources/api/setpoints.json"))), view);
}
@Test
void boostGoesToTheGatewayWithTokenAndUser() throws Exception {
var response = post("/api/setpoints",
Files.readString(Path.of("src/test/resources/api/setpoints-request.json")));
assertEquals(200, response.statusCode());
assertEquals(JSON.readTree(Files.readString(Path.of("src/test/resources/api/setpoints-applied.json"))),
JSON.readTree(response.body()));
assertEquals(1, commandsSeen.size());
assertEquals("Bearer s3cret-token", commandsSeen.getFirst().authorization());
assertEquals("anna", commandsSeen.getFirst().requestedBy());
assertEquals(JSON.readTree(Files.readString(Path.of("../gateway/src/test/resources/commands/request-boost.json"))),
JSON.readTree(commandsSeen.getFirst().body()));
}
@Test
void presetsComeFromPresetsFile(@TempDir Path dir) throws Exception {
Path file = Files.writeString(dir.resolve("presets.json"),
"[{\"id\":\"bath\",\"name\":\"Kąpiel\",\"changes\":[{\"setpoint\":\"cwu_economy\",\"value\":48,\"hours\":1}]}]");
var custom = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
Duration.ofSeconds(2), "s3cret-token", file), CLOCK);
custom.start();
try {
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + custom.port() + "/api/presets/bath"))
.header("Content-Type", "application/json").header("X-Requested-With", "home-pump")
.POST(HttpRequest.BodyPublishers.noBody()).build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
assertEquals(200, response.statusCode());
assertEquals("phone", commandsSeen.getFirst().requestedBy());
assertEquals(JSON.readTree("{\"setpoint\":\"cwu_economy\",\"value\":48,\"minutes\":60}"),
JSON.readTree(commandsSeen.getFirst().body()));
} finally {
custom.stop();
}
}
@Test
void withoutTokenSetpointsAreUnavailableButLiveStateStays() throws Exception {
var noToken = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
Duration.ofSeconds(2), null, null), CLOCK);
noToken.start();
try {
var base = "http://127.0.0.1:" + noToken.port();
assertEquals(503, client.send(HttpRequest.newBuilder(URI.create(base + "/api/setpoints")).build(),
HttpResponse.BodyHandlers.ofString()).statusCode());
assertEquals(200, client.send(HttpRequest.newBuilder(URI.create(base + "/api/heat-pump")).build(),
HttpResponse.BodyHandlers.ofString()).statusCode());
assertTrue(commandsSeen.isEmpty());
} finally {
noToken.stop();
}
}
@Test
void settingsNeedBothUrls() {
var e = assertThrows(IllegalArgumentException.class,
() -> Settings.fromEnvironment(Map.of("INFLUX_URL", "https://influx.example")));
assertTrue(e.getMessage().contains("GATEWAY_URL"));
e = assertThrows(IllegalArgumentException.class,
() -> Settings.fromEnvironment(Map.of("GATEWAY_URL", "http://gw:8080")));
assertTrue(e.getMessage().contains("INFLUX_URL"));
}
@Test
void urlsMayEndWithASingleSlash() {
var settings = Settings.fromEnvironment(
Map.of("GATEWAY_URL", "http://gw:8080/", "INFLUX_URL", "https://influx.example/"));
assertEquals(URI.create("http://gw:8080/"), settings.gatewayUrl());
}
@Test
void settingsHaveDefaults() {
var settings = Settings.fromEnvironment(
Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example"));
assertEquals(8080, settings.port());
assertEquals(Duration.ofSeconds(5), settings.gatewayTimeout());
assertEquals(Duration.ofSeconds(10), settings.influxTimeout());
}
@Test
void tokenAndPresetsFileAreOptional() {
var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example");
var none = Settings.fromEnvironment(urls);
var env = new HashMap<>(urls);
env.put("GATEWAY_TOKEN", " s3cret-token ");
env.put("PRESETS_FILE", "/etc/home-pump/presets.json");
var set = Settings.fromEnvironment(env);
env.put("GATEWAY_TOKEN", " ");
env.put("PRESETS_FILE", "");
assertEquals(null, none.gatewayToken());
assertEquals(null, none.presetsFile());
assertEquals("s3cret-token", set.gatewayToken());
assertEquals(Path.of("/etc/home-pump/presets.json"), set.presetsFile());
assertEquals(null, Settings.fromEnvironment(env).gatewayToken());
}
@Test
void settingsRejectBadValues() {
var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example");
for (Map<String, String> bad : List.of(
Map.of("GATEWAY_URL", "gw:8080", "INFLUX_URL", "https://influx.example"),
Map.of("GATEWAY_URL", "ftp://gw", "INFLUX_URL", "https://influx.example"),
Map.of("GATEWAY_URL", "http://gw:8080/api", "INFLUX_URL", "https://influx.example"),
Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example/influx/"),
Map.of("GATEWAY_URL", "http://gw:8080?x=1", "INFLUX_URL", "https://influx.example"))) {
assertThrows(IllegalArgumentException.class, () -> Settings.fromEnvironment(bad));
}
for (String name : List.of("PORT", "GATEWAY_TIMEOUT", "INFLUX_TIMEOUT")) {
for (String value : List.of("abc", "-1", "0x10", "99999999")) {
var env = new HashMap<>(urls);
env.put(name, value);
var e = assertThrows(IllegalArgumentException.class, () -> Settings.fromEnvironment(env), name + value);
assertTrue(e.getMessage().contains(name));
}
}
}
}
@@ -0,0 +1,68 @@
package home.backend.adapters.config;
import home.backend.domain.Preset;
import home.backend.domain.Preset.Change;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PresetFileTest {
@TempDir
Path dir;
private Path file(String json) throws IOException {
return Files.writeString(dir.resolve("presets.json"), json);
}
@Test
void defaultsAreBoostAndAway() {
var presets = PresetFile.load(null);
assertEquals(List.of(
new Preset("cwu-now", "Ciepła woda teraz", null, List.of(new Change("cwu_economy", 45, 2.0))),
new Preset("away", "Wyjazd", "chosen", List.of(new Change("cwu_comfort", 10, null),
new Change("co_comfort", 15, null)))), presets);
}
@Test
void fileReplacesTheDefaults() throws Exception {
var presets = PresetFile.load(file("""
[{"id": "bath", "name": "Kąpiel", "changes": [{"setpoint": "cwu_economy", "value": 48, "hours": 1.5}]}]
"""));
assertEquals(List.of(new Preset("bath", "Kąpiel", null, List.of(new Change("cwu_economy", 48, 1.5)))), presets);
}
@Test
void unusableFilesAreRefusedWithTheirName() throws Exception {
for (String json : List.of(
"not json",
"{}",
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": []}]",
"[{\"id\": \"\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 0}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 73}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"until\": \"tomorrow\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"until\": \"chosen\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 2}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]},"
+ "{\"id\": \"a\", \"name\": \"B\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 16}]}]")) {
Path file = file(json);
var e = assertThrows(IllegalArgumentException.class, () -> PresetFile.load(file), json);
assertTrue(e.getMessage().contains("PRESETS_FILE"), e.getMessage());
}
}
@Test
void missingFileIsRefused() {
assertThrows(IllegalArgumentException.class, () -> PresetFile.load(dir.resolve("absent.json")));
}
}
@@ -0,0 +1,187 @@
package home.backend.adapters.gateway;
import com.sun.net.httpserver.HttpServer;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
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.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpTimeoutException;
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.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class GatewayHttpClientTest {
private HttpServer server;
private GatewayHttpClient client;
@BeforeEach
void startServer() throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.start();
client = new GatewayHttpClient(URI.create("http://127.0.0.1:" + server.getAddress().getPort()),
Duration.ofMillis(500));
}
@AfterEach
void stopServer() {
server.stop(0);
}
private void serve(String path, int status, String body) {
server.createContext(path, exchange -> {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
}
private void serveFiles(int healthStatus, String healthFile, String snapshotFile) throws IOException {
serve("/health", healthStatus, resource(healthFile));
serve("/snapshot", 200, resource(snapshotFile));
}
private static String resource(String name) throws IOException {
return Files.readString(Path.of("src/test/resources/gateway", name));
}
private static DeviceSnapshot device(List<DeviceSnapshot> devices, String name) {
return devices.stream().filter(d -> d.name().equals(name)).findFirst().orElseThrow();
}
@Test
void mapsEveryDeviceWithItsMeasurements() throws Exception {
serveFiles(200, "health-all-up.json", "snapshot-all-up.json");
var devices = client.snapshot().devices();
assertEquals(List.of("bme280", "deye", "fronius", "heat-pump", "house-meter"),
devices.stream().map(DeviceSnapshot::name).toList());
assertTrue(devices.stream().allMatch(DeviceSnapshot::up));
DeviceSnapshot pump = device(devices, "heat-pump");
assertEquals(Instant.parse("2026-10-23T22:26:00Z"), pump.lastSuccess());
assertNull(pump.lastError());
assertEquals(List.of("POMPA", "heat_pump"), pump.measurements().stream().map(Measurement::measurement).toList());
var measurement = pump.measurements().get(1);
assertEquals("Dom_Pompa", measurement.database());
assertEquals(4.3, measurement.fields().get("outdoor_temp"), 1e-9);
assertEquals(45.0, measurement.fields().get("setpoint_cwu_comfort"), 1e-9);
assertEquals(Instant.parse("2026-10-23T22:26:00Z"), measurement.time());
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");
var devices = client.snapshot().devices();
assertTrue(device(devices, "heat-pump").up());
DeviceSnapshot deye = device(devices, "deye");
assertFalse(deye.up());
assertEquals("Connect timed out", deye.lastError());
assertEquals(Instant.parse("2026-10-23T22:20:00Z"), deye.lastSuccess());
// The last good reading stays available, with its own time.
var last = deye.measurements().get(0);
assertEquals(570.0, last.fields().get("load_power_w"));
assertEquals(Instant.parse("2026-10-23T22:20:00Z"), last.time());
}
@Test
void aDeviceThatNeverSucceededHasNoLastSuccessAndNoMeasurements() throws Exception {
serveFiles(503, "health-never-succeeded.json", "snapshot-never-succeeded.json");
var devices = client.snapshot().devices();
var pump = device(devices, "heat-pump");
assertFalse(pump.up());
assertNull(pump.lastSuccess());
assertEquals("Connection refused", pump.lastError());
assertTrue(pump.measurements().isEmpty());
assertTrue(device(devices, "deye").up());
}
@Test
void ignoresPropertiesItDoesNotKnow() throws Exception {
serve("/health", 200, """
{"status":"UP","version":"2","devices":[{"name":"x","status":"UP","lastSuccess":null,"lastError":null,"extra":1}]}""");
serve("/snapshot", 200, """
[{"name":"x","new":true,"readings":[{"database":"d","measurement":"m","tags":{},
"fields":{"a":1.0},"time":"2026-10-24T10:00:00Z","unit":"C"}]}]""");
assertEquals(1, client.snapshot().devices().get(0).measurements().size());
}
@Test
void failsWhenSnapshotAnswers503() throws Exception {
serve("/health", 200, resource("health-all-up.json"));
serve("/snapshot", 503, "[]");
assertThrows(IOException.class, client::snapshot);
}
@Test
void failsOnServerError() {
serve("/health", 500, "{\"error\":\"boom\"}");
assertThrows(IOException.class, client::snapshot);
}
@Test
void failsOnBadJson() {
serve("/health", 200, "<html>not json</html>");
assertThrows(IOException.class, client::snapshot);
}
@Test
void failsWhenTheGatewayIsSlowerThanTheTimeout() {
server.createContext("/health", exchange -> {
try {
Thread.sleep(2000);
} catch (InterruptedException ignored) {
// the client gave up; nothing to answer
}
exchange.close();
});
assertThrows(HttpTimeoutException.class, client::snapshot);
}
@Test
void failsWhenTheGatewayIsUnreachable() {
server.stop(0);
assertThrows(IOException.class, client::snapshot);
}
}
@@ -0,0 +1,266 @@
package home.backend.adapters.gateway;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
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;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Against a stub gateway on localhost; the bodies are the gateway's own examples (the contract). */
class GatewayHttpCommandsTest {
private static final ObjectMapper JSON = new ObjectMapper();
private static final Path CONTRACT = Path.of("../gateway/src/test/resources/commands");
private record Seen(String method, String path, String authorization, String requestedBy, String body) {
}
private final List<Seen> seen = new ArrayList<>();
private HttpServer server;
@BeforeEach
void startServer() throws IOException {
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
answerCommands();
server.start();
}
@AfterEach
void stopServer() {
server.stop(0);
}
private int status;
private String body = "{}";
private void serve(int status, String body) {
this.status = status;
this.body = body;
}
private void answerCommands() {
server.createContext("/commands", exchange -> {
seen.add(new Seen(exchange.getRequestMethod(), exchange.getRequestURI().getPath(),
exchange.getRequestHeaders().getFirst("Authorization"),
exchange.getRequestHeaders().getFirst("X-Requested-By"),
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
}
private GatewayHttpCommands commands(String token) {
return new GatewayHttpCommands(URI.create("http://127.0.0.1:" + server.getAddress().getPort()), token,
Duration.ofMillis(500));
}
private static String contract(String name) throws IOException {
return Files.readString(CONTRACT.resolve(name));
}
@Test
void sendsTheBoostWithTokenUserAndMinutes() throws Exception {
serve(200, contract("response-applied.json"));
var result = commands("s3cret-token").apply(new Command("cwu_economy", 45, Duration.ofHours(2), "anna"));
assertEquals(Status.APPLIED, result.status());
assertEquals(Instant.parse("2026-10-24T12:00:00Z"), result.restoreAt());
assertEquals(1, seen.size());
assertEquals("POST", seen.getFirst().method());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
assertEquals("anna", seen.getFirst().requestedBy());
assertEquals(contractJson("request-boost.json"), contractJson(seen.getFirst().body()));
}
@Test
void aHeldChangeSendsTheEndAsUntil() throws Exception {
serve(200, contract("response-applied.json"));
commands("s3cret-token").apply(new Command("cwu_comfort", 10, null, Instant.parse("2026-11-02T17:00:00Z"), "phone"));
assertEquals(contractJson("request-held.json"), contractJson(seen.getFirst().body()));
}
@Test
void awayHasNoMinutes() throws Exception {
serve(200, contract("response-applied.json"));
commands("s3cret-token").apply(new Command("cwu_comfort", 10, null, "phone"));
assertEquals(contractJson("request-away.json"), contractJson(seen.getFirst().body()));
}
@Test
void rejectionIsAVerdictNotAnError() throws Exception {
serve(422, contract("response-rejected.json"));
var result = commands("s3cret-token").apply(new Command("cwu_economy", 60, null, "phone"));
assertEquals(Status.REJECTED, result.status());
assertEquals("cwu_economy allows 5–48 in steps of 1, not 60", result.message());
assertNull(result.restoreAt());
}
@Test
void failedWriteIsAVerdict() throws Exception {
serve(502, "{\"status\":\"FAILED\",\"message\":\"read back 8, expected 45\",\"restoreAt\":null}");
assertEquals(Status.FAILED, commands("s3cret-token").apply(new Command("cwu_economy", 45, null, "phone")).status());
}
@Test
void readsSetpointsWithRestores() throws Exception {
serve(200, contract("setpoints.json"));
var state = commands("s3cret-token").setpoints();
assertEquals("GET", seen.getFirst().method());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
assertEquals(4, state.setpoints().size());
assertEquals(48, state.setpoints().get(2).max());
assertEquals(1, state.restores().size());
assertEquals(8, state.restores().getFirst().originalValue());
}
@Test
void anUntilChangedRestoreHasNoDue() throws Exception {
serve(200, "{\"setpoints\":[],\"restores\":[{\"setpoint\":\"cwu_comfort\",\"register\":4144,"
+ "\"originalValue\":45,\"appliedValue\":10,\"leaseUntil\":\"2026-10-24T10:15:00Z\"}]}");
assertNull(commands("s3cret-token").setpoints().restores().getFirst().due());
}
@Test
void restorePostsTheSetpointWithTokenAndUser() throws Exception {
serve(200, contract("response-restored.json"));
var result = commands("s3cret-token").restore("cwu_economy", "anna");
assertEquals(Outcome.RESTORED, result.outcome());
assertEquals("POST", seen.getFirst().method());
assertEquals("/commands/restore", seen.getFirst().path());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
assertEquals("anna", seen.getFirst().requestedBy());
assertEquals(contractJson("request-restore.json"), contractJson(seen.getFirst().body()));
}
@Test
void aConflictOnRestoreIsAVerdictNotAnError() throws Exception {
serve(409, contract("response-restore-conflict.json"));
assertEquals(Outcome.CONFLICT, commands("s3cret-token").restore("cwu_economy", "anna").outcome());
}
@Test
void everyRestoreStatusOfTheGatewayIsAVerdict() throws Exception {
var expected = java.util.Map.of(404, Outcome.NOT_PENDING, 429, Outcome.TOO_SOON, 502, Outcome.FAILED);
for (var entry : expected.entrySet()) {
serve(entry.getKey(), "{\"outcome\":\"" + entry.getValue() + "\",\"message\":\"x\"}");
assertEquals(entry.getValue(), commands("s3cret-token").restore("cwu_economy", "anna").outcome());
}
}
@Test
void restoreFailsWithoutAVerdictOrToken() {
serve(503, "{\"error\":\"x\"}");
assertThrows(IOException.class, () -> commands("s3cret-token").restore("cwu_economy", "anna"));
var seenBefore = seen.size();
assertThrows(IOException.class, () -> commands(null).restore("cwu_economy", "anna"));
assertEquals(seenBefore, seen.size());
}
@Test
void renewPostsToRenewWithTheTokenAndReturnsHowManyWereExtended() throws Exception {
serve(200, "{\"renewed\":2}");
assertEquals(2, commands("s3cret-token").renewLeases());
assertEquals("POST", seen.getFirst().method());
assertEquals("/commands/renew", seen.getFirst().path());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
}
@Test
void renewFailsOnAnyOtherStatusOrWithoutToken() {
serve(503, "{\"error\":\"x\"}");
assertThrows(IOException.class, () -> commands("s3cret-token").renewLeases());
var seenBefore = seen.size();
assertThrows(IOException.class, () -> commands(null).renewLeases());
assertEquals(seenBefore, seen.size());
}
@Test
void aSetpointNotReadYetStaysNullInsteadOfZero() throws Exception {
serve(200, "{\"setpoints\":[{\"name\":\"co_comfort\",\"register\":4132,\"min\":5,\"max\":25,\"step\":1,"
+ "\"value\":null}],\"restores\":[]}");
assertNull(commands("s3cret-token").setpoints().setpoints().getFirst().value());
}
@Test
void otherStatusesAreNoVerdict() throws Exception {
for (int status : List.of(401, 404, 500, 503)) {
serve(status, "{\"error\":\"x\"}");
assertThrows(IOException.class, () -> commands("s3cret-token").setpoints(), "GET " + status);
assertThrows(IOException.class,
() -> commands("s3cret-token").apply(new Command("cwu_economy", 45, null, "phone")), "POST " + status);
}
}
@Test
void unreadableAnswerIsNoVerdict() {
serve(200, "<html>proxy</html>");
assertThrows(IOException.class,
() -> commands("s3cret-token").apply(new Command("cwu_economy", 45, null, "phone")));
}
@Test
void withoutTokenNothingIsSent() {
serve(200, "{}");
var commands = commands(null);
assertThrows(IOException.class, () -> commands.setpoints());
assertThrows(IOException.class, () -> commands.apply(new Command("cwu_economy", 45, null, "phone")));
assertTrue(seen.isEmpty());
}
@Test
void unreachableGatewayIsAnIOException() {
server.stop(0);
assertThrows(IOException.class, () -> commands("s3cret-token").setpoints());
}
/** A contract file by name, or JSON text as is, parsed so that formatting does not matter. */
private static JsonNode contractJson(String nameOrJson) throws IOException {
return JSON.readTree(nameOrJson.endsWith(".json") ? contract(nameOrJson) : nameOrJson);
}
}
@@ -0,0 +1,670 @@
package home.backend.adapters.http;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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.GatewayCommands;
import home.backend.application.SetpointService;
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.HistoryService;
import home.backend.application.PiSensorService;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import home.backend.domain.HistoryView.Point;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.HashMap;
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 ApiServerTest {
private static final ObjectMapper JSON = new ObjectMapper();
private static final Instant HEALTH_TIME = Instant.parse("2026-10-23T22:14:00Z");
private final HttpClient client = HttpClient.newHttpClient();
private GatewaySnapshot snapshot;
private boolean gatewayDown;
private boolean historyDown;
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 int answersBeforeDrop;
private ApiServer server;
@BeforeEach
void start() throws IOException {
snapshot = exampleSnapshot();
GatewayClient gateway = () -> {
if (gatewayDown) {
throw new IOException("tunnel down");
}
if (gatewayBug != null) {
throw gatewayBug;
}
return snapshot;
};
HistoryRepository history = (series, from, to, max) -> {
if (historyDown) {
throw new IOException("influx down");
}
return List.of(new Point(Instant.parse("2026-10-23T10:00:00Z"), 14.1),
new Point(Instant.parse("2026-10-23T11:00:00Z"), 38.5),
new Point(Instant.parse("2026-10-23T12:00:00Z"), 44.2));
};
verdict = Status.APPLIED;
restoreOutcome = Outcome.RESTORED;
answersBeforeDrop = Integer.MAX_VALUE;
GatewayCommands commands = new GatewayCommands() {
@Override
public CommandResult apply(Command command) throws IOException {
if (commandsDown || sent.size() >= answersBeforeDrop) {
throw new IOException("tunnel down");
}
sent.add(command);
return switch (verdict) {
case APPLIED -> new CommandResult(verdict, "cwu_economy 8 → 45, restore at 2026-10-24T12:00:00Z",
command.duration() == null ? null : Instant.parse("2026-10-24T12:00:00Z"));
case REJECTED -> new CommandResult(verdict, "cwu_economy allows 5–48 in steps of 1, not 60", null);
case FAILED -> new CommandResult(verdict, "read back 8, expected 45", null);
};
}
@Override
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
if (commandsDown) {
throw new IOException("tunnel down");
}
restored.add(setpoint + " by " + requestedBy);
return new RestoreResult(restoreOutcome, "restored to 8");
}
@Override
public SetpointState setpoints() throws IOException {
if (commandsDown) {
throw new IOException("tunnel down");
}
return new SetpointState(List.of(new Setpoint("co_economy", 4131, 5, 25, 1, 18),
new Setpoint("co_comfort", 4132, 5, 25, 1, 19),
new Setpoint("cwu_economy", 4143, 5, 48, 1, 8),
new Setpoint("cwu_comfort", 4144, 5, 48, 1, 45)),
List.of(new Restore("cwu_economy", 4143, 8, 45, Instant.parse("2026-10-24T12:00:00Z"))));
}
@Override
public int renewLeases() {
return 0;
}
};
Clock clock = Clock.fixed(HEALTH_TIME, ZoneOffset.UTC);
server = new ApiServer(0, new HeatPumpService(gateway, clock), new EnergyService(gateway, clock),
new EnergyDetailsService(gateway, clock),
new HealthService(gateway, clock, "v1.3.0"), new HistoryService(history, clock),
new SetpointService(commands, PresetFile.load(null)), new PiSensorService(gateway, clock));
server.start();
}
@AfterEach
void stop() {
server.stop();
}
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,
"pump_outlet_temp", 19.7, "outdoor_avg_temp", 4.8, "target_room_temp", 18.0));
pump.putAll(Map.of("target_co_temp", 31.0, "target_cwu_temp", 8.0, "pump_mode", 0.0,
"output_compressor", 0.0, "output_heater", 0.0, "alarm", 0.0,
"setpoint_co_economy", 18.0, "setpoint_co_comfort", 19.0, "setpoint_cwu_economy", 8.0));
pump.putAll(Map.of("setpoint_cwu_comfort", 45.0, "output_co", 0.0, "output_buffer", 0.0,
"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,
"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("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))),
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("bme280", true, energyTime, null,
List.of(new Measurement("measurement", "MEASUREMENT",
Map.of("temperature", 20.17, "humidity", 45.82, "pressure", 985.8), energyTime)))));
}
private HttpResponse<String> get(String path) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + path))
.build();
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);
}
}
private void assertMatchesExample(String path, String example) throws Exception {
HttpResponse<String> response = get(path);
assertEquals(200, response.statusCode());
assertEquals("application/json", response.headers().firstValue("Content-Type").orElseThrow());
assertEquals("no-store", response.headers().firstValue("Cache-Control").orElseThrow());
assertEquals(example(example), JSON.readTree(response.body()));
}
@Test
void heatPumpMatchesExample() throws Exception {
assertMatchesExample("/api/heat-pump", "heat-pump");
}
@Test
void energyMatchesExample() throws Exception {
assertMatchesExample("/api/energy", "energy");
}
@Test
void energyDetailsMatchExample() throws Exception {
assertMatchesExample("/api/energy/details", "energy-details");
}
@Test
void healthMatchesExample() throws Exception {
snapshot = new GatewaySnapshot("v1.2.1", List.of(snapshot.devices().get(0),
new DeviceSnapshot("deye", false, null, "connect timed out", List.of())));
assertMatchesExample("/api/health", "health");
}
@Test
void heatPumpWithoutReadingMatchesUnreadExample() throws Exception {
snapshot = new GatewaySnapshot("v1.2.1", List.of());
assertMatchesExample("/api/heat-pump", "heat-pump-unread");
}
@Test
void staleDeyeMatchesExample() throws Exception {
Instant old = Instant.parse("2026-10-23T21:00:00Z");
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
Measurement deye = devices.get(1).measurements().get(0);
devices.set(1, new DeviceSnapshot("deye", true, old, null,
List.of(new Measurement("home", "deye", deye.fields(), old))));
snapshot = new GatewaySnapshot("v1.2.1", devices);
assertMatchesExample("/api/energy", "energy-deye-stale");
}
@Test
void 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 historyRangeDefaultsTo24Hours() throws Exception {
assertEquals(200, get("/api/history?series=cwu_temp").statusCode());
}
@Test
void unreachableGatewayIs503WithJsonError() throws Exception {
gatewayDown = true;
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());
}
}
@Test
void healthStaysOkWhenGatewayIsUnreachable() throws Exception {
gatewayDown = true;
HttpResponse<String> response = get("/api/health");
assertEquals(200, response.statusCode());
assertEquals(false, JSON.readTree(response.body()).get("gatewayReachable").asBoolean());
}
@Test
void unknownSeriesOrRangeIs400() throws Exception {
assertEquals(400, get("/api/history?series=nope").statusCode());
assertEquals(400, get("/api/history").statusCode());
assertEquals(400, get("/api/history?series=cwu_temp&range=30d").statusCode());
}
@Test
void malformedQueryIs400NotADroppedConnection() throws Exception {
for (String path : List.of("/api/history?series=%", "/api/history?series=%zz&range=24h")) {
assertEquals(400, rawGet(path), path);
}
}
@Test
void unusableGatewayPayloadIs502() throws Exception {
for (RuntimeException bug : List.of(new IllegalStateException("Duplicate key deye"),
new NullPointerException("status"))) {
gatewayBug = bug;
for (String path : List.of("/api/heat-pump", "/api/energy", "/api/health")) {
HttpResponse<String> response = get(path);
assertEquals(502, response.statusCode(), path);
assertEquals("bad gateway response", JSON.readTree(response.body()).get("error").asText());
}
}
}
@Test
void influxFailureIs502() throws Exception {
historyDown = true;
assertEquals(502, get("/api/history?series=cwu_temp&range=7d").statusCode());
}
private static final String[] BROWSER_HEADERS = {"Content-Type", "application/json", "X-Requested-With", "home-pump"};
/** A POST as the web UI sends it; extra headers come after the defaults. */
private HttpResponse<String> post(String path, String body, String... headers) throws Exception {
var all = new ArrayList<>(List.of(BROWSER_HEADERS));
all.addAll(List.of(headers));
return rawPost(path, body, all.toArray(String[]::new));
}
private HttpResponse<String> rawPost(String path, String body, String... headers) throws Exception {
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + path))
.POST(HttpRequest.BodyPublishers.ofString(body));
if (headers.length > 0) {
request.headers(headers);
}
return client.send(request.build(), HttpResponse.BodyHandlers.ofString());
}
private static final String BOOST = "{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":2}";
@Test
void setpointsMatchExample() throws Exception {
assertMatchesExample("/api/setpoints", "setpoints");
}
@Test
void appliedBoostMatchesExampleAndRestoresAfterTwoHours() throws Exception {
HttpResponse<String> response = post("/api/setpoints", BOOST);
assertEquals(200, response.statusCode());
assertEquals(example("setpoints-applied"), JSON.readTree(response.body()));
assertEquals(List.of(new Command("cwu_economy", 45, java.time.Duration.ofHours(2), "phone")), sent);
}
@Test
void restoreMatchesExampleAndAsksForTheUsersSetpoint() throws Exception {
HttpResponse<String> response = post("/api/setpoints/cwu_economy/restore", "{}", "Remote-User", "anna");
assertEquals(200, response.statusCode());
assertEquals(example("setpoint-restored"), JSON.readTree(response.body()));
assertEquals(List.of("cwu_economy by anna"), restored);
}
@Test
void restoreStatusFollowsTheGatewaysVerdict() throws Exception {
var expected = Map.of(Outcome.NOT_PENDING, 404, Outcome.CONFLICT, 409, Outcome.TOO_SOON, 429,
Outcome.FAILED, 502);
for (var entry : expected.entrySet()) {
restoreOutcome = entry.getKey();
assertEquals(entry.getValue(), post("/api/setpoints/cwu_economy/restore", "{}").statusCode());
}
}
@Test
void restoreWithTheGatewayDownIs503() throws Exception {
commandsDown = true;
assertEquals(503, post("/api/setpoints/cwu_economy/restore", "{}").statusCode());
}
@Test
void restoreIsGuardedLikeEveryWrite() throws Exception {
String path = "/api/setpoints/cwu_economy/restore";
assertEquals(415, rawPost(path, "{}", "X-Requested-With", "home-pump").statusCode());
assertEquals(403, rawPost(path, "{}", "Content-Type", "application/json").statusCode());
assertEquals(403, post(path, "{}", "Origin", "https://evil.example").statusCode());
assertEquals(405, get(path).statusCode());
assertTrue(restored.isEmpty());
}
@Test
void otherPathsUnderSetpointsAre404() throws Exception {
for (String path : List.of("/api/setpoints/", "/api/setpoints/cwu_economy",
"/api/setpoints/cwu_economy/restore/x", "/api/setpoints/CWU/restore", "/api/setpoints/a-b/restore")) {
assertEquals(404, post(path, "{}").statusCode(), path);
}
assertTrue(restored.isEmpty());
}
@Test
void rejectedIs422AndMatchesExample() throws Exception {
verdict = Status.REJECTED;
HttpResponse<String> response = post("/api/setpoints", BOOST);
assertEquals(422, response.statusCode());
assertEquals(example("setpoints-rejected"), JSON.readTree(response.body()));
}
@Test
void failedWriteIs502() throws Exception {
verdict = Status.FAILED;
assertEquals(502, post("/api/setpoints", BOOST).statusCode());
}
@Test
void forwardedUserIsPassedOnAndOddNamesFallBack() throws Exception {
post("/api/setpoints", BOOST, "Remote-User", "anna.k@example.pl");
post("/api/setpoints", BOOST, "Remote-User", "anna smith");
post("/api/setpoints", BOOST);
assertEquals(List.of("anna.k@example.pl", "phone", "phone"), sent.stream().map(Command::requestedBy).toList());
}
@Test
void badSetpointRequestsAre400AndSendNothing() throws Exception {
for (String body : List.of("", "not json", "[]", "{}", "{\"setpoint\":\"cwu_economy\"}",
"{\"setpoint\":\"cwu_economy\",\"value\":45.5}",
"{\"setpoint\":\"cwu_economy\",\"value\":\"45\"}",
"{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":0}",
"{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":-1}",
"{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":72.5}",
"{\"setpoint\":\"cwu_economy\",\"value\":45,\"minutes\":5}")) {
assertEquals(400, post("/api/setpoints", body).statusCode(), body);
}
assertTrue(sent.isEmpty());
}
@Test
void presetResultsMatchExample() throws Exception {
HttpResponse<String> response = post("/api/presets/away", awayRequest());
assertEquals(200, response.statusCode());
JsonNode body = JSON.readTree(response.body());
assertEquals(example("setpoints-preset-applied").get("results").size(), body.get("results").size());
assertEquals("APPLIED", body.get("status").asText());
assertEquals(List.of("cwu_comfort", "co_comfort"), sent.stream().map(Command::setpoint).toList());
}
@Test
void presetCutShortByALostLinkKeepsTheResultsSoFar() throws Exception {
answersBeforeDrop = 1;
HttpResponse<String> response = post("/api/presets/away", awayRequest());
assertEquals(502, response.statusCode());
assertEquals(example("setpoints-preset-partial"), JSON.readTree(response.body()));
}
@Test
void awayPresetSendsTheChosenEndHeldInEachChange() throws Exception {
post("/api/presets/away", awayRequest());
var end = java.time.Instant.parse("2026-11-02T17:00:00Z");
assertEquals(List.of(new Command("cwu_comfort", 10, null, end, "phone"),
new Command("co_comfort", 15, null, end, "phone")), sent);
}
@Test
void awayWithOwnValuesSendsThem() throws Exception {
var response = post("/api/presets/away", example("preset-away-values-request").toString());
assertEquals(200, response.statusCode());
var end = java.time.Instant.parse("2026-11-02T17:00:00Z");
assertEquals(List.of(new Command("cwu_comfort", 12, null, end, "phone"),
new Command("co_comfort", 16, null, end, "phone")), sent);
}
@Test
void ownValuesForOtherSetpointsOrOutsideTheLimitsAre400AndSendNothing() throws Exception {
String until = "\"until\":\"2026-11-02T18:00:00Z\"";
for (String values : List.of("{\"cwu_economy\":6}", "{\"co_comfort\":4}", "{\"co_comfort\":26}",
"{\"co_comfort\":null}", "{\"co_comfort\":15.5}", "{\"co_comfort\":\"15\"}", "[]")) {
assertEquals(400, post("/api/presets/away", "{" + until + ",\"values\":" + values + "}").statusCode(),
values);
}
assertTrue(sent.isEmpty());
}
@Test
void ownValueThatDoesNotLowerAHeldSetpointIs422() throws Exception {
var body = "{\"until\":\"2026-11-02T18:00:00Z\",\"values\":{\"co_comfort\":19}}";
assertEquals(422, post("/api/presets/away", body).statusCode());
assertTrue(sent.isEmpty());
}
@Test
void badOrMissingEndTimesAre400AndSendNothing() throws Exception {
for (String body : List.of("", "{}", "{\"until\":null}", "{\"until\":\"tomorrow\"}", "{\"until\":5}",
"{\"until\":\"2026-11-02T18:00:00\"}", "not json", "{\"until\":\"2026-11-02T18:00:00Z\",\"x\":1}")) {
assertEquals(400, post("/api/presets/away", body).statusCode(), body);
}
assertEquals(400, post("/api/presets/cwu-now", awayRequest()).statusCode());
assertTrue(sent.isEmpty());
}
@Test
void presetWithoutAnEndStillTakesNoBodyOrAnEmptyObject() throws Exception {
assertEquals(200, post("/api/presets/cwu-now", "").statusCode());
assertEquals(200, post("/api/presets/cwu-now", "{}").statusCode());
}
@Test
void unknownPresetIs404() throws Exception {
assertEquals(404, post("/api/presets/nope", "").statusCode());
assertTrue(sent.isEmpty());
}
@Test
void presetOutsideLimitsIs422() throws Exception {
verdict = Status.REJECTED;
assertEquals(422, post("/api/presets/away", awayRequest()).statusCode());
}
@Test
void unreachableGatewayOnSetpointsIs503() throws Exception {
commandsDown = true;
assertEquals(503, get("/api/setpoints").statusCode());
assertEquals(503, post("/api/setpoints", BOOST).statusCode());
assertEquals(503, post("/api/presets/away", awayRequest()).statusCode());
}
@Test
void writesWithoutJsonContentTypeAre415() throws Exception {
for (String path : List.of("/api/setpoints", "/api/presets/away")) {
assertEquals(415, rawPost(path, BOOST, "X-Requested-With", "home-pump").statusCode(), path);
assertEquals(415, rawPost(path, BOOST, "X-Requested-With", "home-pump", "Content-Type", "text/plain")
.statusCode(), path);
}
assertTrue(sent.isEmpty());
}
@Test
void jsonContentTypeMayCarryACharset() throws Exception {
assertEquals(200, rawPost("/api/setpoints", BOOST, "X-Requested-With", "home-pump",
"Content-Type", "application/json; charset=utf-8").statusCode());
}
@Test
void writesWithoutTheCustomHeaderAre403() throws Exception {
for (String path : List.of("/api/setpoints", "/api/presets/away")) {
assertEquals(403, rawPost(path, BOOST, "Content-Type", "application/json").statusCode(), path);
assertEquals(403, rawPost(path, BOOST, "Content-Type", "application/json",
"X-Requested-With", "XMLHttpRequest").statusCode(), path);
}
assertTrue(sent.isEmpty());
}
@Test
void writesFromAnotherOriginAre403() throws Exception {
for (String path : List.of("/api/setpoints", "/api/presets/away")) {
for (String origin : List.of("https://evil.example", "null", "http://localhost:1")) {
assertEquals(403, post(path, BOOST, "Origin", origin).statusCode(), origin);
}
}
assertTrue(sent.isEmpty());
}
@Test
void writesFromTheSameOriginAreAccepted() throws Exception {
assertEquals(200, post("/api/setpoints", BOOST, "Origin", "http://localhost:" + server.port()).statusCode());
}
@Test
void setpointMethodsAreChecked() throws Exception {
assertEquals(405, post("/api/history", "").statusCode());
assertEquals(405, get("/api/presets/away").statusCode());
HttpRequest put = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + "/api/setpoints"))
.PUT(HttpRequest.BodyPublishers.noBody()).build();
assertEquals(405, client.send(put, HttpResponse.BodyHandlers.ofString()).statusCode());
}
@Test
void apiAcceptsOnlyGet() throws Exception {
HttpRequest post = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + "/api/health"))
.POST(HttpRequest.BodyPublishers.noBody()).build();
assertEquals(405, client.send(post, HttpResponse.BodyHandlers.ofString()).statusCode());
}
@Test
void everyResponseCarriesTheSecurityHeaders() throws Exception {
var paths = List.of("/", "/index.html", "/missing.css", "/api/health", "/api/nothing", "/api/history");
for (String path : paths) {
assertSecurityHeaders(get(path), path);
}
assertSecurityHeaders(post("/api/health", ""), "405");
assertSecurityHeaders(post("/api/setpoints", "{"), "400");
assertSecurityHeaders(rawPost("/api/setpoints", BOOST), "403");
}
private static void assertSecurityHeaders(HttpResponse<String> response, String what) {
var headers = response.headers();
assertEquals("default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
+ "font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
headers.firstValue("Content-Security-Policy").orElse(null), what);
assertEquals("nosniff", headers.firstValue("X-Content-Type-Options").orElse(null), what);
assertEquals("no-referrer", headers.firstValue("Referrer-Policy").orElse(null), what);
}
@Test
void unknownApiPathIs404() throws Exception {
assertEquals(404, get("/api/nothing").statusCode());
}
@Test
void apiRoutesMatchTheExactPathOnly() throws Exception {
for (String path : List.of("/api/heat-pumpX", "/api/heat-pump/", "/api/heat-pump/x", "/api/energy2",
"/api/healthz", "/api/history2?series=cwu_temp", "/api/setpointsX")) {
assertEquals(404, get(path).statusCode(), path);
}
assertEquals(404, post("/api/setpointsX", BOOST).statusCode());
assertEquals(200, get("/api/heat-pump?x=1").statusCode());
}
@Test
void stopClosesThePort() {
server.stop();
assertThrows(IOException.class, () -> get("/api/health"));
}
@Test
void requestBodiesOver4KbAre413AndSendNothing() throws Exception {
String padding = " ".repeat(4096 - BOOST.length());
assertEquals(200, post("/api/setpoints", BOOST + padding).statusCode());
sent.clear();
assertEquals(413, post("/api/setpoints", BOOST + padding + " ").statusCode());
assertEquals(413, post("/api/presets/away", "x".repeat(5000)).statusCode());
assertEquals(0, sent.size());
}
@Test
void answersOnlyTheApi() throws Exception {
// The web UI is the frontend container's (ADR-010); the backend serves no files.
for (String path : List.of("/", "/index.html", "/assets/index.js", "/api", "/api/")) {
assertEquals(404, get(path).statusCode(), path);
}
}
/** The JDK client normalises dot segments, so send the request line as is. */
private int rawGet(String path) throws IOException {
try (Socket socket = new Socket("localhost", server.port())) {
socket.getOutputStream().write(("GET " + path + " HTTP/1.1\r\nHost: localhost\r\n"
+ "Connection: close\r\n\r\n").getBytes(StandardCharsets.US_ASCII));
String statusLine = new String(socket.getInputStream().readAllBytes(), StandardCharsets.US_ASCII)
.lines().findFirst().orElseThrow();
return Integer.parseInt(statusLine.split(" ")[1]);
}
}
}
@@ -0,0 +1,244 @@
package home.backend.adapters.influx;
import com.sun.net.httpserver.HttpServer;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
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.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InfluxHistoryRepositoryTest {
private static final Instant FROM = Instant.parse("2026-10-24T00:00:00Z");
private static final Instant TO = Instant.parse("2026-10-24T06:00:00Z");
private static final Duration TIMEOUT = Duration.ofSeconds(5);
private HttpServer server;
private final List<Map<String, String>> requests = Collections.synchronizedList(new ArrayList<>());
private volatile CountDownLatch rendezvous = new CountDownLatch(0);
private final AtomicInteger metTogether = new AtomicInteger();
private final Map<String, String> bodyByField = new HashMap<>();
private int status = 200;
private String failingField = "";
private String defaultBody = "{\"results\":[{\"statement_id\":0}]}";
@BeforeEach
void startServer() throws IOException {
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
// Concurrent handlers: the repository asks for the parts of a series at the same time.
server.setExecutor(Executors.newCachedThreadPool());
server.createContext("/query", exchange -> {
Map<String, String> params = params(exchange.getRequestURI().getRawQuery());
requests.add(params);
rendezvous.countDown();
try {
if (rendezvous.await(5, TimeUnit.SECONDS)) {
metTogether.incrementAndGet();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
String body = bodyByField.entrySet().stream()
.filter(e -> params.get("q").contains("\"" + e.getKey() + "\""))
.map(Map.Entry::getValue).findFirst().orElse(defaultBody);
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
boolean fails = !failingField.isEmpty() && params.get("q").contains("\"" + failingField + "\"");
exchange.sendResponseHeaders(fails ? 500 : status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
server.start();
}
@AfterEach
void stopServer() {
server.stop(0);
}
@Test
void asksTheLegacyDatabaseForATemperatureWithQuotedFieldName() throws IOException {
defaultBody = fixture("influx-query-cwu-6h.json");
List<Point> points = repository(TIMEOUT).history(Series.CWU_TEMP, FROM, TO, 360);
assertEquals(1, requests.size());
assertEquals("Dom_Pompa", requests.get(0).get("db"));
assertEquals("ms", requests.get(0).get("epoch"));
assertEquals("SELECT mean(\"Temp cwu\") FROM \"POMPA\" WHERE time >= 1792800000000ms"
+ " AND time < 1792821600000ms GROUP BY time(1m) fill(none)", requests.get(0).get("q"));
assertEquals(7, points.size());
assertEquals(new Point(Instant.ofEpochMilli(1792771200000L), 43.76666666666667), points.get(0));
}
@Test
void asksTheElectricityDatabaseForGridPower() throws IOException {
repository(TIMEOUT).history(Series.GRID_POWER, FROM, TO, 72);
assertEquals("electricity", requests.get(0).get("db"));
assertEquals("SELECT mean(\"total_power_active\") FROM \"PRAD2\" WHERE time >= 1792800000000ms"
+ " AND time < 1792821600000ms GROUP BY time(5m) fill(none)", requests.get(0).get("q"));
}
@Test
void roundsTheStepUpToWholeMinutes() throws IOException {
repository(TIMEOUT).history(Series.LOAD_POWER, FROM, TO, 100);
assertTrue(requests.get(0).get("q").contains("GROUP BY time(4m)"), requests.get(0).get("q"));
}
@Test
void sumsPvPowerAcrossMeasurementsAndKeepsBucketsWithOnePart() throws IOException {
bodyByField.put("pv1_power_w", series(1000, 100.0, 2000, 200.0));
bodyByField.put("pv2_power_w", series(1000, 50.0));
bodyByField.put("pv_power_w", series(2000, 5.0, 3000, 7.0));
List<Point> points = repository(TIMEOUT).history(Series.PV_POWER, FROM, TO, 72);
assertEquals(3, requests.size());
assertEquals(List.of(
new Point(Instant.ofEpochMilli(1000), 150.0),
new Point(Instant.ofEpochMilli(2000), 205.0),
new Point(Instant.ofEpochMilli(3000), 7.0)), points);
}
@Test
void asksForTheThreePvPartsAtTheSameTime() throws IOException {
rendezvous = new CountDownLatch(3);
repository(TIMEOUT).history(Series.PV_POWER, FROM, TO, 72);
// A handler passes the latch only when all three requests are in flight together.
assertEquals(3, metTogether.get());
}
@Test
void oneFailingPartFailsTheWholeSeries() {
failingField = "pv2_power_w";
assertThrows(IOException.class, () -> repository(TIMEOUT).history(Series.PV_POWER, FROM, TO, 72));
}
@Test
void sortsPointsByTime() throws IOException {
defaultBody = series(3000, 3.0, 1000, 1.0, 2000, 2.0);
List<Point> points = repository(TIMEOUT).history(Series.BATTERY_SOC, FROM, TO, 72);
assertEquals(List.of(1000L, 2000L, 3000L), points.stream().map(p -> p.t().toEpochMilli()).toList());
}
@Test
void noDataGivesAnEmptyList() throws IOException {
defaultBody = fixture("influx-query-empty.json");
assertEquals(List.of(), repository(TIMEOUT).history(Series.PV_POWER, FROM, TO, 72));
}
@Test
void topLevelErrorIsAnIOException() throws IOException {
defaultBody = fixture("influx-query-error.json");
IOException e = assertThrows(IOException.class,
() -> repository(TIMEOUT).history(Series.CWU_TEMP, FROM, TO, 72));
assertTrue(e.getMessage().contains("error parsing query"), e.getMessage());
}
@Test
void perResultErrorIsAnIOException() {
defaultBody = "{\"results\":[{\"statement_id\":0,\"error\":\"database not found: electricity\"}]}";
IOException e = assertThrows(IOException.class,
() -> repository(TIMEOUT).history(Series.GRID_POWER, FROM, TO, 72));
assertTrue(e.getMessage().contains("database not found"), e.getMessage());
}
@Test
void httpErrorIsAnIOException() {
status = 503;
IOException e = assertThrows(IOException.class,
() -> repository(TIMEOUT).history(Series.CWU_TEMP, FROM, TO, 72));
assertTrue(e.getMessage().contains("503"), e.getMessage());
}
@Test
void badJsonIsAnIOException() {
defaultBody = "<html>not json";
assertThrows(IOException.class, () -> repository(TIMEOUT).history(Series.CWU_TEMP, FROM, TO, 72));
}
@Test
void slowServerTimesOut() {
server.removeContext("/query");
server.createContext("/query", exchange -> {
try {
Thread.sleep(1000);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
exchange.close();
});
assertThrows(IOException.class,
() -> repository(Duration.ofMillis(200)).history(Series.CWU_TEMP, FROM, TO, 72));
}
@Test
void rejectsAnEmptyRange() {
assertThrows(IllegalArgumentException.class,
() -> repository(TIMEOUT).history(Series.CWU_TEMP, FROM, FROM, 72));
}
private InfluxHistoryRepository repository(Duration timeout) {
URI base = URI.create("http://127.0.0.1:" + server.getAddress().getPort());
return new InfluxHistoryRepository(new InfluxSettings(base, timeout));
}
private static String series(Object... timeAndValue) {
StringBuilder rows = new StringBuilder();
for (int i = 0; i < timeAndValue.length; i += 2) {
rows.append(i == 0 ? "" : ",").append('[').append(timeAndValue[i]).append(',')
.append(timeAndValue[i + 1]).append(']');
}
return "{\"results\":[{\"statement_id\":0,\"series\":[{\"name\":\"m\",\"columns\":[\"time\",\"mean\"],"
+ "\"values\":[" + rows + "]}]}]}";
}
private static String fixture(String name) throws IOException {
return Files.readString(Path.of("src/test/resources/influx", name));
}
private static Map<String, String> params(String rawQuery) {
Map<String, String> params = new HashMap<>();
for (String pair : rawQuery.split("&")) {
String[] kv = pair.split("=", 2);
params.put(kv[0], URLDecoder.decode(kv[1], StandardCharsets.UTF_8));
}
return params;
}
}
@@ -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());
}
}
@@ -0,0 +1,465 @@
package home.backend.application;
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;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
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;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ServicesTest {
private static final Instant NOW = Instant.parse("2026-10-24T12:00:00Z");
private static final Instant READ = Instant.parse("2026-10-24T11:59:50Z");
private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
private static DeviceSnapshot device(String name, String database, String measurement,
Map<String, Double> fields) {
return new DeviceSnapshot(name, true, READ, null,
List.of(new Measurement(database, measurement, fields, READ)));
}
private static GatewayClient gateway(DeviceSnapshot... devices) {
return () -> new GatewaySnapshot("v1.2.1", List.of(devices));
}
private static GatewayClient unreachable() {
return () -> {
throw new IOException("tunnel down");
};
}
private static HeatPumpView heatPump(Map<String, Double> fields) throws IOException {
return new HeatPumpService(gateway(device("heat-pump", "Dom_Pompa", "heat_pump", fields)), CLOCK)
.current();
}
@Test
void heatPumpStateComesFromPumpMode() throws IOException {
assertEquals(State.IDLE, heatPump(Map.of("pump_mode", 0.0)).state());
assertEquals(State.HEATING_CO, heatPump(Map.of("pump_mode", 1.0)).state());
assertEquals(State.HEATING_CWU, heatPump(Map.of("pump_mode", 2.0)).state());
assertEquals(State.HEATING_BOTH, heatPump(Map.of("pump_mode", 3.0)).state());
}
@Test
void heatPumpMapsOutputsAlarmAndTime() throws IOException {
HeatPumpView view = heatPump(Map.of("output_compressor", 66.0, "output_heater", 100.0,
"output_cwu", 100.0, "output_co", 0.0, "alarm", 4.0, "setpoint_cwu_economy", 8.0));
assertEquals(READ, view.time());
assertEquals(66, view.compressorPercent());
assertTrue(view.heaterOn());
assertTrue(view.alarm());
assertTrue(view.pumps().cwu());
assertFalse(view.pumps().co());
assertEquals(8, view.setpoints().cwuEconomy());
}
@Test
void heatPumpAlarmCodeRaisesTheAlarmAndIsNamedInPolish() throws IOException {
HeatPumpView sensor = heatPump(Map.of("alarm", 0.0, "alarm_code", 3.0));
HeatPumpView pressure = heatPump(Map.of("alarm_code", 1003.0));
HeatPumpView unknown = heatPump(Map.of("alarm_code", 77.0));
HeatPumpView healthy = heatPump(Map.of("alarm", 0.0, "alarm_code", 0.0));
assertTrue(sensor.alarm());
assertEquals(3, sensor.alarmCode());
assertEquals("Usterka czujnika temperatury CO", sensor.alarmText());
assertEquals("Zadziałał presostat (ciśnienie)", pressure.alarmText());
assertEquals("Kod usterki 77", unknown.alarmText());
assertFalse(healthy.alarm());
assertNull(healthy.alarmText());
}
@Test
void heatPumpMissingFieldsAreNull() throws IOException {
HeatPumpView view = heatPump(Map.of());
assertNull(view.state());
assertNull(view.compressorPercent());
assertNull(view.temperatures().outdoor());
assertNull(view.targets().cwu());
assertNull(view.setpoints().coComfort());
assertFalse(view.alarm());
assertNull(view.alarmCode());
assertNull(view.alarmText());
}
@Test
void heatPumpTemperaturesDropBinaryNoiseOfTheGatewayScaling() throws IOException {
HeatPumpView view = heatPump(Map.of("indoor_temp", 212 * 0.1, "target_co_temp", 310 * 0.1,
"outdoor_temp", -43 * 0.1));
assertEquals(21.2, view.temperatures().indoor());
assertEquals(31.0, view.targets().co());
assertEquals(-4.3, view.temperatures().outdoor());
}
@Test
void heatPumpIgnoresMeasurementFromAnotherDatabase() throws IOException {
HeatPumpService service = new HeatPumpService(gateway(
device("heat-pump", "other", "heat_pump", Map.of("pump_mode", 1.0))), CLOCK);
HeatPumpView view = service.current();
assertNull(view.state());
assertNull(view.time());
assertTrue(view.stale());
}
@Test
void heatPumpWithoutAReadingHasNoTimeAndIsStale() throws IOException {
HeatPumpView view = new HeatPumpService(gateway(), CLOCK).current();
assertNull(view.time());
assertTrue(view.stale());
assertNull(view.state());
assertNull(view.temperatures().outdoor());
assertNull(view.setpoints().cwuComfort());
}
@Test
void heatPumpPumpModeOutOfRangeHasNoState() throws IOException {
assertNull(heatPump(Map.of("pump_mode", 4.0)).state());
assertNull(heatPump(Map.of("pump_mode", -1.0)).state());
}
@Test
void heatPumpIsFreshOnlyWhileUpAndNotOlderThanTenMinutes() throws IOException {
assertFalse(heatPump(Map.of("pump_mode", 0.0)).stale());
assertTrue(heatPumpRead(true, NOW.minusSeconds(601)).stale());
assertFalse(heatPumpRead(true, NOW.minusSeconds(600)).stale());
HeatPumpView down = heatPumpRead(false, READ);
assertTrue(down.stale());
assertEquals(READ, down.time());
}
private static HeatPumpView heatPumpRead(boolean up, Instant time) throws IOException {
var reading = new Measurement("Dom_Pompa", "heat_pump", Map.of("pump_mode", 0.0), time);
return new HeatPumpService(gateway(new DeviceSnapshot("heat-pump", up, time, null, List.of(reading))),
CLOCK).current();
}
@Test
void heatPumpPropagatesGatewayFailure() {
assertThrows(IOException.class, () -> new HeatPumpService(unreachable(), CLOCK).current());
}
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();
}
private static final Map<String, Double> DEYE = Map.of("pv1_power_w", 1000.0, "pv2_power_w", 500.0,
"pv_today_kwh", 10.0, "grid_power_w", -200.0, "battery_soc_pct", 80.0, "load_power_w", 900.0);
private static final Map<String, Double> FRONIUS = Map.of("pv_power_w", 2000.0, "energy_day_wh", 4400.0);
@Test
void energySumsPvOfBothInverters() throws IOException {
EnergyView view = energy(device("deye", "home", "deye", DEYE),
device("fronius", "home", "fronius", FRONIUS));
assertEquals(3500.0, view.pvPowerW());
assertEquals(14.4, view.today().pvKwh());
assertEquals(-200.0, view.gridPowerW());
assertEquals(80.0, view.batterySocPct());
assertNull(view.batteryPowerW());
}
@Test
void energyWithoutFroniusUsesDeyeOnly() throws IOException {
EnergyView view = energy(device("deye", "home", "deye", DEYE));
assertEquals(1500.0, view.pvPowerW());
assertEquals(10.0, view.today().pvKwh());
assertEquals(READ, view.time());
}
@Test
void energyWithoutDeyeKeepsFroniusPvAndNullsTheRest() throws IOException {
EnergyView view = energy(device("fronius", "home", "fronius", FRONIUS));
assertEquals(2000.0, view.pvPowerW());
assertEquals(4.4, view.today().pvKwh());
assertNull(view.gridPowerW());
assertNull(view.today().loadKwh());
}
@Test
void energyWithoutSourcesHasNoTimeAndBothSourcesStale() throws IOException {
EnergyView view = energy();
assertNull(view.pvPowerW());
assertNull(view.today().pvKwh());
assertNull(view.time());
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)));
}
@Test
void energyWithStaleDeyeKeepsOnlyFreshFroniusAndTheOldestTime() throws IOException {
Instant old = NOW.minusSeconds(3600);
EnergyView view = energy(deyeRead(true, old), device("fronius", "home", "fronius", FRONIUS));
assertEquals(old, view.time());
assertEquals(List.of(new Source("deye", old, true), new Source("fronius", READ, false)), view.sources());
assertEquals(2000.0, view.pvPowerW());
assertEquals(4.4, view.today().pvKwh());
assertNull(view.gridPowerW());
assertNull(view.batteryPowerW());
assertNull(view.batterySocPct());
assertNull(view.loadPowerW());
assertNull(view.today().loadKwh());
}
@Test
void energyWithStaleFroniusKeepsDeyeAlone() throws IOException {
Instant old = NOW.minusSeconds(3600);
var fronius = new DeviceSnapshot("fronius", true, old, null,
List.of(new Measurement("home", "fronius", FRONIUS, old)));
EnergyView view = energy(device("deye", "home", "deye", DEYE), fronius);
assertEquals(1500.0, view.pvPowerW());
assertEquals(10.0, view.today().pvKwh());
assertEquals(80.0, view.batterySocPct());
assertEquals(old, view.time());
}
@Test
void energySourceWhoseLinkIsDownIsStaleEvenWithAFreshReading() throws IOException {
EnergyView view = energy(deyeRead(false, READ));
assertTrue(view.sources().get(0).stale());
assertNull(view.gridPowerW());
assertEquals(READ, view.time());
}
@Test
void energyPropagatesGatewayFailure() {
assertThrows(IOException.class, () -> new EnergyService(unreachable(), CLOCK).current());
}
@Test
void healthListsDevices() {
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, "v1.3.0").current();
assertTrue(view.gatewayReachable());
assertEquals(NOW, view.time());
assertEquals(2, view.devices().size());
assertEquals("connect timed out", view.devices().get(1).lastError());
assertFalse(view.devices().get(1).up());
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, "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. */
private static final class RecordingHistory implements HistoryRepository {
Series series;
Instant from;
Instant to;
int maxPoints;
@Override
public List<Point> history(Series series, Instant from, Instant to, int maxPoints) {
this.series = series;
this.from = from;
this.to = to;
this.maxPoints = maxPoints;
return List.of(new Point(from, 1.5));
}
}
@Test
void historyDefaultsToLast24Hours() throws IOException {
RecordingHistory repository = new RecordingHistory();
HistoryView view = new HistoryService(repository, CLOCK).history("cwu_temp", null);
assertEquals("cwu_temp", view.series());
assertEquals("°C", view.unit());
assertEquals(1, view.points().size());
assertEquals(Series.CWU_TEMP, repository.series);
assertEquals(NOW, repository.to);
assertEquals(NOW.minus(Duration.ofHours(24)), repository.from);
assertEquals(300, repository.maxPoints);
}
@Test
void historyOfSevenDays() throws IOException {
RecordingHistory repository = new RecordingHistory();
new HistoryService(repository, CLOCK).history("pv_power", "7d");
assertEquals(NOW.minus(Duration.ofDays(7)), repository.from);
}
@Test
void historyRejectsUnknownSeriesAndRange() {
HistoryService service = new HistoryService(new RecordingHistory(), CLOCK);
assertThrows(IllegalArgumentException.class, () -> service.history("nope", "24h"));
assertThrows(IllegalArgumentException.class, () -> service.history(null, "24h"));
assertThrows(IllegalArgumentException.class, () -> service.history("cwu_temp", "30d"));
assertThrows(IllegalArgumentException.class, () -> service.history("cwu_temp", ""));
}
@Test
void historyPropagatesRepositoryFailure() {
HistoryService service = new HistoryService((series, from, to, max) -> {
throw new IOException("influx down");
}, CLOCK);
assertThrows(IOException.class, () -> service.history("cwu_temp", "24h"));
}
}
@@ -0,0 +1,298 @@
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;
import home.backend.domain.PresetResult;
import home.backend.domain.PresetResult.ChangeResult;
import home.backend.domain.PresetResult.ChangeStatus;
import home.backend.domain.Restore;
import home.backend.domain.Setpoint;
import home.backend.domain.SetpointState;
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;
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),
new Setpoint("cwu_comfort", 4144, 5, 48, 1, 45)), List.<Restore>of());
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
public CommandResult apply(Command command) throws IOException {
if (down) {
throw new IOException("tunnel down");
}
Object next = script.isEmpty() ? Status.APPLIED : script.removeFirst();
if (next instanceof IOException e) {
throw e;
}
sent.add(command);
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) {
throw new IOException("tunnel down");
}
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();
assertEquals(STATE.setpoints(), view.setpoints());
assertEquals(presets, view.presets());
}
@Test
void hoursBecomeAWholeNumberOfMinutes() throws Exception {
service.apply("cwu_economy", 45, 1.5, "anna");
service.apply("cwu_comfort", 10, null, "anna");
assertEquals(new Command("cwu_economy", 45, Duration.ofMinutes(90), "anna"), sent.get(0));
assertEquals(new Command("cwu_comfort", 10, null, "anna"), sent.get(1));
}
@Test
void durationUnderAMinuteIsRefusedBeforeSending() {
assertThrows(IllegalArgumentException.class, () -> service.apply("cwu_economy", 45, 0.001, "anna"));
assertThrows(IllegalArgumentException.class, () -> service.apply("cwu_economy", 45, -1.0, "anna"));
assertThrows(IllegalArgumentException.class, () -> service.apply("cwu_economy", 45, 72.5, "anna"));
assertTrue(sent.isEmpty());
}
@Test
void seventyTwoHoursIsTheLongestBoost() throws Exception {
service.apply("cwu_economy", 45, 72.0, "anna");
assertEquals(Duration.ofHours(72), sent.getFirst().duration());
}
@Test
void boostPresetSendsOneTimedCommand() throws Exception {
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 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, 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", null, Map.of(), "anna").isEmpty());
assertTrue(sent.isEmpty());
}
@Test
void presetOutsideGatewayLimitsWritesNothing() throws Exception {
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", null, 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("cwu_economy"));
assertTrue(sent.isEmpty());
}
@Test
void presetWithUnknownSetpointWritesNothing() throws Exception {
var odd = new Preset("odd", "Dziwny", null, List.of(new Change("garage_heater", 1, null)));
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", null, Map.of(), "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertTrue(sent.isEmpty());
}
private static List<ChangeStatus> statuses(PresetResult result) {
return result.results().stream().map(ChangeResult::status).toList();
}
@Test
void presetReportsEveryChangeAndStopsAfterTheFirstThatIsNotApplied() throws Exception {
script.add(Status.FAILED);
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));
assertEquals(1, sent.size());
}
@Test
void secondChangeRejectedLeavesThePresetPartial() throws Exception {
script.addAll(List.of(Status.APPLIED, Status.REJECTED));
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));
}
@Test
void firstChangeRejectedByTheGatewayIsRejectedAsAWhole() throws Exception {
script.add(Status.REJECTED);
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));
}
@Test
void lostAnswerOnTheSecondChangeKeepsTheFirstResult() throws Exception {
script.addAll(List.of(Status.APPLIED, new IOException("tunnel dropped")));
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));
}
@Test
void lostAnswerOnTheFirstChangeIsFailedNotUnreachable() throws Exception {
script.add(new IOException("tunnel dropped"));
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));
}
@Test
void unreachableGatewayIsAnIOException() {
down = true;
assertThrows(IOException.class, service::current);
assertThrows(IOException.class, () -> service.apply("cwu_economy", 45, null, "anna"));
assertThrows(IOException.class, () -> service.applyPreset("away", RETURN, Map.of(), "anna"));
assertTrue(sent.isEmpty());
}
}
@@ -0,0 +1,20 @@
package home.backend.domain;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
class SeriesTest {
@Test
void findsSeriesByItsId() {
assertEquals(Optional.of(Series.CWU_TEMP), Series.fromId("cwu_temp"));
}
@Test
void unknownIdFindsNothing() {
assertEquals(Optional.empty(), Series.fromId("DROP MEASUREMENT"));
}
}
@@ -0,0 +1,63 @@
{
"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 }
] },
{ "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 }
] }
]
},
{
"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 }
] }
]
}
]
}
@@ -0,0 +1,13 @@
{
"time": "2026-10-23T21:00:00Z",
"sources": [
{ "name": "deye", "time": "2026-10-23T21:00:00Z", "stale": true },
{ "name": "fronius", "time": "2026-10-23T22:26:00Z", "stale": false }
],
"pvPowerW": 0.0,
"gridPowerW": null,
"batteryPowerW": null,
"batterySocPct": null,
"loadPowerW": null,
"today": { "pvKwh": 4.4, "gridBoughtKwh": null, "gridSoldKwh": null, "loadKwh": null }
}
@@ -0,0 +1,13 @@
{
"time": "2026-10-23T22:26:00Z",
"sources": [
{ "name": "deye", "time": "2026-10-23T22:26:00Z", "stale": false },
{ "name": "fronius", "time": "2026-10-23T22:26:00Z", "stale": false }
],
"pvPowerW": 0.0,
"gridPowerW": 645.0,
"batteryPowerW": -24.0,
"batterySocPct": 99.0,
"loadPowerW": 570.0,
"today": { "pvKwh": 34.3, "gridBoughtKwh": 16.8, "gridSoldKwh": 22.3, "loadKwh": 24.2 }
}
@@ -0,0 +1,10 @@
{
"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,17 @@
{
"time": null,
"stale": true,
"state": null,
"compressorPercent": null,
"heaterOn": false,
"alarm": false,
"alarmCode": null,
"alarmText": null,
"pumps": { "co": false, "buffer": false, "circulation": false, "ground": false, "cwu": false },
"temperatures": {
"outdoor": null, "indoor": null, "coBuffer": null, "cwu": null,
"groundIn": null, "groundOut": null, "pumpOutlet": null, "outdoorAvg": null
},
"targets": { "room": null, "co": null, "cwu": null },
"setpoints": { "coEconomy": null, "coComfort": null, "cwuEconomy": null, "cwuComfort": null }
}
@@ -0,0 +1,17 @@
{
"time": "2026-10-23T22:26:00Z",
"stale": false,
"state": "IDLE",
"compressorPercent": 0,
"heaterOn": false,
"alarm": false,
"alarmCode": null,
"alarmText": null,
"pumps": { "co": false, "buffer": false, "circulation": false, "ground": false, "cwu": false },
"temperatures": {
"outdoor": 4.3, "indoor": 21.2, "coBuffer": 16.9, "cwu": 13.6,
"groundIn": 16.0, "groundOut": 14.3, "pumpOutlet": 19.7, "outdoorAvg": 4.8
},
"targets": { "room": 18.0, "co": 31.0, "cwu": 8.0 },
"setpoints": { "coEconomy": 18, "coComfort": 19, "cwuEconomy": 8, "cwuComfort": 45 }
}
@@ -0,0 +1,9 @@
{
"series": "cwu_temp",
"unit": "°C",
"points": [
{ "t": "2026-10-23T10:00:00Z", "v": 14.1 },
{ "t": "2026-10-23T11:00:00Z", "v": 38.5 },
{ "t": "2026-10-23T12:00:00Z", "v": 44.2 }
]
}
@@ -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" }
@@ -0,0 +1 @@
{ "status": "APPLIED", "message": "cwu_economy 8 → 45, restore at 2026-10-24T12:00:00Z", "restoreAt": "2026-10-24T12:00:00Z" }
@@ -0,0 +1,7 @@
{
"status": "APPLIED",
"results": [
{ "status": "APPLIED", "message": "cwu_comfort 45 → 10", "restoreAt": null },
{ "status": "APPLIED", "message": "co_comfort 19 → 15", "restoreAt": null }
]
}
@@ -0,0 +1,7 @@
{
"status": "PARTIAL",
"results": [
{ "status": "APPLIED", "message": "cwu_economy 8 → 45, restore at 2026-10-24T12:00:00Z", "restoreAt": null },
{ "status": "UNKNOWN", "message": "no answer from the gateway", "restoreAt": null }
]
}
@@ -0,0 +1 @@
{ "status": "REJECTED", "message": "cwu_economy allows 5–48 in steps of 1, not 60", "restoreAt": null }
@@ -0,0 +1 @@
{ "setpoint": "cwu_economy", "value": 45, "hours": 2 }
@@ -0,0 +1,27 @@
{
"setpoints": [
{ "name": "co_economy", "register": 4131, "min": 5, "max": 25, "step": 1, "value": 18 },
{ "name": "co_comfort", "register": 4132, "min": 5, "max": 25, "step": 1, "value": 19 },
{ "name": "cwu_economy", "register": 4143, "min": 5, "max": 48, "step": 1, "value": 8 },
{ "name": "cwu_comfort", "register": 4144, "min": 5, "max": 48, "step": 1, "value": 45 }
],
"restores": [
{ "setpoint": "cwu_economy", "register": 4143, "originalValue": 8, "appliedValue": 45, "due": "2026-10-24T12:00:00Z" }
],
"presets": [
{
"id": "cwu-now",
"name": "Ciepła woda teraz",
"changes": [ { "setpoint": "cwu_economy", "value": 45, "hours": 2.0 } ]
},
{
"id": "away",
"name": "Wyjazd",
"until": "chosen",
"changes": [
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
{ "setpoint": "co_comfort", "value": 15, "hours": null }
]
}
]
}
@@ -0,0 +1 @@
{"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 @@
{"status":"DOWN","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":"DOWN","lastSuccess":null,"lastError":"Connection refused"},{"name":"house-meter","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null}]}
@@ -0,0 +1 @@
{"status":"DOWN","devices":[{"name":"bme280","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"deye","status":"DOWN","lastSuccess":"2026-10-23T22:20:00Z","lastError":"Connect timed out"},{"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 @@
[{"name":"bme280","interval":"PT5M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"measurement","measurement":"MEASUREMENT","tags":{"location":"dom-szczepancowa"},"fields":{"pressure":985.8,"humidity":45.82,"temperature":20.17},"time":"2026-10-23T22:26:00Z"}]},{"name":"deye","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"deye","tags":{"location":"dom-szczepancowa"},"fields":{"grid_power_w":645.0,"battery_temperature_c":19.2,"battery_discharge_today_kwh":0.1,"load_today_kwh":24.2,"pv2_power_w":0.0,"pv1_power_w":0.0,"grid_voltage_l1_v":233.9,"grid_bought_today_kwh":16.8,"load_power_w":570.0,"grid_voltage_l2_v":233.4,"battery_soc_pct":99.0,"pv_today_kwh":29.9,"battery_power_w":-24.0,"grid_power_l1_w":390.0,"grid_voltage_l3_v":231.9,"battery_charge_today_kwh":0.5,"grid_power_l2_w":52.0,"load_power_l1_w":364.0,"load_power_l2_w":26.0,"grid_power_l3_w":203.0,"battery_voltage_v":56.49,"grid_sold_today_kwh":22.3,"load_power_l3_w":180.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"fronius","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"fronius","tags":{"location":"dom-szczepancowa"},"fields":{"energy_year_wh":1846276.375,"pv_power_w":0.0,"energy_day_wh":4400.0,"energy_total_wh":2.90713E7},"time":"2026-10-23T22:26:00Z"}]},{"name":"heat-pump","interval":"PT10S","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"Dom_Pompa","measurement":"POMPA","tags":{},"fields":{"Temp co":16.900000000000002,"Temp cwu":13.600000000000001,"Temp out":14.3,"Temp in":16.0,"Temp wew":21.200000000000003,"External temperature":4.3,"Temp zsr":4.800000000000001,"Temp pc out":19.700000000000003},"time":"2026-10-23T22:26:00Z"},{"database":"Dom_Pompa","measurement":"heat_pump","tags":{},"fields":{"pump_outlet_temp":19.700000000000003,"setpoint_cwu_economy":8.0,"pressure_switch":0.0,"setpoint_co_comfort":19.0,"output_co":0.0,"co_buffer_temp":16.900000000000002,"ground_out_temp":14.3,"setpoint_co_economy":18.0,"output_heater":0.0,"pump_mode":0.0,"target_co_temp":31.0,"cwu_temp":13.600000000000001,"alarm":0.0,"reg_4225":0.0,"reg_4223":0.0,"reg_4224":0.0,"reg_4222":0.0,"target_room_temp":18.0,"output_circulation":0.0,"output_compressor":0.0,"output_ground":0.0,"outdoor_avg_temp":4.800000000000001,"program":0.0,"output_cwu":0.0,"target_cwu_temp":8.0,"indoor_temp":21.200000000000003,"output_buffer":0.0,"setpoint_cwu_comfort":45.0,"output_cooling":0.0,"zb_state":0.0,"outdoor_temp":4.3,"ground_in_temp":16.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"house-meter","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"PRAD2","tags":{"location":"dom-szczepancowa"},"fields":{"p3_power_reactive":26.52,"p2_demand_current":0.98,"total_power_factor":1.0,"p23_voltage_thd":0.0,"import_energy_reactive":6433.02,"maximum_export_demand_power_active":0.0,"total_energy_apparent":138275.8,"p1_power_apparent":74.71,"p3_demand_current":0.86,"p2_current_thd":99.27,"total_current":262147.94,"p2_power_factor":0.87,"p1_power_factor":0.75,"p3_current":0.67,"p2_current":0.88,"total_energy_active":134191.39,"maximum_neutral_demand_current":20.28,"maximum_import_demand_power_active":0.0,"voltage_ln_thd":3.65,"p2_power_apparent":207.46,"p1_phase_angle":41.52,"export_demand_power_active":0.0,"maximum_p1_demand_current":21.31,"neutral_demand_current":0.77,"p31_voltage_thd":0.0,"p3_power_apparent":155.65,"neutral_current":0.67,"export_energy_active":66677.11,"total_phase_angle":-4.12,"import_demand_power_active":0.0,"maximum_import_demand_power_apparent":-10986.21,"current_thd":95.36,"maximum_p2_demand_current":20.79,"p23_voltage":405.05,"p3_power_active":153.38,"p2_power_active":179.47,"p3_voltage":232.2,"p2_voltage":235.51,"frequency":50.01,"maximum_p3_demand_current":16.35,"p12_voltage":408.07,"maximum_demand_power_apparent":10989.65,"p1_demand_current":0.33,"p3_current_thd":72.45,"total_power_apparent":437.82,"voltage_ln":234.47,"voltage_ll_thd":0.0,"voltage_ll":406.11,"p2_phase_angle":-30.11,"p3_power_factor":0.99,"p1_current":0.32,"export_energy_reactive":26926.61,"total_power_reactive":-28.04,"p12_voltage_thd":0.0,"import_energy_active":67514.27,"p31_voltage":405.21,"p1_current_thd":114.36,"total_energy_reactive":33359.63,"total_demand_power_apparent":507.11,"p1_power_reactive":49.52,"current_ln":0.85,"total_power_active":388.78,"p1_voltage":235.69,"p2_power_reactive":-104.07,"p3_phase_angle":9.81,"p3n_voltage_thd":3.64,"p2n_voltage_thd":3.82,"p1n_voltage_thd":3.48,Line truncated
@@ -0,0 +1 @@
[{"name":"bme280","interval":"PT5M","since":"2026-10-23T22:20:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"measurement","measurement":"MEASUREMENT","tags":{"location":"dom-szczepancowa"},"fields":{"pressure":985.8,"humidity":45.82,"temperature":20.17},"time":"2026-10-23T22:26:00Z"}]},{"name":"deye","interval":"PT1M","since":"2026-10-23T22:20:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"deye","tags":{"location":"dom-szczepancowa"},"fields":{"grid_power_w":645.0,"battery_temperature_c":19.2,"battery_discharge_today_kwh":0.1,"load_today_kwh":24.2,"pv2_power_w":0.0,"pv1_power_w":0.0,"grid_voltage_l1_v":233.9,"grid_bought_today_kwh":16.8,"load_power_w":570.0,"grid_voltage_l2_v":233.4,"battery_soc_pct":99.0,"pv_today_kwh":29.9,"battery_power_w":-24.0,"grid_power_l1_w":390.0,"grid_voltage_l3_v":231.9,"battery_charge_today_kwh":0.5,"grid_power_l2_w":52.0,"load_power_l1_w":364.0,"load_power_l2_w":26.0,"grid_power_l3_w":203.0,"battery_voltage_v":56.49,"grid_sold_today_kwh":22.3,"load_power_l3_w":180.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"fronius","interval":"PT1M","since":"2026-10-23T22:20:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"fronius","tags":{"location":"dom-szczepancowa"},"fields":{"energy_year_wh":1846276.375,"pv_power_w":0.0,"energy_day_wh":4400.0,"energy_total_wh":2.90713E7},"time":"2026-10-23T22:26:00Z"}]},{"name":"heat-pump","interval":"PT10S","since":"2026-10-23T22:20:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":null,"lastError":"Connection refused","readings":[]},{"name":"house-meter","interval":"PT1M","since":"2026-10-23T22:20:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"PRAD2","tags":{"location":"dom-szczepancowa"},"fields":{"p3_power_reactive":26.52,"p2_demand_current":0.98,"total_power_factor":1.0,"p23_voltage_thd":0.0,"import_energy_reactive":6433.02,"maximum_export_demand_power_active":0.0,"total_energy_apparent":138275.8,"p1_power_apparent":74.71,"p3_demand_current":0.86,"p2_current_thd":99.27,"total_current":262147.94,"p2_power_factor":0.87,"p1_power_factor":0.75,"p3_current":0.67,"p2_current":0.88,"total_energy_active":134191.39,"maximum_neutral_demand_current":20.28,"maximum_import_demand_power_active":0.0,"voltage_ln_thd":3.65,"p2_power_apparent":207.46,"p1_phase_angle":41.52,"export_demand_power_active":0.0,"maximum_p1_demand_current":21.31,"neutral_demand_current":0.77,"p31_voltage_thd":0.0,"p3_power_apparent":155.65,"neutral_current":0.67,"export_energy_active":66677.11,"total_phase_angle":-4.12,"import_demand_power_active":0.0,"maximum_import_demand_power_apparent":-10986.21,"current_thd":95.36,"maximum_p2_demand_current":20.79,"p23_voltage":405.05,"p3_power_active":153.38,"p2_power_active":179.47,"p3_voltage":232.2,"p2_voltage":235.51,"frequency":50.01,"maximum_p3_demand_current":16.35,"p12_voltage":408.07,"maximum_demand_power_apparent":10989.65,"p1_demand_current":0.33,"p3_current_thd":72.45,"total_power_apparent":437.82,"voltage_ln":234.47,"voltage_ll_thd":0.0,"voltage_ll":406.11,"p2_phase_angle":-30.11,"p3_power_factor":0.99,"p1_current":0.32,"export_energy_reactive":26926.61,"total_power_reactive":-28.04,"p12_voltage_thd":0.0,"import_energy_active":67514.27,"p31_voltage":405.21,"p1_current_thd":114.36,"total_energy_reactive":33359.63,"total_demand_power_apparent":507.11,"p1_power_reactive":49.52,"current_ln":0.85,"total_power_active":388.78,"p1_voltage":235.69,"p2_power_reactive":-104.07,"p3_phase_angle":9.81,"p3n_voltage_thd":3.64,"p2n_voltage_thd":3.82,"p1n_voltage_thd":3.48,"total_line_current":2.55,"p1_power_active":55.94,"total_import_demand_power_active":452.07},"time":"2026-10-23T22:26:00Z"}]}]
@@ -0,0 +1 @@
[{"name":"bme280","interval":"PT5M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"measurement","measurement":"MEASUREMENT","tags":{"location":"dom-szczepancowa"},"fields":{"pressure":985.8,"humidity":45.82,"temperature":20.17},"time":"2026-10-23T22:26:00Z"}]},{"name":"deye","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:20:00Z","lastError":"Connect timed out","readings":[{"database":"electricity","measurement":"deye","tags":{"location":"dom-szczepancowa"},"fields":{"grid_power_w":645.0,"battery_temperature_c":19.2,"battery_discharge_today_kwh":0.1,"load_today_kwh":24.2,"pv2_power_w":0.0,"pv1_power_w":0.0,"grid_voltage_l1_v":233.9,"grid_bought_today_kwh":16.8,"load_power_w":570.0,"grid_voltage_l2_v":233.4,"battery_soc_pct":99.0,"pv_today_kwh":29.9,"battery_power_w":-24.0,"grid_power_l1_w":390.0,"grid_voltage_l3_v":231.9,"battery_charge_today_kwh":0.5,"grid_power_l2_w":52.0,"load_power_l1_w":364.0,"load_power_l2_w":26.0,"grid_power_l3_w":203.0,"battery_voltage_v":56.49,"grid_sold_today_kwh":22.3,"load_power_l3_w":180.0},"time":"2026-10-23T22:20:00Z"}]},{"name":"fronius","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"fronius","tags":{"location":"dom-szczepancowa"},"fields":{"energy_year_wh":1846276.375,"pv_power_w":0.0,"energy_day_wh":4400.0,"energy_total_wh":2.90713E7},"time":"2026-10-23T22:26:00Z"}]},{"name":"heat-pump","interval":"PT10S","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"Dom_Pompa","measurement":"POMPA","tags":{},"fields":{"Temp co":16.900000000000002,"Temp cwu":13.600000000000001,"Temp out":14.3,"Temp in":16.0,"Temp wew":21.200000000000003,"External temperature":4.3,"Temp zsr":4.800000000000001,"Temp pc out":19.700000000000003},"time":"2026-10-23T22:26:00Z"},{"database":"Dom_Pompa","measurement":"heat_pump","tags":{},"fields":{"pump_outlet_temp":19.700000000000003,"setpoint_cwu_economy":8.0,"pressure_switch":0.0,"setpoint_co_comfort":19.0,"output_co":0.0,"co_buffer_temp":16.900000000000002,"ground_out_temp":14.3,"setpoint_co_economy":18.0,"output_heater":0.0,"pump_mode":0.0,"target_co_temp":31.0,"cwu_temp":13.600000000000001,"alarm":0.0,"reg_4225":0.0,"reg_4223":0.0,"reg_4224":0.0,"reg_4222":0.0,"target_room_temp":18.0,"output_circulation":0.0,"output_compressor":0.0,"output_ground":0.0,"outdoor_avg_temp":4.800000000000001,"program":0.0,"output_cwu":0.0,"target_cwu_temp":8.0,"indoor_temp":21.200000000000003,"output_buffer":0.0,"setpoint_cwu_comfort":45.0,"output_cooling":0.0,"zb_state":0.0,"outdoor_temp":4.3,"ground_in_temp":16.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"house-meter","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"PRAD2","tags":{"location":"dom-szczepancowa"},"fields":{"p3_power_reactive":26.52,"p2_demand_current":0.98,"total_power_factor":1.0,"p23_voltage_thd":0.0,"import_energy_reactive":6433.02,"maximum_export_demand_power_active":0.0,"total_energy_apparent":138275.8,"p1_power_apparent":74.71,"p3_demand_current":0.86,"p2_current_thd":99.27,"total_current":262147.94,"p2_power_factor":0.87,"p1_power_factor":0.75,"p3_current":0.67,"p2_current":0.88,"total_energy_active":134191.39,"maximum_neutral_demand_current":20.28,"maximum_import_demand_power_active":0.0,"voltage_ln_thd":3.65,"p2_power_apparent":207.46,"p1_phase_angle":41.52,"export_demand_power_active":0.0,"maximum_p1_demand_current":21.31,"neutral_demand_current":0.77,"p31_voltage_thd":0.0,"p3_power_apparent":155.65,"neutral_current":0.67,"export_energy_active":66677.11,"total_phase_angle":-4.12,"import_demand_power_active":0.0,"maximum_import_demand_power_apparent":-10986.21,"current_thd":95.36,"maximum_p2_demand_current":20.79,"p23_voltage":405.05,"p3_power_active":153.38,"p2_power_active":179.47,"p3_voltage":232.2,"p2_voltage":235.51,"frequency":50.01,"maximum_p3_demand_current":16.35,"p12_voltage":408.07,"maximum_demand_power_apparent":10989.65,"p1_demand_current":0.33,"p3_current_thd":72.45,"total_power_apparent":437.82,"voltage_ln":234.47,"voltage_ll_thd":0.0,"voltage_ll":406.11,"p2_phase_angle":-30.11,"p3_power_factor":0.99,"p1_current":0.32,"export_energy_reactive":26926.61,"total_power_reactive":-28.04,"p12_voltage_thd":0.0,"import_energy_active":67514.27,"p31_voltage":405.21,"p1_current_thd":114.36,"total_energy_reactive":33359.63,"total_demand_power_apparent":507.11,"p1_power_reactive":49.52,"current_ln":0.85,"total_power_active":388.78,"p1_voltage":235.69,"p2_power_reactive":-104.07,"p3_phase_angle":9.81,"p3n_voltage_thd":3.64,"p2n_voltage_thd":3.82,"p1n_volLine truncated
@@ -0,0 +1 @@
{"results":[{"statement_id":0,"series":[{"name":"POMPA","columns":["time","mean"],"values":[[1792771200000,43.76666666666667],[1792774800000,42.40000000000001],[1792778400000,26.733333333333334],[1792782000000,19.15833333333333],[1792785600000,13.799999999999997],[1792789200000,13.58333333333333],[1792792800000,13.65]]}]}]}
@@ -0,0 +1 @@
{"results":[{"statement_id":0}]}
@@ -0,0 +1 @@
{"error":"error parsing query: found SELEC, expected SELECT, DELETE, SHOW, CREATE, DROP, EXPLAIN, GRANT, REVOKE, ALTER, SET, KILL at line 1, char 1"}
@@ -0,0 +1,30 @@
# ADR-008: Backend and web UI stack
Status: proposed · 2026-10-24 (taken alone during the night of M1/M3 work; to confirm)
## Context
M3 needs the backend in Kraków (phone API, history from InfluxDB, live state from the gateway)
and a mobile-first web UI served by it (ADR-004). ADR-001 asks for the least code that works;
the gateway already shows the stack is enough (ADR-007).
## Decision
- **Backend**: a separate Maven project in `backend/`, Java 21, no framework: JDK
`HttpServer` with virtual threads, JDK `HttpClient` for the gateway and InfluxDB (InfluxQL
`/query`), Jackson. Layers as in the spec. One fat jar, run in Docker.
- **API**: JSON under `/api/`: `heat-pump`, `energy`, `history?series=&range=`, `health`. The
records in `home.backend.domain` and the examples in `backend/src/test/resources/api/` are
the contract between backend and UI.
- **Web UI**: React, TypeScript and Vite in `frontend/`, Tailwind for styling, Recharts for
charts; the overview animates an SVG layer over a picture of the installation. Polish texts.
Changed on 2026-10-24 at the user's request: the plain-JS UI was too weak to grow. Served by
its own nginx container, not by the backend (ADR-010).
- **History**: series are an allowlist (`Series`); temperatures come from the legacy
`Dom_Pompa.POMPA` fields, which already hold years of data.
- **Auth**: not in the app; Traefik in front decides (the login method is still open, M3).
## Consequences
- A JavaScript toolchain to keep up to date (`package-lock.json`); CI sets up Node as well.
- `scripts/check.sh` builds and tests both projects.
@@ -0,0 +1,27 @@
# ADR-009: Web UI reachable from Krosno only, without a login for now
Status: accepted · 2026-10-24
## Context
M3 needed a login method before the backend goes behind Traefik in Kraków (ADR-008 left it
open). Keycloak runs there but no stack uses it yet. The first users are in the house in
Krosno, on its network.
## Decision
- No login in the app or in Traefik for now. The backend's router gets an `ipwhitelist`
middleware that admits only Krosno: its public address or the WireGuard range, whichever path
the phones take; settled when the route is set up.
- A `headers` middleware on the same router clears a client's `Remote-User`, so the name sent as
`X-Requested-By` with a setpoint change is never one a client made up; without a login it is
always `phone`.
- The container publishes no host port; only Traefik reaches it.
## Consequences
- Anyone on the Krosno network can change setpoints once writes are enabled (#53). The limits
and timed restores (ADR-006) still apply.
- Not usable away from home. Before that, choose a login (Keycloak forward-auth or Traefik
basic auth with `headerField: Remote-User`) in a new ADR that supersedes this one.
- If Krosno's public address changes, the whitelist must follow; the WireGuard range does not.
@@ -0,0 +1,31 @@
# ADR-010: Gateway, backend and frontend as three images, each with its compose
Status: accepted · 2026-10-24
## Context
The code had grown into a gateway at the root and a backend that also built and served the
web UI. The Pi ran a jar under systemd, copied by a deploy script; Kraków runs Docker stacks
from homelab-compose through Arcane. The user wanted the parts separate and each deployable the
same way.
## Decision
- Three folders, `gateway/`, `backend/`, `frontend/`, each with a `Dockerfile` and a
`compose.yaml`. The backend serves only `/api/`; nginx in the frontend image serves the UI
and passes `/api/` to the backend, so they share one origin (CSP and the Origin check hold).
- A pushed tag `vMAJOR.MINOR.PATCH` (`scripts/release.sh`) makes CI publish the three images in
the Gitea registry; the gateway also for arm64. The Dockerfiles build on the runner's own
platform and only copy into the final stage, so no emulation is needed.
- The gateway runs in Docker on the Pi (Docker installed there, the OS untouched): host network
for Modbus, `/home/pi/home-pump` mounted at the same path, logs to journald. `update.sh` on
the Pi pulls a release with its `compose.yaml` and goes back to the previous one if `/health`
fails or the container restarts.
## Consequences
- No jar copying: the Pi and Kraków run the same published images; a version is a git tag.
- The Pi needs Docker and, if the packages are private, a registry login: one-time steps.
- CI needs Node and a `REGISTRY_TOKEN` secret with package write access.
- Supersedes the systemd unit and `scripts/deploy-gateway.sh`; ADR-006 (gateway on the Pi)
stands.
+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).
+120
View File
@@ -0,0 +1,120 @@
# Frisko app and map knowledge for the RX910-SE21
What the vendor app (`FriskoOnline2025.apk`, decompiled with androguard, nothing run), the XML maps
and the PDFs say. **Everything here is unverified on the device** unless
[rx910-se21.md](rx910-se21.md) says so. Source tags: `PDF` = `RX910-SE21-AdresyMODBUS`, `XML` =
`RX910-SE21_v24062015.xml`, `MR208` = MR208-PC+ manual/map (another model: hints only), `APK` =
class in `com.frisko.frisko_mobile`.
## Key finding: the app has no control logic
The app is a generic Modbus viewer: every screen, scale, value name and icon comes from the XML
(`XmlHandler`, `ListDataParam`). It contains no register numbers except 4011 (`Comm_thread.STATUS_REG`),
base 4000 (`Frisko_Sterownik.ADR_BAZOWY`, only for its local array index; the wire address is the raw
register) and the XML tag set. It never reads 4221, 4222–4225, 4227, 4228, 4119 or 4129. Meanings
below therefore come from the PDF, the XML and the MR208 manual, not from code.
## Alarm: register 4011 (the app) vs 4221 (the PDF)
| Register | Source | Meaning |
|---|---|---|
| 4221 "Instalacja" | PDF | 0 = installation OK, 1 = installation fault. Binary, no bits, no codes. |
| 4011 "Status" | XML (Status screen) | Fault code, not a bit field: 0 OK, 2 Err_Tzew, 3 Err_Tco, 4 Err_Twew, 5 Err_Tcwu, 14 Err_Tpc, 15 Err_Tdz, 1003 Err_Presostat |
| 4073 "Presostat" | XML, PDF | 0 OK, 1 error (pressure switch input) |
- The app polls 4011 always, and on any non-zero value (even one not in the XML list) raises a
notification "Awaria sterownika: <number>" with sound and vibration; it repeats only when the code
changes (`Comm_thread.update_regs`, `status_error`). It never reads 4221.
- Likely mapping of names (inference from naming and the MR208 manual): `Tzew` outdoor 4065, `Tco`
buffer 4067, `Twew` indoor 4066, `Tcwu` 4069, `Tpc` pump outlet 4072, `Tdz` ground source
(4070/4071); codes 2–5, 14, 15 = sensor fault (MR208 "ErrTemp": pump off), 1003 = pressure switch
(MR208 "ErrPres": pump off; three trips in one cycle latch it until service clears it).
- MR208 also reports TmaxPC, TminDZ, dTmaxDZ shutdowns; SE21 may put them in 4011 or 4221
(unknown). 4011 is not in the PDF map. Next: log a read-only FC 3 of 4011 beside 4221 and 4073.
## Registers 4119, 4222–4225, 4228
| Reg | What is known | Source |
|---|---|---|
| 4119 `WyCHL` | Output, "0 OFF, 100 ON" like 4112–4118. "CHL" probably means cooling (the gateway calls it `output_cooling`; a guess). Not in the XML, so the app never shows it. The −14308 read means the register is not implemented on this firmware | PDF; read per rx910-se21.md |
| 4222–4225 | Not in the PDF, XML or APK. MR208 uses 4225 for TFer (a different layout, do not transfer). Gateway fixtures show 0 | none |
| 4228 `StanZB` | PDF text is cut: "0 –", "2 – zbCW" (value 1 and the text of 0 are missing from the PDF extract, also with `-raw`). "ZB" is not explained; not in the XML | PDF |
| 4227 `StanCO/CW` | 0 pc STOP, 1 pcCO, 2 pcCW, 3 pcCO/CW. Not in the XML; the app shows output icons instead | PDF |
| 4226 `Status` | 0 Ferie, 1 Tygodn (weekly), 2 Party, 3 ROZRUCH (start-up), 4 STOP, 5 Dezynf. Same list in the XML "Program pracy" with icons `program_ferie`, `program_tygodniowy`, `program_party`, `praca`, `stop`, `dezynf` | PDF, XML |
4226 is the effective state; 4140 is the chosen program (4226 can say STOP while 4140 says weekly).
Open: the gateway test fixture (real output, PR #40) shows 4226 = 0 while the settings backup has
4140 = 1 (weekly) and rx910-se21.md observed the weekly program. If 4226 really reads 0 then, the
PDF numbering may be shifted by one. Check by reading 4226 and 4140 together, then changing nothing.
## Modes and programs
| Reg | Values | Source |
|---|---|---|
| 4121 `TrybLZ` | Effective season: 0 summer, 1 winter. Read-only on the app's Status screen | PDF, XML |
| 4122 `TrypRA` | 0 R, 1 A, 2 *. Not in the XML. MR208 shows "Zima(A)" on its main screen: season plus A (automatic) or R (manual) | PDF, MR208 manual |
| 4123 `Tryb` | Season setting: 0 Lato, 1 Zima, 2 Auto (editable; backup value 2). Auto switches at 4124 `Temp progu Z/L` (5–35 °C whole, backup 18) | XML, PDF, backup |
| 4125 `Tryb pracy` | 0 STOP, 1 PRACA (editable; backup 1) | XML, PDF |
| 4140 `Prg` | 0 Ferie, 1 Tygodniowy, 2 Party (editable) | XML, PDF |
| 4141 Ferie days | 0–90 (XML). MR208: 0 = until switched back by hand | XML, MR208 manual |
| 4142 Party hours | 0–90 (XML). MR208: 0 = until switched back; counts down to the end of Party | XML, MR208 manual |
MR208 semantics (SE21 likely the same, unverified): Ferie keeps CO at the ferie target (4133) and CWU
at economy or off; Party keeps comfort for CO and CWU; weekly intervals are comfort, the rest economy.
## Economy or comfort period
- The app does not decide this. It shows the XML values only; the XML has no "period" register.
- The controller decides: weekly program intervals (4169–4176) give comfort, everything else economy
(MR208 manual, "Programy"). Ferie and Party override it as above.
- The effective targets 4201 (room), 4203 (CWU) are the answer. Inference for a UI: compare 4201
with 4131 (economy), 4132 (comfort), 4133 (ferie), and 4203 with 4143 / 4144. Ambiguous when two
setpoints are equal. 4202 (CO supply target) comes from the heating curve (4134) and is not a setpoint.
## "Heating CWU / CO" in the app
The app shows no such state. Its "Stan wyjść" screen lists outputs 4117 Pompa DZ, 4113 BUF, 4112 CO,
4118 CWU, 4116 CCW, 4114 Sprężarka (0/33/66/100), 4115 Grzałka CWU, each as an icon for 0, 1 or 100
(XML). Heating CO or CWU means output 4112 or 4118 on. 4227 is the controller's own summary of the
same thing (PDF); the app ignores it.
## How the app writes (read-only knowledge for a later milestone)
| Item | Finding | Source |
|---|---|---|
| Function codes | FC 3 read, FC 6 write one register. FC 16 exists in the code (`WRITE_REG_MULTI = 16`, `MDBwriteMulti`) but no UI path uses it | `Frisko_MDBRozkaz`, `Frisko_ModbusTCPSystem` |
| Unlock or password | None. No other frame is sent; `HasloLAN` 4129 (PDF) is never used. Matches the FC 6 test on 4143 | APK, rx910-se21.md |
| Unit id | The "slave" number set per controller in the app, in the MBAP unit byte | `Comm_thread`, `MDBsendData` |
| Scales | Value = register / 10 for format `XXX.X`; whole for `XX`. `ster_format` MZ = sign-magnitude: a negative write n is sent as 0x8000 + \|n\| | XML, `JavaToSterownik` |
| Range check | Min and max from the XML only, on the phone | XML |
| Queue | Writes go to the front of the queue (`AddPriorRozkaz`), ahead of polling | `Frisko_ModbusTCPSystem` |
| Confirmation | The reply (function, transaction id, address) is checked; no explicit read-back. The screen shows the new value after the next poll of that register | `MDBreadODP` |
| Failure | Up to 3 attempts per request, 500 ms between them; then the socket is closed and re-opened | `MBD_ERROR`, `ERROR_TIME_OUT` |
| Delay after write | 200 ms after FC 6, 300 ms after FC 16 | `run()` |
| Program selector | Circuit 4177 and day 4167 are plain writes, then the interval registers are polled; no explicit wait. MR208 map prescribes 500 ms between selecting and reading or writing | APK, MR208 map |
The XML schema also has `menu_set_adr` / `menu_set_val` (write a fixed value on opening a menu);
the SE21 XML does not use it.
## Request pacing
| Item | Value | Source |
|---|---|---|
| Pause after each request | 200 ms (300 ms after FC 16); poll thread starts after 250 ms | `Frisko_ModbusTCPSystem.run` |
| Poll list empty | 200 ms, then rebuilt: continuous polling, about 5 requests/s while a screen is open | `MDB_TIME_BREAK` |
| Timeout | 5000 ms default, user setting (2–5 digits) | `MDB_TIME_OUT`, `SterSettingsActivity` |
| Block read | Up to 28 registers per request, gaps up to 5 registers are read through | `MBD_SIZE_OPTYMAL`, `MBD_BREAK_OPTYMAL` |
| What is polled | Only the registers on the open screen, plus 4011 | `REG_DATA_STATUS`, `Comm_thread.set_reg` |
| Connections | One socket held open, up to 50 reconnect attempts | `disconnect_retries` |
The vendor documents no limit. The app's 200 ms pause is the only known pacing; our 36 registers in
5 blocks took 2.5 s (rx910-se21.md), so a 10 s cycle is far below the app's load.
## Open questions
- What are 4222–4225, and the missing values of 4228? (a read-only log over a heating cycle)
- Does 4011 exist and move on this firmware? Its relation to 4221.
- Is 4226 numbered as the PDF says (see above)?
- What does 4119 read when it is implemented, and is the register used at all?
- Meaning of 4122 value 2 ("*") and of 4126 `Uklad` (no values given).
+1 -1
View File
@@ -28,7 +28,7 @@ unless marked otherwise.
| Setpoints (F3) | 4131/4132 CO economy/comfort (5–35), 4143/4144 CWU economy/comfort (5–70) | whole °C |
| Protection | 4158 TmaxPc = 52 °C, 4149 TmaxCWU = 55, 4148 HistCWU = 5, 4146 PriorCWU = 1, 4147 heater delay = 20 | read 2026-10-23 |
Unknown: 4119 `WyCHL` reads −14308.
Unknown: 4119 `WyCHL` reads −14308. Vendor-app findings, all unverified: [app-knowledge.md](app-knowledge.md).
## Weekly program
+31 -29
View File
@@ -5,42 +5,45 @@ Closed milestones shrink to one line here; details stay in git history and in de
## Done
- (none yet) · baseline `e345063`: read-only, sends 8 sensor values to InfluxDB every 300 s;
no control of the controller. Runs on the Pi.
- M0 Foundation · `v1.0.0`: `scripts/check.sh` gate in CI and pre-push, deploy with health check
and rollback, `/ship` and `/close-milestone`. Baseline (`e345063`) still does the work: read-only,
8 heat pump values to InfluxDB every 300 s.
## Current: M0 Foundation
## Current: M1 Gateway: all readings
No behaviour change.
Done when: a week on the Pi without manual action, Grafana unchanged. Existing measurements keep
their names and fields; new data goes into new measurements (ADR-005). The heat pump stays at
300 s polls while the Frisko app is still in use (MK01 takes one client).
- [x] Docs structure: `CLAUDE.md`, `CONTRIBUTING.md`, `docs/`
- [x] `scripts/check.sh`: build, tests, doc size limits, secret scan
- [x] Git `pre-push` hook and CI both run `scripts/check.sh`; remove `.drone.yml` and `Dockerfile`
- [ ] Deploy: `scripts/deploy-gateway.sh` copies the jar to the Pi, restarts, checks `/health`,
rolls back on failure. Written and tested against stubs; open until its first run on the Pi
- [x] Skills: `/ship`, `/close-milestone`
## Feasibility checks (after M0, before M1)
- [x] C1 Gateway sharing: MK01 takes one client; our poll drops my parents' Frisko app.
- [x] C2 Frisko app is used rarely: keep 300 s polls until our UI replaces it, then the backend
is the only client, with a "release gateway" switch for the app.
- [x] C5 Parents raise the economy temperature (CWU 4143, CO 4131) to force heating; no tariff.
- [x] C6 Energy sources readable locally: Deye via Solarman logger, Fronius Solar API, house meter.
- [x] C3 Backup of the 14 app-editable settings: `research/frisko/settings-backup-2026-10-23.json`.
- [x] C4 Write works: FC6 4143 8 → 9 → 8 (my request), echo OK, read-back after 1 s, no password.
- [x] Skeleton: `Reading`, ports `Device`, `ReadingSink`, `ModbusClient`, `ModbusTransport`
- [x] Modbus core (ADR-007): FC3/FC4 codec, TCP transport, Solarman V5 transport
- [x] Heat pump: legacy `Dom_Pompa.POMPA` fields unchanged; 4112–4119, 4201–4203, 4221–4228
and the setpoints into `heat_pump`
- [x] House meter SDM630 (`electricity.PRAD2`) and BME280 via IIO (`measurement.MEASUREMENT`)
- [x] Deye and Fronius into `electricity.deye`, `electricity.fronius`
- [x] Influx writer: line protocol over JDK `HttpClient`, file buffer (fixes D2, D8)
- [x] Poller: one virtual thread per device, snapshot, watchdog without `/health` polling (D1)
- [x] HTTP: `/health` per device, `/snapshot`
- [x] Config file outside the jar (D3), wire `Main`, drop modbus4j, influxdb-java and the
baseline classes (D4, D5)
- [x] Three parts with their images and compose (ADR-010); releases by tag, `update.sh` on the Pi
- [x] On the Pi (2026-10-24): D7, Docker (no iptables, no forwarding), `gateway.json`, ufw 8080
on `wg0` only, `v1.1.1` running with the heat pump, Deye and Fronius; old service stopped
- [x] On the Pi (2026-10-24): IIO overlay, BME280 and the meter in the gateway (`v1.2.1`: the
meter's converter speaks RTU over TCP); `rpi-measurements` stopped and disabled
- [ ] After the week: remove `rpi-measurements` and the old `home-pump.service` files
## Next
| Milestone | Scope | Done when |
|---|---|---|
| M1 Gateway: all readings | Fix D7 first. Baseline becomes the gateway: heat pump incl. state registers (4112–4119, 4201–4203, 4221–4228), Deye, Fronius, house meter and BME280 (moved from `rpi-measurements`, same schemas; then retire it) into Influx with a buffer; snapshot; config outside the jar; fix D1–D5 | A week on the Pi without manual action, Grafana unchanged |
| M2 Understand the controller | Timing (safe poll rate, response times), which pumps run in which state, open questions in `docs/frisko/` | `docs/frisko/` reviewed; F3 limits agreed |
| M3 Backend and web UI, read-only | Backend in Docker in Kraków: history from Influx, snapshot from the gateway; F1, F2, F5, F7 with the schematic and charts after `research/ui/`; CI builds its image; Traefik route and auth (decide the login method first) | My father sees the heat pump and the energy on his phone |
| M4 Setpoints | F3 through gateway commands with timed restore (ADR-006); CWU first, then CO; S1–S6 | Father boosts hot water from his phone, value restored on time |
| M3 Backend and web UI, read-only | Backend in Docker in Kraków: history from Influx, snapshot from the gateway; F1, F2, F5, F7 with the schematic and charts after `research/ui/`; Traefik route, Krosno only and no login for now (ADR-009). Backend, frontend and their images are on master (ADR-008 proposed, ADR-010); open: `REGISTRY_TOKEN`, first release, the stack in homelab-compose (Arcane) | My father sees the heat pump and the energy on his phone |
| M4 Setpoints | F3 through gateway commands with timed restore (ADR-006) and 15 min leases the backend renews (ADR-011), away held until a chosen date (ADR-012); CWU first, then CO; S1–S6. On master and wired, off until `commands.enabled` (gateway/README.md: Enabling commands): command service with restores, FC6, token-protected `/commands`, backend `/api/setpoints` and presets, UI tab Ustawienia | Father boosts hot water from his phone, value restored on time |
| M5 Alarms | F4; decide the channel first (e.g. ntfy, Telegram) | Simulated alarm reaches the phone |
| M6 Native app? | Decide: keep the web UI, WebView wrapper or React Native | Decision recorded |
## Known defects in the baseline (input for M1)
## Known defects in the baseline
- D1 Self-restart works only while something polls `/health` (`HealthMonitor`).
- D2 Influx client logs every request (`LogLevel.FULL`), ~420 journal lines/hour.
@@ -48,11 +51,10 @@ No behaviour change.
- D4 Dead code and config: `pollIntervalMs`, `windowSeconds`, `ModbusCachedWrapper`, write path.
- D5 Single and batch reads use different addresses for registers ≥ 4100 (`ModbusRtuTcpRepository`).
- D6 Development runs wrote test fields into the production `Dom_Pompa` database.
- D7 Pi network: all 880 DNS errors of the week came in one hour (19.10, 10:00), likely an outage,
not config. But the Pi's WireGuard routes most of `192.168.0.0/24` into the tunnel (Kraków uses
the same subnet), so it reaches only `.10` and `.150` locally. Fix: Pi `AllowedIPs` only
`10.0.0.0/24, 10.2.0.0/24` (Influx and Gitea go via the public IP anyway), plus outbound NAT on
OPNsense's WireGuard interface so the Pi answers Kraków through the tunnel.
- D8 Each field is written as its own point with its own timestamp, so one poll can split into
points 1 ms apart (`InfluxMetricModbusSender`); write one point per poll.
- D7 fixed 2026-10-24: Pi `AllowedIPs` only `10.0.0.0/24, 10.2.0.0/24`, and OPNsense NATs
`192.168.0.0/24 → 10.0.0.2` to its tunnel address, as both sites use `192.168.0.0/24`.
## Knowledge
Loaded 100 of 310 files, more files were not shown because too many files have changed in this diff. Show more