Compare commits

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 04:39:35 +02:00
root 1cc8a3b545 Merge PR #41: fix(web): enlarge schematic, chart and tab text
CI / Backend tests (Maven / Java 21) (push) Successful in 1m5s
2026-10-24 04:36:14 +02:00
root 9ec0ae2425 Merge PR #40: test(backend): use real gateway output for the end-to-end tests 2026-10-24 04:36:14 +02:00
root f84bf5a953 Merge PR #39: fix(backend): round heat pump temperatures to the controller's 0.1 2026-10-24 04:36:14 +02:00
rootandClaude Sonnet 5.5 4446499996 fix(web): enlarge schematic, chart and tab text
CI / Backend tests (Maven / Java 21) (push) Successful in 1m4s
Small labels were hard to read; the solar collector box is shortened so its text no longer touches the border.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

+3
View File
@@ -56,3 +56,6 @@ coverage/
/apk/
/old/
/old_backup/
# Agent worktrees
/.claude/worktrees/
+24 -157
View File
@@ -1,168 +1,35 @@
# Home Pump Monitor
# home-pump
A Java application that reads data from a heat pump via Modbus TCP and sends metrics to InfluxDB.
Heat pump (Frisko RX910-SE21) and home energy for the house in Krosno, from a phone.
## Features
- Reads Modbus holding registers from a heat pump
- Configurable send interval to InfluxDB (default: 5 minutes)
- Automatic buffering when InfluxDB is unavailable (up to 50,000 data points)
- Health check endpoint for monitoring application status
- Automatic restart on Modbus connection failures
- Graceful shutdown with InfluxDB buffer flush
- Systemd service with automatic recovery
- Configurable via JSON file
## Quick Start
### Local Development
```bash
mvn clean package
java -jar target/home-pump-1.0.0.jar
```
### Production Deployment (Raspberry Pi / Ubuntu)
See [DEPLOYMENT.md](DEPLOYMENT.md) for complete installation and configuration guide.
- What we build: [docs/spec.md](docs/spec.md)
- Plan and current milestone: [docs/roadmap.md](docs/roadmap.md)
- Rules for changes: [CONTRIBUTING.md](CONTRIBUTING.md)
- Controller knowledge: [docs/frisko/](docs/frisko/rx910-se21.md)
```bash
# Build
mvn clean package
# Install as systemd service
sudo scripts/install-service.sh
# Check status
systemctl status home-pump.service
curl http://localhost:8080/health
scripts/check.sh # docs, secret scan, build, 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)
```
## Configuration
## Backend (phone API and web UI)
Edit `src/main/resources/registryMapping.json` to configure:
Settings are environment variables; the backend refuses to start without the two URLs.
- Modbus TCP connection (address, port)
- InfluxDB connection (address, username, password)
- Send interval (influxSendIntervalSeconds, default: 300)
- Connection timeouts and retry settings
- List of registers to read with descriptions
Additional mapping fields (optional):
- `useMock` (Boolean, top-level): When `true`, the application runs with a local `MockRepository` instead of attempting to connect to a Modbus device. Useful for local development when a device is not available (default: false).
- `unitId` (Integer): Modbus slave/unit id for the register (default: 1)
- `scale` (Double): Multiply raw device value by this to obtain display value (default: 0.1)
- `dataType` (String): Data type used for the locator (e.g. `TWO_BYTE_INT_SIGNED`). Supported values: `TWO_BYTE_INT_SIGNED`, `TWO_BYTE_INT_UNSIGNED`, `FOUR_BYTE_INT_SIGNED`, `FOUR_BYTE_INT_UNSIGNED`, `FOUR_BYTE_FLOAT` (the latter reads two adjacent registers and interprets as IEEE754 float). For devices with non-standard word order, the following swapped variants are supported: `FOUR_BYTE_INT_SWAPPED`, `FOUR_BYTE_INT_UNSIGNED_SWAPPED`, `FOUR_BYTE_FLOAT_SWAPPED` (word order low/high).
- # Example Configuration
```json
{
"address": "192.168.0.150",
"port": 502,
"useMock": false,
"influxSendIntervalSeconds": 300,
"timeoutMs": 5000,
"maxRetries": 2,
"retryBackoffMs": 1000,
"influx_db": {
"address": "https://influxdb.example.com",
"username": "",
"password": ""
},
"registry_mapping": [
{
"registry": 4065,
"desc": "External temperature",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED"
}
]
}
```
## Monitoring & Health Check
The application exposes a health check endpoint on port 8080:
| 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 |
```bash
curl http://localhost:8080/health
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
```
**Healthy response (HTTP 200):**
```json
{
"status": "UP",
"lastSuccessfulRead": "2026-02-06T19:30:45Z",
"message": "Last successful read 15 seconds ago"
}
```
**Unhealthy response (HTTP 503):**
```json
{
"status": "DOWN",
"lastSuccessfulRead": "2026-02-06T19:20:45Z",
"message": "No successful read for 620 seconds. Last error: Connection timeout"
}
```
### Important Notes
- **InfluxDB failures do NOT trigger unhealthy status** - data is buffered in memory
- Only Modbus read failures trigger unhealthy status and potential restart
- Health check considers the application unhealthy if no successful Modbus read within 2× send interval
- Automatic monitoring script restarts the service on health check failures
## Production Deployment
For production use on Raspberry Pi or Ubuntu server:
1. **Install as systemd service** - Auto-start on boot with resource limits
2. **Enable health monitoring** - Cron job checks health every 2 minutes
3. **Automatic recovery** - Restarts service on Modbus failures (max 3 attempts per 10 minutes)
4. **Graceful shutdown** - Flushes buffered data to InfluxDB before exit
See [DEPLOYMENT.md](DEPLOYMENT.md) for detailed instructions.
## Data Buffering
When InfluxDB is unavailable, data is automatically buffered:
- **Buffer size**: 50,000 data points in memory
- **Batch size**: 50 points per flush
- **Flush interval**: 180 seconds (3 minutes)
- **No data loss**: Buffer persists during InfluxDB outages
- **Automatic retry**: Built-in retry mechanism in InfluxDB Java client
## Troubleshooting
### Check application logs
```bash
journalctl -u home-pump.service -f
```
### Test health endpoint
```bash
./scripts/test-health.sh
```
### View monitor logs
```bash
tail -f /var/log/home-pump-monitor.log
```
### Common Issues
1. **Service won't start**: Check JAR file exists and Java 21 is installed
2. **Health check fails**: Verify Modbus device is accessible
3. **InfluxDB connection errors**: Check network and credentials (data will be buffered)
4. **Too many restarts**: Check Modbus device connectivity and network stability clean package
java -jar target/home-pump-1.0.0.jar
```
## Requirements
- Java 21
- Modbus TCP accessible heat pump
- InfluxDB instance
+16
View File
@@ -0,0 +1,16 @@
# Build the jar first: mvn -f backend/pom.xml clean package
FROM eclipse-temurin:21-jre-alpine
RUN adduser -D -u 10001 app
USER app
WORKDIR /app
COPY 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"]
+84
View File
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.home</groupId>
<artifactId>home-pump-backend</artifactId>
<version>1.0.0</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>21</maven.compiler.release>
<jackson.version>2.20.1</jackson.version>
</properties>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>1.7.32</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.8.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
<configuration>
<redirectTestOutputToFile>true</redirectTestOutputToFile>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.4.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>home.backend.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,31 @@
package home.backend;
import home.backend.adapters.gateway.GatewayHttpClient;
import home.backend.adapters.http.ApiServer;
import home.backend.adapters.influx.InfluxHistoryRepository;
import home.backend.adapters.influx.InfluxSettings;
import home.backend.application.EnergyService;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService;
import java.io.IOException;
import java.time.Clock;
/** Wires the adapters and services together; the only place that knows all of them. */
final class Backend {
private Backend() {
}
/** Binds the port but does not start serving. */
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()));
return new ApiServer(settings.port(),
new HeatPumpService(gateway, clock),
new EnergyService(gateway, clock),
new HealthService(gateway, clock),
new HistoryService(influx, clock));
}
}
@@ -0,0 +1,27 @@
package home.backend;
import home.backend.adapters.http.ApiServer;
import java.io.IOException;
import java.time.Clock;
public final class Main {
private Main() {
}
public static void main(String[] args) throws IOException {
Settings settings;
try {
settings = Settings.fromEnvironment(System.getenv());
} catch (IllegalArgumentException e) {
System.err.println("home-pump-backend: " + e.getMessage());
System.exit(2);
return;
}
ApiServer server = Backend.assemble(settings, Clock.systemUTC());
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
server.start();
System.out.println("home-pump-backend listening on port " + server.port());
}
}
@@ -0,0 +1,48 @@
package home.backend;
import java.net.URI;
import java.time.Duration;
import java.util.Map;
/** Twelve-factor settings: everything comes from environment variables. */
public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gatewayTimeout, Duration influxTimeout) {
public static Settings fromEnvironment(Map<String, String> env) {
return new Settings(
url(env, "GATEWAY_URL"),
url(env, "INFLUX_URL"),
(int) number(env, "PORT", 8080, 0, 65535),
Duration.ofSeconds(number(env, "GATEWAY_TIMEOUT", 5, 1, 300)),
Duration.ofSeconds(number(env, "INFLUX_TIMEOUT", 10, 1, 300)));
}
private static URI url(Map<String, String> env, String name) {
String value = env.get(name);
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(name + " is required, e.g. " + name + "=http://host:8080");
}
URI uri = URI.create(value.strip());
if (uri.getHost() == null || !("http".equals(uri.getScheme()) || "https".equals(uri.getScheme()))) {
throw new IllegalArgumentException(name + " must be an http(s) URL with a host, got: " + value);
}
return uri;
}
/** Timeouts are whole seconds. */
private static long number(Map<String, String> env, String name, long fallback, long min, long max) {
String value = env.get(name);
if (value == null || value.isBlank()) {
return fallback;
}
try {
long number = Long.parseLong(value.strip());
if (number >= min && number <= max) {
return number;
}
} catch (NumberFormatException e) {
// reported below with the same message
}
throw new IllegalArgumentException(name + " must be a whole number from " + min + " to " + max
+ ", got: " + value);
}
}
@@ -0,0 +1,88 @@
package home.backend.adapters.gateway;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import home.backend.application.GatewayClient;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/** Reads the gateway's {@code /health} and {@code /snapshot} over HTTP. */
public class GatewayHttpClient implements GatewayClient {
private record Health(List<DeviceHealth> devices) {
}
private record DeviceHealth(String name, String status, Instant lastSuccess, String lastError) {
}
private record DeviceReadings(String name, List<Reading> readings) {
}
private record Reading(String database, String measurement, Map<String, Double> fields, Instant time) {
}
// Unknown properties are ignored so the gateway can add fields without breaking us.
private static final ObjectMapper JSON = new ObjectMapper()
.registerModule(new JavaTimeModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private final URI baseUri;
private final Duration timeout;
private final HttpClient http;
public GatewayHttpClient(URI baseUri, Duration timeout) {
this.baseUri = baseUri;
this.timeout = timeout;
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
}
@Override
public GatewaySnapshot snapshot() throws IOException {
var health = JSON.readValue(get("/health", true), Health.class);
var readings = JSON.readValue(get("/snapshot", false), DeviceReadings[].class);
Map<String, List<Measurement>> measurements = Arrays.stream(readings)
.collect(Collectors.toMap(DeviceReadings::name, GatewayHttpClient::measurements));
var devices = health.devices().stream()
.map(device -> new DeviceSnapshot(device.name(), device.status().equals("UP"),
device.lastSuccess(), device.lastError(),
measurements.getOrDefault(device.name(), List.of())))
.toList();
return new GatewaySnapshot(devices);
}
private static List<Measurement> measurements(DeviceReadings device) {
return device.readings().stream()
.map(r -> new Measurement(r.database(), r.measurement(), r.fields(), r.time()))
.toList();
}
/** A gateway with a device down answers /health with 503 and a valid body. */
private byte[] get(String path, boolean downIsValid) throws IOException {
var request = HttpRequest.newBuilder(baseUri.resolve(path)).timeout(timeout).GET().build();
try {
var response = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
int status = response.statusCode();
if (status != 200 && !(downIsValid && status == 503)) {
throw new IOException("gateway " + path + " answered " + status);
}
return response.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("interrupted while calling gateway " + path, e);
}
}
}
@@ -0,0 +1,123 @@
package home.backend.adapters.http;
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.HttpServer;
import home.backend.application.EnergyService;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Executors;
/** The phone-facing JSON API under {@code /api/} and the static web UI. */
public class ApiServer {
private static final ObjectMapper JSON = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
private final HttpServer server;
/** Binds the port now; 0 picks a free one. */
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy, HealthService health,
HistoryService history) throws IOException {
server = HttpServer.create(new InetSocketAddress(port), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.createContext("/", new StaticFiles());
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));
}
public void start() {
server.start();
}
public void stop() {
server.stop(0);
}
public int port() {
return server.getAddress().getPort();
}
private interface Gateway {
Object get(Map<String, String> query) throws IOException;
}
/** Gateway trouble on a live-state endpoint is the gateway being unreachable: 503. */
private void api(String path, Gateway gateway) {
server.createContext(path, exchange -> respond(exchange, () -> {
try {
send(exchange, 200, gateway.get(Map.of()));
} catch (IOException e) {
send(exchange, 503, error("gateway unreachable"));
}
}));
}
private void handleHistory(HttpExchange exchange, HistoryService history) throws IOException {
respond(exchange, () -> {
Map<String, String> query = parseQuery(exchange.getRequestURI().getRawQuery());
try {
send(exchange, 200, history.history(query.get("series"), query.get("range")));
} catch (IllegalArgumentException e) {
send(exchange, 400, error(e.getMessage()));
} catch (IOException e) {
send(exchange, 502, error("history unavailable"));
}
});
}
private interface Action {
void run() throws IOException;
}
private static void respond(HttpExchange exchange, Action action) throws IOException {
try (exchange) {
if (!exchange.getRequestMethod().equals("GET")) {
exchange.sendResponseHeaders(405, -1);
return;
}
action.run();
}
}
private static void send(HttpExchange exchange, int status, Object body) throws IOException {
byte[] bytes = JSON.writeValueAsBytes(body);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.getResponseHeaders().set("Cache-Control", "no-store");
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
}
private static Map<String, String> error(String message) {
return Map.of("error", message);
}
private static Map<String, String> parseQuery(String raw) {
Map<String, String> query = new HashMap<>();
if (raw == null) {
return query;
}
for (String pair : raw.split("&")) {
String[] kv = pair.split("=", 2);
query.put(decode(kv[0]), kv.length == 2 ? decode(kv[1]) : "");
}
return query;
}
private static String decode(String s) {
return URLDecoder.decode(s, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,63 @@
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));
}
}
@@ -0,0 +1,78 @@
package home.backend.adapters.influx;
import home.backend.application.HistoryRepository;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/** History from InfluxDB 1.x over its HTTP {@code /query} endpoint (ADR-005, ADR-008). */
public final class InfluxHistoryRepository implements HistoryRepository {
private final InfluxSettings settings;
private final HttpClient http;
public InfluxHistoryRepository(InfluxSettings settings) {
this.settings = settings;
this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build();
}
@Override
public List<Point> history(Series series, Instant from, Instant to, int maxPoints) throws IOException {
if (maxPoints < 1 || !from.isBefore(to)) {
throw new IllegalArgumentException("need maxPoints >= 1 and from < to");
}
long stepMinutes = stepMinutes(from, to, maxPoints);
Map<Long, Double> summed = new TreeMap<>();
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));
}
return summed.entrySet().stream()
.map(e -> new Point(Instant.ofEpochMilli(e.getKey()), e.getValue()))
.toList();
}
private static long stepMinutes(Instant from, Instant to, int maxPoints) {
long stepMillis = Math.ceilDiv(Duration.between(from, to).toMillis(), maxPoints);
return Math.max(1, Math.ceilDiv(stepMillis, Duration.ofMinutes(1).toMillis()));
}
private String fetch(String database, String query) throws IOException {
HttpRequest request = HttpRequest.newBuilder(queryUri(database, query))
.timeout(settings.requestTimeout())
.GET()
.build();
try {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("InfluxDB answered HTTP " + response.statusCode());
}
return response.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("InfluxDB request interrupted", e);
}
}
private URI queryUri(String database, String query) {
String base = settings.baseUri().toString().replaceAll("/+$", "");
return URI.create(base + "/query?db=" + encode(database) + "&epoch=ms&q=" + encode(query));
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,50 @@
package home.backend.adapters.influx;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.Map;
import java.util.TreeMap;
/** The {@code time, mean} rows of an InfluxDB 1.x query response. */
final class InfluxResponse {
private static final ObjectMapper JSON = new ObjectMapper();
private InfluxResponse() {
}
/** Mean per bucket start in epoch milliseconds, ascending; empty when there is no data. */
static Map<Long, Double> parse(String body) throws IOException {
JsonNode root = read(body);
failOnError(root);
Map<Long, Double> means = new TreeMap<>();
for (JsonNode result : root.path("results")) {
failOnError(result);
for (JsonNode series : result.path("series")) {
for (JsonNode row : series.path("values")) {
if (row.size() >= 2 && row.get(1).isNumber()) {
means.put(row.get(0).asLong(), row.get(1).asDouble());
}
}
}
}
return means;
}
private static JsonNode read(String body) throws IOException {
try {
return JSON.readTree(body);
} catch (JsonProcessingException e) {
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
}
}
private static void failOnError(JsonNode node) throws IOException {
if (node.hasNonNull("error")) {
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
}
}
}
@@ -0,0 +1,7 @@
package home.backend.adapters.influx;
import java.net.URI;
import java.time.Duration;
public record InfluxSettings(URI baseUri, Duration requestTimeout) {
}
@@ -0,0 +1,44 @@
package home.backend.adapters.influx;
import home.backend.domain.Series;
import java.util.List;
import java.util.Map;
/** Where each series lives in InfluxDB. A fixed table: no query is built from user input. */
final class SeriesSources {
private static final String LEGACY = "Dom_Pompa";
private static final String ELECTRICITY = "electricity";
private static final Map<Series, List<Source>> SOURCES = Map.ofEntries(
Map.entry(Series.OUTDOOR_TEMP, List.of(pompa("External temperature"))),
Map.entry(Series.INDOOR_TEMP, List.of(pompa("Temp wew"))),
Map.entry(Series.CO_BUFFER_TEMP, List.of(pompa("Temp co"))),
Map.entry(Series.CWU_TEMP, List.of(pompa("Temp cwu"))),
Map.entry(Series.GROUND_IN_TEMP, List.of(pompa("Temp in"))),
Map.entry(Series.GROUND_OUT_TEMP, List.of(pompa("Temp out"))),
Map.entry(Series.PUMP_OUTLET_TEMP, List.of(pompa("Temp pc out"))),
Map.entry(Series.GRID_POWER, List.of(electricity("PRAD2", "total_power_active"))),
Map.entry(Series.PV_POWER, List.of(
electricity("deye", "pv1_power_w"),
electricity("deye", "pv2_power_w"),
electricity("fronius", "pv_power_w"))),
Map.entry(Series.LOAD_POWER, List.of(electricity("deye", "load_power_w"))),
Map.entry(Series.BATTERY_SOC, List.of(electricity("deye", "battery_soc_pct"))));
private SeriesSources() {
}
static List<Source> of(Series series) {
return SOURCES.get(series);
}
private static Source pompa(String field) {
return new Source(LEGACY, "POMPA", field);
}
private static Source electricity(String measurement, String field) {
return new Source(ELECTRICITY, measurement, field);
}
}
@@ -0,0 +1,15 @@
package home.backend.adapters.influx;
/** One field of one measurement; the parts of a series are summed. */
record Source(String database, String measurement, String field) {
String query(long fromMs, long toMs, long stepMinutes) {
return "SELECT mean(" + quote(field) + ") FROM " + quote(measurement)
+ " WHERE time >= " + fromMs + "ms AND time < " + toMs + "ms"
+ " GROUP BY time(" + stepMinutes + "m) fill(none)";
}
private static String quote(String identifier) {
return '"' + identifier.replace("\\", "\\\\").replace("\"", "\\\"") + '"';
}
}
@@ -0,0 +1,56 @@
package home.backend.application;
import home.backend.domain.EnergyView;
import home.backend.domain.EnergyView.Today;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
/** F7: house electricity from the Deye inverter and the Fronius PV inverter. */
public class EnergyService {
private final GatewayClient gateway;
private final Clock clock;
public EnergyService(GatewayClient gateway, Clock clock) {
this.gateway = gateway;
this.clock = clock;
}
public EnergyView current() throws IOException {
GatewaySnapshot snapshot = gateway.snapshot();
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());
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),
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);
}
/** Sum of the values that exist; null when none does. */
private static Double sum(Double... values) {
return Stream.of(values).filter(v -> v != null).reduce(Double::sum).orElse(null);
}
/** Wh to kWh leaves binary noise such as 34.300000000000004. */
private static Double round(Double kwh) {
return kwh == null ? null : Math.round(kwh * 1000) / 1000.0;
}
}
@@ -0,0 +1,11 @@
package home.backend.application;
import home.backend.domain.GatewaySnapshot;
import java.io.IOException;
/** The live state, read from the gateway on the Pi through the tunnel. */
public interface GatewayClient {
GatewaySnapshot snapshot() throws IOException;
}
@@ -0,0 +1,35 @@
package home.backend.application;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.HealthView;
import home.backend.domain.HealthView.DeviceHealth;
import java.io.IOException;
import java.time.Clock;
import java.util.List;
/** F5: whether the gateway answers and which device links are up. */
public class HealthService {
private final GatewayClient gateway;
private final Clock clock;
public HealthService(GatewayClient gateway, Clock clock) {
this.gateway = gateway;
this.clock = clock;
}
public HealthView current() {
try {
List<DeviceHealth> devices = gateway.snapshot().devices().stream()
.map(HealthService::health).toList();
return new HealthView(clock.instant(), true, devices);
} catch (IOException e) {
return new HealthView(clock.instant(), false, List.of());
}
}
private static DeviceHealth health(DeviceSnapshot d) {
return new DeviceHealth(d.name(), d.up(), d.lastSuccess(), d.lastError());
}
}
@@ -0,0 +1,65 @@
package home.backend.application;
import home.backend.domain.GatewaySnapshot.Measurement;
import home.backend.domain.HeatPumpView;
import home.backend.domain.HeatPumpView.Pumps;
import home.backend.domain.HeatPumpView.Setpoints;
import home.backend.domain.HeatPumpView.State;
import home.backend.domain.HeatPumpView.Targets;
import home.backend.domain.HeatPumpView.Temperatures;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.util.Map;
import static home.backend.application.SnapshotFields.on;
import static home.backend.application.SnapshotFields.whole;
/** F1: the heat pump's live state from the gateway's {@code heat_pump} measurement. */
public class HeatPumpService {
private static final String DATABASE = "Dom_Pompa";
private static final State[] STATES = State.values();
private final GatewayClient gateway;
private final Clock clock;
public HeatPumpService(GatewayClient gateway, Clock clock) {
this.gateway = gateway;
this.clock = clock;
}
public HeatPumpView current() throws IOException {
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()));
}
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"),
new Pumps(on(f, "output_co"), on(f, "output_buffer"), on(f, "output_circulation"),
on(f, "output_ground"), on(f, "output_cwu")),
new Temperatures(tenths(f, "outdoor_temp"), tenths(f, "indoor_temp"),
tenths(f, "co_buffer_temp"), tenths(f, "cwu_temp"), tenths(f, "ground_in_temp"),
tenths(f, "ground_out_temp"), tenths(f, "pump_outlet_temp"),
tenths(f, "outdoor_avg_temp")),
new Targets(tenths(f, "target_room_temp"), tenths(f, "target_co_temp"),
tenths(f, "target_cwu_temp")),
new Setpoints(whole(f, "setpoint_co_economy"), whole(f, "setpoint_co_comfort"),
whole(f, "setpoint_cwu_economy"), whole(f, "setpoint_cwu_comfort")));
}
/** The controller resolves 0.1 °C; the gateway's 212 * 0.1 is 21.200000000000003. */
private static Double tenths(Map<String, Double> fields, String name) {
Double value = fields.get(name);
return value == null ? null : Math.round(value * 10) / 10.0;
}
private static State state(Map<String, Double> f) {
Integer mode = whole(f, "pump_mode");
return mode == null || mode < 0 || mode >= STATES.length ? null : STATES[mode];
}
}
@@ -0,0 +1,14 @@
package home.backend.application;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
/** Past values from InfluxDB, averaged into about {@code maxPoints} steps. */
public interface HistoryRepository {
List<Point> history(Series series, Instant from, Instant to, int maxPoints) throws IOException;
}
@@ -0,0 +1,43 @@
package home.backend.application;
import home.backend.domain.HistoryView;
import home.backend.domain.Series;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
/** F2: one series over the last 24 hours or 7 days. */
public class HistoryService {
static final int MAX_POINTS = 300;
private static final String DEFAULT_RANGE = "24h";
private static final Map<String, Duration> RANGES =
Map.of("24h", Duration.ofHours(24), "7d", Duration.ofDays(7));
private final HistoryRepository repository;
private final Clock clock;
public HistoryService(HistoryRepository repository, Clock clock) {
this.repository = repository;
this.clock = clock;
}
/**
* @param range {@code 24h} or {@code 7d}; null means 24h
* @throws IllegalArgumentException for an unknown series or range
*/
public HistoryView history(String seriesId, String range) throws IOException {
Series series = Series.fromId(seriesId)
.orElseThrow(() -> new IllegalArgumentException("unknown series: " + seriesId));
Duration length = RANGES.get(range == null ? DEFAULT_RANGE : range);
if (length == null) {
throw new IllegalArgumentException("unknown range: " + range);
}
Instant to = clock.instant();
return new HistoryView(series.id(), series.unit(),
repository.history(series, to.minus(length), to, MAX_POINTS));
}
}
@@ -0,0 +1,35 @@
package home.backend.application;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/** Looks up one measurement of one device in a snapshot and reads typed fields. */
final class SnapshotFields {
private SnapshotFields() {
}
static Optional<Measurement> find(GatewaySnapshot snapshot, String device, String measurement) {
return snapshot.devices().stream()
.filter(d -> d.name().equals(device))
.map(DeviceSnapshot::measurements)
.flatMap(List::stream)
.filter(m -> m.measurement().equals(measurement))
.findFirst();
}
static Integer whole(Map<String, Double> fields, String name) {
Double value = fields.get(name);
return value == null ? null : (int) Math.round(value);
}
static boolean on(Map<String, Double> fields, String name) {
Double value = fields.get(name);
return value != null && value != 0;
}
}
@@ -0,0 +1,15 @@
package home.backend.domain;
import java.time.Instant;
/**
* The house's electricity now (F7). Powers in W; grid + import / − export, battery + discharge /
* − charge. Null when the source device is not read.
*/
public record EnergyView(Instant time, Double pvPowerW, Double gridPowerW, Double batteryPowerW,
Double batterySocPct, Double loadPowerW, Today today) {
/** Day totals in kWh since local midnight; PV adds Deye and Fronius. */
public record Today(Double pvKwh, Double gridBoughtKwh, Double gridSoldKwh, Double loadKwh) {
}
}
@@ -0,0 +1,17 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
import java.util.Map;
/** The gateway's latest state of every device, as the backend sees it. */
public record GatewaySnapshot(List<DeviceSnapshot> devices) {
public record DeviceSnapshot(String name, boolean up, Instant lastSuccess, String lastError,
List<Measurement> measurements) {
}
/** One reading the gateway wrote to InfluxDB, e.g. heat_pump or deye. */
public record Measurement(String database, String measurement, Map<String, Double> fields, Instant time) {
}
}
@@ -0,0 +1,11 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/** Health of the chain phone → backend → gateway → devices (F5). */
public record HealthView(Instant time, boolean gatewayReachable, List<DeviceHealth> devices) {
public record DeviceHealth(String name, boolean up, Instant lastSuccess, String lastError) {
}
}
@@ -0,0 +1,26 @@
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) {
/** From register 4227: 0 stop, 1 CO, 2 CWU, 3 both. */
public enum State { IDLE, HEATING_CO, HEATING_CWU, HEATING_BOTH }
public record Pumps(boolean co, boolean buffer, boolean circulation, boolean ground, boolean cwu) {
}
public record Temperatures(Double outdoor, Double indoor, Double coBuffer, Double cwu,
Double groundIn, Double groundOut, Double pumpOutlet, Double outdoorAvg) {
}
/** Targets the controller works to right now (economy or comfort period). */
public record Targets(Double room, Double co, Double cwu) {
}
/** The four setpoints F3 will change; whole °C. */
public record Setpoints(Integer coEconomy, Integer coComfort, Integer cwuEconomy, Integer cwuComfort) {
}
}
@@ -0,0 +1,11 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/** One series over a time range, already averaged into chart-sized steps. */
public record HistoryView(String series, String unit, List<Point> points) {
public record Point(Instant t, double v) {
}
}
@@ -0,0 +1,39 @@
package home.backend.domain;
import java.util.Arrays;
import java.util.Optional;
/** The history series the UI may ask for (F2); an allowlist, so no query is built from input. */
public enum Series {
OUTDOOR_TEMP("outdoor_temp", "°C"),
INDOOR_TEMP("indoor_temp", "°C"),
CO_BUFFER_TEMP("co_buffer_temp", "°C"),
CWU_TEMP("cwu_temp", "°C"),
GROUND_IN_TEMP("ground_in_temp", "°C"),
GROUND_OUT_TEMP("ground_out_temp", "°C"),
PUMP_OUTLET_TEMP("pump_outlet_temp", "°C"),
GRID_POWER("grid_power", "W"),
PV_POWER("pv_power", "W"),
LOAD_POWER("load_power", "W"),
BATTERY_SOC("battery_soc", "%");
private final String id;
private final String unit;
Series(String id, String unit) {
this.id = id;
this.unit = unit;
}
public String id() {
return id;
}
public String unit() {
return unit;
}
public static Optional<Series> fromId(String id) {
return Arrays.stream(values()).filter(series -> series.id.equals(id)).findFirst();
}
}
+19
View File
@@ -0,0 +1,19 @@
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();
}
// A write is sent once and never retried. Resolves with the {status, message, restoreAt}
// answer, also for a refusal; rejects when the gateway is unreachable (503) or the network fails.
export async function postJson(url, body) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body ?? {}),
});
if (response.status === 503) throw new Error(`${url}: 503`);
const answer = await response.json().catch(() => null);
if (!answer?.status) throw new Error(`${url}: ${response.status}`);
return answer;
}
+65
View File
@@ -0,0 +1,65 @@
import { ageMinutes, ago } from "./format.js";
import heatPump from "./heat-pump.js";
import energy from "./energy.js";
import history from "./history.js";
import settings from "./settings.js";
import status from "./status.js";
const REFRESH_MS = 30000;
const STALE_MINUTES = 10;
const screens = { "heat-pump": heatPump, energy, history, settings, 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
@@ -0,0 +1,100 @@
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
@@ -0,0 +1,78 @@
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
@@ -0,0 +1,39 @@
// 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)}`;
@@ -0,0 +1,46 @@
import { getJson } from "./api.js";
import { celsius, 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, 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>`;
}
export default { load: () => getJson("api/heat-pump"), view };
+61
View File
@@ -0,0 +1,61 @@
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 };
+22
View File
@@ -0,0 +1,22 @@
<!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="settings"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h2M10 17h10" fill="none" stroke-width="2.5"/><circle cx="16" cy="7" r="2.5"/><circle cx="8" cy="17" r="2.5"/></svg>Ustawienia</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>
@@ -0,0 +1,47 @@
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>`;
}
+165
View File
@@ -0,0 +1,165 @@
import { getJson, postJson } from "./api.js";
import { number, hour, dayAndHour } from "./format.js";
const NAMES = {
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" },
];
const OFFLINE = "Dom nie odpowiada, spróbuj za chwilę.";
const RESULT_TEXT = {
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.",
};
// Screen state outlives the 30 s refresh, so a half-made change is not lost.
// draft: the one setpoint being changed; confirming: the summary is shown, waiting for "Tak".
const state = { draft: null, hours: 2, preset: null, confirming: false, busy: false, result: null };
const name = (id) => NAMES[id] ?? id;
const degrees = (value) => `${number(value, 1).replace(/,0$/, "")} °C`;
const disabled = () => (state.busy ? " disabled" : "");
const find = (data, id) => data.setpoints.find((s) => s.name === id);
function when(iso) {
const date = new Date(iso);
return date.toDateString() === new Date().toDateString() ? hour(date) : dayAndHour(date);
}
function restores(data) {
if (!data.restores.length) return "";
const lines = data.restores.map((r) =>
`<p>${name(r.setpoint)}: <b>${degrees(r.appliedValue)}</b> do ${when(r.due)}, potem <b>${degrees(r.originalValue)}</b>.</p>`);
return `<section class="card"><h2>Zmiany czasowe</h2>${lines.join("")}</section>`;
}
function resultBox() {
const r = state.result;
if (!r) return "";
if (r.status === "APPLIED") {
const back = r.restoreAt ? ` Wrócimy do poprzedniej wartości o ${when(r.restoreAt)}.` : "";
return `<div class="banner ok" role="status">Zapisano.${back}</div>`;
}
const detail = r.message ? `<br><span class="detail">${r.message}</span>` : "";
return `<div class="banner bad" role="alert">${r.text ?? RESULT_TEXT[r.status]}${detail}</div>`;
}
function confirmButtons(yes, action) {
return `<div class="seg"><button data-act="cancel"${disabled()}>Anuluj</button>
<button class="primary" data-act="${action}"${disabled()}>${yes}</button></div>`;
}
function presets(data) {
const buttons = data.presets.map((p) =>
`<button class="big" data-preset="${p.id}"${disabled()}>${p.label}</button>`).join("");
return `<section class="card"><h2>Szybkie ustawienia</h2><div class="stack">${buttons}</div></section>`;
}
function presetConfirm(data) {
const preset = data.presets.find((p) => p.id === state.preset);
const effect = preset.description ? `<p class="lead">${preset.description}</p>` : "";
return `<section class="card"><h2>${preset.label}</h2>${effect}<p>Potwierdzasz?</p>
${confirmButtons("Tak, ustaw", "preset-ok")}</section>`;
}
function stepper(s) {
const shown = state.draft?.name === s.name ? state.draft.value : s.value;
const label = name(s.name);
return `<div class="setpoint${shown === s.value ? "" : " changed"}"><span class="name">${label}</span>
<div class="step">
<button data-step="-1" data-name="${s.name}" aria-label="Mniej: ${label}"${disabled()}${shown <= s.min ? " disabled" : ""}>−</button>
<output>${degrees(shown)}</output>
<button data-step="1" data-name="${s.name}" aria-label="Więcej: ${label}"${disabled()}${shown >= s.max ? " disabled" : ""}>+</button>
</div><span class="muted">od ${degrees(s.min)} do ${degrees(s.max)}</span></div>`;
}
function saveBox(data) {
const d = state.draft;
if (!d) return "";
const s = find(data, d.name);
const options = DURATIONS.map((o) =>
`<button data-hours="${o.hours ?? ""}" aria-pressed="${o.hours === state.hours}"${disabled()}>${o.label}</button>`).join("");
const action = state.confirming
? `<p class="lead">${saveSummary(s)}</p>${confirmButtons("Tak, zapisz", "save-ok")}`
: `<div class="seg"><button data-act="cancel"${disabled()}>Anuluj</button>
<button class="primary" data-act="save"${disabled()}>Zapisz</button></div>`;
return `<div class="savebox"><p class="lead">${name(d.name)}: z ${degrees(s.value)} na <b>${degrees(d.value)}</b></p>
<p>Na jak długo?</p><div class="seg wrap">${options}</div>${action}</div>`;
}
function saveSummary(s) {
const value = degrees(state.draft.value);
if (!state.hours) return `Ustawię ${value} do odwołania. Poprzednia wartość (${degrees(s.value)}) nie wróci sama.`;
const back = when(new Date(Date.now() + state.hours * 3600000).toISOString());
return `Ustawię ${value}. O ${back} wrócimy do ${degrees(s.value)}.`;
}
function body(data) {
if (state.preset) return resultBox() + presetConfirm(data);
return `${resultBox()}${restores(data)}${presets(data)}
<section class="card"><h2>Nastawy</h2>${data.setpoints.map(stepper).join("")}${saveBox(data)}</section>`;
}
const view = (data) => `<div id="settings">${body(data)}</div>`;
function change(data, el) {
const { step, name: id, hours, preset, act } = el.dataset;
state.result = null;
if (step) {
const s = find(data, id);
const from = state.draft?.name === id ? state.draft.value : s.value;
const value = Math.min(s.max, Math.max(s.min, from + Number(step) * s.step));
state.draft = value === s.value ? null : { name: id, value };
state.confirming = false;
} else if (hours !== undefined) {
state.hours = hours === "" ? null : Number(hours);
state.confirming = false;
} else if (preset) {
state.preset = preset;
} else if (act === "cancel") {
Object.assign(state, { draft: null, preset: null, confirming: false });
} else if (act === "save") {
state.confirming = true;
}
}
function bind(root, data, reload) {
const box = root.querySelector("#settings");
async function send(url, payload) {
state.busy = true;
box.innerHTML = body(data);
try {
state.result = await postJson(url, payload);
if (state.result.status === "APPLIED") state.draft = null;
} catch {
state.result = { status: "FAILED", text: OFFLINE };
}
Object.assign(state, { busy: false, preset: null, confirming: false });
window.scrollTo(0, 0);
reload();
}
box.addEventListener("click", (event) => {
const el = event.target.closest("button");
if (!el || state.busy) return;
const act = el.dataset.act;
if (act === "save-ok") {
const { name: setpoint, value } = state.draft;
send("api/setpoints", { setpoint, value, hours: state.hours ?? undefined });
} else if (act === "preset-ok") {
send(`api/presets/${state.preset}`);
} else {
change(data, el);
box.innerHTML = body(data);
box.querySelector(".savebox")?.scrollIntoView({ block: "nearest" });
}
});
}
export default { load: () => getJson("api/setpoints"), view, bind };
+32
View File
@@ -0,0 +1,32 @@
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 };
+102
View File
@@ -0,0 +1,102 @@
: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; --ok-bg: #d9f2e4;
}
@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; --ok-bg: #153d2a;
}
}
* { 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.7rem; 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; }
.banner.ok { background: var(--ok-bg); color: var(--ok); border: 2px solid var(--ok); }
.detail { font-weight: 400; font-size: 0.85rem; }
.lead { font-size: 1.1rem; font-weight: 600; margin: 8px 0; }
.stack { display: grid; gap: 10px; }
button.big, .seg button.primary {
min-height: 64px; border: 2px solid var(--accent); border-radius: 12px; font: inherit;
font-size: 1.1rem; font-weight: 700; background: var(--card); color: var(--accent);
}
.seg button.primary { background: var(--accent); color: var(--on-accent); }
button:disabled { opacity: 0.45; }
.seg.wrap { flex-wrap: wrap; }
.seg.wrap button { flex: 1 1 40%; }
.setpoint { padding: 10px 0; border-top: 1px solid var(--line); }
.setpoint:first-of-type { border-top: 0; }
.setpoint .name { display: block; font-weight: 600; }
.setpoint.changed { border-left: 5px solid var(--accent); padding-left: 10px; }
.step { display: flex; align-items: center; gap: 10px; margin: 6px 0 2px; }
.step button {
width: 64px; height: 64px; border: 2px solid var(--accent); border-radius: 12px; background: var(--card);
color: var(--accent); font: inherit; font-size: 2rem; font-weight: 700; line-height: 1;
}
.step output { flex: 1; text-align: center; font-size: 1.8rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.savebox { border-top: 2px solid var(--accent); margin-top: 8px; padding-top: 8px; }
.savebox { scroll-margin-bottom: 84px; }
@@ -0,0 +1,193 @@
package home.backend;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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 java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** The assembled backend against a stub gateway and a stub InfluxDB, both on localhost. */
class BackendTest {
private static final ObjectMapper JSON = new ObjectMapper();
private final HttpClient client = HttpClient.newHttpClient();
private HttpServer gateway;
private HttpServer influx;
private ApiServer backend;
@BeforeEach
void start() throws IOException {
gateway = stub(Map.of(
"/health", "src/test/resources/gateway/health-all-up.json",
"/snapshot", "src/test/resources/gateway/snapshot-all-up.json"));
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());
backend.start();
}
@AfterEach
void stop() {
backend.stop();
gateway.stop(0);
influx.stop(0);
}
private static HttpServer stub(Map<String, String> bodies) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
bodies.forEach((path, file) -> server.createContext(path, exchange -> {
byte[] body = Files.readAllBytes(Path.of(file));
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
}));
server.start();
return server;
}
private static URI url(HttpServer server) {
return URI.create("http://127.0.0.1:" + server.getAddress().getPort());
}
private HttpResponse<String> get(String path) throws Exception {
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path)).build();
return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
}
/** The names of every field, nested ones as a.b; array elements are not descended into. */
private static TreeSet<String> fieldNames(JsonNode node, String prefix, TreeSet<String> names) {
node.fieldNames().forEachRemaining(name -> {
names.add(prefix + name);
fieldNames(node.get(name), prefix + name + ".", names);
});
return names;
}
private void assertServesApiShapeOf(String path, String example) throws Exception {
HttpResponse<String> response = get(path);
assertEquals(200, response.statusCode(), path);
assertEquals("application/json", response.headers().firstValue("Content-Type").orElseThrow());
JsonNode expected = JSON.readTree(Files.readString(Path.of("src/test/resources/api/" + example + ".json")));
assertEquals(fieldNames(expected, "", new TreeSet<>()),
fieldNames(JSON.readTree(response.body()), "", new TreeSet<>()), path);
}
/** The body equals the documented example, value by value. */
private void assertServesExactly(String path, String example) throws Exception {
HttpResponse<String> response = get(path);
assertEquals(200, response.statusCode(), path);
assertEquals(JSON.readTree(Files.readString(Path.of("src/test/resources/api/" + example + ".json"))),
JSON.readTree(response.body()), path);
}
@Test
void servesHeatPumpFromTheGatewayReadings() throws Exception {
assertServesExactly("/api/heat-pump", "heat-pump");
}
@Test
void servesEnergyFromTheGatewayReadings() throws Exception {
assertServesExactly("/api/energy", "energy");
}
@Test
void servesHealthOfEveryGatewayDevice() throws Exception {
assertServesApiShapeOf("/api/health", "health");
JsonNode health = JSON.readTree(get("/api/health").body());
assertTrue(health.get("gatewayReachable").asBoolean());
List<String> names = new ArrayList<>();
health.get("devices").forEach(device -> {
names.add(device.get("name").asText());
assertTrue(device.get("up").asBoolean(), names.getLast());
});
assertEquals(List.of("bme280", "deye", "fronius", "heat-pump", "house-meter"), names);
}
@Test
void servesHistoryFromInflux() throws Exception {
assertServesApiShapeOf("/api/history?series=cwu_temp&range=24h", "history");
JsonNode points = JSON.readTree(get("/api/history?series=cwu_temp&range=24h").body()).get("points");
assertFalse(points.isEmpty());
}
@Test
void servesTheRealWebUi() throws Exception {
HttpResponse<String> index = get("/");
HttpResponse<String> script = get("/app.js");
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());
}
@Test
void settingsNeedBothUrls() {
var e = assertThrows(IllegalArgumentException.class,
() -> Settings.fromEnvironment(Map.of("INFLUX_URL", "https://influx.example")));
assertTrue(e.getMessage().contains("GATEWAY_URL"));
e = assertThrows(IllegalArgumentException.class,
() -> Settings.fromEnvironment(Map.of("GATEWAY_URL", "http://gw:8080")));
assertTrue(e.getMessage().contains("INFLUX_URL"));
}
@Test
void settingsHaveDefaults() {
var settings = Settings.fromEnvironment(
Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example"));
assertEquals(8080, settings.port());
assertEquals(Duration.ofSeconds(5), settings.gatewayTimeout());
assertEquals(Duration.ofSeconds(10), settings.influxTimeout());
}
@Test
void 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"))) {
assertThrows(IllegalArgumentException.class, () -> Settings.fromEnvironment(bad));
}
for (String name : List.of("PORT", "GATEWAY_TIMEOUT", "INFLUX_TIMEOUT")) {
for (String value : List.of("abc", "-1", "0x10", "99999999")) {
var env = new HashMap<>(urls);
env.put(name, value);
var e = assertThrows(IllegalArgumentException.class, () -> Settings.fromEnvironment(env), name + value);
assertTrue(e.getMessage().contains(name));
}
}
}
}
@@ -0,0 +1,173 @@
package home.backend.adapters.gateway;
import com.sun.net.httpserver.HttpServer;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class GatewayHttpClientTest {
private HttpServer server;
private GatewayHttpClient client;
@BeforeEach
void startServer() throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.start();
client = new GatewayHttpClient(URI.create("http://127.0.0.1:" + server.getAddress().getPort()),
Duration.ofMillis(500));
}
@AfterEach
void stopServer() {
server.stop(0);
}
private void serve(String path, int status, String body) {
server.createContext(path, exchange -> {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
}
private void serveFiles(int healthStatus, String healthFile, String snapshotFile) throws IOException {
serve("/health", healthStatus, resource(healthFile));
serve("/snapshot", 200, resource(snapshotFile));
}
private static String resource(String name) throws IOException {
return Files.readString(Path.of("src/test/resources/gateway", name));
}
private static DeviceSnapshot device(List<DeviceSnapshot> devices, String name) {
return devices.stream().filter(d -> d.name().equals(name)).findFirst().orElseThrow();
}
@Test
void mapsEveryDeviceWithItsMeasurements() throws Exception {
serveFiles(200, "health-all-up.json", "snapshot-all-up.json");
var devices = client.snapshot().devices();
assertEquals(List.of("bme280", "deye", "fronius", "heat-pump", "house-meter"),
devices.stream().map(DeviceSnapshot::name).toList());
assertTrue(devices.stream().allMatch(DeviceSnapshot::up));
DeviceSnapshot pump = device(devices, "heat-pump");
assertEquals(Instant.parse("2026-10-23T22:26:00Z"), pump.lastSuccess());
assertNull(pump.lastError());
assertEquals(List.of("POMPA", "heat_pump"), pump.measurements().stream().map(Measurement::measurement).toList());
var measurement = pump.measurements().get(1);
assertEquals("Dom_Pompa", measurement.database());
assertEquals(4.3, measurement.fields().get("outdoor_temp"), 1e-9);
assertEquals(45.0, measurement.fields().get("setpoint_cwu_comfort"), 1e-9);
assertEquals(Instant.parse("2026-10-23T22:26:00Z"), measurement.time());
assertEquals(645.0, device(devices, "deye").measurements().get(0).fields().get("grid_power_w"));
}
@Test
void aDownDeviceAnswering503IsStillASnapshot() throws Exception {
serveFiles(503, "health-one-down.json", "snapshot-one-down.json");
var devices = client.snapshot().devices();
assertTrue(device(devices, "heat-pump").up());
DeviceSnapshot deye = device(devices, "deye");
assertFalse(deye.up());
assertEquals("Connect timed out", deye.lastError());
assertEquals(Instant.parse("2026-10-23T22:20:00Z"), deye.lastSuccess());
// The last good reading stays available, with its own time.
var last = deye.measurements().get(0);
assertEquals(570.0, last.fields().get("load_power_w"));
assertEquals(Instant.parse("2026-10-23T22:20:00Z"), last.time());
}
@Test
void aDeviceThatNeverSucceededHasNoLastSuccessAndNoMeasurements() throws Exception {
serveFiles(503, "health-never-succeeded.json", "snapshot-never-succeeded.json");
var devices = client.snapshot().devices();
var pump = device(devices, "heat-pump");
assertFalse(pump.up());
assertNull(pump.lastSuccess());
assertEquals("Connection refused", pump.lastError());
assertTrue(pump.measurements().isEmpty());
assertTrue(device(devices, "deye").up());
}
@Test
void ignoresPropertiesItDoesNotKnow() throws Exception {
serve("/health", 200, """
{"status":"UP","version":"2","devices":[{"name":"x","status":"UP","lastSuccess":null,"lastError":null,"extra":1}]}""");
serve("/snapshot", 200, """
[{"name":"x","new":true,"readings":[{"database":"d","measurement":"m","tags":{},
"fields":{"a":1.0},"time":"2026-10-24T10:00:00Z","unit":"C"}]}]""");
assertEquals(1, client.snapshot().devices().get(0).measurements().size());
}
@Test
void failsWhenSnapshotAnswers503() throws Exception {
serve("/health", 200, resource("health-all-up.json"));
serve("/snapshot", 503, "[]");
assertThrows(IOException.class, client::snapshot);
}
@Test
void failsOnServerError() {
serve("/health", 500, "{\"error\":\"boom\"}");
assertThrows(IOException.class, client::snapshot);
}
@Test
void failsOnBadJson() {
serve("/health", 200, "<html>not json</html>");
assertThrows(IOException.class, client::snapshot);
}
@Test
void failsWhenTheGatewayIsSlowerThanTheTimeout() {
server.createContext("/health", exchange -> {
try {
Thread.sleep(2000);
} catch (InterruptedException ignored) {
// the client gave up; nothing to answer
}
exchange.close();
});
assertThrows(HttpTimeoutException.class, client::snapshot);
}
@Test
void failsWhenTheGatewayIsUnreachable() {
server.stop(0);
assertThrows(IOException.class, client::snapshot);
}
}
@@ -0,0 +1,248 @@
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.application.GatewayClient;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryRepository;
import home.backend.application.HistoryService;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import home.backend.domain.HistoryView.Point;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ApiServerTest {
private static final ObjectMapper JSON = new ObjectMapper();
private static final Instant HEALTH_TIME = Instant.parse("2026-10-23T22:14:00Z");
private final HttpClient client = HttpClient.newHttpClient();
private GatewaySnapshot snapshot;
private boolean gatewayDown;
private boolean historyDown;
private ApiServer server;
@BeforeEach
void start() throws IOException {
snapshot = exampleSnapshot();
GatewayClient gateway = () -> {
if (gatewayDown) {
throw new IOException("tunnel down");
}
return snapshot;
};
HistoryRepository history = (series, from, to, max) -> {
if (historyDown) {
throw new IOException("influx down");
}
return List.of(new Point(Instant.parse("2026-10-23T10:00:00Z"), 14.1),
new Point(Instant.parse("2026-10-23T11:00:00Z"), 38.5),
new Point(Instant.parse("2026-10-23T12:00:00Z"), 44.2));
};
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));
server.start();
}
@AfterEach
void stop() {
server.stop();
}
private static GatewaySnapshot exampleSnapshot() {
Map<String, Double> pump = new HashMap<>(Map.of("outdoor_temp", 4.3, "indoor_temp", 21.2,
"co_buffer_temp", 16.9, "cwu_temp", 13.6, "ground_in_temp", 16.0, "ground_out_temp", 14.3,
"pump_outlet_temp", 19.7, "outdoor_avg_temp", 4.8, "target_room_temp", 18.0));
pump.putAll(Map.of("target_co_temp", 31.0, "target_cwu_temp", 8.0, "pump_mode", 0.0,
"output_compressor", 0.0, "output_heater", 0.0, "alarm", 0.0,
"setpoint_co_economy", 18.0, "setpoint_co_comfort", 19.0, "setpoint_cwu_economy", 8.0));
pump.putAll(Map.of("setpoint_cwu_comfort", 45.0, "output_co", 0.0, "output_buffer", 0.0,
"output_circulation", 0.0, "output_ground", 0.0, "output_cwu", 0.0));
Instant pumpTime = Instant.parse("2026-10-23T22:13:35Z");
Instant energyTime = Instant.parse("2026-10-23T22:26:00Z");
Map<String, Double> deye = Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
"grid_power_w", 645.0, "battery_power_w", -24.0, "battery_soc_pct", 99.0,
"load_power_w", 570.0, "grid_bought_today_kwh", 16.8, "grid_sold_today_kwh", 22.3,
"load_today_kwh", 24.2);
return new GatewaySnapshot(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",
List.of(new Measurement("home", "deye", deye, energyTime))),
new DeviceSnapshot("fronius", true, energyTime, null,
List.of(new Measurement("home", "fronius",
Map.of("pv_power_w", 0.0, "energy_day_wh", 4400.0), energyTime)))));
}
private HttpResponse<String> get(String path) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + path))
.build();
return client.send(request, HttpResponse.BodyHandlers.ofString());
}
private static JsonNode example(String name) throws IOException {
try (InputStream in = ApiServerTest.class.getResourceAsStream("/api/" + name + ".json")) {
return JSON.readTree(in);
}
}
private void assertMatchesExample(String path, String example) throws Exception {
HttpResponse<String> response = get(path);
assertEquals(200, response.statusCode());
assertEquals("application/json", response.headers().firstValue("Content-Type").orElseThrow());
assertEquals("no-store", response.headers().firstValue("Cache-Control").orElseThrow());
assertEquals(example(example), JSON.readTree(response.body()));
}
@Test
void heatPumpMatchesExample() throws Exception {
assertMatchesExample("/api/heat-pump", "heat-pump");
}
@Test
void energyMatchesExample() throws Exception {
assertMatchesExample("/api/energy", "energy");
}
@Test
void healthMatchesExample() throws Exception {
snapshot = new GatewaySnapshot(snapshot.devices().subList(0, 2));
assertMatchesExample("/api/health", "health");
}
@Test
void historyMatchesExample() throws Exception {
assertMatchesExample("/api/history?series=cwu_temp&range=24h", "history");
}
@Test
void historyRangeDefaultsTo24Hours() throws Exception {
assertEquals(200, get("/api/history?series=cwu_temp").statusCode());
}
@Test
void unreachableGatewayIs503WithJsonError() throws Exception {
gatewayDown = true;
for (String path : List.of("/api/heat-pump", "/api/energy")) {
HttpResponse<String> response = get(path);
assertEquals(503, response.statusCode());
assertEquals("gateway unreachable", JSON.readTree(response.body()).get("error").asText());
}
}
@Test
void healthStaysOkWhenGatewayIsUnreachable() throws Exception {
gatewayDown = true;
HttpResponse<String> response = get("/api/health");
assertEquals(200, response.statusCode());
assertEquals(false, JSON.readTree(response.body()).get("gatewayReachable").asBoolean());
}
@Test
void unknownSeriesOrRangeIs400() throws Exception {
assertEquals(400, get("/api/history?series=nope").statusCode());
assertEquals(400, get("/api/history").statusCode());
assertEquals(400, get("/api/history?series=cwu_temp&range=30d").statusCode());
}
@Test
void influxFailureIs502() throws Exception {
historyDown = true;
assertEquals(502, get("/api/history?series=cwu_temp&range=7d").statusCode());
}
@Test
void apiAcceptsOnlyGet() throws Exception {
HttpRequest post = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + "/api/health"))
.POST(HttpRequest.BodyPublishers.noBody()).build();
assertEquals(405, client.send(post, HttpResponse.BodyHandlers.ofString()).statusCode());
}
@Test
void 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")) {
assertEquals(404, get(path).statusCode(), path);
}
}
@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);
}
}
/** The JDK client normalises dot segments, so send the request line as is. */
private int rawGet(String path) throws IOException {
try (Socket socket = new Socket("localhost", server.port())) {
socket.getOutputStream().write(("GET " + path + " HTTP/1.1\r\nHost: localhost\r\n"
+ "Connection: close\r\n\r\n").getBytes(StandardCharsets.US_ASCII));
String statusLine = new String(socket.getInputStream().readAllBytes(), StandardCharsets.US_ASCII)
.lines().findFirst().orElseThrow();
return Integer.parseInt(statusLine.split(" ")[1]);
}
}
}
@@ -0,0 +1,208 @@
package home.backend.adapters.influx;
import com.sun.net.httpserver.HttpServer;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.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 InfluxHistoryRepositoryTest {
private static final Instant FROM = Instant.parse("2026-10-24T00:00:00Z");
private static final Instant TO = Instant.parse("2026-10-24T06:00:00Z");
private static final Duration TIMEOUT = Duration.ofSeconds(5);
private HttpServer server;
private final List<Map<String, String>> requests = new ArrayList<>();
private final Map<String, String> bodyByField = new HashMap<>();
private int status = 200;
private String defaultBody = "{\"results\":[{\"statement_id\":0}]}";
@BeforeEach
void startServer() throws IOException {
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/query", exchange -> {
Map<String, String> params = params(exchange.getRequestURI().getRawQuery());
requests.add(params);
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);
exchange.getResponseBody().write(bytes);
exchange.close();
});
server.start();
}
@AfterEach
void stopServer() {
server.stop(0);
}
@Test
void asksTheLegacyDatabaseForATemperatureWithQuotedFieldName() throws IOException {
defaultBody = fixture("influx-query-cwu-6h.json");
List<Point> points = repository(TIMEOUT).history(Series.CWU_TEMP, FROM, TO, 360);
assertEquals(1, requests.size());
assertEquals("Dom_Pompa", requests.get(0).get("db"));
assertEquals("ms", requests.get(0).get("epoch"));
assertEquals("SELECT mean(\"Temp cwu\") FROM \"POMPA\" WHERE time >= 1792800000000ms"
+ " AND time < 1792821600000ms GROUP BY time(1m) fill(none)", requests.get(0).get("q"));
assertEquals(7, points.size());
assertEquals(new Point(Instant.ofEpochMilli(1792771200000L), 43.76666666666667), points.get(0));
}
@Test
void asksTheElectricityDatabaseForGridPower() throws IOException {
repository(TIMEOUT).history(Series.GRID_POWER, FROM, TO, 72);
assertEquals("electricity", requests.get(0).get("db"));
assertEquals("SELECT mean(\"total_power_active\") FROM \"PRAD2\" WHERE time >= 1792800000000ms"
+ " AND time < 1792821600000ms GROUP BY time(5m) fill(none)", requests.get(0).get("q"));
}
@Test
void roundsTheStepUpToWholeMinutes() throws IOException {
repository(TIMEOUT).history(Series.LOAD_POWER, FROM, TO, 100);
assertTrue(requests.get(0).get("q").contains("GROUP BY time(4m)"), requests.get(0).get("q"));
}
@Test
void sumsPvPowerAcrossMeasurementsAndKeepsBucketsWithOnePart() throws IOException {
bodyByField.put("pv1_power_w", series(1000, 100.0, 2000, 200.0));
bodyByField.put("pv2_power_w", series(1000, 50.0));
bodyByField.put("pv_power_w", series(2000, 5.0, 3000, 7.0));
List<Point> points = repository(TIMEOUT).history(Series.PV_POWER, FROM, TO, 72);
assertEquals(3, requests.size());
assertEquals(List.of(
new Point(Instant.ofEpochMilli(1000), 150.0),
new Point(Instant.ofEpochMilli(2000), 205.0),
new Point(Instant.ofEpochMilli(3000), 7.0)), points);
}
@Test
void sortsPointsByTime() throws IOException {
defaultBody = series(3000, 3.0, 1000, 1.0, 2000, 2.0);
List<Point> points = repository(TIMEOUT).history(Series.BATTERY_SOC, FROM, TO, 72);
assertEquals(List.of(1000L, 2000L, 3000L), points.stream().map(p -> p.t().toEpochMilli()).toList());
}
@Test
void noDataGivesAnEmptyList() throws IOException {
defaultBody = fixture("influx-query-empty.json");
assertEquals(List.of(), repository(TIMEOUT).history(Series.PV_POWER, FROM, TO, 72));
}
@Test
void topLevelErrorIsAnIOException() throws IOException {
defaultBody = fixture("influx-query-error.json");
IOException e = assertThrows(IOException.class,
() -> repository(TIMEOUT).history(Series.CWU_TEMP, FROM, TO, 72));
assertTrue(e.getMessage().contains("error parsing query"), e.getMessage());
}
@Test
void perResultErrorIsAnIOException() {
defaultBody = "{\"results\":[{\"statement_id\":0,\"error\":\"database not found: electricity\"}]}";
IOException e = assertThrows(IOException.class,
() -> repository(TIMEOUT).history(Series.GRID_POWER, FROM, TO, 72));
assertTrue(e.getMessage().contains("database not found"), e.getMessage());
}
@Test
void httpErrorIsAnIOException() {
status = 503;
IOException e = assertThrows(IOException.class,
() -> repository(TIMEOUT).history(Series.CWU_TEMP, FROM, TO, 72));
assertTrue(e.getMessage().contains("503"), e.getMessage());
}
@Test
void badJsonIsAnIOException() {
defaultBody = "<html>not json";
assertThrows(IOException.class, () -> repository(TIMEOUT).history(Series.CWU_TEMP, FROM, TO, 72));
}
@Test
void slowServerTimesOut() {
server.removeContext("/query");
server.createContext("/query", exchange -> {
try {
Thread.sleep(1000);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
exchange.close();
});
assertThrows(IOException.class,
() -> repository(Duration.ofMillis(200)).history(Series.CWU_TEMP, FROM, TO, 72));
}
@Test
void rejectsAnEmptyRange() {
assertThrows(IllegalArgumentException.class,
() -> repository(TIMEOUT).history(Series.CWU_TEMP, FROM, FROM, 72));
}
private InfluxHistoryRepository repository(Duration timeout) {
URI base = URI.create("http://127.0.0.1:" + server.getAddress().getPort());
return new InfluxHistoryRepository(new InfluxSettings(base, timeout));
}
private static String series(Object... timeAndValue) {
StringBuilder rows = new StringBuilder();
for (int i = 0; i < timeAndValue.length; i += 2) {
rows.append(i == 0 ? "" : ",").append('[').append(timeAndValue[i]).append(',')
.append(timeAndValue[i + 1]).append(']');
}
return "{\"results\":[{\"statement_id\":0,\"series\":[{\"name\":\"m\",\"columns\":[\"time\",\"mean\"],"
+ "\"values\":[" + rows + "]}]}]}";
}
private static String fixture(String name) throws IOException {
return Files.readString(Path.of("src/test/resources/influx", name));
}
private static Map<String, String> params(String rawQuery) {
Map<String, String> params = new HashMap<>();
for (String pair : rawQuery.split("&")) {
String[] kv = pair.split("=", 2);
params.put(kv[0], URLDecoder.decode(kv[1], StandardCharsets.UTF_8));
}
return params;
}
}
@@ -0,0 +1,251 @@
package home.backend.application;
import home.backend.domain.EnergyView;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import home.backend.domain.HealthView;
import home.backend.domain.HeatPumpView;
import home.backend.domain.HeatPumpView.State;
import home.backend.domain.HistoryView;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ServicesTest {
private static final Instant NOW = Instant.parse("2026-10-24T12:00:00Z");
private static final Instant READ = Instant.parse("2026-10-24T11:59:50Z");
private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
private static DeviceSnapshot device(String name, String database, String measurement,
Map<String, Double> fields) {
return new DeviceSnapshot(name, true, READ, null,
List.of(new Measurement(database, measurement, fields, READ)));
}
private static GatewayClient gateway(DeviceSnapshot... devices) {
return () -> new GatewaySnapshot(List.of(devices));
}
private static GatewayClient unreachable() {
return () -> {
throw new IOException("tunnel down");
};
}
private static HeatPumpView heatPump(Map<String, Double> fields) throws IOException {
return new HeatPumpService(gateway(device("heat-pump", "Dom_Pompa", "heat_pump", fields)), CLOCK)
.current();
}
@Test
void heatPumpStateComesFromPumpMode() throws IOException {
assertEquals(State.IDLE, heatPump(Map.of("pump_mode", 0.0)).state());
assertEquals(State.HEATING_CO, heatPump(Map.of("pump_mode", 1.0)).state());
assertEquals(State.HEATING_CWU, heatPump(Map.of("pump_mode", 2.0)).state());
assertEquals(State.HEATING_BOTH, heatPump(Map.of("pump_mode", 3.0)).state());
}
@Test
void heatPumpMapsOutputsAlarmAndTime() throws IOException {
HeatPumpView view = heatPump(Map.of("output_compressor", 66.0, "output_heater", 100.0,
"output_cwu", 100.0, "output_co", 0.0, "alarm", 4.0, "setpoint_cwu_economy", 8.0));
assertEquals(READ, view.time());
assertEquals(66, view.compressorPercent());
assertTrue(view.heaterOn());
assertTrue(view.alarm());
assertTrue(view.pumps().cwu());
assertFalse(view.pumps().co());
assertEquals(8, view.setpoints().cwuEconomy());
}
@Test
void heatPumpMissingFieldsAreNull() throws IOException {
HeatPumpView view = heatPump(Map.of());
assertNull(view.state());
assertNull(view.compressorPercent());
assertNull(view.temperatures().outdoor());
assertNull(view.targets().cwu());
assertNull(view.setpoints().coComfort());
assertFalse(view.alarm());
}
@Test
void heatPumpTemperaturesDropBinaryNoiseOfTheGatewayScaling() throws IOException {
HeatPumpView view = heatPump(Map.of("indoor_temp", 212 * 0.1, "target_co_temp", 310 * 0.1,
"outdoor_temp", -43 * 0.1));
assertEquals(21.2, view.temperatures().indoor());
assertEquals(31.0, view.targets().co());
assertEquals(-4.3, view.temperatures().outdoor());
}
@Test
void heatPumpIgnoresMeasurementFromAnotherDatabase() throws IOException {
HeatPumpService service = new HeatPumpService(gateway(
device("heat-pump", "other", "heat_pump", Map.of("pump_mode", 1.0))), CLOCK);
HeatPumpView view = service.current();
assertNull(view.state());
assertEquals(NOW, view.time());
}
@Test
void heatPumpPropagatesGatewayFailure() {
assertThrows(IOException.class, () -> new HeatPumpService(unreachable(), CLOCK).current());
}
private static EnergyView energy(DeviceSnapshot... devices) throws IOException {
return new EnergyService(gateway(devices), CLOCK).current();
}
private static final Map<String, Double> DEYE = Map.of("pv1_power_w", 1000.0, "pv2_power_w", 500.0,
"pv_today_kwh", 10.0, "grid_power_w", -200.0, "battery_soc_pct", 80.0, "load_power_w", 900.0);
private static final Map<String, Double> FRONIUS = Map.of("pv_power_w", 2000.0, "energy_day_wh", 4400.0);
@Test
void energySumsPvOfBothInverters() throws IOException {
EnergyView view = energy(device("deye", "home", "deye", DEYE),
device("fronius", "home", "fronius", FRONIUS));
assertEquals(3500.0, view.pvPowerW());
assertEquals(14.4, view.today().pvKwh());
assertEquals(-200.0, view.gridPowerW());
assertEquals(80.0, view.batterySocPct());
assertNull(view.batteryPowerW());
}
@Test
void energyWithoutFroniusUsesDeyeOnly() throws IOException {
EnergyView view = energy(device("deye", "home", "deye", DEYE));
assertEquals(1500.0, view.pvPowerW());
assertEquals(10.0, view.today().pvKwh());
assertEquals(READ, view.time());
}
@Test
void energyWithoutDeyeKeepsFroniusPvAndNullsTheRest() throws IOException {
EnergyView view = energy(device("fronius", "home", "fronius", FRONIUS));
assertEquals(2000.0, view.pvPowerW());
assertEquals(4.4, view.today().pvKwh());
assertNull(view.gridPowerW());
assertNull(view.today().loadKwh());
}
@Test
void energyWithoutSourcesIsNullAtClockTime() throws IOException {
EnergyView view = energy();
assertNull(view.pvPowerW());
assertNull(view.today().pvKwh());
assertEquals(NOW, view.time());
}
@Test
void energyPropagatesGatewayFailure() {
assertThrows(IOException.class, () -> new EnergyService(unreachable(), CLOCK).current());
}
@Test
void healthListsDevices() {
DeviceSnapshot down = new DeviceSnapshot("deye", false, null, "connect timed out", List.of());
HealthView view = new HealthService(gateway(device("heat-pump", "Dom_Pompa", "heat_pump", Map.of()),
down), CLOCK).current();
assertTrue(view.gatewayReachable());
assertEquals(NOW, view.time());
assertEquals(2, view.devices().size());
assertEquals("connect timed out", view.devices().get(1).lastError());
assertFalse(view.devices().get(1).up());
assertEquals(READ, view.devices().get(0).lastSuccess());
}
@Test
void healthWithUnreachableGatewayHasNoDevices() {
HealthView view = new HealthService(unreachable(), CLOCK).current();
assertFalse(view.gatewayReachable());
assertTrue(view.devices().isEmpty());
}
/** Remembers the request and answers with one point. */
private static final class RecordingHistory implements HistoryRepository {
Series series;
Instant from;
Instant to;
int maxPoints;
@Override
public List<Point> history(Series series, Instant from, Instant to, int maxPoints) {
this.series = series;
this.from = from;
this.to = to;
this.maxPoints = maxPoints;
return List.of(new Point(from, 1.5));
}
}
@Test
void historyDefaultsToLast24Hours() throws IOException {
RecordingHistory repository = new RecordingHistory();
HistoryView view = new HistoryService(repository, CLOCK).history("cwu_temp", null);
assertEquals("cwu_temp", view.series());
assertEquals("°C", view.unit());
assertEquals(1, view.points().size());
assertEquals(Series.CWU_TEMP, repository.series);
assertEquals(NOW, repository.to);
assertEquals(NOW.minus(Duration.ofHours(24)), repository.from);
assertEquals(300, repository.maxPoints);
}
@Test
void historyOfSevenDays() throws IOException {
RecordingHistory repository = new RecordingHistory();
new HistoryService(repository, CLOCK).history("pv_power", "7d");
assertEquals(NOW.minus(Duration.ofDays(7)), repository.from);
}
@Test
void historyRejectsUnknownSeriesAndRange() {
HistoryService service = new HistoryService(new RecordingHistory(), CLOCK);
assertThrows(IllegalArgumentException.class, () -> service.history("nope", "24h"));
assertThrows(IllegalArgumentException.class, () -> service.history(null, "24h"));
assertThrows(IllegalArgumentException.class, () -> service.history("cwu_temp", "30d"));
assertThrows(IllegalArgumentException.class, () -> service.history("cwu_temp", ""));
}
@Test
void historyPropagatesRepositoryFailure() {
HistoryService service = new HistoryService((series, from, to, max) -> {
throw new IOException("influx down");
}, CLOCK);
assertThrows(IOException.class, () -> service.history("cwu_temp", "24h"));
}
}
@@ -0,0 +1,20 @@
package home.backend.domain;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
class SeriesTest {
@Test
void findsSeriesByItsId() {
assertEquals(Optional.of(Series.CWU_TEMP), Series.fromId("cwu_temp"));
}
@Test
void unknownIdFindsNothing() {
assertEquals(Optional.empty(), Series.fromId("DROP MEASUREMENT"));
}
}
@@ -0,0 +1,9 @@
{
"time": "2026-10-23T22:26:00Z",
"pvPowerW": 0.0,
"gridPowerW": 645.0,
"batteryPowerW": -24.0,
"batterySocPct": 99.0,
"loadPowerW": 570.0,
"today": { "pvKwh": 34.3, "gridBoughtKwh": 16.8, "gridSoldKwh": 22.3, "loadKwh": 24.2 }
}
@@ -0,0 +1,8 @@
{
"time": "2026-10-23T22:14:00Z",
"gatewayReachable": true,
"devices": [
{ "name": "heat-pump", "up": true, "lastSuccess": "2026-10-23T22:13:35Z", "lastError": null },
{ "name": "deye", "up": false, "lastSuccess": null, "lastError": "connect timed out" }
]
}
@@ -0,0 +1,14 @@
{
"time": "2026-10-23T22:26:00Z",
"state": "IDLE",
"compressorPercent": 0,
"heaterOn": false,
"alarm": false,
"pumps": { "co": false, "buffer": false, "circulation": false, "ground": false, "cwu": false },
"temperatures": {
"outdoor": 4.3, "indoor": 21.2, "coBuffer": 16.9, "cwu": 13.6,
"groundIn": 16.0, "groundOut": 14.3, "pumpOutlet": 19.7, "outdoorAvg": 4.8
},
"targets": { "room": 18.0, "co": 31.0, "cwu": 8.0 },
"setpoints": { "coEconomy": 18, "coComfort": 19, "cwuEconomy": 8, "cwuComfort": 45 }
}
@@ -0,0 +1,9 @@
{
"series": "cwu_temp",
"unit": "°C",
"points": [
{ "t": "2026-10-23T10:00:00Z", "v": 14.1 },
{ "t": "2026-10-23T11:00:00Z", "v": 38.5 },
{ "t": "2026-10-23T12:00:00Z", "v": 44.2 }
]
}
@@ -0,0 +1 @@
{"status":"UP","devices":[{"name":"bme280","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"deye","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"fronius","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"heat-pump","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"house-meter","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null}]}
@@ -0,0 +1 @@
{"status":"DOWN","devices":[{"name":"bme280","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"deye","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"fronius","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"heat-pump","status":"DOWN","lastSuccess":null,"lastError":"Connection refused"},{"name":"house-meter","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null}]}
@@ -0,0 +1 @@
{"status":"DOWN","devices":[{"name":"bme280","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"deye","status":"DOWN","lastSuccess":"2026-10-23T22:20:00Z","lastError":"Connect timed out"},{"name":"fronius","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"heat-pump","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"house-meter","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null}]}
@@ -0,0 +1 @@
[{"name":"bme280","interval":"PT5M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"measurement","measurement":"MEASUREMENT","tags":{"location":"dom-szczepancowa"},"fields":{"pressure":985.8,"humidity":45.82,"temperature":20.17},"time":"2026-10-23T22:26:00Z"}]},{"name":"deye","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"deye","tags":{"location":"dom-szczepancowa"},"fields":{"grid_power_w":645.0,"battery_temperature_c":19.2,"battery_discharge_today_kwh":0.1,"load_today_kwh":24.2,"pv2_power_w":0.0,"pv1_power_w":0.0,"grid_voltage_l1_v":233.9,"grid_bought_today_kwh":16.8,"load_power_w":570.0,"grid_voltage_l2_v":233.4,"battery_soc_pct":99.0,"pv_today_kwh":29.9,"battery_power_w":-24.0,"grid_power_l1_w":390.0,"grid_voltage_l3_v":231.9,"battery_charge_today_kwh":0.5,"grid_power_l2_w":52.0,"load_power_l1_w":364.0,"load_power_l2_w":26.0,"grid_power_l3_w":203.0,"battery_voltage_v":56.49,"grid_sold_today_kwh":22.3,"load_power_l3_w":180.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"fronius","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"fronius","tags":{"location":"dom-szczepancowa"},"fields":{"energy_year_wh":1846276.375,"pv_power_w":0.0,"energy_day_wh":4400.0,"energy_total_wh":2.90713E7},"time":"2026-10-23T22:26:00Z"}]},{"name":"heat-pump","interval":"PT10S","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"Dom_Pompa","measurement":"POMPA","tags":{},"fields":{"Temp co":16.900000000000002,"Temp cwu":13.600000000000001,"Temp out":14.3,"Temp in":16.0,"Temp wew":21.200000000000003,"External temperature":4.3,"Temp zsr":4.800000000000001,"Temp pc out":19.700000000000003},"time":"2026-10-23T22:26:00Z"},{"database":"Dom_Pompa","measurement":"heat_pump","tags":{},"fields":{"pump_outlet_temp":19.700000000000003,"setpoint_cwu_economy":8.0,"pressure_switch":0.0,"setpoint_co_comfort":19.0,"output_co":0.0,"co_buffer_temp":16.900000000000002,"ground_out_temp":14.3,"setpoint_co_economy":18.0,"output_heater":0.0,"pump_mode":0.0,"target_co_temp":31.0,"cwu_temp":13.600000000000001,"alarm":0.0,"reg_4225":0.0,"reg_4223":0.0,"reg_4224":0.0,"reg_4222":0.0,"target_room_temp":18.0,"output_circulation":0.0,"output_compressor":0.0,"output_ground":0.0,"outdoor_avg_temp":4.800000000000001,"program":0.0,"output_cwu":0.0,"target_cwu_temp":8.0,"indoor_temp":21.200000000000003,"output_buffer":0.0,"setpoint_cwu_comfort":45.0,"output_cooling":0.0,"zb_state":0.0,"outdoor_temp":4.3,"ground_in_temp":16.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"house-meter","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"PRAD2","tags":{"location":"dom-szczepancowa"},"fields":{"p3_power_reactive":26.52,"p2_demand_current":0.98,"total_power_factor":1.0,"p23_voltage_thd":0.0,"import_energy_reactive":6433.02,"maximum_export_demand_power_active":0.0,"total_energy_apparent":138275.8,"p1_power_apparent":74.71,"p3_demand_current":0.86,"p2_current_thd":99.27,"total_current":262147.94,"p2_power_factor":0.87,"p1_power_factor":0.75,"p3_current":0.67,"p2_current":0.88,"total_energy_active":134191.39,"maximum_neutral_demand_current":20.28,"maximum_import_demand_power_active":0.0,"voltage_ln_thd":3.65,"p2_power_apparent":207.46,"p1_phase_angle":41.52,"export_demand_power_active":0.0,"maximum_p1_demand_current":21.31,"neutral_demand_current":0.77,"p31_voltage_thd":0.0,"p3_power_apparent":155.65,"neutral_current":0.67,"export_energy_active":66677.11,"total_phase_angle":-4.12,"import_demand_power_active":0.0,"maximum_import_demand_power_apparent":-10986.21,"current_thd":95.36,"maximum_p2_demand_current":20.79,"p23_voltage":405.05,"p3_power_active":153.38,"p2_power_active":179.47,"p3_voltage":232.2,"p2_voltage":235.51,"frequency":50.01,"maximum_p3_demand_current":16.35,"p12_voltage":408.07,"maximum_demand_power_apparent":10989.65,"p1_demand_current":0.33,"p3_current_thd":72.45,"total_power_apparent":437.82,"voltage_ln":234.47,"voltage_ll_thd":0.0,"voltage_ll":406.11,"p2_phase_angle":-30.11,"p3_power_factor":0.99,"p1_current":0.32,"export_energy_reactive":26926.61,"total_power_reactive":-28.04,"p12_voltage_thd":0.0,"import_energy_active":67514.27,"p31_voltage":405.21,"p1_current_thd":114.36,"total_energy_reactive":33359.63,"total_demand_power_apparent":507.11,"p1_power_reactive":49.52,"current_ln":0.85,"total_power_active":388.78,"p1_voltage":235.69,"p2_power_reactive":-104.07,"p3_phase_angle":9.81,"p3n_voltage_thd":3.64,"p2n_voltage_thd":3.82,"p1n_voltage_thd":3.48,Line truncated
@@ -0,0 +1 @@
[{"name":"bme280","interval":"PT5M","since":"2026-10-23T22:20:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"measurement","measurement":"MEASUREMENT","tags":{"location":"dom-szczepancowa"},"fields":{"pressure":985.8,"humidity":45.82,"temperature":20.17},"time":"2026-10-23T22:26:00Z"}]},{"name":"deye","interval":"PT1M","since":"2026-10-23T22:20:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"deye","tags":{"location":"dom-szczepancowa"},"fields":{"grid_power_w":645.0,"battery_temperature_c":19.2,"battery_discharge_today_kwh":0.1,"load_today_kwh":24.2,"pv2_power_w":0.0,"pv1_power_w":0.0,"grid_voltage_l1_v":233.9,"grid_bought_today_kwh":16.8,"load_power_w":570.0,"grid_voltage_l2_v":233.4,"battery_soc_pct":99.0,"pv_today_kwh":29.9,"battery_power_w":-24.0,"grid_power_l1_w":390.0,"grid_voltage_l3_v":231.9,"battery_charge_today_kwh":0.5,"grid_power_l2_w":52.0,"load_power_l1_w":364.0,"load_power_l2_w":26.0,"grid_power_l3_w":203.0,"battery_voltage_v":56.49,"grid_sold_today_kwh":22.3,"load_power_l3_w":180.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"fronius","interval":"PT1M","since":"2026-10-23T22:20:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"fronius","tags":{"location":"dom-szczepancowa"},"fields":{"energy_year_wh":1846276.375,"pv_power_w":0.0,"energy_day_wh":4400.0,"energy_total_wh":2.90713E7},"time":"2026-10-23T22:26:00Z"}]},{"name":"heat-pump","interval":"PT10S","since":"2026-10-23T22:20:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":null,"lastError":"Connection refused","readings":[]},{"name":"house-meter","interval":"PT1M","since":"2026-10-23T22:20:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"PRAD2","tags":{"location":"dom-szczepancowa"},"fields":{"p3_power_reactive":26.52,"p2_demand_current":0.98,"total_power_factor":1.0,"p23_voltage_thd":0.0,"import_energy_reactive":6433.02,"maximum_export_demand_power_active":0.0,"total_energy_apparent":138275.8,"p1_power_apparent":74.71,"p3_demand_current":0.86,"p2_current_thd":99.27,"total_current":262147.94,"p2_power_factor":0.87,"p1_power_factor":0.75,"p3_current":0.67,"p2_current":0.88,"total_energy_active":134191.39,"maximum_neutral_demand_current":20.28,"maximum_import_demand_power_active":0.0,"voltage_ln_thd":3.65,"p2_power_apparent":207.46,"p1_phase_angle":41.52,"export_demand_power_active":0.0,"maximum_p1_demand_current":21.31,"neutral_demand_current":0.77,"p31_voltage_thd":0.0,"p3_power_apparent":155.65,"neutral_current":0.67,"export_energy_active":66677.11,"total_phase_angle":-4.12,"import_demand_power_active":0.0,"maximum_import_demand_power_apparent":-10986.21,"current_thd":95.36,"maximum_p2_demand_current":20.79,"p23_voltage":405.05,"p3_power_active":153.38,"p2_power_active":179.47,"p3_voltage":232.2,"p2_voltage":235.51,"frequency":50.01,"maximum_p3_demand_current":16.35,"p12_voltage":408.07,"maximum_demand_power_apparent":10989.65,"p1_demand_current":0.33,"p3_current_thd":72.45,"total_power_apparent":437.82,"voltage_ln":234.47,"voltage_ll_thd":0.0,"voltage_ll":406.11,"p2_phase_angle":-30.11,"p3_power_factor":0.99,"p1_current":0.32,"export_energy_reactive":26926.61,"total_power_reactive":-28.04,"p12_voltage_thd":0.0,"import_energy_active":67514.27,"p31_voltage":405.21,"p1_current_thd":114.36,"total_energy_reactive":33359.63,"total_demand_power_apparent":507.11,"p1_power_reactive":49.52,"current_ln":0.85,"total_power_active":388.78,"p1_voltage":235.69,"p2_power_reactive":-104.07,"p3_phase_angle":9.81,"p3n_voltage_thd":3.64,"p2n_voltage_thd":3.82,"p1n_voltage_thd":3.48,"total_line_current":2.55,"p1_power_active":55.94,"total_import_demand_power_active":452.07},"time":"2026-10-23T22:26:00Z"}]}]
@@ -0,0 +1 @@
[{"name":"bme280","interval":"PT5M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"measurement","measurement":"MEASUREMENT","tags":{"location":"dom-szczepancowa"},"fields":{"pressure":985.8,"humidity":45.82,"temperature":20.17},"time":"2026-10-23T22:26:00Z"}]},{"name":"deye","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:20:00Z","lastError":"Connect timed out","readings":[{"database":"electricity","measurement":"deye","tags":{"location":"dom-szczepancowa"},"fields":{"grid_power_w":645.0,"battery_temperature_c":19.2,"battery_discharge_today_kwh":0.1,"load_today_kwh":24.2,"pv2_power_w":0.0,"pv1_power_w":0.0,"grid_voltage_l1_v":233.9,"grid_bought_today_kwh":16.8,"load_power_w":570.0,"grid_voltage_l2_v":233.4,"battery_soc_pct":99.0,"pv_today_kwh":29.9,"battery_power_w":-24.0,"grid_power_l1_w":390.0,"grid_voltage_l3_v":231.9,"battery_charge_today_kwh":0.5,"grid_power_l2_w":52.0,"load_power_l1_w":364.0,"load_power_l2_w":26.0,"grid_power_l3_w":203.0,"battery_voltage_v":56.49,"grid_sold_today_kwh":22.3,"load_power_l3_w":180.0},"time":"2026-10-23T22:20:00Z"}]},{"name":"fronius","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"fronius","tags":{"location":"dom-szczepancowa"},"fields":{"energy_year_wh":1846276.375,"pv_power_w":0.0,"energy_day_wh":4400.0,"energy_total_wh":2.90713E7},"time":"2026-10-23T22:26:00Z"}]},{"name":"heat-pump","interval":"PT10S","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"Dom_Pompa","measurement":"POMPA","tags":{},"fields":{"Temp co":16.900000000000002,"Temp cwu":13.600000000000001,"Temp out":14.3,"Temp in":16.0,"Temp wew":21.200000000000003,"External temperature":4.3,"Temp zsr":4.800000000000001,"Temp pc out":19.700000000000003},"time":"2026-10-23T22:26:00Z"},{"database":"Dom_Pompa","measurement":"heat_pump","tags":{},"fields":{"pump_outlet_temp":19.700000000000003,"setpoint_cwu_economy":8.0,"pressure_switch":0.0,"setpoint_co_comfort":19.0,"output_co":0.0,"co_buffer_temp":16.900000000000002,"ground_out_temp":14.3,"setpoint_co_economy":18.0,"output_heater":0.0,"pump_mode":0.0,"target_co_temp":31.0,"cwu_temp":13.600000000000001,"alarm":0.0,"reg_4225":0.0,"reg_4223":0.0,"reg_4224":0.0,"reg_4222":0.0,"target_room_temp":18.0,"output_circulation":0.0,"output_compressor":0.0,"output_ground":0.0,"outdoor_avg_temp":4.800000000000001,"program":0.0,"output_cwu":0.0,"target_cwu_temp":8.0,"indoor_temp":21.200000000000003,"output_buffer":0.0,"setpoint_cwu_comfort":45.0,"output_cooling":0.0,"zb_state":0.0,"outdoor_temp":4.3,"ground_in_temp":16.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"house-meter","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"PRAD2","tags":{"location":"dom-szczepancowa"},"fields":{"p3_power_reactive":26.52,"p2_demand_current":0.98,"total_power_factor":1.0,"p23_voltage_thd":0.0,"import_energy_reactive":6433.02,"maximum_export_demand_power_active":0.0,"total_energy_apparent":138275.8,"p1_power_apparent":74.71,"p3_demand_current":0.86,"p2_current_thd":99.27,"total_current":262147.94,"p2_power_factor":0.87,"p1_power_factor":0.75,"p3_current":0.67,"p2_current":0.88,"total_energy_active":134191.39,"maximum_neutral_demand_current":20.28,"maximum_import_demand_power_active":0.0,"voltage_ln_thd":3.65,"p2_power_apparent":207.46,"p1_phase_angle":41.52,"export_demand_power_active":0.0,"maximum_p1_demand_current":21.31,"neutral_demand_current":0.77,"p31_voltage_thd":0.0,"p3_power_apparent":155.65,"neutral_current":0.67,"export_energy_active":66677.11,"total_phase_angle":-4.12,"import_demand_power_active":0.0,"maximum_import_demand_power_apparent":-10986.21,"current_thd":95.36,"maximum_p2_demand_current":20.79,"p23_voltage":405.05,"p3_power_active":153.38,"p2_power_active":179.47,"p3_voltage":232.2,"p2_voltage":235.51,"frequency":50.01,"maximum_p3_demand_current":16.35,"p12_voltage":408.07,"maximum_demand_power_apparent":10989.65,"p1_demand_current":0.33,"p3_current_thd":72.45,"total_power_apparent":437.82,"voltage_ln":234.47,"voltage_ll_thd":0.0,"voltage_ll":406.11,"p2_phase_angle":-30.11,"p3_power_factor":0.99,"p1_current":0.32,"export_energy_reactive":26926.61,"total_power_reactive":-28.04,"p12_voltage_thd":0.0,"import_energy_active":67514.27,"p31_voltage":405.21,"p1_current_thd":114.36,"total_energy_reactive":33359.63,"total_demand_power_apparent":507.11,"p1_power_reactive":49.52,"current_ln":0.85,"total_power_active":388.78,"p1_voltage":235.69,"p2_power_reactive":-104.07,"p3_phase_angle":9.81,"p3n_voltage_thd":3.64,"p2n_voltage_thd":3.82,"p1n_volLine truncated
@@ -0,0 +1 @@
{"results":[{"statement_id":0,"series":[{"name":"POMPA","columns":["time","mean"],"values":[[1792771200000,43.76666666666667],[1792774800000,42.40000000000001],[1792778400000,26.733333333333334],[1792782000000,19.15833333333333],[1792785600000,13.799999999999997],[1792789200000,13.58333333333333],[1792792800000,13.65]]}]}]}
@@ -0,0 +1 @@
{"results":[{"statement_id":0}]}
@@ -0,0 +1 @@
{"error":"error parsing query: found SELEC, expected SELECT, DELETE, SHOW, CREATE, DROP, EXPLAIN, GRANT, REVOKE, ALTER, SET, KILL at line 1, char 1"}
+1
View File
@@ -0,0 +1 @@
secret
@@ -0,0 +1 @@
x
+1
View File
@@ -0,0 +1 @@
body{}
+1
View File
@@ -0,0 +1 @@
ICO
+1
View File
@@ -0,0 +1 @@
a
+1
View File
@@ -0,0 +1 @@
PNG
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg"/>

After

Width:  |  Height:  |  Size: 42 B

@@ -0,0 +1,29 @@
# ADR-008: Backend and web UI stack
Status: proposed · 2026-10-24 (taken alone during the night of M1/M3 work; to confirm)
## Context
M3 needs the backend in Kraków (phone API, history from InfluxDB, live state from the gateway)
and a mobile-first web UI served by it (ADR-004). ADR-001 asks for the least code that works;
the gateway already shows the stack is enough (ADR-007).
## Decision
- **Backend**: a separate Maven project in `backend/`, Java 21, no framework: JDK
`HttpServer` with virtual threads, JDK `HttpClient` for the gateway and InfluxDB (InfluxQL
`/query`), Jackson. Layers as in the spec. One fat jar, run in Docker.
- **API**: JSON under `/api/`: `heat-pump`, `energy`, `history?series=&range=`, `health`. The
records in `home.backend.domain` and the examples in `backend/src/test/resources/api/` are
the contract between backend and UI.
- **Web UI**: 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.
- **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).
- `scripts/check.sh` builds and tests both projects.
+23 -22
View File
@@ -5,42 +5,41 @@ Closed milestones shrink to one line here; details stay in git history and in de
## Done
- (none yet) · baseline `e345063`: read-only, sends 8 sensor values to InfluxDB every 300 s;
no control of the controller. Runs on the Pi.
- M0 Foundation · `v1.0.0`: `scripts/check.sh` gate in CI and pre-push, deploy with health check
and rollback, `/ship` and `/close-milestone`. Baseline (`e345063`) still does the work: read-only,
8 heat pump values to InfluxDB every 300 s.
## Current: M0 Foundation
## Current: M1 Gateway: all readings
No behaviour change.
Done when: a week on the Pi without manual action, Grafana unchanged. Existing measurements keep
their names and fields; new data goes into new measurements (ADR-005). The heat pump stays at
300 s polls while the Frisko app is still in use (MK01 takes one client).
- [x] Docs structure: `CLAUDE.md`, `CONTRIBUTING.md`, `docs/`
- [x] `scripts/check.sh`: build, tests, doc size limits, secret scan
- [x] Git `pre-push` hook and CI both run `scripts/check.sh`; remove `.drone.yml` and `Dockerfile`
- [ ] Deploy: `scripts/deploy-gateway.sh` copies the jar to the Pi, restarts, checks `/health`,
rolls back on failure. Written and tested against stubs; open until its first run on the Pi
- [x] Skills: `/ship`, `/close-milestone`
## Feasibility checks (after M0, before M1)
- [x] C1 Gateway sharing: MK01 takes one client; our poll drops my parents' Frisko app.
- [x] C2 Frisko app is used rarely: keep 300 s polls until our UI replaces it, then the backend
is the only client, with a "release gateway" switch for the app.
- [x] C5 Parents raise the economy temperature (CWU 4143, CO 4131) to force heating; no tariff.
- [x] C6 Energy sources readable locally: Deye via Solarman logger, Fronius Solar API, house meter.
- [x] C3 Backup of the 14 app-editable settings: `research/frisko/settings-backup-2026-10-23.json`.
- [x] C4 Write works: FC6 4143 8 → 9 → 8 (my request), echo OK, read-back after 1 s, no password.
- [ ] 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
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
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
## Next
| Milestone | Scope | Done when |
|---|---|---|
| M1 Gateway: all readings | Fix D7 first. Baseline becomes the gateway: heat pump incl. state registers (4112–4119, 4201–4203, 4221–4228), Deye, Fronius, house meter and BME280 (moved from `rpi-measurements`, same schemas; then retire it) into Influx with a buffer; snapshot; config outside the jar; fix D1–D5 | A week on the Pi without manual action, Grafana unchanged |
| M2 Understand the controller | Timing (safe poll rate, response times), which pumps run in which state, open questions in `docs/frisko/` | `docs/frisko/` reviewed; F3 limits agreed |
| M3 Backend and web UI, read-only | Backend in Docker in Kraków: history from Influx, snapshot from the gateway; F1, F2, F5, F7 with the schematic and charts after `research/ui/`; CI builds its image; Traefik route and auth (decide the login method first) | My father sees the heat pump and the energy on his phone |
| M4 Setpoints | F3 through gateway commands with timed restore (ADR-006); CWU first, then CO; S1–S6 | Father boosts hot water from his phone, value restored on time |
| M5 Alarms | F4; decide the channel first (e.g. ntfy, Telegram) | Simulated alarm reaches the phone |
| M6 Native app? | Decide: keep the web UI, WebView wrapper or React Native | Decision recorded |
## Known defects in the baseline (input for M1)
## Known defects in the baseline
- D1 Self-restart works only while something polls `/health` (`HealthMonitor`).
- D2 Influx client logs every request (`LogLevel.FULL`), ~420 journal lines/hour.
@@ -48,6 +47,8 @@ No behaviour change.
- D4 Dead code and config: `pollIntervalMs`, `windowSeconds`, `ModbusCachedWrapper`, write path.
- D5 Single and batch reads use different addresses for registers ≥ 4100 (`ModbusRtuTcpRepository`).
- D6 Development runs wrote test fields into the production `Dom_Pompa` database.
- 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
+4
View File
@@ -92,6 +92,10 @@
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
<configuration>
<!-- Test logs go to target/surefire-reports; a failure still prints its summary. -->
<redirectTestOutputToFile>true</redirectTestOutputToFile>
</configuration>
</plugin>
-82
View File
@@ -1,82 +0,0 @@
{
"address": "192.168.0.150",
"port": 502,
"useMock": false,
"pollIntervalMs": 30000,
"windowSeconds": 300,
"influxSendIntervalSeconds": 300,
"timeoutMs": 5000,
"maxRetries": 2,
"retryBackoffMs": 1000,
"influx_db": {
"address": "https://influxdb.mariuszpacek.pl",
"username": "",
"password": ""
},
"registry_mapping": [
{
"registry": 4065,
"desc": "External temperature",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED",
"signedLegacyWrap": true
},
{
"registry": 4070,
"desc": "Temp in",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED",
"signedLegacyWrap": true
},
{
"registry": 4071,
"desc": "Temp out",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED",
"signedLegacyWrap": true
},
{
"registry": 4072,
"desc": "Temp pc out",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED",
"signedLegacyWrap": true
},
{
"registry": 4067,
"desc": "Temp co",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED",
"signedLegacyWrap": true
},
{
"registry": 4069,
"desc": "Temp cwu",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED",
"signedLegacyWrap": true
},
{
"registry": 4066,
"desc": "Temp wew",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED",
"signedLegacyWrap": true
},
{
"registry": 4199,
"desc": "Temp zsr",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED",
"signedLegacyWrap": true
}
]
}
+1
View File
@@ -55,4 +55,5 @@ if (( failed )); then
fi
mvn -B -q clean package
mvn -B -q -f backend/pom.xml clean package
echo "check: OK"
-112
View File
@@ -1,112 +0,0 @@
#!/bin/bash
# Health check and restart script for home-pump service
# Run this via cron every 2 minutes: */2 * * * * /home/pi/home-pump/scripts/healthcheck-monitor.sh
SERVICE_NAME="home-pump.service"
HEALTH_ENDPOINT="http://localhost:8080/health"
LOG_FILE="/var/log/home-pump-monitor.log"
MAX_RESTART_ATTEMPTS=3
RESTART_WINDOW_SECONDS=600
RESTART_COUNT_FILE="/tmp/home-pump-restart-count"
RESTART_TIMESTAMP_FILE="/tmp/home-pump-restart-timestamp"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
# Function to check if service is running
is_service_running() {
systemctl is-active --quiet "$SERVICE_NAME"
}
# Function to get restart count in the current window
get_restart_count() {
if [ -f "$RESTART_COUNT_FILE" ]; then
cat "$RESTART_COUNT_FILE"
else
echo "0"
fi
}
# Function to get last restart timestamp
get_last_restart_time() {
if [ -f "$RESTART_TIMESTAMP_FILE" ]; then
cat "$RESTART_TIMESTAMP_FILE"
else
echo "0"
fi
}
# Function to reset restart counter if window expired
reset_counter_if_expired() {
local last_restart=$(get_last_restart_time)
local now=$(date +%s)
local diff=$((now - last_restart))
if [ "$diff" -gt "$RESTART_WINDOW_SECONDS" ]; then
echo "0" > "$RESTART_COUNT_FILE"
fi
}
# Function to increment restart counter
increment_restart_count() {
local count=$(get_restart_count)
count=$((count + 1))
echo "$count" > "$RESTART_COUNT_FILE"
date +%s > "$RESTART_TIMESTAMP_FILE"
}
# Check if service is running
if ! is_service_running; then
log "ERROR: Service $SERVICE_NAME is not running. Attempting to start..."
sudo systemctl start "$SERVICE_NAME"
sleep 5
if is_service_running; then
log "SUCCESS: Service restarted successfully"
else
log "CRITICAL: Failed to start service $SERVICE_NAME"
exit 1
fi
fi
# Check health endpoint
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_ENDPOINT" --max-time 10)
if [ "$HTTP_CODE" != "200" ]; then
log "WARNING: Health check failed with HTTP code: $HTTP_CODE"
# Reset counter if restart window expired
reset_counter_if_expired
# Check if we can restart
restart_count=$(get_restart_count)
if [ "$restart_count" -ge "$MAX_RESTART_ATTEMPTS" ]; then
log "CRITICAL: Maximum restart attempts ($MAX_RESTART_ATTEMPTS) reached in the last $RESTART_WINDOW_SECONDS seconds. Manual intervention required."
# Send alert (could be email, Pushover, etc.)
# Example: echo "Home pump service needs manual intervention" | mail -s "Critical: Home Pump Alert" admin@example.com
exit 2
fi
log "Attempting to restart service (attempt $((restart_count + 1))/$MAX_RESTART_ATTEMPTS)..."
increment_restart_count
sudo systemctl restart "$SERVICE_NAME"
sleep 10
# Verify restart
if is_service_running; then
NEW_HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_ENDPOINT" --max-time 10)
if [ "$NEW_HTTP_CODE" == "200" ]; then
log "SUCCESS: Service restarted and health check passed"
else
log "WARNING: Service restarted but health check still failing (HTTP $NEW_HTTP_CODE)"
fi
else
log "ERROR: Failed to restart service"
fi
else
# Health check passed, reset counter
echo "0" > "$RESTART_COUNT_FILE"
fi
-14
View File
@@ -1,14 +0,0 @@
#!/bin/bash
# DEPRECATED: This script is no longer used
#
# Previously this script was used with cron to monitor and restart the service.
# Now systemd handles all restart logic automatically.
#
# The application monitors health and exits with status 1 when failures exceed threshold,
# causing systemd to automatically restart the service (Restart=on-failure).
#
# This approach is simpler and doesn't require external monitoring.
# The service unit (home-pump.service) handles all restart logic.
echo "This script is deprecated. Systemd automat handles service restarts."
exit 0
-75
View File
@@ -1,75 +0,0 @@
#!/bin/bash
# Installation script for home-pump service on Raspberry Pi/Ubuntu
set -e
echo "Home Pump Service Installation Script"
echo "======================================"
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "Please run as root (use sudo)"
exit 1
fi
# Configuration
PROJECT_DIR="/home/pi/home-pump"
SERVICE_FILE="scripts/home-pump.service"
JAR_FILE="target/home-pump-1.0.0.jar"
# Check if project directory exists
if [ ! -d "$PROJECT_DIR" ]; then
echo "Error: Project directory $PROJECT_DIR not found"
echo "Please adjust PROJECT_DIR in this script or create the directory"
exit 1
fi
cd "$PROJECT_DIR"
# Check if JAR file exists
if [ ! -f "$JAR_FILE" ]; then
echo "Error: JAR file not found at $JAR_FILE"
echo "Please build the project first using: mvn clean package"
exit 1
fi
# Install systemd service
echo "Installing systemd service..."
cp "$SERVICE_FILE" /etc/systemd/system/home-pump.service
systemctl daemon-reload
echo "✓ Systemd service installed"
# Enable service to start on boot
systemctl enable home-pump.service
echo "✓ Service enabled to start on boot"
# Start the service
systemctl start home-pump.service
echo "✓ Service started"
# Wait a bit and check status
sleep 3
if systemctl is-active --quiet home-pump.service; then
echo ""
echo "✓✓✓ Installation completed successfully! ✓✓✓"
echo ""
echo "Service status:"
systemctl status home-pump.service --no-pager -l
echo ""
echo "How it works:"
echo " - Application monitors health internally"
echo " - On consecutive failures (default 3), app exits with error code"
echo " - Systemd automatically restarts the service (Restart=on-failure)"
echo " - No external monitoring script needed"
echo ""
echo "Useful commands:"
echo " - View logs: journalctl -u home-pump.service -f"
echo " - Check status: systemctl status home-pump.service"
echo " - Restart service: sudo systemctl restart home-pump.service"
echo " - Stop service: sudo systemctl stop home-pump.service"
echo " - Health check: curl http://localhost:8080/health"
else
echo ""
echo "⚠ Warning: Service installed but not running properly"
echo "Check logs with: journalctl -u home-pump.service -n 50"
fi
-31
View File
@@ -1,31 +0,0 @@
const http = require('http');
const data = JSON.stringify({value: 60});
const options = {
hostname: 'localhost',
port: 7000,
path: '/api/registry/4202/write',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data)
}
};
const req = http.request(options, (res) => {
console.log('STATUS', res.statusCode);
res.setEncoding('utf8');
res.on('data', (chunk) => console.log('BODY', chunk));
res.on('end', () => {
// fetch registries
http.get('http://localhost:7000/api/registries', (res2) => {
console.log('REG STATUS', res2.statusCode);
let body = '';
res2.setEncoding('utf8');
res2.on('data', c => body += c);
res2.on('end', () => console.log('REG BODY', body));
});
});
});
req.on('error', (e) => console.error('REQ ERR', e));
req.write(data);
req.end();
-14
View File
@@ -1,14 +0,0 @@
#!/bin/bash
# Quick test script for local development
echo "Testing health endpoint..."
curl -v http://localhost:8080/health
echo ""
echo ""
echo "Expected response when healthy:"
echo '{"status":"UP","lastSuccessfulRead":"2026-02-06T19:30:45Z","message":"Last successful read 15 seconds ago"}'
echo ""
echo "Expected response when unhealthy (503):"
echo '{"status":"DOWN","lastSuccessfulRead":"2026-02-06T19:20:45Z","message":"No successful read for 620 seconds. Last error: Connection timeout"}'
@@ -0,0 +1,83 @@
package home.gateway.adapters.bme280;
import home.gateway.application.Device;
import home.gateway.domain.Reading;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
/**
* The BME280 through the kernel IIO driver. The driver exposes only processed values
* ({@code in_*_input}), never raw and scale: temperature in milli-degC, pressure in kPa and
* relative humidity in milli-%RH.
*/
public final class Bme280Device implements Device {
public static final Path DEFAULT_BASE_DIR = Path.of("/sys/bus/iio/devices");
private static final String DRIVER_NAME = "bme280";
private static final Map<String, String> TAGS = Map.of("location", "dom-szczepancowa");
private static final double HPA_PER_KPA = 10;
private static final double MILLI = 1000;
private final Path baseDir;
public Bme280Device(Path baseDir) {
this.baseDir = baseDir;
}
@Override
public String name() {
return "bme280";
}
@Override
public Duration interval() {
return Duration.ofSeconds(300);
}
@Override
public List<Reading> read(Instant now) throws IOException {
Path device = findDevice();
Map<String, Double> fields = Map.of(
"temperature", round(value(device, "in_temp_input") / MILLI),
"pressure", round(value(device, "in_pressure_input") * HPA_PER_KPA),
"humidity", round(value(device, "in_humidityrelative_input") / MILLI));
return List.of(new Reading("measurement", "MEASUREMENT", TAGS, fields, now));
}
private Path findDevice() throws IOException {
try (Stream<Path> entries = Files.list(baseDir)) {
for (Path entry : entries.sorted().toList()) {
if (isBme280(entry)) {
return entry;
}
}
}
throw new IOException("no IIO device named " + DRIVER_NAME + " under " + baseDir);
}
private static boolean isBme280(Path entry) throws IOException {
Path nameFile = entry.resolve("name");
return Files.isRegularFile(nameFile) && Files.readString(nameFile).strip().equals(DRIVER_NAME);
}
private static double value(Path device, String file) throws IOException {
String text = Files.readString(device.resolve(file)).strip();
try {
return Double.parseDouble(text);
} catch (NumberFormatException e) {
throw new IOException(file + " is not a number: '" + text + "'", e);
}
}
private static double round(double value) {
return Math.round(value * 100) / 100.0;
}
}
@@ -0,0 +1,107 @@
package home.gateway.adapters.deye;
import home.gateway.adapters.modbus.ModbusClient;
import home.gateway.application.Device;
import home.gateway.domain.Reading;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.DoubleUnaryOperator;
/** The Deye hybrid inverter; registers and scales are verified in research/energy/README.md. */
public final class DeyeDevice implements Device {
private static final int UNIT_ID = 1;
private record Block(int address, int count) {
}
private record Field(String name, int register, DoubleUnaryOperator decode) {
}
/** One request per block, in this order; the serial (3–7) is identity only and is skipped. */
private static final List<Block> BLOCKS = List.of(
new Block(514, 2), new Block(520, 2), new Block(526, 1), new Block(529, 1),
new Block(586, 3), new Block(590, 1), new Block(598, 3), new Block(622, 4),
new Block(650, 4), new Block(672, 2));
private static final List<Field> FIELDS = List.of(
new Field("battery_charge_today_kwh", 514, scaled(10)),
new Field("battery_discharge_today_kwh", 515, scaled(10)),
new Field("grid_bought_today_kwh", 520, scaled(10)),
new Field("grid_sold_today_kwh", 521, scaled(10)),
new Field("load_today_kwh", 526, scaled(10)),
new Field("pv_today_kwh", 529, scaled(10)),
new Field("battery_temperature_c", 586, x -> (x - 1000) / 10),
new Field("battery_voltage_v", 587, scaled(100)),
new Field("battery_soc_pct", 588, scaled(1)),
new Field("battery_power_w", 590, DeyeDevice::signed),
new Field("grid_voltage_l1_v", 598, scaled(10)),
new Field("grid_voltage_l2_v", 599, scaled(10)),
new Field("grid_voltage_l3_v", 600, scaled(10)),
new Field("grid_power_l1_w", 622, DeyeDevice::signed),
new Field("grid_power_l2_w", 623, DeyeDevice::signed),
new Field("grid_power_l3_w", 624, DeyeDevice::signed),
new Field("grid_power_w", 625, DeyeDevice::signed),
new Field("load_power_l1_w", 650, scaled(1)),
new Field("load_power_l2_w", 651, scaled(1)),
new Field("load_power_l3_w", 652, scaled(1)),
new Field("load_power_w", 653, scaled(1)),
new Field("pv1_power_w", 672, scaled(1)),
new Field("pv2_power_w", 673, scaled(1)));
private final ModbusClient modbus;
public DeyeDevice(ModbusClient modbus) {
this.modbus = modbus;
}
@Override
public String name() {
return "deye";
}
@Override
public Duration interval() {
return Duration.ofSeconds(60);
}
@Override
public List<Reading> read(Instant now) throws IOException {
Map<Integer, Integer> words = readBlocks();
Map<String, Double> fields = new LinkedHashMap<>();
for (Field field : FIELDS) {
fields.put(field.name(), field.decode().applyAsDouble(words.get(field.register())));
}
return List.of(new Reading("electricity", "deye", Map.of("location", "dom-szczepancowa"),
fields, now));
}
private Map<Integer, Integer> readBlocks() throws IOException {
Map<Integer, Integer> words = new HashMap<>();
for (Block block : BLOCKS) {
int[] values = modbus.readHoldingRegisters(UNIT_ID, block.address(), block.count());
if (values.length != block.count()) {
throw new IOException("Deye block " + block.address() + " returned "
+ values.length + " registers, expected " + block.count());
}
for (int i = 0; i < values.length; i++) {
words.put(block.address() + i, values[i]);
}
}
return words;
}
private static DoubleUnaryOperator scaled(int divisor) {
return x -> x / divisor;
}
private static double signed(double word) {
return (short) (int) word;
}
}
@@ -0,0 +1,94 @@
package home.gateway.adapters.fronius;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.gateway.application.Device;
import home.gateway.domain.Reading;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Fronius Symo over the local Solar API V1. One GET of the power flow gives PV power and the
* day, year and total energy, so the inverter endpoint is not needed.
*/
public final class FroniusDevice implements Device {
private static final String POWER_FLOW_PATH = "/solar_api/v1/GetPowerFlowRealtimeData.fcgi";
/** Every value can be null while the inverter sleeps, so each one is optional. */
private static final Map<String, String> VALUE_FIELDS = Map.of(
"P_PV", "pv_power_w", "E_Day", "energy_day_wh",
"E_Year", "energy_year_wh", "E_Total", "energy_total_wh");
private final HttpClient http = HttpClient.newHttpClient();
private final ObjectMapper json = new ObjectMapper();
private final URI powerFlow;
private final Duration timeout;
public FroniusDevice(URI baseUri, Duration timeout) {
this.powerFlow = baseUri.resolve(POWER_FLOW_PATH);
this.timeout = timeout;
}
@Override
public String name() {
return "fronius";
}
@Override
public Duration interval() {
return Duration.ofSeconds(60);
}
@Override
public List<Reading> read(Instant now) throws IOException {
JsonNode root = fetch();
requireStatusOk(root);
JsonNode site = root.path("Body").path("Data").path("Site");
Map<String, Double> fields = new HashMap<>();
VALUE_FIELDS.forEach((source, target) -> {
JsonNode value = site.path(source);
if (value.isNumber()) {
fields.put(target, value.doubleValue());
}
});
if (fields.isEmpty()) {
throw new IOException("Fronius reply has no power or energy value");
}
// The inverter reports no PV power while it sleeps; it produces nothing then.
fields.putIfAbsent("pv_power_w", 0.0);
return List.of(new Reading("electricity", "fronius",
Map.of("location", "dom-szczepancowa"), fields, now));
}
private JsonNode fetch() throws IOException {
HttpRequest request = HttpRequest.newBuilder(powerFlow).timeout(timeout).GET().build();
try {
HttpResponse<byte[]> response = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200) {
throw new IOException("Fronius answered HTTP " + response.statusCode());
}
return json.readTree(response.body());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted while reading Fronius", e);
}
}
private static void requireStatusOk(JsonNode root) throws IOException {
JsonNode status = root.path("Head").path("Status");
if (status.path("Code").asInt(-1) != 0) {
throw new IOException("Fronius status " + status.path("Code") + ": "
+ status.path("Reason").asText());
}
}
}
@@ -0,0 +1,73 @@
package home.gateway.adapters.heatpump;
import home.gateway.adapters.modbus.ModbusClient;
import home.gateway.application.Device;
import home.gateway.domain.Reading;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** The Frisko heat pump controller: every poll yields the legacy POMPA point and the full heat_pump point. */
public final class HeatPumpDevice implements Device {
private static final String DATABASE = "Dom_Pompa";
private final ModbusClient modbus;
private final HeatPumpSettings settings;
public HeatPumpDevice(ModbusClient modbus, HeatPumpSettings settings) {
this.modbus = modbus;
this.settings = settings;
}
@Override
public String name() {
return "heat-pump";
}
@Override
public Duration interval() {
return settings.interval();
}
@Override
public List<Reading> read(Instant now) throws IOException {
Map<Integer, Integer> words = readWords();
Map<String, Double> all = new HashMap<>();
Map<String, Double> legacy = new HashMap<>();
for (Register register : RegisterCatalog.ALL) {
Integer word = words.get(register.address());
if (word == null) {
continue;
}
double value = register.scale().decode(word);
all.put(register.field(), value);
String legacyName = RegisterCatalog.LEGACY_NAMES.get(register.field());
if (legacyName != null) {
legacy.put(legacyName, value);
}
}
return List.of(
new Reading(DATABASE, "POMPA", Map.of(), legacy, now),
new Reading(DATABASE, "heat_pump", Map.of(), all, now));
}
private Map<Integer, Integer> readWords() throws IOException {
Map<Integer, Integer> words = new HashMap<>();
for (RegisterBlock block : settings.blocks()) {
int[] values = modbus.readHoldingRegisters(settings.unitId(), block.start(), block.count());
if (values.length != block.count()) {
throw new IOException("block " + block.start() + " returned " + values.length
+ " registers, expected " + block.count());
}
for (int i = 0; i < values.length; i++) {
words.put(block.start() + i, values[i]);
}
}
return words;
}
}
@@ -0,0 +1,22 @@
package home.gateway.adapters.heatpump;
import java.time.Duration;
import java.util.List;
public record HeatPumpSettings(int unitId, Duration interval, List<RegisterBlock> blocks) {
/** Gap-free blocks covering the whole catalog; the controller is never asked for unknown registers. */
public static final List<RegisterBlock> PRODUCTION_BLOCKS = List.of(
new RegisterBlock(4065, 3),
new RegisterBlock(4069, 5),
new RegisterBlock(4112, 8),
new RegisterBlock(4131, 2),
new RegisterBlock(4143, 2),
new RegisterBlock(4199, 1),
new RegisterBlock(4201, 3),
new RegisterBlock(4221, 8));
public HeatPumpSettings {
blocks = List.copyOf(blocks);
}
}
@@ -0,0 +1,4 @@
package home.gateway.adapters.heatpump;
record Register(int address, String field, Scale scale) {
}
@@ -0,0 +1,9 @@
package home.gateway.adapters.heatpump;
/** Contiguous registers read with one request. */
public record RegisterBlock(int start, int count) {
boolean contains(int address) {
return address >= start && address < start + count;
}
}
@@ -0,0 +1,59 @@
package home.gateway.adapters.heatpump;
import java.util.List;
import java.util.Map;
import static home.gateway.adapters.heatpump.Scale.TENTHS;
import static home.gateway.adapters.heatpump.Scale.UNITS;
/** Registers of the Frisko RX910-SE21 that we read (docs/frisko/rx910-se21.md). Addresses are raw. */
final class RegisterCatalog {
static final List<Register> ALL = List.of(
new Register(4065, "outdoor_temp", TENTHS),
new Register(4066, "indoor_temp", TENTHS),
new Register(4067, "co_buffer_temp", TENTHS),
new Register(4069, "cwu_temp", TENTHS),
new Register(4070, "ground_in_temp", TENTHS),
new Register(4071, "ground_out_temp", TENTHS),
new Register(4072, "pump_outlet_temp", TENTHS),
new Register(4073, "pressure_switch", UNITS),
new Register(4112, "output_co", UNITS),
new Register(4113, "output_buffer", UNITS),
new Register(4114, "output_compressor", UNITS),
new Register(4115, "output_heater", UNITS),
new Register(4116, "output_circulation", UNITS),
new Register(4117, "output_ground", UNITS),
new Register(4118, "output_cwu", UNITS),
new Register(4119, "output_cooling", UNITS),
new Register(4131, "setpoint_co_economy", UNITS),
new Register(4132, "setpoint_co_comfort", UNITS),
new Register(4143, "setpoint_cwu_economy", UNITS),
new Register(4144, "setpoint_cwu_comfort", UNITS),
new Register(4199, "outdoor_avg_temp", TENTHS),
new Register(4201, "target_room_temp", TENTHS),
new Register(4202, "target_co_temp", TENTHS),
new Register(4203, "target_cwu_temp", TENTHS),
new Register(4221, "alarm", UNITS),
new Register(4222, "reg_4222", UNITS),
new Register(4223, "reg_4223", UNITS),
new Register(4224, "reg_4224", UNITS),
new Register(4225, "reg_4225", UNITS),
new Register(4226, "program", UNITS),
new Register(4227, "pump_mode", UNITS),
new Register(4228, "zb_state", UNITS));
/** Field names of the POMPA measurement that Grafana already uses. */
static final Map<String, String> LEGACY_NAMES = Map.of(
"outdoor_temp", "External temperature",
"indoor_temp", "Temp wew",
"co_buffer_temp", "Temp co",
"cwu_temp", "Temp cwu",
"ground_in_temp", "Temp in",
"ground_out_temp", "Temp out",
"pump_outlet_temp", "Temp pc out",
"outdoor_avg_temp", "Temp zsr");
private RegisterCatalog() {
}
}
@@ -0,0 +1,19 @@
package home.gateway.adapters.heatpump;
/** How a decoded register magnitude maps to its value. Multiplying by 0.1 keeps the baseline's numbers bit for bit. */
enum Scale {
TENTHS(0.1),
UNITS(1.0);
private final double factor;
Scale(double factor) {
this.factor = factor;
}
/** Registers are sign-magnitude: bit 15 is the sign, the low 15 bits the magnitude. */
double decode(int word) {
double value = (word & 0x7FFF) * factor;
return (word & 0x8000) != 0 ? -value : value;
}
}
@@ -0,0 +1,110 @@
package home.gateway.adapters.http;
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.HttpServer;
import home.gateway.application.DeviceState;
import home.gateway.application.Snapshot;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Executors;
/**
* Read-only JSON API: {@code /health} for monitoring, {@code /snapshot} for the app. Only critical
* devices decide the {@code /health} status code, because the deploy script rolls back on a non-200
* and a dead optional device is no reason for that. The body status tells the three cases apart.
*/
public class GatewayHttpServer implements AutoCloseable {
private record DeviceHealth(String name, String status, Instant lastSuccess, String lastError) {
}
private record Health(String status, List<DeviceHealth> devices) {
}
private record Error(String error) {
}
private static final ObjectMapper JSON = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.disable(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS);
private final Snapshot snapshot;
private final Clock clock;
private final Set<String> criticalDevices;
private final HttpServer server;
public GatewayHttpServer(Snapshot snapshot, Clock clock, int port,
Set<String> criticalDevices) throws IOException {
this.snapshot = snapshot;
this.clock = clock;
this.criticalDevices = criticalDevices;
this.server = HttpServer.create(new InetSocketAddress(port), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.createContext("/", this::route);
}
public void start() {
server.start();
}
public int port() {
return server.getAddress().getPort();
}
@Override
public void close() {
server.stop(0);
}
private void route(HttpExchange exchange) throws IOException {
try (exchange) {
var path = exchange.getRequestURI().getPath();
if (!path.equals("/health") && !path.equals("/snapshot")) {
respond(exchange, 404, new Error("not found"));
} else if (!exchange.getRequestMethod().equals("GET")) {
exchange.getResponseHeaders().set("Allow", "GET");
respond(exchange, 405, new Error("method not allowed"));
} else if (path.equals("/health")) {
health(exchange);
} else {
respond(exchange, 200, snapshot.states());
}
}
}
private void health(HttpExchange exchange) throws IOException {
var now = clock.instant();
var devices = snapshot.states().stream().map(state -> deviceHealth(state, now)).toList();
boolean criticalUp = devices.stream()
.filter(device -> criticalDevices.contains(device.name()))
.allMatch(GatewayHttpServer::isUp);
boolean allUp = devices.stream().allMatch(GatewayHttpServer::isUp);
var status = !criticalUp ? "DOWN" : allUp ? "UP" : "DEGRADED";
respond(exchange, criticalUp ? 200 : 503, new Health(status, devices));
}
private static boolean isUp(DeviceHealth device) {
return device.status().equals("UP");
}
private static DeviceHealth deviceHealth(DeviceState state, Instant now) {
return new DeviceHealth(state.name(), state.isHealthy(now) ? "UP" : "DOWN",
state.lastSuccess(), state.lastError());
}
private void respond(HttpExchange exchange, int status, Object body) throws IOException {
byte[] bytes = JSON.writeValueAsBytes(body);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
}
}
@@ -0,0 +1,13 @@
package home.gateway.adapters.influx;
import java.net.URI;
import java.nio.file.Path;
import java.time.Duration;
/**
* @param spoolCapBytes per database; the oldest lines are dropped beyond it
* @param batchSize maximum lines per HTTP request
*/
public record InfluxSettings(URI baseUri, Duration requestTimeout, Path spoolDirectory,
long spoolCapBytes, int batchSize) {
}
@@ -0,0 +1,204 @@
package home.gateway.adapters.influx;
import home.gateway.application.ReadingSink;
import home.gateway.domain.Reading;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.URI;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Writes readings to InfluxDB 1.x through a spool on disk: {@link #write} only appends to
* the spool, {@link #flush} (called on a schedule) delivers it.
*/
public final class InfluxWriter implements ReadingSink {
private static final Logger log = LoggerFactory.getLogger(InfluxWriter.class);
private static final String SPOOL_SUFFIX = ".lp";
private static final int NO_ANSWER = 0;
private record Answer(int status, String body) {
}
private final InfluxSettings settings;
private final HttpClient http;
private final Map<String, Spool> spools = new ConcurrentHashMap<>();
private volatile boolean reachable = true;
private volatile int batchSize;
public InfluxWriter(InfluxSettings settings) {
this.settings = settings;
this.batchSize = settings.batchSize();
this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build();
try {
Files.createDirectories(settings.spoolDirectory());
spooledDatabases().forEach(this::spool);
} catch (IOException e) {
throw new UncheckedIOException("Cannot open the spool " + settings.spoolDirectory(), e);
}
}
@Override
public void write(List<Reading> readings) {
readings.stream().collect(Collectors.groupingBy(Reading::database))
.forEach(this::spoolDatabase);
}
/** Sends the pending lines of every database; what fails stays for the next call. */
public void flush() {
spools.forEach(this::flushDatabase);
}
private void spoolDatabase(String database, List<Reading> readings) {
List<String> lines = readings.stream().map(LineProtocol::encode)
.flatMap(Optional::stream).toList();
if (lines.isEmpty()) {
return;
}
try {
spool(database).append(lines);
} catch (IOException | RuntimeException e) {
log.error("Cannot spool {} lines for {}", lines.size(), database, e);
}
}
private void flushDatabase(String database, Spool spool) {
AtomicInteger dropped = new AtomicInteger();
try {
Spool.Batch batch;
int settled;
do {
batch = spool.peek(batchSize);
settled = deliver(database, batch.lines(), dropped);
spool.settle(batch, settled);
} while (settled == batch.lines().size() && !batch.lines().isEmpty());
spool.compact();
} catch (IOException | RuntimeException e) {
log.error("Cannot flush {}", database, e);
}
if (dropped.get() > 1) {
log.warn("Dropped {} more lines InfluxDB rejected for {}", dropped.get() - 1, database);
}
}
/**
* Returns how many leading lines are settled, delivered or dropped. A rejected batch is
* split until the bad line is alone, so one bad line cannot cost its neighbours or block
* the queue; resending accepted points is harmless, as the same series and time overwrite.
* 413 only says the request was too big: the batch size shrinks and nothing is dropped.
*/
private int deliver(String database, List<String> lines, AtomicInteger dropped) {
if (lines.isEmpty()) {
return 0;
}
Answer answer = post(database, lines);
if (answer.status() == 204) {
return lines.size();
}
if (!isRejection(answer.status())) {
return 0;
}
if (lines.size() == 1) {
return answer.status() == 413 ? 0 : drop(database, lines.get(0), answer, dropped);
}
int half = lines.size() / 2;
if (answer.status() == 413) {
batchSize = Math.min(batchSize, half);
}
int first = deliver(database, lines.subList(0, half), dropped);
return first < half ? first
: half + deliver(database, lines.subList(half, lines.size()), dropped);
}
/** Only the first drop of a flush is logged in full, so a systematic conflict cannot flood the journal. */
private static int drop(String database, String line, Answer answer, AtomicInteger dropped) {
if (dropped.incrementAndGet() == 1) {
log.warn("Dropped a line InfluxDB rejected for {} ({}: {}): {}", database,
answer.status(), answer.body().strip(), line);
}
return 1;
}
/**
* 400, 413 and 422 mean the request is bad. Other 4xx (401, 404, 429) are fixed outside, so
* their lines wait in the spool, bounded by its cap.
*/
private static boolean isRejection(int status) {
return status == 400 || status == 413 || status == 422;
}
private Answer post(String database, List<String> lines) {
URI uri = URI.create(settings.baseUri().toString().replaceAll("/+$", "")
+ "/write?db=" + URLEncoder.encode(database, StandardCharsets.UTF_8)
+ "&precision=ms");
HttpRequest request = HttpRequest.newBuilder(uri).timeout(settings.requestTimeout())
.POST(HttpRequest.BodyPublishers.ofString(
String.join("\n", lines) + "\n", StandardCharsets.UTF_8))
.build();
try {
HttpResponse<String> response =
http.send(request, HttpResponse.BodyHandlers.ofString());
markReachable(response.statusCode() < 500);
return new Answer(response.statusCode(), response.body());
} catch (IOException e) {
markReachable(false);
return new Answer(NO_ANSWER, "");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return new Answer(NO_ANSWER, "");
}
}
private void markReachable(boolean now) {
if (reachable != now) {
reachable = now;
if (now) {
log.info("InfluxDB is reachable again");
} else {
log.warn("InfluxDB is unreachable; readings are kept in the spool");
}
}
}
private Spool spool(String database) {
return spools.computeIfAbsent(database, name -> {
try {
return new Spool(spoolFile(name), settings.spoolCapBytes());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
private Path spoolFile(String database) {
return settings.spoolDirectory().resolve(
URLEncoder.encode(database, StandardCharsets.UTF_8) + SPOOL_SUFFIX);
}
private List<String> spooledDatabases() throws IOException {
try (Stream<Path> files = Files.list(settings.spoolDirectory())) {
return files.map(file -> file.getFileName().toString())
.filter(name -> name.endsWith(SPOOL_SUFFIX))
.map(name -> name.substring(0, name.length() - SPOOL_SUFFIX.length()))
.map(name -> URLDecoder.decode(name, StandardCharsets.UTF_8))
.toList();
}
}
}
@@ -0,0 +1,49 @@
package home.gateway.adapters.influx;
import home.gateway.domain.Reading;
import java.math.BigDecimal;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import java.util.stream.Collectors;
/** Encodes a {@link Reading} as one InfluxDB 1.x line protocol line. */
final class LineProtocol {
private LineProtocol() {
}
/** Empty when no field has a finite value, because InfluxDB rejects NaN and infinity. */
static Optional<String> encode(Reading reading) {
String fields = new TreeMap<>(reading.fields()).entrySet().stream()
.filter(field -> Double.isFinite(field.getValue()))
.map(field -> escapeKey(field.getKey()) + "=" + number(field.getValue()))
.collect(Collectors.joining(","));
if (fields.isEmpty()) {
return Optional.empty();
}
return Optional.of(escapeMeasurement(reading.measurement()) + tags(reading.tags())
+ " " + fields + " " + reading.time().toEpochMilli());
}
private static String tags(Map<String, String> tags) {
return new TreeMap<>(tags).entrySet().stream()
.map(tag -> "," + escapeKey(tag.getKey()) + "=" + escapeKey(tag.getValue()))
.collect(Collectors.joining());
}
/** Plain decimal with a dot, never an exponent and never an "i" suffix: always a float. */
private static String number(double value) {
String plain = BigDecimal.valueOf(value).toPlainString();
return plain.contains(".") ? plain : plain + ".0";
}
private static String escapeMeasurement(String text) {
return text.replace(",", "\\,").replace(" ", "\\ ");
}
private static String escapeKey(String text) {
return text.replace(",", "\\,").replace("=", "\\=").replace(" ", "\\ ");
}
}
@@ -0,0 +1,171 @@
package home.gateway.adapters.influx;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.List;
/**
* The pending lines of one database, one per row of an append-only file. Draining reads
* forward from the first undelivered byte and only remembers how far it got; the delivered
* head is cut off once per flush ({@link #compact}), so the cost of a flush is proportional
* to what was delivered, not to the size of the spool. A crash forgets the delivered mark and
* resends, which is harmless: the same series and time overwrite. Every access is serialized
* and short, so the network is never touched under the lock.
*/
final class Spool {
private static final Logger log = LoggerFactory.getLogger(Spool.class);
/** Cutting down to this share of the cap keeps the cap from rewriting the file on every append. */
private static final double CAP_TARGET = 0.9;
/** Lines read from byte {@code from} of the spool (counted from its first byte ever). */
record Batch(List<String> lines, long from) {
}
private final Path file;
private final long capBytes;
/** Position, in bytes since the spool was opened, of the file's first byte. */
private long start;
/** Position up to which lines are settled; never behind {@code start}. */
private long delivered;
Spool(Path file, long capBytes) throws IOException {
this.file = file;
this.capBytes = capBytes;
if (Files.exists(file)) {
dropTornLine();
}
}
synchronized void append(List<String> lines) throws IOException {
Files.writeString(file, String.join("\n", lines) + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
if (Files.size(file) > capBytes) {
dropOldest();
}
}
synchronized Batch peek(int maxLines) throws IOException {
List<String> lines = new ArrayList<>();
if (Files.exists(file)) {
try (InputStream in = new BufferedInputStream(Files.newInputStream(file))) {
in.skipNBytes(delivered - start);
ByteArrayOutputStream line = new ByteArrayOutputStream();
for (int b = in.read(); b >= 0 && lines.size() < maxLines; b = in.read()) {
if (b == '\n') {
lines.add(line.toString(StandardCharsets.UTF_8));
line.reset();
} else {
line.write(b);
}
}
}
}
return new Batch(List.copyOf(lines), delivered);
}
/** Marks the batch's first {@code count} lines as settled; the cap may have cut them already. */
synchronized void settle(Batch batch, int count) {
long end = batch.from();
for (String line : batch.lines().subList(0, count)) {
end += line.getBytes(StandardCharsets.UTF_8).length + 1;
}
delivered = Math.max(delivered, end);
}
/** Cuts the settled head off the file: one copy of the undelivered tail per flush. */
synchronized void compact() throws IOException {
if (delivered > start) {
cutHead(delivered - start);
}
}
private void dropOldest() throws IOException {
long size = Files.size(file);
long cut = findLineStart(size - (long) (capBytes * CAP_TARGET));
long lost = Math.max(0, start + cut - delivered);
cutHead(cut);
log.warn("Spool {} is over {} bytes: dropped its oldest {} undelivered bytes",
file.getFileName(), capBytes, lost);
}
/** The first line start at or after {@code position}, or the end of the file. */
private long findLineStart(long position) throws IOException {
try (InputStream in = new BufferedInputStream(Files.newInputStream(file))) {
in.skipNBytes(Math.max(0, position - 1));
long offset = Math.max(0, position - 1);
for (int b = in.read(); b >= 0; b = in.read()) {
offset++;
if (b == '\n') {
return offset;
}
}
return offset;
}
}
private void cutHead(long bytes) throws IOException {
if (bytes >= Files.size(file)) {
Files.delete(file);
} else {
Path temp = file.resolveSibling(file.getFileName() + ".tmp");
try (InputStream in = Files.newInputStream(file);
OutputStream out = Files.newOutputStream(temp)) {
in.skipNBytes(bytes);
in.transferTo(out);
}
Files.move(temp, file, StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
}
start += bytes;
delivered = Math.max(delivered, start);
}
/** A crash may leave a fragment after the last newline, which would glue onto the next append. */
private void dropTornLine() throws IOException {
try (FileChannel channel = FileChannel.open(file, StandardOpenOption.READ,
StandardOpenOption.WRITE)) {
long end = endOfLastLine(channel);
if (end < channel.size()) {
channel.truncate(end);
}
}
if (Files.size(file) == 0) {
Files.delete(file);
}
}
private static long endOfLastLine(FileChannel channel) throws IOException {
ByteBuffer chunk = ByteBuffer.allocate(8192);
long position = channel.size();
while (position > 0) {
int length = (int) Math.min(chunk.capacity(), position);
position -= length;
chunk.clear().limit(length);
while (chunk.hasRemaining() && channel.read(chunk, position + chunk.position()) >= 0) {
continue;
}
for (int i = length - 1; i >= 0; i--) {
if (chunk.get(i) == '\n') {
return position + i + 1;
}
}
}
return 0;
}
}
@@ -0,0 +1,99 @@
package home.gateway.adapters.meter;
import home.gateway.adapters.meter.Sdm630Registers.Register;
import home.gateway.adapters.modbus.ModbusClient;
import home.gateway.application.Device;
import home.gateway.domain.Reading;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/** The Eastron SDM630 house meter: all input registers, one point per poll. */
public final class HouseMeterDevice implements Device {
private static final int UNIT_ID = 1;
private static final int MAX_REGISTERS_PER_REQUEST = 125;
private static final Duration INTERVAL = Duration.ofSeconds(60);
private static final int DECIMALS = 2;
private record Block(int address, int count, List<Register> registers) {
}
private final ModbusClient modbus;
private final List<Block> blocks = blocksOf(Sdm630Registers.INPUT);
public HouseMeterDevice(ModbusClient modbus) {
this.modbus = modbus;
}
@Override
public String name() {
return "house-meter";
}
@Override
public Duration interval() {
return INTERVAL;
}
@Override
public List<Reading> read(Instant now) throws IOException {
Map<String, Double> fields = new LinkedHashMap<>();
for (Block block : blocks) {
readBlock(block, fields);
}
if (fields.isEmpty()) {
throw new IOException("house meter returned no finite value");
}
return List.of(new Reading("electricity", "PRAD2", Map.of("location", "dom-szczepancowa"),
fields, now));
}
private void readBlock(Block block, Map<String, Double> fields) throws IOException {
int[] words = modbus.readInputRegisters(UNIT_ID, block.address(), block.count());
if (words.length != block.count()) {
throw new IOException("house meter block at " + block.address() + " returned "
+ words.length + " registers, expected " + block.count());
}
for (Register register : block.registers()) {
int offset = register.address() - block.address();
float value = Float.intBitsToFloat((words[offset] << 16) | words[offset + 1]);
// The old Python code lost the whole point on NaN or infinity; one bad value
// should not cost the other 70.
if (Float.isFinite(value)) {
fields.put(register.name(), round(value));
}
}
}
/** Same as Python's {@code round(float(x), 2)}: the exact binary value, ties to even. */
private static double round(float value) {
return new BigDecimal(value).setScale(DECIMALS, RoundingMode.HALF_EVEN).doubleValue();
}
private static List<Block> blocksOf(List<Register> registers) {
Map<Integer, List<Register>> byBatch = new TreeMap<>();
for (Register register : registers) {
byBatch.computeIfAbsent(register.batch(), batch -> new ArrayList<>()).add(register);
}
List<Block> result = new ArrayList<>();
for (List<Register> batch : byBatch.values()) {
int first = batch.getFirst().address();
int count = batch.getLast().address() + Sdm630Registers.WORDS_PER_VALUE - first;
if (count > MAX_REGISTERS_PER_REQUEST) {
throw new IllegalStateException("block at " + first + " has " + count
+ " registers, limit " + MAX_REGISTERS_PER_REQUEST);
}
result.add(new Block(first, count, batch));
}
return List.copyOf(result);
}
}
@@ -0,0 +1,93 @@
package home.gateway.adapters.meter;
import java.util.List;
/**
* The SDM630 input registers, as sdm_modbus 0.4.4 reads them in {@code read_all()}. Names are the
* field names of {@code electricity.PRAD2}. Every value is a float32 in two registers, unscaled.
* The batch number is the library's: registers of one batch are fetched with one request.
*/
final class Sdm630Registers {
static final int WORDS_PER_VALUE = 2;
record Register(String name, int address, int batch) {
}
static final List<Register> INPUT = List.of(
new Register("p1_voltage", 0x0000, 1),
new Register("p2_voltage", 0x0002, 1),
new Register("p3_voltage", 0x0004, 1),
new Register("p1_current", 0x0006, 1),
new Register("p2_current", 0x0008, 1),
new Register("p3_current", 0x000a, 1),
new Register("p1_power_active", 0x000c, 1),
new Register("p2_power_active", 0x000e, 1),
new Register("p3_power_active", 0x0010, 1),
new Register("p1_power_apparent", 0x0012, 1),
new Register("p2_power_apparent", 0x0014, 1),
new Register("p3_power_apparent", 0x0016, 1),
new Register("p1_power_reactive", 0x0018, 1),
new Register("p2_power_reactive", 0x001a, 1),
new Register("p3_power_reactive", 0x001c, 1),
new Register("p1_power_factor", 0x001e, 1),
new Register("p2_power_factor", 0x0020, 1),
new Register("p3_power_factor", 0x0022, 1),
new Register("p1_phase_angle", 0x0024, 1),
new Register("p2_phase_angle", 0x0026, 1),
new Register("p3_phase_angle", 0x0028, 1),
new Register("voltage_ln", 0x002a, 1),
new Register("current_ln", 0x002e, 1),
new Register("total_line_current", 0x0030, 1),
new Register("total_power_active", 0x0034, 1),
new Register("total_power_apparent", 0x0038, 1),
new Register("total_power_reactive", 0x003c, 1),
new Register("total_power_factor", 0x003e, 1),
new Register("total_phase_angle", 0x0042, 1),
new Register("frequency", 0x0046, 1),
new Register("import_energy_active", 0x0048, 1),
new Register("export_energy_active", 0x004a, 1),
new Register("import_energy_reactive", 0x004c, 1),
new Register("export_energy_reactive", 0x004e, 1),
new Register("total_energy_apparent", 0x0050, 2),
new Register("total_current", 0x0052, 2),
new Register("total_import_demand_power_active", 0x0054, 2),
new Register("maximum_import_demand_power_apparent", 0x0056, 2),
new Register("import_demand_power_active", 0x0058, 2),
new Register("maximum_import_demand_power_active", 0x005a, 2),
new Register("export_demand_power_active", 0x005c, 2),
new Register("maximum_export_demand_power_active", 0x005e, 2),
new Register("total_demand_power_apparent", 0x0064, 2),
new Register("maximum_demand_power_apparent", 0x0066, 2),
new Register("neutral_demand_current", 0x0068, 2),
new Register("maximum_neutral_demand_current", 0x006a, 2),
new Register("p12_voltage", 0x00c8, 3),
new Register("p23_voltage", 0x00ca, 3),
new Register("p31_voltage", 0x00cc, 3),
new Register("voltage_ll", 0x00ce, 3),
new Register("neutral_current", 0x00e0, 3),
new Register("p1n_voltage_thd", 0x00ea, 3),
new Register("p2n_voltage_thd", 0x00ec, 3),
new Register("p3n_voltage_thd", 0x00ee, 3),
new Register("p1_current_thd", 0x00f0, 3),
new Register("p2_current_thd", 0x00f2, 3),
new Register("p3_current_thd", 0x00f4, 3),
new Register("voltage_ln_thd", 0x00f8, 3),
new Register("current_thd", 0x00fa, 3),
new Register("p1_demand_current", 0x0102, 3),
new Register("p2_demand_current", 0x0104, 3),
new Register("p3_demand_current", 0x0106, 3),
new Register("maximum_p1_demand_current", 0x0108, 3),
new Register("maximum_p2_demand_current", 0x010a, 3),
new Register("maximum_p3_demand_current", 0x010c, 3),
new Register("p12_voltage_thd", 0x014e, 4),
new Register("p23_voltage_thd", 0x0150, 4),
new Register("p31_voltage_thd", 0x0152, 4),
new Register("voltage_ll_thd", 0x0154, 4),
new Register("total_energy_active", 0x0156, 4),
new Register("total_energy_reactive", 0x0158, 4)
);
private Sdm630Registers() {
}
}
@@ -0,0 +1,11 @@
package home.gateway.adapters.modbus;
import java.io.IOException;
/** Register reads; values are raw 16-bit words (0–65535), decoding belongs to the device. */
public interface ModbusClient {
int[] readHoldingRegisters(int unitId, int address, int count) throws IOException;
int[] readInputRegisters(int unitId, int address, int count) throws IOException;
}
@@ -0,0 +1,39 @@
package home.gateway.adapters.modbus;
import java.io.IOException;
/** The device answered a request with a Modbus exception response. */
public class ModbusException extends IOException {
private final int function;
private final int code;
public ModbusException(int function, int code) {
super("Modbus exception on function " + function + ": " + describe(code));
this.function = function;
this.code = code;
}
public int function() {
return function;
}
public int code() {
return code;
}
private static String describe(int code) {
String name = switch (code) {
case 0x01 -> "illegal function";
case 0x02 -> "illegal data address";
case 0x03 -> "illegal data value";
case 0x04 -> "server device failure";
case 0x05 -> "acknowledge";
case 0x06 -> "server busy";
case 0x0A -> "gateway path unavailable";
case 0x0B -> "gateway target failed to respond";
default -> "unknown code";
};
return name + " (0x%02X)".formatted(code);
}
}
@@ -0,0 +1,10 @@
package home.gateway.adapters.modbus;
import java.io.Closeable;
import java.io.IOException;
/** Carries one Modbus PDU to a unit and returns the response PDU (function code first). */
public interface ModbusTransport extends Closeable {
byte[] exchange(int unitId, byte[] requestPdu) throws IOException;
}
@@ -0,0 +1,70 @@
package home.gateway.adapters.modbus;
import java.io.IOException;
import java.nio.ByteBuffer;
/** Modbus read functions FC3 and FC4 encoded and decoded as PDUs over a transport. */
public class PduModbusClient implements ModbusClient {
private static final int READ_HOLDING_REGISTERS = 0x03;
private static final int READ_INPUT_REGISTERS = 0x04;
private static final int EXCEPTION_FLAG = 0x80;
private static final int MAX_QUANTITY = 125;
private static final int MAX_ADDRESS = 0xFFFF;
private final ModbusTransport transport;
public PduModbusClient(ModbusTransport transport) {
this.transport = transport;
}
@Override
public int[] readHoldingRegisters(int unitId, int address, int count) throws IOException {
return read(READ_HOLDING_REGISTERS, unitId, address, count);
}
@Override
public int[] readInputRegisters(int unitId, int address, int count) throws IOException {
return read(READ_INPUT_REGISTERS, unitId, address, count);
}
private int[] read(int function, int unitId, int address, int count) throws IOException {
if (address < 0 || address > MAX_ADDRESS) {
throw new IllegalArgumentException("Register address out of range 0..65535: " + address);
}
if (count < 1 || count > MAX_QUANTITY) {
throw new IllegalArgumentException("Register count out of range 1..125: " + count);
}
byte[] request = ByteBuffer.allocate(5)
.put((byte) function).putShort((short) address).putShort((short) count).array();
return decode(function, count, transport.exchange(unitId, request));
}
private static int[] decode(int function, int count, byte[] response) throws IOException {
if (response.length < 2) {
throw new IOException("Modbus response too short: " + response.length + " bytes");
}
ByteBuffer buffer = ByteBuffer.wrap(response);
int received = buffer.get() & 0xFF;
if (received == (function | EXCEPTION_FLAG)) {
throw new ModbusException(function, buffer.get() & 0xFF);
}
if (received != function) {
throw new IOException("Modbus response function 0x%02X does not match request 0x%02X"
.formatted(received, function));
}
int byteCount = buffer.get() & 0xFF;
if (byteCount != 2 * count) {
throw new IOException("Modbus byte count " + byteCount + ", expected " + 2 * count);
}
if (buffer.remaining() != byteCount) {
throw new IOException("Modbus response carries " + buffer.remaining()
+ " data bytes, byte count says " + byteCount);
}
int[] words = new int[count];
for (int i = 0; i < count; i++) {
words[i] = buffer.getShort() & 0xFFFF;
}
return words;
}
}
@@ -0,0 +1,150 @@
package home.gateway.adapters.modbus;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.ByteBuffer;
import java.time.Duration;
import java.util.concurrent.locks.ReentrantLock;
/**
* Modbus TCP (MBAP) over one long-lived socket. The MK01 gateway serves a single client and a
* second connection drops the first, so the socket is reused and requests never overlap.
*/
public final class TcpTransport implements ModbusTransport {
public record Settings(String host, int port, Duration connectTimeout, Duration readTimeout,
Duration minimumPause) {
}
private static final int HEADER_LENGTH = 7;
// Not synchronized: a virtual thread blocked in a monitor pins its carrier thread.
private final ReentrantLock lock = new ReentrantLock();
private final Settings settings;
private Socket socket;
private DataInputStream in;
private int transactionId;
private long lastResponseNanos;
public TcpTransport(Settings settings) {
this.settings = settings;
this.lastResponseNanos = System.nanoTime() - settings.minimumPause().toNanos();
}
@Override
public byte[] exchange(int unitId, byte[] requestPdu) throws IOException {
lock.lock();
try {
return exchangeLocked(unitId, requestPdu);
} finally {
lock.unlock();
}
}
private byte[] exchangeLocked(int unitId, byte[] requestPdu) throws IOException {
try {
awaitPause();
connectIfNeeded();
int id = nextTransactionId();
socket.getOutputStream().write(frame(id, unitId, requestPdu));
byte[] response = readResponse(id, unitId);
lastResponseNanos = System.nanoTime();
return response;
} catch (IOException e) {
closeSocket();
throw e;
}
}
@Override
public void close() {
lock.lock();
try {
closeSocket();
} finally {
lock.unlock();
}
}
private void awaitPause() throws InterruptedIOException {
long remaining = settings.minimumPause().toNanos() - (System.nanoTime() - lastResponseNanos);
if (remaining <= 0) {
return;
}
try {
Thread.sleep(Duration.ofNanos(remaining));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new InterruptedIOException("interrupted while waiting between requests");
}
}
private void connectIfNeeded() throws IOException {
if (socket != null) {
return;
}
Socket candidate = new Socket();
try {
candidate.setTcpNoDelay(true);
candidate.setSoTimeout((int) settings.readTimeout().toMillis());
candidate.connect(new InetSocketAddress(settings.host(), settings.port()),
(int) settings.connectTimeout().toMillis());
} catch (IOException e) {
candidate.close();
throw e;
}
socket = candidate;
in = new DataInputStream(candidate.getInputStream());
}
private int nextTransactionId() {
transactionId = (transactionId + 1) & 0xFFFF;
return transactionId;
}
private static byte[] frame(int transactionId, int unitId, byte[] pdu) {
return ByteBuffer.allocate(HEADER_LENGTH + pdu.length)
.putShort((short) transactionId)
.putShort((short) 0)
.putShort((short) (pdu.length + 1))
.put((byte) unitId)
.put(pdu)
.array();
}
private byte[] readResponse(int expectedTransactionId, int expectedUnitId) throws IOException {
ByteBuffer header = ByteBuffer.wrap(in.readNBytes(HEADER_LENGTH));
if (header.remaining() < HEADER_LENGTH) {
throw new IOException("connection closed by peer");
}
int id = header.getShort() & 0xFFFF;
int protocol = header.getShort() & 0xFFFF;
int length = header.getShort() & 0xFFFF;
int unitId = header.get() & 0xFF;
if (protocol != 0 || id != expectedTransactionId || unitId != expectedUnitId || length < 2) {
throw new IOException("unexpected MBAP header: transaction %d, protocol %d, length %d, unit %d"
.formatted(id, protocol, length, unitId));
}
byte[] pdu = in.readNBytes(length - 1);
if (pdu.length < length - 1) {
throw new IOException("connection closed by peer");
}
return pdu;
}
private void closeSocket() {
if (socket == null) {
return;
}
try {
socket.close();
} catch (IOException ignored) {
// nothing useful to do with a socket that fails to close
}
socket = null;
in = null;
}
}
@@ -0,0 +1,54 @@
package home.gateway.adapters.solarman;
import java.io.IOException;
/** Modbus RTU frame: unit id, PDU, CRC16 with the low byte first. */
final class ModbusRtuFrame {
private static final int CRC_LENGTH = 2;
private static final int MIN_LENGTH = 1 + 1 + CRC_LENGTH;
private ModbusRtuFrame() {
}
static byte[] build(int unitId, byte[] pdu) {
byte[] frame = new byte[1 + pdu.length + CRC_LENGTH];
frame[0] = (byte) unitId;
System.arraycopy(pdu, 0, frame, 1, pdu.length);
int crc = crc16(frame, frame.length - CRC_LENGTH);
frame[frame.length - 2] = (byte) crc;
frame[frame.length - 1] = (byte) (crc >> 8);
return frame;
}
static boolean hasValidCrc(byte[] frame) {
if (frame.length < MIN_LENGTH) {
return false;
}
int crc = crc16(frame, frame.length - CRC_LENGTH);
return frame[frame.length - 2] == (byte) crc && frame[frame.length - 1] == (byte) (crc >> 8);
}
static byte[] parse(byte[] frame, int expectedUnitId) throws IOException {
if (!hasValidCrc(frame)) {
throw new IOException("Modbus RTU frame has a bad CRC or is too short");
}
if ((frame[0] & 0xFF) != expectedUnitId) {
throw new IOException("Modbus RTU frame from unit " + (frame[0] & 0xFF) + ", expected " + expectedUnitId);
}
byte[] pdu = new byte[frame.length - 1 - CRC_LENGTH];
System.arraycopy(frame, 1, pdu, 0, pdu.length);
return pdu;
}
static int crc16(byte[] data, int length) {
int crc = 0xFFFF;
for (int i = 0; i < length; i++) {
crc ^= data[i] & 0xFF;
for (int bit = 0; bit < 8; bit++) {
crc = (crc & 1) != 0 ? (crc >>> 1) ^ 0xA001 : crc >>> 1;
}
}
return crc;
}
}
@@ -0,0 +1,203 @@
package home.gateway.adapters.solarman;
import home.gateway.adapters.modbus.ModbusTransport;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.time.Duration;
import java.util.Arrays;
import java.util.concurrent.locks.ReentrantLock;
/**
* Modbus RTU carried in Solarman V5 frames over the Wi-Fi logger's TCP port (usually 8899).
* One connection is kept and reused, one request at a time; after any error it is closed and
* the next call reconnects. All V5 fields are little endian, the RTU frame inside is not.
*/
public final class SolarmanV5Transport implements ModbusTransport {
public record Settings(String host, int port, long loggerSerial, Duration connectTimeout, Duration readTimeout) {
}
private static final int START = 0xA5;
private static final int END = 0x15;
private static final int REQUEST_CONTROL_CODE = 0x4510;
private static final int RESPONSE_CONTROL_CODE = 0x1510;
private static final int HEADER_LENGTH = 11;
private static final int TRAILER_LENGTH = 2;
private static final int REQUEST_PAYLOAD_PREFIX = 15;
private static final int RESPONSE_PAYLOAD_PREFIX = 14;
private static final int INVERTER_FRAME_TYPE = 0x02;
private static final int DOUBLE_CRC_LENGTH = 2;
// Not synchronized: a virtual thread blocked in a monitor pins its carrier thread.
private final ReentrantLock lock = new ReentrantLock();
private final Settings settings;
private Socket socket;
private int sequence = (int) (Math.random() * 256);
public SolarmanV5Transport(Settings settings) {
this.settings = settings;
}
@Override
public byte[] exchange(int unitId, byte[] requestPdu) throws IOException {
lock.lock();
try {
return exchangeLocked(unitId, requestPdu);
} finally {
lock.unlock();
}
}
private byte[] exchangeLocked(int unitId, byte[] requestPdu) throws IOException {
try {
Socket connection = connection();
int requestSequence = nextSequence();
OutputStream out = connection.getOutputStream();
out.write(requestFrame(requestSequence, ModbusRtuFrame.build(unitId, requestPdu)));
out.flush();
byte[] rtu = readResponseRtu(new DataInputStream(connection.getInputStream()), requestSequence);
return ModbusRtuFrame.parse(rtu, unitId);
} catch (IOException | RuntimeException e) {
closeConnection();
throw e;
}
}
@Override
public void close() {
lock.lock();
try {
closeConnection();
} finally {
lock.unlock();
}
}
private Socket connection() throws IOException {
if (socket == null) {
Socket candidate = new Socket();
try {
candidate.connect(new InetSocketAddress(settings.host(), settings.port()),
(int) settings.connectTimeout().toMillis());
candidate.setSoTimeout((int) settings.readTimeout().toMillis());
} catch (IOException e) {
candidate.close();
throw e;
}
socket = candidate;
}
return socket;
}
private void closeConnection() {
if (socket != null) {
try {
socket.close();
} catch (IOException ignored) {
// already failing; the next call reconnects
}
socket = null;
}
}
// Only the low byte is echoed by the logger; it counts the high byte itself.
private int nextSequence() {
sequence = (sequence + 1) & 0xFF;
return sequence;
}
private byte[] requestFrame(int requestSequence, byte[] rtu) {
int payloadLength = REQUEST_PAYLOAD_PREFIX + rtu.length;
ByteBuffer frame = ByteBuffer.allocate(HEADER_LENGTH + payloadLength + TRAILER_LENGTH)
.order(ByteOrder.LITTLE_ENDIAN);
frame.put((byte) START)
.putShort((short) payloadLength)
.putShort((short) REQUEST_CONTROL_CODE)
.putShort((short) requestSequence)
.putInt((int) settings.loggerSerial())
.put((byte) INVERTER_FRAME_TYPE)
.put(new byte[REQUEST_PAYLOAD_PREFIX - 1]) // sensor type and three timers, all zero
.put(rtu);
frame.put((byte) checksum(frame.array(), frame.position()));
frame.put((byte) END);
return frame.array();
}
// The logger also sends frames nobody asked for (heartbeats, late answers); skip them until
// the answer to this request arrives or the read times out.
private byte[] readResponseRtu(DataInputStream in, int requestSequence) throws IOException {
long deadline = System.nanoTime() + settings.readTimeout().toNanos();
while (true) {
if (System.nanoTime() > deadline) {
throw new SocketTimeoutException("no V5 answer to sequence " + requestSequence);
}
byte[] header = new byte[HEADER_LENGTH];
in.readFully(header);
ByteBuffer head = ByteBuffer.wrap(header).order(ByteOrder.LITTLE_ENDIAN);
int payloadLength = head.getShort(1) & 0xFFFF;
if ((header[0] & 0xFF) != START) {
throw new IOException("V5 response does not start with 0xA5");
}
byte[] rest = new byte[payloadLength + TRAILER_LENGTH];
in.readFully(rest);
if (!isAnswerTo(head, requestSequence)) {
continue;
}
if (payloadLength < RESPONSE_PAYLOAD_PREFIX) {
throw new IOException("V5 response payload too short: " + payloadLength);
}
verify(head, header, rest);
byte[] rtu = new byte[payloadLength - RESPONSE_PAYLOAD_PREFIX];
System.arraycopy(rest, RESPONSE_PAYLOAD_PREFIX, rtu, 0, rtu.length);
return withoutDoubleCrc(rtu);
}
}
private static boolean isAnswerTo(ByteBuffer head, int requestSequence) {
return (head.getShort(3) & 0xFFFF) == RESPONSE_CONTROL_CODE
&& (head.getShort(5) & 0xFF) == requestSequence;
}
private void verify(ByteBuffer head, byte[] header, byte[] rest) throws IOException {
if (head.getInt(7) != (int) settings.loggerSerial()) {
throw new IOException("V5 response from another logger");
}
byte[] checked = new byte[header.length + rest.length - TRAILER_LENGTH];
System.arraycopy(header, 0, checked, 0, header.length);
System.arraycopy(rest, 0, checked, header.length, rest.length - TRAILER_LENGTH);
if ((rest[rest.length - 2] & 0xFF) != checksum(checked, checked.length)) {
throw new IOException("V5 response has a bad checksum");
}
if ((rest[rest.length - 1] & 0xFF) != END) {
throw new IOException("V5 response does not end with 0x15");
}
}
// Deye loggers append a second, always zero CRC. Check the trimmed frame first: a frame
// followed by its own CRC also passes the CRC check, with the CRC taken as payload.
private static byte[] withoutDoubleCrc(byte[] rtu) {
if (rtu.length > DOUBLE_CRC_LENGTH) {
byte[] trimmed = Arrays.copyOf(rtu, rtu.length - DOUBLE_CRC_LENGTH);
if (ModbusRtuFrame.hasValidCrc(trimmed)) {
return trimmed;
}
}
return rtu;
}
/** Sum of all bytes between the start byte and the checksum, modulo 256. */
private static int checksum(byte[] frame, int end) {
int sum = 0;
for (int i = 1; i < end; i++) {
sum += frame[i] & 0xFF;
}
return sum & 0xFF;
}
}
@@ -0,0 +1,18 @@
package home.gateway.application;
import home.gateway.domain.Command;
import home.gateway.domain.CommandResult;
import home.gateway.domain.Restore;
import home.gateway.domain.Setpoint;
import java.util.List;
/** What the HTTP adapter may ask of the setpoint service (F3); the service does every check. */
public interface Commands {
CommandResult apply(Command command);
List<Setpoint> setpoints();
List<Restore> pendingRestores();
}
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff. Show more