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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
---
name: close-milestone
description: Close the current milestone in docs/roadmap.md, remove its working notes and start the next one. Use when every item of the current milestone is done.
---
# Close milestone
1. Read `docs/roadmap.md`. Every item of "Current" is checked and its "Done when" holds.
Anything you cannot verify yourself (e.g. "a week on the Pi") you ask the user about.
Stop and report if something is open.
2. Branch `docs/close-<milestone>` (e.g. `docs/close-m1`).
3. Under "Done", add one line: `- M<n> <name> · <tag or commit>: <what it delivered>`.
Remove the "Current" checklist and any placeholder line.
4. Remove the milestone's working notes: fixed defects (D…), consumed feasibility checks,
notes that served only this milestone. Verified knowledge moves first: controller facts
to `docs/frisko/`, scope or architecture to `docs/spec.md`, decisions to a new ADR.
5. Make the next row of "Next" the new "Current": a checklist of PR-sized items in build
order, keeping its "Done when". Ask the user when the split is not obvious.
6. `scripts/check.sh` (roadmap ≤ 80 lines), then `/ship`.
+30
View File
@@ -0,0 +1,30 @@
---
name: ship
description: Take the current topic branch to master through a Gitea PR with green CI, then optionally deploy the gateway to the Pi. Use when a change is ready to merge or release.
---
# Ship
Repo rules: `CONTRIBUTING.md`. Gitea: https://git.mariuszpacek.pl/mariusz.pacek/home-pump
(no API token here; reads need none).
1. On a topic branch (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), clean tree, commits
in Conventional Commits. Run `scripts/check.sh`.
2. `git push -u origin <branch>`; CI runs on every pushed branch.
3. Open the PR by push (Gitea AGit), title in Conventional Commits, description says why:
`git push origin HEAD:refs/for/master -o topic=<branch> -o title="…" -o description="…"`.
The description is one line (push options cannot hold newlines). After later fixes push
both the branch and `refs/for/master` with the same topic, so CI and the PR both update.
If that is refused, give the user the compare link `…/compare/master...<branch>`.
4. Wait for CI on the head commit, polling every 30 s:
`curl -s …/api/v1/repos/mariusz.pacek/home-pump/commits/<sha>/status` → `state`.
On `failure`, read the run under `…/actions`, fix, push again. Never merge red.
5. Merge only if the user asked for it in this task:
`git switch master && git pull --ff-only && git merge --no-ff <branch> -m "Merge PR #N: <title>"`,
then `git push origin master` and delete the branch locally and on origin. Confirm CI is
green on master. Gitea does not close an AGit PR merged by hand: ask the user to close it.
6. Deploy only with the user's explicit approval for this deploy, and only for changes that
touch the gateway: `scripts/deploy-gateway.sh vMAJOR.MINOR.PATCH` (patch for fixes, minor
for features; `PI=<ssh host>` while D7 is open). Then watch
`ssh <host> journalctl -u home-pump.service -f`
and Grafana for one polling cycle (300 s) and report what you saw.
-18
View File
@@ -1,18 +0,0 @@
kind: pipeline
type: docker
name: default
steps:
- name: test
image: maven:3-jdk-11
commands:
- mvn install -DskipTests=true -Dmaven.javadoc.skip=true -B -V
- mvn test -B
- name: docker
image: plugins/docker
settings:
repo: registry.mariuszpacek.pl/home-pump
tags: latest
when:
branch:
- master
+2 -2
View File
@@ -28,5 +28,5 @@ jobs:
uses: stCarolas/setup-maven@v5
with:
maven-version: 3.9.6
- name: Run Maven tests (parallel)
run: mvn -B -T 1C test
- name: Check
run: scripts/check.sh
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Enabled once per clone: git config core.hooksPath .githooks
exec "$(git rev-parse --show-toplevel)/scripts/check.sh"
+3
View File
@@ -56,3 +56,6 @@ coverage/
/apk/
/old/
/old_backup/
# Agent worktrees
/.claude/worktrees/
+15
View File
@@ -0,0 +1,15 @@
Backend for controlling the heat pump in Krosno (Frisko controller, Modbus TCP) from a phone.
@CONTRIBUTING.md
## For agents
- Start from `docs/roadmap.md`; stay within the current milestone unless asked.
- Read `docs/spec.md` and a decision in `docs/decisions/` only when the task touches it.
- Production: `pi@raspberrypi.local`, service `home-pump.service`. Read-only inspection is fine;
deploy, restart or file changes on the Pi need explicit approval each time.
- The Pi also runs `rpi-measurements.service` (house meter and BME280 into Influx); do not break
it. It moves into the gateway in M1; never run both against the meter at once. Do not upgrade
or reinstall the Pi's OS.
- Never write to the controller `192.168.0.150` and never to the `Dom_Pompa` database.
- Ask before force-pushing or rewriting history on Gitea.
+60
View File
@@ -0,0 +1,60 @@
# Repository rules
What we build: [docs/spec.md](docs/spec.md). Plan: [docs/roadmap.md](docs/roadmap.md).
Reasons: [docs/decisions/](docs/decisions/).
## Branches and pull requests
- `master` is always deployable and matches what runs on the Pi.
- Every change goes through a pull request on Gitea; no direct commits to `master`.
- Branch names: `feat/…`, `fix/…`, `docs/…`, `chore/…`, `refactor/…`.
- One topic per PR, small enough to review in one sitting. Merge only with green CI.
- `archive/spring-boot-attempt` is reference only. Never merge it.
## Commits
- English, Conventional Commits: `fix(health): restart without /health polling`.
- Imperative subject ≤ 72 characters; the body says why when it is not obvious.
- Each commit builds and passes tests.
## Code
- Java 21, Maven, no framework (ADR-001). `mvn clean package`, `mvn test`.
- `scripts/check.sh` is the gate: doc limits, secret scan, build and tests. CI runs it, and so
does the pre-push hook once enabled with `git config core.hooksPath .githooks`.
- Readable first: small classes and methods, names from the domain, comments only for the why.
- No dead code, no unused config, no duplicated logic, no speculative abstractions.
- Every behaviour change has a test. Modbus is tested against `MockModbusServer` only.
## Safety
- Tests and local runs never use the real controller or the `Dom_Pompa` database.
- A register is written only if it is on the allowlist (min, max, step) and verified in
`docs/frisko/`.
- Changes to how or when we write to the controller need a test of the failure case.
## Knowledge and docs
Docs are written so an agent needs little context: read only what the task touches.
| File | Limit | Changes |
|---|---|---|
| `CLAUDE.md` | 40 lines | rarely |
| `docs/spec.md` | 120 lines | when scope or architecture changes |
| `docs/roadmap.md` | 80 lines | every milestone |
| `docs/decisions/NNN-*.md` | 40 lines | never after acceptance; supersede with a new ADR |
| `docs/frisko/<topic>.md` | 120 lines | when knowledge is verified |
| `<package>/CLAUDE.md` | 30 lines | rules of that layer |
| `research/<source>/` | none | raw vendor material, kept as found; read only on demand |
- Limits are checked by `scripts/check.sh`.
- No status, audit or log documents: the state is git history and the roadmap.
- Closing a milestone compresses it to one line in the roadmap and removes its working notes.
- English everywhere; the UI is Polish.
## Configuration, secrets, releases
- No secrets in git. Production config lives on the Pi, outside the jar.
- Deploy only from `master` with `scripts/deploy-gateway.sh vMAJOR.MINOR.PATCH`; it tags the
deploy and rolls back if the service does not come up healthy.
- After a deploy, watch the journal and Grafana for at least one polling cycle.
-4
View File
@@ -1,4 +0,0 @@
FROM amazoncorretto:11.0.17
COPY target/test*.jar app/home-pump.jar
CMD ["java", "-jar", "app/home-pump.jar"]
+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

