Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24a141a377 | ||
|
|
a1d6a5c8a6 | ||
|
|
d1f053079a | ||
|
|
8b07b9ba0e | ||
|
|
aaf08d4f5c | ||
|
|
b0c0872775 | ||
|
|
f128b03ae5 | ||
|
|
9f9e4e4f50 | ||
|
|
4b26028d01 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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ą");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user