Compare commits

..
Author SHA1 Message Date
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
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 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
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
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
273 changed files with 11861 additions and 4597 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
+56
View File
@@ -0,0 +1,56 @@
# A pushed tag vMAJOR.MINOR.PATCH (scripts/release.sh) publishes the three images under that name
# and as latest. The Pi pulls the gateway with gateway/update.sh; Kraków runs backend and frontend.
name: Release
on:
push:
tags:
- "v*.*.*"
env:
REGISTRY: git.mariuszpacek.pl
IMAGES: git.mariuszpacek.pl/mariusz.pacek/home-pump
jobs:
images:
name: 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
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
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
platforms: linux/amd64
push: true
tags: ${{ env.IMAGES }}-frontend:${{ gitea.ref_name }},${{ env.IMAGES }}-frontend:latest
+1 -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
+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.
+12 -22
View File
@@ -7,29 +7,19 @@ Heat pump (Frisko RX910-SE21) and home energy for the house in Krosno, from a ph
- Rules for changes: [CONTRIBUTING.md](CONTRIBUTING.md)
- Controller knowledge: [docs/frisko/](docs/frisko/rx910-se21.md)
```bash
scripts/check.sh # docs, secret scan, build, tests
git config core.hooksPath .githooks # once per clone: check before every push
scripts/deploy-gateway.sh vMAJOR.MINOR.PATCH # deploy master to the Pi (approval needed)
```
## Backend (phone API and web UI)
Settings are environment variables; the backend refuses to start without the two URLs.
| Variable | Default | Meaning |
| Part | Runs | Readme |
|---|---|---|
| `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/` | Pi in Krosno, Docker: devices to InfluxDB, `/commands` | [gateway/README.md](gateway/README.md) |
| `backend/` | Kraków, Docker: phone API | [backend/README.md](backend/README.md) |
| `frontend/` | Kraków, Docker: web UI with nginx | [frontend/README.md](frontend/README.md) |
Each part has its `Dockerfile` and `compose.yaml` (ADR-010).
```bash
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 # UI at http://localhost:8080/
docker build -t home-pump-backend backend # after the mvn package above
docker run --rm -p 8080:8080 -e GATEWAY_URL=... -e INFLUX_URL=... home-pump-backend
scripts/check.sh # docs, secret scan, build and tests of all three parts
git config core.hooksPath .githooks # once per clone: check before every push
scripts/release.sh vMAJOR.MINOR.PATCH # tag master; CI publishes the three images in Gitea
```
After a release: on the Pi `./update.sh <version>` (gateway/README.md); in Kraków Arcane runs the
backend and frontend images.
+1
View File
@@ -0,0 +1 @@
target
+11 -8
View File
@@ -1,16 +1,19 @@
# Build the jar first: mvn -f backend/pom.xml clean package
# 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
RUN adduser -D -u 10001 app
USER app
USER 10001:10001
WORKDIR /app
COPY target/home-pump-backend-1.0.0.jar app.jar
COPY --from=build /src/target/home-pump-backend-1.0.0.jar app.jar
EXPOSE 8080
# /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"]
+30
View File
@@ -0,0 +1,30 @@
# 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.
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 |
```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
```
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).
+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
@@ -1,6 +1,8 @@
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;
@@ -8,6 +10,7 @@ import home.backend.application.EnergyService;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService;
import home.backend.application.SetpointService;
import java.io.IOException;
import java.time.Clock;
@@ -18,14 +21,21 @@ final class Backend {
private Backend() {
}
/** Binds the port but does not start serving. */
/**
* Binds the port but does not start serving.
*
* @throws IllegalArgumentException when PRESETS_FILE is unusable
*/
static ApiServer assemble(Settings settings, Clock clock) throws IOException {
var gateway = new GatewayHttpClient(settings.gatewayUrl(), settings.gatewayTimeout());
var influx = new InfluxHistoryRepository(new InfluxSettings(settings.influxUrl(), settings.influxTimeout()));
var commands = new GatewayHttpCommands(settings.gatewayUrl(), settings.gatewayToken(),
settings.gatewayTimeout());
var setpoints = new SetpointService(commands, PresetFile.load(settings.presetsFile()));
return new ApiServer(settings.port(),
new HeatPumpService(gateway, clock),
new EnergyService(gateway, clock),
new HealthService(gateway, clock),
new HistoryService(influx, clock));
new HistoryService(influx, clock), setpoints);
}
}
+5 -1
View File
@@ -12,14 +12,18 @@ public final class 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;
}
ApiServer server = Backend.assemble(settings, Clock.systemUTC());
if (settings.gatewayToken() == null) {
System.err.println("home-pump-backend: GATEWAY_TOKEN is not set, setpoint changes are disabled");
}
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
server.start();
System.out.println("home-pump-backend listening on port " + server.port());
@@ -1,11 +1,14 @@
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) {
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(
@@ -13,7 +16,15 @@ public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gateway
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)));
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) {
@@ -25,6 +36,12 @@ public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gateway
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;
}
@@ -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,81 @@
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.datatype.jsr310.JavaTimeModule;
import home.backend.application.GatewayCommands;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
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.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);
@JsonInclude(JsonInclude.Include.NON_NULL)
private record Request(String setpoint, int value, Long minutes) {
}
private static final ObjectMapper JSON = new ObjectMapper()
.registerModule(new JavaTimeModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private final URI endpoint;
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.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));
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 SetpointState setpoints() throws IOException {
return JSON.readValue(send(request().GET(), Set.of(200)), SetpointState.class);
}
private HttpRequest.Builder request() throws IOException {
if (token == null) {
throw new IOException("GATEWAY_TOKEN is not set, commands are disabled");
}
return HttpRequest.newBuilder(endpoint).timeout(timeout).header("Authorization", "Bearer " + token);
}
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);
}
}
}
@@ -1,14 +1,21 @@
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.EnergyService;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService;
import home.backend.application.SetpointService;
import home.backend.domain.CommandResult.Status;
import home.backend.domain.PresetResult;
import java.io.IOException;
import java.net.InetSocketAddress;
@@ -16,6 +23,8 @@ 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. */
@@ -23,20 +32,68 @@ public class ApiServer {
private static final ObjectMapper JSON = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
.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) {
}
private static final String UNREACHABLE = "gateway unreachable";
private static final String WRITE_MARKER = "home-pump";
private static final String PRESETS_PATH = "/api/presets/";
/** 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, HealthService health,
HistoryService history) throws IOException {
HistoryService history, SetpointService setpoints) throws IOException {
server = HttpServer.create(new InetSocketAddress(port), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.createContext("/", new StaticFiles());
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/health", query -> health.current());
server.createContext("/api/history", exchange -> handleHistory(exchange, history));
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(PRESETS_PATH, exchange -> handle(exchange, Map.of(
"POST", () -> guarded(exchange, body -> postPreset(exchange, 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() {
@@ -44,7 +101,8 @@ public class ApiServer {
}
public void stop() {
server.stop(0);
server.stop(STOP_GRACE_SECONDS);
executor.shutdown();
}
public int port() {
@@ -57,17 +115,103 @@ public class ApiServer {
/** Gateway trouble on a live-state endpoint is the gateway being unreachable: 503. */
private void api(String path, Gateway gateway) {
server.createContext(path, exchange -> respond(exchange, () -> {
route(path, exchange -> handle(exchange, Map.of("GET", () -> {
try {
send(exchange, 200, gateway.get(Map.of()));
} catch (IOException e) {
send(exchange, 503, error("gateway unreachable"));
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 postPreset(HttpExchange exchange, SetpointService setpoints) throws IOException {
String id = exchange.getRequestURI().getPath().substring(PRESETS_PATH.length());
try {
var result = setpoints.applyPreset(id, user(exchange));
if (result.isPresent()) {
send(exchange, presetHttpStatus(result.get().status()), result.get());
} else {
send(exchange, 404, error("no such 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 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 {
respond(exchange, () -> {
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")));
@@ -76,20 +220,33 @@ public class ApiServer {
} catch (IOException e) {
send(exchange, 502, error("history unavailable"));
}
});
}));
}
private interface Action {
void run() throws IOException;
}
private static void respond(HttpExchange exchange, Action action) 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) {
if (!exchange.getRequestMethod().equals("GET")) {
Action action = byMethod.get(exchange.getRequestMethod());
if (action == null) {
exchange.sendResponseHeaders(405, -1);
return;
}
action.run();
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"));
}
}
}
}
@@ -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";
}
}
@@ -1,63 +0,0 @@
package home.backend.adapters.http;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Arrays;
import java.util.Map;
/** Serves the web UI from the classpath folder {@code web/}; only files of known types. */
final class StaticFiles implements HttpHandler {
private static final Map<String, String> CONTENT_TYPES = Map.of(
"html", "text/html; charset=utf-8",
"css", "text/css; charset=utf-8",
"js", "text/javascript; charset=utf-8",
"svg", "image/svg+xml",
"png", "image/png",
"ico", "image/x-icon");
@Override
public void handle(HttpExchange exchange) throws IOException {
try (exchange) {
if (!exchange.getRequestMethod().equals("GET")) {
exchange.sendResponseHeaders(405, -1);
return;
}
String path = exchange.getRequestURI().getPath();
String resource = path.equals("/") ? "index.html" : path.substring(1);
String type = contentType(resource);
try (InputStream in = type == null || !isSafe(resource) ? null : open(resource)) {
if (in == null) {
exchange.sendResponseHeaders(404, -1);
return;
}
byte[] body = in.readAllBytes();
exchange.getResponseHeaders().set("Content-Type", type);
exchange.getResponseHeaders().set("Cache-Control", "no-cache");
exchange.sendResponseHeaders(200, body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
}
}
}
private static InputStream open(String resource) {
return StaticFiles.class.getClassLoader().getResourceAsStream("web/" + resource);
}
/** The path is already URL-decoded, so this also catches %2e%2e. */
private static boolean isSafe(String resource) {
return !resource.contains("\\") && !resource.contains("\0") && !resource.contains("//")
&& Arrays.stream(resource.split("/")).noneMatch(s -> s.equals("..") || s.equals("."));
}
private static String contentType(String resource) {
int dot = resource.lastIndexOf('.');
return dot < 0 ? null : CONTENT_TYPES.get(resource.substring(dot + 1));
}
}
@@ -16,6 +16,8 @@ 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 {
@@ -35,16 +37,31 @@ public final class InfluxHistoryRepository implements HistoryRepository {
}
long stepMinutes = stepMinutes(from, to, maxPoints);
Map<Long, Double> summed = new TreeMap<>();
for (Source source : SeriesSources.of(series)) {
String query = source.query(from.toEpochMilli(), to.toEpochMilli(), stepMinutes);
// A part without data is absent, not zero, so a bucket is kept if any part has it.
InfluxResponse.parse(fetch(source.database(), query)).forEach((t, v) -> summed.merge(t, v, Double::sum));
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()));
@@ -1,6 +1,7 @@
package home.backend.application;
import home.backend.domain.EnergyView;
import home.backend.domain.EnergyView.Source;
import home.backend.domain.EnergyView.Today;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
@@ -8,6 +9,7 @@ import home.backend.domain.GatewaySnapshot.Measurement;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
@@ -15,6 +17,10 @@ import java.util.stream.Stream;
/** F7: house electricity from the Deye inverter and the Fronius PV inverter. */
public class EnergyService {
/** One inverter's fields; empty when it is stale, so nothing old is shown as current. */
private record Inverter(Source source, Map<String, Double> fields) {
}
private final GatewayClient gateway;
private final Clock clock;
@@ -25,23 +31,30 @@ public class EnergyService {
public EnergyView current() throws IOException {
GatewaySnapshot snapshot = gateway.snapshot();
Optional<Measurement> deye = SnapshotFields.find(snapshot, "deye", "deye");
Optional<Measurement> fronius = SnapshotFields.find(snapshot, "fronius", "fronius");
Map<String, Double> d = deye.map(Measurement::fields).orElse(Map.of());
Map<String, Double> f = fronius.map(Measurement::fields).orElse(Map.of());
Inverter deye = inverter(snapshot, "deye");
Inverter fronius = inverter(snapshot, "fronius");
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"));
return new EnergyView(time(deye, fronius),
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 Instant time(Optional<Measurement> deye, Optional<Measurement> fronius) {
return Stream.of(deye, fronius).flatMap(Optional::stream).map(Measurement::time)
.max(Instant::compareTo).orElseGet(clock::instant);
private Inverter inverter(GatewaySnapshot snapshot, String name) {
Optional<Measurement> reading = SnapshotFields.find(snapshot, name, name);
boolean stale = SnapshotFields.stale(SnapshotFields.device(snapshot, name), reading, clock.instant());
Source source = new Source(name, reading.map(Measurement::time).orElse(null), stale);
return new Inverter(source, stale ? Map.of() : reading.get().fields());
}
private static Instant oldest(List<Source> sources) {
return sources.stream().map(Source::time).flatMap(Stream::ofNullable).min(Instant::compareTo).orElse(null);
}
/** Sum of the values that exist; null when none does. */
@@ -0,0 +1,16 @@
package home.backend.application;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
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;
SetpointState setpoints() throws IOException;
}
@@ -1,5 +1,6 @@
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;
@@ -12,6 +13,7 @@ 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;
@@ -31,15 +33,18 @@ public class HeatPumpService {
}
public HeatPumpView current() throws IOException {
return SnapshotFields.find(gateway.snapshot(), "heat-pump", "heat_pump")
.filter(m -> DATABASE.equals(m.database()))
.map(m -> view(m.time(), m.fields()))
.orElseGet(() -> view(clock.instant(), Map.of()));
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, Map<String, Double> f) {
return new HeatPumpView(time, state(f), whole(f, "output_compressor"), on(f, "output_heater"),
on(f, "alarm"),
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"),
@@ -52,6 +57,23 @@ public class HeatPumpService {
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);
@@ -0,0 +1,104 @@
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.Setpoint;
import home.backend.domain.SetpointState;
import home.backend.domain.SetpointsView;
import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/** 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));
}
/**
* 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.
*/
public Optional<PresetResult> applyPreset(String id, String user) throws IOException {
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
if (preset.isEmpty()) {
return Optional.empty();
}
List<Change> changes = preset.get().changes();
List<Setpoint> limits = gateway.setpoints().setpoints();
List<ChangeResult> checked = changes.stream().map(c -> check(c, limits)).toList();
if (checked.stream().anyMatch(r -> r.status() == ChangeStatus.REJECTED)) {
return Optional.of(PresetResult.of(checked));
}
return Optional.of(PresetResult.of(run(changes, user)));
}
private List<ChangeResult> run(List<Change> changes, String user) {
List<ChangeResult> results = new ArrayList<>();
for (Change change : changes) {
boolean stopped = !results.isEmpty() && results.getLast().status() != ChangeStatus.APPLIED;
results.add(stopped ? ChangeResult.notAttempted() : attempt(change, user));
}
return results;
}
private ChangeResult attempt(Change change, String user) {
try {
return ChangeResult.of(apply(change.setpoint(), change.value(), change.hours(), user));
} catch (IOException e) {
return new ChangeResult(ChangeStatus.UNKNOWN, "no answer from the gateway", null);
}
}
/** REJECTED with the reason, or NOT_ATTEMPTED (nothing is sent if any change is rejected). */
private static ChangeResult check(Change change, List<Setpoint> limits) {
String problem = problem(change, limits);
return problem == null ? ChangeResult.notAttempted() : new ChangeResult(ChangeStatus.REJECTED, problem, null);
}
private static String problem(Change change, 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();
}
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);
}
}
@@ -4,6 +4,8 @@ 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;
@@ -14,6 +16,19 @@ 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;
}
static Optional<Measurement> find(GatewaySnapshot snapshot, String device, String measurement) {
return snapshot.devices().stream()
.filter(d -> d.name().equals(device))
@@ -0,0 +1,10 @@
package home.backend.domain;
import java.time.Duration;
/** Set a setpoint; with a duration the gateway restores the old value afterwards, else it stays. */
public record Command(String setpoint, int value, Duration duration, String requestedBy) {
/** 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 }
}
@@ -1,13 +1,19 @@
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. Null when the source device is not read.
* − 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, Double pvPowerW, Double gridPowerW, Double batteryPowerW,
Double batterySocPct, Double loadPowerW, Today today) {
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) {
@@ -2,9 +2,13 @@ package home.backend.domain;
import java.time.Instant;
/** What the heat pump does now (F1). Values are null when the gateway has not read them yet. */
public record HeatPumpView(Instant time, State state, Integer compressorPercent, boolean heaterOn,
boolean alarm, Pumps pumps, Temperatures temperatures, Targets targets, Setpoints setpoints) {
/**
* 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 }
@@ -0,0 +1,29 @@
package home.backend.domain;
import java.util.List;
/** A named one-tap bundle of setpoint changes (F3), e.g. boost or away. */
public record Preset(String id, String name, List<Change> changes) {
/** {@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);
}
}
}
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");
}
}
}
@@ -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,7 @@
package home.backend.domain;
import java.time.Instant;
/** A boost the gateway will undo at {@code due}, unless someone changed the register meanwhile. */
public record Restore(String setpoint, int register, int originalValue, int appliedValue, Instant due) {
}
@@ -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,15 @@
[
{
"id": "cwu-now",
"name": "Ciepła woda teraz",
"changes": [ { "setpoint": "cwu_economy", "value": 45, "hours": 2 } ]
},
{
"id": "away",
"name": "Wyjazd",
"changes": [
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
{ "setpoint": "co_comfort", "value": 15, "hours": null }
]
}
]
-5
View File
@@ -1,5 +0,0 @@
export async function getJson(url) {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) throw new Error(`${url}: ${response.status}`);
return response.json();
}
-64
View File
@@ -1,64 +0,0 @@
import { ageMinutes, ago } from "./format.js";
import heatPump from "./heat-pump.js";
import energy from "./energy.js";
import history from "./history.js";
import status from "./status.js";
const REFRESH_MS = 30000;
const STALE_MINUTES = 10;
const screens = { "heat-pump": heatPump, energy, history, status };
const root = document.getElementById("screen");
const tabs = document.querySelectorAll("#tabs button");
const cache = {}; // last good data per screen, kept while the backend is unreachable
let current = screens[location.hash.slice(1)] ? location.hash.slice(1) : "heat-pump";
function notice(offline, data) {
if (offline) {
const last = data
? ` Pokazuję ostatnie dane (${ago(data.time)}).`
: " Nie można pobrać danych z domu. Aplikacja spróbuje znowu za chwilę.";
return `<div class="banner bad" role="alert">Brak połączenia.${last}</div>`;
}
if (data.time && ageMinutes(data.time) >= STALE_MINUTES) {
return `<div class="banner warn">Dane są nieaktualne: ${ago(data.time)}.</div>`;
}
return "";
}
function draw(name, offline) {
if (name !== current) return;
const data = cache[name];
root.innerHTML = notice(offline, data) + (data ? screens[name].view(data) : "");
screens[name].bind?.(root, data, () => refresh(name));
}
async function refresh(name) {
let offline = false;
try {
cache[name] = await screens[name].load();
} catch {
offline = true;
}
draw(name, offline);
}
function show(name) {
current = name;
location.hash = name;
for (const tab of tabs) {
if (tab.dataset.screen === name) tab.setAttribute("aria-current", "page");
else tab.removeAttribute("aria-current");
}
window.scrollTo(0, 0);
if (cache[name]) draw(name, false);
else root.innerHTML = `<p class="muted">Ładowanie…</p>`;
refresh(name);
}
for (const tab of tabs) tab.addEventListener("click", () => show(tab.dataset.screen));
setInterval(() => refresh(current), REFRESH_MS);
document.addEventListener("visibilitychange", () => {
if (!document.hidden) refresh(current);
});
show(current);
-100
View File
@@ -1,100 +0,0 @@
import { number, hour, dayAndHour } from "./format.js";
const W = 340, H = 220;
const MARGIN = { left: 48, right: 22, top: 24, bottom: 26 };
const Y_TICKS = 4; // about; the step is rounded
const DAY_NAMES = ["nd", "pn", "wt", "śr", "cz", "pt", "sb"];
const DAY_MS = 86400000;
const HOURS_BETWEEN_TICKS = 6;
// Local midnights (or every 6th full hour) inside the range, so the axis reads like a clock.
function timeTicks(t0, t1, range) {
const ticks = [];
const date = new Date(t0);
date.setMinutes(0, 0, 0);
if (range === "7d") date.setHours(0);
else date.setHours(Math.ceil(date.getHours() / HOURS_BETWEEN_TICKS) * HOURS_BETWEEN_TICKS);
for (; date.getTime() <= t1; range === "7d" ? date.setDate(date.getDate() + 1) : date.setHours(date.getHours() + HOURS_BETWEEN_TICKS)) {
if (date.getTime() >= t0) ticks.push(date.getTime());
}
return ticks;
}
// 1, 2 or 5 times a power of ten, so the value labels are round numbers.
function niceStep(rough) {
const magnitude = 10 ** Math.floor(Math.log10(rough));
return [1, 2, 5, 10].map((m) => m * magnitude).find((s) => s >= rough);
}
const xText = (px, anchor, text) => `<text class="sub" x="${px}" y="${H - 6}" text-anchor="${anchor}">${text}</text>`;
function hourLabels(ticks, x) {
return ticks.map((t) => xText(x(t), "middle", hour(new Date(t)))).join("");
}
// A weekday name sits in the middle of its day, between two midnight lines.
function dayLabels(midnights, t0, t1, x) {
const edges = [t0, ...midnights, t1];
return edges.slice(0, -1).map((from, i) => {
const to = edges[i + 1];
if (to - from < DAY_MS / 3) return "";
return xText((x(from) + x(to)) / 2, "middle", DAY_NAMES[new Date(from + 1).getDay()]);
}).join("");
}
// Time and value axes for a series of {t, v} points; returns the scales and the SVG markup.
export function lineChart(points, range, unit) {
const times = points.map((p) => Date.parse(p.t));
const values = points.map((p) => p.v);
const [t0, t1] = [Math.min(...times), Math.max(...times)];
let [lo, hi] = [Math.min(...values), Math.max(...values)];
if (hi - lo < 1) [lo, hi] = [lo - 0.5, hi + 0.5];
const step = niceStep((hi - lo) / Y_TICKS);
[lo, hi] = [Math.floor(lo / step) * step, Math.ceil(hi / step) * step];
const x = (t) => MARGIN.left + ((t - t0) / (t1 - t0 || 1)) * (W - MARGIN.left - MARGIN.right);
const y = (v) => H - MARGIN.bottom - ((v - lo) / (hi - lo)) * (H - MARGIN.top - MARGIN.bottom);
const digits = step < 1 ? 1 : 0;
const yLabels = Array.from({ length: Math.round((hi - lo) / step) + 1 }, (_, i) => {
const v = lo + i * step;
return `<line class="axis" x1="${MARGIN.left}" x2="${W - MARGIN.right}" y1="${y(v)}" y2="${y(v)}"/>
<text class="sub" x="${MARGIN.left - 6}" y="${y(v) + 4}" text-anchor="end">${number(v, digits)}</text>`;
}).join("");
const ticks = timeTicks(t0, t1, range);
const grid = ticks.map((t) => `<line class="axis" x1="${x(t)}" x2="${x(t)}" y1="${MARGIN.top}" y2="${H - MARGIN.bottom}"/>`).join("");
const xLabels = range === "7d" ? dayLabels(ticks, t0, t1, x) : hourLabels(ticks, x);
const path = points.map((p, i) => `${i ? "L" : "M"}${x(times[i]).toFixed(1)} ${y(p.v).toFixed(1)}`).join("");
const svg = `<svg class="chart" viewBox="0 0 ${W} ${H}" role="img" aria-label="Wykres">
<text class="sub" x="2" y="12">${unit}</text>
${yLabels}${grid}${xLabels}<path class="line" d="${path}"/>
<g id="cursor" hidden><line class="cursor" y1="${MARGIN.top}" y2="${H - MARGIN.bottom}"/>
<circle class="mark" r="5"/></g></svg>`;
return { svg, x, y, min: Math.min(...values), max: Math.max(...values) };
}
// Shows the point nearest to the finger or mouse in the readout element.
export function trackPointer(svg, readout, chart, { points, unit }) {
const cursor = svg.querySelector("#cursor");
const times = points.map((p) => Date.parse(p.t));
const show = (event) => {
const box = svg.getBoundingClientRect();
const px = ((event.clientX - box.left) / box.width) * W;
let best = 0;
times.forEach((time, i) => {
if (Math.abs(chart.x(time) - px) < Math.abs(chart.x(times[best]) - px)) best = i;
});
const [cx, cy] = [chart.x(times[best]), chart.y(points[best].v)];
cursor.hidden = false;
cursor.querySelector("line").setAttribute("x1", cx);
cursor.querySelector("line").setAttribute("x2", cx);
cursor.querySelector("circle").setAttribute("cx", cx);
cursor.querySelector("circle").setAttribute("cy", cy);
readout.textContent = `${dayAndHour(new Date(times[best]))}: ${number(points[best].v, unit === "W" || unit === "%" ? 0 : 1)} ${unit}`;
};
svg.addEventListener("pointerdown", show);
svg.addEventListener("pointermove", (event) => {
if (event.buttons || event.pointerType === "mouse") show(event);
});
}
-78
View File
@@ -1,78 +0,0 @@
import { getJson } from "./api.js";
import { kwh, percent, power } from "./format.js";
const FLOWING_W = 10;
// Nodes sit around the house; an arrow runs from the source to the sink of each flow.
const NODES = {
pv: { x: 68, y: 40, label: "Słońce" },
grid: { x: 292, y: 40, label: "Sieć" },
house: { x: 180, y: 125, label: "Dom" },
battery: { x: 180, y: 215, label: "Bateria" },
};
function node(id, value) {
const { x, y, label } = NODES[id];
return `<rect class="box" x="${x - 58}" y="${y - 30}" width="116" height="60" rx="10"/>
<text x="${x}" y="${y - 7}" text-anchor="middle" font-size="15" class="sub">${label}</text>
<text x="${x}" y="${y + 18}" text-anchor="middle" font-size="20" font-weight="700">${value}</text>`;
}
function flow(from, to, watts) {
if (watts == null || Math.abs(watts) < FLOWING_W) return "";
const [source, sink] = watts > 0 ? [from, to] : [to, from];
const a = NODES[source], b = NODES[sink];
const length = Math.hypot(b.x - a.x, b.y - a.y);
const t1 = 40 / length, t2 = 1 - 46 / length;
const point = (t, axis) => a[axis] + (b[axis] - a[axis]) * t;
return `<line class="flow" x1="${point(t1, "x")}" y1="${point(t1, "y")}"
x2="${point(t2, "x")}" y2="${point(t2, "y")}" marker-end="url(#arrow)"/>`;
}
function flowPicture(e) {
return `<svg class="schematic" viewBox="0 0 360 260" role="img" aria-label="Przepływ energii w domu">
<defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5"
orient="auto"><path d="M0 0 10 5 0 10z" fill="var(--accent)"/></marker></defs>
${flow("pv", "house", e.pvPowerW)}${flow("grid", "house", e.gridPowerW)}
${flow("battery", "house", e.batteryPowerW)}
${node("pv", power(e.pvPowerW))}${node("grid", power(magnitude(e.gridPowerW)))}
${node("house", power(e.loadPowerW))}${node("battery", percent(e.batterySocPct))}
</svg>`;
}
const magnitude = (watts) => (watts == null ? null : Math.abs(watts));
function gridText(watts) {
if (watts == null) return "—";
if (Math.abs(watts) < FLOWING_W) return "bez wymiany";
return watts > 0 ? "pobór" : "oddawanie";
}
function batteryText(watts) {
if (watts == null) return "—";
if (Math.abs(watts) < FLOWING_W) return "bez przepływu";
return watts > 0 ? `rozładowuje się, ${power(watts)}` : `ładuje się, ${power(-watts)}`;
}
const tile = (label, value, note = "") =>
`<div class="tile"><div class="muted">${label}</div><div class="big">${value}</div>
<div class="muted">${note}</div></div>`;
function view(e) {
const today = e.today;
return `<section class="card"><h1>Energia teraz</h1>${flowPicture(e)}</section>
<section class="card"><div class="grid2">
${tile("Produkcja PV", power(e.pvPowerW), "Deye + Fronius")}
${tile("Zużycie domu", power(e.loadPowerW))}
${tile("Sieć", power(magnitude(e.gridPowerW)), gridText(e.gridPowerW))}
${tile("Bateria", percent(e.batterySocPct), batteryText(e.batteryPowerW))}
</div></section>
<section class="card"><h2>Dzisiaj</h2><div class="grid2">
${tile("Produkcja PV", kwh(today.pvKwh))}
${tile("Zużycie", kwh(today.loadKwh))}
${tile("Pobrano z sieci", kwh(today.gridBoughtKwh))}
${tile("Oddano do sieci", kwh(today.gridSoldKwh))}
</div></section>`;
}
export default { load: () => getJson("api/energy"), view };
-39
View File
@@ -1,39 +0,0 @@
// Polish number and time formats; every function accepts null and shows a dash.
const DASH = "—";
const decimals = (digits) => new Intl.NumberFormat("pl-PL", {
minimumFractionDigits: digits, maximumFractionDigits: digits,
});
const formats = [decimals(0), decimals(1), decimals(2)];
// The typographic minus is wider than the hyphen, so -3 is not read as a dash or a range.
export function number(value, digits = 0) {
return value == null ? DASH : formats[digits].format(value).replace("-", "\u2212");
}
export const celsius = (value) => (value == null ? DASH : `${number(value, 1)} °C`);
export const percent = (value) => (value == null ? DASH : `${number(value)} %`);
export const kwh = (value) => (value == null ? DASH : `${number(value, 1)} kWh`);
export function power(watts) {
if (watts == null) return DASH;
return Math.abs(watts) >= 1000 ? `${number(watts / 1000, 1)} kW` : `${number(watts)} W`;
}
export function ageMinutes(iso, now = Date.now()) {
return Math.max(0, Math.floor((now - Date.parse(iso)) / 60000));
}
export function ago(iso) {
if (iso == null) return "brak odczytu";
const minutes = ageMinutes(iso);
if (minutes < 1) return "przed chwilą";
if (minutes < 60) return `${minutes} min temu`;
const hours = Math.floor(minutes / 60);
if (hours < 48) return `${hours} godz. temu`;
return `${Math.floor(hours / 24)} dni temu`;
}
export const hour = (date) => date.toLocaleTimeString("pl-PL", { hour: "2-digit", minute: "2-digit" });
export const weekday = (date) => date.toLocaleDateString("pl-PL", { weekday: "short" });
export const dayAndHour = (date) => `${weekday(date)} ${hour(date)}`;
@@ -1,53 +0,0 @@
import { getJson } from "./api.js";
import { celsius, number, hour } from "./format.js";
import { schematic } from "./schematic.js";
const HEADLINES = {
IDLE: "Pompa odpoczywa",
HEATING_CWU: "Grzeje wodę (CWU)",
HEATING_CO: "Grzeje dom (CO)",
HEATING_BOTH: "Grzeje dom i wodę",
};
const PUMP_NAMES = {
ground: "Pompa gruntowa", buffer: "Pompa bufora", co: "Pompa CO",
cwu: "Pompa CWU", circulation: "Pompa cyrkulacji",
};
const row = (label, value) => `<div><span>${label}</span><b>${value}</b></div>`;
function pumpRows(pumps) {
return Object.entries(PUMP_NAMES)
.map(([key, name]) => row(name, pumps[key] ? "działa" : "stoi")).join("");
}
function view(data) {
const { targets, setpoints: s, temperatures: t } = data;
const alarm = data.alarm
? `<div class="banner bad" role="alert">ALARM pompy ciepła. Sprawdź sterownik w domu.</div>` : "";
return `${alarm}
<section class="card">
<p class="headline">${HEADLINES[data.state]}</p>
<p class="muted">Odczyt z ${hour(new Date(data.time))}</p>
${schematic(data)}
</section>
<section class="card rows"><h2>Pompy</h2>${pumpRows(data.pumps)}</section>
<section class="card rows"><h2>Temperatury</h2>
${row("Na zewnątrz", celsius(t.outdoor))}
${row("Średnia zewnętrzna", celsius(t.outdoorAvg))}
</section>
<section class="card rows"><h2>Cele teraz</h2>
${row("Pokój", celsius(targets.room))}
${row("Woda grzewcza CO", celsius(targets.co))}
${row("Ciepła woda CWU", celsius(targets.cwu))}
</section>
<section class="card rows"><h2>Nastawy</h2>
${row("CO oszczędnie", `${number(s.coEconomy)} °C`)}
${row("CO komfort", `${number(s.coComfort)} °C`)}
${row("CWU oszczędnie", `${number(s.cwuEconomy)} °C`)}
${row("CWU komfort", `${number(s.cwuComfort)} °C`)}
<p class="muted">Tylko podgląd. Zmiana nastaw będzie później.</p>
</section>`;
}
export default { load: () => getJson("api/heat-pump"), view };
-61
View File
@@ -1,61 +0,0 @@
import { getJson } from "./api.js";
import { number } from "./format.js";
import { lineChart, trackPointer } from "./chart.js";
// Ids as in Series.java.
const SERIES = {
outdoor_temp: "Temperatura na zewnątrz",
indoor_temp: "Temperatura w domu",
co_buffer_temp: "Bufor CO",
cwu_temp: "Ciepła woda (CWU)",
ground_in_temp: "Gruntowe: do pompy",
ground_out_temp: "Gruntowe: z pompy",
pump_outlet_temp: "Wyjście pompy ciepła",
grid_power: "Moc z sieci (+ pobór, − oddawanie)",
pv_power: "Produkcja PV",
load_power: "Zużycie domu",
battery_soc: "Bateria (naładowanie)",
};
const RANGES = { "24h": "24 godziny", "7d": "7 dni" };
const choice = { series: "cwu_temp", range: "24h" };
let chart = null; // the one drawn by the last view, for the pointer handler
const options = () => Object.entries(SERIES)
.map(([id, name]) => `<option value="${id}"${id === choice.series ? " selected" : ""}>${name}</option>`).join("");
const rangeButtons = () => Object.entries(RANGES)
.map(([id, name]) => `<button data-range="${id}" aria-pressed="${id === choice.range}">${name}</button>`).join("");
function body(data) {
chart = null;
if (data.points.length < 2) return `<p class="muted">Za mało danych w tym okresie.</p>`;
chart = lineChart(data.points, choice.range, data.unit);
const digits = data.unit === "°C" ? 1 : 0;
return `${chart.svg}
<p class="readout" id="readout">Dotknij wykresu, aby zobaczyć wartość.</p>
<p class="muted">Najmniej ${number(chart.min, digits)} ${data.unit} · najwięcej ${number(chart.max, digits)} ${data.unit}</p>`;
}
const view = (data) => `<section class="card"><h1>Historia</h1>
<select id="series" aria-label="Wielkość">${options()}</select>
<div class="seg">${rangeButtons()}</div>
${body(data)}</section>`;
function bind(root, data, reload) {
root.querySelector("#series").addEventListener("change", (event) => {
choice.series = event.target.value;
reload();
});
for (const button of root.querySelectorAll("[data-range]")) {
button.addEventListener("click", () => {
choice.range = button.dataset.range;
reload();
});
}
if (chart) trackPointer(root.querySelector("svg.chart"), root.querySelector("#readout"), chart, data);
}
const load = () => getJson(`api/history?series=${choice.series}&range=${choice.range}`);
export default { load, view, bind };
-21
View File
@@ -1,21 +0,0 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Dom Krosno</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="style.css">
<script type="module" src="app.js"></script>
</head>
<body>
<main id="screen" aria-live="polite"></main>
<nav id="tabs" aria-label="Ekrany">
<button data-screen="heat-pump"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3c3 4 5 6.5 5 10a5 5 0 0 1-10 0c0-2 1-3.5 2-5 1 1 1.5 2 2 2.5C11.5 8.5 11 6 12 3z"/></svg>Pompa ciepła</button>
<button data-screen="energy"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 5 13h6l-1 9 8-11h-6z"/></svg>Energia</button>
<button data-screen="history"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 18 9 11l4 4 8-10" fill="none" stroke-width="2.5"/></svg>Historia</button>
<button data-screen="status"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" fill="none" stroke-width="2.5"/><path d="m8 12 3 3 5-6" fill="none" stroke-width="2.5"/></svg>Stan</button>
</nav>
</body>
</html>
@@ -1,47 +0,0 @@
import { celsius, number } from "./format.js";
// The installation as one picture: ground loop → heat pump → CO buffer → house, and the CWU
// tank. The solar collector has no sensor, so it is drawn without values.
const on = (flag) => (flag ? " on" : "");
function pump(x, y, running) {
return `<g class="${running ? "on" : ""}" transform="translate(${x} ${y})">
<circle class="pump" r="13"/>
<path class="spokes" d="M-8 0H8M0-8V8"/></g>`;
}
function box(x, y, width, height, lines) {
const text = lines.map(([content, cls], i) =>
`<text x="${x + 10}" y="${y + 22 + i * 20}" class="${cls ?? ""}" font-size="${cls ? 15.5 : 17}"
font-weight="${cls ? 400 : 700}">${content}</text>`).join("");
return `<rect class="box" x="${x}" y="${y}" width="${width}" height="${height}" rx="10"/>${text}`;
}
export function schematic(view) {
const { temperatures: t, pumps, state } = view;
const heatsCo = state === "HEATING_CO" || state === "HEATING_BOTH";
const heatsCwu = state === "HEATING_CWU" || state === "HEATING_BOTH";
const compressor = view.compressorPercent == null ? "—" : `${number(view.compressorPercent)} %`;
return `<svg class="schematic" viewBox="0 0 360 460" role="img"
aria-label="Schemat instalacji: odwierty, pompa ciepła, bufor CO, dom i zbiornik CWU">
<path class="pipe cold${on(pumps.ground)}" d="M150 78V130M210 78V130"/>
<path class="pipe${on(heatsCo)}" d="M100 218V262M100 346V376"/>
<path class="pipe${on(heatsCwu)}" d="M260 218V262"/>
${box(15, 10, 330, 68, [["Odwierty (dolne źródło)"],
[`Do pompy ${celsius(t.groundIn)}`, "sub"], [`Z pompy ${celsius(t.groundOut)}`, "sub"]])}
${box(15, 130, 330, 90, [["Pompa ciepła"],
[`Sprężarka ${compressor} · grzałka ${view.heaterOn ? "włączona" : "wyłączona"}`, "sub"],
[`Wyjście pompy ${celsius(t.pumpOutlet)}`, "sub"]])}
${box(15, 262, 165, 84, [["Bufor CO"], [celsius(t.coBuffer), "sub"], [`Cel ${celsius(view.targets.co)}`, "sub"]])}
${box(190, 262, 155, 84, [["Zbiornik CWU"], [celsius(t.cwu), "sub"], [`Cel ${celsius(view.targets.cwu)}`, "sub"]])}
${box(15, 376, 165, 70, [["Dom (grzejniki)"], [`Wewnątrz ${celsius(t.indoor)}`, "sub"], [`Cel ${celsius(view.targets.room)}`, "sub"]])}
${box(190, 376, 155, 70, [["Kolektor"], ["na dachu", "sub"], ["bez czujnika", "sub"]])}
<path class="pipe" d="M270 346V376" stroke-dasharray="3 7"/>
${pump(210, 104, pumps.ground)}
${pump(100, 240, pumps.buffer)}
${pump(100, 361, pumps.co)}
${pump(260, 240, pumps.cwu)}
${pump(145, 361, pumps.circulation)}
</svg>`;
}
-32
View File
@@ -1,32 +0,0 @@
import { getJson } from "./api.js";
import { ago } from "./format.js";
const DEVICE_NAMES = {
"heat-pump": "Pompa ciepła",
"house-meter": "Licznik domu",
bme280: "Czujnik pogody",
deye: "Falownik Deye (bateria, PV)",
fronius: "Falownik Fronius (PV)",
};
const dot = (up) => `<span class="dot ${up ? "" : "down"}" aria-hidden="true"></span>`;
function device(d) {
const last = d.lastSuccess ? `ostatni odczyt ${ago(d.lastSuccess)}` : "brak odczytu";
const detail = d.up ? last : `nie odpowiada, ${last}`;
return `<div><span>${dot(d.up)}${DEVICE_NAMES[d.name] ?? d.name}<br>
<span class="muted">${detail}</span></span><b>${d.up ? "działa" : "brak"}</b></div>`;
}
const devices = (data) => (data.devices.length
? data.devices.map(device).join("")
: `<p class="muted">Lista pojawi się, gdy dom odpowie.</p>`);
function view(data) {
const gateway = data.gatewayReachable
? `${dot(true)}Dom odpowiada` : `${dot(false)}Dom nie odpowiada`;
return `<section class="card"><h2>Połączenie z domem</h2><p class="headline">${gateway}</p></section>
<section class="card rows"><h2>Urządzenia</h2>${devices(data)}</section>`;
}
export default { load: () => getJson("api/health"), view };
-78
View File
@@ -1,78 +0,0 @@
:root {
--bg: #f1f5f2; --card: #ffffff; --text: #14201a; --muted: #4a5a52; --line: #cfd9d3;
--accent: #0d6b45; --on-accent: #ffffff; --ok: #0d6b45; --warn: #8a5a00; --bad: #b3261e;
--warn-bg: #fff1cc; --bad-bg: #fde0dd; --hot: #c8501a; --cold: #1b6fb5; --off: #8d9a93;
--box: #e8f0eb;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1713; --card: #19241e; --text: #f0f6f2; --muted: #b4c4ba; --line: #34463c;
--accent: #4fcf94; --on-accent: #06210f; --ok: #4fcf94; --warn: #f2c14e; --bad: #ff8a80;
--warn-bg: #3d3210; --bad-bg: #4a1f1c; --hot: #ff9a62; --cold: #6bb6f2; --off: #7b8b82;
--box: #223229;
}
}
* { box-sizing: border-box; }
html { font-size: 18px; }
body {
margin: 0; background: var(--bg); color: var(--text);
font-family: system-ui, "Segoe UI", Roboto, sans-serif; line-height: 1.35;
}
main { max-width: 640px; margin: 0 auto; padding: 16px 12px 96px; }
h1 { font-size: 1.3rem; margin: 0 0 12px; }
h2 { font-size: 1.05rem; margin: 0 0 8px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 14px; margin-bottom: 12px; }
.muted { color: var(--muted); font-size: 0.85rem; }
.headline { font-size: 1.5rem; font-weight: 700; margin: 0 0 4px; }
.banner { border-radius: 12px; padding: 12px 14px; margin-bottom: 12px; font-weight: 600; }
.banner.bad { background: var(--bad-bg); color: var(--bad); border: 2px solid var(--bad); }
.banner.warn { background: var(--warn-bg); color: var(--warn); border: 2px solid var(--warn); }
.rows > div { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-top: 1px solid var(--line); }
.rows > div:first-child { border-top: 0; }
.rows b { font-variant-numeric: tabular-nums; text-align: right; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.tile { background: var(--box); border-radius: 12px; padding: 12px; }
.tile .big { font-size: 1.5rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.dot { display: inline-block; width: 14px; height: 14px; border-radius: 50%; margin-right: 8px; vertical-align: -1px; background: var(--ok); }
.dot.down { background: var(--bad); }
nav {
position: fixed; left: 0; right: 0; bottom: 0; display: flex; background: var(--card);
border-top: 1px solid var(--line); padding-bottom: env(safe-area-inset-bottom);
}
nav button {
flex: 1; min-height: 64px; border: 0; background: none; color: var(--muted); font: inherit;
font-size: 0.75rem; font-weight: 600; white-space: nowrap; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 2px; padding: 4px 2px;
}
nav svg { width: 26px; height: 26px; fill: currentColor; stroke: currentColor; stroke-linejoin: round; stroke-linecap: round; }
nav button[aria-current="page"] { color: var(--accent); box-shadow: inset 0 3px 0 var(--accent); }
button:focus-visible, select:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
.seg { display: flex; gap: 8px; margin: 8px 0; }
.seg button, select {
flex: 1; min-height: 48px; border: 2px solid var(--line); background: var(--card); color: var(--text);
border-radius: 12px; font: inherit; font-weight: 600;
}
select { width: 100%; padding: 0 10px; }
.seg button[aria-pressed="true"] { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
svg text { fill: var(--text); font-family: inherit; }
svg .sub { fill: var(--muted); }
.schematic { width: 100%; height: auto; display: block; }
.box { fill: var(--box); stroke: var(--line); stroke-width: 1.5; }
.pipe { stroke: var(--off); stroke-width: 5; fill: none; }
.pipe.on { stroke: var(--hot); }
.pipe.cold.on { stroke: var(--cold); }
.pump { fill: var(--card); stroke: var(--off); stroke-width: 3; }
.on .pump { stroke: var(--ok); }
.spokes { stroke: var(--off); stroke-width: 3; transform-origin: center; transform-box: fill-box; }
.on .spokes { stroke: var(--ok); animation: spin 2s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .on .spokes { animation: none; } }
.flow { stroke: var(--accent); stroke-width: 4; }
.chart text { font-size: 14.5px; }
.chart [hidden] { display: none; }
.chart .axis { stroke: var(--line); stroke-width: 1; }
.chart .line { fill: none; stroke: var(--accent); stroke-width: 2.5; stroke-linejoin: round; }
.chart .cursor { stroke: var(--muted); stroke-width: 1; }
.chart .mark { fill: var(--accent); }
.chart { touch-action: pan-y; width: 100%; height: auto; display: block; }
.readout { min-height: 2.7em; font-weight: 600; }
@@ -2,11 +2,13 @@ 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;
@@ -20,6 +22,8 @@ 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;
@@ -36,6 +40,9 @@ 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;
@@ -46,12 +53,42 @@ class BackendTest {
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));
backend = Backend.assemble(settings, Clock.systemUTC());
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();
@@ -141,16 +178,64 @@ class BackendTest {
}
@Test
void servesTheRealWebUi() throws Exception {
HttpResponse<String> index = get("/");
HttpResponse<String> script = get("/app.js");
void servesSetpointsWithTheBuiltInPresets() throws Exception {
JsonNode view = JSON.readTree(get("/api/setpoints").body());
assertEquals(200, index.statusCode());
assertEquals("text/html; charset=utf-8", index.headers().firstValue("Content-Type").orElseThrow());
assertEquals(Files.readString(Path.of("src/main/resources/web/index.html")), index.body());
// Browsers refuse ES modules served with any other type.
assertEquals(200, script.statusCode());
assertEquals("text/javascript; charset=utf-8", script.headers().firstValue("Content-Type").orElseThrow());
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
@@ -163,6 +248,14 @@ class BackendTest {
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(
@@ -173,12 +266,33 @@ class BackendTest {
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", "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")) {
@@ -0,0 +1,66 @@
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", List.of(new Change("cwu_economy", 45, 2.0))),
new Preset("away", "Wyjazd", 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", 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\", \"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,186 @@
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 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 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.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 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 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);
}
}
@@ -3,7 +3,16 @@ package home.backend.adapters.http;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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.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;
@@ -27,11 +36,13 @@ 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 {
@@ -43,6 +54,11 @@ class ApiServerTest {
private GatewaySnapshot snapshot;
private boolean gatewayDown;
private boolean historyDown;
private RuntimeException gatewayBug;
private final List<Command> sent = new ArrayList<>();
private Status verdict;
private boolean commandsDown;
private int answersBeforeDrop;
private ApiServer server;
@BeforeEach
@@ -52,6 +68,9 @@ class ApiServerTest {
if (gatewayDown) {
throw new IOException("tunnel down");
}
if (gatewayBug != null) {
throw gatewayBug;
}
return snapshot;
};
HistoryRepository history = (series, from, to, max) -> {
@@ -62,9 +81,39 @@ class ApiServerTest {
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;
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 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"))));
}
};
Clock clock = Clock.fixed(HEALTH_TIME, ZoneOffset.UTC);
server = new ApiServer(0, new HeatPumpService(gateway, clock), new EnergyService(gateway, clock),
new HealthService(gateway, clock), new HistoryService(history, clock));
new HealthService(gateway, clock), new HistoryService(history, clock),
new SetpointService(commands, PresetFile.load(null)));
server.start();
}
@@ -91,7 +140,7 @@ class ApiServerTest {
return new GatewaySnapshot(List.of(
new DeviceSnapshot("heat-pump", true, pumpTime, null,
List.of(new Measurement("Dom_Pompa", "heat_pump", pump, energyTime))),
new DeviceSnapshot("deye", false, null, "connect timed out",
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",
@@ -131,10 +180,28 @@ class ApiServerTest {
@Test
void healthMatchesExample() throws Exception {
snapshot = new GatewaySnapshot(snapshot.devices().subList(0, 2));
snapshot = new GatewaySnapshot(List.of(snapshot.devices().get(0),
new DeviceSnapshot("deye", false, null, "connect timed out", List.of())));
assertMatchesExample("/api/health", "health");
}
@Test
void heatPumpWithoutReadingMatchesUnreadExample() throws Exception {
snapshot = new GatewaySnapshot(List.of());
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(devices);
assertMatchesExample("/api/energy", "energy-deye-stale");
}
@Test
void historyMatchesExample() throws Exception {
assertMatchesExample("/api/history?series=cwu_temp&range=24h", "history");
@@ -173,6 +240,26 @@ class ApiServerTest {
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;
@@ -180,6 +267,173 @@ class ApiServerTest {
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 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", "");
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", "{}");
assertEquals(502, response.statusCode());
assertEquals(example("setpoints-preset-partial"), JSON.readTree(response.body()));
}
@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", "").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", "").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"))
@@ -188,50 +442,64 @@ class ApiServerTest {
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 servesTheRealIndexAtRoot() throws Exception {
HttpResponse<String> response = get("/");
assertEquals(200, response.statusCode());
assertEquals("text/html; charset=utf-8", response.headers().firstValue("Content-Type").orElseThrow());
assertTrue(response.body().contains("Dom Krosno"));
}
@Test
void servesStaticFilesWithContentTypes() throws Exception {
Map<String, String> types = Map.of(
"/index.html", "text/html; charset=utf-8",
"/test.css", "text/css; charset=utf-8",
"/test.js", "text/javascript; charset=utf-8",
"/test.svg", "image/svg+xml",
"/test.png", "image/png",
"/test.ico", "image/x-icon");
for (Map.Entry<String, String> entry : types.entrySet()) {
HttpResponse<String> response = get(entry.getKey());
assertEquals(200, response.statusCode(), entry.getKey());
assertEquals(entry.getValue(), response.headers().firstValue("Content-Type").orElseThrow());
}
}
@Test
void missingUnknownTypeAndDirectoriesAre404() throws Exception {
for (String path : List.of("/missing.css", "/sub/data.txt", "/sub", "/sub/", "/web")) {
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 pathTraversalIsRefused() throws Exception {
// secret.css sits next to web/ on the test classpath
for (String path : List.of("/../secret.css", "/%2e%2e/secret.css", "/sub/../../secret.css",
"/%2e%2e%2fsecret.css", "/..%5csecret.css")) {
assertEquals(404, rawGet(path), path);
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);
}
}
@@ -18,9 +18,14 @@ 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;
@@ -33,22 +38,36 @@ class InfluxHistoryRepositoryTest {
private static final Duration TIMEOUT = Duration.ofSeconds(5);
private HttpServer server;
private final List<Map<String, String>> requests = new ArrayList<>();
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);
exchange.sendResponseHeaders(status, bytes.length);
boolean fails = !failingField.isEmpty() && params.get("q").contains("\"" + failingField + "\"");
exchange.sendResponseHeaders(fails ? 500 : status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
@@ -106,6 +125,23 @@ class InfluxHistoryRepositoryTest {
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);
@@ -1,6 +1,7 @@
package home.backend.application;
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;
@@ -75,6 +76,22 @@ class ServicesTest {
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());
@@ -85,6 +102,8 @@ class ServicesTest {
assertNull(view.targets().cwu());
assertNull(view.setpoints().coComfort());
assertFalse(view.alarm());
assertNull(view.alarmCode());
assertNull(view.alarmText());
}
@Test
@@ -105,7 +124,41 @@ class ServicesTest {
HeatPumpView view = service.current();
assertNull(view.state());
assertEquals(NOW, view.time());
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
@@ -153,12 +206,57 @@ class ServicesTest {
}
@Test
void energyWithoutSourcesIsNullAtClockTime() throws IOException {
void energyWithoutSourcesHasNoTimeAndBothSourcesStale() throws IOException {
EnergyView view = energy();
assertNull(view.pvPowerW());
assertNull(view.today().pvKwh());
assertEquals(NOW, view.time());
assertNull(view.time());
assertEquals(List.of(new Source("deye", null, true), new Source("fronius", null, true)), view.sources());
}
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
@@ -0,0 +1,204 @@
package home.backend.application;
import home.backend.adapters.config.PresetFile;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
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.util.ArrayList;
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 SetpointServiceTest {
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 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 SetpointState setpoints() throws IOException {
if (down) {
throw new IOException("tunnel down");
}
return STATE;
}
};
private final List<Preset> presets = PresetFile.load(null);
private final SetpointService service = new SetpointService(gateway, presets);
@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", "anna").orElseThrow();
assertEquals(PresetResult.Status.APPLIED, result.status());
assertEquals(List.of(new Command("cwu_economy", 45, Duration.ofHours(2), "anna")), sent);
}
@Test
void awayPresetSendsBothChangesUntilChanged() throws Exception {
var result = service.applyPreset("away", "anna").orElseThrow();
assertEquals(2, result.results().size());
assertEquals(List.of(new Command("cwu_comfort", 10, null, "anna"), new Command("co_comfort", 15, null, "anna")),
sent);
}
@Test
void unknownPresetIsEmpty() throws Exception {
assertTrue(service.applyPreset("nope", "anna").isEmpty());
assertTrue(sent.isEmpty());
}
@Test
void presetOutsideGatewayLimitsWritesNothing() throws Exception {
var tooHot = new Preset("hot", "Za gorąco", List.of(new Change("co_comfort", 15, null),
new Change("cwu_economy", 60, 2.0)));
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", "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", List.of(new Change("garage_heater", 1, null)));
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", "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", "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", "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", "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", "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", "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", "anna"));
assertTrue(sent.isEmpty());
}
}
@@ -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 }
}
@@ -1,5 +1,9 @@
{
"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,
@@ -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 }
}
@@ -1,9 +1,12 @@
{
"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,
@@ -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 @@
{ "setpoint": "cwu_economy", "value": 45, "hours": 2 }
@@ -0,0 +1,26 @@
{
"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",
"changes": [
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
{ "setpoint": "co_comfort", "value": 15, "hours": null }
]
}
]
}
-1
View File
@@ -1 +0,0 @@
secret
@@ -1 +0,0 @@
x
-1
View File
@@ -1 +0,0 @@
body{}
-1
View File
@@ -1 +0,0 @@
ICO
-1
View File
@@ -1 +0,0 @@
a
-1
View File
@@ -1 +0,0 @@
PNG
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg"/>

Before

Width:  |  Height:  |  Size: 42 B

+5 -4
View File
@@ -16,14 +16,15 @@ the gateway already shows the stack is enough (ADR-007).
- **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**: static HTML, CSS and plain JavaScript in `backend/src/main/resources/web/`, no
build step, no npm. Charts and the schematic are small hand-written SVG. Polish texts.
- **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
- No JavaScript toolchain to install, update or audit; the UI is a few files a person can read.
- Richer interactions later may need a library; revisit then (M6 decides native or not).
- 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.
+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
+18 -18
View File
@@ -15,27 +15,30 @@ Done when: a week on the Pi without manual action, Grafana unchanged. Existing m
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).
- [ ] Skeleton: `Reading`, ports `Device`, `ReadingSink`, `ModbusClient`, `ModbusTransport`
- [ ] Modbus core (ADR-007): FC3/FC4 codec, TCP transport, Solarman V5 transport
- [ ] Heat pump: legacy `Dom_Pompa.POMPA` fields unchanged; 4112–4119, 4201–4203, 4221–4228
- [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`
- [ ] House meter SDM630 (`electricity.PRAD2`) and BME280 via IIO (`measurement.MEASUREMENT`)
- [ ] Deye and Fronius into `electricity.deye`, `electricity.fronius`
- [ ] Influx writer: line protocol over JDK `HttpClient`, file buffer (fixes D2, D8)
- [ ] Poller: one virtual thread per device, snapshot, watchdog without `/health` polling (D1)
- [ ] HTTP: `/health` per device, `/snapshot`
- [ ] Config file outside the jar (D3), wire `Main`, drop modbus4j, influxdb-java and the
- [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)
- [ ] On the Pi, each step approved: D7, config file and unit, IIO overlay and reboot, stop
`rpi-measurements` before the gateway polls the meter, deploy, then retire it
- [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
- [ ] IIO overlay and reboot, then BME280; stop `rpi-measurements` before the gateway polls the
meter, then retire it and the old `home-pump.service` files
## Next
| Milestone | Scope | Done when |
|---|---|---|
| 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); 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 |
@@ -49,11 +52,8 @@ their names and fields; new data goes into new measurements (ADR-005). The heat
- D6 Development runs wrote test fields into the production `Dom_Pompa` database.
- 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 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.
- 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
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+14
View File
@@ -0,0 +1,14 @@
# Built on the build machine's own platform; the image only holds the static files and nginx.
FROM --platform=$BUILDPLATFORM node:24-alpine AS build
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
# Tests run in scripts/check.sh; they read the API contract from backend/, outside this context.
RUN npm run build
FROM nginxinc/nginx-unprivileged:1.29-alpine
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
COPY --from=build /src/dist /usr/share/nginx/html
ENV BACKEND_URL=http://backend:8080
EXPOSE 8080
+15
View File
@@ -0,0 +1,15 @@
# Frontend
The web UI (ADR-008, ADR-010): React, TypeScript, Vite, Tailwind and Recharts, Polish texts. In
production nginx serves the build and passes `/api/` to the backend, so both share one origin.
```bash
cd frontend && npm ci
npm run dev # http://localhost:5173, /api to BACKEND (default http://localhost:8080)
npm test # Vitest; reads the API contract from backend/src/test/resources/api
npm run build # into dist/
docker compose -f frontend/compose.yaml up --build # after the backend's; .env sets HOST and KROSNO_SOURCES
```
The overview picture is `research/ui/house-cutaway.webp`; `python3 scripts/house-assets.py` makes
the sizes and the pipe masks in `src/assets/` from it.
+32
View File
@@ -0,0 +1,32 @@
# The web UI in Kraków (ADR-010): nginx serves the build and passes /api to the backend on the
# network home-pump (start backend/compose.yaml first). Traefik routes to it; .env next to this
# file sets HOST and KROSNO_SOURCES.
name: home-pump-frontend
services:
frontend:
image: git.mariuszpacek.pl/mariusz.pacek/home-pump-frontend:${VERSION:-latest}
build: .
restart: unless-stopped
environment:
BACKEND_URL: http://backend:8080
networks: [home-pump, proxy]
labels:
com.getarcaneapp.arcane.updater: "false"
traefik.enable: "true"
traefik.docker.network: proxy
traefik.http.routers.home-pump.rule: Host(`${HOST:?the public host name}`)
traefik.http.routers.home-pump.entrypoints: web-secured
traefik.http.routers.home-pump.tls: "true"
traefik.http.routers.home-pump.tls.certresolver: http
traefik.http.routers.home-pump.service: home-pump
traefik.http.services.home-pump.loadbalancer.server.port: "8080"
# ADR-009: only Krosno may open it, and no client may name the user who changes a setpoint.
traefik.http.middlewares.home-pump-krosno.ipwhitelist.sourcerange: ${KROSNO_SOURCES:?Krosno's address or range}
traefik.http.middlewares.home-pump-no-user.headers.customrequestheaders.Remote-User: ""
traefik.http.routers.home-pump.middlewares: home-pump-krosno,home-pump-no-user
networks:
home-pump:
external: true
proxy:
external: true
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#0e2a21">
<title>Dom Szczepańcowa</title>
<link rel="icon" href="data:,">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
# The web UI and /api in one origin, so the backend's Origin check and the CSP hold (ADR-010).
# BACKEND_URL is filled in at start by the nginx image's envsubst step.
# Vite names assets after their content, so they never change under the same name.
map $uri $cache_control {
~^/assets/ "public, max-age=31536000, immutable";
default "no-cache";
}
server {
listen 8080;
server_tokens off;
root /usr/share/nginx/html;
# The same hardening the backend sends with the API (SecurityHeaders.java).
add_header Content-Security-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'" always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy no-referrer always;
add_header Cache-Control $cache_control;
location /api/ {
proxy_pass ${BACKEND_URL};
# With the port: the backend compares Origin with Host before a write.
proxy_set_header Host $http_host;
proxy_read_timeout 30s;
}
location / {
try_files $uri /index.html;
}
}
+3442
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "home-pump-web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"test": "vitest run"
},
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"recharts": "^3.10.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.8",
"@types/node": "^26.6.5",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"jsdom": "^29.1.1",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.3.5",
"vitest": "^5.0.5"
}
}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Builds the installation picture's assets from research/ui/house-cutaway.webp (1536 x 1024).
Writes into src/assets: the picture at two widths, and one mask per pipe circuit, found by the
pipes' orange and blue colours inside the circuit's region. Schematic.tsx makes a masked
circuit flow while its pump runs and greys it out while it stands.
Usage, from frontend/: python3 scripts/house-assets.py (needs Pillow and NumPy)
"""
from pathlib import Path
import numpy as np
from PIL import Image, ImageFilter
SOURCE = Path("../research/ui/house-cutaway.webp")
OUT = Path("src/assets")
# Regions as (x0, y0, x1, y1) in the 1536 x 1024 picture.
CIRCUITS = {
"ground": (150, 585, 832, 1024), # boreholes and the brine pipes under the house
"boiler-room": (595, 355, 832, 585), # heat pump to the CWU tank and the CO buffer
"floor": (880, 595, 1350, 690), # underfloor heating in the living room
}
def pipes(rgb: np.ndarray) -> np.ndarray:
r, g, b = (rgb[..., i].astype(int) for i in range(3))
orange = (r > 190) & (g > 70) & (g < 170) & (b < 90) & (r - b > 120)
blue = (b > 150) & (r < 110) & (b - r > 70) & (g > 60) & (g < 190)
return orange | blue
def main() -> None:
picture = Image.open(SOURCE).convert("RGB")
assert picture.size == (1536, 1024), picture.size
for width in (1536, 768):
picture.resize((width, width * 2 // 3), Image.LANCZOS).save(OUT / f"house-{width}.webp", quality=80)
found = pipes(np.asarray(picture))
for name, (x0, y0, x1, y1) in CIRCUITS.items():
mask = np.zeros_like(found)
mask[y0:y1, x0:x1] = found[y0:y1, x0:x1]
# A little wider and softer than the pipe, so the flow covers its edges.
image = Image.fromarray((mask * 255).astype("uint8")).filter(ImageFilter.MaxFilter(3))
image.filter(ImageFilter.GaussianBlur(0.8)).save(OUT / f"mask-{name}.png", optimize=True)
if __name__ == "__main__":
main()
+119
View File
@@ -0,0 +1,119 @@
import { useEffect, useState } from "react";
import { celsius, hour, longDate } from "./format";
import { Icon, type IconName } from "./icons";
import { Energy } from "./Energy";
import { History } from "./History";
import { Overview } from "./Overview";
import { usePoll } from "./poll";
import { Settings } from "./Settings";
import { Status } from "./Status";
import { useTheme, type Theme } from "./theme";
import type { HeatPump } from "./types";
import { Dot } from "./ui";
const SCREENS = {
overview: { label: "Podgląd", icon: "home", view: Overview },
energy: { label: "Energia", icon: "bolt", view: Energy },
history: { label: "Historia", icon: "chart", view: History },
settings: { label: "Ustawienia", icon: "sliders", view: Settings },
status: { label: "Stan", icon: "check", view: Status },
} satisfies Record<string, { label: string; icon: IconName; view: () => unknown }>;
type ScreenId = keyof typeof SCREENS;
const fromHash = (): ScreenId => {
const id = location.hash.slice(1);
return id in SCREENS ? (id as ScreenId) : "overview";
};
function useClock() {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 30000);
return () => clearInterval(timer);
}, []);
return now;
}
const THEMES: Record<Theme, string> = { auto: "Auto", light: "Jasny", dark: "Ciemny" };
function ThemeSwitch({ className }: { className: string }) {
const [theme, setTheme] = useTheme();
return (
<div role="group" aria-label="Motyw" className={`flex gap-1 rounded-xl p-1 text-sm ${className}`}>
{Object.entries(THEMES).map(([id, label]) => (
<button key={id} aria-pressed={id === theme} onClick={() => setTheme(id as Theme)}
className="flex-1 rounded-lg px-2.5 py-1.5 font-semibold opacity-70 aria-pressed:bg-white aria-pressed:text-side aria-pressed:opacity-100">
{label}
</button>
))}
</div>
);
}
// Weather, clock and whether the house answers, at the foot of the side bar on a large screen.
function SideFooter() {
const { data, offline } = usePoll<HeatPump>("api/heat-pump");
const now = useClock();
return (
<div className="mt-auto space-y-4 px-2">
<div className="flex items-center gap-3">
<Icon name="sun" className="size-9 text-amber-400" />
<div>
<p className="text-2xl font-semibold">{celsius(data?.temperatures.outdoor)}</p>
<p className="text-sm text-white/60">Szczepańcowa</p>
</div>
</div>
<p className="text-sm text-white/80">{longDate(now)} · {hour(now)}</p>
<ThemeSwitch className="bg-white/10" />
<div className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/5 p-3">
<Dot tone={offline ? "bad" : "ok"} live={!offline} />
<div className="text-sm">
<p className="font-semibold">{offline ? "Brak połączenia" : "System online"}</p>
<p className="text-white/60">{data?.time ? `Ostatni odczyt: ${hour(new Date(data.time))}` : "Czekam na odczyt"}</p>
</div>
</div>
</div>
);
}
export function App() {
const [screen, setScreen] = useState<ScreenId>(fromHash);
useEffect(() => {
const onHash = () => setScreen(fromHash());
addEventListener("hashchange", onHash);
return () => removeEventListener("hashchange", onHash);
}, []);
const show = (id: ScreenId) => {
location.hash = id;
setScreen(id);
scrollTo(0, 0);
};
const View = SCREENS[screen].view;
return (
<div className="min-h-dvh lg:pl-64">
<nav aria-label="Ekrany"
className="fixed inset-x-0 bottom-0 z-10 flex border-t border-edge bg-panel pb-[env(safe-area-inset-bottom)] lg:inset-y-0 lg:left-0 lg:right-auto lg:w-64 lg:flex-col lg:gap-1 lg:border-0 lg:bg-side lg:px-4 lg:py-6 lg:text-white">
<div className="mb-8 hidden items-center gap-3 px-2 lg:flex">
<span className="grid size-10 place-items-center rounded-xl bg-brand/20 text-brand"><Icon name="leaf" className="size-6" /></span>
<div>
<p className="text-lg font-bold leading-tight">Dom Szczepańcowa</p>
<p className="text-xs text-white/60">Pompa ciepła i energia</p>
</div>
</div>
{Object.entries(SCREENS).map(([id, s]) => (
<button key={id} onClick={() => show(id as ScreenId)} aria-current={id === screen ? "page" : undefined}
className="flex min-h-16 flex-1 flex-col items-center justify-center gap-0.5 text-[0.7rem] font-semibold text-soft aria-[current=page]:text-brand aria-[current=page]:shadow-[inset_0_3px_0_var(--color-brand)] lg:min-h-12 lg:flex-none lg:flex-row lg:justify-start lg:gap-3 lg:rounded-xl lg:px-4 lg:text-base lg:text-white/75 lg:hover:bg-white/5 lg:aria-[current=page]:bg-side-hi lg:aria-[current=page]:text-white lg:aria-[current=page]:shadow-none">
<Icon name={s.icon} className="size-6" />
{s.label}
</button>
))}
<div className="hidden flex-1 lg:flex lg:flex-col"><SideFooter /></div>
</nav>
<main className="mx-auto max-w-[1500px] px-4 pt-5 pb-28 sm:px-6 lg:px-8 lg:pt-8 lg:pb-10">
<View />
<ThemeSwitch className="mx-auto mt-8 max-w-xs border border-edge bg-panel text-ink lg:hidden" />
</main>
</div>
);
}
+163
View File
@@ -0,0 +1,163 @@
import { Cell, Pie, PieChart } from "recharts";
import { kwh, percent, power, when } from "./format";
import { Icon, type IconName } from "./icons";
import { usePoll } from "./poll";
import type { Energy as EnergyData } from "./types";
import { Banner, Card, Freshness, Loading, PageTitle, Pill, Row } from "./ui";
const FLOWING_W = 10;
const INVERTERS = { deye: "Falownik Deye", fronius: "Falownik Fronius" } as const;
/** Inverters without a fresh reading, in plain words. A source the backend does not list is missing too. */
export function silentInverters(e: EnergyData) {
return Object.entries(INVERTERS).flatMap(([id, label]) => {
const source = e.sources?.find((s) => s.name === id);
if (source && !source.stale && source.time) return [];
return [{ label, since: source?.time ? `brak odczytu od ${when(source.time)}` : "brak odczytu" }];
});
}
const flowing = (watts: number | null) => watts != null && Math.abs(watts) >= FLOWING_W;
const magnitude = (watts: number | null) => (watts == null ? null : Math.abs(watts));
// The SDM630 and Deye sign: + the house takes from the grid, − it gives back.
export function gridText(watts: number | null) {
if (watts == null) return "—";
if (!flowing(watts)) return "bez wymiany";
return watts > 0 ? "pobór" : "oddawanie";
}
function batteryText(watts: number | null) {
if (watts == null) return "—";
if (!flowing(watts)) return "bez przepływu";
return watts > 0 ? `rozładowuje się, ${power(watts)}` : `ładuje się, ${power(-watts)}`;
}
// Nodes sit around the house; dots run along a line from the source to the sink of each flow.
const NODES: Record<"pv" | "grid" | "house" | "battery", { x: number; y: number; label: string; icon: IconName; colour: string }> = {
pv: { x: 90, y: 70, label: "Słońce (PV)", icon: "sun", colour: "#f59e0b" },
grid: { x: 510, y: 70, label: "Sieć", icon: "plug", colour: "#3b82f6" },
house: { x: 300, y: 170, label: "Dom", icon: "home", colour: "#10b981" },
battery: { x: 300, y: 300, label: "Bateria", icon: "battery", colour: "#8b5cf6" },
};
function Flow({ from, to, watts }: { from: keyof typeof NODES; to: keyof typeof NODES; watts: number | null }) {
const a = NODES[from], b = NODES[to];
const on = flowing(watts);
const [s, e] = on && watts! < 0 ? [b, a] : [a, b];
const d = `M${s.x} ${s.y} L${e.x} ${e.y}`;
// Faster dots for more power: 2.5 s per run at 10 W, down to 0.6 s at 5 kW and more.
const speed = on ? Math.max(0.6, 2.5 - (Math.min(Math.abs(watts!), 5000) / 5000) * 1.9) : 0;
return (
<g data-flow={`${from}-${to}`} data-on={on}>
<path d={d} stroke={on ? s.colour : "var(--color-off)"} strokeWidth={on ? 5 : 3} strokeLinecap="round" opacity={on ? 0.35 : 0.6} />
{on && <path d={d} className="flow" stroke={s.colour} strokeWidth={5} style={{ stroke: s.colour, animationDuration: `${speed}s` }} />}
</g>
);
}
function Node({ id, value, note }: { id: keyof typeof NODES; value: string; note?: string }) {
const { x, y, label, icon, colour } = NODES[id];
return (
<g>
<rect x={x - 78} y={y - 40} width={156} height={80} rx={18} className="fill-panel" stroke="var(--color-edge)" strokeWidth={1.5} />
<g transform={`translate(${x - 64} ${y - 26})`} color={colour}><Icon name={icon} size={28} /></g>
<text x={x - 28} y={y - 10} fontSize={14} className="scene-soft">{label}</text>
<text x={x - 28} y={y + 16} fontSize={22} fontWeight={700} className="scene-text">{value}</text>
{note && <text x={x - 28} y={y + 33} fontSize={12} className="scene-soft">{note}</text>}
</g>
);
}
function FlowPicture({ e }: { e: EnergyData }) {
return (
<svg viewBox="0 0 600 350" className="h-auto w-full" role="img" aria-label="Przepływ energii w domu">
<Flow from="pv" to="house" watts={e.pvPowerW} />
<Flow from="grid" to="house" watts={e.gridPowerW} />
<Flow from="battery" to="house" watts={e.batteryPowerW} />
<Node id="pv" value={power(e.pvPowerW)} />
<Node id="grid" value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
<Node id="house" value={power(e.loadPowerW)} note="zużycie" />
<Node id="battery" value={percent(e.batterySocPct)} note={flowing(e.batteryPowerW) ? (e.batteryPowerW! > 0 ? "rozładowuje" : "ładuje") : undefined} />
</svg>
);
}
// Today's use split by where it came from; the battery is charged from both, so "own" is what the grid did not give.
export function EnergyToday({ e }: { e: EnergyData | undefined }) {
const today = e?.today;
const load = today?.loadKwh ?? null;
const bought = today?.gridBoughtKwh ?? null;
const own = load != null && bought != null ? Math.max(0, load - bought) : null;
const parts = [
{ name: "Z PV i baterii", value: own ?? 0, colour: "#10b981" },
{ name: "Z sieci", value: bought ?? 0, colour: "#3b82f6" },
];
const partial = e ? silentInverters(e).length > 0 : false;
return (
<Card title="Energia – dziś" aside={partial ? <Pill tone="warn">Tylko część danych</Pill> : undefined}>
{!e ? <Loading /> : (
<div className="flex flex-wrap items-center gap-5">
<div className="relative size-36 shrink-0">
<PieChart width={144} height={144}>
<Pie data={parts} dataKey="value" innerRadius={50} outerRadius={68} startAngle={90} endAngle={-270}
stroke="none" isAnimationActive={false}>
{parts.map((p) => <Cell key={p.name} fill={p.colour} />)}
</Pie>
</PieChart>
<div className="absolute inset-0 grid place-items-center text-center">
<div><p className="text-xl font-bold tabular-nums">{kwh(load)}</p><p className="text-xs text-soft">zużycie</p></div>
</div>
</div>
<div className="min-w-44 flex-1 text-sm">
{parts.map((p) => (
<Row key={p.name} label={<><span className="inline-block size-2.5 rounded-full" style={{ background: p.colour }} />{p.name}</>}
value={kwh(p.name === "Z sieci" ? bought : own)} />
))}
<Row label="Produkcja PV" value={kwh(today?.pvKwh)} />
<Row label="Oddano do sieci" value={kwh(today?.gridSoldKwh)} />
</div>
</div>
)}
</Card>
);
}
function Stat({ icon, label, value, note }: { icon: IconName; label: string; value: string; note?: string }) {
return (
<Card>
<div className="flex items-center gap-3">
<span className="grid size-10 place-items-center rounded-xl bg-brand-soft text-brand"><Icon name={icon} /></span>
<div>
<p className="text-sm text-soft">{label}</p>
<p className="text-2xl font-bold tabular-nums">{value}</p>
{note && <p className="text-xs text-soft">{note}</p>}
</div>
</div>
</Card>
);
}
export function Energy() {
const { data: e, offline } = usePoll<EnergyData>("api/energy");
const title = <PageTitle title="Energia" subtitle="Falowniki Deye i Fronius, licznik domu" />;
if (!e) return <>{title}<Freshness offline={offline} />{!offline && <Loading />}</>;
const missing = silentInverters(e);
return (
<>
{title}
<Freshness offline={offline} time={e.time} />
{missing.length > 0 && <Banner tone="warn">{missing.map((m) => <div key={m.label}>{m.label}: {m.since}</div>)}</Banner>}
<div className="grid gap-4 lg:grid-cols-3">
<Card title="Energia teraz" className="lg:col-span-2"><FlowPicture e={e} /></Card>
<EnergyToday e={e} />
<div className="grid gap-4 sm:grid-cols-2 lg:col-span-3 lg:grid-cols-4">
<Stat icon="sun" label="Produkcja PV" value={power(e.pvPowerW)} note={missing.length ? "tylko część: brakuje danych z falownika" : "Deye + Fronius"} />
<Stat icon="home" label="Zużycie domu" value={power(e.loadPowerW)} />
<Stat icon="plug" label="Sieć" value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
<Stat icon="battery" label="Bateria" value={percent(e.batterySocPct)} note={batteryText(e.batteryPowerW)} />
</div>
</div>
</>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { useState } from "react";
import { number } from "./format";
import { usePoll } from "./poll";
import { TimeChart, toRows, type Range } from "./TimeChart";
import type { History as HistoryData } from "./types";
import { Card, Freshness, Loading, PageTitle } from "./ui";
// Ids as in Series.java.
const SERIES = {
outdoor_temp: ["Temperatura na zewnątrz", "#3b82f6"],
indoor_temp: ["Temperatura w domu", "#10b981"],
co_buffer_temp: ["Bufor CO", "#ef4444"],
cwu_temp: ["Ciepła woda (CWU)", "#f59e0b"],
ground_in_temp: ["Gruntowe: do pompy", "#22a6c9"],
ground_out_temp: ["Gruntowe: z pompy", "#2f7fe0"],
pump_outlet_temp: ["Wyjście pompy ciepła", "#f26b1d"],
grid_power: ["Moc z sieci (+ pobór, − oddawanie)", "#3b82f6"],
pv_power: ["Produkcja PV", "#f59e0b"],
load_power: ["Zużycie domu", "#10b981"],
battery_soc: ["Bateria (naładowanie)", "#8b5cf6"],
} as const;
type SeriesId = keyof typeof SERIES;
const RANGES: Record<Range, string> = { "24h": "24 godziny", "7d": "7 dni" };
function Chart({ series, range }: { series: SeriesId; range: Range }) {
// One poller per choice, so an answer is never shown for another one.
const { data, offline } = usePoll<HistoryData>(`api/history?series=${series}&range=${range}`);
if (!data) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
const values = data.points.map((p) => p.v);
const digits = data.unit === "°C" ? 1 : 0;
const [name, colour] = SERIES[series];
return (
<>
{offline && <Freshness offline />}
{data.points.length < 2 ? <p className="py-8 text-soft">Za mało danych w tym okresie.</p> : (
<>
<TimeChart rows={toRows({ [series]: data.points })} lines={[{ key: series, name, colour }]}
unit={data.unit} range={range} height={320} area />
<p className="mt-2 text-sm text-soft">
Najmniej {number(Math.min(...values), digits)} {data.unit} · najwięcej {number(Math.max(...values), digits)} {data.unit}.
Dotknij wykresu, aby zobaczyć wartość.
</p>
</>
)}
</>
);
}
export function History() {
const [series, setSeries] = useState<SeriesId>("cwu_temp");
const [range, setRange] = useState<Range>("24h");
return (
<>
<PageTitle title="Historia" subtitle="Dane z bazy pomiarów" />
<Card>
<div className="mb-4 flex flex-wrap gap-3">
<select value={series} onChange={(e) => setSeries(e.target.value as SeriesId)} aria-label="Wielkość"
className="min-h-12 flex-1 rounded-xl border-2 border-edge bg-panel px-3 font-semibold">
{Object.entries(SERIES).map(([id, [name]]) => <option key={id} value={id}>{name}</option>)}
</select>
<div className="flex gap-2 rounded-xl bg-page p-1">
{Object.entries(RANGES).map(([id, name]) => (
<button key={id} onClick={() => setRange(id as Range)} aria-pressed={id === range}
className="min-h-10 rounded-lg px-4 font-semibold text-soft aria-pressed:bg-panel aria-pressed:text-brand aria-pressed:shadow-sm">
{name}
</button>
))}
</div>
</div>
<Chart series={series} range={range} />
</Card>
</>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { example, serve } from "./fixtures";
import type { HeatPump } from "./types";
const overview = async () => (await import("./Overview")).Overview;
const fresh = (hp: HeatPump) => ({ ...hp, time: new Date().toISOString() });
const history = { series: "x", unit: "°C", points: [] };
function serveHeatPump(hp: HeatPump) {
return serve({ "api/heat-pump": hp, "api/energy": example("energy"), "api/history": history });
}
const flowing = (container: HTMLElement) =>
[...container.querySelectorAll("[data-circuit][data-on=true]")].map((p) => p.getAttribute("data-circuit"));
const glowing = (container: HTMLElement) =>
[...container.querySelectorAll("[data-glow][data-on=true]")].map((p) => p.getAttribute("data-glow"));
describe("Overview", () => {
it("draws a resting pump with nothing flowing", async () => {
serveHeatPump(fresh(example("heat-pump")));
const Overview = await overview();
const { container } = render(<Overview />);
expect(await screen.findAllByText("Praca prawidłowa")).not.toHaveLength(0);
expect(flowing(container)).toEqual([]);
expect(glowing(container)).toEqual([]);
});
it("makes the pipes of the running pumps flow", async () => {
const hp = fresh(example<HeatPump>("heat-pump"));
serveHeatPump({ ...hp, state: "HEATING_CWU", compressorPercent: 60, pumps: { ...hp.pumps, cwu: true, ground: true } });
const Overview = await overview();
const { container } = render(<Overview />);
expect(await screen.findAllByText("Grzeje wodę (CWU)")).not.toHaveLength(0);
expect(flowing(container)).toEqual(["ground", "boiler-room"]);
expect(glowing(container)).toEqual(["heat-pump", "cwu"]);
});
it("shows the alarm in words", async () => {
serveHeatPump({ ...fresh(example("heat-pump")), alarm: true, alarmCode: 5, alarmText: "Usterka czujnika temperatury CWU" });
const Overview = await overview();
render(<Overview />);
expect(await screen.findByRole("alert")).toHaveTextContent("ALARM pompy ciepła. Usterka czujnika temperatury CWU.");
});
it("claims nothing before the first reading", async () => {
serveHeatPump(example("heat-pump-unread"));
const Overview = await overview();
render(<Overview />);
expect(await screen.findByText("Brak odczytu z pompy")).toBeInTheDocument();
expect(screen.queryByText("Praca prawidłowa")).toBeNull();
});
it("warns when the reading is old", async () => {
serveHeatPump(example("heat-pump")); // its time is long past
const Overview = await overview();
render(<Overview />);
expect(await screen.findByText(/Dane są nieaktualne/)).toBeInTheDocument();
});
});
+219
View File
@@ -0,0 +1,219 @@
import type { ReactNode } from "react";
import { celsius, degrees, hour, longDate, percent, power } from "./format";
import { Icon, type IconName } from "./icons";
import { usePoll } from "./poll";
import { Schematic } from "./Schematic";
import { Legend, TimeChart, toRows, type Line } from "./TimeChart";
import type { Energy, HeatPump, History, PumpState } from "./types";
import { Card, Dot, Freshness, Loading, PageTitle, Pill, Row } from "./ui";
import { EnergyToday, gridText } from "./Energy";
const HEADLINES: Record<PumpState, string> = {
IDLE: "Pompa odpoczywa",
HEATING_CWU: "Grzeje wodę (CWU)",
HEATING_CO: "Grzeje dom (CO)",
HEATING_BOTH: "Grzeje dom i wodę",
};
export const PUMP_NAMES = {
ground: "Pompa gruntowa", buffer: "Pompa bufora", co: "Pompa CO",
cwu: "Pompa CWU", circulation: "Pompa cyrkulacji",
} as const;
const HISTORY_MS = 5 * 60000; // history moves slowly; the backend asks InfluxDB for every request
const TEMPERATURE_LINES: Line[] = [
{ key: "cwu_temp", name: "CWU", colour: "#f59e0b" },
{ key: "co_buffer_temp", name: "Bufor CO", colour: "#ef4444" },
{ key: "indoor_temp", name: "W domu", colour: "#10b981" },
{ key: "outdoor_temp", name: "Na zewnątrz", colour: "#3b82f6", dashed: true },
];
const onOff = (on: boolean | null, yes: string, no: string) => (on == null ? "—" : on ? yes : no);
function StatusPill({ hp }: { hp: HeatPump }) {
if (hp.alarm) return <Pill tone="bad" live>Alarm</Pill>;
if (hp.state == null) return <Pill tone="off">Stan nieznany</Pill>;
return hp.state === "IDLE" ? <Pill tone="ok">Praca prawidłowa</Pill> : <Pill tone="hot" live>{HEADLINES[hp.state]}</Pill>;
}
function Tile({ icon, title, value, note, status, children }: {
icon: IconName; title: string; value: ReactNode; note: string; status: ReactNode; children: ReactNode;
}) {
return (
<Card className="flex flex-col">
<div className="flex items-start gap-3">
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-brand-soft text-brand">
<Icon name={icon} className="size-6" />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold">{title}</p>
<p className="text-2xl font-bold tabular-nums tracking-tight">{value}</p>
<p className="text-xs text-soft">{note}</p>
</div>
</div>
<div className="my-3">{status}</div>
<div className="mt-auto text-sm [&>div]:text-sm">{children}</div>
</Card>
);
}
const running = (on: boolean | null, yes = "Pracuje") =>
on == null ? <Pill tone="off">Brak odczytu</Pill> : on ? <Pill tone="ok" live>{yes}</Pill> : <Pill tone="off">Stoi</Pill>;
function Temperatures({ hp }: { hp: HeatPump }) {
const t = hp.temperatures;
const rows: [IconName, string, number | null, string][] = [
["thermometer", "Na zewnątrz", t.outdoor, "text-cold"],
["thermometer", "Średnia zewnętrzna", t.outdoorAvg, "text-cold"],
["home", "W domu", t.indoor, "text-brand"],
["heat", "Bufor CO", t.coBuffer, "text-hot"],
["drop", "Ciepła woda (CWU)", t.cwu, "text-hot"],
["fan", "Wyjście pompy", t.pumpOutlet, "text-hot"],
];
return (
<Card title="Temperatury">
{rows.map(([icon, label, value, colour]) => (
<Row key={label} label={label} value={celsius(value)} icon={<span className={colour}><Icon name={icon} /></span>} />
))}
</Card>
);
}
// Every moving part as a chip under the picture: what runs now is green and pulses.
function Devices({ hp }: { hp: HeatPump }) {
const compressor = hp.compressorPercent == null ? null : hp.compressorPercent > 0;
const items: [string, boolean | null, string][] = [
["Sprężarka", compressor, compressor ? percent(hp.compressorPercent) : "stoi"],
["Grzałka", hp.heaterOn, onOff(hp.heaterOn, "włączona", "wyłączona")],
...Object.entries(PUMP_NAMES).map(([key, name]): [string, boolean | null, string] => {
const on = hp.pumps[key as keyof typeof PUMP_NAMES];
return [name, on, onOff(on, "pracuje", "stoi")];
}),
];
return (
<ul aria-label="Urządzenia" className="mt-3 grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
{items.map(([name, on, text]) => (
<li key={name} title={`${name}: ${text}`}
className={`flex items-center gap-2 whitespace-nowrap rounded-full border px-3 py-1.5 text-xs sm:text-sm ${on ? "border-brand/30 bg-brand-soft" : "border-edge text-soft"}`}>
<Dot tone={on ? "ok" : "off"} live={!!on} />
<span>{name}</span>
{/* On a phone the colour says it; the word needs the room of a larger screen. */}
<b className={`hidden sm:inline ${on ? "text-brand" : "text-soft"}`}>{text}</b>
</li>
))}
</ul>
);
}
function Targets({ hp }: { hp: HeatPump }) {
const s = hp.setpoints;
return (
<Card title="Cele i nastawy">
<Row label="Pokój, cel teraz" value={celsius(hp.targets.room)} />
<Row label="Woda grzewcza CO, cel" value={celsius(hp.targets.co)} />
<Row label="Ciepła woda CWU, cel" value={celsius(hp.targets.cwu)} />
<Row label="CWU oszczędnie · komfort" value={`${degrees(s.cwuEconomy)} · ${degrees(s.cwuComfort)}`} />
<Row label="CO oszczędnie · komfort" value={`${degrees(s.coEconomy)} · ${degrees(s.coComfort)}`} />
</Card>
);
}
function useTemperatureHistory() {
const cwu = usePoll<History>("api/history?series=cwu_temp&range=24h", HISTORY_MS);
const co = usePoll<History>("api/history?series=co_buffer_temp&range=24h", HISTORY_MS);
const indoor = usePoll<History>("api/history?series=indoor_temp&range=24h", HISTORY_MS);
const outdoor = usePoll<History>("api/history?series=outdoor_temp&range=24h", HISTORY_MS);
const all = [cwu, co, indoor, outdoor].map((p) => p.data);
if (all.some((d) => !d)) return null;
return toRows(Object.fromEntries(all.map((d) => [d!.series, d!.points])));
}
function TemperatureChart() {
const rows = useTemperatureHistory();
return (
<Card title="Temperatury – ostatnie 24 h" className="lg:col-span-2">
<Legend lines={TEMPERATURE_LINES} />
{rows == null ? <Loading /> : rows.length < 2
? <p className="text-soft">Za mało danych w tym okresie.</p>
: <TimeChart rows={rows} lines={TEMPERATURE_LINES} unit="°C" range="24h" />}
</Card>
);
}
function NoReading() {
return (
<Card>
<p className="text-xl font-bold">Brak odczytu z pompy</p>
<p className="mt-1 text-soft">Dom jeszcze nie odczytał pompy ciepła. Odczyt pojawi się tutaj, gdy będzie dostępny.</p>
</Card>
);
}
export function Overview() {
const { data: hp, offline } = usePoll<HeatPump>("api/heat-pump");
const { data: e } = usePoll<Energy>("api/energy");
const now = hp?.time ? new Date(hp.time) : null;
const title = (
<PageTitle title="Dom"
subtitle={now ? `Pompa ciepła i energia · odczyt z ${hour(now)}, ${longDate(now)}` : "Pompa ciepła i energia"} />
);
if (!hp) return <>{title}<Freshness offline={offline} />{!offline && <Loading />}</>;
return (
<>
{title}
<Freshness offline={offline} time={hp.time} stale={hp.stale} />
{/* alarmText is one of the backend's fixed Polish sentences for register 4011, never user input. */}
{hp.alarm && (
<div role="alert" className="mb-4 flex items-start gap-3 rounded-xl border-2 border-alarm bg-red-50 px-4 py-3 font-semibold text-alarm dark:bg-red-950/60 dark:text-red-200">
<Icon name="alert" className="size-6 shrink-0" />
<span>ALARM pompy ciepła. {hp.alarmText ? `${hp.alarmText}. ` : ""}Sprawdź sterownik w domu.</span>
</div>
)}
{hp.time == null ? <NoReading /> : (
<div className="grid gap-4 lg:grid-cols-3">
<Card title="Schemat instalacji" aside={<StatusPill hp={hp} />} className="lg:col-span-2 lg:row-span-2">
<Schematic hp={hp} pvPowerW={e?.pvPowerW} />
<Devices hp={hp} />
<p className="mt-3 text-xs text-soft">
Rury płyną, gdy pracuje pompa obiegu, a stojące są szare. Kolektor słoneczny nie ma czujnika, więc nie wiemy, czy jego obieg pracuje.
</p>
</Card>
<Temperatures hp={hp} />
<Targets hp={hp} />
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:col-span-3 xl:grid-cols-5">
<Tile icon="fan" title="Pompa ciepła" note="sprężarka"
value={hp.compressorPercent == null ? "—" : `${hp.compressorPercent} %`}
status={running(hp.compressorPercent == null ? null : hp.compressorPercent > 0)}>
<Row label="Wyjście" value={celsius(hp.temperatures.pumpOutlet)} />
<Row label="Grzałka" value={onOff(hp.heaterOn, "włączona", "wyłączona")} />
</Tile>
<Tile icon="drop" title="Zbiornik CWU" note="ciepła woda" value={celsius(hp.temperatures.cwu)}
status={running(hp.pumps.cwu, "Grzeje wodę")}>
<Row label="Cel teraz" value={celsius(hp.targets.cwu)} />
<Row label="Cyrkulacja" value={onOff(hp.pumps.circulation, "pracuje", "stoi")} />
</Tile>
<Tile icon="heat" title="Ogrzewanie CO" note="bufor" value={celsius(hp.temperatures.coBuffer)}
status={running(hp.pumps.co, "Grzeje dom")}>
<Row label="Cel bufora" value={celsius(hp.targets.co)} />
<Row label="Pokój" value={celsius(hp.temperatures.indoor)} />
</Tile>
<Tile icon="borehole" title="Odwierty" note="do pompy" value={celsius(hp.temperatures.groundIn)}
status={running(hp.pumps.ground)}>
<Row label="Z pompy" value={celsius(hp.temperatures.groundOut)} />
<Row label="Różnica" value={hp.temperatures.groundIn != null && hp.temperatures.groundOut != null
? celsius(hp.temperatures.groundIn - hp.temperatures.groundOut) : "—"} />
</Tile>
<Tile icon="sun" title="Prąd teraz" note="produkcja PV" value={power(e?.pvPowerW)}
status={e ? <Pill tone={(e.gridPowerW ?? 0) < 0 ? "ok" : "off"}>Sieć: {gridText(e.gridPowerW)}</Pill> : <Pill tone="off">Brak danych</Pill>}>
<Row label="Zużycie domu" value={power(e?.loadPowerW)} />
<Row label="Bateria" value={percent(e?.batterySocPct)} />
</Tile>
</div>
<TemperatureChart />
<EnergyToday e={e} />
</div>
)}
</>
);
}
+150
View File
@@ -0,0 +1,150 @@
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import house1536 from "./assets/house-1536.webp";
import house768 from "./assets/house-768.webp";
import maskBoilerRoom from "./assets/mask-boiler-room.png";
import maskFloor from "./assets/mask-floor.png";
import maskGround from "./assets/mask-ground.png";
import { celsius, number, percent, power } from "./format";
import type { HeatPump } from "./types";
// The installation as it is now, over the house picture (research/ui/house-cutaway.webp,
// assets from scripts/house-assets.py). Coordinates are pixels of the 1536 × 1024 picture.
// A pipe circuit flows while its pump runs and is greyed out while it stands; the solar
// collector has no sensor, so it is never animated.
const W = 1536, H = 1024;
const CIRCUITS = {
ground: { mask: maskGround, name: "Odwierty (pompa gruntowa)" },
"boiler-room": { mask: maskBoilerRoom, name: "Kotłownia (pompy CWU i bufora)" },
floor: { mask: maskFloor, name: "Podłogówka (pompa CO)" },
} as const;
type Circuit = keyof typeof CIRCUITS;
function Pipes({ id, on }: { id: Circuit; on: boolean }) {
return (
<g data-circuit={id} data-on={on}>
<title>{`${CIRCUITS[id].name}: ${on ? "płynie" : "stoi"}`}</title>
<mask id={`mask-${id}`} maskUnits="userSpaceOnUse" x="0" y="0" width={W} height={H}>
<image href={CIRCUITS[id].mask} width={W} height={H} />
</mask>
{on
? <g mask={`url(#mask-${id})`}><rect className="shimmer" x={-120} width={W + 240} height={H} fill="url(#stripes)" /></g>
: <rect width={W} height={H} mask={`url(#mask-${id})`} className="fill-[#a3acb3]" opacity="0.8" />}
</g>
);
}
// A pulsing outline around a device that works now.
function Glow({ x, y, w, h, colour, on, id }: { x: number; y: number; w: number; h: number; colour: string; on: boolean; id: string }) {
return (
<rect data-glow={id} data-on={on} x={x} y={y} width={w} height={h} rx={14} fill="none"
stroke={colour} strokeWidth={on ? 6 : 0} className={on ? "glow" : ""} filter="url(#soft)" />
);
}
/**
* A label with a value, joined by a thin line to the thing it describes; the box grows to fit
* its text once the browser has measured it. Hidden on a phone.
*/
function Callout({ x, y, title, value, to, tone = "var(--color-ink)" }: {
x: number; y: number; title: string; value: ReactNode; to?: [number, number]; tone?: string;
}) {
const texts = useRef<SVGGElement>(null);
const [w, setW] = useState(220);
useLayoutEffect(() => {
const box = texts.current?.getBBox?.(); // absent in tests without layout
if (box) setW(Math.ceil(box.width) + 40);
});
const h = 92;
return (
<g className="label">
{to && <>
<line x1={Math.min(Math.max(to[0], x + 24), x + w - 24)} y1={to[1] < y ? y : y + h} x2={to[0]} y2={to[1]}
stroke="#fff" strokeWidth={3} opacity={0.9} />
<circle cx={to[0]} cy={to[1]} r={7} fill="#fff" stroke={tone} strokeWidth={3} />
</>}
<rect x={x} y={y} width={w} height={h} rx={18} className="fill-panel" opacity={0.94} filter="url(#shadow)" />
<g ref={texts}>
<text x={x + 20} y={y + 36} fontSize={24} fontWeight={600} className="scene-soft">{title}</text>
<text x={x + 20} y={y + 74} fontSize={32} fontWeight={700} fill={tone}>{value}</text>
</g>
</g>
);
}
const target = (value: number | null) => <tspan fontSize={22} className="scene-soft">{` cel ${number(value, 0)}°`}</tspan>;
export function Schematic({ hp, pvPowerW }: { hp: HeatPump; pvPowerW: number | null | undefined }) {
const { temperatures: t, targets, pumps } = hp;
const ground = !!pumps.ground, buffer = !!pumps.buffer, cwu = !!pumps.cwu, co = !!pumps.co;
const compressor = (hp.compressorPercent ?? 0) > 0;
const sunny = (pvPowerW ?? 0) >= 10;
return (
<div className="scene relative overflow-hidden rounded-xl bg-white">
<img src={house1536} srcSet={`${house768} 768w, ${house1536} 1536w`} sizes="(min-width: 1024px) 60vw, 100vw"
width={W} height={H} alt="" className="block h-auto w-full dark:brightness-90" />
<svg viewBox={`0 0 ${W} ${H}`} className="absolute inset-0 h-full w-full select-none" role="img"
aria-label="Schemat instalacji: odwierty, pompa ciepła w kotłowni, zbiornik CWU, bufor CO, podłogówka, kolektor i fotowoltaika">
<defs>
<pattern id="stripes" width="64" height="64" patternUnits="userSpaceOnUse" patternTransform="rotate(35)">
<rect width="64" height="64" fill="#fff" opacity="0.15" />
<rect width="18" height="64" fill="#fff" opacity="0.95" />
</pattern>
<linearGradient id="sweep" x1="0" y1="0" x2="1" y2="0">
<stop offset="0.35" stopColor="#fff" stopOpacity="0" />
<stop offset="0.5" stopColor="#fff" stopOpacity="0.55" />
<stop offset="0.65" stopColor="#fff" stopOpacity="0" />
</linearGradient>
<filter id="soft" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="4" /></filter>
<filter id="shadow" x="-10%" y="-10%" width="120%" height="140%">
<feDropShadow dx="0" dy="4" stdDeviation="6" floodOpacity="0.18" />
</filter>
<clipPath id="pv"><polygon points="985,188 1226,193 1369,328 1100,319" /></clipPath>
</defs>
<Pipes id="ground" on={ground} />
<Pipes id="boiler-room" on={cwu || buffer} />
<Pipes id="floor" on={co} />
{/* Photovoltaics: a light sweeps over the panels while they produce */}
{sunny && <g clipPath="url(#pv)" data-pv="on"><rect className="sweep" x={985} y={180} width={400} height={160} fill="url(#sweep)" /></g>}
<Glow id="heat-pump" x={506} y={422} w={102} h={188} colour="#10b981" on={compressor} />
<Glow id="cwu" x={639} y={402} w={90} h={216} colour="#f97316" on={cwu} />
<Glow id="buffer" x={746} y={489} w={70} h={134} colour="#ef4444" on={buffer} />
{compressor && <circle className="beat" cx={575} cy={462} r={6} fill="#22c55e" />}
{hp.heaterOn && <rect className="glow" x={518} y={560} width={78} height={14} rx={7} fill="#ef4444" />}
{/* Warmth rising from the floor while the CO pump runs */}
{co && [960, 1080, 1200, 1300].map((x, i) => (
<path key={x} className="rise" style={{ animationDelay: `${i * 0.6}s` }} fill="none" stroke="#f97316"
strokeWidth={5} strokeLinecap="round" opacity="0" d={`M${x} 600 c-12-14 12-24 0-38 c-12-14 12-24 0-38`} />
))}
<Callout x={36} y={40} title="Na zewnątrz" value={celsius(t.outdoor)} />
<Callout x={210} y={400} title="Pompa ciepła" to={[512, 470]} tone={compressor ? "var(--color-brand)" : undefined}
value={compressor ? `sprężarka ${percent(hp.compressorPercent)}` : "stoi"} />
<Callout x={540} y={215} title="Ciepła woda (CWU)" to={[684, 410]} tone={cwu ? "#ea580c" : undefined}
value={<>{celsius(t.cwu)}{target(targets.cwu)}</>} />
<Callout x={660} y={730} title="Bufor CO" to={[781, 617]} tone={buffer ? "#dc2626" : undefined}
value={<>{celsius(t.coBuffer)}{target(targets.co)}</>} />
<Callout x={380} y={850} title="Odwierty: do pompy · z pompy" to={[365, 880]}
value={`${celsius(t.groundIn)} · ${celsius(t.groundOut)}`} />
<Callout x={1000} y={370} title="W domu" tone={co ? "#ea580c" : undefined}
value={<>{celsius(t.indoor)}{target(targets.room)}</>} />
<Callout x={1250} y={50} title="Fotowoltaika" to={[1230, 230]} tone={sunny ? "#d97706" : undefined} value={power(pvPowerW)} />
<Callout x={470} y={95} title="Kolektor słoneczny" to={[790, 190]}
value={<tspan fontSize={24} className="scene-soft">bez czujnika</tspan>} />
{hp.alarm && (
<g className="glow">
<rect x={492} y={392} width={130} height={40} rx={20} fill="var(--color-alarm)" />
<text x={557} y={420} fontSize={24} fontWeight={700} textAnchor="middle" fill="#fff">ALARM</text>
</g>
)}
</svg>
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { answer, example, serve } from "./fixtures";
import type { Setpoints } from "./types";
// A fresh module per test: the poller keeps its answers between screens, not between tests.
const settings = async () => (await import("./Settings")).Settings;
// A sentence split by <b> into several text nodes.
const paragraph = (text: string) => (_: string, el: Element | null) => el?.tagName === "P" && el.textContent === text;
const posts = (fetch: ReturnType<typeof serve>) => fetch.mock.calls.filter(([, init]) => init?.method === "POST");
describe("Settings", () => {
it("writes only after the summary is confirmed", async () => {
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-applied")));
const Settings = await settings();
render(<Settings />);
await userEvent.click(await screen.findByLabelText("Więcej: Ciepła woda, oszczędnie"));
await userEvent.click(screen.getByRole("button", { name: "Zapisz" }));
expect(posts(fetch)).toHaveLength(0);
expect(screen.getByText("Ustawię 9 °C. Za 2 godz. wrócimy do 8 °C.")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Tak, zapisz" }));
expect(posts(fetch)).toHaveLength(1);
expect(JSON.parse(String(posts(fetch)[0][1]!.body))).toEqual({ setpoint: "cwu_economy", value: 9, hours: 2 });
expect(await screen.findByText(/^Zapisano\./)).toBeInTheDocument();
});
it("never claims nothing changed when the answer is lost, and does not retry", async () => {
const fetch = serve({ "api/setpoints": example("setpoints") }, () => Promise.reject(new TypeError("network")));
const Settings = await settings();
render(<Settings />);
await userEvent.click(await screen.findByLabelText("Mniej: Ogrzewanie domu, komfort"));
await userEvent.click(screen.getByRole("button", { name: "Zapisz" }));
await userEvent.click(screen.getByRole("button", { name: "Tak, zapisz" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Nie wiadomo, czy zmiana doszła do pompy");
expect(posts(fetch)).toHaveLength(1);
});
it("says nothing changed when the pump refuses", async () => {
serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-rejected"), 422));
const Settings = await settings();
render(<Settings />);
await userEvent.click(await screen.findByLabelText("Więcej: Ciepła woda, komfort"));
await userEvent.click(screen.getByRole("button", { name: "Zapisz" }));
await userEvent.click(screen.getByRole("button", { name: "Tak, zapisz" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Pompa nie przyjęła tej zmiany. Nic nie zostało zmienione.");
});
it("shows what a preset changes and sends it only on yes, reporting each change", async () => {
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-preset-partial"), 502));
const Settings = await settings();
render(<Settings />);
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
expect(screen.getByText(paragraph("Ciepła woda, komfort: 10 °C do odwołania."))).toBeInTheDocument();
expect(posts(fetch)).toHaveLength(0);
await userEvent.click(screen.getByRole("button", { name: "Tak, ustaw" }));
expect(posts(fetch)[0][0]).toBe("api/presets/away");
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Tylko część zmian jest zapisana.");
expect(alert).toHaveTextContent("Ciepła woda, komfort: zapisano");
expect(alert).toHaveTextContent("Ogrzewanie domu, komfort: brak odpowiedzi, nie wiadomo, czy doszło do pompy");
});
it("does not offer a change built on a missing reading", async () => {
const data = example<Setpoints>("setpoints");
data.setpoints[2].value = null;
serve({ "api/setpoints": data });
const Settings = await settings();
render(<Settings />);
expect(await screen.findByLabelText("Więcej: Ciepła woda, oszczędnie")).toBeDisabled();
expect(screen.getByLabelText("Mniej: Ciepła woda, oszczędnie")).toBeDisabled();
expect(screen.getByRole("button", { name: "Ciepła woda teraz (brak odczytu)" })).toBeDisabled();
});
});
+244
View File
@@ -0,0 +1,244 @@
import { useEffect, useState, type ReactNode } from "react";
import { postJson, WriteError } from "./api";
import { degrees, duration, when } from "./format";
import { reload, usePoll } from "./poll";
import type { Change, CommandResult, LineStatus, Setpoint, Setpoints } from "./types";
import { Card, Freshness, Loading, PageTitle } from "./ui";
const URL = "api/setpoints";
const NAMES: Record<string, string> = {
cwu_economy: "Ciepła woda, oszczędnie",
cwu_comfort: "Ciepła woda, komfort",
co_economy: "Ogrzewanie domu, oszczędnie",
co_comfort: "Ogrzewanie domu, komfort",
};
const DURATIONS = [
{ hours: 1, label: "1 godz." }, { hours: 2, label: "2 godz." },
{ hours: 4, label: "4 godz." }, { hours: null, label: "Do odwołania" },
];
// A write that loses its answer may still have reached the pump, so never claim that nothing changed.
export const WRITE_LOST = "Dom nie odpowiada. Nie wiadomo, czy zmiana doszła do pompy: sprawdź wartości, gdy połączenie wróci.";
const ERROR_TEXT: Record<number, string> = {
400: "Nieprawidłowa wartość. Nic nie zostało zmienione.",
404: "Nie ma takiego ustawienia. Nic nie zostało zmienione.",
};
const RESULT_TEXT: Record<string, string> = {
REJECTED: "Pompa nie przyjęła tej zmiany. Nic nie zostało zmienione.",
FAILED: "Nie wiadomo, czy zmiana doszła do pompy. Sprawdź wartości poniżej, zanim spróbujesz ponownie.",
PARTIAL: "Tylko część zmian jest zapisana. Sprawdź wartości poniżej, zanim spróbujesz ponownie.",
};
const LINE_TEXT: Record<LineStatus, string> = {
APPLIED: "zapisano",
REJECTED: "pompa nie przyjęła zmiany",
FAILED: "nie wiadomo, czy doszło do pompy",
UNKNOWN: "brak odpowiedzi, nie wiadomo, czy doszło do pompy",
NOT_ATTEMPTED: "nie wysłano",
};
type Result = (CommandResult & { changes?: Change[]; text?: string }) | null;
const name = (id: string) => NAMES[id] ?? id;
const find = (data: Setpoints, id: string) => data.setpoints.find((s) => s.name === id)!;
// The gateway has no reading until its first poll succeeds; never show or build on a made-up 0 °C.
const unread = (s: Setpoint | undefined) => s?.value == null;
const presetReady = (data: Setpoints, changes: Change[]) => changes.every((c) => !unread(find(data, c.setpoint)));
// A new temporary change keeps the value from before the first one (ADR-006), so that is what returns.
const returnValue = (data: Setpoints, id: string) =>
data.restores.find((r) => r.setpoint === id)?.originalValue ?? find(data, id).value;
function Button({ primary = false, big = false, children, ...props }: {
primary?: boolean; big?: boolean; children: ReactNode; onClick: () => void; disabled?: boolean;
"aria-label"?: string; "aria-pressed"?: boolean;
}) {
const look = primary
? "border-brand bg-brand text-white dark:text-side"
: "border-brand bg-panel text-brand aria-pressed:bg-brand aria-pressed:text-white dark:aria-pressed:text-side";
return (
<button {...props}
className={`flex-1 rounded-xl border-2 px-3 font-bold transition active:scale-[0.98] disabled:opacity-40 ${look} ${big ? "min-h-16 text-lg" : "min-h-12"}`}>
{children}
</button>
);
}
function ResultBox({ result }: { result: Result }) {
if (!result) return null;
const ok = result.status === "APPLIED";
const tone = ok ? "border-brand bg-brand-soft text-brand" : "border-alarm bg-red-50 text-alarm dark:bg-red-950/60 dark:text-red-200";
let body: ReactNode;
if (result.results) {
body = (
<>
{ok ? "Zapisano." : RESULT_TEXT[result.status]}
<ul className="mt-1 list-disc pl-5 font-medium">
{result.results.map((x, i) => (
<li key={i}>
{result.changes?.[i] ? name(result.changes[i].setpoint) : `Zmiana ${i + 1}`}: {LINE_TEXT[x.status]}
{x.status === "APPLIED" && x.restoreAt ? `, wróci o ${when(x.restoreAt)}` : ""}
</li>
))}
</ul>
</>
);
} else if (ok) {
body = <>Zapisano.{result.restoreAt ? ` Wrócimy do poprzedniej wartości o ${when(result.restoreAt)}.` : ""}</>;
} else {
body = result.text ?? RESULT_TEXT[result.status];
}
return <div role={ok ? "status" : "alert"} className={`mb-4 rounded-xl border-2 px-4 py-3 font-semibold ${tone}`}>{body}</div>;
}
function changeSentence(data: Setpoints, c: Change) {
const back = degrees(returnValue(data, c.setpoint));
const tail = c.hours == null ? "do odwołania" : `przez ${duration(c.hours)}, potem wróci ${back}`;
return <>{name(c.setpoint)}: <b>{degrees(c.value)}</b> {tail}.</>;
}
export function Settings() {
const { data, offline } = usePoll<Setpoints>(URL);
// The one setpoint being changed; confirming: the summary is shown, waiting for "Tak".
const [draft, setDraft] = useState<{ name: string; value: number } | null>(null);
const [hours, setHours] = useState<number | null>(2);
const [presetId, setPresetId] = useState<string | null>(null);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<Result>(null);
// A refresh can lose the reading while a change is half-made; drop what was built on it.
useEffect(() => {
if (!data) return;
if (draft && unread(find(data, draft.name))) { setDraft(null); setConfirming(false); }
const preset = data.presets.find((p) => p.id === presetId);
if (preset && !presetReady(data, preset.changes)) setPresetId(null);
}, [data, draft, presetId]);
const title = <PageTitle title="Ustawienia" subtitle="Temperatury zadane pompy ciepła" />;
if (!data) return <>{title}<Freshness offline={offline} />{!offline && <Loading />}</>;
const cancel = () => { setDraft(null); setPresetId(null); setConfirming(false); setResult(null); };
async function send(url: string, payload: unknown, changes?: Change[]) {
setBusy(true);
let answer: Result;
try {
answer = await postJson(url, payload);
if (changes) answer.changes = changes;
if (answer.status === "APPLIED") setDraft(null);
} catch (error) {
const status = error instanceof WriteError ? error.status : 0;
answer = { status: "FAILED", text: ERROR_TEXT[status] ?? WRITE_LOST };
}
setResult(answer);
setBusy(false);
setPresetId(null);
setConfirming(false);
window.scrollTo(0, 0);
reload(URL);
}
function step(s: Setpoint, direction: number) {
if (unread(s)) return;
setResult(null);
const from = draft?.name === s.name ? draft.value : s.value!;
const value = Math.min(s.max, Math.max(s.min, from + direction * s.step));
setDraft(value === s.value ? null : { name: s.name, value });
setConfirming(false);
}
const preset = data.presets.find((p) => p.id === presetId);
if (preset) {
return (
<>
{title}
<ResultBox result={result} />
<Card title={preset.name} className="max-w-2xl">
{preset.changes.map((c) => <p key={c.setpoint} className="my-2 text-lg font-semibold">{changeSentence(data, c)}</p>)}
<p className="my-3">Potwierdzasz?</p>
<div className="flex gap-3">
<Button onClick={cancel} disabled={busy}>Anuluj</Button>
<Button primary onClick={() => send(`api/presets/${preset.id}`, undefined, preset.changes)} disabled={busy}>Tak, ustaw</Button>
</div>
</Card>
</>
);
}
const current = draft ? find(data, draft.name) : null;
return (
<>
{title}
{offline && <Freshness offline />}
<ResultBox result={result} />
<div className="grid items-start gap-4 lg:grid-cols-2">
<div className="grid gap-4">
{data.restores.length > 0 && (
<Card title="Zmiany czasowe">
{data.restores.map((r) => (
<p key={r.setpoint} className="my-1">
{name(r.setpoint)}: <b>{degrees(r.appliedValue)}</b> do {when(r.due)}, potem <b>{degrees(r.originalValue)}</b>.
</p>
))}
</Card>
)}
<Card title="Szybkie ustawienia">
<div className="grid gap-3">
{data.presets.map((p) => {
const ready = presetReady(data, p.changes);
return (
<Button key={p.id} big onClick={() => { setResult(null); setPresetId(p.id); }} disabled={busy || !ready}>
{p.name}{ready ? "" : " (brak odczytu)"}
</Button>
);
})}
</div>
</Card>
</div>
<Card title="Nastawy">
{data.setpoints.map((s) => {
const shown = draft?.name === s.name ? draft.value : s.value;
const label = name(s.name);
return (
<div key={s.name} className={`border-t border-edge py-3 first:border-t-0 ${shown === s.value ? "" : "border-l-4 border-l-brand pl-3"}`}>
<span className="block font-semibold">{label}</span>
<div className="my-2 flex items-center gap-3">
<button aria-label={`Mniej: ${label}`} onClick={() => step(s, -1)} disabled={busy || unread(s) || shown! <= s.min}
className="size-16 rounded-xl border-2 border-brand bg-panel text-3xl font-bold text-brand disabled:opacity-40">−</button>
<output className="flex-1 text-center text-3xl font-bold tabular-nums">{unread(s) ? "brak odczytu" : degrees(shown)}</output>
<button aria-label={`Więcej: ${label}`} onClick={() => step(s, 1)} disabled={busy || unread(s) || shown! >= s.max}
className="size-16 rounded-xl border-2 border-brand bg-panel text-3xl font-bold text-brand disabled:opacity-40">+</button>
</div>
<span className="text-sm text-soft">od {degrees(s.min)} do {degrees(s.max)}</span>
</div>
);
})}
{draft && current && (
<div className="mt-2 border-t-2 border-brand pt-3">
<p className="text-lg font-semibold">{name(draft.name)}: z {degrees(current.value)} na <b>{degrees(draft.value)}</b></p>
<p className="my-2">Na jak długo?</p>
<div className="mb-3 grid grid-cols-2 gap-2">
{DURATIONS.map((o) => (
<Button key={o.label} aria-pressed={o.hours === hours} disabled={busy}
onClick={() => { setHours(o.hours); setConfirming(false); }}>{o.label}</Button>
))}
</div>
{confirming && (
<p className="my-3 text-lg font-semibold">
{hours
? `Ustawię ${degrees(draft.value)}. Za ${duration(hours)} wrócimy do ${degrees(returnValue(data, draft.name))}.`
: `Ustawię ${degrees(draft.value)} do odwołania. Poprzednia wartość (${degrees(returnValue(data, draft.name))}) nie wróci sama.`}
</p>
)}
<div className="flex gap-3">
<Button onClick={cancel} disabled={busy}>Anuluj</Button>
{confirming
? <Button primary disabled={busy} onClick={() => send(URL, { setpoint: draft.name, value: draft.value, hours: hours ?? undefined })}>Tak, zapisz</Button>
: <Button primary disabled={busy} onClick={() => setConfirming(true)}>Zapisz</Button>}
</div>
</div>
)}
</Card>
</div>
</>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { ago } from "./format";
import { usePoll } from "./poll";
import type { Health } from "./types";
import { Card, Dot, Freshness, Loading, PageTitle, Row } from "./ui";
const DEVICE_NAMES: Record<string, string> = {
"heat-pump": "Pompa ciepła",
"house-meter": "Licznik domu",
bme280: "Czujnik pogody",
deye: "Falownik Deye (bateria, PV)",
fronius: "Falownik Fronius (PV)",
};
export function Status() {
const { data, offline } = usePoll<Health>("api/health");
const title = <PageTitle title="Stan" subtitle="Połączenie z domem i urządzenia" />;
if (!data) return <>{title}<Freshness offline={offline} />{!offline && <Loading />}</>;
return (
<>
{title}
{offline && <Freshness offline time={data.time} />}
<div className="grid gap-4 lg:grid-cols-2">
<Card title="Połączenie z domem">
<p className="flex items-center gap-3 text-xl font-bold">
<Dot tone={data.gatewayReachable ? "ok" : "bad"} live={data.gatewayReachable} />
{data.gatewayReachable ? "Dom odpowiada" : "Dom nie odpowiada"}
</p>
</Card>
<Card title="Urządzenia">
{data.devices.length === 0 ? <p className="text-soft">Lista pojawi się, gdy dom odpowie.</p>
: data.devices.map((d) => {
const last = d.lastSuccess ? `ostatni odczyt ${ago(d.lastSuccess)}` : "brak odczytu";
return (
<Row key={d.name} icon={<Dot tone={d.up ? "ok" : "bad"} />}
label={<span><span className="text-ink">{DEVICE_NAMES[d.name] ?? d.name}</span><br />
<span className="text-xs">{d.up ? last : `nie odpowiada, ${last}`}</span></span>}
value={<span className={d.up ? "text-brand" : "text-alarm"}>{d.up ? "działa" : "brak"}</span>} />
);
})}
</Card>
</div>
</>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { Area, CartesianGrid, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { dayAndHour, hour, number, warsaw } from "./format";
import type { Point } from "./types";
export type Range = "24h" | "7d";
const HOUR_MS = 3600000;
const HOURS_BETWEEN_TICKS = 6;
const GAP_FACTOR = 3; // of the median step between points
const DAY_NAMES = ["nd", "pn", "wt", "śr", "cz", "pt", "sb"];
export interface Row {
t: number;
[key: string]: number | null;
}
/**
* One row per time, one column per series. A gap much longer than the usual step is an outage:
* a null row is put there, so the line stops instead of drawing a ramp that was never measured.
*/
export function toRows(series: Record<string, Point[]>): Row[] {
const rows = new Map<number, Row>();
for (const [key, points] of Object.entries(series)) {
for (const p of points) {
const t = Date.parse(p.t);
const row = rows.get(t) ?? { t };
row[key] = p.v;
rows.set(t, row);
}
}
const sorted = [...rows.values()].sort((a, b) => a.t - b.t);
const steps = sorted.slice(1).map((r, i) => r.t - sorted[i].t).sort((a, b) => a - b);
const limit = GAP_FACTOR * (steps[Math.floor(steps.length / 2)] ?? 0);
return sorted.flatMap((row, i) => (i && row.t - sorted[i - 1].t > limit ? [{ t: row.t - 1 }, row] : [row]));
}
// Warsaw midnights (or every 6th full Warsaw hour) inside the range, so the axis reads like a clock.
export function timeTicks(t0: number, t1: number, range: Range): number[] {
const ticks = [];
for (let t = Math.ceil(t0 / HOUR_MS) * HOUR_MS; t <= t1; t += HOUR_MS) {
const h = warsaw(t).hour;
if (range === "7d" ? h === 0 : h % HOURS_BETWEEN_TICKS === 0) ticks.push(t);
}
return ticks;
}
export interface Line {
key: string;
name: string;
colour: string;
dashed?: boolean;
}
export function TimeChart({ rows, lines, unit, range, height = 260, area = false }: {
rows: Row[]; lines: Line[]; unit: string; range: Range; height?: number; area?: boolean;
}) {
const times = rows.map((r) => r.t);
const ticks = timeTicks(Math.min(...times), Math.max(...times), range);
const digits = unit === "°C" ? 1 : 0;
const tick = (t: number) => (range === "7d" ? DAY_NAMES[warsaw(t + 1).weekday] : hour(new Date(t)));
return (
<div style={{ height }} className="w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -8 }}>
<defs>
{lines.map((l) => (
<linearGradient key={l.key} id={`fill-${l.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={l.colour} stopOpacity={0.35} />
<stop offset="1" stopColor={l.colour} stopOpacity={0} />
</linearGradient>
))}
</defs>
<CartesianGrid stroke="var(--color-edge)" vertical={false} />
<XAxis dataKey="t" type="number" scale="time" domain={["dataMin", "dataMax"]} ticks={ticks}
tickFormatter={tick} stroke="var(--color-soft)" fontSize={12} tickLine={false} />
<YAxis stroke="var(--color-soft)" fontSize={12} tickLine={false} axisLine={false} width={48}
domain={["auto", "auto"]} tickFormatter={(v: number) => number(v, 0)} />
<Tooltip
labelFormatter={(t) => dayAndHour(new Date(Number(t)))}
formatter={(v, name) => [`${number(Number(v), digits)} ${unit}`, name]}
contentStyle={{ background: "var(--color-panel)", border: "1px solid var(--color-edge)", borderRadius: 12 }}
/>
{lines.map((l) => (area
? <Area key={l.key} dataKey={l.key} name={l.name} stroke={l.colour} strokeWidth={2.5}
fill={`url(#fill-${l.key})`} dot={false} isAnimationActive={false} connectNulls={false} />
: <Line key={l.key} dataKey={l.key} name={l.name} stroke={l.colour} strokeWidth={2.5}
strokeDasharray={l.dashed ? "5 5" : undefined} dot={false} isAnimationActive={false} connectNulls={false} />
))}
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
export function Legend({ lines }: { lines: Line[] }) {
return (
<ul className="mb-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-soft">
{lines.map((l) => (
<li key={l.key} className="flex items-center gap-1.5">
<svg width="18" height="6" aria-hidden="true">
<line x1="1" y1="3" x2="17" y2="3" stroke={l.colour} strokeWidth="3" strokeLinecap="round"
strokeDasharray={l.dashed ? "4 3" : undefined} />
</svg>
{l.name}
</li>
))}
</ul>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { CommandResult } from "./types";
// A request that gets no answer in time is given up, so a screen never waits for ever.
const READ_TIMEOUT_MS = 15000;
const WRITE_TIMEOUT_MS = 20000;
export async function getJson<T>(url: string): Promise<T> {
const response = await fetch(url, { cache: "no-store", signal: AbortSignal.timeout(READ_TIMEOUT_MS) });
if (!response.ok) throw new Error(`${url}: ${response.status}`);
return response.json();
}
export class WriteError extends Error {
constructor(readonly status: number) {
super(`write failed: ${status}`);
}
}
// A write is sent once and never retried. Resolves with the {status, ...} answer, also for a
// refusal (422) or a failed write (502); rejects with the HTTP status on 400, 404 and 503,
// and with status 0 when the network fails or the answer does not come in time.
export async function postJson(url: string, body?: unknown): Promise<CommandResult> {
const response = await fetch(url, {
method: "POST",
// The custom header is what the backend uses to tell this page from another site's form.
headers: { "Content-Type": "application/json", "X-Requested-With": "home-pump" },
body: JSON.stringify(body ?? {}),
signal: AbortSignal.timeout(WRITE_TIMEOUT_MS),
}).catch(() => {
throw new WriteError(0);
});
const answer = await response.json().catch(() => null);
if (!answer?.status) throw new WriteError(response.status);
return answer;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Loaded 100 of 273 files, more files were not shown because too many files have changed in this diff. Show more