feat(backend): POST /api/refresh forwards the refresh to the gateway

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-24 18:36:18 +02:00
1 parent b0c0872775
commit aaf08d4f5c
9 files changed
+118 -7

No files matched your search

+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 }]
}