Compare commits

...
9 Commits
Author SHA1 Message Date
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
32 changed files with 722 additions and 43 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
@@ -59,7 +59,7 @@ 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) {
@@ -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 {
@@ -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;
}
@@ -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",
@@ -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
+4
View File
@@ -20,6 +20,10 @@ 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 starts with the "Odśwież" button (`Refresh.tsx`): 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
+2
View File
@@ -3,6 +3,7 @@ import { celsius, hour, longDate } from "./format";
import { Icon, type IconName } from "./icons";
import { Energy } from "./Energy";
import { Overview } from "./Overview";
import { RefreshButton } from "./Refresh";
import { usePoll } from "./poll";
import { useTheme, type Theme } from "./theme";
import type { HeatPump } from "./types";
@@ -119,6 +120,7 @@ export function App() {
<div className="hidden flex-1 lg:flex lg:flex-col"><SideFooter /></div>
</nav>
<main className="mx-auto max-w-[1500px] px-4 pt-5 pb-28 sm:px-6 lg:px-8 lg:pt-8 lg:pb-10">
<RefreshButton />
<Suspense fallback={<Loading />}><View /></Suspense>
<ThemeSwitch className="mx-auto mt-8 max-w-xs border border-edge bg-panel text-ink lg:hidden" />
</main>
+98
View File
@@ -0,0 +1,98 @@
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");
// 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(status()).toBeEmptyDOMElement();
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ą");
});
});
+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="mb-4 flex items-center justify-end gap-3">
<p role="status" className={`text-sm ${state.kind === "error" ? "text-bad" : "text-soft"}`}>{text}</p>
<button onClick={tap} disabled={busy || state.kind === "fresh"} aria-label="Odśwież"
className="flex min-h-11 min-w-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" : ""}`} />
<span className="hidden sm:inline">Odśwież</span>
</button>
</div>
);
}
+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;
+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));
}
+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;
+6
View File
@@ -114,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 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);
}
@@ -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
@@ -76,6 +78,11 @@ public class GatewayHttpServer implements AutoCloseable {
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() {
server.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"));
}
}
}
}
}
@@ -6,6 +6,8 @@ import org.slf4j.LoggerFactory;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -16,10 +18,17 @@ import java.util.concurrent.locks.LockSupport;
/**
* 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 +40,8 @@ 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<>();
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 +75,24 @@ public class Poller implements AutoCloseable {
}
}
/** Requests one extra poll of each device whose last poll 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 = lastAttempts.get(device.name());
var wait = last == null ? Duration.ZERO : REFRESH_COOLDOWN.minus(Duration.between(last, now));
if (wait.isPositive()) {
skipped.add(new RefreshResult.Skipped(device.name(), (wait.toMillis() + 999) / 1000));
} else {
requestPoll(device.name());
requested.add(device.name());
}
}
return new RefreshResult(requested, skipped);
}
@Override
public void close() throws InterruptedException {
closed = true;
@@ -109,6 +138,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) {
}
}
@@ -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);
}
}
@@ -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,119 @@ 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 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"