Compare commits

..
35 Commits
Author SHA1 Message Date
root e6d9487f0c Merge PR #95: fix: refresh cooldown counts pending requests, button beside the title
CI / Check (gateway, backend, frontend) (push) Successful in 51s
Release / Image (backend) (push) Successful in 33s
Release / Image (frontend) (push) Successful in 48s
Release / Image (gateway) (push) Successful in 1m15s
2026-10-24 19:10:07 +02:00
rootandClaude Opus 5.5 64ffd035c5 feat(frontend): Odśwież button beside every screen title, with its text
CI / Check (gateway, backend, frontend) (push) Successful in 57s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:08:15 +02:00
rootandClaude Opus 5.5 87d111398c fix(gateway): refresh cooldown counts from the last refresh request
A second refresh within the repoll delay reported the device as requested again
although the poll was coalesced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:08:15 +02:00
root 24a141a377 Merge PR #94: feat: Odśwież button with a 60 s per-device cooldown
CI / Check (gateway, backend, frontend) (push) Successful in 53s
Release / Image (backend) (push) Successful in 1m42s
Release / Image (frontend) (push) Successful in 1m10s
Release / Image (gateway) (push) Successful in 1m50s
2026-10-24 18:56:22 +02:00
root a1d6a5c8a6 Merge PR #93: chore: parallel builds in check.sh and releases, release waits for green CI 2026-10-24 18:56:22 +02:00
rootandClaude Opus 5.5 d1f053079a chore(ci): one job again, caches cannot work on this runner
CI / Check (gateway, backend, frontend) (push) Successful in 49s
The actions cache server is unreachable from job containers (ETIMEDOUT after 4.5 min per cache step) and the runner takes one job at a time, so the split cost 30 minutes. check.sh runs the parts in parallel inside the job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:49:16 +02:00
rootandClaude Opus 5.5 8b07b9ba0e feat(frontend): Odśwież button that asks the gateway for fresh data
CI / Check (gateway, backend, frontend) (push) Successful in 1m31s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:38:33 +02:00
rootandClaude Opus 5.5 aaf08d4f5c feat(backend): POST /api/refresh forwards the refresh to the gateway
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:36:18 +02:00
rootandClaude Opus 5.5 b0c0872775 feat(gateway): POST /refresh polls devices again with a 60 s cooldown
The Frisko MK01 takes one client, so a button must not poll the heat pump
more often than once a minute. Devices polled more recently are skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:33:40 +02:00
rootandClaude Opus 5.5 f128b03ae5 chore(release): require green CI on Gitea instead of re-running the checks
CI / Check (gateway) (push) Successful in 9m48s
CI / Check (frontend) (push) Successful in 9m23s
CI / Check (docs, secrets) (push) Successful in 5s
CI / Check (backend) (push) Successful in 9m39s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:19:20 +02:00
rootandClaude Opus 5.5 9f9e4e4f50 chore(release): build the three images in parallel with a registry cache
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:19:20 +02:00
rootandClaude Opus 5.5 4b26028d01 chore(ci): run the part builds in parallel with dependency caches
check.sh takes optional targets (docs, gateway, backend, frontend) and builds the parts concurrently. CI runs one job per part with Maven and npm caches; Maven retries downloads.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:39:16 +02:00
rootandClaude Opus 5.5 338befaffa fix(battery): return no schedule when a slot start is missing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:39:16 +02:00
81 changed files with 1679 additions and 420 deletions

No files matched your search