+22
View File
@@ -0,0 +1,22 @@
# ADR-001: Evolve the running baseline, plain Java
Status: accepted · 2026-10-23
## Context
Two starting points: the code running on the Pi (`e345063`, ~1300 lines, plain Java 21, works
since February) or a rewrite. The previous attempt (Spring Boot, branch
`archive/spring-boot-attempt`) grew large and complex without doing the essential job: it was
built on a register catalog that does not match the device.
## Decision
Keep the baseline and reshape it in small, deployable steps. Introduce the layers from the spec
as code is touched. Stay on plain Java 21 without a framework; add a small library only when it
removes more code than it adds, and record it here.
## Consequences
- What is proven on the device (decoding, sign handling, register addresses) is kept.
- Every step can be compared against the current Grafana data.
- No dependency-injection or web framework to learn before reading the code.
+19
View File
@@ -0,0 +1,19 @@
# ADR-002: Backend on the Pi, server as the gateway
Status: superseded by ADR-006 · 2026-10-23
## Context
The phone is outside the Krosno network. The backend could run on the Pi, next to the controller,
or on my server and reach Modbus through WireGuard.
## Decision
The backend runs on the Pi. My server only routes and authenticates: Traefik forwards an
authenticated HTTPS route through WireGuard to the Pi.
## Consequences
- Modbus never crosses the WAN: low latency, and the backend keeps running when the tunnel drops.
- No pump logic on the server.
- Deploy target stays the Pi (`scripts/deploy.sh`).
+25
View File
@@ -0,0 +1,25 @@
# ADR-003: One Modbus owner; snapshot queries, queued commands
Status: accepted · 2026-10-23
## Context
The controller answers slowly, and with frequent reads, commands were not carried out reliably.
Several parts of the system want data at once (Influx export, API, health).
## Decision
- A single worker thread owns the only Modbus connection and talks to the controller in turn.
- It polls at a fixed, conservative rate and publishes an immutable snapshot.
- Queries (API, Influx export, health) read the snapshot only.
- Commands go into a queue and get an id. The worker runs them between polls, reads the register
back and marks them `APPLIED` or `FAILED`. The client polls the command status.
## Consequences
- Load on the controller is fixed and set in one place, whatever the number of users.
- Commands take seconds, not milliseconds; the UI shows them as pending.
- The safe poll rate and command timing are measured in M2.
- The MK01 gateway accepts one TCP client and a new one drops the old: each of our polls
disconnects the Frisko phone app. While the Frisko app is still in use, poll rarely (as now,
300 s) and keep each connection short.
+18
View File
@@ -0,0 +1,18 @@
# ADR-004: Mobile-first web UI before any native app
Status: accepted · 2026-10-23
## Context
The users are my parents, on their phones, away from home. A native app (React Native) or a
WebView wrapper is possible later, but only once the backend works well.
## Decision
Build a mobile-first web UI, served by the backend, talking to the JSON API. Decide on native,
WebView or web-only in M6, once real use shows what is missing.
## Consequences
- One API serves the web UI now and any app later.
- Frontend technology is chosen in M3 with the same rule as ADR-001: the least code that works.
+25
View File
@@ -0,0 +1,25 @@
# ADR-005: InfluxDB for data, one local file for pending restores, no other database
Status: accepted · 2026-10-23
## Context
We store readings and energy data (time series), an audit log of writes (S3), and the state of
running boosts: the value to restore and when (F3). InfluxDB 1.x already holds the history and
feeds Grafana. It runs on my server, so the Pi loses it whenever the tunnel or DNS fails (D7).
## Decision
- **InfluxDB** holds all time series: heat pump readings and state, energy, and the audit log
as events. New data goes into new measurements, so existing Grafana dashboards keep working.
- **One small JSON file on the Pi** holds the running boosts (register, previous value, restore
time), written atomically. A restore must work without the network.
- Configuration (limits, presets, device addresses) is a file on the Pi, not data.
- No other database. The command queue is in memory; a restart drops pending commands, and the
UI shows them as failed.
## Consequences
- Nothing new to install or back up besides one file.
- Audit history in the app depends on InfluxDB; the journal on the Pi keeps a local copy.
- If we ever need users, many records or complex queries, revisit this decision.
+40
View File
@@ -0,0 +1,40 @@
# ADR-006: Thin gateway on the Pi, backend on the server in Kraków
Status: accepted · 2026-10-23 · supersedes ADR-002
## Context
The Pi is the weakest part (SD card, Debian 11 without support). My server in Kraków runs
Proxmox and Docker next to InfluxDB. But the MK01 gateway takes one client at a time, and a boost
must be restored on time even when the tunnel is down.
## Decision
**Gateway (Pi)**, the only client of the devices, and nothing else:
- polls every device (heat pump, Deye, Fronius, house meter, BME280 on the Pi) at a fixed rate, writes the readings
to InfluxDB with a local buffer, and serves the last snapshot (`GET /snapshot`);
- accepts commands `register, value, minutes or until changed` (`POST /commands`) only from the
backend (token and firewall), checks them itself (allowlist, min/max, rate limit), writes,
reads back, reports `APPLIED` or `FAILED`;
- keeps timed restores in a local file and runs them without the server.
Restore rules:
- The value to restore is read **from the controller just before the first write**, never sent
by the server.
- A new boost on a register already boosted replaces the end time and keeps the original value.
- At restore time, if the register no longer holds our value (someone changed it on the panel or
in the Frisko app), the gateway leaves it and reports a conflict.
- After a restart, overdue restores run first.
**Backend (Docker in Kraków)**: phone API, auth via Traefik, presets, notifications. Reads history
from InfluxDB and the live snapshot from the gateway; sends commands only to the gateway.
## Consequences
- The baseline code evolves into the gateway (ADR-001); the backend is a new, separate app.
- Two deployables: a Docker image for the server, a jar for the Pi.
- While the tunnel is down the app shows no live data, but readings are buffered and restores
still happen on time.
- `rpi-measurements` (house meter, BME280) moves into the gateway and is retired: one place for
all devices. The Pi's OS stays as it is (Debian 11), by decision.
- Reading Deye and Fronius from the Pi needs the subnet overlap with Kraków fixed first (D7).
+29
View File
@@ -0,0 +1,29 @@
# ADR-007: Gateway stack: JDK first, our own small Modbus core
Status: accepted · 2026-10-23
## Context
The gateway talks to four kinds of devices: the heat pump (Modbus TCP, sign-magnitude), the
house meter (Modbus TCP, FC4, float32), Deye (Modbus RTU inside Solarman V5 on port 8899) and
Fronius (HTTP JSON), plus the BME280 on the Pi's I2C. No Java library speaks Solarman V5. The
baseline uses modbus4j and influxdb-java; the latter pulls in most of the 10 MB jar.
## Decision
Java 21 (already on the Pi), Maven, a fat jar. As few dependencies as possible:
| Area | Choice |
|---|---|
| Modbus | Our own package: FC3/FC4/FC6 codec with exceptions, a TCP transport and a Solarman V5 transport (RTU frame, CRC16). Readable as protocol documentation, tested with frames recorded from our devices. modbus4j is removed. |
| Reading | Explicit register blocks per device in the config; one request per block, strictly one at a time per device, with a pause (200–300 ms for the heat pump). No automatic batching, no async pipelining. Devices run in parallel, one virtual thread each. |
| InfluxDB | JDK `HttpClient`, line protocol to `/write`, our own buffer kept in a file. influxdb-java is removed. |
| HTTP API | JDK `com.sun.net.httpserver` with virtual threads. |
| JSON, config | Jackson. |
| Logs, tests | slf4j-simple; JUnit 5, Mockito. |
| BME280 | The kernel's IIO driver (`dtoverlay=i2c-sensor,bme280,addr=0x76` in `/boot/config.txt`, approved; applied in M1 with a planned reboot) and plain file reads from `/sys/bus/iio`. No library. |
## Consequences
- About 250 lines of protocol code we own, instead of two libraries; a much smaller jar.
- Fallback for Modbus if our core disappoints: j2mod (Apache 2.0).
@@ -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.
+49
View File
@@ -0,0 +1,49 @@
# Frisko RX910-SE21: what we know
Sources: [research/frisko/](../../research/frisko/README.md). Facts here are verified on the device
unless marked otherwise.
## Identity and protocol
- Heat pump controller **RX910-SE21**, firmware **v24062015**: a read of 4131–4133 gave 18/19/15
(whole °C; firmware v10112017 uses tenths).
- Behind the MK01 gateway (`192.168.0.150:502`, Modbus TCP → RTU 9600 8N1). One TCP client at a
time; a new one drops the old.
- FC 3 read, FC 6 write verified 2026-10-23 (4143 8 → 9: echo OK, read-back 9 after 1 s, no
password needed); FC 16 per MR208-PC+ docs. Values are sign-magnitude
integers; the baseline's "legacy wrap" decodes this. Addresses are raw (4131 is 4131).
- `RX910-SE21-AdresyMODBUS.pdf` is the official map (~90 registers) and has no scales: take them
from `RX910-SE21_v24062015.xml` (format, min/max). Scale is per register, not a global 0.1.
- No SE21 manual for the control logic; `MR208-PC+_9P_v3.pdf` is the closest reference.
- `frisko/mr208_m3p.csv` on `archive/spring-boot-attempt` is for MR208-M3+: do not use.
## Registers we rely on
| Group | Registers | Notes |
|---|---|---|
| Temperatures | 4065 outdoor, 4066 indoor, 4067 CO/buffer, 4069 CWU, 4070/4071 ground in/out, 4072 pump outlet, 4199 avg outdoor | X10 |
| Targets now | 4201 room, 4202 CO supply, 4203 CWU | X10; show economy vs comfort period |
| State | 4227 pump (0 stop, 1 CO, 2 CWU, 3 both), 4226 program, 4221 alarm, 4073 pressure switch | |
| Outputs | 4112 CO, 4113 buffer, 4114 compressor (0/33/66/100), 4115 heater, 4116 circulation, 4117 ground, 4118 CWU | 0 or 100 |
| Setpoints (F3) | 4131/4132 CO economy/comfort (5–35), 4143/4144 CWU economy/comfort (5–70) | whole °C |
| Protection | 4158 TmaxPc = 52 °C, 4149 TmaxCWU = 55, 4148 HistCWU = 5, 4146 PriorCWU = 1, 4147 heater delay = 20 | read 2026-10-23 |
Unknown: 4119 `WyCHL` reads −14308.
## Weekly program
Behind a selector: write circuit (4177) and day (4167), then read or write the intervals
(4169–4176). Reading it is therefore not read-only, and the selector is shared with the panel and
the Frisko app. Party mode (4140 = 2, hours in 4142) is a simpler "heat now" override.
## Observed behaviour
- 2026-10-23 21:52: pump stop, weekly program, all outputs 0, targets 18.0 / 31.0 / 8.0 °C
(economy period). 5 blocks, 36 registers in 2.5 s.
- CWU is heated daily at ~11:00 and ~17:30 to ~44 °C (~15–20 °C/h, pump outlet ~47 °C).
Slow morning rises with the pump off come from the solar collector.
- The pump outlet tops out at ~52 °C (TmaxPc), so the pump alone reaches ~47–48 °C in the tank.
Higher CWU values in history occur only in summer (solar).
- Setting CWU above what the pump reaches risks the electric heater (after the delay) and
pressure-switch trips; three trips in one cycle latch a fault until manual reset (MR208-PC+ docs).
- Frisko phone app: direct Modbus TCP, LAN only, 200–300 ms between requests, 5 s timeout.
+62
View File
@@ -0,0 +1,62 @@
# Roadmap
Bottom-up: each milestone builds on a verified one below it, and master stays deployable.
Closed milestones shrink to one line here; details stay in git history and in decisions/.
## Done
- 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: M1 Gateway: all readings
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).
- [ ] 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 |
|---|---|---|
| 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
- D1 Self-restart works only while something polls `/health` (`HealthMonitor`).
- D2 Influx client logs every request (`LogLevel.FULL`), ~420 journal lines/hour.
- D3 Config is packaged inside the jar.
- 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
`10.0.0.0/24, 10.2.0.0/24` (Influx and Gitea go via the public IP anyway), plus outbound NAT on
OPNsense's WireGuard interface so the Pi answers Kraków through the tunnel.
## Knowledge
- Controller: [docs/frisko/rx910-se21.md](frisko/rx910-se21.md)
- Energy devices: [research/energy/](../research/energy/README.md)
- UI reference: [research/ui/](../research/ui/README.md)
+106
View File
@@ -0,0 +1,106 @@
# Specification
Stable description of what we build. The plan lives in [roadmap.md](roadmap.md), the reasons in
[decisions/](decisions/).
## Goal
An app that helps my father run and understand the house in Krosno, from his phone, also away
from home. The heat pump comes first: see what it does and change its few setpoints, replacing
the outdated Frisko app. Right after the first heat pump view, the energy: PV, battery, grid and consumption, which my
father wants a lot.
Simpler, readable and better tested than Frisko. Grafana stays for long-term analysis; the app
shows up to 7 days of history.
## Scope
In:
- Heat pump: live state, history, four setpoints (boost, away), alarms.
- Energy, read-only: PV production (Deye, Fronius), battery, grid import/export, house consumption.
- Access from outside through my server; Polish UI for my parents.
Out, until decided otherwise:
- Writing to the inverters or the battery, and controlling PV.
- Editing the weekly program of the heat pump.
- Cloud services (Solarman, Frisko Online) and the logger's push to a second server.
- Replacing Grafana.
## Principles
1. The heating must keep working. Safety beats features.
2. Understand a device first, then add features one at a time, bottom-up.
3. Frisko is a source of knowledge about the controller, never of code.
4. Code a person reads quickly: small classes, clear names, few dependencies.
## Context
| Component | Where | Notes |
|---|---|---|
| Heat pump controller Frisko RX910-SE21, fw v24062015 | behind the gateway | Ground source (boreholes); slow to respond; see [frisko/](frisko/rx910-se21.md) |
| Gateway Frisko MK01 (DS1206/EM500) | `192.168.0.150:502` | Modbus TCP → RTU 9600; **one TCP client at a time, a new one drops the old** |
| Solar thermal collector | roof | Also heats the CWU tank; not connected to anything we read |
| Hybrid inverter Deye SUN-10K-SG04LP3-EU | logger `192.168.0.232:8899` | 10 kW, 3 phases, 48 V battery (backup only); most of the PV |
| PV inverter Fronius Symo 3.7-3-M | `192.168.0.143` | 4.2 kWp; local Solar API (JSON) |
| House meter SDM630 | RS485 gateway `192.168.0.10:502` | Whole house; read today by `rpi-measurements` into Influx `electricity`/`PRAD2`, moving into our gateway |
| Solar-Log | `192.168.0.210` | Old monitoring, no data since April 2026 |
| Raspberry Pi (`raspberrypi`) | `192.168.0.158` | Debian 11 (kept as is); runs the gateway (and `rpi-measurements` until the move); BME280 sensor; WireGuard `10.0.0.2` |
| My server | Kraków | Proxmox and Docker: backend, Traefik, InfluxDB 1.x, Grafana, Gitea. Same subnet `192.168.0.0/24` as Krosno; WireGuard on OPNsense |
InfluxDB has no authentication; Traefik allows only Krosno LAN, home LAN and WireGuard.
## Architecture
```
phone (web UI) ─HTTPS─▶ Traefik + auth ─▶ backend (Docker, Kraków) ─WireGuard─▶ gateway (Pi) ─▶ devices
history in InfluxDB owns every device link
```
- **Gateway** on the Pi ([ADR-006](decisions/006-gateway-on-the-pi.md)): the only client of the heat
pump (MK01), Deye, Fronius and the house meter. Polls, writes readings to InfluxDB with a buffer,
serves the snapshot, executes checked commands and timed restores, even without the tunnel.
- **Backend** in Kraków: phone API, presets, notifications. Reads history from InfluxDB and the
live snapshot from the gateway; sends commands only to the gateway.
Layers in both apps, dependencies point inwards only:
| Layer | Contains | Depends on |
|---|---|---|
| `domain` | readings, settings, commands, rules | nothing |
| `application` | use cases, ports | `domain` |
| `adapters` | gateway: Modbus, Solarman, Fronius, InfluxDB writer, HTTP; backend: gateway client, InfluxDB reader, HTTP | `application`, `domain` |
## Requirements
Functional:
- F1 Live state of the heat pump: idle / heating CWU / heating CO, compressor stage, pumps,
heater, alarm, current targets and temperatures.
- F2 History for the last 7 days.
- F3 Change four setpoints: economy and comfort temperature of CWU (4143, 4144) and CO
(4131, 4132). Any value within limits, or one tap on a named preset. Limits and presets live in
the backend config: CWU 5–50 °C (pump maximum per my father; 48 °C proposed for margin, to
decide in M2), CO 5–25 °C.
- **Boost**: raise the economy temperature for a duration the user picks; the previous value
is restored automatically.
- **Away / not needed**: lower the comfort temperature (e.g. CWU 10 °C, CO 15 °C) until changed back.
- F4 Alarms with notifications: heat pump alarm, controller link lost, grid outage (house on battery).
- F5 Health of backend, device links and tunnel.
- F6 Later, low priority: show the weekly program (reading needs selector writes).
- F7 Energy view: PV production, battery state, grid import/export, house consumption; day totals.
Safety, for every write:
- S1 Only allowlisted registers, each with min, max and step.
- S2 Read back and verify after writing.
- S3 Audit log: who, when, register, old and new value.
- S4 Rate limit per register.
- S5 Tests and development never write to a real device or the production database.
- S6 The gateway accepts commands only from the backend: a shared token, and a firewall rule on
the Pi that allows only the backend's address.
Non-functional:
- N1 Reachable from outside only through Traefik with authentication.
- N2 Recovers on its own from device, InfluxDB and tunnel outages.
- N3 Existing Grafana dashboards keep working.
- N4 Usable by my parents: large controls, few screens, Polish UI.
+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
}
]
}
+40
View File
@@ -0,0 +1,40 @@
# Energy sources
| File | What |
|---|---|
| `deye-nameplate.jpg` | Nameplate of the hybrid inverter Deye SUN-10K-SG04LP3-EU, SN 2408266176 (photo 2026-10-23) |
## Deye hybrid inverter, read locally
Solarman Wi-Fi logger at `192.168.0.232:8899`, logger SN **3112107147** (sticker on the stick;
the `PWD` on the sticker is the stick's own Wi-Fi hotspot password and is not needed).
Protocol: Solarman V5 wrapping Modbus RTU, slave id 1, FC3 read only (`pysolarmanv5`).
Registers verified 2026-10-23 22:26 (serial 3–7 matched the nameplate, grid power matched the
SDM630 house meter: 645 W vs 640 W):
| Register | Meaning | Scale | Value at check |
|---|---|---|---|
| 3–7 | inverter serial, ASCII | – | 2408266176 |
| 586 | battery temperature | (x − 1000) / 10 °C | 19.2 |
| 587 | battery voltage | /100 V | 56.49 |
| 588 | battery SOC | % | 99 |
| 590 | battery power, + discharge / − charge | W, signed | −24 |
| 598–600 | grid voltage L1–L3 | /10 V | 233.9 / 233.4 / 231.9 |
| 622–624, 625 | grid power per phase, total; + import / − export | W, signed | 390 / 52 / 203, 645 |
| 650–653 | load power per phase, total | W | 364 / 26 / 180, 570 |
| 672, 673 | PV1, PV2 power | W | 0, 0 (night) |
| 514, 515 | today battery charge, discharge | /10 kWh | 0.5, 0.1 |
| 520, 521 | today grid bought, sold | /10 kWh | 16.8, 22.3 |
| 526 | today load | /10 kWh | 24.2 |
| 529 | today PV production | /10 kWh | 29.9 |
The battery is configured as **backup only** (power cuts), on purpose: day and night the house
runs on PV and the grid, so a full battery with ~0 W flow is the normal state.
Fronius Symo 3.7 (`192.168.0.143`, Solar API JSON) produced another 4.4 kWh that day.
Considered and rejected (2026-10-23): pointing the logger's second server ("Server B") at our
backend. Push frames are poorly documented binary data every ~5 min, need a TCP server with
acks, and a wrong setting could cut off the Solarman cloud my father uses. Local FC3 polling
gives documented registers at our own rate. Server B stays empty; fallback only.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 MiB

Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large. Load diff
Binary file not shown.
@@ -0,0 +1,193 @@
Mapa pamięci sterownika MR208-PC+
Komunikacja
MR208-PC+ posiada dwa porty komunikacyjne RS1 (RS232/RS485 wybierany na etapie
zamawiania) oraz RS2 (zawsze RS485).
Regulator obsługuje protokół MODBUS RTU o parametrach transmisji:
szybkość 9600b/s,
format 8N1 (znak 8 bitów, brak kontroli parzystości, 1 bit stopu).
Dostęp do rejestrów realizowany jest funkcjami:
3 - odczyt grupy rejestrów,
6 - zapis pojedynczego rejestru,
16 - zapis grupy rejestrów.
Mapa pamięci
Rejestry zapisane są w formacie moduł-znak jako liczby całkowite.
Oznaczenia w opisie:
X10 - wartość rejestru należy podzielić przez 10 aby otrzymać wartość parametru (np. wartość
rejestru =1 oznacza 0.1, wartość =10 oznacza 1.0, wartość =100 oznacza 10.0 itd. ).
X1 - wartość rejestru odpowiada wartości parametru,
Pomiary
Parametr Rejestr Zakres Opis
Tzew 4248 -300÷950 X10
Tzsr 4154 -300÷950 X10
Twew 4065 -300÷950 X10
ZdS 4243 -40÷40 X10
ZadTwew 4150 0÷350 X10
Tcwu_g 4069 -300÷950 X10
Tcwu_d 4068 -300÷950 X10
ZadTcwu 4121 0÷900 X10
Tpodl 4066 -300÷950 X10
Tbuf 4067 -300÷950 X10
ZadTbuf(podl) 4158 0÷900 X10
TzasPC 4072 -300÷950 X10
TweDZ 4071 -300÷950 X10
TwyDZ 4070 -300÷950 X10
TsrDZ 4197 -300÷950 X10
WePres 4073 0, 1 0-ROZWARTE, 1-ZWARTE
WeFerie 4236 0, 1 0-ROZWARTE, 1-ZWARTE
WeParty 4237 0, 1 0-ROZWARTE, 1-ZWARTE
Mapa pamięci sterownika MR208-PC+ 1/4
Zegar i tryb
Parametr Rejestr Zakres Opis
Godzina 4002 0÷23 X1
Minuta 4001 0÷59 X1
Dzien 4006 0÷6 0-Pn. 1-Wt,…,6-Ni
Tryb 4159 0÷2 0-Lato, 1-Zima, 2-Auto
TprogZ/L 4160 5÷35 X1
Nastawy CO
Parametr Rejestr Zakres Opis
TEko 4129 50÷350 X10
TKmf 4130 50÷350 X10
TFer 4225 50÷350 X10
Krzywa 4131 0÷15 X1
PrzesKrz 4238 -99÷999 X10
RegPokoj 4183 0, 1 0-NIE, 1-TAK
WspKor 4124 0÷9 X1
TmaxCO 4125 5÷90 X1
t_zwlBZC 4187 0÷999 X1
t_minGrz 4173 1÷99 X1
HistBUF 4163 1÷20 X1
PriorCWU 4170 0, 1 0-NIE, 1-TAK
AdrCzujnika 4244 11÷18 X1
ZdS 4245 0÷3 0-Eko, 1-Kmf, 2-Kmf+Eko, 3-Wyl
Nastawy CWU
Parametr Rejestr Zakres Opis
TEko 4155 5÷70 X1
TKmf 4156 5÷70 X1
TrybBZC 4169 0, 1 0-Eko, 1-Kmf
Dezynf. 4143 0, 1, 2 0-Wyl, 1-Zal, 2-Auto
Dez.Dni 4229 0÷20 X1
Dez.Czas 4228 0÷23 X1
HistCWU 4144 1÷20 X1
TgrzPC 4171 0÷99 X1
∆Eko 4168 0÷99 X1
t_wybPcw 4172 0÷999 X1
t_minGrz 4174 1÷99 X1
t_zalPccw 4179 0÷99 X1
t_wylPccw 4180 0÷99 X1
Mapa pamięci sterownika MR208-PC+ 2/4
Programy
Parametr Rejestr Zakres Opis
Obwod 4114 0÷3 0-CO1, 1-TAR, 2-CCW, 3-CWU
Dzien 4134 0÷6 0-Pn. 1-Wt,…,6-Ni - dla CO, CWU i CCW
0, 5 0-Pn-Pt, 5-So-Ni - dla TAR
Godz1 4135 0÷24 X1 – godzina początku 1 przedziału
Minuta1 4139 0÷59 X1 – minuta początku 1 przedziału
Godz2 4136 0÷24 X1 – godzina końca 1 przedziału
Minuta2 4140 0÷59 X1 – minuta końca 1 przedziału
Godz3 4137 0÷24 X1 – godzina początku 2 przedziału
Minuta3 4141 0÷59 X1 – minuta początku 2 przedziału
Godz4 4138 0÷24 X1 – godzina końca 2 przedziału
Minuta4 4142 0÷59 X1 – minuta końca 2 przedziału
Godz5 4230 0÷24 X1 – godzina początku 3 przedziału
Minuta5 4235 0÷59 X1 – minuta początku 3 przedziału
Godz6 4233 0÷24 X1 – godzina końca 3 przedziału
Minuta6 4234 0÷59 X1 – minuta końca 3 przedziału
Kopiuj do: 4133 0÷7 0-Pn. 1-Wt,…,6-Ni, 7-?
Procedura zapisu/odczytu programu dobowego jest następująca:
należy ustawić rejestry Obwod i Dzien,
odczekać 500ms,
dokonać zapisu/odczytu rejestrów Godz1...Minuta6.
Parametry
Parametr Rejestr Zakres Opis
Prg 4123 0, 1, 2 0-Ferie, 1-Tygodniowy, 2-Party
Dni 4191 0÷99 X1
Godzin 4227 0÷99 X1
Parametry PC
Parametr Rejestr Zakres Opis
TmaxPC 4162 0÷90 X1
TminDZ 4188 -20÷20 X1
∆TmaxDZ 4189 0÷99 X1
t_blokSPR 4175 0÷99 X1
t_zwlPC 4164 0÷999 X1
t_wybDZ 4165 0÷999 X1
Parametry TAR
Parametr Rejestr Zakres Opis
CzasTAR - godziny 4115 0÷3 X1
CzasTAR - minuty 4116 0÷59 X1
Tpodbicia 4120 0÷30 X1
BZC.CO 4201 0, 1 0-NIE, 1-TAK
BZC.CWU 4202 0, 1 0-NIE, 1-TAK
Mapa pamięci sterownika MR208-PC+ 3/4
Liczniki
Parametr Rejestr Zakres Opis
PC.CO 4203 0÷32000 X10
PC.CWU 4204 0÷32000 X10
PC 4205 0÷32000 X10
Konfiguracja
Parametr Rejestr Zakres Opis
Układ 4212 0÷5 0-U1, 1-U2, 2-U3, 3-U4, 4-U5, 5-U6,
CzujnikTwe1 4255 0, 1 0-NIE, 1-TAK
BufCW 4122 0, 1 0-1czujnik, 1-2czujniki
Sygnal 4242 0, 1 0-NIE, 1-TAK
Modbus 4241 0, 1 0-SLAVE, 1-MASTER
Adres 4192 0÷255 X1
SygnalKom 4249 0÷99 0-NIE, 1-LED, 2-BUZER
Diagnostyka
Parametr Rejestr Zakres Opis
Presostat 4186 0÷99 0-OK, 1-ERR
Przegrzew 4250 0÷999 X1
Przechlodz 4251 0÷999 X1
Stan wyjsc
Parametr Rejestr Zakres Opis
Pdz 4216 0, 100 0-WYL, 100-ZAL
Pcwu 4214 0, 100 0-WYL, 100-ZAL
Silow.ZR 4100 0, 100 0-CWU, 100-CO
BZC.CWU 4232 0, 100 0-WYL, 100-ZAL
BZC.CO 4231 0, 100 0-WYL, 100-ZAL
Pbuf 4100 0, 100 0-WYL, 100-ZAL
Pzas 4214 0, 100 0-WYL, 100-ZAL
Pco 4215 0, 100 0-WYL, 100-ZAL
Sprez 4101 0, 100 0-WYL, 100-ZAL
Pccw 4213 0, 100 0-WYL, 100-ZAL
Alarm 4099 0, 100 0-WYL, 100-ZAL
Mapa pamięci sterownika MR208-PC+ 4/4
+16
View File
@@ -0,0 +1,16 @@
# Frisko sources
Raw vendor material, kept as found. Read a file only when a task needs it; verified knowledge
goes to `docs/frisko/`. `.txt` files are `pdftotext -layout` extracts of the PDF with the same name.
| File | What | Source |
|---|---|---|
| `RX910-SE21-AdresyMODBUS.pdf` | **Our controller's register map** (Frisko, 2017-04-10). Addresses only, no scales | from the manufacturer, years ago |
| `RX910-SE21_v24062015.xml` | Phone-app screen definition for **our firmware**: names, formats, min/max, edit flags | `http://frisko.pl/XML4MobileApk/` |
| `RX910-SE21_v10112017.xml` | Same for the newer firmware (CO setpoints 4131–4133 in tenths) | same |
| `MR208-PC+_v6_AdresyMODBUS.pdf` | Register map of the current Frisko heat-pump controller: protocol, X10/X1 scales, sign-magnitude format | frisko24.pl product page |
| `MR208-PC+_9P_v3.pdf` | MR208-PC+ manual (2025): closest available description of heat-pump control logic | frisko24.pl product page |
| `settings-backup-2026-10-23.json` | Our controller's 14 app-editable settings, read just before the first write test |
| `FriskoOnline2025.apk` | Frisko phone app 1.4 (`com.frisko.frisko_mobile`), direct Modbus TCP | `http://frisko.pl/XML4MobileApk/` |
Retrieved 2026-10-23. Decompile the APK with `jadx` when needed; the decompiled code is not kept.
Binary file not shown.
+175
View File
@@ -0,0 +1,175 @@
Mapa pamięci sterownika RX910-SE21
Główny
Parametr Rejestr Opis
DzienTyg 4006
Godziny 4002
Minuty 4001
TrybLZ 4121 0 – Lato,
1 – Zimo.
TrypRA 4122 0 – R,
1 – A,
2–*
StanCO/CW 4227 0 – pc STOP,
1 – pcCO,
2 – pcCW,
3 – pcCO/CW.
StanZB 4228 0–
2 – zbCW,
Instalacja 4221 0 – Instal sprawna,
1 – Awaria instal.
Status 4226 0 – Ferie,
1 – Tygodn,
2 – Party,
3 – ROZRUCH,
4 – STOP,
5 – Dezynf.
Menu
Parametr Rejestr Opis
Tryb 4125 0 - STOP, 1 - PRACA
Uklad 4126
Temperatury
Parametr Rejestr Opis
Tzew 4065
TdzWe 4070
TdzWy 4071
TpcWy 4072
Tco 4067
ZadTco 4202
Tcwu 4069
ZadTcwu 4203
Twew 4066
ZadTwew 4201
Tzsr 4199
Presostat 4073 0 – OK, 1 - Err
Nastawy CO
Parametr Rejestr Opis
T.Eko 4131
T.Kmf 4132
T.Fer 4133
Krzywa 4134
Prg 4140 0-Ferie, 1-Tygodniowy, 2-Party
Ferie.Dni 4141
Party.Godzin 4142
T.Eko_CH 4244
T.Kmf_CH 4245
Nastawy CWU
Parametr Rejestr Opis
T.Eko 4143
T.Kmf 4144
Dezynf 4145 0-WYL, -ZAL, 2-AUTO
Programy
Parametr Rejestr Opis
Program 4177 0-CO, 1-CWU, 2-CCW, 3-TAR
Dzien 4167 0-Pn. 1-Wt,…,6-Ni
Copy 4168 0-Pn. 1-Wt,…,6-Ni, 7-?
Godz1 4169
Godz2 4170
Godz3 4171
Godz4 4172
Minuty1 4173
Minuty2 4174
Minuty3 4175
Minuty4 4176
Aby wyświetlić albo edytować dany program należy najpierw wybrać Program oraz Dzien.
Zegar i tryb
Parametr Rejestr Opis
Godzina 4002
Minuta 4001
Dzien 4006 0-Pn. 1-Wt,…,6-Ni
Tryb 4123 0-Lato, 1-Zima, 2-Auto
TprogZ/L 4124
Liczniki
Parametr Rejestr Opis
Czas pracy PC 4190 MM - minuty
tylko dla CO 4191 LLLL - młodsza część
(HHLLLL:MM) 4192 HH - starsza część
Czas pracy PC 4193 MM - minuty
tylko dla CWU 4194 LLLL - młodsza część
(HHLLLL:MM) 4195 HH - starsza część
Czas pracy PC 4196 MM - minuty
dla CO i CWU 4197 LLLL - młodsza część
(HHLLLL:MM) 4198 HH - starsza część
Parametry CO
Parametr Rejestr Opis
TmaxCO 4135
RegPok 4136 0- NIE, 1-TAK
WspKor 4137
TpodbCO 4138
t_podbCO 4139
Parametry CWU
Parametr Rejestr Opis
PriorCWU 4146 0-WYL, 1-ZAL
t_zwlGrz 4147
t_zalCCW 4155
t_wylCCW 4156
HistCWU 4148
HistGrz 4246
TmaxCWU 4149
TpodbCWU 4150
t_podbCWU 4151
Dez.Dni 4152
DezTemp 4153
Dez.t_max 4154
Parametry
Parametr Rejestr Opis
TminDz 4157
TmaxPc 4158
∆TmaxDz 4159
t_zwlZalSpr 4160
t_zwlWylPdz 4161
t_wylSpr 4162
t_minSpr 4163
t_maxSpr 4164
zwlZSt(..) 4165
zwlZSt(II) 4166
zwlWst(..) 4247
zwlWst(II) 4248
Konfiguracja
Parametr Rejestr Opis
Sygnal 4127 0-NIE, 1-TAK
Adres 4128
HasloLAN 4129
Stan wyjsc
Parametr Rejestr Opis
Pompa Dz 4117 0-WYL, 100-ZAL
Pompa BUF 4113 0-WYL, 100-ZAL
Pompa CO 4112 0-WYL, 100-ZAL
Pompa CWU 4118 0-WYL, 100-ZAL
Pompa CCW 4116 0-WYL, 100-ZAL
Sprezarka 4114 0-WYL, 33-1st, 66-2st, 100-3st
GrzalkaCWU 4115 0-WYL, 100-ZAL
WyCHL 4119 0-WYL, 100-ZAL
Kalibracja
Parametr Rejestr Opis
Tzew 4084
TdWe 4089
TdWy 4090
TpWy 4091
Tco 4086
Tcwu 4088
Twew 4085
+633
View File
@@ -0,0 +1,633 @@
<?xml version="1.0" encoding="utf-8"?>
<sterownik>
<ster_nazwa>RX910-SE21_v10112017</ster_nazwa>
<ster_format>MZ</ster_format>
<menu>
<menu_nazwa>Status</menu_nazwa>
<menu_icon>status.png</menu_icon>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Dzień</param_nazwa>
<param_adres>4006</param_adres>
<param_edycja>false</param_edycja>
<param_icon>dt_pn.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>dt_wt.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>dt_sr.png</param_icon>
<param_val>2</param_val>
<param_text></param_text>
<param_icon>dt_cz.png</param_icon>
<param_val>3</param_val>
<param_text></param_text>
<param_icon>dt_pt.png</param_icon>
<param_val>4</param_val>
<param_text></param_text>
<param_icon>dt_so.png</param_icon>
<param_val>5</param_val>
<param_text></param_text>
<param_icon>dt_ni.png</param_icon>
<param_val>6</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>time</param_typ>
<param_nazwa>Czas</param_nazwa>
<param_adres_godz>4002</param_adres_godz>
<param_adres_min>4001</param_adres_min>
<param_edycja>false</param_edycja>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Tryb</param_nazwa>
<param_adres>4121</param_adres>
<param_edycja>false</param_edycja>
<param_icon>tryb_lato.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>tryb_zima.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Program pracy</param_nazwa>
<param_adres>4226</param_adres>
<param_edycja>false</param_edycja>
<param_icon>program_ferie.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>program_tygodniowy.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>program_party.png</param_icon>
<param_val>2</param_val>
<param_text></param_text>
<param_icon>praca.png</param_icon>
<param_val>3</param_val>
<param_text></param_text>
<param_icon>stop.png</param_icon>
<param_val>4</param_val>
<param_text></param_text>
<param_icon>dezynf.png</param_icon>
<param_val>5</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>list</param_typ>
<param_nazwa>Status</param_nazwa>
<param_adres>4011</param_adres>
<param_edycja>false</param_edycja>
<param_val>0</param_val>
<param_text>OK</param_text>
<param_val>2</param_val>
<param_text>Err_Tzew</param_text>
<param_val>3</param_val>
<param_text>Err_Tco</param_text>
<param_val>4</param_val>
<param_text>Err_Twew</param_text>
<param_val>5</param_val>
<param_text>Err_Tcwu</param_text>
<param_val>14</param_val>
<param_text>Err_Tpc</param_text>
<param_val>15</param_val>
<param_text>Err_Tdz</param_text>
<param_val>1003</param_val>
<param_text>Err_Presostat</param_text>
</parametry>
</menu>
<menu>
<menu_nazwa>Temperatury</menu_nazwa>
<menu_icon>temperatura.png</menu_icon>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp zewnętrzna</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4065</param_adres>
<param_edycja>false</param_edycja>
<param_format>XXX.X</param_format>
<param_min>-300</param_min>
<param_max>950</param_max>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Śr temp zewnętrzna</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4199</param_adres>
<param_edycja>false</param_edycja>
<param_format>XXX.X</param_format>
<param_min>-300</param_min>
<param_max>950</param_max>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp wej. DZ</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4070</param_adres>
<param_edycja>false</param_edycja>
<param_format>XXX.X</param_format>
<param_min>-300</param_min>
<param_max>950</param_max>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp wyj. DZ</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4071</param_adres>
<param_edycja>false</param_edycja>
<param_format>XXX.X</param_format>
<param_min>-300</param_min>
<param_max>950</param_max>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp wyj. PC</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4072</param_adres>
<param_edycja>false</param_edycja>
<param_format>XXX.X</param_format>
<param_min>-300</param_min>
<param_max>950</param_max>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp wewnętrzna CO</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4066</param_adres>
<param_edycja>false</param_edycja>
<param_format>XXX.X</param_format>
<param_min>-300</param_min>
<param_max>950</param_max>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp CO</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4067</param_adres>
<param_edycja>false</param_edycja>
<param_format>XXX.X</param_format>
<param_min>-300</param_min>
<param_max>950</param_max>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp CWU</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4069</param_adres>
<param_edycja>false</param_edycja>
<param_format>XXX.X</param_format>
<param_min>-300</param_min>
<param_max>950</param_max>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Presostat</param_nazwa>
<param_adres>4073</param_adres>
<param_edycja>false</param_edycja>
<param_icon>stan_ok.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>stan_awaria.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
</parametry>
</menu>
<menu>
<menu_nazwa>Zegar i tryb</menu_nazwa>
<menu_icon>zegar_tryb.png</menu_icon>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Tryb pracy</param_nazwa>
<param_adres>4125</param_adres>
<param_edycja>true</param_edycja>
<param_icon>stop.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>praca.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>time</param_typ>
<param_nazwa>Czas</param_nazwa>
<param_adres_godz>4002</param_adres_godz>
<param_adres_min>4001</param_adres_min>
<param_edycja>true</param_edycja>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Dzień</param_nazwa>
<param_adres>4006</param_adres>
<param_edycja>true</param_edycja>
<param_icon>dt_pn.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>dt_wt.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>dt_sr.png</param_icon>
<param_val>2</param_val>
<param_text></param_text>
<param_icon>dt_cz.png</param_icon>
<param_val>3</param_val>
<param_text></param_text>
<param_icon>dt_pt.png</param_icon>
<param_val>4</param_val>
<param_text></param_text>
<param_icon>dt_so.png</param_icon>
<param_val>5</param_val>
<param_text></param_text>
<param_icon>dt_ni.png</param_icon>
<param_val>6</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Tryb</param_nazwa>
<param_adres>4123</param_adres>
<param_edycja>true</param_edycja>
<param_icon>tryb_lato.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>tryb_zima.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>tryb_auto.png</param_icon>
<param_val>2</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp progu Z/L</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4124</param_adres>
<param_edycja>true</param_edycja>
<param_format>XX</param_format>
<param_min>5</param_min>
<param_max>35</param_max>
</parametry>
</menu>
<menu>
<menu_nazwa>Program</menu_nazwa>
<menu_icon>program.png</menu_icon>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Obwód</param_nazwa>
<param_adres>4177</param_adres>
<param_edycja>true</param_edycja>
<param_icon>to_co1.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>to_cwu.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>to_ccw.png</param_icon>
<param_val>2</param_val>
<param_text></param_text>
<param_icon>to_taryfa_pradu.png</param_icon>
<param_val>3</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Dzień</param_nazwa>
<param_adres>4167</param_adres>
<param_edycja>true</param_edycja>
<param_icon>dt_pn.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>dt_wt.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>dt_sr.png</param_icon>
<param_val>2</param_val>
<param_text></param_text>
<param_icon>dt_cz.png</param_icon>
<param_val>3</param_val>
<param_text></param_text>
<param_icon>dt_pt.png</param_icon>
<param_val>4</param_val>
<param_text></param_text>
<param_icon>dt_so.png</param_icon>
<param_val>5</param_val>
<param_text></param_text>
<param_icon>dt_ni.png</param_icon>
<param_val>6</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>prg</param_typ>
<param_nazwa>Przedział 1</param_nazwa>
<param_edycja>true</param_edycja>
<param_adres_godz1>4169</param_adres_godz1>
<param_adres_min1>4173</param_adres_min1>
<param_adres_godz2>4170</param_adres_godz2>
<param_adres_min2>4174</param_adres_min2>
</parametry>
<parametry>
<param_typ>prg</param_typ>
<param_nazwa>Przedział 2</param_nazwa>
<param_edycja>true</param_edycja>
<param_adres_godz1>4171</param_adres_godz1>
<param_adres_min1>4175</param_adres_min1>
<param_adres_godz2>4172</param_adres_godz2>
<param_adres_min2>4176</param_adres_min2>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Kopiuj do</param_nazwa>
<param_adres>4168</param_adres>
<param_edycja>true</param_edycja>
<param_icon>dt_pn.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>dt_wt.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>dt_sr.png</param_icon>
<param_val>2</param_val>
<param_text></param_text>
<param_icon>dt_cz.png</param_icon>
<param_val>3</param_val>
<param_text></param_text>
<param_icon>dt_pt.png</param_icon>
<param_val>4</param_val>
<param_text></param_text>
<param_icon>dt_so.png</param_icon>
<param_val>5</param_val>
<param_text></param_text>
<param_icon>dt_ni.png</param_icon>
<param_val>6</param_val>
<param_text></param_text>
<param_icon>dt_brak.png</param_icon>
<param_val>7</param_val>
<param_text></param_text>
</parametry>
</menu>
<menu>
<menu_nazwa>Nastawy CO</menu_nazwa>
<menu_icon>nastawy_co1.png</menu_icon>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp ekonomiczna</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4131</param_adres>
<param_edycja>true</param_edycja>
<param_format>XX.X</param_format>
<param_min>50</param_min>
<param_max>350</param_max>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp komfortowa</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4132</param_adres>
<param_edycja>true</param_edycja>
<param_format>XX.X</param_format>
<param_min>50</param_min>
<param_max>350</param_max>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp ferie</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4133</param_adres>
<param_edycja>true</param_edycja>
<param_format>XX.X</param_format>
<param_min>50</param_min>
<param_max>350</param_max>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Krzywa</param_nazwa>
<param_adres>4134</param_adres>
<param_jedn></param_jedn>
<param_edycja>true</param_edycja>
<param_format>XX</param_format>
<param_min>0</param_min>
<param_max>15</param_max>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Program pracy</param_nazwa>
<param_adres>4140</param_adres>
<param_edycja>true</param_edycja>
<param_icon>program_ferie.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>program_tygodniowy.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>program_party.png</param_icon>
<param_val>2</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Ferie dni</param_nazwa>
<param_adres>4141</param_adres>
<param_jedn></param_jedn>
<param_edycja>true</param_edycja>
<param_format>XX</param_format>
<param_min>0</param_min>
<param_max>90</param_max>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Party godziny</param_nazwa>
<param_adres>4142</param_adres>
<param_jedn></param_jedn>
<param_edycja>true</param_edycja>
<param_format>XX</param_format>
<param_min>0</param_min>
<param_max>90</param_max>
</parametry>
</menu>
<menu>
<menu_nazwa>Nastawy CWU</menu_nazwa>
<menu_icon>nastawy_cwu.png</menu_icon>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp ekonomiczna</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4143</param_adres>
<param_edycja>true</param_edycja>
<param_format>XX</param_format>
<param_min>5</param_min>
<param_max>70</param_max>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp komfortowa</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4144</param_adres>
<param_edycja>true</param_edycja>
<param_format>XX</param_format>
<param_min>5</param_min>
<param_max>70</param_max>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Dezynfekcja</param_nazwa>
<param_adres>4145</param_adres>
<param_edycja>true</param_edycja>
<param_icon>fun_wyl.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>fun_zal.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>ctr_auto.png</param_icon>
<param_val>2</param_val>
<param_text></param_text>
</parametry>
</menu>
<menu>
<menu_nazwa>Liczniki</menu_nazwa>
<menu_icon>liczniki.png</menu_icon>
<parametry>
<param_typ>big_val</param_typ>
<param_nazwa>Czas pracy na CO</param_nazwa>
<param_jedn></param_jedn>
<param_adres_1>4192</param_adres_1>
<param_adres_2>4191</param_adres_2>
<param_adres_3>4190</param_adres_3>
<param_min>0</param_min>
<param_max>99999959</param_max>
</parametry>
<parametry>
<param_typ>big_val</param_typ>
<param_nazwa>Czas pracy na CWU</param_nazwa>
<param_jedn></param_jedn>
<param_adres_1>4195</param_adres_1>
<param_adres_2>4194</param_adres_2>
<param_adres_3>4193</param_adres_3>
<param_min>0</param_min>
<param_max>99999959</param_max>
</parametry>
<parametry>
<param_typ>big_val</param_typ>
<param_nazwa>Czas pracy na CO i CWU</param_nazwa>
<param_jedn></param_jedn>
<param_adres_1>4198</param_adres_1>
<param_adres_2>4197</param_adres_2>
<param_adres_3>4196</param_adres_3>
<param_min>0</param_min>
<param_max>99999959</param_max>
</parametry>
</menu>
<menu>
<menu_nazwa>Stan wyjść</menu_nazwa>
<menu_icon>stan_wyjsc.png</menu_icon>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Pompa DZ</param_nazwa>
<param_adres>4117</param_adres>
<param_edycja>false</param_edycja>
<param_icon>wyl.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>zal.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>zal.png</param_icon>
<param_val>100</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Pompa BUf</param_nazwa>
<param_adres>4113</param_adres>
<param_edycja>false</param_edycja>
<param_icon>wyl.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>zal.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>zal.png</param_icon>
<param_val>100</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Pompa CO</param_nazwa>
<param_adres>4112</param_adres>
<param_edycja>false</param_edycja>
<param_icon>wyl.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>zal.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>zal.png</param_icon>
<param_val>100</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Pompa CWU</param_nazwa>
<param_adres>4118</param_adres>
<param_edycja>false</param_edycja>
<param_icon>wyl.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>zal.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>zal.png</param_icon>
<param_val>100</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Pompa CCW</param_nazwa>
<param_adres>4116</param_adres>
<param_edycja>false</param_edycja>
<param_icon>wyl.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>zal.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>zal.png</param_icon>
<param_val>100</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Sprężarka</param_nazwa>
<param_adres>4114</param_adres>
<param_edycja>false</param_edycja>
<param_icon>wyl.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>zal_1st.png</param_icon>
<param_val>33</param_val>
<param_text></param_text>
<param_icon>zal_2st.png</param_icon>
<param_val>66</param_val>
<param_text></param_text>
<param_icon>zal_3st.png</param_icon>
<param_val>100</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Grzałka CWU</param_nazwa>
<param_adres>4115</param_adres>
<param_edycja>false</param_edycja>
<param_icon>wyl.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>zal.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>zal.png</param_icon>
<param_val>100</param_val>
<param_text></param_text>
</parametry>
</menu>
</sterownik>
+633
View File
@@ -0,0 +1,633 @@
<?xml version="1.0" encoding="utf-8"?>
<sterownik>
<ster_nazwa>RX910-SE21_v24062015</ster_nazwa>
<ster_format>MZ</ster_format>
<menu>
<menu_nazwa>Status</menu_nazwa>
<menu_icon>status.png</menu_icon>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Dzień</param_nazwa>
<param_adres>4006</param_adres>
<param_edycja>false</param_edycja>
<param_icon>dt_pn.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>dt_wt.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>dt_sr.png</param_icon>
<param_val>2</param_val>
<param_text></param_text>
<param_icon>dt_cz.png</param_icon>
<param_val>3</param_val>
<param_text></param_text>
<param_icon>dt_pt.png</param_icon>
<param_val>4</param_val>
<param_text></param_text>
<param_icon>dt_so.png</param_icon>
<param_val>5</param_val>
<param_text></param_text>
<param_icon>dt_ni.png</param_icon>
<param_val>6</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>time</param_typ>
<param_nazwa>Czas</param_nazwa>
<param_adres_godz>4002</param_adres_godz>
<param_adres_min>4001</param_adres_min>
<param_edycja>false</param_edycja>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Tryb</param_nazwa>
<param_adres>4121</param_adres>
<param_edycja>false</param_edycja>
<param_icon>tryb_lato.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>tryb_zima.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Program pracy</param_nazwa>
<param_adres>4226</param_adres>
<param_edycja>false</param_edycja>
<param_icon>program_ferie.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>program_tygodniowy.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>program_party.png</param_icon>
<param_val>2</param_val>
<param_text></param_text>
<param_icon>praca.png</param_icon>
<param_val>3</param_val>
<param_text></param_text>
<param_icon>stop.png</param_icon>
<param_val>4</param_val>
<param_text></param_text>
<param_icon>dezynf.png</param_icon>
<param_val>5</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>list</param_typ>
<param_nazwa>Status</param_nazwa>
<param_adres>4011</param_adres>
<param_edycja>false</param_edycja>
<param_val>0</param_val>
<param_text>OK</param_text>
<param_val>2</param_val>
<param_text>Err_Tzew</param_text>
<param_val>3</param_val>
<param_text>Err_Tco</param_text>
<param_val>4</param_val>
<param_text>Err_Twew</param_text>
<param_val>5</param_val>
<param_text>Err_Tcwu</param_text>
<param_val>14</param_val>
<param_text>Err_Tpc</param_text>
<param_val>15</param_val>
<param_text>Err_Tdz</param_text>
<param_val>1003</param_val>
<param_text>Err_Presostat</param_text>
</parametry>
</menu>
<menu>
<menu_nazwa>Temperatury</menu_nazwa>
<menu_icon>temperatura.png</menu_icon>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp zewnętrzna</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4065</param_adres>
<param_edycja>false</param_edycja>
<param_format>XXX.X</param_format>
<param_min>-300</param_min>
<param_max>950</param_max>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Śr temp zewnętrzna</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4199</param_adres>
<param_edycja>false</param_edycja>
<param_format>XXX.X</param_format>
<param_min>-300</param_min>
<param_max>950</param_max>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp wej. DZ</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4070</param_adres>
<param_edycja>false</param_edycja>
<param_format>XXX.X</param_format>
<param_min>-300</param_min>
<param_max>950</param_max>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp wyj. DZ</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4071</param_adres>
<param_edycja>false</param_edycja>
<param_format>XXX.X</param_format>
<param_min>-300</param_min>
<param_max>950</param_max>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp wyj. PC</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4072</param_adres>
<param_edycja>false</param_edycja>
<param_format>XXX.X</param_format>
<param_min>-300</param_min>
<param_max>950</param_max>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp wewnętrzna CO</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4066</param_adres>
<param_edycja>false</param_edycja>
<param_format>XXX.X</param_format>
<param_min>-300</param_min>
<param_max>950</param_max>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp CO</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4067</param_adres>
<param_edycja>false</param_edycja>
<param_format>XXX.X</param_format>
<param_min>-300</param_min>
<param_max>950</param_max>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp CWU</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4069</param_adres>
<param_edycja>false</param_edycja>
<param_format>XXX.X</param_format>
<param_min>-300</param_min>
<param_max>950</param_max>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Presostat</param_nazwa>
<param_adres>4073</param_adres>
<param_edycja>false</param_edycja>
<param_icon>stan_ok.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>stan_awaria.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
</parametry>
</menu>
<menu>
<menu_nazwa>Zegar i tryb</menu_nazwa>
<menu_icon>zegar_tryb.png</menu_icon>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Tryb pracy</param_nazwa>
<param_adres>4125</param_adres>
<param_edycja>true</param_edycja>
<param_icon>stop.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>praca.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>time</param_typ>
<param_nazwa>Czas</param_nazwa>
<param_adres_godz>4002</param_adres_godz>
<param_adres_min>4001</param_adres_min>
<param_edycja>true</param_edycja>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Dzień</param_nazwa>
<param_adres>4006</param_adres>
<param_edycja>true</param_edycja>
<param_icon>dt_pn.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>dt_wt.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>dt_sr.png</param_icon>
<param_val>2</param_val>
<param_text></param_text>
<param_icon>dt_cz.png</param_icon>
<param_val>3</param_val>
<param_text></param_text>
<param_icon>dt_pt.png</param_icon>
<param_val>4</param_val>
<param_text></param_text>
<param_icon>dt_so.png</param_icon>
<param_val>5</param_val>
<param_text></param_text>
<param_icon>dt_ni.png</param_icon>
<param_val>6</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Tryb</param_nazwa>
<param_adres>4123</param_adres>
<param_edycja>true</param_edycja>
<param_icon>tryb_lato.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>tryb_zima.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>tryb_auto.png</param_icon>
<param_val>2</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp progu Z/L</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4124</param_adres>
<param_edycja>true</param_edycja>
<param_format>XX</param_format>
<param_min>5</param_min>
<param_max>35</param_max>
</parametry>
</menu>
<menu>
<menu_nazwa>Program</menu_nazwa>
<menu_icon>program.png</menu_icon>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Obwód</param_nazwa>
<param_adres>4177</param_adres>
<param_edycja>true</param_edycja>
<param_icon>to_co1.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>to_cwu.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>to_ccw.png</param_icon>
<param_val>2</param_val>
<param_text></param_text>
<param_icon>to_taryfa_pradu.png</param_icon>
<param_val>3</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Dzień</param_nazwa>
<param_adres>4167</param_adres>
<param_edycja>true</param_edycja>
<param_icon>dt_pn.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>dt_wt.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>dt_sr.png</param_icon>
<param_val>2</param_val>
<param_text></param_text>
<param_icon>dt_cz.png</param_icon>
<param_val>3</param_val>
<param_text></param_text>
<param_icon>dt_pt.png</param_icon>
<param_val>4</param_val>
<param_text></param_text>
<param_icon>dt_so.png</param_icon>
<param_val>5</param_val>
<param_text></param_text>
<param_icon>dt_ni.png</param_icon>
<param_val>6</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>prg</param_typ>
<param_nazwa>Przedział 1</param_nazwa>
<param_edycja>true</param_edycja>
<param_adres_godz1>4169</param_adres_godz1>
<param_adres_min1>4173</param_adres_min1>
<param_adres_godz2>4170</param_adres_godz2>
<param_adres_min2>4174</param_adres_min2>
</parametry>
<parametry>
<param_typ>prg</param_typ>
<param_nazwa>Przedział 2</param_nazwa>
<param_edycja>true</param_edycja>
<param_adres_godz1>4171</param_adres_godz1>
<param_adres_min1>4175</param_adres_min1>
<param_adres_godz2>4172</param_adres_godz2>
<param_adres_min2>4176</param_adres_min2>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Kopiuj do</param_nazwa>
<param_adres>4168</param_adres>
<param_edycja>true</param_edycja>
<param_icon>dt_pn.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>dt_wt.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>dt_sr.png</param_icon>
<param_val>2</param_val>
<param_text></param_text>
<param_icon>dt_cz.png</param_icon>
<param_val>3</param_val>
<param_text></param_text>
<param_icon>dt_pt.png</param_icon>
<param_val>4</param_val>
<param_text></param_text>
<param_icon>dt_so.png</param_icon>
<param_val>5</param_val>
<param_text></param_text>
<param_icon>dt_ni.png</param_icon>
<param_val>6</param_val>
<param_text></param_text>
<param_icon>dt_brak.png</param_icon>
<param_val>7</param_val>
<param_text></param_text>
</parametry>
</menu>
<menu>
<menu_nazwa>Nastawy CO</menu_nazwa>
<menu_icon>nastawy_co1.png</menu_icon>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp ekonomiczna</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4131</param_adres>
<param_edycja>true</param_edycja>
<param_format>XX</param_format>
<param_min>5</param_min>
<param_max>35</param_max>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp komfortowa</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4132</param_adres>
<param_edycja>true</param_edycja>
<param_format>XX</param_format>
<param_min>5</param_min>
<param_max>35</param_max>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp ferie</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4133</param_adres>
<param_edycja>true</param_edycja>
<param_format>XX</param_format>
<param_min>5</param_min>
<param_max>35</param_max>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Krzywa</param_nazwa>
<param_adres>4134</param_adres>
<param_jedn></param_jedn>
<param_edycja>true</param_edycja>
<param_format>XX</param_format>
<param_min>0</param_min>
<param_max>15</param_max>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Program pracy</param_nazwa>
<param_adres>4140</param_adres>
<param_edycja>true</param_edycja>
<param_icon>program_ferie.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>program_tygodniowy.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>program_party.png</param_icon>
<param_val>2</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Ferie dni</param_nazwa>
<param_adres>4141</param_adres>
<param_jedn></param_jedn>
<param_edycja>true</param_edycja>
<param_format>XX</param_format>
<param_min>0</param_min>
<param_max>90</param_max>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Party godziny</param_nazwa>
<param_adres>4142</param_adres>
<param_jedn></param_jedn>
<param_edycja>true</param_edycja>
<param_format>XX</param_format>
<param_min>0</param_min>
<param_max>90</param_max>
</parametry>
</menu>
<menu>
<menu_nazwa>Nastawy CWU</menu_nazwa>
<menu_icon>nastawy_cwu.png</menu_icon>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp ekonomiczna</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4143</param_adres>
<param_edycja>true</param_edycja>
<param_format>XX</param_format>
<param_min>5</param_min>
<param_max>70</param_max>
</parametry>
<parametry>
<param_typ>val</param_typ>
<param_nazwa>Temp komfortowa</param_nazwa>
<param_jedn>°C</param_jedn>
<param_adres>4144</param_adres>
<param_edycja>true</param_edycja>
<param_format>XX</param_format>
<param_min>5</param_min>
<param_max>70</param_max>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Dezynfekcja</param_nazwa>
<param_adres>4145</param_adres>
<param_edycja>true</param_edycja>
<param_icon>fun_wyl.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>fun_zal.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>ctr_auto.png</param_icon>
<param_val>2</param_val>
<param_text></param_text>
</parametry>
</menu>
<menu>
<menu_nazwa>Liczniki</menu_nazwa>
<menu_icon>liczniki.png</menu_icon>
<parametry>
<param_typ>big_val</param_typ>
<param_nazwa>Czas pracy na CO</param_nazwa>
<param_jedn></param_jedn>
<param_adres_1>4192</param_adres_1>
<param_adres_2>4191</param_adres_2>
<param_adres_3>4190</param_adres_3>
<param_min>0</param_min>
<param_max>99999959</param_max>
</parametry>
<parametry>
<param_typ>big_val</param_typ>
<param_nazwa>Czas pracy na CWU</param_nazwa>
<param_jedn></param_jedn>
<param_adres_1>4195</param_adres_1>
<param_adres_2>4194</param_adres_2>
<param_adres_3>4193</param_adres_3>
<param_min>0</param_min>
<param_max>99999959</param_max>
</parametry>
<parametry>
<param_typ>big_val</param_typ>
<param_nazwa>Czas pracy na CO i CWU</param_nazwa>
<param_jedn></param_jedn>
<param_adres_1>4198</param_adres_1>
<param_adres_2>4197</param_adres_2>
<param_adres_3>4196</param_adres_3>
<param_min>0</param_min>
<param_max>99999959</param_max>
</parametry>
</menu>
<menu>
<menu_nazwa>Stan wyjść</menu_nazwa>
<menu_icon>stan_wyjsc.png</menu_icon>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Pompa DZ</param_nazwa>
<param_adres>4117</param_adres>
<param_edycja>false</param_edycja>
<param_icon>wyl.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>zal.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>zal.png</param_icon>
<param_val>100</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Pompa BUf</param_nazwa>
<param_adres>4113</param_adres>
<param_edycja>false</param_edycja>
<param_icon>wyl.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>zal.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>zal.png</param_icon>
<param_val>100</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Pompa CO</param_nazwa>
<param_adres>4112</param_adres>
<param_edycja>false</param_edycja>
<param_icon>wyl.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>zal.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>zal.png</param_icon>
<param_val>100</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Pompa CWU</param_nazwa>
<param_adres>4118</param_adres>
<param_edycja>false</param_edycja>
<param_icon>wyl.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>zal.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>zal.png</param_icon>
<param_val>100</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Pompa CCW</param_nazwa>
<param_adres>4116</param_adres>
<param_edycja>false</param_edycja>
<param_icon>wyl.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>zal.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>zal.png</param_icon>
<param_val>100</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Sprężarka</param_nazwa>
<param_adres>4114</param_adres>
<param_edycja>false</param_edycja>
<param_icon>wyl.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>zal_1st.png</param_icon>
<param_val>33</param_val>
<param_text></param_text>
<param_icon>zal_2st.png</param_icon>
<param_val>66</param_val>
<param_text></param_text>
<param_icon>zal_3st.png</param_icon>
<param_val>100</param_val>
<param_text></param_text>
</parametry>
<parametry>
<param_typ>list_ico</param_typ>
<param_nazwa>Grzałka CWU</param_nazwa>
<param_adres>4115</param_adres>
<param_edycja>false</param_edycja>
<param_icon>wyl.png</param_icon>
<param_val>0</param_val>
<param_text></param_text>
<param_icon>zal.png</param_icon>
<param_val>1</param_val>
<param_text></param_text>
<param_icon>zal.png</param_icon>
<param_val>100</param_val>
<param_text></param_text>
</parametry>
</menu>
</sterownik>
@@ -0,0 +1,61 @@
{
"taken": "2026-10-23T23:23:03+0200",
"registers": {
"4006": {
"name": "Dzień",
"value": 4
},
"4123": {
"name": "Tryb",
"value": 2
},
"4124": {
"name": "Temp progu Z/L",
"value": 18
},
"4125": {
"name": "Tryb pracy",
"value": 1
},
"4131": {
"name": "Temp ekonomiczna",
"value": 18
},
"4132": {
"name": "Temp komfortowa",
"value": 19
},
"4133": {
"name": "Temp ferie",
"value": 15
},
"4134": {
"name": "Krzywa",
"value": 6
},
"4140": {
"name": "Program pracy",
"value": 1
},
"4141": {
"name": "Ferie dni",
"value": 0
},
"4142": {
"name": "Party godziny",
"value": 0
},
"4143": {
"name": "Temp ekonomiczna",
"value": 8
},
"4144": {
"name": "Temp komfortowa",
"value": 43
},
"4145": {
"name": "Dezynfekcja",
"value": 0
}
}
}
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff. Show more