+2 -1
View File
@@ -24,7 +24,8 @@ Repo rules: `CONTRIBUTING.md`. Gitea: https://git.mariuszpacek.pl/mariusz.pacek/
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. Release and update only with the user's explicit approval for this one:
`scripts/release.sh vMAJOR.MINOR.PATCH` (patch for fixes, minor for features) tags master;
`scripts/release.sh vMAJOR.MINOR.PATCH` (patch for fixes, minor for features) waits for green
CI on master's head (no local check run), then tags master;
wait until the Release run under `…/actions` is green (it publishes the images). Then on the
Pi (`ssh <host>`, an alias while D7 is open): `cd /home/pi/home-pump && ./update.sh <version>`,
watch `journalctl -t home-pump-gateway -f` and Grafana for one polling cycle (300 s) and
+3
View File
@@ -1,3 +1,6 @@
# One job: check.sh builds the three parts in parallel itself. The runner runs one job at a time
# and its actions cache server is unreachable from job containers (ETIMEDOUT), so separate jobs
# or setup-java/setup-node caches would only add waiting.
name: CI
on:
+20 -23
View File
@@ -13,8 +13,20 @@ env:
jobs:
images:
name: Images
name: Image (${{ matrix.part }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# The Pi is arm64. Each Dockerfile builds on the runner's platform and only copies into
# the final stage, so the arm64 image needs no emulation.
- part: gateway
platforms: linux/arm64,linux/amd64
- part: backend
platforms: linux/amd64
- part: frontend
platforms: linux/amd64
steps:
- name: Checkout
@@ -31,29 +43,14 @@ jobs:
username: ${{ gitea.repository_owner }}
# The owner's token with package write access (the secret homelab-upgrade uses too).
password: ${{ secrets.REGISTRY_TOKEN }}
# The Pi is arm64. Each Dockerfile builds on the runner's platform and only copies into the
# final stage, so the arm64 image needs no emulation.
- name: Gateway
# The registry cache keeps the Maven and npm layers between releases.
- name: Build and push
uses: docker/build-push-action@v6
with:
context: gateway
context: ${{ matrix.part }}
build-args: VERSION=${{ gitea.ref_name }}
platforms: linux/arm64,linux/amd64
platforms: ${{ matrix.platforms }}
push: true
tags: ${{ env.IMAGES }}-gateway:${{ gitea.ref_name }},${{ env.IMAGES }}-gateway:latest
- name: Backend
uses: docker/build-push-action@v6
with:
context: backend
build-args: VERSION=${{ gitea.ref_name }}
platforms: linux/amd64
push: true
tags: ${{ env.IMAGES }}-backend:${{ gitea.ref_name }},${{ env.IMAGES }}-backend:latest
- name: Frontend
uses: docker/build-push-action@v6
with:
context: frontend
build-args: VERSION=${{ gitea.ref_name }}
platforms: linux/amd64
push: true
tags: ${{ env.IMAGES }}-frontend:${{ gitea.ref_name }},${{ env.IMAGES }}-frontend:latest
tags: ${{ env.IMAGES }}-${{ matrix.part }}:${{ gitea.ref_name }},${{ env.IMAGES }}-${{ matrix.part }}:latest
cache-from: type=registry,ref=${{ env.IMAGES }}-${{ matrix.part }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGES }}-${{ matrix.part }}:buildcache,mode=max
+5
View File
@@ -72,6 +72,11 @@ the others keep it. 400 for a setpoint the preset does not change or a value out
(from `/commands`), checked before anything is sent; a held value that is not below the current one is 422. The gateway holds those changes until then without
renewals (ADR-012) and rejects an end that is past or over 30 days away, or a change that does not lower the value (422).
`POST /api/refresh` (same Origin, header and JSON rules; send `{}`) is the "Odśwież" button: it asks the gateway for one
extra poll of every device and passes its answer through, example `src/test/resources/api/refresh.json`
(`requested`, and `skipped` with `retryInSeconds` for devices polled within the last minute). Read-only; 503 when
`GATEWAY_TOKEN` is not set or the gateway is unreachable.
`POST /api/setpoints/{name}/restore` (same Origin, header and JSON rules as the other writes; send `{}`) ends the
pending change of that setpoint now and answers with the gateway's verdict, example
`src/test/resources/api/setpoint-restored.json`: 200 restored, 404 nothing pending, 409 the value was changed
@@ -34,6 +34,8 @@ import java.util.Objects;
final class Backend {
private static final Duration WEATHER_TIMEOUT = Duration.ofSeconds(10);
/** Warnings are optional, so a slow IMGW must not hold the forecast. */
private static final Duration WARNINGS_TIMEOUT = Duration.ofSeconds(3);
private Backend() {
}
@@ -57,14 +59,14 @@ final class Backend {
new HistoryService(influx, clock), setpoints, new PiSensorService(gateway, clock),
new BatteryService(gateway, commands, clock),
new AuditService(new InfluxAuditRepository(influxSettings), clock), new StatsService(influx, clock),
weather(influxSettings, clock));
weather(influxSettings, clock), commands);
}
private static WeatherService weather(InfluxSettings influx, Clock clock) {
var place = WeatherSettings.fromEnvironment(System.getenv());
return new WeatherService(
new OpenMeteoClient(URI.create("https://api.open-meteo.com"), place, WEATHER_TIMEOUT),
new ImgwClient(URI.create("https://danepubliczne.imgw.pl"), place, WEATHER_TIMEOUT),
new ImgwClient(URI.create("https://danepubliczne.imgw.pl"), place, WARNINGS_TIMEOUT),
new InfluxPvEnergy(influx), clock);
}
@@ -7,11 +7,13 @@ import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import home.backend.application.GatewayBattery;
import home.backend.application.GatewayCommands;
import home.backend.application.GatewayRefresh;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView.Reserve;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.RefreshResult;
import home.backend.domain.RestoreResult;
import home.backend.domain.SetpointState;
@@ -25,8 +27,8 @@ import java.time.Instant;
import java.util.List;
import java.util.Set;
/** Sends commands to the gateway's {@code /commands} and {@code /battery} with the shared token (S6). */
public class GatewayHttpCommands implements GatewayCommands, GatewayBattery {
/** Calls the gateway's {@code /commands}, {@code /battery} and {@code /refresh} with the shared token (S6). */
public class GatewayHttpCommands implements GatewayCommands, GatewayBattery, GatewayRefresh {
/** The gateway answers a verdict with 200 (applied), 422 (rejected) or 502 (failed). */
private static final Set<Integer> VERDICTS = Set.of(200, 422, 502);
@@ -66,6 +68,7 @@ public class GatewayHttpCommands implements GatewayCommands, GatewayBattery {
private final URI endpoint;
private final URI renewEndpoint;
private final URI restoreEndpoint;
private final URI refreshEndpoint;
private final URI reserveEndpoint;
private final URI scheduleEndpoint;
private final String token;
@@ -77,6 +80,7 @@ public class GatewayHttpCommands implements GatewayCommands, GatewayBattery {
this.endpoint = baseUri.resolve("/commands");
this.renewEndpoint = baseUri.resolve("/commands/renew");
this.restoreEndpoint = baseUri.resolve("/commands/restore");
this.refreshEndpoint = baseUri.resolve("/refresh");
this.reserveEndpoint = baseUri.resolve("/battery/reserve");
this.scheduleEndpoint = baseUri.resolve("/battery/schedule");
this.token = token;
@@ -113,6 +117,12 @@ public class GatewayHttpCommands implements GatewayCommands, GatewayBattery {
return JSON.readValue(send(request, Set.of(200)), Renewal.class).renewed();
}
@Override
public RefreshResult refresh() throws IOException {
var request = request(refreshEndpoint).POST(HttpRequest.BodyPublishers.noBody());
return JSON.readValue(send(request, Set.of(200)), RefreshResult.class);
}
@Override
public Reserve reserve() throws IOException {
return JSON.readValue(send(request(reserveEndpoint).GET(), Set.of(200)), Reserve.class);
@@ -13,6 +13,7 @@ import home.backend.application.AuditService;
import home.backend.application.BatteryService;
import home.backend.application.EnergyDetailsService;
import home.backend.application.EnergyService;
import home.backend.application.GatewayRefresh;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService;
@@ -90,8 +91,8 @@ public class ApiServer {
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
EnergyDetailsService energyDetails, HealthService health,
HistoryService history, SetpointService setpoints, PiSensorService piSensor,
BatteryService battery, AuditService audit, StatsService stats, WeatherService weather)
throws IOException {
BatteryService battery, AuditService audit, StatsService stats, WeatherService weather,
GatewayRefresh refresh) throws IOException {
server = HttpServer.create(new InetSocketAddress(port), 0);
server.setExecutor(executor);
// Everything outside the API, with the security headers too: the web UI is the frontend's (ADR-010).
@@ -108,6 +109,8 @@ public class ApiServer {
"POST", () -> guarded(exchange, body -> postSchedule(exchange, body, battery)))));
route("/api/battery/reserve", exchange -> handle(exchange, Map.of(
"POST", () -> guarded(exchange, body -> postReserve(exchange, body, battery)))));
route("/api/refresh", exchange -> handle(exchange, Map.of(
"POST", () -> guarded(exchange, body -> postRefresh(exchange, refresh)))));
api("/api/pi-sensor", query -> piSensor.current());
api("/api/weather", query -> weather.current());
api("/api/health", query -> health.current());
@@ -259,6 +262,14 @@ public class ApiServer {
}
}
private static void postRefresh(HttpExchange exchange, GatewayRefresh refresh) throws IOException {
try {
send(exchange, 200, refresh.refresh());
} catch (IOException e) {
send(exchange, 503, error(UNREACHABLE));
}
}
private static void postRestore(HttpExchange exchange, String setpoint, SetpointService setpoints)
throws IOException {
try {
@@ -13,7 +13,7 @@ import java.time.LocalDate;
import java.util.Map;
import java.util.TreeMap;
/** Open-Meteo (free, no key): daily values for the past 14 days and the next 3, and the temperature now. */
/** Open-Meteo (free, no key): daily values for the past 14 days and the next 4, and the temperature now. */
public final class OpenMeteoClient implements ForecastSource {
private static final String DAILY = "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,"
@@ -26,7 +26,7 @@ public final class OpenMeteoClient implements ForecastSource {
this.http = new HttpJson(timeout);
this.uri = URI.create(baseUri.toString().replaceAll("/+$", "") + "/v1/forecast?latitude="
+ place.latitude() + "&longitude=" + place.longitude() + "&daily=" + DAILY
+ "&current=temperature_2m&timezone=Europe%2FWarsaw&past_days=14&forecast_days=3");
+ "&current=temperature_2m&timezone=Europe%2FWarsaw&past_days=14&forecast_days=4");
}
@Override
@@ -19,8 +19,8 @@ public class AuditService {
private static final Map<String, Duration> RANGES =
Map.of("24h", Duration.ofHours(24), "7d", Duration.ofDays(7), "30d", Duration.ofDays(30));
private static final Map<String, String> SETPOINTS = Map.of(
"cwu_economy", "CWU eko", "cwu_comfort", "CWU komfort",
"co_economy", "CO eko", "co_comfort", "CO komfort");
"cwu_economy", "Ciepła woda – oszczędnie", "cwu_comfort", "Ciepła woda – komfort",
"co_economy", "Ogrzewanie – oszczędnie", "co_comfort", "Ogrzewanie – komfort");
private static final String RESTORE_USER = "restore";
private static final String RESTORE_LABEL = "automatycznie (przywrócenie)";
@@ -86,8 +86,13 @@ public class BatteryService {
: "Rezerwa na awarię: bateria nie rozładowuje się na co dzień");
}
/** Slot n runs from its start to the start of slot n + 1; the last one wraps to the first. */
/** Slot n runs from its start to the start of slot n + 1; the last one wraps to the first.
* No schedule at all when a start is missing. */
private static List<Slot> schedule(Map<String, Double> s) {
boolean complete = IntStream.rangeClosed(1, SLOTS).allMatch(n -> whole(s, "prog" + n + "_start_hhmm") != null);
if (!complete) {
return List.of();
}
return IntStream.rangeClosed(1, SLOTS).mapToObj(n -> {
String prefix = "prog" + n + "_";
String next = "prog" + (n % SLOTS + 1) + "_start_hhmm";
@@ -4,8 +4,13 @@ import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
/** One remote answer kept for a while; when a refresh fails the older answer is served and marked stale. */
/**
* One remote answer kept for a while, and no longer than its local day when a zone is given. When a refresh
* fails the older answer is served marked stale, and the source is left alone for a short back-off so a
* source that is down does not make every request wait for its timeout.
*/
final class Cached<T> {
interface Fetch<T> {
@@ -15,33 +20,61 @@ final class Cached<T> {
record Entry<T>(T value, boolean stale) {
}
private static final Duration BACK_OFF = Duration.ofSeconds(60);
private final Duration ttl;
private final ZoneId dayZone;
private final Clock clock;
private final Fetch<T> source;
private T value;
private Instant fetchedAt;
private Instant failedAt;
Cached(Duration ttl, Clock clock, Fetch<T> source) {
this(ttl, null, clock, source);
}
/** @param dayZone the answer expires at midnight of this zone even before the ttl, or null */
Cached(Duration ttl, ZoneId dayZone, Clock clock, Fetch<T> source) {
this.ttl = ttl;
this.dayZone = dayZone;
this.clock = clock;
this.source = source;
}
/** @throws IOException when the refresh fails and there is nothing older to serve */
/** @throws IOException when the refresh fails or is backing off, and there is nothing older to serve */
synchronized Entry<T> get() throws IOException {
Instant now = clock.instant();
if (value != null && now.isBefore(fetchedAt.plus(ttl))) {
if (value != null && now.isBefore(expiry())) {
return new Entry<>(value, false);
}
if (failedAt != null && now.isBefore(failedAt.plus(BACK_OFF))) {
return older(new IOException("source failed recently, not retrying yet"));
}
try {
value = source.fetch();
fetchedAt = now;
failedAt = null;
return new Entry<>(value, false);
} catch (IOException e) {
if (value == null) {
throw e;
}
return new Entry<>(value, true);
failedAt = now;
return older(e);
}
}
private Entry<T> older(IOException failure) throws IOException {
if (value == null) {
throw failure;
}
return new Entry<>(value, true);
}
private Instant expiry() {
Instant byTtl = fetchedAt.plus(ttl);
if (dayZone == null) {
return byTtl;
}
Instant midnight = fetchedAt.atZone(dayZone).toLocalDate().plusDays(1).atStartOfDay(dayZone).toInstant();
return midnight.isBefore(byTtl) ? midnight : byTtl;
}
}
@@ -0,0 +1,11 @@
package home.backend.application;
import home.backend.domain.RefreshResult;
import java.io.IOException;
/** Asks the gateway for fresh readings; it decides which devices may be polled again yet. */
public interface GatewayRefresh {
RefreshResult refresh() throws IOException;
}
@@ -37,7 +37,7 @@ public class WeatherService {
Clock clock) {
this.clock = clock;
this.pv = pv;
this.forecast = new Cached<>(Duration.ofMinutes(30), clock, () -> calibrated(forecastSource.forecast()));
this.forecast = new Cached<>(Duration.ofMinutes(30), ZONE, clock, () -> calibrated(forecastSource.forecast()));
this.warnings = new Cached<>(Duration.ofMinutes(10), clock, warningSource::warnings);
}
@@ -0,0 +1,10 @@
package home.backend.domain;
import java.util.List;
/** The gateway's answer to a refresh: the devices it will poll again, and those polled too recently. */
public record RefreshResult(List<String> requested, List<Skipped> skipped) {
public record Skipped(String device, long retryInSeconds) {
}
}
@@ -9,6 +9,7 @@ import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView;
import home.backend.domain.Command;
import home.backend.domain.CommandResult.Status;
import home.backend.domain.RefreshResult;
import home.backend.domain.RestoreResult.Outcome;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -65,7 +66,7 @@ class GatewayHttpCommandsTest {
}
private void answerCommands() {
for (String path : List.of("/commands", "/battery")) {
for (String path : List.of("/commands", "/battery", "/refresh")) {
server.createContext(path, this::answer);
}
}
@@ -225,6 +226,29 @@ class GatewayHttpCommandsTest {
assertEquals(seenBefore, seen.size());
}
@Test
void refreshPostsToRefreshWithTheTokenAndParsesTheAnswer() throws Exception {
serve(200, Files.readString(Path.of("src/test/resources/api/refresh.json")));
var result = commands("s3cret-token").refresh();
assertEquals("POST", seen.getFirst().method());
assertEquals("/refresh", seen.getFirst().path());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
assertEquals(List.of("deye", "house-meter"), result.requested());
assertEquals(new RefreshResult.Skipped("heat-pump", 41), result.skipped().getFirst());
}
@Test
void refreshFailsOnAnyOtherStatusOrWithoutToken() {
serve(401, "{\"error\":\"x\"}");
assertThrows(IOException.class, () -> commands("s3cret-token").refresh());
var seenBefore = seen.size();
assertThrows(IOException.class, () -> commands(null).refresh());
assertEquals(seenBefore, seen.size());
}
@Test
void readsTheBatteryReserveWithTheToken() throws Exception {
serve(200, contract("battery-reserve.json"));
@@ -18,6 +18,7 @@ import home.backend.domain.BatteryView.Reserve;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.CommandResult.Status;
import home.backend.domain.RefreshResult;
import home.backend.domain.RestoreResult;
import home.backend.domain.RestoreResult.Outcome;
import home.backend.domain.Restore;
@@ -80,6 +81,7 @@ class ApiServerTest {
private final List<Command> sent = new ArrayList<>();
private Status verdict;
private Outcome restoreOutcome;
private int refreshes;
private final List<String> restored = new ArrayList<>();
private boolean commandsDown;
private boolean batteryDown;
@@ -204,7 +206,15 @@ class ApiServerTest {
new BatteryService(gateway, batteryCommands, clock),
new AuditService(auditRows(), clock), new StatsService(samples, clock),
new WeatherService(() -> exampleForecast(), () -> List.of(exampleWarning()),
(from, to) -> exampleMeasuredPv(), clock));
(from, to) -> exampleMeasuredPv(), clock),
() -> {
if (commandsDown) {
throw new IOException("tunnel down");
}
refreshes++;
return new RefreshResult(List.of("deye", "house-meter"),
List.of(new RefreshResult.Skipped("heat-pump", 41)));
});
server.start();
}
@@ -667,6 +677,32 @@ class ApiServerTest {
assertTrue(restored.isEmpty());
}
@Test
void refreshPassesTheGatewaysAnswerThrough() throws Exception {
var response = post("/api/refresh", "{}");
assertEquals(200, response.statusCode());
assertEquals(example("refresh"), JSON.readTree(response.body()));
assertEquals(1, refreshes);
}
@Test
void refreshIs503WhenTheGatewayCannotBeReached() throws Exception {
commandsDown = true;
assertEquals(503, post("/api/refresh", "{}").statusCode());
}
@Test
void refreshIsGuardedLikeEveryWrite() throws Exception {
String path = "/api/refresh";
assertEquals(415, rawPost(path, "{}", "X-Requested-With", "home-pump").statusCode());
assertEquals(403, rawPost(path, "{}", "Content-Type", "application/json").statusCode());
assertEquals(403, post(path, "{}", "Origin", "https://evil.example").statusCode());
assertEquals(405, get(path).statusCode());
assertEquals(0, refreshes);
}
@Test
void otherPathsUnderSetpointsAre404() throws Exception {
for (String path : List.of("/api/setpoints/", "/api/setpoints/cwu_economy",
@@ -54,7 +54,7 @@ class AuditServiceTest {
var entries = service.audit(null).entries();
assertEquals(List.of("CWU eko", "Rezerwa baterii", "CO komfort"),
assertEquals(List.of("Ciepła woda – oszczędnie", "Rezerwa baterii", "Ogrzewanie – komfort"),
entries.stream().map(AuditEntry::what).toList());
assertEquals(List.of("heat_pump", "battery", "heat_pump"),
entries.stream().map(AuditEntry::source).toList());
@@ -279,6 +279,14 @@ class ServicesTest {
assertEquals(new BatteryView.Slot("21:00", "01:00", 8000, 60, false), schedule.get(5));
}
@Test
void batteryScheduleIsEmptyWhenASlotStartIsMissing() throws IOException {
Map<String, Double> fields = settings(1);
fields.remove("prog3_start_hhmm");
assertTrue(battery(deyeSettings(fields, READ)).schedule().isEmpty());
}
@Test
void batteryModeFollowsTimeOfUse() throws IOException {
BatteryView backup = battery(deyeSettings(settings(0), READ));
@@ -55,6 +55,7 @@ class WeatherServiceTest {
private boolean warningsDown;
private boolean influxDown;
private int measuredDays = 5;
private LocalDate today = TODAY;
private List<Warning> warned = List.of(warning(START.plus(Duration.ofHours(8))));
private final WeatherService service = new WeatherService(
@@ -76,24 +77,24 @@ class WeatherServiceTest {
if (influxDown) {
throw new IOException("influx down");
}
assertEquals(TODAY.minusDays(14), from);
assertEquals(TODAY, to);
assertEquals(today.minusDays(14), from);
assertEquals(today, to);
Map<LocalDate, Double> measured = new HashMap<>();
for (int i = 1; i <= measuredDays; i++) {
measured.put(TODAY.minusDays(i), 15.0);
measured.put(today.minusDays(i), 15.0);
}
return measured;
},
clock);
private static Forecast forecast() {
private Forecast forecast() {
Map<LocalDate, DailyForecast> days = new TreeMap<>();
for (int i = 1; i <= 14; i++) {
days.put(TODAY.minusDays(i), new DailyForecast(3, 10.0, 2.0, 0.0, 5.0, 3600.0));
days.put(today.minusDays(i), new DailyForecast(3, 10.0, 2.0, 0.0, 5.0, 3600.0));
}
days.put(TODAY, new DailyForecast(0, 12.34, 3.0, 0.0, 6.0, 5400.0));
days.put(TODAY.plusDays(1), new DailyForecast(63, 9.0, 4.0, 5.5, null, null));
days.put(TODAY.plusDays(2), new DailyForecast(null, null, null, null, 1.0, 0.0));
days.put(today, new DailyForecast(0, 12.34, 3.0, 0.0, 6.0, 5400.0));
days.put(today.plusDays(1), new DailyForecast(63, 9.0, 4.0, 5.5, null, null));
days.put(today.plusDays(2), new DailyForecast(null, null, null, null, 1.0, 0.0));
return new Forecast(8.5, days);
}
@@ -130,6 +131,48 @@ class WeatherServiceTest {
assertEquals(TODAY, service.current().today().date());
}
@Test
void aForecastFetchedBeforeLocalMidnightIsNotUsedAfterIt() throws IOException {
now = Instant.parse("2026-10-24T21:50:00Z");
service.current();
now = Instant.parse("2026-10-24T22:05:00Z");
today = TODAY.plusDays(1);
WeatherView view = service.current();
assertEquals(2, forecastCalls.get());
assertEquals(today, view.today().date());
assertEquals(today.plusDays(2), view.dayAfter().date());
}
@Test
void aFailedRefreshServesTheOlderWarningsWithoutAskingAgainFor60Seconds() throws IOException {
service.current();
warningsDown = true;
now = START.plus(Duration.ofMinutes(11));
service.current();
assertEquals(2, warningCalls.get());
now = now.plusSeconds(59);
assertTrue(service.current().stale());
assertEquals(2, warningCalls.get());
now = now.plusSeconds(1);
service.current();
assertEquals(3, warningCalls.get());
}
@Test
void withoutAnyForecastItFailsFastDuringTheBackOff() {
forecastDown = true;
assertThrows(IOException.class, service::current);
now = START.plusSeconds(30);
assertThrows(IOException.class, service::current);
assertEquals(1, forecastCalls.get());
}
@Test
void fewerThanFiveMeasuredDaysGiveNoEstimate() throws IOException {
measuredDays = 4;
+2 -2
View File
@@ -2,11 +2,11 @@
"entries": [
{ "time": "2026-10-24T09:30:00Z", "source": "battery", "what": "Rezerwa baterii", "oldValue": 50, "newValue": 60,
"unit": "%", "who": "mariusz", "status": "APPLIED" },
{ "time": "2026-10-24T08:00:00Z", "source": "heat_pump", "what": "CWU eko", "oldValue": 45, "newValue": 8,
{ "time": "2026-10-24T08:00:00Z", "source": "heat_pump", "what": "Ciepła woda – oszczędnie", "oldValue": 45, "newValue": 8,
"unit": "°C", "who": "automatycznie (przywrócenie)", "status": "APPLIED" },
{ "time": "2026-10-23T18:00:00Z", "source": "battery", "what": "Przedział 3: ładowanie z sieci", "oldValue": 0,
"newValue": 1, "unit": "0/1", "who": "mariusz", "status": "FAILED" },
{ "time": "2026-10-23T07:00:00Z", "source": "heat_pump", "what": "CO komfort", "oldValue": null, "newValue": 22,
{ "time": "2026-10-23T07:00:00Z", "source": "heat_pump", "what": "Ogrzewanie – komfort", "oldValue": null, "newValue": 22,
"unit": "°C", "who": "mariusz", "status": "REJECTED" }
]
}
@@ -0,0 +1,4 @@
{
"requested": ["deye", "house-meter"],
"skipped": [{ "device": "heat-pump", "retryInSeconds": 41 }]
}
+2 -1
View File
@@ -75,7 +75,8 @@ Functional:
- F1 Live state of the heat pump: idle / heating CWU / heating CO, compressor stage, pumps,
heater, alarm, current targets and temperatures; the BME280 reading (temperature, humidity,
pressure) of the living room.
pressure) of the living room. An "Odśwież" button asks for one extra poll of each device, at most once
a minute per device (the MK01 takes one client).
- F2 History for the last 7 days, and per-day heat pump statistics for 7 or 30 days (compressor, CWU, CO and heater hours,
starts "at least" because polls are 5 minutes apart).
- F3 Change four setpoints: economy and comfort temperature of CWU (4143, 4144) and CO
+5 -1
View File
@@ -20,13 +20,17 @@ and gateway's versions in the "Wersje" card of the Stan screen.
The Energia screen has three views, kept in the URL hash so Back works: `#energy` (Teraz), `#energy/battery` (Bateria, also
opened by tapping the battery in the flow diagram) and `#energy/inverters` (Falowniki, the device details).
Every screen title (`PageTitle`) has the "Odśwież" button (`Refresh.tsx`) beside it, with its status text below: it posts `api/refresh`, waits 5 s for the gateway's extra poll, then
`refreshAll()` (`poll.ts`) fetches every poller that is on screen again. When the gateway skipped every device (polled within a minute) it says so and
waits out the shortest cooldown.
Ustawienia ends with the "Historia zmian" card (`ChangeHistory.tsx`): the last 7 days of `api/audit`, 20 rows and "Pokaż więcej".
In the Bateria view, `editable` from `api/battery` (the gateway takes battery changes, ADR-013) adds the reserve stepper and "Edytuj" for the
schedule: the user confirms a summary, only changed fields are sent to `api/battery/schedule` and `api/battery/reserve`, and the UI
checks the gateway's rules first (levels from the reserve to 100 %, starts increasing).
Podgląd shows the "Pogoda" card (3 days, PV estimate) and one banner per IMGW warning from `api/weather`; Bateria hints to keep the battery full on a storm or strong-wind warning (a hint only, nothing changes by itself).
Podgląd opens with the alarm and one collapsible line per distinct IMGW warning, then four key figures (CWU, home, outdoor, power), the schematic with the device rows, the "Pogoda" card (3 days, PV estimate, from `api/weather`) and "Szczegóły temperatur"; Bateria hints to keep the battery full on a storm or strong-wind warning (a hint only, nothing changes by itself).
Historia has two views in the hash, like Energia: `#history` (Wykresy) and `#history/stats` (Statystyki: totals and a daily chart of
compressor hours from `api/stats`, 7 or 30 days). `SubView.tsx` holds the shared tab switch.
+16 -10
View File
@@ -6,7 +6,8 @@ import { Overview } from "./Overview";
import { usePoll } from "./poll";
import { useTheme, type Theme } from "./theme";
import type { HeatPump } from "./types";
import { Dot, Loading } from "./ui";
import { Dot, isStale, Loading } from "./ui";
import { useWeather, WeatherIcon } from "./Weather";
// Energy shares code with the first screen; the others load when opened.
const History = lazy(() => import("./History").then((m) => ({ default: m.History })));
@@ -44,7 +45,7 @@ function ThemeSwitch({ className }: { className: string }) {
<div role="group" aria-label="Motyw" className={`flex gap-1 rounded-xl p-1 text-sm ${className}`}>
{Object.entries(THEMES).map(([id, label]) => (
<button key={id} aria-pressed={id === theme} onClick={() => setTheme(id as Theme)}
className="flex-1 rounded-lg px-2.5 py-1.5 font-semibold opacity-70 aria-pressed:bg-white aria-pressed:text-side aria-pressed:opacity-100">
className="min-h-11 flex-1 rounded-lg px-2.5 font-semibold opacity-80 aria-pressed:bg-white aria-pressed:text-side aria-pressed:opacity-100">
{label}
</button>
))}
@@ -56,10 +57,12 @@ function ThemeSwitch({ className }: { className: string }) {
function SideFooter() {
const { data, offline } = usePoll<HeatPump>("api/heat-pump");
const now = useClock();
const old = !offline && data?.time != null && isStale(data.time, data.stale);
const weather = useWeather();
return (
<div className="mt-auto space-y-4 px-2">
<div className="mt-auto space-y-3 border-t border-white/10 px-2 pt-4">
<div className="flex items-center gap-3">
<Icon name="sun" className="size-9 text-amber-400" />
{weather && <WeatherIcon code={weather.today.weatherCode} className="size-9" />}
<div>
<p className="text-2xl font-semibold">{celsius(data?.temperatures.outdoor)}</p>
<p className="text-sm text-white/60">Szczepańcowa</p>
@@ -68,9 +71,9 @@ function SideFooter() {
<p className="text-sm text-white/80">{longDate(now)} · {hour(now)}</p>
<ThemeSwitch className="bg-white/10" />
<div className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/5 p-3">
<Dot tone={offline ? "bad" : "ok"} live={!offline} />
<Dot tone={offline ? "bad" : old ? "warn" : "ok"} live={!offline && !old} />
<div className="text-sm">
<p className="font-semibold">{offline ? "Brak połączenia" : "System online"}</p>
<p className="font-semibold">{offline ? "Brak połączenia" : old ? "Dane nieaktualne" : "System online"}</p>
<p className="text-white/60">{data?.time ? `Ostatni odczyt: ${hour(new Date(data.time))}` : "Czekam na odczyt"}</p>
</div>
</div>
@@ -81,14 +84,17 @@ function SideFooter() {
export function App() {
const [screen, setScreen] = useState<ScreenId>(fromHash);
useEffect(() => {
const onHash = () => setScreen(fromHash());
// A link to another screen, such as "Energia ›", arrives here too.
const onHash = () => {
setScreen(fromHash());
scrollTo(0, 0);
};
addEventListener("hashchange", onHash);
return () => removeEventListener("hashchange", onHash);
}, []);
const show = (id: ScreenId) => {
location.hash = id;
setScreen(id);
scrollTo(0, 0);
};
const View = SCREENS[screen].view;
@@ -99,8 +105,8 @@ export function App() {
<div className="mb-8 hidden items-center gap-3 px-2 lg:flex">
<span className="grid size-10 place-items-center rounded-xl bg-brand/20 text-brand"><Icon name="leaf" className="size-6" /></span>
<div>
<p className="text-lg font-bold leading-tight">Dom Szczepańcowa</p>
<p className="text-xs text-white/60">Pompa ciepła i energia</p>
<p className="text-base font-bold leading-tight">Dom Szczepańcowa</p>
<p className="text-xs text-white/70">Pompa ciepła i energia</p>
</div>
</div>
{Object.entries(SCREENS).map(([id, s]) => (
+3 -3
View File
@@ -24,7 +24,7 @@ describe("Battery view", () => {
await show({ powerW: 3 });
expect(screen.getByRole("img", { name: "Naładowanie baterii: 99 %" })).toBeInTheDocument();
expect(screen.getByText("14,2 kWh z 14,3 kWh")).toBeInTheDocument();
expect(screen.getByText("14,2 / 14,3 kWh")).toBeInTheDocument();
expect(screen.getByText("stoi")).toBeInTheDocument();
});
@@ -47,7 +47,7 @@ describe("Battery view", () => {
expect(screen.getByText("Rezerwa na awarię: bateria nie rozładowuje się na co dzień")).toBeInTheDocument();
const table = screen.getByRole("table");
expect(screen.getByText("wyłączony")).toBeInTheDocument();
expect(screen.getByText("Harmonogram wyłączony – bateria tylko na awarię.")).toBeInTheDocument();
expect(table).toHaveClass("opacity-50");
expect(within(table).getAllByRole("row")).toHaveLength(7);
expect(within(table).getByRole("row", { name: /21:00–01:00/ })).toHaveTextContent("80 %nie");
@@ -101,7 +101,7 @@ describe("Battery view", () => {
total: { chargeKwh: null, dischargeKwh: null } });
expect(screen.getByText(/Dane są nieaktualne/)).toBeInTheDocument();
expect(screen.getByRole("img", { name: "Naładowanie baterii: —" })).toBeInTheDocument();
expect(screen.getByRole("img", { name: "Naładowanie baterii: —" }).closest(".opacity-50")).not.toBeNull();
expect(screen.getByText("Dziś naładowano").parentElement).toHaveTextContent("—");
});
});
+17 -11
View File
@@ -1,10 +1,10 @@
import { useState } from "react";
import { flowing } from "./flow";
import { amps, DASH, hour, kwh, number, percent, power } from "./format";
import { amps, DASH, dateTime, hour, kwh, number, percent, power } from "./format";
import { usePoll } from "./poll";
import type { Battery as BatteryData } from "./types";
import { Reserve, Schedule, type Notice } from "./BatterySettings";
import { Banner, Card, Freshness, Loading, Row } from "./ui";
import { Banner, Card, Freshness, isStale, Loading, Row } from "./ui";
import { threatensPower, useWeather } from "./Weather";
/** What the battery does, in one word: + discharge / − charge. */
@@ -31,21 +31,21 @@ function Gauge({ soc, stored, capacity }: { soc: number | null; stored: number |
<div className="absolute inset-0 grid place-items-center text-center">
<div>
<p className="text-4xl font-bold tabular-nums">{percent(soc)}</p>
{stored != null && capacity != null && <p className="text-sm text-soft">{kwh(stored)} z {kwh(capacity)}</p>}
{stored != null && capacity != null && <p className="text-sm text-soft">{number(stored, 1)} / {kwh(capacity)}</p>}
</div>
</div>
</div>
);
}
function Now({ b }: { b: BatteryData }) {
function Now({ b, stale }: { b: BatteryData; stale: boolean }) {
const stored = b.socPct != null && b.capacityKwh != null ? (b.socPct / 100) * b.capacityKwh : null;
return (
<Card title="Bateria teraz">
<div className="flex flex-wrap items-center gap-5">
<div className={`flex flex-wrap items-center gap-5 ${stale ? "opacity-50" : ""}`}>
<Gauge soc={b.socPct} stored={stored} capacity={b.capacityKwh} />
<div className="min-w-44 flex-1">
<p className={`text-2xl font-bold ${flowing(b.powerW) ? "text-run" : ""}`}>
<p className={`text-2xl font-bold ${flowing(b.powerW) ? "text-brand" : ""}`}>
{batteryAction(b.powerW)}
{flowing(b.powerW) && <span className="tabular-nums"> {power(Math.abs(b.powerW!))}</span>}
</p>
@@ -90,17 +90,23 @@ function Totals({ b }: { b: BatteryData }) {
export function Battery() {
const { data: b, offline } = usePoll<BatteryData>("api/battery");
const [notice, setNotice] = useState<Notice>(null);
const weather = useWeather();
const threats = useWeather()?.warnings.filter(threatensPower) ?? [];
if (!b) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
return (
<>
<Freshness offline={offline} time={b.time} stale={b.stale} />
{weather?.warnings.some(threatensPower) && (
<Banner tone="warn">Ostrzeżenie IMGW: rozważ trzymanie baterii pełnej (rezerwa na awarię).</Banner>
<Freshness offline={offline} time={b.time} stale={b.stale} compact />
{threats.length > 0 && (
<Banner tone="warn" compact>
<p>Zapowiadany silny wiatr lub burza – zostaw baterię naładowaną (rezerwa na awarię).</p>
<details className="mt-1 font-normal">
<summary className="cursor-pointer py-1 font-semibold">Szczegóły ostrzeżenia</summary>
<ul>{threats.map((w) => <li key={`${w.event}${w.level}${w.from}`}>IMGW, stopień {w.level}: {w.event}, {dateTime(w.from)} – {dateTime(w.to)}</li>)}</ul>
</details>
</Banner>
)}
{notice && <Banner tone={notice.ok ? "ok" : "bad"}>{notice.text}</Banner>}
<div className="grid gap-4 lg:grid-cols-2">
<div className="lg:col-span-2"><Now b={b} /></div>
<div className="lg:col-span-2"><Now b={b} stale={isStale(b.time, b.stale)} /></div>
{b.settingsStale || !b.mode || !b.limits ? (
<div className="lg:col-span-2">
<Banner tone="warn">Ustawienia baterii są nieaktualne{b.settingsTime ? ` (ostatnio ${hour(new Date(b.settingsTime))})` : ", brak odczytu"}.</Banner>
+11 -1
View File
@@ -1,6 +1,6 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { answer, example, serve } from "./fixtures";
import type { Battery as BatteryData } from "./types";
@@ -191,6 +191,16 @@ describe("Battery schedule editing", () => {
expect(screen.getByRole("button", { name: "Zapisz" })).toBeEnabled();
});
it("lists slots that share a start without a duplicate key", async () => {
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
const schedule = example<BatteryData>("battery").schedule.map((slot) => ({ ...slot, from: "00:00" }));
await show({ schedule });
expect(screen.getAllByText(/^00:00–/)).toHaveLength(6);
expect(error).not.toHaveBeenCalled();
error.mockRestore();
});
it("requires every start to be filled in", async () => {
await show();
await edit();
+11 -10
View File
@@ -3,7 +3,7 @@ import { postJson, WriteError } from "./api";
import { percent } from "./format";
import { reload } from "./poll";
import type { Battery, BatteryChange, BatteryResult, BatterySlot } from "./types";
import { Button, Card, Pill, Row, Stepper } from "./ui";
import { Button, Card, Row, Stepper } from "./ui";
const URL = "api/battery";
// The gateway polls the settings again 3 s after a write; the backend shows them from then on.
@@ -109,7 +109,7 @@ export function Reserve({ b, onDone }: { b: Battery; onDone: (notice: Notice) =>
<>
<Stepper label="rezerwa na awarię" value={draft ?? reserve.pct} min={reserve.floorPct} max={100}
format={percent} disabled={busy} onStep={step} />
<p className="text-sm text-soft">od {percent(reserve.floorPct)} do {percent(100)}</p>
<p className="text-base text-soft">od {percent(reserve.floorPct)} do {percent(100)}</p>
{breaksSchedule && <p role="alert" className="mt-2 font-semibold text-alarm">{RESERVE_BREAKS_SCHEDULE}</p>}
{draft != null && !confirming && (
<div className="mt-3 flex gap-3">
@@ -275,19 +275,20 @@ export function Schedule({ b, onDone }: { b: Battery; onDone: (notice: Notice) =
}
const edit = b.editable && b.reserve ? <Button onClick={() => setEditing(true)}>Edytuj</Button> : null;
return (
<Card title="Harmonogram" aside={<div className="flex items-center gap-2">{off && <Pill tone="off">wyłączony</Pill>}{edit}</div>}>
<table className={`w-full text-[0.95rem] ${off ? "opacity-50" : ""}`}>
<Card title="Harmonogram" aside={edit}>
{off && <p className="mb-3 font-semibold">Harmonogram wyłączony – bateria tylko na awarię.</p>}
<table className={`w-full text-base ${off ? "opacity-50" : ""}`}>
<caption className="sr-only">Przedziały pracy baterii{off ? " (wyłączone)" : ""}</caption>
<thead>
<tr className="text-left text-sm text-soft">
<th scope="col" className="pb-2 font-medium">Godziny</th>
<th scope="col" className="pb-2 font-medium">Poziom</th>
<th scope="col" className="pb-2 text-right font-medium">Ładowanie z sieci</th>
<tr className="text-left text-soft">
<th scope="col" className="pb-2 font-semibold">Godziny</th>
<th scope="col" className="pb-2 font-semibold">Poziom</th>
<th scope="col" className="pb-2 text-right font-semibold">Z sieci</th>
</tr>
</thead>
<tbody>
{b.schedule.map((slot) => (
<tr key={slot.from} className="border-t border-edge">
{b.schedule.map((slot, i) => (
<tr key={i + 1} className="border-t border-edge">
<th scope="row" className="py-2 text-left font-semibold tabular-nums">{slot.from}–{slot.to}</th>
<td className="py-2 tabular-nums">{percent(slot.socPct)}</td>
<td className="py-2 text-right font-semibold">{slot.gridCharge ? "tak" : "nie"}</td>
+1 -1
View File
@@ -26,7 +26,7 @@ describe("ChangeHistory", () => {
expect(rows[1]).toHaveTextContent("automatycznie (przywrócenie)");
expect(rows[2]).toHaveTextContent("nie → tak");
expect(within(rows[2]).getByText("nieudana")).toBeInTheDocument();
expect(rows[3]).toHaveTextContent("— → 22 °C");
expect(rows[3]).toHaveTextContent("ustawiono 22 °C");
expect(within(rows[3]).getByText("odrzucona")).toBeInTheDocument();
expect(within(rows[0]).queryByText(/nieudana|odrzucona|konflikt|nieważna/)).toBeNull();
});
+12 -7
View File
@@ -6,8 +6,10 @@ import { Card, Pill } from "./ui";
const URL = "api/audit?range=7d";
const PAGE = 20;
const STATUS: Record<string, string> = {
FAILED: "nieudana", REJECTED: "odrzucona", CONFLICT: "konflikt", INVALID: "nieważna",
// A failure may have reached the pump, so it is red; the others changed nothing, so amber.
const STATUS: Record<string, { text: string; tone: "bad" | "warn" }> = {
FAILED: { text: "nieudana", tone: "bad" }, REJECTED: { text: "odrzucona", tone: "warn" },
CONFLICT: { text: "konflikt", tone: "warn" }, INVALID: { text: "nieważna", tone: "warn" },
};
// 1830 is a clock time, 0/1 is off/on.
@@ -20,17 +22,20 @@ const SHOWN: Record<string, (value: number) => string> = {
const shown = (value: number | null, unit: string) => (value == null ? "—" : (SHOWN[unit] ?? number)(value));
function Row({ entry }: { entry: AuditEntry }) {
const status = entry.status === "APPLIED" ? null : (STATUS[entry.status] ?? entry.status);
const status = entry.status === "APPLIED" ? null : (STATUS[entry.status] ?? { text: entry.status, tone: "warn" as const });
return (
<li className="border-t border-edge py-3 first:border-t-0 first:pt-0">
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
<div className="flex items-baseline justify-between gap-3">
<span className="font-semibold">{entry.what}</span>
<span className="text-sm text-soft">{dateTime(entry.time)}</span>
<span className="shrink-0 text-sm text-soft">{dateTime(entry.time)}</span>
</div>
<p className="my-1">{shown(entry.oldValue, entry.unit)} → <b>{shown(entry.newValue, entry.unit)}</b></p>
<p className="my-1">
{entry.oldValue == null ? <>ustawiono <b>{shown(entry.newValue, entry.unit)}</b></>
: <>{shown(entry.oldValue, entry.unit)} → <b>{shown(entry.newValue, entry.unit)}</b></>}
</p>
<p className="flex flex-wrap items-center gap-2 text-sm text-soft">
{entry.who}
{status && <Pill tone="off">{status}</Pill>}
{status && <Pill tone={status.tone}>{status.text}</Pill>}
</p>
</li>
);
+51
View File
@@ -0,0 +1,51 @@
import type { ReactNode } from "react";
import { celsius, degrees, number } from "./format";
import type { HeatPump, PiSensor } from "./types";
import { Card, Row } from "./ui";
// The Raspberry Pi with the BME280 stands in the living room.
const NO_READING = "brak odczytu";
const reading = (value: number | null | undefined, digits: number, unit: string) =>
value == null ? NO_READING : `${number(value, digits)} ${unit}`;
function Group({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="mt-3 first:mt-0 [&>div]:py-1.5">
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-soft">{title}</h3>
{children}
</div>
);
}
const difference = (a: number | null, b: number | null) => (a != null && b != null ? celsius(a - b) : "—");
/** Every temperature and setpoint the overview does not already show in its four key figures. */
export function TemperatureDetails({ hp, pi }: { hp: HeatPump; pi: PiSensor | undefined }) {
const { temperatures: t, targets, setpoints: s } = hp;
return (
<Card title="Szczegóły temperatur">
<Group title="Dom">
<Row label="Salon" value={reading(pi?.temperature, 1, "°C")} />
<Row label="Wilgotność w salonie" value={reading(pi?.humidity, 1, "%")} />
<Row label="Ciśnienie" value={reading(pi?.pressure, 0, "hPa")} />
<Row label="Pokój według pompy" value={celsius(t.indoor)} />
<Row label="Pokój, cel teraz" value={celsius(targets.room)} />
<Row label="Na zewnątrz, średnia" value={celsius(t.outdoorAvg)} />
</Group>
<Group title="Woda i ogrzewanie">
<Row label="Bufor CO" value={celsius(t.coBuffer)} />
<Row label="Bufor CO, cel" value={celsius(targets.co)} />
<Row label="Wyjście pompy" value={celsius(t.pumpOutlet)} />
</Group>
<Group title="Dolne źródło">
<Row label="Do pompy" value={celsius(t.groundIn)} />
<Row label="Z pompy" value={celsius(t.groundOut)} />
<Row label="Różnica" value={difference(t.groundIn, t.groundOut)} />
</Group>
<Group title="Nastawy">
<Row label="Ciepła woda – oszczędnie · komfort" value={`${degrees(s.cwuEconomy)} · ${degrees(s.cwuComfort)}`} />
<Row label="Ogrzewanie – oszczędnie · komfort" value={`${degrees(s.coEconomy)} · ${degrees(s.coComfort)}`} />
</Group>
</Card>
);
}
+11 -6
View File
@@ -1,16 +1,21 @@
import { useEffect, useRef } from "react";
import { quantity, when } from "./format";
import type { EnergyDetailsDevice } from "./types";
import { Row } from "./ui";
import { Dot, isStale, Row } from "./ui";
/** The tappable row that says in words and with a chevron that a device has more to show. */
export function DetailsButton({ device, onOpen }: { device: EnergyDetailsDevice; onOpen: () => void }) {
/** The tappable row: status, the main current value and the read time; the chevron says there is more. */
export function DetailsButton({ device, summary, onOpen }: { device: EnergyDetailsDevice; summary: string; onOpen: () => void }) {
const stale = device.stale || isStale(device.time);
const read = stale ? (device.time ? `brak odczytu od ${when(device.time)}` : "brak odczytu") : device.time && `odczyt ${when(device.time)}`;
return (
<button type="button" onClick={onOpen}
className="flex min-h-14 w-full items-center justify-between gap-3 rounded-xl border border-edge px-4 py-2 text-left hover:bg-brand-soft focus-visible:outline-2 focus-visible:outline-brand">
className="flex min-h-16 w-full items-center justify-between gap-3 rounded-xl border border-edge px-4 py-2 text-left hover:bg-brand-soft focus-visible:outline-2 focus-visible:outline-brand">
<span>
<span className="block font-semibold">{device.name}</span>
{device.stale && <span className="block text-xs text-soft">brak odczytu</span>}
<span className="flex items-center gap-2 font-semibold">
<Dot tone={stale ? "warn" : "ok"} />{device.name}
</span>
{!stale && summary && <span className="block text-lg font-bold tabular-nums">{summary}</span>}
{read && <span className="block text-sm text-soft">{read}</span>}
</span>
<span className="flex shrink-0 items-center gap-1 font-semibold text-brand">
Szczegóły <span aria-hidden="true" className="text-xl leading-none">›</span>
+34 -1
View File
@@ -1,6 +1,6 @@
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { example, serve } from "./fixtures";
import type { Energy as EnergyData, EnergyDetails } from "./types";
@@ -15,6 +15,11 @@ beforeEach(() => {
location.hash = "";
});
afterEach(() => vi.useRealTimers());
// The fixtures are from a fixed evening; this makes them "just read".
const justAfterFixture = () => vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-10-23T22:28:00Z") });
describe("Energy views", () => {
const tab = (name: string) => screen.getByRole("tab", { name });
@@ -90,6 +95,34 @@ describe("Energy details", () => {
expect(screen.queryByRole("dialog")).toBeNull();
});
it("shows the main value and the read time on each row, and says when a device is silent", async () => {
justAfterFixture();
const details = example<EnergyDetails>("energy-details");
serveEnergy({ devices: details.devices.map((d) => (d.id === "fronius" ? { ...d, stale: true, groups: [] } : d)) });
location.hash = "energy/inverters";
const Energy = await energy();
render(<Energy />);
expect(await screen.findByRole("button", { name: /Falownik Deye/ })).toHaveTextContent("Bateria 99 % · PV 0 W");
expect(screen.getByRole("button", { name: /Licznik domu/ })).toHaveTextContent("645 W pobór");
expect(screen.getByRole("button", { name: /Falownik Fronius/ })).toHaveTextContent("brak odczytu od");
expect(screen.getByRole("button", { name: /Falownik Fronius/ })).not.toHaveTextContent("PV");
});
it("dims the values of a source that has gone quiet", async () => {
justAfterFixture();
const e = example<EnergyData>("energy");
serve({ "api/energy/details": example("energy-details"), "api/battery": example("battery"),
"api/energy": { ...e, sources: e.sources.map((s) => (s.name === "deye" ? { ...s, stale: true } : s)) } });
const Energy = await energy();
render(<Energy />);
const stats = (await screen.findByText("Zużycie domu")).closest("section")!;
expect(screen.getByText("Produkcja PV", { selector: "p" }).closest("section")).toHaveClass("opacity-60");
expect(screen.getAllByText("Bateria", { selector: "p" })[0].closest("section")).toHaveClass("opacity-60");
expect(stats).not.toHaveClass("opacity-60");
});
it("opens the groups of a device and closes by button, backdrop and Escape", async () => {
serveEnergy();
location.hash = "energy/inverters";
+69 -34
View File
@@ -5,10 +5,10 @@ import { flowing } from "./flow";
import { kwh, percent, power, when } from "./format";
import { Icon, type IconName } from "./icons";
import { usePoll } from "./poll";
import type { Energy as EnergyData, EnergyDetails } from "./types";
import type { Energy as EnergyData, EnergyDetails, EnergyDetailsDevice } from "./types";
import { DetailsButton, DeviceDetails } from "./DeviceDetails";
import { SubViewPanel, SubViewSwitch, useSubView } from "./SubView";
import { Banner, Card, Freshness, Loading, PageTitle, Pill, Row } from "./ui";
import { Banner, Card, Freshness, isStale, Loading, PageTitle, Pill, Row } from "./ui";
const INVERTERS = { deye: "Falownik Deye", fronius: "Falownik Fronius" } as const;
@@ -17,7 +17,7 @@ export function silentInverters(e: EnergyData) {
return Object.entries(INVERTERS).flatMap(([id, label]) => {
const source = e.sources?.find((s) => s.name === id);
if (source && !source.stale && source.time) return [];
return [{ label, since: source?.time ? `brak odczytu od ${when(source.time)}` : "brak odczytu" }];
return [{ id, label, since: source?.time ? `brak odczytu od ${when(source.time)}` : "brak odczytu" }];
});
}
@@ -38,17 +38,17 @@ function batteryText(watts: number | null) {
// Nodes sit around the house; dots run along a line from the source to the sink of each flow.
const NODES: Record<"pv" | "grid" | "house" | "battery", { x: number; y: number; label: string; icon: IconName; colour: string }> = {
pv: { x: 90, y: 70, label: "Słońce (PV)", icon: "sun", colour: "#f59e0b" },
grid: { x: 510, y: 70, label: "Sieć", icon: "plug", colour: "#3b82f6" },
house: { x: 300, y: 170, label: "Dom", icon: "home", colour: "#10b981" },
battery: { x: 300, y: 300, label: "Bateria", icon: "battery", colour: "#8b5cf6" },
pv: { x: 94, y: 56, label: "Słońce (PV)", icon: "sun", colour: "#f59e0b" },
grid: { x: 286, y: 56, label: "Sieć", icon: "plug", colour: "#3b82f6" },
house: { x: 190, y: 196, label: "Dom", icon: "home", colour: "#10b981" },
battery: { x: 190, y: 336, label: "Bateria", icon: "battery", colour: "#8b5cf6" },
};
// A triangle halfway along the line, pointing at the sink: the direction is readable without the moving dots.
function Arrowhead({ from, to }: { from: { x: number; y: number; colour: string }; to: { x: number; y: number } }) {
const angle = (Math.atan2(to.y - from.y, to.x - from.x) * 180) / Math.PI;
const x = (from.x + to.x) / 2, y = (from.y + to.y) / 2;
return <polygon data-arrow points="-11,-10 11,0 -11,10" fill={from.colour} transform={`translate(${x} ${y}) rotate(${angle})`} />;
return <polygon data-arrow points="-13,-12 13,0 -13,12" fill={from.colour} transform={`translate(${x} ${y}) rotate(${angle})`} />;
}
function Flow({ from, to, watts }: { from: keyof typeof NODES; to: keyof typeof NODES; watts: number | null }) {
@@ -60,15 +60,15 @@ function Flow({ from, to, watts }: { from: keyof typeof NODES; to: keyof typeof
const speed = on ? Math.max(0.6, 2.5 - (Math.min(Math.abs(watts!), 5000) / 5000) * 1.9) : 0;
return (
<g data-flow={`${from}-${to}`} data-on={on} aria-hidden="true">
<path d={d} stroke={on ? s.colour : "var(--color-off)"} strokeWidth={on ? 5 : 3} strokeLinecap="round" opacity={on ? 0.35 : 0.6} />
{on && <path d={d} className="flow" stroke={s.colour} strokeWidth={5} style={{ stroke: s.colour, animationDuration: `${speed}s` }} />}
<path d={d} stroke={on ? s.colour : "var(--color-off)"} strokeWidth={on ? 7 : 4} strokeLinecap="round" opacity={on ? 0.45 : 1} />
{on && <path d={d} className="flow" stroke={s.colour} strokeWidth={7} style={{ stroke: s.colour, animationDuration: `${speed}s` }} />}
{on && <Arrowhead from={s} to={e} />}
</g>
);
}
// With onOpen the node is a button: the chevron says it leads somewhere.
function Node({ id, value, note, onOpen }: { id: keyof typeof NODES; value: string; note?: string; onOpen?: () => void }) {
function Node({ id, value, note, dim, onOpen }: { id: keyof typeof NODES; value: string; note?: string; dim: boolean; onOpen?: () => void }) {
const { x, y, label, icon, colour } = NODES[id];
const button = onOpen && {
role: "button", tabIndex: 0, "aria-label": `${label}: pokaż szczegóły`, onClick: onOpen, className: "cursor-pointer outline-offset-2 focus-visible:outline-2 focus-visible:outline-brand",
@@ -81,12 +81,14 @@ function Node({ id, value, note, onOpen }: { id: keyof typeof NODES; value: stri
};
return (
<g aria-hidden={onOpen ? undefined : true} {...button}>
<rect x={x - 78} y={y - 40} width={156} height={80} rx={18} className="fill-panel" stroke="var(--color-edge)" strokeWidth={1.5} />
<g transform={`translate(${x - 64} ${y - 26})`} color={colour}><Icon name={icon} size={28} /></g>
<text x={x - 28} y={y - 10} fontSize={14} className="scene-soft">{label}</text>
<text x={x - 28} y={y + 16} fontSize={22} fontWeight={700} className="scene-text">{value}</text>
{note && <text x={x - 28} y={y + 33} fontSize={12} className="scene-soft">{note}</text>}
{onOpen && <text x={x + 62} y={y + 8} fontSize={28} textAnchor="middle" className="scene-soft" aria-hidden="true">›</text>}
<rect x={x - 86} y={y - 44} width={172} height={88} rx={18} className="fill-panel" stroke="var(--color-edge)" strokeWidth={2} />
<g transform={`translate(${x - 76} ${y - 38})`} color={colour}><Icon name={icon} size={28} /></g>
<g opacity={dim ? 0.45 : 1}>
<text x={x - 40} y={y - 10} fontSize={16} className="scene-soft">{label}</text>
<text x={x - 40} y={y + 18} fontSize={26} fontWeight={700} className="scene-text">{value}</text>
{note && <text x={x - 40} y={y + 37} fontSize={14} className="scene-soft">{note}</text>}
</g>
{onOpen && <text x={x + 72} y={y + 10} fontSize={32} textAnchor="middle" className="scene-soft" aria-hidden="true">›</text>}
</g>
);
}
@@ -103,25 +105,33 @@ export function flowSummary(e: EnergyData) {
];
}
// A source without a fresh reading keeps its last value on the screen, so the value is dimmed.
function quietNodes(e: EnergyData): string[] {
if (isStale(e.time)) return Object.keys(NODES);
return silentInverters(e).flatMap(({ id }) => (id === "deye" ? ["pv", "battery"] : ["pv"]));
}
function FlowPicture({ e, onBattery }: { e: EnergyData; onBattery: () => void }) {
const quiet = quietNodes(e);
const dim = (id: keyof typeof NODES) => quiet.includes(id);
return (
<>
<ul className="sr-only">{flowSummary(e).map((t) => <li key={t}>{t}</li>)}</ul>
<svg viewBox="0 0 600 350" className="h-auto w-full">
<svg viewBox="0 0 380 400" className="mx-auto h-auto w-full max-w-md">
<Flow from="pv" to="house" watts={e.pvPowerW} />
<Flow from="grid" to="house" watts={e.gridPowerW} />
<Flow from="battery" to="house" watts={e.batteryPowerW} />
<Node id="pv" value={power(e.pvPowerW)} />
<Node id="grid" value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
<Node id="house" value={power(e.loadPowerW)} note="zużycie" />
<Node id="battery" onOpen={onBattery} value={percent(e.batterySocPct)} note={flowing(e.batteryPowerW) ? (e.batteryPowerW! > 0 ? "rozładowuje" : "ładuje") : undefined} />
<Node id="pv" dim={dim("pv")} value={power(e.pvPowerW)} />
<Node id="grid" dim={dim("grid")} value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
<Node id="house" dim={dim("house")} value={power(e.loadPowerW)} note="zużycie" />
<Node id="battery" dim={dim("battery")} onOpen={onBattery} value={percent(e.batterySocPct)} note={flowing(e.batteryPowerW) ? (e.batteryPowerW! > 0 ? "rozładowuje" : "ładuje") : undefined} />
</svg>
</>
);
}
// Today's use split by where it came from; the battery is charged from both, so "own" is what the grid did not give.
export function EnergyToday({ e }: { e: EnergyData | undefined }) {
export function EnergyToday({ e, link = false }: { e: EnergyData | undefined; link?: boolean }) {
const today = e?.today;
const load = today?.loadKwh ?? null;
const bought = today?.gridBoughtKwh ?? null;
@@ -131,19 +141,25 @@ export function EnergyToday({ e }: { e: EnergyData | undefined }) {
{ name: "Z sieci", value: bought ?? 0, colour: "#3b82f6" },
];
const partial = e ? silentInverters(e).length > 0 : false;
const aside = (
<span className="flex items-center gap-3">
{partial && <Pill tone="warn">Tylko część danych</Pill>}
{link && <a href="#energy" className="font-semibold text-brand">Energia ›</a>}
</span>
);
return (
<Card title="Energia – dziś" aside={partial ? <Pill tone="warn">Tylko część danych</Pill> : undefined}>
<Card title="Energia – dziś" aside={aside}>
{!e ? <Loading /> : (
<div className="flex flex-wrap items-center gap-5">
<div className="relative size-36 shrink-0">
<div className={`relative size-36 shrink-0 ${isStale(e?.time) ? "opacity-50" : ""}`}>
<PieChart width={144} height={144}>
<Pie data={parts} dataKey="value" innerRadius={50} outerRadius={68} startAngle={90} endAngle={-270}
<Pie data={parts} dataKey="value" innerRadius={54} outerRadius={68} startAngle={90} endAngle={-270}
stroke="none" isAnimationActive={false}>
{parts.map((p) => <Cell key={p.name} fill={p.colour} />)}
</Pie>
</PieChart>
<div className="absolute inset-0 grid place-items-center text-center">
<div><p className="text-xl font-bold tabular-nums">{kwh(load)}</p><p className="text-xs text-soft">zużycie</p></div>
<div><p className="text-lg font-bold tabular-nums">{kwh(load)}</p><p className="text-xs text-soft">zużycie</p></div>
</div>
</div>
<div className="min-w-44 flex-1 text-sm">
@@ -160,9 +176,9 @@ export function EnergyToday({ e }: { e: EnergyData | undefined }) {
);
}
function Stat({ icon, label, value, note }: { icon: IconName; label: string; value: string; note?: string }) {
function Stat({ icon, label, value, note, dim }: { icon: IconName; label: string; value: string; note?: string; dim: boolean }) {
return (
<Card>
<Card className={dim ? "opacity-60" : ""}>
<div className="flex items-center gap-3">
<span className="grid size-10 place-items-center rounded-xl bg-brand-soft text-brand"><Icon name={icon} /></span>
<div>
@@ -175,8 +191,26 @@ function Stat({ icon, label, value, note }: { icon: IconName; label: string; val
);
}
const reading = (d: EnergyDetailsDevice, group: string, label?: string) =>
d.groups.find((g) => g.title === group)?.items.filter((item) => !label || item.label === label) ?? [];
// A value wraps between its parts, never inside one.
const unbreakable = (text: string) => text.replaceAll(" ", "\u00a0");
/** The main current values of a device, from the details and, for the house meter, from the flow. */
export function deviceSummary(d: EnergyDetailsDevice, e: EnergyData | undefined): string {
if (d.id === "deye") {
const pv = reading(d, "Panele PV").reduce((sum, item) => sum + item.value, 0);
return [`Bateria ${percent(reading(d, "Bateria", "Naładowanie")[0]?.value)}`, `PV ${power(pv)}`].map(unbreakable).join(" · ");
}
if (d.id === "fronius") return `PV ${power(reading(d, "Teraz", "Moc PV")[0]?.value)}`;
if (d.id === "house-meter" && e) return `${power(magnitude(e.gridPowerW))} ${gridText(e.gridPowerW)}`;
return "";
}
function Devices() {
const { data } = usePoll<EnergyDetails>("api/energy/details");
const { data: energy } = usePoll<EnergyData>("api/energy");
const [openId, setOpenId] = useState<string | null>(null);
const close = useCallback(() => setOpenId(null), []);
const open = data?.devices.find((d) => d.id === openId);
@@ -184,7 +218,7 @@ function Devices() {
<Card title="Urządzenia">
{!data ? <Loading /> : (
<div className="grid gap-3 sm:grid-cols-3">
{data.devices.map((d) => <DetailsButton key={d.id} device={d} onOpen={() => setOpenId(d.id)} />)}
{data.devices.map((d) => <DetailsButton key={d.id} device={d} summary={deviceSummary(d, energy)} onOpen={() => setOpenId(d.id)} />)}
</div>
)}
{open && <DeviceDetails device={open} onClose={close} />}
@@ -196,6 +230,7 @@ function Now({ onBattery }: { onBattery: () => void }) {
const { data: e, offline } = usePoll<EnergyData>("api/energy");
if (!e) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
const missing = silentInverters(e);
const quiet = quietNodes(e);
return (
<>
<Freshness offline={offline} time={e.time} />
@@ -204,10 +239,10 @@ function Now({ onBattery }: { onBattery: () => void }) {
<Card title="Energia teraz" className="lg:col-span-2"><FlowPicture e={e} onBattery={onBattery} /></Card>
<EnergyToday e={e} />
<div className="grid gap-4 sm:grid-cols-2 lg:col-span-3 lg:grid-cols-4">
<Stat icon="sun" label="Produkcja PV" value={power(e.pvPowerW)} note={missing.length ? "tylko część: brakuje danych z falownika" : "Deye + Fronius"} />
<Stat icon="home" label="Zużycie domu" value={power(e.loadPowerW)} />
<Stat icon="plug" label="Sieć" value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
<Stat icon="battery" label="Bateria" value={percent(e.batterySocPct)} note={batteryText(e.batteryPowerW)} />
<Stat dim={quiet.includes("pv")} icon="sun" label="Produkcja PV" value={power(e.pvPowerW)} note={missing.length ? "tylko część: brakuje danych z falownika" : "Deye + Fronius"} />
<Stat dim={quiet.includes("house")} icon="home" label="Zużycie domu" value={power(e.loadPowerW)} />
<Stat dim={quiet.includes("grid")} icon="plug" label="Sieć" value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
<Stat dim={quiet.includes("battery")} icon="battery" label="Bateria" value={percent(e.batterySocPct)} note={batteryText(e.batteryPowerW)} />
</div>
</div>
</>
+1 -1
View File
@@ -53,7 +53,7 @@ describe("Statystyki", () => {
it("shows the totals, the starts as a minimum and the heater highlighted when it ran", async () => {
await open();
expect(await screen.findByText("co najmniej 1")).toBeInTheDocument();
expect(await screen.findByText("Załączenia sprężarki, co najmniej")).toBeInTheDocument();
expect(screen.getAllByText("0,2 h").length).toBeGreaterThan(0);
expect(screen.getByText("Grzałka").closest("[data-alert]")).toHaveAttribute("data-alert", "true");
expect(screen.getByText("Prąd z grzałki jest drogi")).toBeInTheDocument();
+2 -9
View File
@@ -2,7 +2,7 @@ import { useState } from "react";
import { number } from "./format";
import { usePoll } from "./poll";
import { Stats } from "./Stats";
import { SubViewPanel, SubViewSwitch, useSubView } from "./SubView";
import { RangeToggle, SubViewPanel, SubViewSwitch, useSubView } from "./SubView";
import { TimeChart, toRows, type Range } from "./TimeChart";
import type { History as HistoryData } from "./types";
import { Card, Freshness, Loading, PageTitle } from "./ui";
@@ -59,14 +59,7 @@ function Charts() {
className="min-h-12 flex-1 rounded-xl border-2 border-edge bg-panel px-3 font-semibold">
{Object.entries(SERIES).map(([id, [name]]) => <option key={id} value={id}>{name}</option>)}
</select>
<div className="flex gap-2 rounded-xl bg-page p-1">
{Object.entries(RANGES).map(([id, name]) => (
<button key={id} onClick={() => setRange(id as Range)} aria-pressed={id === range}
className="min-h-10 rounded-lg px-4 font-semibold text-soft aria-pressed:bg-panel aria-pressed:text-brand aria-pressed:shadow-sm">
{name}
</button>
))}
</div>
<RangeToggle label="Okres" options={RANGES} value={range} onSelect={setRange} />
</div>
<Chart series={series} range={range} />
</Card>
+49 -12
View File
@@ -1,9 +1,8 @@
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { example, serve } from "./fixtures";
import type { HeatPump } from "./types";
import { PI_SENSOR_TITLE } from "./Overview";
const overview = async () => (await import("./Overview")).Overview;
const fresh = (hp: HeatPump) => ({ ...hp, time: new Date().toISOString() });
const history = { series: "x", unit: "°C", points: [] };
@@ -19,29 +18,67 @@ const glowing = (container: HTMLElement) =>
[...container.querySelectorAll("[data-glow][data-on=true]")].map((p) => p.getAttribute("data-glow"));
describe("Overview", () => {
it("shows the sensor of the Raspberry Pi in the living room", async () => {
serveHeatPump(fresh(example("heat-pump")));
it("shows the four key figures once, with the living room as the home temperature", async () => {
serve({ "api/heat-pump": fresh(example("heat-pump")), "api/energy": example("energy"), "api/history": history,
"api/pi-sensor": example("pi-sensor"), "api/weather": example("weather") });
const Overview = await overview();
render(<Overview />);
const card = (await screen.findByRole("heading", { name: PI_SENSOR_TITLE })).closest("section")!;
expect(card).toHaveTextContent("Temperatura20,2 °C");
expect(card).toHaveTextContent("Wilgotność45,8 %");
expect(card).toHaveTextContent("Ciśnienie986 hPa");
const now = (await screen.findByRole("region", { name: "Teraz" }));
const [water, home, outdoor, power] = [...now.children];
expect(water).toHaveTextContent("Ciepła woda13,6 °Ccel 8,0 °C");
expect(home).toHaveTextContent("W domu20,2 °Cwilgotność 46 %");
expect(outdoor).toHaveTextContent("Na zewnątrz4,3 °CPochmurno");
expect(power).toHaveTextContent("Prąd");
expect(power).toHaveTextContent(/pobór|oddawanie|bez wymiany/);
});
it("says there is no reading when the sensor is stale", async () => {
it("falls back to the pump's room sensor when the living room has no reading", async () => {
serve({ "api/heat-pump": fresh(example("heat-pump")), "api/energy": example("energy"), "api/history": history,
"api/pi-sensor": example("pi-sensor-stale") });
const Overview = await overview();
render(<Overview />);
const card = (await screen.findByRole("heading", { name: PI_SENSOR_TITLE })).closest("section")!;
expect(card).toHaveTextContent("Temperaturabrak odczytu");
expect(card).toHaveTextContent("Wilgotnośćbrak odczytu");
const home = (await screen.findByRole("region", { name: "Teraz" })).children[1];
expect(home).toHaveTextContent("W domu21,2 °Cczujnik pompy");
});
it("lists the living room and the other temperatures once in the details card", async () => {
serveHeatPump(fresh(example("heat-pump")));
const Overview = await overview();
render(<Overview />);
const card = (await screen.findByRole("heading", { name: "Szczegóły temperatur" })).closest("section")!;
expect(card).toHaveTextContent("Salon20,2 °C");
expect(card).toHaveTextContent("Wilgotność w salonie45,8 %");
expect(card).toHaveTextContent("Ciśnienie986 hPa");
expect(card).toHaveTextContent("Różnica1,7 °C");
expect(within(card).getAllByRole("heading", { level: 3 }).map((h) => h.textContent))
.toEqual(["Dom", "Woda i ogrzewanie", "Dolne źródło", "Nastawy"]);
});
it("says there is no reading in the details when the sensor is stale", async () => {
serve({ "api/heat-pump": fresh(example("heat-pump")), "api/energy": example("energy"), "api/history": history,
"api/pi-sensor": example("pi-sensor-stale") });
const Overview = await overview();
render(<Overview />);
const card = (await screen.findByRole("heading", { name: "Szczegóły temperatur" })).closest("section")!;
expect(card).toHaveTextContent("Salonbrak odczytu");
expect(card).toHaveTextContent("Wilgotność w saloniebrak odczytu");
expect(card).toHaveTextContent("Ciśnieniebrak odczytu");
});
it("offers the four devices and a link to the Energia screen", async () => {
serveHeatPump(fresh(example("heat-pump")));
const Overview = await overview();
render(<Overview />);
const devices = await screen.findByRole("region", { name: "Stan urządzeń" });
expect(devices.children).toHaveLength(4);
expect(screen.getByRole("link", { name: "Energia ›" })).toHaveAttribute("href", "#energy");
});
it("shows a running part in the red accent and a stopped one in grey", async () => {
const hp = fresh(example<HeatPump>("heat-pump"));
serveHeatPump({ ...hp, state: "HEATING_CWU", compressorPercent: 60, pumps: { ...hp.pumps, cwu: true } });
+81 -107
View File
@@ -1,13 +1,15 @@
import type { ReactNode } from "react";
import { celsius, degrees, hour, longDate, number, percent, power } from "./format";
import { celsius, hour, longDate, number, percent, power } from "./format";
import { Icon, type IconName } from "./icons";
import { flowing } from "./flow";
import { usePoll } from "./poll";
import { Schematic } from "./Schematic";
import { Legend, TimeChart, toRows, type Line } from "./TimeChart";
import type { Energy, HeatPump, History, PiSensor, PumpState } from "./types";
import { Card, Dot, Freshness, Loading, PageTitle, Pill, Row } from "./ui";
import type { Energy, HeatPump, History, PiSensor, PumpState, Weather } from "./types";
import { Card, Dot, Freshness, Loading, PageTitle, Pill } from "./ui";
import { EnergyToday, gridText } from "./Energy";
import { useWeather, WeatherCard, WeatherWarnings } from "./Weather";
import { TemperatureDetails } from "./Details";
import { useWeather, WeatherCard, WeatherIcon, WeatherWarnings } from "./Weather";
const HEADLINES: Record<PumpState, string> = {
IDLE: "Pompa odpoczywa",
@@ -38,49 +40,76 @@ function StatusPill({ hp }: { hp: HeatPump }) {
return hp.state === "IDLE" ? <Pill tone="off">Praca prawidłowa</Pill> : <Pill tone="run" live>{HEADLINES[hp.state]}</Pill>;
}
function Tile({ icon, title, value, note, status, children }: {
icon: IconName; title: string; value: ReactNode; note: string; status: ReactNode; children: ReactNode;
}) {
const running = (on: boolean | null, yes = "Pracuje") =>
on == null ? <Pill tone="off">Brak odczytu</Pill> : on ? <Pill tone="run" live>{yes}</Pill> : <Pill tone="off">Stoi</Pill>;
// One key figure; the number never wraps, so it is sized to fit two across a phone.
function Figure({ icon, label, value, note }: { icon: IconName; label: string; value: string; note: ReactNode }) {
return (
<Card className="flex flex-col">
<div className="flex items-start gap-3">
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-brand-soft text-brand">
<Icon name={icon} className="size-6" />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold">{title}</p>
<p className="text-2xl font-bold tabular-nums tracking-tight">{value}</p>
<p className="text-xs text-soft">{note}</p>
</div>
</div>
<div className="my-3">{status}</div>
<div className="mt-auto text-sm [&>div]:text-sm">{children}</div>
<Card className="min-w-0 p-3! sm:p-5!">
<p className="flex items-center gap-1.5 text-sm font-semibold text-soft"><Icon name={icon} className="size-4 shrink-0" />{label}</p>
<p className="mt-1 whitespace-nowrap text-[1.7rem] font-bold leading-tight tabular-nums tracking-tight sm:text-3xl">{value}</p>
<p className="mt-0.5 flex min-h-5 items-center gap-1.5 text-xs text-soft sm:text-sm">{note}</p>
</Card>
);
}
const running = (on: boolean | null, yes = "Pracuje") =>
on == null ? <Pill tone="off">Brak odczytu</Pill> : on ? <Pill tone="run" live>{yes}</Pill> : <Pill tone="off">Stoi</Pill>;
const gridNote = (watts: number | null | undefined) =>
watts == null ? "brak danych o sieci"
: flowing(watts) ? `${gridText(watts)} ${power(Math.abs(watts))}` : gridText(watts);
function Temperatures({ hp }: { hp: HeatPump }) {
const t = hp.temperatures;
const rows: [IconName, string, number | null, string][] = [
["thermometer", "Na zewnątrz", t.outdoor, "text-cold"],
["thermometer", "Średnia zewnętrzna", t.outdoorAvg, "text-cold"],
["home", "W domu", t.indoor, "text-brand"],
["heat", "Bufor CO", t.coBuffer, "text-hot"],
["drop", "Ciepła woda (CWU)", t.cwu, "text-hot"],
["fan", "Wyjście pompy", t.pumpOutlet, "text-hot"],
];
// The four figures the family checks first; each value is shown here and nowhere else on the screen.
function Now({ hp, e, pi, weather }: { hp: HeatPump; e: Energy | undefined; pi: PiSensor | undefined; weather: Weather | undefined }) {
const home = pi?.temperature ?? hp.temperatures.indoor;
const humidity = pi?.temperature == null ? null : pi.humidity;
return (
<Card title="Temperatury">
{rows.map(([icon, label, value, colour]) => (
<Row key={label} label={label} value={celsius(value)} icon={<span className={colour}><Icon name={icon} /></span>} />
))}
<section aria-label="Teraz" className="grid grid-cols-2 gap-3 sm:gap-4 lg:col-span-3 lg:grid-cols-4">
<Figure icon="drop" label="Ciepła woda" value={celsius(hp.temperatures.cwu)} note={`cel ${celsius(hp.targets.cwu)}`} />
<Figure icon="home" label="W domu" value={celsius(home)}
note={humidity == null ? "czujnik pompy" : `wilgotność ${number(humidity)} %`} />
<Figure icon="thermometer" label="Na zewnątrz" value={celsius(hp.temperatures.outdoor)}
note={weather && <><WeatherIcon code={weather.today.weatherCode} className="size-5" />{weather.today.description}</>} />
<Figure icon="sun" label="Prąd" value={power(e?.pvPowerW)} note={gridNote(e?.gridPowerW)} />
</section>
);
}
// A device on one row: what it is, what it does now, and the one thing only it knows.
function Tile({ icon, title, detail, value, status }: {
icon: IconName; title: string; detail: string; value?: string; status: ReactNode;
}) {
return (
<Card className="flex items-center gap-3 p-3! sm:p-5!">
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-brand-soft text-brand">
<Icon name={icon} className="size-6" />
</span>
<div className="min-w-0 flex-1">
<p className="font-semibold">{title}</p>
<p className="text-xs text-soft sm:text-sm">{detail}</p>
</div>
<div className="flex flex-col items-end gap-1">
{value && <p className="text-xl font-bold tabular-nums">{value}</p>}
{status}
</div>
</Card>
);
}
function DeviceTiles({ hp }: { hp: HeatPump }) {
return (
<section aria-label="Stan urządzeń" className="grid gap-3 sm:grid-cols-2 sm:gap-4">
<Tile icon="fan" title="Pompa ciepła" detail={`sprężarka, grzałka ${onOff(hp.heaterOn, "włączona", "wyłączona")}`}
value={hp.compressorPercent == null ? "—" : `${hp.compressorPercent} %`}
status={running(hp.compressorPercent == null ? null : hp.compressorPercent > 0)} />
<Tile icon="tank" title="Zbiornik CWU" detail={`cyrkulacja ${onOff(hp.pumps.circulation, "pracuje", "stoi")}`}
status={running(hp.pumps.cwu, "Grzeje wodę")} />
<Tile icon="heat" title="Ogrzewanie CO" detail={`pompa bufora ${onOff(hp.pumps.buffer, "pracuje", "stoi")}`}
status={running(hp.pumps.co, "Grzeje dom")} />
<Tile icon="borehole" title="Odwierty" detail="dolne źródło ciepła" status={running(hp.pumps.ground)} />
</section>
);
}
// Every moving part as a chip under the picture: what runs now is red and pulses.
function Devices({ hp }: { hp: HeatPump }) {
const compressor = hp.compressorPercent == null ? null : hp.compressorPercent > 0;
@@ -107,37 +136,6 @@ function Devices({ hp }: { hp: HeatPump }) {
);
}
function Targets({ hp }: { hp: HeatPump }) {
const s = hp.setpoints;
return (
<Card title="Cele i nastawy">
<Row label="Pokój, cel teraz" value={celsius(hp.targets.room)} />
<Row label="Woda grzewcza CO, cel" value={celsius(hp.targets.co)} />
<Row label="Ciepła woda CWU, cel" value={celsius(hp.targets.cwu)} />
<Row label="CWU oszczędnie · komfort" value={`${degrees(s.cwuEconomy)} · ${degrees(s.cwuComfort)}`} />
<Row label="CO oszczędnie · komfort" value={`${degrees(s.coEconomy)} · ${degrees(s.coComfort)}`} />
</Card>
);
}
// The Raspberry Pi with the BME280 stands in the living room.
export const PI_SENSOR_TITLE = "Salon";
const NO_READING = "brak odczytu";
const reading = (value: number | null | undefined, digits: number, unit: string) =>
value == null ? NO_READING : `${number(value, digits)} ${unit}`;
function PiSensorCard() {
const { data } = usePoll<PiSensor>("api/pi-sensor");
return (
<Card title={PI_SENSOR_TITLE}>
<Row label="Temperatura" value={reading(data?.temperature, 1, "°C")} />
<Row label="Wilgotność" value={reading(data?.humidity, 1, "%")} />
<Row label="Ciśnienie" value={reading(data?.pressure, 0, "hPa")} />
</Card>
);
}
function useTemperatureHistory() {
const cwu = usePoll<History>("api/history?series=cwu_temp&range=24h", HISTORY_MS);
const co = usePoll<History>("api/history?series=co_buffer_temp&range=24h", HISTORY_MS);
@@ -172,6 +170,7 @@ function NoReading() {
export function Overview() {
const { data: hp, offline } = usePoll<HeatPump>("api/heat-pump");
const { data: e } = usePoll<Energy>("api/energy");
const { data: pi } = usePoll<PiSensor>("api/pi-sensor");
const weather = useWeather();
const now = hp?.time ? new Date(hp.time) : null;
const title = (
@@ -193,48 +192,23 @@ export function Overview() {
{weather && <WeatherWarnings warnings={weather.warnings} />}
{hp.time == null ? <NoReading /> : (
<div className="grid gap-4 lg:grid-cols-3">
<Card title="Schemat instalacji" aside={<StatusPill hp={hp} />} className="lg:col-span-2 lg:row-span-2">
<Schematic hp={hp} pvPowerW={e?.pvPowerW} />
<Devices hp={hp} />
<p className="mt-3 text-xs text-soft">
Rury płyną, gdy pracuje pompa obiegu, a stojące są szare. Kolektor słoneczny nie ma czujnika, więc nie wiemy, czy jego obieg pracuje.
</p>
</Card>
<Temperatures hp={hp} />
<Targets hp={hp} />
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:col-span-3 xl:grid-cols-5">
<Tile icon="fan" title="Pompa ciepła" note="sprężarka"
value={hp.compressorPercent == null ? "—" : `${hp.compressorPercent} %`}
status={running(hp.compressorPercent == null ? null : hp.compressorPercent > 0)}>
<Row label="Wyjście" value={celsius(hp.temperatures.pumpOutlet)} />
<Row label="Grzałka" value={onOff(hp.heaterOn, "włączona", "wyłączona")} />
</Tile>
<Tile icon="drop" title="Zbiornik CWU" note="ciepła woda" value={celsius(hp.temperatures.cwu)}
status={running(hp.pumps.cwu, "Grzeje wodę")}>
<Row label="Cel teraz" value={celsius(hp.targets.cwu)} />
<Row label="Cyrkulacja" value={onOff(hp.pumps.circulation, "pracuje", "stoi")} />
</Tile>
<Tile icon="heat" title="Ogrzewanie CO" note="bufor" value={celsius(hp.temperatures.coBuffer)}
status={running(hp.pumps.co, "Grzeje dom")}>
<Row label="Cel bufora" value={celsius(hp.targets.co)} />
<Row label="Pokój" value={celsius(hp.temperatures.indoor)} />
</Tile>
<Tile icon="borehole" title="Odwierty" note="do pompy" value={celsius(hp.temperatures.groundIn)}
status={running(hp.pumps.ground)}>
<Row label="Z pompy" value={celsius(hp.temperatures.groundOut)} />
<Row label="Różnica" value={hp.temperatures.groundIn != null && hp.temperatures.groundOut != null
? celsius(hp.temperatures.groundIn - hp.temperatures.groundOut) : "—"} />
</Tile>
<Tile icon="sun" title="Prąd teraz" note="produkcja PV" value={power(e?.pvPowerW)}
status={e ? <Pill tone={(e.gridPowerW ?? 0) < 0 ? "ok" : "off"}>Sieć: {gridText(e.gridPowerW)}</Pill> : <Pill tone="off">Brak danych</Pill>}>
<Row label="Zużycie domu" value={power(e?.loadPowerW)} />
<Row label="Bateria" value={percent(e?.batterySocPct)} />
</Tile>
<Now hp={hp} e={e} pi={pi} weather={weather} />
<div className="grid content-start gap-4 lg:col-span-2">
<Card title="Schemat instalacji" aside={<StatusPill hp={hp} />}>
<Schematic hp={hp} pvPowerW={e?.pvPowerW} />
<Devices hp={hp} />
<p className="mt-3 text-xs text-soft">
Rury płyną, gdy pracuje pompa obiegu, a stojące są szare. Kolektor słoneczny nie ma czujnika, więc nie wiemy, czy jego obieg pracuje.
</p>
</Card>
<DeviceTiles hp={hp} />
</div>
{weather && <WeatherCard weather={weather} />}
<div className="grid content-start gap-4">
{weather && <WeatherCard weather={weather} />}
<TemperatureDetails hp={hp} pi={pi} />
</div>
<EnergyToday e={e} link />
<TemperatureChart />
<PiSensorCard />
<EnergyToday e={e} />
</div>
)}
</>
+108
View File
@@ -0,0 +1,108 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { answer, example, serve } from "./fixtures";
const button = () => screen.getByRole("button", { name: "Odśwież" });
const status = () => screen.getByRole("status");
const noStatus = () => screen.queryByRole("status");
// The button and a poller that shows api/heat-pump, as a screen would.
async function shown(onPost: () => Promise<Response>) {
const fetch = serve({ "api/heat-pump": example("heat-pump") }, onPost);
const { RefreshButton } = await import("./Refresh");
const { usePoll } = await import("./poll");
function Screen() {
usePoll("api/heat-pump");
return <RefreshButton />;
}
render(<Screen />);
await act(async () => {});
return fetch;
}
const posts = (fetch: ReturnType<typeof serve>) => fetch.mock.calls.filter(([, init]) => init?.method === "POST");
const gets = (fetch: ReturnType<typeof serve>) => fetch.mock.calls.filter(([, init]) => init?.method !== "POST");
describe("RefreshButton", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["setTimeout", "setInterval", "Date"] });
vi.setSystemTime(new Date("2026-10-24T10:00:00Z"));
});
afterEach(() => vi.useRealTimers());
it("asks once, shows the progress, then fetches again and says when it updated", async () => {
const fetch = await shown(() => answer({ requested: ["deye"], skipped: [] }));
const before = gets(fetch).length;
fireEvent.click(button());
fireEvent.click(button());
await act(async () => {});
expect(posts(fetch)).toHaveLength(1);
expect(posts(fetch)[0][1]!.headers).toMatchObject({ "X-Requested-With": "home-pump" });
expect(status()).toHaveTextContent("Odświeżanie…");
expect(button()).toBeDisabled();
expect(gets(fetch)).toHaveLength(before);
await act(() => vi.advanceTimersByTimeAsync(5000));
expect(gets(fetch)).toHaveLength(before + 1);
expect(status()).toHaveTextContent("Zaktualizowano 12:00:05");
expect(button()).toBeEnabled();
});
it("says the data is fresh when every device was skipped and waits out the cooldown", async () => {
const fetch = await shown(() => answer({
requested: [],
skipped: [{ device: "heat-pump", retryInSeconds: 41 }, { device: "deye", retryInSeconds: 30 }],
}));
const before = gets(fetch).length;
fireEvent.click(button());
await act(async () => {});
expect(status()).toHaveTextContent("Dane są świeże – spróbuj za 30 s");
expect(button()).toBeDisabled();
expect(gets(fetch)).toHaveLength(before);
await act(() => vi.advanceTimersByTimeAsync(10_000));
expect(status()).toHaveTextContent("Dane są świeże – spróbuj za 20 s");
await act(() => vi.advanceTimersByTimeAsync(20_000));
expect(noStatus()).toBeNull();
expect(button()).toBeEnabled();
});
it("shows a short error when the backend has no gateway, and allows another try", async () => {
const fetch = await shown(() => answer({ error: "gateway unreachable" }, 503));
fireEvent.click(button());
await act(async () => {});
expect(status()).toHaveTextContent("Nie udało się odświeżyć danych");
expect(button()).toBeEnabled();
fireEvent.click(button());
await act(async () => {});
expect(posts(fetch)).toHaveLength(2);
});
it("says so when the network is down", async () => {
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new TypeError("offline"))));
const { RefreshButton } = await import("./Refresh");
render(<RefreshButton />);
fireEvent.click(button());
await act(async () => {});
expect(status()).toHaveTextContent("Brak połączenia z siecią");
});
it("sits in the heading row of a screen, next to the title", async () => {
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new TypeError("offline"))));
const { PageTitle } = await import("./ui");
render(<PageTitle title="Stan" />);
expect(screen.getByRole("banner")).toContainElement(button());
expect(button()).toHaveTextContent("Odśwież");
});
});
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import { postRefresh, WriteError } from "./api";
import { hourWithSeconds } from "./format";
import { Icon } from "./icons";
import { refreshAll } from "./poll";
// The gateway polls the devices about 3 s after the request; the answers are fetched after this.
export const SETTLE_MS = 5000;
type State =
| { kind: "idle" }
| { kind: "busy" }
| { kind: "done"; at: Date }
| { kind: "fresh"; until: number }
| { kind: "error"; text: string };
const failure = (error: unknown) =>
error instanceof WriteError && error.status === 0 ? "Brak połączenia z siecią" : "Nie udało się odświeżyć danych";
// Asks the gateway for current readings; it skips devices it polled less than a minute ago.
export function RefreshButton() {
const [state, setState] = useState<State>({ kind: "idle" });
const [now, setNow] = useState(Date.now());
const waiting = state.kind === "fresh" ? Math.ceil((state.until - now) / 1000) : 0;
useEffect(() => {
if (state.kind !== "fresh") return;
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [state.kind]);
useEffect(() => {
if (state.kind === "fresh" && waiting <= 0) setState({ kind: "idle" });
}, [state.kind, waiting]);
const tap = async () => {
setState({ kind: "busy" });
try {
const { requested, skipped } = await postRefresh();
if (requested.length > 0) {
await new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
refreshAll();
setState({ kind: "done", at: new Date() });
} else {
const retry = Math.min(...skipped.map((s) => s.retryInSeconds));
setNow(Date.now());
setState({ kind: "fresh", until: Date.now() + retry * 1000 });
}
} catch (error) {
setState({ kind: "error", text: failure(error) });
}
};
const text = {
idle: "", busy: "Odświeżanie…", error: state.kind === "error" ? state.text : "",
done: state.kind === "done" ? `Zaktualizowano ${hourWithSeconds(state.at)}` : "",
fresh: `Dane są świeże – spróbuj za ${waiting} s`,
}[state.kind];
const busy = state.kind === "busy";
return (
<div className="flex shrink-0 flex-col items-end gap-1">
<button onClick={tap} disabled={busy || state.kind === "fresh"}
className="flex min-h-11 items-center justify-center gap-2 rounded-xl border-2 border-brand bg-panel px-3 font-bold text-brand transition active:scale-[0.98] disabled:opacity-40">
<Icon name="refresh" className={`size-5 ${busy ? "animate-spin" : ""}`} />
Odśwież
</button>
{text && <p role="status" className={`max-w-44 text-right text-xs ${state.kind === "error" ? "text-bad" : "text-soft"}`}>{text}</p>}
</div>
);
}
+28 -19
View File
@@ -14,12 +14,21 @@ const paragraph = (text: string) => (_: string, el: Element | null) => el?.tagNa
const posts = (fetch: ReturnType<typeof serve>) => fetch.mock.calls.filter(([, init]) => init?.method === "POST");
describe("Settings", () => {
it("describes each quick action in one line", async () => {
serve({ "api/setpoints": example("setpoints") });
const Settings = await settings();
render(<Settings />);
expect(await screen.findByText("Ciepła woda – oszczędnie: 45 °C na 2 godz.")).toBeInTheDocument();
expect(screen.getByText("Obniża temperatury na czas nieobecności, do dnia powrotu")).toBeInTheDocument();
});
it("writes only after the summary is confirmed", async () => {
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-applied")));
const Settings = await settings();
render(<Settings />);
await userEvent.click(await screen.findByLabelText("Więcej: Ciepła woda, oszczędnie"));
await userEvent.click(await screen.findByLabelText("Więcej: Ciepła woda – oszczędnie"));
await userEvent.click(screen.getByRole("button", { name: "Zapisz" }));
expect(posts(fetch)).toHaveLength(0);
expect(screen.getByText("Ustawię 9 °C. Za 2 godz. wrócimy do 8 °C.")).toBeInTheDocument();
@@ -35,7 +44,7 @@ describe("Settings", () => {
const Settings = await settings();
render(<Settings />);
await userEvent.click(await screen.findByLabelText("Mniej: Ogrzewanie domu, komfort"));
await userEvent.click(await screen.findByLabelText("Mniej: Ogrzewanie – komfort"));
await userEvent.click(screen.getByRole("button", { name: "Zapisz" }));
await userEvent.click(screen.getByRole("button", { name: "Tak, zapisz" }));
@@ -48,7 +57,7 @@ describe("Settings", () => {
const Settings = await settings();
render(<Settings />);
await userEvent.click(await screen.findByLabelText("Więcej: Ciepła woda, komfort"));
await userEvent.click(await screen.findByLabelText("Więcej: Ciepła woda – komfort"));
await userEvent.click(screen.getByRole("button", { name: "Zapisz" }));
await userEvent.click(screen.getByRole("button", { name: "Tak, zapisz" }));
@@ -61,15 +70,15 @@ describe("Settings", () => {
render(<Settings />);
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
expect(screen.getByText(paragraph("Ciepła woda, komfort: 10 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
expect(screen.getByText(paragraph("Ciepła woda – komfort: 10 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
expect(posts(fetch)).toHaveLength(0);
await userEvent.click(screen.getByRole("button", { name: "Ustaw wyjazd" }));
expect(posts(fetch)[0][0]).toBe("api/presets/away");
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Tylko część zmian jest zapisana.");
expect(alert).toHaveTextContent("Ciepła woda, komfort: zapisano");
expect(alert).toHaveTextContent("Ogrzewanie domu, komfort: brak odpowiedzi, nie wiadomo, czy doszło do pompy");
expect(alert).toHaveTextContent("Ciepła woda – komfort: zapisano");
expect(alert).toHaveTextContent("Ogrzewanie – komfort: brak odpowiedzi, nie wiadomo, czy doszło do pompy");
});
it("asks for the return time of a trip and sends the chosen time with the local offset", async () => {
@@ -107,10 +116,10 @@ describe("Settings", () => {
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
expect(screen.getAllByRole("status").map((o) => o.textContent)).toEqual(["10 °C", "15 °C"]);
await userEvent.click(screen.getByLabelText("Więcej: Ciepła woda, komfort"));
await userEvent.click(screen.getByLabelText("Więcej: Ciepła woda, komfort"));
await userEvent.click(screen.getByLabelText("Mniej: Ogrzewanie domu, komfort"));
expect(screen.getByText(paragraph("Ciepła woda, komfort: 12 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
await userEvent.click(screen.getByLabelText("Więcej: Ciepła woda – komfort"));
await userEvent.click(screen.getByLabelText("Więcej: Ciepła woda – komfort"));
await userEvent.click(screen.getByLabelText("Mniej: Ogrzewanie – komfort"));
expect(screen.getByText(paragraph("Ciepła woda – komfort: 12 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Ustaw wyjazd" }));
const body = JSON.parse(String(posts(fetch)[0][1]!.body));
@@ -125,8 +134,8 @@ describe("Settings", () => {
render(<Settings />);
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
expect(screen.getByLabelText("Więcej: Ogrzewanie domu, komfort")).toBeDisabled();
expect(screen.getByText(paragraph("Ogrzewanie domu, komfort: 14 °C do powrotu, potem wróci 15 °C."))).toBeInTheDocument();
expect(screen.getByLabelText("Więcej: Ogrzewanie – komfort")).toBeDisabled();
expect(screen.getByText(paragraph("Ogrzewanie – komfort: 14 °C do powrotu, potem wróci 15 °C."))).toBeInTheDocument();
});
it("disables a trip temperature that cannot be lowered and the confirmation with it", async () => {
@@ -137,8 +146,8 @@ describe("Settings", () => {
render(<Settings />);
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
expect(screen.getByLabelText("Mniej: Ogrzewanie domu, komfort")).toBeDisabled();
expect(screen.getByLabelText("Więcej: Ogrzewanie domu, komfort")).toBeDisabled();
expect(screen.getByLabelText("Mniej: Ogrzewanie – komfort")).toBeDisabled();
expect(screen.getByLabelText("Więcej: Ogrzewanie – komfort")).toBeDisabled();
expect(screen.getByText("Już na minimum (5 °C), nie da się obniżyć.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Ustaw wyjazd" })).toBeDisabled();
});
@@ -189,7 +198,7 @@ describe("Settings", () => {
const Settings = await settings();
render(<Settings />);
expect(await screen.findByText(paragraph("Ciepła woda, komfort: 10 °C do 2 lis, 18:00, potem 43 °C."))).toBeInTheDocument();
expect(await screen.findByText(paragraph("Ciepła woda – komfort: 10 °C do 2 lis, 18:00, potem 43 °C."))).toBeInTheDocument();
});
it("labels a change without an end time as lasting while the server runs", async () => {
@@ -200,7 +209,7 @@ describe("Settings", () => {
render(<Settings />);
expect(
await screen.findByText(paragraph("Ciepła woda, komfort: 10 °C do odwołania (dopóki serwer działa), potem 45 °C.")),
await screen.findByText(paragraph("Ciepła woda – komfort: 10 °C do odwołania (dopóki serwer działa), potem 45 °C.")),
).toBeInTheDocument();
});
@@ -211,13 +220,13 @@ describe("Settings", () => {
const Settings = await settings();
render(<Settings />);
expect(await screen.findByLabelText("Więcej: Ciepła woda, oszczędnie")).toBeDisabled();
expect(screen.getByLabelText("Mniej: Ciepła woda, oszczędnie")).toBeDisabled();
expect(await screen.findByLabelText("Więcej: Ciepła woda – oszczędnie")).toBeDisabled();
expect(screen.getByLabelText("Mniej: Ciepła woda – oszczędnie")).toBeDisabled();
expect(screen.getByRole("button", { name: "Ciepła woda teraz (brak odczytu)" })).toBeDisabled();
});
describe("restore now", () => {
const restoreButton = () => screen.findByRole("button", { name: "Przywróć teraz: Ciepła woda, oszczędnie" });
const restoreButton = () => screen.findByRole("button", { name: "Przywróć teraz: Ciepła woda – oszczędnie" });
const restoreAnswer = (outcome: string, status: number) => () => answer({ outcome, message: "x" }, status);
it("asks first, then restores and reloads the state", async () => {
+18 -7
View File
@@ -8,10 +8,10 @@ import { Button, Card, Freshness, Loading, PageTitle, Stepper } from "./ui";
const URL = "api/setpoints";
const NAMES: Record<string, string> = {
cwu_economy: "Ciepła woda, oszczędnie",
cwu_comfort: "Ciepła woda, komfort",
co_economy: "Ogrzewanie domu, oszczędnie",
co_comfort: "Ogrzewanie domu, komfort",
cwu_economy: "Ciepła woda – oszczędnie",
cwu_comfort: "Ciepła woda – komfort",
co_economy: "Ogrzewanie – oszczędnie",
co_comfort: "Ogrzewanie – komfort",
};
const DURATIONS = [
{ hours: 1, label: "1 godz." }, { hours: 2, label: "2 godz." },
@@ -144,6 +144,11 @@ function ReturnForm({ data, preset, busy, onCancel, onConfirm }: {
);
}
// What a preset does, in one line under its button; a held preset has no fixed values (the user picks them).
const presetHint = (p: Preset) => p.until
? "Obniża temperatury na czas nieobecności, do dnia powrotu"
: p.changes.map((c) => `${name(c.setpoint)}: ${degrees(c.value)}${c.hours == null ? " do odwołania" : ` na ${duration(c.hours)}`}`).join(", ");
function changeSentence(data: Setpoints, c: Change, held: boolean) {
const back = degrees(returnValue(data, c.setpoint));
const tail = held ? `do powrotu, potem wróci ${back}`
@@ -284,15 +289,21 @@ export function Settings() {
{data.presets.map((p) => {
const ready = presetReady(data, p.changes);
return (
<Button key={p.id} big onClick={() => { setResult(null); setPresetId(p.id); }} disabled={busy || !ready}>
{p.name}{ready ? "" : " (brak odczytu)"}
</Button>
<div key={p.id} className="flex flex-col gap-1.5">
<Button big onClick={() => { setResult(null); setPresetId(p.id); }} disabled={busy || !ready}>
{p.name}{ready ? "" : " (brak odczytu)"}
</Button>
<p className="text-center text-sm text-soft">{presetHint(p)}</p>
</div>
);
})}
</div>
</Card>
</div>
<Card title="Nastawy">
<p className="mb-3 text-sm text-soft">
„Komfort” i „oszczędnie” to dwie temperatury, między którymi pompa przełącza się sama według tygodniowego programu na sterowniku.
</p>
{data.setpoints.map((s) => {
const shown = draft?.name === s.name ? draft.value : s.value;
const label = name(s.name);
+32 -19
View File
@@ -3,11 +3,13 @@ import { Bar, CartesianGrid, ComposedChart, Line, ResponsiveContainer, Tooltip,
import { DASH, degrees, number } from "./format";
import { usePoll } from "./poll";
import type { Stats as StatsData, StatsDay } from "./types";
import { RangeToggle } from "./SubView";
import { Card, Freshness, Loading } from "./ui";
const RANGES = { "7d": "7 dni", "30d": "30 dni" } as const;
type RangeId = keyof typeof RANGES;
const STATS_MS = 5 * 60000; // the backend reads every raw heat pump row of the range for each request
const TAKTING_STARTS_PER_DAY = 24;
const BAR = "#0f8a5f";
const TEMPERATURE = "#3b82f6";
@@ -26,12 +28,14 @@ export function takting(days: StatsDay[]) {
return days.some((d) => (d.compressorStarts ?? 0) > TAKTING_STARTS_PER_DAY);
}
function Tile({ label, value, hint, alert = false }: { label: string; value: string; hint?: string; alert?: boolean }) {
function Tile({ label, value, hint, alert = false, wide = false }: {
label: string; value: string; hint?: string; alert?: boolean; wide?: boolean;
}) {
return (
<div data-alert={alert} className={`rounded-xl border p-3 ${alert ? "border-alarm bg-run-soft" : "border-edge bg-page"}`}>
<div data-alert={alert} className={`rounded-xl border p-3 ${wide ? "col-span-2 lg:col-span-1" : ""} ${alert ? "border-alarm bg-run-soft" : "border-edge bg-page"}`}>
<p className="text-2xl font-bold tabular-nums">{value}</p>
<p className="text-sm text-soft">{label}</p>
{hint && <p className="text-xs text-soft">{hint}</p>}
{hint && <p className="text-sm text-soft">{hint}</p>}
</div>
);
}
@@ -41,27 +45,43 @@ function Totals({ stats }: { stats: StatsData }) {
return (
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-5">
<Tile label="Sprężarka" value={hours(t.compressorHours)} />
<Tile label="Załączenia sprężarki" value={`co najmniej ${number(t.compressorStarts)}`} />
<Tile label="Załączenia sprężarki, co najmniej" value={number(t.compressorStarts)} />
<Tile label="Ciepła woda (CWU)" value={hours(t.cwuHours)} />
<Tile label="Ogrzewanie (CO)" value={hours(t.coHours)} />
<Tile label="Grzałka" value={hours(t.heaterHours)} alert={t.heaterHours > 0}
<Tile label="Grzałka" value={hours(t.heaterHours)} wide alert={t.heaterHours > 0}
hint={t.heaterHours > 0 ? "Prąd z grzałki jest drogi" : undefined} />
</div>
);
}
function ChartLegend() {
return (
<ul className="mb-2 flex flex-wrap gap-x-4 gap-y-1 text-sm text-soft">
<li className="flex items-center gap-1.5">
<span className="size-3 rounded-sm" style={{ background: BAR }} aria-hidden="true" />słupki: godziny pracy sprężarki
</li>
<li className="flex items-center gap-1.5">
<span className="h-1 w-4 rounded-full" style={{ background: TEMPERATURE }} aria-hidden="true" />linia: średnia temp. zewnętrzna
</li>
</ul>
);
}
function DailyChart({ days }: { days: StatsDay[] }) {
const rows = days.map((d) => ({ label: dayLabel(d.date), hours: d.compressorHours, outdoor: d.avgOutdoorTemp }));
return (
<div style={{ height: 280 }} className="w-full" role="img" aria-label="Godziny pracy sprężarki w kolejnych dniach">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={rows} margin={{ top: 8, right: 0, bottom: 0, left: -8 }}>
<ComposedChart data={rows} margin={{ top: 8, right: 0, bottom: 0, left: 0 }}>
<CartesianGrid stroke="var(--color-edge)" vertical={false} />
<XAxis dataKey="label" stroke="var(--color-soft)" fontSize={12} tickLine={false} minTickGap={12} />
<YAxis yAxisId="hours" stroke="var(--color-soft)" fontSize={12} tickLine={false} axisLine={false} width={40}
domain={[0, "auto"]} tickFormatter={(v: number) => number(v, 0)} />
<YAxis yAxisId="hours" stroke="var(--color-soft)" fontSize={12} tickLine={false} axisLine={false} width={44}
domain={[0, (max: number) => Math.ceil(max / 2) * 2]} tickCount={5} allowDecimals={false}
tickFormatter={(v: number) => number(v, 0)}
label={{ value: "godz. pracy (h)", angle: -90, position: "insideLeft", offset: 4, fill: "var(--color-soft)", fontSize: 12 }} />
<YAxis yAxisId="temperature" orientation="right" stroke="var(--color-soft)" fontSize={12} tickLine={false}
axisLine={false} width={40} tickFormatter={(v: number) => number(v, 0)} />
axisLine={false} width={44} allowDecimals={false} tickFormatter={(v: number) => number(v, 0)}
label={{ value: "°C", angle: 90, position: "insideRight", offset: 4, fill: "var(--color-soft)", fontSize: 12 }} />
<Tooltip
formatter={(v, name) => [name === "Średnia temperatura zewnętrzna" ? degrees(Number(v)) : hours(Number(v)), name]}
contentStyle={{ background: "var(--color-panel)", border: "1px solid var(--color-edge)", borderRadius: 12 }}
@@ -77,7 +97,7 @@ function DailyChart({ days }: { days: StatsDay[] }) {
function StatsFor({ range }: { range: RangeId }) {
// One poller per range, so an answer is never shown for the other one.
const { data, offline } = usePoll<StatsData>(`api/stats?range=${range}`);
const { data, offline } = usePoll<StatsData>(`api/stats?range=${range}`, STATS_MS);
if (!data) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
const withData = data.days.filter((d) => d.compressorHours != null);
return (
@@ -91,9 +111,9 @@ function StatsFor({ range }: { range: RangeId }) {
Dużo załączeń – sprężarka może taktować, czyli często się włączać i wyłączać na krótko. To zużywa ją szybciej.
</p>
)}
<ChartLegend />
<DailyChart days={data.days} />
<p className="mt-2 text-sm text-soft">
Zielone słupki: godziny pracy sprężarki, niebieska linia: średnia temperatura na zewnątrz.
Wartości liczone z pomiarów co 5 minut, więc załączenia są policzone „co najmniej”: krótkie cykle między pomiarami są niewidoczne.
</p>
</>
@@ -106,14 +126,7 @@ export function Stats() {
const [range, setRange] = useState<RangeId>("7d");
return (
<Card>
<div className="mb-4 flex gap-2 rounded-xl bg-page p-1 sm:w-fit">
{(Object.entries(RANGES) as [RangeId, string][]).map(([id, name]) => (
<button key={id} onClick={() => setRange(id)} aria-pressed={id === range}
className="min-h-10 flex-1 rounded-lg px-4 font-semibold text-soft aria-pressed:bg-panel aria-pressed:text-brand aria-pressed:shadow-sm">
{name}
</button>
))}
</div>
<div className="mb-4"><RangeToggle label="Okres" options={RANGES} value={range} onSelect={setRange} /></div>
<StatsFor range={range} />
</Card>
);
+23
View File
@@ -27,4 +27,27 @@ describe("Status", () => {
expect(versions()).toHaveTextContent("Bramkanieznana");
expect(versions()).toHaveTextContent("Serwerv1.3.0");
});
it("does not call a device working when its last reading is old", async () => {
const health = example<Health>("health");
const pump = health.devices[0];
health.devices[0] = { ...pump, lastSuccess: new Date(Date.now() - 17 * 3600_000).toISOString() };
serve({ "api/health": health });
const Status = await status();
render(<Status />);
expect(await screen.findByText("brak świeżych danych")).toBeInTheDocument();
expect(screen.queryByText("działa")).toBeNull();
});
it("says a device works when it answered a moment ago", async () => {
const health = example<Health>("health");
health.devices[0] = { ...health.devices[0], lastSuccess: new Date().toISOString() };
serve({ "api/health": health });
const Status = await status();
render(<Status />);
expect(await screen.findByText("działa")).toBeInTheDocument();
expect(screen.queryByText("brak świeżych danych")).toBeNull();
});
});
+8 -3
View File
@@ -1,7 +1,8 @@
import { ago } from "./format";
import { Icon } from "./icons";
import { usePoll } from "./poll";
import type { Health } from "./types";
import { Card, Dot, Freshness, Loading, PageTitle, Row } from "./ui";
import { Card, Dot, Freshness, isStale, Loading, PageTitle, Row } from "./ui";
const DEVICE_NAMES: Record<string, string> = {
"heat-pump": "Pompa ciepła",
@@ -30,11 +31,15 @@ export function Status() {
{data.devices.length === 0 ? <p className="text-soft">Lista pojawi się, gdy dom odpowie.</p>
: data.devices.map((d) => {
const last = d.lastSuccess ? `ostatni odczyt ${ago(d.lastSuccess)}` : "brak odczytu";
// "Up" says the last try worked; a reading that old is no longer news.
const old = d.up && d.lastSuccess != null && isStale(d.lastSuccess);
return (
<Row key={d.name} icon={<Dot tone={d.up ? "ok" : "bad"} />}
<Row key={d.name} icon={<Dot tone={d.up ? (old ? "warn" : "ok") : "bad"} />}
label={<span><span className="text-ink">{DEVICE_NAMES[d.name] ?? d.name}</span><br />
<span className="text-xs">{d.up ? last : `nie odpowiada, ${last}`}</span></span>}
value={<span className={d.up ? "text-brand" : "text-alarm"}>{d.up ? "działa" : "brak"}</span>} />
value={d.up ? (old ? <span className="inline-flex items-center gap-1 text-amber-700 dark:text-amber-300">
<Icon name="alert" className="size-4" />brak świeżych danych</span> : <span className="text-brand">działa</span>)
: <span className="text-alarm">brak</span>} />
);
})}
</Card>
+16
View File
@@ -41,3 +41,19 @@ export function SubViewSwitch<T extends string>({ screen, label, views, view, on
export function SubViewPanel({ screen, view, children }: { screen: string; view: string; children: ReactNode }) {
return <div role="tabpanel" id={`${screen}-view`} aria-labelledby={`${screen}-tab-${view}`}>{children}</div>;
}
/** Two or three choices of one setting (a range); the chosen one is filled so it reads at a glance. */
export function RangeToggle<T extends string>({ label, options, value, onSelect }: {
label: string; options: Record<T, string>; value: T; onSelect: (id: T) => void;
}) {
return (
<div role="group" aria-label={label} className="flex w-full gap-1 rounded-xl border border-edge bg-page p-1 sm:w-fit">
{(Object.entries(options) as [T, string][]).map(([id, name]) => (
<button key={id} onClick={() => onSelect(id)} aria-pressed={id === value}
className="min-h-11 min-w-28 flex-1 whitespace-nowrap rounded-lg px-4 font-semibold text-soft focus-visible:outline-2 focus-visible:outline-brand aria-pressed:bg-brand aria-pressed:text-white dark:aria-pressed:text-black">
{name}
</button>
))}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { timeTicks, valueTicks } from "./TimeChart";
const HOUR = 3600000;
const start = Date.parse("2026-10-23T00:00:00+02:00");
describe("timeTicks", () => {
it("puts 4 to 6 ticks on a day, at round hours", () => {
const ticks = timeTicks(start + HOUR / 2, start + 24 * HOUR, "24h");
expect(ticks.length).toBeGreaterThanOrEqual(4);
expect(ticks.length).toBeLessThanOrEqual(6);
expect(ticks.map((t) => ((t - start) / HOUR) % 6)).toEqual(ticks.map(() => 0));
});
it("uses finer steps for a short span", () => {
expect(timeTicks(start, start + 2 * HOUR, "24h")).toHaveLength(3);
});
it("puts a tick on every midnight of 7 days (the clocks change on the 25th)", () => {
expect(timeTicks(start + HOUR, start + 7 * 24 * HOUR, "7d")).toHaveLength(6);
});
});
describe("valueTicks", () => {
it("hugs the data with round steps", () => {
expect(valueTicks(14.1, 44.2)).toEqual([10, 20, 30, 40, 50]);
});
it("copes with flat data", () => {
expect(valueTicks(5, 5)).toEqual([4, 6]);
});
});
+25 -9
View File
@@ -5,7 +5,6 @@ import type { Point } from "./types";
export type Range = "24h" | "7d";
const HOUR_MS = 3600000;
const HOURS_BETWEEN_TICKS = 6;
const GAP_FACTOR = 3; // of the median step between points
const DAY_NAMES = ["nd", "pn", "wt", "śr", "cz", "pt", "sb"];
@@ -34,14 +33,28 @@ export function toRows(series: Record<string, Point[]>): Row[] {
return sorted.flatMap((row, i) => (i && row.t - sorted[i - 1].t > limit ? [{ t: row.t - 1 }, row] : [row]));
}
// Warsaw midnights (or every 6th full Warsaw hour) inside the range, so the axis reads like a clock.
const HOUR_STEPS = [1, 2, 3, 6, 12];
const MAX_TICKS = 6;
// Warsaw midnights (7 days) or the finest whole-hour step with at most 6 ticks, so the axis reads like a clock.
export function timeTicks(t0: number, t1: number, range: Range): number[] {
const ticks = [];
for (let t = Math.ceil(t0 / HOUR_MS) * HOUR_MS; t <= t1; t += HOUR_MS) {
const h = warsaw(t).hour;
if (range === "7d" ? h === 0 : h % HOURS_BETWEEN_TICKS === 0) ticks.push(t);
}
return ticks;
const wholeHours: { t: number; h: number }[] = [];
for (let t = Math.ceil(t0 / HOUR_MS) * HOUR_MS; t <= t1; t += HOUR_MS) wholeHours.push({ t, h: warsaw(t).hour });
if (range === "7d") return wholeHours.filter(({ h }) => h === 0).map(({ t }) => t);
const ticksAt = (step: number) => wholeHours.filter(({ h }) => h % step === 0).map(({ t }) => t);
const step = HOUR_STEPS.find((s) => ticksAt(s).length <= MAX_TICKS) ?? HOUR_STEPS[HOUR_STEPS.length - 1];
return ticksAt(step);
}
/** Round values around the data (steps of 1, 2 or 5 times a power of ten), about 4 intervals. */
export function valueTicks(min: number, max: number): number[] {
const span = max - min || Math.abs(max) || 1;
const rough = span / 4;
const magnitude = 10 ** Math.floor(Math.log10(rough));
const step = ([1, 2, 5, 10].find((m) => m * magnitude >= rough) ?? 10) * magnitude;
const first = Math.floor(min / step);
const last = Math.ceil(max / step);
return Array.from({ length: last - first + 1 }, (_, i) => Number(((first + i) * step).toPrecision(12)));
}
export interface Line {
@@ -56,6 +69,8 @@ export function TimeChart({ rows, lines, unit, range, height = 260, area = false
}) {
const times = rows.map((r) => r.t);
const ticks = timeTicks(Math.min(...times), Math.max(...times), range);
const values = rows.flatMap((r) => lines.map((l) => r[l.key])).filter((v): v is number => v != null);
const valueAxis = valueTicks(Math.min(...values), Math.max(...values));
const digits = unit === "°C" ? 1 : 0;
const tick = (t: number) => (range === "7d" ? DAY_NAMES[warsaw(t + 1).weekday] : hour(new Date(t)));
return (
@@ -74,7 +89,8 @@ export function TimeChart({ rows, lines, unit, range, height = 260, area = false
<XAxis dataKey="t" type="number" scale="time" domain={["dataMin", "dataMax"]} ticks={ticks}
tickFormatter={tick} stroke="var(--color-soft)" fontSize={12} tickLine={false} />
<YAxis stroke="var(--color-soft)" fontSize={12} tickLine={false} axisLine={false} width={48}
domain={["auto", "auto"]} tickFormatter={(v: number) => number(v, 0)} />
ticks={valueAxis} domain={[valueAxis[0], valueAxis[valueAxis.length - 1]]}
tickFormatter={(v: number) => number(v, valueAxis.every(Number.isInteger) ? 0 : 1)} />
<Tooltip
labelFormatter={(t) => dayAndHour(new Date(Number(t)))}
formatter={(v, name) => [`${number(Number(v), digits)} ${unit}`, name]}
+22 -10
View File
@@ -1,4 +1,5 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";
import { example, serve } from "./fixtures";
import type { HeatPump, Weather } from "./types";
@@ -25,7 +26,6 @@ describe("Pogoda card", () => {
const card = (await screen.findByRole("heading", { name: "Pogoda" })).closest("section")!;
const [today, tomorrow, dayAfter] = within(card).getAllByRole("listitem");
expect(card).toHaveTextContent("teraz 10,6 °C");
expect(today).toHaveTextContent("Dziś");
expect(today).toHaveTextContent("Pochmurno");
expect(today).toHaveTextContent("10,9 °C / 3,8 °C");
@@ -64,27 +64,39 @@ describe("Pogoda card", () => {
const { Overview } = await import("./Overview");
render(<Overview />);
await screen.findByRole("heading", { name: "Salon" });
await screen.findByRole("heading", { name: "Szczegóły temperatur" });
expect(screen.queryByRole("heading", { name: "Pogoda" })).not.toBeInTheDocument();
});
});
describe("IMGW warnings on the overview", () => {
it("shows each warning as a banner with its degree, period and text", async () => {
it("shows each warning as one line with its degree and period, and the text on a tap", async () => {
await overview();
const banner = await screen.findByText(/Ostrzeżenie IMGW, stopień 2: Silny wiatr/);
expect(banner.closest("[role=alert]")).toHaveTextContent("Prognozuje się wystąpienie silnego wiatru");
expect(banner.closest("[role=alert]")).toHaveClass("border-orange-500");
const line = await screen.findByText(/Ostrzeżenie IMGW, stopień 2: Silny wiatr/);
const banner = line.closest("details")!;
expect(banner).toHaveClass("border-orange-500");
expect(banner).not.toHaveAttribute("open");
await userEvent.click(line);
expect(banner).toHaveAttribute("open");
expect(banner).toHaveTextContent("Prognozuje się wystąpienie silnego wiatru");
});
it("colours the degrees yellow, orange and red", async () => {
await overview({ warnings: [warning("Mróz", 1), warning("Upał", 2), warning("Burza", 3)] });
await screen.findByText(/stopień 3: Burza/);
expect(screen.getByText(/stopień 1: Mróz/).closest("[role=alert]")).toHaveClass("border-yellow-500");
expect(screen.getByText(/stopień 2: Upał/).closest("[role=alert]")).toHaveClass("border-orange-500");
expect(screen.getByText(/stopień 3: Burza/).closest("[role=alert]")).toHaveClass("border-alarm");
expect(screen.getByText(/stopień 1: Mróz/).closest("details")).toHaveClass("border-yellow-500");
expect(screen.getByText(/stopień 2: Upał/).closest("details")).toHaveClass("border-orange-500");
expect(screen.getByText(/stopień 3: Burza/).closest("details")).toHaveClass("border-alarm");
});
it("shows one banner for identical warnings and keeps ones that differ only in their end", async () => {
const w = warning("Silny wiatr", 1);
await overview({ warnings: [w, { ...w }, { ...w, to: "2026-10-26T18:00:00+01:00" }] });
await screen.findByRole("heading", { name: "Pogoda" });
expect(screen.getAllByText(/stopień 1: Silny wiatr/)).toHaveLength(2);
});
it("shows no banner without warnings", async () => {
@@ -103,7 +115,7 @@ describe("IMGW hint on the battery", () => {
await screen.findByText("Bateria teraz");
}
const HINT = "Ostrzeżenie IMGW: rozważ trzymanie baterii pełnej (rezerwa na awarię).";
const HINT = "Zapowiadany silny wiatr lub burza – zostaw baterię naładowaną (rezerwa na awarię).";
it("suggests keeping the battery full for strong wind", async () => {
await battery([warning("Silny wiatr", 1)]);
+31 -26
View File
@@ -35,12 +35,12 @@ const PICTURES: Record<Kind, { label: string; paths: string[]; colour: string }>
storm: { label: "burza", paths: [CLOUD, "M12.5 14l-2.5 4h3l-1.5 4"], colour: "text-alarm" },
};
export function WeatherIcon({ code }: { code: number | null }) {
export function WeatherIcon({ code, className = "size-10" }: { code: number | null; className?: string }) {
const kind = kindOf(code);
if (!kind) return <span className="grid size-10 place-items-center text-soft">—</span>;
if (!kind) return <span className={`grid shrink-0 place-items-center text-soft ${className}`}>—</span>;
const { label, paths, colour } = PICTURES[kind];
return (
<svg viewBox="0 0 24 24" role="img" aria-label={label} className={`size-10 ${colour}`} fill="none"
<svg viewBox="0 0 24 24" role="img" aria-label={label} className={`shrink-0 ${className} ${colour}`} fill="none"
stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
{paths.map((d) => <path key={d} d={d} />)}
</svg>
@@ -52,15 +52,16 @@ const DAY_NAMES = ["Dziś", "Jutro", "Pojutrze"];
function Day({ name, day }: { name: string; day: WeatherDay }) {
const rain = day.precipitationMm;
return (
<li className="flex flex-col items-center gap-1 text-center">
<b>{name}</b>
<WeatherIcon code={day.weatherCode} />
<span className="text-sm text-soft">{day.description}</span>
<span className="tabular-nums"><b>{celsius(day.tMax)}</b> / {celsius(day.tMin)}</span>
<span className="text-sm text-soft">Opady: {rain == null ? "—" : `${number(rain, 1)} mm`}</span>
{day.pvForecastKwh != null && (
<span className="text-sm font-semibold text-brand">PV ok. {number(day.pvForecastKwh)} kWh</span>
)}
<li className="flex items-center gap-3 border-t border-edge py-2.5 first:border-t-0 first:pt-0 last:pb-0">
<WeatherIcon code={day.weatherCode} className="size-9" />
<div className="min-w-0 flex-1">
<p><b>{name}</b> <span className="text-soft">{day.description}</span></p>
<p className="flex flex-wrap gap-x-3 text-xs text-soft">
<span className="whitespace-nowrap">Opady: {rain == null ? "—" : `${number(rain, 1)} mm`}</span>
{day.pvForecastKwh != null && <b className="whitespace-nowrap font-semibold text-brand">PV ok. {number(day.pvForecastKwh)} kWh</b>}
</p>
</div>
<p className="whitespace-nowrap tabular-nums"><b>{celsius(day.tMax)}</b> / {celsius(day.tMin)}</p>
</li>
);
}
@@ -68,9 +69,8 @@ function Day({ name, day }: { name: string; day: WeatherDay }) {
export function WeatherCard({ weather }: { weather: Weather }) {
const days = [weather.today, weather.tomorrow, weather.dayAfter];
return (
<Card title="Pogoda" aside={<span className="text-sm text-soft">teraz {celsius(weather.now.temperature)}</span>}
className="lg:col-span-2">
<ul className="grid grid-cols-3 gap-2">
<Card title="Pogoda">
<ul>
{days.map((day, i) => <Day key={day.date} name={DAY_NAMES[i]} day={day} />)}
</ul>
{weather.stale && <p className="mt-3 text-xs text-soft">Nie udało się odświeżyć pogody, pokazuję starsze dane.</p>}
@@ -84,20 +84,25 @@ const LEVELS: Record<number, string> = {
3: "border-alarm bg-red-50 text-alarm dark:bg-red-950/60 dark:text-red-200",
};
/** The IMGW warnings, one banner each, coloured by degree. The text is IMGW's own, shown as text. */
/** The IMGW warnings, one line each (degree colour, event, period); the text is IMGW's own and opens on a tap. */
export function WeatherWarnings({ warnings }: { warnings: WeatherWarning[] }) {
// IMGW sends one warning per area, so the same one can arrive twice.
const key = (w: WeatherWarning) => `${w.event}|${w.level}|${w.from}|${w.to}`;
const distinct = warnings.filter((w, i) => warnings.findIndex((o) => key(o) === key(w)) === i);
return (
<>
{warnings.map((w) => (
<div key={`${w.event}${w.level}${w.from}`} role="alert"
className={`mb-4 flex items-start gap-3 rounded-xl border-2 px-4 py-3 ${LEVELS[w.level] ?? LEVELS[1]}`}>
<Icon name="alert" className="size-6 shrink-0" />
<div>
<p className="font-semibold">Ostrzeżenie IMGW, stopień {w.level}: {w.event}</p>
<p className="text-sm">{dateTime(w.from)} – {dateTime(w.to)}</p>
<p className="mt-1 text-sm">{w.text}</p>
</div>
</div>
{distinct.map((w) => (
<details key={key(w)} role="alert"
className={`mb-3 rounded-xl border-2 px-4 py-2 ${LEVELS[w.level] ?? LEVELS[1]}`}>
<summary className="flex cursor-pointer items-center gap-3">
<Icon name="alert" className="size-5 shrink-0" />
<span className="min-w-0 flex-1">
<b>Ostrzeżenie IMGW, stopień {w.level}: {w.event}</b>
<span className="block text-sm">{dateTime(w.from)} – {dateTime(w.to)}</span>
</span>
</summary>
<p className="mt-2 text-sm">{w.text}</p>
</details>
))}
</>
);
+15 -1
View File
@@ -1,4 +1,4 @@
import type { CommandResult } from "./types";
import type { CommandResult, RefreshAnswer } from "./types";
// A request that gets no answer in time is given up, so a screen never waits for ever.
const READ_TIMEOUT_MS = 15000;
@@ -35,3 +35,17 @@ export async function postJson<T extends { status?: string; outcome?: string } =
if (!answer?.status && !answer?.outcome) throw new WriteError(response.status);
return answer;
}
// Asks the gateway for fresh readings. Rejects with the HTTP status (503: no gateway), 0 when the network fails.
export async function postRefresh(): Promise<RefreshAnswer> {
const response = await fetch("api/refresh", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Requested-With": "home-pump" },
body: "{}",
signal: AbortSignal.timeout(WRITE_TIMEOUT_MS),
}).catch(() => {
throw new WriteError(0);
});
if (!response.ok) throw new WriteError(response.status);
return response.json();
}
+2
View File
@@ -57,6 +57,8 @@ export function warsaw(ms: number) {
export const hour = (date: Date) =>
date.toLocaleTimeString("pl-PL", { hour: "2-digit", minute: "2-digit", timeZone: ZONE });
export const hourWithSeconds = (date: Date) =>
date.toLocaleTimeString("pl-PL", { hour: "2-digit", minute: "2-digit", second: "2-digit", timeZone: ZONE });
export const weekday = (date: Date) => date.toLocaleDateString("pl-PL", { weekday: "short", timeZone: ZONE });
export const dayAndHour = (date: Date) => `${weekday(date)} ${hour(date)}`;
export const longDate = (date: Date) =>
+1
View File
@@ -16,6 +16,7 @@ const PATHS = {
battery: "M3 7h15a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1zM22 11v2M6 10v4M10 10v4",
plug: "M9 2v6M15 2v6M6 8h12v4a6 6 0 0 1-12 0zM12 18v4",
alert: "M12 3 2 21h20zM12 10v5M12 18v.5",
refresh: "M20 11a8 8 0 1 0-2.3 6M20 4v7h-7",
clock: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2",
} as const;
+3 -3
View File
@@ -9,7 +9,7 @@
--color-page: #f2f6f3;
--color-panel: #ffffff;
--color-ink: #13201a;
--color-soft: #5b6b63;
--color-soft: #4f5f57;
--color-edge: #e2e9e5;
--color-brand: #0f8a5f;
--color-brand-soft: #e3f4ec;
@@ -29,10 +29,10 @@
:root[data-theme="dark"] {
color-scheme: dark;
--color-page: #0b1310;
--color-panel: #142019;
--color-panel: #1a2a21;
--color-ink: #eef5f1;
--color-soft: #a9bab1;
--color-edge: #24352c;
--color-edge: #35493d;
--color-brand: #3fd197;
--color-brand-soft: #143628;
--color-run: #ff7a8a;
+5
View File
@@ -67,3 +67,8 @@ export function usePoll<T>(url: string, intervalMs = REFRESH_MS): Polled<T> {
/** Asks again at once, e.g. after a write. */
export const reload = (url: string) => refresh(url);
/** Asks again at once for everything that is on screen, e.g. after the gateway polled the devices. */
export function refreshAll() {
entries.forEach((entry, url) => entry.listeners.size > 0 && refresh(url));
}
+10 -1
View File
@@ -8,11 +8,20 @@ function isShellRequest(request) {
return request.method === "GET" && url.origin === self.location.origin && !url.pathname.includes("/api/");
}
const NAVIGATION_TIMEOUT_MS = 4000;
// A stalled connection must not hang the app: after the timeout the cached shell opens instead.
function fetchWithin(request, ms) {
let timer;
const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("timeout")), ms); });
return Promise.race([fetch(request), timeout]).finally(() => clearTimeout(timer));
}
async function answer(request) {
const cache = await caches.open(CACHE);
if (request.mode === "navigate") {
try {
return await fetch(request); // network first, so an update shows at once
return await fetchWithin(request, NAVIGATION_TIMEOUT_MS); // network first, so an update shows at once
} catch {
return cache.match("./index.html");
}
+13 -1
View File
@@ -40,7 +40,7 @@ async function run(type: "install" | "activate") {
}
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.unstubAllGlobals());
afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); });
describe("service worker", () => {
it("leaves /api/ to the network", () => {
@@ -70,6 +70,18 @@ describe("service worker", () => {
expect(await respondWith.mock.calls[0][0]).toBe(index);
});
it("opens the cached index.html when a navigation stalls", async () => {
vi.useFakeTimers();
const index = new Response("shell");
cacheStore.set("./index.html", index);
vi.stubGlobal("fetch", vi.fn(() => new Promise(() => undefined)));
const respondWith = fetchEvent("https://pompa.test/", { mode: "navigate" });
const answer = respondWith.mock.calls[0][0];
await vi.advanceTimersByTimeAsync(4000);
expect(await answer).toBe(index);
vi.useRealTimers();
});
it("precaches the shell on install", async () => {
await run("install");
expect(cache.addAll).toHaveBeenCalledWith(["./", "./index.html"]);
+5
View File
@@ -232,6 +232,11 @@ export interface RestoreResult {
message?: string;
}
export interface RefreshAnswer {
requested: string[];
skipped: { device: string; retryInSeconds: number }[];
}
export interface CommandResult {
status: "APPLIED" | "REJECTED" | "FAILED" | "PARTIAL";
message?: string;
+18 -10
View File
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { ageMinutes, ago } from "./format";
import { RefreshButton } from "./Refresh";
export const STALE_MINUTES = 10;
@@ -36,7 +37,7 @@ const DOT: Record<Tone, string> = {
export function Pill({ tone, live = false, children }: { tone: Tone; live?: boolean; children: ReactNode }) {
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold ${PILL[tone]}`}>
<span className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-semibold ${PILL[tone]}`}>
<Dot tone={tone} live={live} />
{children}
</span>
@@ -58,17 +59,24 @@ const BANNER = {
ok: "border-brand bg-brand-soft text-brand",
};
export function Banner({ tone, children }: { tone: keyof typeof BANNER; children: ReactNode }) {
/** Compact is for a note above a screen that must still show its content on the first page of a phone. */
export function Banner({ tone, compact = false, children }: { tone: keyof typeof BANNER; compact?: boolean; children: ReactNode }) {
return (
<div role={tone === "ok" ? "status" : "alert"}
className={`mb-4 rounded-xl border-2 px-4 py-3 font-semibold ${BANNER[tone]}`}>
className={`rounded-xl border-2 font-semibold ${compact ? "mb-3 px-3 py-2 text-sm" : "mb-4 px-4 py-3"} ${BANNER[tone]}`}>
{children}
</div>
);
}
/** The backend knows best when a source has gone quiet; the age check covers a slow gateway. */
export const isStale = (time: string | null | undefined, stale = false): boolean =>
!!time && (stale || ageMinutes(time) >= STALE_MINUTES);
/** Offline or old data, in words; nothing when the data is fresh. */
export function Freshness({ offline, time, stale = false }: { offline: boolean; time?: string | null; stale?: boolean }) {
export function Freshness({ offline, time, stale = false, compact = false }: {
offline: boolean; time?: string | null; stale?: boolean; compact?: boolean;
}) {
if (offline) {
return (
<Banner tone="bad">
@@ -77,9 +85,8 @@ export function Freshness({ offline, time, stale = false }: { offline: boolean;
</Banner>
);
}
// The backend knows best when a source has gone quiet; the age check covers a slow gateway.
if (time && (stale || ageMinutes(time) >= STALE_MINUTES)) {
return <Banner tone="warn">Dane są nieaktualne: {ago(time)}.</Banner>;
if (isStale(time, stale)) {
return <Banner tone="warn" compact={compact}>Dane są nieaktualne: {ago(time ?? null)}.</Banner>;
}
return null;
}
@@ -97,14 +104,15 @@ export function Row({ label, value, icon }: { label: ReactNode; value: ReactNode
);
}
export function PageTitle({ title, subtitle, aside }: { title: string; subtitle?: ReactNode; aside?: ReactNode }) {
// Every screen starts with this, so the refresh button sits beside each title.
export function PageTitle({ title, subtitle }: { title: string; subtitle?: ReactNode }) {
return (
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
<header className="mb-5 flex items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-soft">{subtitle}</p>}
</div>
{aside}
<RefreshButton />
</header>
);
}
+6 -3
View File
@@ -5,8 +5,9 @@ import { createHash } from "node:crypto";
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
import type { Plugin } from "vite";
// Writes dist/sw.js from src/sw.js with the built files to precache; the cache name carries a hash
// of that list, so a new build gets a new cache and the old one is removed on activate.
// Writes dist/sw.js from src/sw.js with the built files to precache; the cache name carries a hash of
// that list and of the public files' contents, so a new build gets a new cache and the old one is
// removed on activate.
function precachePlugin(): Plugin {
return {
name: "precache",
@@ -14,7 +15,9 @@ function precachePlugin(): Plugin {
writeBundle(options, bundle) {
const files = [...Object.keys(bundle), ...readdirSync("public")].sort();
const shell = ["./", ...files.map((file) => `./${file}`)];
const version = createHash("sha256").update(shell.join()).digest("hex").slice(0, 12);
const hash = createHash("sha256").update(shell.join());
readdirSync("public").sort().forEach((file) => hash.update(readFileSync(`public/${file}`)));
const version = hash.digest("hex").slice(0, 12);
const source = readFileSync("src/sw.js", "utf8")
.replace("__SHELL__", JSON.stringify(shell))
.replace("__VERSION__", JSON.stringify(version));
+11 -2
View File
@@ -69,7 +69,7 @@ reused; `commands.enabled` may stay false). `deyeCommands`: `reserveFile` (`rese
restore file, `{"pct": 50}`, replaced atomically; missing means 50, an unreadable file is logged and means 50),
`reserveFloorPct` (30; the reserve is never lower, nor below the inverter's shutdown SOC + 10),
`minIntervalSeconds` (60, per register) and `readBackDelayMillis` (1000). Same Bearer token and failed-login
throttle as `/commands`; `X-Requested-By` names the requester. While it is off, `/battery` answers 404.
throttle as `/commands` (one failed-token counter for both); `X-Requested-By` names the requester. While it is off, `/battery` answers 404.
- `GET /battery/reserve` answers `{"pct", "floorPct"}`; `POST /battery/reserve` `{"pct": 60}` changes it.
- `POST /battery/schedule` `{"enabled": true, "slots": [{"slot": 2, "start": 930, "soc": 90, "gridCharge": true}]}`;
@@ -79,7 +79,7 @@ throttle as `/commands`; `X-Requested-By` names the requester. While it is off,
register, 502 failed: the body's `read` holds the registers as read back, nothing is retried.
- Rules: SOC from the reserve to 100; grid charge written as 0 or 1 only; starts are HHMM, strictly increasing
from slot 1 to 6; an enabled schedule needs every slot SOC at or above the reserve; the reserve cannot be
raised above a slot SOC of an enabled schedule. The whole table is checked before anything is written,
raised above a slot SOC of an enabled schedule (decided on the table read live, 502 if that read fails). The whole table is checked before anything is written,
neighbouring registers go in one FC16, the Time of Use switch last, the days (bits 1 to 7 of 146) stay as read.
- Audit: `electricity.deye_settings_audit` (tags `register`, `requested_by`, `status`; fields `old_value`,
`new_value`). After a write the settings are polled again 3 s later. No restore and no lease: these are
@@ -102,6 +102,9 @@ and runs at its end. The gateway accepts it only if the end is in the future and
new value is lower than both the current value and the original that will come back; otherwise 422 and no
write. A renewal only ever extends a lease, so it never shortens a held change. A held change may replace a
pending boost (the original stays the first one); a later ordinary change on a held one is a lease again.
If that ordinary change fails after its write may have landed (lost reply, read-back mismatch), the held restore
keeps the original and accepts both values, but its lease is capped to now + 15 minutes: nothing above the
original outlives the boost, and without the backend the restore runs within 15 minutes.
**Restore now.** `POST /commands/restore` with `{"setpoint": "cwu_economy"}` (same token and throttle; the
requester comes from `X-Requested-By`) runs that setpoint's pending restore at once through the usual path.
@@ -111,6 +114,12 @@ pending with its backoff.
**Extra poll.** After a write or restore that took, the heat pump is polled once more 3 s later by its own poller thread (so never beside another read, and several writes share one poll), because its derived targets would otherwise show the old value until the next 300 s poll; a failure of it counts like any failed poll.
**Refresh.** `POST /refresh` (same Bearer token and throttle, no body, on when `commands` or `deyeCommands` is enabled, so the token exists)
is the app's "Odśwież" button. It asks every device's poller for one extra poll (after the same 3 s) and answers
`{"requested": ["deye"], "skipped": [{"device": "heat-pump", "retryInSeconds": 41}]}`. A device is polled again only if its
last poll (regular or extra) started, and last refresh was requested, at least 60 s ago (`Poller.REFRESH_COOLDOWN`); otherwise it is skipped, not queued. The
heat pump needs the floor most: the Frisko MK01 takes one client and every poll of ours drops the phone app (ADR-003). Read-only.
**Shown value.** `GET /commands` shows, per setpoint, the value read back after the gateway's last write or
restore or the last polled value, whichever is newer: a poll takes minutes, a panel change shows in a later one.
@@ -125,6 +125,9 @@ public final class Gateway implements AutoCloseable {
if (commands != null) {
http.serveCommands(commands, token);
}
if (writes) {
http.serveRefresh(poller::refresh, token);
}
if (config.deyeCommands().enabled()) {
http.serveBattery(startBattery(snapshot, writer, poller), token);
}
@@ -9,7 +9,6 @@ import home.gateway.domain.ScheduleChange;
import home.gateway.domain.SlotChange;
import java.io.IOException;
import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
@@ -30,9 +29,9 @@ class BatteryEndpoint implements HttpHandler {
private final BatteryCommands battery;
private final TokenGuard guard;
BatteryEndpoint(BatteryCommands battery, String token, Clock clock) {
BatteryEndpoint(BatteryCommands battery, String token, FailedLoginThrottle throttle) {
this.battery = battery;
this.guard = new TokenGuard(token, clock);
this.guard = new TokenGuard(token, throttle);
}
@Override
@@ -14,7 +14,6 @@ import home.gateway.domain.Setpoint;
import home.gateway.domain.VerifiedValue;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.OffsetDateTime;
@@ -55,8 +54,8 @@ class CommandsEndpoint implements HttpHandler {
private final Snapshot snapshot;
private final TokenGuard guard;
CommandsEndpoint(Commands commands, Snapshot snapshot, String token, Clock clock) {
this.guard = new TokenGuard(token, clock);
CommandsEndpoint(Commands commands, Snapshot snapshot, String token, FailedLoginThrottle throttle) {
this.guard = new TokenGuard(token, throttle);
this.commands = commands;
this.snapshot = snapshot;
}
@@ -8,6 +8,7 @@ import com.sun.net.httpserver.HttpServer;
import home.gateway.application.BatteryCommands;
import home.gateway.application.Commands;
import home.gateway.application.DeviceState;
import home.gateway.application.RefreshResult;
import home.gateway.application.Snapshot;
import java.io.IOException;
@@ -18,6 +19,7 @@ import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.function.Supplier;
/**
* JSON API: read-only {@code /health} for monitoring and {@code /snapshot} for the app, plus
@@ -50,11 +52,13 @@ public class GatewayHttpServer implements AutoCloseable {
private final Clock clock;
private final Set<String> criticalDevices;
private final HttpServer server;
private final FailedLoginThrottle throttle;
public GatewayHttpServer(Snapshot snapshot, Clock clock, InetSocketAddress address,
Set<String> criticalDevices) throws IOException {
this.snapshot = snapshot;
this.clock = clock;
this.throttle = new FailedLoginThrottle(clock);
this.criticalDevices = criticalDevices;
this.server = HttpServer.create(address, 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
@@ -66,12 +70,17 @@ public class GatewayHttpServer implements AutoCloseable {
* read-only server needs no token; call before {@link #start()}.
*/
public void serveCommands(Commands commands, String token) {
server.createContext("/commands", new CommandsEndpoint(commands, snapshot, token, clock));
server.createContext("/commands", new CommandsEndpoint(commands, snapshot, token, throttle));
}
/** Adds the battery settings endpoints (ADR-013) with their own token check; call before {@link #start()}. */
public void serveBattery(BatteryCommands battery, String token) {
server.createContext("/battery", new BatteryEndpoint(battery, token, clock));
server.createContext("/battery", new BatteryEndpoint(battery, token, throttle));
}
/** Adds {@code POST /refresh}, which asks for extra polls ({@code refresh}); call before {@link #start()}. */
public void serveRefresh(Supplier<RefreshResult> refresh, String token) {
server.createContext("/refresh", new RefreshEndpoint(refresh, token, throttle));
}
public void start() {
@@ -0,0 +1,42 @@
package home.gateway.adapters.http;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import home.gateway.application.RefreshResult;
import java.io.IOException;
import java.util.function.Supplier;
import static home.gateway.adapters.http.GatewayHttpServer.respond;
/**
* {@code POST /refresh} (no body) asks for one extra poll of every device that has not been polled
* for a minute and answers which ones were requested and which skipped, with the seconds until they
* may be. Read-only, but with the same token and failed-login throttle as the write endpoints.
*/
class RefreshEndpoint implements HttpHandler {
private final Supplier<RefreshResult> refresh;
private final TokenGuard guard;
RefreshEndpoint(Supplier<RefreshResult> refresh, String token, FailedLoginThrottle throttle) {
this.refresh = refresh;
this.guard = new TokenGuard(token, throttle);
}
@Override
public void handle(HttpExchange exchange) throws IOException {
try (exchange) {
if (!exchange.getRequestURI().getPath().equals("/refresh")) {
respond(exchange, 404, new GatewayHttpServer.Error("not found"));
} else if (guard.admit(exchange)) {
if (exchange.getRequestMethod().equals("POST")) {
respond(exchange, 200, refresh.get());
} else {
exchange.getResponseHeaders().set("Allow", "POST");
respond(exchange, 405, new GatewayHttpServer.Error("method not allowed"));
}
}
}
}
}
@@ -5,11 +5,10 @@ import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import static home.gateway.adapters.http.GatewayHttpServer.respond;
/** The shared token of the write endpoints (S6) and the throttle on wrong ones. */
/** The shared token of the write endpoints (S6); wrong ones count on the throttle all endpoints share. */
final class TokenGuard {
static final int MIN_TOKEN_LENGTH = 16;
@@ -18,13 +17,13 @@ final class TokenGuard {
private final byte[] token;
private final FailedLoginThrottle throttle;
TokenGuard(String token, Clock clock) {
TokenGuard(String token, FailedLoginThrottle throttle) {
if (token == null || token.length() < MIN_TOKEN_LENGTH) {
throw new IllegalArgumentException(
"command token must be at least " + MIN_TOKEN_LENGTH + " characters");
}
this.token = token.getBytes(StandardCharsets.UTF_8);
this.throttle = new FailedLoginThrottle(clock);
this.throttle = throttle;
}
/** True if the request may go on; otherwise it has been answered with 429 or 401. */
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.gateway.application.ReserveStore;
import home.gateway.domain.Schedule;
import java.io.IOException;
import java.nio.file.Files;
@@ -24,13 +25,17 @@ public class JsonReserveStore implements ReserveStore {
this.file = file;
}
/** @throws IOException when the file exists but is not a valid reserve */
/** @throws IOException when the file exists but is not a valid reserve (0 to 100 %) */
@Override
public OptionalInt load() throws IOException {
if (!Files.exists(file)) {
return OptionalInt.empty();
}
return OptionalInt.of(json.readValue(file.toFile(), Stored.class).pct());
int pct = json.readValue(file.toFile(), Stored.class).pct();
if (pct < 0 || pct > Schedule.MAX_SOC) {
throw new IOException("reserve " + pct + " % is outside 0 to " + Schedule.MAX_SOC);
}
return OptionalInt.of(pct);
}
@Override
@@ -113,12 +113,16 @@ public class BatterySettings implements BatteryCommands {
"the reserve must be " + current.floorPct() + " to " + Schedule.MAX_SOC + " %");
}
if (pct > current.pct()) {
// Raising it must not break an enabled schedule; lowering never can.
var settings = freshSettings();
if (settings == null) {
return BatteryResult.of(Status.STALE, staleMessage());
// Raising it must not break an enabled schedule; lowering never can. The cached reading may
// predate a schedule change (ours or the Solarman app's), so the table is read live.
Schedule schedule;
try {
schedule = ProgramRegisters.decode(registers.read(ProgramRegisters.FIRST, ProgramRegisters.COUNT));
} catch (IOException e) {
return BatteryResult.of(Status.FAILED, "cannot read the settings before raising the reserve: "
+ e.getMessage());
}
if (settings.schedule().enabled() && lowestSoc(settings.schedule()) < pct) {
if (schedule.enabled() && lowestSoc(schedule) < pct) {
return BatteryResult.of(Status.REJECTED, "the schedule is enabled and a slot SOC is below "
+ pct + " %: raise the slot SOCs first or disable the schedule");
}
@@ -259,8 +263,24 @@ public class BatterySettings implements BatteryCommands {
.findFirst().map(BatterySettings::fromReading).orElse(null);
}
private static final List<String> REQUIRED_FIELDS = requiredFields();
private static List<String> requiredFields() {
var names = new ArrayList<>(List.of("tou_enabled", "tou_days", "shutdown_soc_pct"));
for (int slot = 1; slot <= Schedule.SLOTS; slot++) {
for (var suffix : List.of("start_hhmm", "soc_pct", "grid_charge")) {
names.add("prog" + slot + "_" + suffix);
}
}
return names;
}
/** Null when the reading lacks a field: that is no usable settings, not an error. */
private static Settings fromReading(Reading reading) {
var fields = reading.fields();
if (!REQUIRED_FIELDS.stream().allMatch(fields::containsKey)) {
return null;
}
var slots = new ArrayList<Slot>();
for (int slot = 1; slot <= Schedule.SLOTS; slot++) {
String prefix = "prog" + slot + "_";
@@ -171,12 +171,18 @@ public class CommandService implements Commands {
/**
* The first change's restore must survive a failed second one. The failed write may still have
* landed (a lost reply), so the register can hold either value and both count as ours.
* landed (a lost reply), so the register can hold either value and both count as ours. Unless
* the failed command was itself held, the restore is an ordinary lease from now on, even if it
* was held: a value that may have landed must not outlive the boost (ADR-012).
*/
private CommandResult failedWrite(Command command, Setpoint setpoint, Integer old, Restore previous,
String message) {
if (previous != null) {
restores.put(previous.register(), previous.alsoApplied(command.value()));
var restore = previous.alsoApplied(command.value());
if (command.heldUntil() == null) {
restore = restore.leaseCappedAt(clock.instant().plus(Restore.LEASE));
}
restores.put(previous.register(), restore);
persist();
}
return failed(command, setpoint, old, message);
@@ -6,20 +6,32 @@ import org.slf4j.LoggerFactory;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.locks.LockSupport;
import java.util.stream.Stream;
/**
* Polls every device on its own virtual thread, so a slow or broken device affects no other.
* {@link #requestPoll} adds one poll soon on that same thread, so it never overlaps another read
* of the device, and leaves the regular schedule as it is.
* of the device, and leaves the regular schedule as it is. {@link #refresh} does that for every
* device, but only for those not polled within {@link #REFRESH_COOLDOWN}.
*/
public class Poller implements AutoCloseable {
/**
* The least time between two polls of a device that a refresh may cause: the Frisko MK01 takes
* one client and each of our polls drops the phone app (ADR-003), so a button must not hammer it.
*/
public static final Duration REFRESH_COOLDOWN = Duration.ofSeconds(60);
private static final Logger LOGGER = LoggerFactory.getLogger(Poller.class);
private final List<Device> devices;
@@ -31,6 +43,10 @@ public class Poller implements AutoCloseable {
private final Map<String, Thread> threadsByDevice = new ConcurrentHashMap<>();
// Device name to the nanoTime of its extra poll; a request while one is waiting joins it.
private final Map<String, Long> extraPolls = new ConcurrentHashMap<>();
// Device name to the start of its latest poll, regular or extra.
private final Map<String, Instant> lastAttempts = new ConcurrentHashMap<>();
// Device name to its latest refresh; the extra poll it causes starts only after the repoll delay.
private final Map<String, Instant> lastRefreshes = new ConcurrentHashMap<>();
private final Set<String> devicesWithSinkFailure = ConcurrentHashMap.newKeySet();
// A read may swallow the interrupt, so the loop cannot rely on the thread's flag alone.
private volatile boolean closed;
@@ -64,6 +80,26 @@ public class Poller implements AutoCloseable {
}
}
/** Requests one extra poll of each device whose last poll or refresh is at least the cooldown old; skips the others. */
public RefreshResult refresh() {
var now = clock.instant();
var requested = new ArrayList<String>();
var skipped = new ArrayList<RefreshResult.Skipped>();
for (Device device : devices) {
var last = Stream.of(lastAttempts.get(device.name()), lastRefreshes.get(device.name()))
.filter(Objects::nonNull).max(Comparator.naturalOrder());
var wait = last.map(at -> REFRESH_COOLDOWN.minus(Duration.between(at, now))).orElse(Duration.ZERO);
if (wait.isPositive()) {
skipped.add(new RefreshResult.Skipped(device.name(), (wait.toMillis() + 999) / 1000));
} else {
lastRefreshes.put(device.name(), now);
requestPoll(device.name());
requested.add(device.name());
}
}
return new RefreshResult(requested, skipped);
}
@Override
public void close() throws InterruptedException {
closed = true;
@@ -109,6 +145,7 @@ public class Poller implements AutoCloseable {
private boolean pollOnce(Device device, boolean wasUp) {
var now = clock.instant();
lastAttempts.put(device.name(), now);
List<Reading> readings;
try {
readings = device.read(now);
@@ -0,0 +1,10 @@
package home.gateway.application;
import java.util.List;
/** What a refresh request did: the devices that will be polled again, and those polled too recently. */
public record RefreshResult(List<String> requested, List<Skipped> skipped) {
public record Skipped(String device, long retryInSeconds) {
}
}
@@ -43,4 +43,10 @@ public record Restore(String setpoint, int register, int originalValue, int appl
var longest = newLeaseUntil.isAfter(leaseUntil) ? newLeaseUntil : leaseUntil;
return new Restore(setpoint, register, originalValue, appliedValue, due, longest, otherApplied);
}
/** The lease ends no later than {@code limit}: a held change loses its far end (ADR-012). */
public Restore leaseCappedAt(Instant limit) {
var shortest = limit.isBefore(leaseUntil) ? limit : leaseUntil;
return new Restore(setpoint, register, originalValue, appliedValue, due, shortest, otherApplied);
}
}
@@ -3,6 +3,7 @@ package home.gateway.adapters.http;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.gateway.application.BatteryCommands;
import home.gateway.application.DisabledCommands;
import home.gateway.application.Snapshot;
import home.gateway.domain.BatteryResult;
import home.gateway.domain.BatteryResult.Status;
@@ -177,6 +178,16 @@ class BatteryEndpointTest {
assertEquals(List.of(), schedules);
}
@Test
void wrongTokensOnTheCommandsEndpointAlsoBlockTheBatteryOne() throws Exception {
server.serveCommands(new DisabledCommands("test"), BEARER);
for (int i = 0; i < FailedLoginThrottle.MAX_FAILURES; i++) {
send("/commands", "GET", null);
}
assertEquals(429, authorized("/battery/reserve", "GET", null).statusCode());
}
@Test
void knowsOnlyItsPathsAndMethods() throws Exception {
assertEquals(404, authorized("/battery/other", "GET", null).statusCode());
@@ -187,6 +198,6 @@ class BatteryEndpointTest {
@Test
void refusesAShortToken() {
var other = Clock.fixed(Instant.EPOCH, ZoneOffset.UTC);
assertThrows(IllegalArgumentException.class, () -> new BatteryEndpoint(battery, "short", other));
assertThrows(IllegalArgumentException.class, () -> new BatteryEndpoint(battery, "short", new FailedLoginThrottle(other)));
}
}
@@ -0,0 +1,94 @@
package home.gateway.adapters.http;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.gateway.application.RefreshResult;
import home.gateway.application.Snapshot;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
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.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
class RefreshEndpointTest {
private static final String BEARER = "0123456789abcdef-secret";
private final ObjectMapper json = new ObjectMapper();
private final HttpClient client = HttpClient.newHttpClient();
private int refreshes;
private GatewayHttpServer server;
@BeforeEach
void startServer() throws Exception {
var clock = Clock.fixed(Instant.parse("2026-10-24T10:00:00Z"), ZoneOffset.UTC);
server = new GatewayHttpServer(new Snapshot(), clock, new InetSocketAddress(0), Set.of());
server.serveRefresh(() -> {
refreshes++;
return new RefreshResult(List.of("deye"),
List.of(new RefreshResult.Skipped("heat-pump", 41)));
}, BEARER);
server.start();
}
@AfterEach
void stopServer() {
server.close();
}
private HttpResponse<String> send(String method, String... headers) throws Exception {
var builder = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + "/refresh"))
.method(method, HttpRequest.BodyPublishers.noBody());
for (int i = 0; i < headers.length; i += 2) {
builder.setHeader(headers[i], headers[i + 1]);
}
return client.send(builder.build(), HttpResponse.BodyHandlers.ofString());
}
@Test
void postAnswersWhichDevicesWereRequestedAndWhichSkipped() throws Exception {
var response = send("POST", "Authorization", "Bearer " + BEARER);
assertEquals(200, response.statusCode());
assertEquals(json.readTree("""
{"requested": ["deye"], "skipped": [{"device": "heat-pump", "retryInSeconds": 41}]}"""),
json.readTree(response.body()));
assertEquals(1, refreshes);
}
@Test
void withoutTheTokenItIsRefusedAndNothingIsRequested() throws Exception {
assertEquals(401, send("POST").statusCode());
assertEquals(401, send("POST", "Authorization", "Bearer wrong-token-value-x").statusCode());
assertEquals(0, refreshes);
}
@Test
void getIsNotAllowed() throws Exception {
var response = send("GET", "Authorization", "Bearer " + BEARER);
assertEquals(405, response.statusCode());
assertEquals("POST", response.headers().firstValue("Allow").orElseThrow());
assertEquals(0, refreshes);
}
@Test
void failedTokensCountOnTheSharedThrottle() throws Exception {
for (int i = 0; i < 5; i++) {
send("POST");
}
assertEquals(429, send("POST", "Authorization", "Bearer " + BEARER).statusCode());
assertEquals(0, refreshes);
}
}
@@ -37,6 +37,17 @@ class JsonReserveStoreTest {
}
}
@Test
void aReserveOutsideZeroTo100FailsTheLoad() throws IOException {
var file = dir.resolve("reserve.json");
for (var content : List.of("{\"pct\":101}", "{\"pct\":-1}", "{\"pct\":2000000}")) {
Files.writeString(file, content);
assertThrows(IOException.class, () -> new JsonReserveStore(file).load(), content);
}
Files.writeString(file, "{\"pct\":100}");
assertEquals(OptionalInt.of(100), new JsonReserveStore(file).load());
}
@Test
void anUnusableFileFailsTheLoad() throws IOException {
var file = dir.resolve("reserve.json");
@@ -318,7 +318,16 @@ class BatterySettingsTest {
assertEquals(Status.STALE, result.status());
assertNothingWritten();
assertEquals(0, inverter.reads);
assertEquals(Status.STALE, service.changeReserve(60, "ala").status());
}
@Test
void aReadingWithoutTheExpectedFieldsIsNoUsableSettings() {
var service = service();
snapshot.recordSuccess(DeyeSettingsDevice.NAME, clock.instant(), List.of(new Reading("electricity",
"deye_settings", Map.of(), Map.of("tou_enabled", 1.0), clock.instant())));
assertEquals(Status.STALE, service.changeSchedule(change(null, soc(1, 90))).status());
assertNothingWritten();
}
@Test
@@ -515,12 +524,37 @@ class BatterySettingsTest {
}
@Test
void raisingTheReserveNeedsAFreshReadingButLoweringDoesNot() {
void raisingTheReserveDecidesOnTheLiveTableNotTheOldReading() {
var service = service();
clock.advance(Duration.ofMinutes(45));
assertEquals(Status.APPLIED, service.changeReserve(60, "ala").status());
inverter.registers.put(146, 255);
var result = service.changeReserve(85, "ala");
assertEquals(Status.REJECTED, result.status());
assertEquals(60, service.reserve().pct());
}
@Test
void raisingTheReserveFailsWhenTheTableCannotBeRead() {
var service = service();
inverter.failReadNumber = inverter.reads + 1;
var result = service.changeReserve(60, "ala");
assertEquals(Status.FAILED, result.status());
assertEquals(-1, storedReserve);
assertEquals(50, service.reserve().pct());
}
@Test
void loweringTheReserveNeedsNoRead() {
var service = service();
int reads = inverter.reads;
assertEquals(Status.STALE, service.changeReserve(60, "ala").status());
assertEquals(Status.APPLIED, service.changeReserve(40, "ala").status());
assertEquals(reads, inverter.reads);
}
@Test
@@ -545,6 +545,40 @@ class CommandServiceTest {
assertTrue(service.pendingRestores().isEmpty());
}
@Test
void aBoostWithALostReplyOnAHeldChangeCapsTheHeldLease() throws IOException {
var service = service();
service.apply(held("cwu_comfort", 35, Duration.ofDays(7)));
clock.advance(Duration.ofMinutes(5));
registers.failReadNumber = registers.reads + 2;
assertEquals(Status.FAILED, service.apply(command("cwu_comfort", 48, HOUR)).status());
var expected = new Restore("cwu_comfort", 4144, 45, 35, T0.plus(Duration.ofDays(7)),
clock.instant().plus(Restore.LEASE), 48);
assertEquals(List.of(expected), service.pendingRestores());
assertEquals(List.of(expected), stored);
assertEquals(48, registers.values.get(4144));
clock.advance(Restore.LEASE);
service.runDueRestores();
assertEquals(45, registers.values.get(4144));
assertTrue(service.pendingRestores().isEmpty());
}
@Test
void aFailedHeldLoweringOnAHeldChangeKeepsTheHeldEnd() throws IOException {
var service = service();
var end = Duration.ofDays(7);
service.apply(held("cwu_comfort", 40, end));
clock.advance(Duration.ofMinutes(2));
registers.ignoreWrites = true;
assertEquals(Status.FAILED, service.apply(held("cwu_comfort", 35, Duration.ofDays(3))).status());
assertEquals(T0.plus(end), service.pendingRestores().getFirst().runAt());
assertEquals(35, service.pendingRestores().getFirst().otherApplied());
}
@Test
void secondBoostWithAReadBackMismatchKeepsTheFirstRestore() throws IOException {
var service = afterFirstBoost();
@@ -7,11 +7,13 @@ import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.ZoneId;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BooleanSupplier;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -210,4 +212,134 @@ class PollerTest {
assertEquals(1, slow.calls());
}
private static final Duration HOURLY = Duration.ofHours(1);
private Duration repoll = Duration.ofMillis(20);
private final AtomicReference<Instant> later = new AtomicReference<>(NOW);
private void startWithMovableClock(Device... devices) throws InterruptedException {
var movable = new Clock() {
@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(ZoneId zone) {
return this;
}
@Override
public Instant instant() {
return later.get();
}
};
poller = new Poller(List.of(devices), snapshot, sink, movable, repoll);
poller.start();
for (Device device : devices) {
await(() -> snapshot.states().stream()
.anyMatch(s -> s.name().equals(device.name()) && s.lastSuccess() != null));
}
}
private void passSeconds(long seconds) {
later.updateAndGet(time -> time.plusSeconds(seconds));
}
@Test
void refreshSkipsADevicePolledWithinTheCooldownAndSaysWhenToRetry() throws Exception {
var pump = FakeDevice.working("pump", HOURLY);
startWithMovableClock(pump);
passSeconds(19);
var result = poller.refresh();
assertEquals(List.of(), result.requested());
assertEquals(List.of(new RefreshResult.Skipped("pump", 41)), result.skipped());
Thread.sleep(100);
assertEquals(1, pump.calls());
}
@Test
void refreshPollsADeviceOnceTheCooldownHasPassedAndThenCoolsDownAgain() throws Exception {
var pump = FakeDevice.working("pump", HOURLY);
startWithMovableClock(pump);
passSeconds(60);
assertEquals(List.of("pump"), poller.refresh().requested());
await(() -> pump.calls() == 2);
assertEquals(List.of(new RefreshResult.Skipped("pump", 60)), poller.refresh().skipped());
}
@Test
void aSecondRefreshBeforeTheExtraPollRunsIsSkippedToo() throws Exception {
var pump = FakeDevice.working("pump", HOURLY);
startWithMovableClock(pump);
passSeconds(120);
assertEquals(List.of("pump"), poller.refresh().requested());
passSeconds(2);
var second = poller.refresh();
assertEquals(List.of(), second.requested());
assertEquals(List.of(new RefreshResult.Skipped("pump", 58)), second.skipped());
}
@Test
void refreshJudgesEachDeviceOnItsOwn() throws Exception {
var pump = FakeDevice.working("pump", HOURLY);
var meter = FakeDevice.working("meter", FAST);
startWithMovableClock(pump, meter);
passSeconds(60);
int before = meter.calls();
await(() -> meter.calls() > before);
var result = poller.refresh();
assertEquals(List.of("pump"), result.requested());
assertEquals("meter", result.skipped().get(0).device());
}
@Test
void repeatedRefreshesBeforeTheExtraPollRunsCoalesceIntoOne() throws Exception {
var pump = FakeDevice.working("pump", HOURLY);
repoll = Duration.ofMillis(300);
startWithMovableClock(pump);
passSeconds(120);
poller.refresh();
poller.refresh();
await(() -> pump.calls() == 2);
Thread.sleep(400);
assertEquals(2, pump.calls());
}
@Test
void aFailingExtraPollFromARefreshIsRecordedLikeAnyFailedPoll() throws Exception {
var pump = new FakeDevice("pump", HOURLY, call -> {
if (call == 2) {
throw new IOException("unreachable");
}
});
startWithMovableClock(pump);
passSeconds(60);
poller.refresh();
await(() -> state("pump").lastError() != null);
assertEquals("unreachable", state("pump").lastError());
assertEquals(List.of(new RefreshResult.Skipped("pump", 60)), poller.refresh().skipped());
}
@Test
void anUnknownDeviceIsIgnored() throws Exception {
startWithMovableClock(FakeDevice.working("pump", HOURLY));
poller.requestPoll("ghost");
assertEquals(List.of("pump"), snapshot.states().stream().map(DeviceState::name).toList());
}
}
+49 -7
View File
@@ -1,5 +1,7 @@
#!/usr/bin/env bash
# The one quality gate: run by the pre-push hook and by CI. Usage: scripts/check.sh
# The one quality gate: run by the pre-push hook and by CI.
# Usage: scripts/check.sh [docs] [gateway] [backend] [frontend] (default: everything)
# The three part builds run in parallel; a part's log is printed only when it fails.
set -euo pipefail
cd "$(dirname "$0")/.."
@@ -48,13 +50,53 @@ check_secrets() {
done
}
check_doc_limits
check_secrets
# Retries help against "Connection reset" while downloading dependencies.
mvn_build() {
mvn -B -q -Daether.connector.http.retryHandler.count=5 \
-Daether.connector.http.retryHandler.requestSentEnabled=true -f "$1/pom.xml" clean package
}
build_gateway() { mvn_build gateway; }
build_backend() { mvn_build backend; }
build_frontend() {
cd frontend
npm ci --no-audit --no-fund --loglevel=error && npm test --silent && npm run build --silent
}
run_parts() {
local part
logs=$(mktemp -d)
trap 'rm -rf "$logs"' EXIT
for part in "$@"; do
"build_$part" > "$logs/$part.log" 2>&1 &
pids[$part]=$!
done
for part in "$@"; do
if ! wait "${pids[$part]}"; then
cat "$logs/$part.log"
fail "$part failed"
fi
done
}
targets=("$@")
(( ${#targets[@]} )) || targets=(docs gateway backend frontend)
parts=()
declare -A pids
for target in "${targets[@]}"; do
case $target in
docs) check_doc_limits; check_secrets ;;
gateway | backend | frontend) parts+=("$target") ;;
*) fail "unknown target $target"; exit 1 ;;
esac
done
if (( failed )); then
exit 1
fi
if (( ${#parts[@]} )); then
run_parts "${parts[@]}"
fi
if (( failed )); then
exit 1
fi
mvn -B -q -f gateway/pom.xml clean package
mvn -B -q -f backend/pom.xml clean package
(cd frontend && npm ci --no-audit --no-fund --loglevel=error && npm test --silent && npm run build --silent)
echo "check: OK"
+18 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Releases master: scripts/release.sh vMAJOR.MINOR.PATCH
# Runs the gate, tags master and pushes the tag; .gitea/workflows/release.yml then publishes the
# Needs a green CI status for master's head on Gitea, tags master and pushes the tag; .gitea/workflows/release.yml then publishes the
# gateway, backend and frontend images under that name. Then, on the Pi: ./update.sh <version>.
set -euo pipefail
cd "$(dirname "$0")/.."
@@ -10,6 +10,22 @@ die() {
exit 1
}
# The combined state of all CI jobs for HEAD; polls while pending, up to 15 minutes.
wait_for_green_ci() {
local url state attempt
url=https://git.mariuszpacek.pl/api/v1/repos/mariusz.pacek/home-pump/commits/$(git rev-parse HEAD)/status
for attempt in $(seq 30); do
state=$(curl -fsS "$url" | sed -n 's/^{"state":"\([a-z]*\)".*/\1/p')
case $state in
success) return ;;
failure | error) die "CI is $state for $(git rev-parse --short HEAD)" ;;
esac
echo "release: CI is ${state:-unknown}, waiting ($attempt/30)"
sleep 30
done
die "CI did not finish in time"
}
version=${1:-}
[[ $version =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "usage: $0 vMAJOR.MINOR.PATCH"
[[ $(git branch --show-current) == master ]] || die "release only from master"
@@ -18,7 +34,7 @@ git fetch -q origin master --tags
[[ $(git rev-parse HEAD) == $(git rev-parse origin/master) ]] || die "master differs from origin/master"
! git rev-parse -q --verify "refs/tags/$version" > /dev/null || die "tag $version exists"
scripts/check.sh
wait_for_green_ci
git tag -a "$version" -m "Release $version"
git push -q origin "$version"
echo "release: $version tagged. When the Release run in Gitea is green: ssh to the Pi, then"