Compare commits

..
8 Commits
Author SHA1 Message Date
root b6997560d2 Merge PR #75: feat: show a change at once and restore a setpoint now
CI / Check (gateway, backend, frontend) (push) Successful in 1m18s
Release / Images (push) Successful in 2m58s
2026-10-24 13:37:34 +02:00
rootandClaude Opus 5.5 09042c6338 feat(frontend): Przywróć teraz for pending changes in Ustawienia
CI / Check (gateway, backend, frontend) (push) Successful in 1m19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:34:34 +02:00
rootandClaude Opus 5.5 f9fbff4eea feat(backend): POST /api/setpoints/{name}/restore
Forwards to the gateway's restore now and passes its verdict on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:34:34 +02:00
rootandClaude Opus 5.5 7e50d1203b feat(gateway): show the verified value and restore a setpoint on request
GET /commands showed the last poll for up to 5 minutes after a write.
It now shows the value read back after our own write or restore when
that is newer than the poll. POST /commands/restore runs a pending
restore at once through the normal path (conflict check, read-back,
audit with the requester, rate limit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:34:34 +02:00
root 934330e157 Merge PR #74: feat: away holds until a chosen return date (ADR-012)
CI / Check (gateway, backend, frontend) (push) Successful in 1m18s
2026-10-24 13:28:16 +02:00
rootandClaude Opus 5.5 2e273f05c2 feat(frontend): pick the return date for Wyjazd
CI / Check (gateway, backend, frontend) (push) Successful in 1m18s
Tapping Wyjazd opens a form with a datetime-local picker (from one hour
to 30 days ahead, default in a week at 18:00) and sends the chosen time
with the local offset. A held restore shows its end date, for example
"do 2 lis, 18:00, potem 43 °C".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:25:10 +02:00
rootandClaude Opus 5.5 9a48442220 feat(backend): presets held until a date the user chooses
A preset with "until": "chosen" needs {"until": ...} in POST
/api/presets/{id} and sends each change to the gateway as a held change
(ADR-012). Wyjazd becomes such a preset. A held preset that would not
lower a setpoint is refused up front, so it never stops half way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:22:50 +02:00
rootandClaude Opus 5.5 b3038aeef7 feat(gateway): hold a lowering change until a chosen end without renewals
A command may carry "until" (ISO-8601 with offset). The change is stored
as a restore with leaseUntil = due = until, so it survives a restart and
runs at its end. Held changes must lower the value below both the current
value and the original, end in the future and within 30 days; otherwise
they are rejected without a write. Renewals now only extend a lease.
ADR-012 amends ADR-011 for held changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:22:02 +02:00
41 changed files with 1396 additions and 126 deletions

No files matched your search

+12 -1
View File
@@ -17,7 +17,7 @@ Setpoint changes go only to the gateway; the user name from Traefik's `Remote-Us
| `GATEWAY_TIMEOUT` | `5` | seconds |
| `INFLUX_TIMEOUT` | `10` | seconds |
| `GATEWAY_TOKEN` | unset | shared token for the gateway's `/commands`; without it the backend runs but `/api/setpoints` and `/api/presets/{id}` answer 503 |
| `PRESETS_FILE` | built-in | JSON file replacing the presets: `[{"id", "name", "changes": [{"setpoint", "value", "hours"}]}]`, `hours` null means until changed; a bad file stops the start |
| `PRESETS_FILE` | built-in | JSON file replacing the presets: `[{"id", "name", "changes": [{"setpoint", "value", "hours"}]}]`, `hours` null means until changed; a bad file stops the start. A preset may also have `"until": "chosen"` (then no `hours`): the user picks the end, see below |
```bash
mvn -f backend/pom.xml clean package
@@ -26,6 +26,17 @@ GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example \
docker compose -f backend/compose.yaml up --build # with backend/.env; network home-pump
```
`POST /api/presets/{id}` takes no body for an ordinary preset. A preset with `"until": "chosen"` (the built-in "Wyjazd")
needs `{"until": "2026-11-02T18:00:00+01:00"}` (ISO-8601 with offset, example `src/test/resources/api/preset-away-request.json`):
400 when it is missing, invalid, or sent to a preset that has none. 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/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
meanwhile (left alone, the restore is dropped), 429 too soon after the last write, 502 failed (the restore stays
pending), 503 gateway unreachable.
Publish no host port in production: only the frontend, behind Traefik, may reach the backend. It
trusts `Remote-User` and has no login of its own, so a direct port would bypass Traefik's access
rules (ADR-009).
@@ -3,10 +3,12 @@ package home.backend.adapters.gateway;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import home.backend.application.GatewayCommands;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.RestoreResult;
import home.backend.domain.SetpointState;
import java.io.IOException;
@@ -15,6 +17,7 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.Set;
/** Sends commands to the gateway's {@code /commands} with the shared token (S6). */
@@ -23,8 +26,14 @@ public class GatewayHttpCommands implements GatewayCommands {
/** The gateway answers a verdict with 200 (applied), 422 (rejected) or 502 (failed). */
private static final Set<Integer> VERDICTS = Set.of(200, 422, 502);
/** Restore now: 200 restored, 404 nothing pending, 409 changed meanwhile, 429 too soon, 502 failed. */
private static final Set<Integer> RESTORE_VERDICTS = Set.of(200, 404, 409, 429, 502);
@JsonInclude(JsonInclude.Include.NON_NULL)
private record Request(String setpoint, int value, Long minutes) {
private record Request(String setpoint, int value, Long minutes, Instant until) {
}
private record RestoreRequest(String setpoint) {
}
private record Renewal(int renewed) {
@@ -32,10 +41,12 @@ public class GatewayHttpCommands implements GatewayCommands {
private static final ObjectMapper JSON = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private final URI endpoint;
private final URI renewEndpoint;
private final URI restoreEndpoint;
private final String token;
private final Duration timeout;
private final HttpClient http;
@@ -44,6 +55,7 @@ public class GatewayHttpCommands implements GatewayCommands {
public GatewayHttpCommands(URI baseUri, String token, Duration timeout) {
this.endpoint = baseUri.resolve("/commands");
this.renewEndpoint = baseUri.resolve("/commands/renew");
this.restoreEndpoint = baseUri.resolve("/commands/restore");
this.token = token;
this.timeout = timeout;
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
@@ -52,13 +64,21 @@ public class GatewayHttpCommands implements GatewayCommands {
@Override
public CommandResult apply(Command command) throws IOException {
Long minutes = command.duration() == null ? null : command.duration().toMinutes();
byte[] body = JSON.writeValueAsBytes(new Request(command.setpoint(), command.value(), minutes));
byte[] body = JSON.writeValueAsBytes(new Request(command.setpoint(), command.value(), minutes, command.heldUntil()));
HttpRequest.Builder request = request().header("Content-Type", "application/json")
.header("X-Requested-By", command.requestedBy())
.POST(HttpRequest.BodyPublishers.ofByteArray(body));
return JSON.readValue(send(request, VERDICTS), CommandResult.class);
}
@Override
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
byte[] body = JSON.writeValueAsBytes(new RestoreRequest(setpoint));
var request = request(restoreEndpoint).header("Content-Type", "application/json")
.header("X-Requested-By", requestedBy).POST(HttpRequest.BodyPublishers.ofByteArray(body));
return JSON.readValue(send(request, RESTORE_VERDICTS), RestoreResult.class);
}
@Override
public SetpointState setpoints() throws IOException {
return JSON.readValue(send(request().GET(), Set.of(200)), SetpointState.class);
@@ -17,8 +17,11 @@ import home.backend.application.HistoryService;
import home.backend.application.SetpointService;
import home.backend.domain.CommandResult.Status;
import home.backend.domain.PresetResult;
import home.backend.domain.RestoreResult;
import java.io.IOException;
import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
@@ -41,9 +44,14 @@ public class ApiServer {
private record SetpointRequest(String setpoint, Integer value, Double hours) {
}
private record PresetRequest(String until) {
}
private static final String UNREACHABLE = "gateway unreachable";
private static final String WRITE_MARKER = "home-pump";
private static final String PRESETS_PATH = "/api/presets/";
private static final String SETPOINTS_PATH = "/api/setpoints/";
private static final Pattern RESTORE_PATH = Pattern.compile("/api/setpoints/([a-z_]{1,32})/restore");
/** The user name Traefik's auth forwards; anything odd falls back to the default. */
private static final String USER_HEADER = "Remote-User";
private static final String DEFAULT_USER = "phone";
@@ -78,8 +86,19 @@ public class ApiServer {
route("/api/setpoints", exchange -> handle(exchange, Map.of(
"GET", () -> getSetpoints(exchange, setpoints),
"POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints)))));
context(SETPOINTS_PATH, exchange -> {
var restore = RESTORE_PATH.matcher(exchange.getRequestURI().getPath());
if (restore.matches()) {
handle(exchange, Map.of("POST", () -> guarded(exchange,
body -> postRestore(exchange, restore.group(1), setpoints))));
} else {
try (exchange) {
exchange.sendResponseHeaders(404, -1);
}
}
});
context(PRESETS_PATH, exchange -> handle(exchange, Map.of(
"POST", () -> guarded(exchange, body -> postPreset(exchange, setpoints)))));
"POST", () -> guarded(exchange, body -> postPreset(exchange, body, setpoints)))));
}
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
@@ -178,15 +197,32 @@ public class ApiServer {
}
}
private static void postPreset(HttpExchange exchange, SetpointService setpoints) throws IOException {
private static void postRestore(HttpExchange exchange, String setpoint, SetpointService setpoints)
throws IOException {
try {
var result = setpoints.restore(setpoint, user(exchange));
send(exchange, restoreHttpStatus(result.outcome()), result);
} catch (IOException e) {
send(exchange, 503, error(UNREACHABLE));
}
}
private static void postPreset(HttpExchange exchange, byte[] body, SetpointService setpoints)
throws IOException {
String id = exchange.getRequestURI().getPath().substring(PRESETS_PATH.length());
try {
var result = setpoints.applyPreset(id, user(exchange));
var request = body.length == 0 ? null : JSON.readValue(body, PresetRequest.class);
var until = request == null || request.until() == null ? null
: OffsetDateTime.parse(request.until()).toInstant();
var result = setpoints.applyPreset(id, until, user(exchange));
if (result.isPresent()) {
send(exchange, presetHttpStatus(result.get().status()), result.get());
} else {
send(exchange, 404, error("no such preset"));
}
} catch (JsonProcessingException | DateTimeParseException | IllegalArgumentException e) {
send(exchange, 400, error("expected {\"until\": \"2026-11-02T18:00:00+01:00\"} for a preset that needs "
+ "an end time, else no body"));
} catch (IOException e) {
send(exchange, 503, error(UNREACHABLE));
}
@@ -200,6 +236,16 @@ public class ApiServer {
};
}
private static int restoreHttpStatus(RestoreResult.Outcome outcome) {
return switch (outcome) {
case RESTORED -> 200;
case NOT_PENDING -> 404;
case CONFLICT -> 409;
case TOO_SOON -> 429;
case FAILED -> 502;
};
}
private static int presetHttpStatus(PresetResult.Status status) {
return switch (status) {
case APPLIED -> 200;
@@ -2,6 +2,7 @@ package home.backend.application;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.RestoreResult;
import home.backend.domain.SetpointState;
import java.io.IOException;
@@ -12,6 +13,9 @@ public interface GatewayCommands {
/** A verdict, even a negative one; IOException only when no verdict could be obtained. */
CommandResult apply(Command command) throws IOException;
/** Ends the pending change of a setpoint now; a verdict like {@link #apply}. */
RestoreResult restore(String setpoint, String requestedBy) throws IOException;
SetpointState setpoints() throws IOException;
/** Keeps the gateway's live restores alive; returns how many it extended. */
@@ -7,12 +7,14 @@ import home.backend.domain.Preset.Change;
import home.backend.domain.PresetResult;
import home.backend.domain.PresetResult.ChangeResult;
import home.backend.domain.PresetResult.ChangeStatus;
import home.backend.domain.RestoreResult;
import home.backend.domain.Setpoint;
import home.backend.domain.SetpointState;
import home.backend.domain.SetpointsView;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@@ -38,48 +40,62 @@ public class SetpointService {
return gateway.apply(new Command(setpoint, value, duration(hours), user));
}
/** Puts the value from before the pending change back now, instead of waiting for its end. */
public RestoreResult restore(String setpoint, String user) throws IOException {
return gateway.restore(setpoint, user);
}
/**
* Empty if there is no such preset. A preset that does not fit the gateway's limits writes nothing.
* IOException only when no change was attempted; once writing starts, trouble is reported per change.
*
* @param until the end the user chose; required by a held preset, not allowed for any other
* @throws IllegalArgumentException when {@code until} does not match what the preset needs
*/
public Optional<PresetResult> applyPreset(String id, String user) throws IOException {
public Optional<PresetResult> applyPreset(String id, Instant until, String user) throws IOException {
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
if (preset.isEmpty()) {
return Optional.empty();
}
if (preset.get().isHeld() != (until != null)) {
throw new IllegalArgumentException(preset.get().isHeld() ? "preset " + id + " needs an end time"
: "preset " + id + " has no end time to choose");
}
List<Change> changes = preset.get().changes();
List<Setpoint> limits = gateway.setpoints().setpoints();
List<ChangeResult> checked = changes.stream().map(c -> check(c, limits)).toList();
List<ChangeResult> checked = changes.stream().map(c -> check(c, preset.get().isHeld(), limits)).toList();
if (checked.stream().anyMatch(r -> r.status() == ChangeStatus.REJECTED)) {
return Optional.of(PresetResult.of(checked));
}
return Optional.of(PresetResult.of(run(changes, user)));
return Optional.of(PresetResult.of(run(changes, until, user)));
}
private List<ChangeResult> run(List<Change> changes, String user) {
private List<ChangeResult> run(List<Change> changes, Instant until, String user) {
List<ChangeResult> results = new ArrayList<>();
for (Change change : changes) {
boolean stopped = !results.isEmpty() && results.getLast().status() != ChangeStatus.APPLIED;
results.add(stopped ? ChangeResult.notAttempted() : attempt(change, user));
results.add(stopped ? ChangeResult.notAttempted() : attempt(change, until, user));
}
return results;
}
private ChangeResult attempt(Change change, String user) {
private ChangeResult attempt(Change change, Instant until, String user) {
try {
return ChangeResult.of(apply(change.setpoint(), change.value(), change.hours(), user));
var command = until == null ? new Command(change.setpoint(), change.value(), duration(change.hours()), user)
: new Command(change.setpoint(), change.value(), null, until, user);
return ChangeResult.of(gateway.apply(command));
} catch (IOException e) {
return new ChangeResult(ChangeStatus.UNKNOWN, "no answer from the gateway", null);
}
}
/** REJECTED with the reason, or NOT_ATTEMPTED (nothing is sent if any change is rejected). */
private static ChangeResult check(Change change, List<Setpoint> limits) {
String problem = problem(change, limits);
private static ChangeResult check(Change change, boolean held, List<Setpoint> limits) {
String problem = problem(change, held, limits);
return problem == null ? ChangeResult.notAttempted() : new ChangeResult(ChangeStatus.REJECTED, problem, null);
}
private static String problem(Change change, List<Setpoint> limits) {
private static String problem(Change change, boolean held, List<Setpoint> limits) {
Optional<Setpoint> known = limits.stream().filter(s -> s.name().equals(change.setpoint())).findFirst();
if (known.isEmpty()) {
return "preset sets " + change.setpoint() + ", which the gateway does not know";
@@ -88,6 +104,11 @@ public class SetpointService {
return "preset sets " + change.setpoint() + " to " + change.value() + ", outside the gateway's limits "
+ known.get().min() + "–" + known.get().max() + " step " + known.get().step();
}
// The gateway refuses a held raise too; asking first keeps a preset from stopping half way.
if (held && known.get().value() != null && change.value() >= known.get().value()) {
return "preset sets " + change.setpoint() + " to " + change.value() + ", which is not below the current "
+ known.get().value();
}
return null;
}
@@ -1,9 +1,18 @@
package home.backend.domain;
import java.time.Duration;
import java.time.Instant;
/** Set a setpoint; with a duration the gateway restores the old value afterwards, else it stays. */
public record Command(String setpoint, int value, Duration duration, String requestedBy) {
/**
* Set a setpoint. With a duration the gateway restores the old value afterwards; with
* {@code heldUntil} it holds a lowering value until then without renewals (ADR-012); else it stays
* while the backend renews the lease (ADR-011).
*/
public record Command(String setpoint, int value, Duration duration, Instant heldUntil, String requestedBy) {
public Command(String setpoint, int value, Duration duration, String requestedBy) {
this(setpoint, value, duration, null, requestedBy);
}
/** The gateway refuses longer boosts too; we refuse first, so the phone gets a clear 400. */
public static final int MAX_HOURS = 72;
@@ -1,9 +1,19 @@
package home.backend.domain;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;
/** A named one-tap bundle of setpoint changes (F3), e.g. boost or away. */
public record Preset(String id, String name, List<Change> changes) {
/**
* A named one-tap bundle of setpoint changes (F3), e.g. boost or away. {@code until} is null, or
* {@link #CHOSEN}: the user picks the end on a calendar and the gateway holds the changes until
* then without renewals (ADR-012).
*/
public record Preset(String id, String name, @JsonInclude(JsonInclude.Include.NON_NULL) String until,
List<Change> changes) {
public static final String CHOSEN = "chosen";
/** {@code hours} null means "until changed back". */
public record Change(String setpoint, int value, Double hours) {
@@ -18,6 +28,11 @@ public record Preset(String id, String name, List<Change> changes) {
}
}
@JsonIgnore
public boolean isHeld() {
return CHOSEN.equals(until);
}
public Preset {
if (id == null || id.isBlank() || name == null || name.isBlank()) {
throw new IllegalArgumentException("a preset needs an id and a name");
@@ -25,5 +40,11 @@ public record Preset(String id, String name, List<Change> changes) {
if (changes == null || changes.isEmpty()) {
throw new IllegalArgumentException("preset " + id + " has no changes");
}
if (until != null && !until.equals(CHOSEN)) {
throw new IllegalArgumentException("preset " + id + ": until can only be \"" + CHOSEN + "\"");
}
if (CHOSEN.equals(until) && changes.stream().anyMatch(change -> change.hours() != null)) {
throw new IllegalArgumentException("preset " + id + " is held until a chosen time, so no hours");
}
}
}
@@ -0,0 +1,11 @@
package home.backend.domain;
/** The gateway's answer to "restore now", passed on to the phone unchanged. */
public record RestoreResult(Outcome outcome, String message) {
/**
* NOT_PENDING: nothing to restore. CONFLICT: the register was changed meanwhile, so it was left alone.
* TOO_SOON: the gateway's rate limit. FAILED: the restore stays pending and is retried.
*/
public enum Outcome { RESTORED, NOT_PENDING, CONFLICT, TOO_SOON, FAILED }
}
@@ -7,6 +7,7 @@
{
"id": "away",
"name": "Wyjazd",
"until": "chosen",
"changes": [
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
{ "setpoint": "co_comfort", "value": 15, "hours": null }
@@ -28,8 +28,8 @@ class PresetFileTest {
var presets = PresetFile.load(null);
assertEquals(List.of(
new Preset("cwu-now", "Ciepła woda teraz", List.of(new Change("cwu_economy", 45, 2.0))),
new Preset("away", "Wyjazd", List.of(new Change("cwu_comfort", 10, null),
new Preset("cwu-now", "Ciepła woda teraz", null, List.of(new Change("cwu_economy", 45, 2.0))),
new Preset("away", "Wyjazd", "chosen", List.of(new Change("cwu_comfort", 10, null),
new Change("co_comfort", 15, null)))), presets);
}
@@ -39,7 +39,7 @@ class PresetFileTest {
[{"id": "bath", "name": "Kąpiel", "changes": [{"setpoint": "cwu_economy", "value": 48, "hours": 1.5}]}]
"""));
assertEquals(List.of(new Preset("bath", "Kąpiel", List.of(new Change("cwu_economy", 48, 1.5)))), presets);
assertEquals(List.of(new Preset("bath", "Kąpiel", null, List.of(new Change("cwu_economy", 48, 1.5)))), presets);
}
@Test
@@ -51,6 +51,8 @@ class PresetFileTest {
"[{\"id\": \"\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 0}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 73}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"until\": \"tomorrow\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"until\": \"chosen\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 2}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]},"
+ "{\"id\": \"a\", \"name\": \"B\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 16}]}]")) {
Path file = file(json);
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import home.backend.domain.Command;
import home.backend.domain.CommandResult.Status;
import home.backend.domain.RestoreResult.Outcome;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -95,6 +96,15 @@ class GatewayHttpCommandsTest {
assertEquals(contractJson("request-boost.json"), contractJson(seen.getFirst().body()));
}
@Test
void aHeldChangeSendsTheEndAsUntil() throws Exception {
serve(200, contract("response-applied.json"));
commands("s3cret-token").apply(new Command("cwu_comfort", 10, null, Instant.parse("2026-11-02T17:00:00Z"), "phone"));
assertEquals(contractJson("request-held.json"), contractJson(seen.getFirst().body()));
}
@Test
void awayHasNoMinutes() throws Exception {
serve(200, contract("response-applied.json"));
@@ -144,6 +154,47 @@ class GatewayHttpCommandsTest {
assertNull(commands("s3cret-token").setpoints().restores().getFirst().due());
}
@Test
void restorePostsTheSetpointWithTokenAndUser() throws Exception {
serve(200, contract("response-restored.json"));
var result = commands("s3cret-token").restore("cwu_economy", "anna");
assertEquals(Outcome.RESTORED, result.outcome());
assertEquals("POST", seen.getFirst().method());
assertEquals("/commands/restore", seen.getFirst().path());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
assertEquals("anna", seen.getFirst().requestedBy());
assertEquals(contractJson("request-restore.json"), contractJson(seen.getFirst().body()));
}
@Test
void aConflictOnRestoreIsAVerdictNotAnError() throws Exception {
serve(409, contract("response-restore-conflict.json"));
assertEquals(Outcome.CONFLICT, commands("s3cret-token").restore("cwu_economy", "anna").outcome());
}
@Test
void everyRestoreStatusOfTheGatewayIsAVerdict() throws Exception {
var expected = java.util.Map.of(404, Outcome.NOT_PENDING, 429, Outcome.TOO_SOON, 502, Outcome.FAILED);
for (var entry : expected.entrySet()) {
serve(entry.getKey(), "{\"outcome\":\"" + entry.getValue() + "\",\"message\":\"x\"}");
assertEquals(entry.getValue(), commands("s3cret-token").restore("cwu_economy", "anna").outcome());
}
}
@Test
void restoreFailsWithoutAVerdictOrToken() {
serve(503, "{\"error\":\"x\"}");
assertThrows(IOException.class, () -> commands("s3cret-token").restore("cwu_economy", "anna"));
var seenBefore = seen.size();
assertThrows(IOException.class, () -> commands(null).restore("cwu_economy", "anna"));
assertEquals(seenBefore, seen.size());
}
@Test
void renewPostsToRenewWithTheTokenAndReturnsHowManyWereExtended() throws Exception {
serve(200, "{\"renewed\":2}");
@@ -11,6 +11,8 @@ import home.backend.application.SetpointService;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.CommandResult.Status;
import home.backend.domain.RestoreResult;
import home.backend.domain.RestoreResult.Outcome;
import home.backend.domain.Restore;
import home.backend.domain.Setpoint;
import home.backend.domain.SetpointState;
@@ -58,6 +60,8 @@ class ApiServerTest {
private RuntimeException gatewayBug;
private final List<Command> sent = new ArrayList<>();
private Status verdict;
private Outcome restoreOutcome;
private final List<String> restored = new ArrayList<>();
private boolean commandsDown;
private int answersBeforeDrop;
private ApiServer server;
@@ -83,6 +87,7 @@ class ApiServerTest {
new Point(Instant.parse("2026-10-23T12:00:00Z"), 44.2));
};
verdict = Status.APPLIED;
restoreOutcome = Outcome.RESTORED;
answersBeforeDrop = Integer.MAX_VALUE;
GatewayCommands commands = new GatewayCommands() {
@Override
@@ -99,6 +104,15 @@ class ApiServerTest {
};
}
@Override
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
if (commandsDown) {
throw new IOException("tunnel down");
}
restored.add(setpoint + " by " + requestedBy);
return new RestoreResult(restoreOutcome, "restored to 8");
}
@Override
public SetpointState setpoints() throws IOException {
if (commandsDown) {
@@ -164,6 +178,10 @@ class ApiServerTest {
return client.send(request, HttpResponse.BodyHandlers.ofString());
}
private static String awayRequest() throws IOException {
return example("preset-away-request").toString();
}
private static JsonNode example(String name) throws IOException {
try (InputStream in = ApiServerTest.class.getResourceAsStream("/api/" + name + ".json")) {
return JSON.readTree(in);
@@ -317,6 +335,52 @@ class ApiServerTest {
assertEquals(List.of(new Command("cwu_economy", 45, java.time.Duration.ofHours(2), "phone")), sent);
}
@Test
void restoreMatchesExampleAndAsksForTheUsersSetpoint() throws Exception {
HttpResponse<String> response = post("/api/setpoints/cwu_economy/restore", "{}", "Remote-User", "anna");
assertEquals(200, response.statusCode());
assertEquals(example("setpoint-restored"), JSON.readTree(response.body()));
assertEquals(List.of("cwu_economy by anna"), restored);
}
@Test
void restoreStatusFollowsTheGatewaysVerdict() throws Exception {
var expected = Map.of(Outcome.NOT_PENDING, 404, Outcome.CONFLICT, 409, Outcome.TOO_SOON, 429,
Outcome.FAILED, 502);
for (var entry : expected.entrySet()) {
restoreOutcome = entry.getKey();
assertEquals(entry.getValue(), post("/api/setpoints/cwu_economy/restore", "{}").statusCode());
}
}
@Test
void restoreWithTheGatewayDownIs503() throws Exception {
commandsDown = true;
assertEquals(503, post("/api/setpoints/cwu_economy/restore", "{}").statusCode());
}
@Test
void restoreIsGuardedLikeEveryWrite() throws Exception {
String path = "/api/setpoints/cwu_economy/restore";
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());
assertTrue(restored.isEmpty());
}
@Test
void otherPathsUnderSetpointsAre404() throws Exception {
for (String path : List.of("/api/setpoints/", "/api/setpoints/cwu_economy",
"/api/setpoints/cwu_economy/restore/x", "/api/setpoints/CWU/restore", "/api/setpoints/a-b/restore")) {
assertEquals(404, post(path, "{}").statusCode(), path);
}
assertTrue(restored.isEmpty());
}
@Test
void rejectedIs422AndMatchesExample() throws Exception {
verdict = Status.REJECTED;
@@ -359,7 +423,7 @@ class ApiServerTest {
@Test
void presetResultsMatchExample() throws Exception {
HttpResponse<String> response = post("/api/presets/away", "");
HttpResponse<String> response = post("/api/presets/away", awayRequest());
assertEquals(200, response.statusCode());
JsonNode body = JSON.readTree(response.body());
@@ -372,12 +436,37 @@ class ApiServerTest {
void presetCutShortByALostLinkKeepsTheResultsSoFar() throws Exception {
answersBeforeDrop = 1;
HttpResponse<String> response = post("/api/presets/away", "{}");
HttpResponse<String> response = post("/api/presets/away", awayRequest());
assertEquals(502, response.statusCode());
assertEquals(example("setpoints-preset-partial"), JSON.readTree(response.body()));
}
@Test
void awayPresetSendsTheChosenEndHeldInEachChange() throws Exception {
post("/api/presets/away", awayRequest());
var end = java.time.Instant.parse("2026-11-02T17:00:00Z");
assertEquals(List.of(new Command("cwu_comfort", 10, null, end, "phone"),
new Command("co_comfort", 15, null, end, "phone")), sent);
}
@Test
void badOrMissingEndTimesAre400AndSendNothing() throws Exception {
for (String body : List.of("", "{}", "{\"until\":null}", "{\"until\":\"tomorrow\"}", "{\"until\":5}",
"{\"until\":\"2026-11-02T18:00:00\"}", "not json", "{\"until\":\"2026-11-02T18:00:00Z\",\"x\":1}")) {
assertEquals(400, post("/api/presets/away", body).statusCode(), body);
}
assertEquals(400, post("/api/presets/cwu-now", awayRequest()).statusCode());
assertTrue(sent.isEmpty());
}
@Test
void presetWithoutAnEndStillTakesNoBodyOrAnEmptyObject() throws Exception {
assertEquals(200, post("/api/presets/cwu-now", "").statusCode());
assertEquals(200, post("/api/presets/cwu-now", "{}").statusCode());
}
@Test
void unknownPresetIs404() throws Exception {
assertEquals(404, post("/api/presets/nope", "").statusCode());
@@ -388,7 +477,7 @@ class ApiServerTest {
void presetOutsideLimitsIs422() throws Exception {
verdict = Status.REJECTED;
assertEquals(422, post("/api/presets/away", "").statusCode());
assertEquals(422, post("/api/presets/away", awayRequest()).statusCode());
}
@Test
@@ -397,7 +486,7 @@ class ApiServerTest {
assertEquals(503, get("/api/setpoints").statusCode());
assertEquals(503, post("/api/setpoints", BOOST).statusCode());
assertEquals(503, post("/api/presets/away", "").statusCode());
assertEquals(503, post("/api/presets/away", awayRequest()).statusCode());
}
@Test
@@ -2,6 +2,7 @@ package home.backend.application;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.RestoreResult;
import home.backend.domain.SetpointState;
import org.junit.jupiter.api.Test;
@@ -26,6 +27,11 @@ class LeaseRenewerTest {
throw new UnsupportedOperationException();
}
@Override
public RestoreResult restore(String setpoint, String requestedBy) {
throw new UnsupportedOperationException();
}
@Override
public SetpointState setpoints() {
throw new UnsupportedOperationException();
@@ -3,6 +3,8 @@ package home.backend.application;
import home.backend.adapters.config.PresetFile;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.RestoreResult;
import home.backend.domain.RestoreResult.Outcome;
import home.backend.domain.CommandResult.Status;
import home.backend.domain.Preset;
import home.backend.domain.Preset.Change;
@@ -16,6 +18,7 @@ import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@@ -25,6 +28,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class SetpointServiceTest {
private static final Instant RETURN = Instant.parse("2026-11-02T17:00:00Z");
private static final SetpointState STATE = new SetpointState(List.of(
new Setpoint("co_comfort", 4132, 5, 25, 1, 19),
new Setpoint("cwu_economy", 4143, 5, 48, 1, 8),
@@ -33,6 +37,7 @@ class SetpointServiceTest {
private final List<Command> sent = new ArrayList<>();
/** What the gateway does on the next calls to apply, in order: a Status, or an IOException; then APPLIED. */
private final List<Object> script = new ArrayList<>();
private final List<String> restored = new ArrayList<>();
private boolean down;
private final GatewayCommands gateway = new GatewayCommands() {
@Override
@@ -48,6 +53,15 @@ class SetpointServiceTest {
return new CommandResult((Status) next, command.setpoint(), null);
}
@Override
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
if (down) {
throw new IOException("tunnel down");
}
restored.add(setpoint + " by " + requestedBy);
return new RestoreResult(Outcome.RESTORED, "restored");
}
@Override
public SetpointState setpoints() throws IOException {
if (down) {
@@ -64,6 +78,20 @@ class SetpointServiceTest {
private final List<Preset> presets = PresetFile.load(null);
private final SetpointService service = new SetpointService(gateway, presets);
@Test
void restoreAsksTheGatewayInTheUsersName() throws Exception {
assertEquals(Outcome.RESTORED, service.restore("cwu_economy", "anna").outcome());
assertEquals(List.of("cwu_economy by anna"), restored);
}
@Test
void restoreWithTheGatewayDownIsAnIoError() {
down = true;
assertThrows(IOException.class, () -> service.restore("cwu_economy", "anna"));
}
@Test
void viewJoinsGatewayStateAndPresets() throws Exception {
var view = service.current();
@@ -98,33 +126,53 @@ class SetpointServiceTest {
@Test
void boostPresetSendsOneTimedCommand() throws Exception {
var result = service.applyPreset("cwu-now", "anna").orElseThrow();
var result = service.applyPreset("cwu-now", null, "anna").orElseThrow();
assertEquals(PresetResult.Status.APPLIED, result.status());
assertEquals(List.of(new Command("cwu_economy", 45, Duration.ofHours(2), "anna")), sent);
}
@Test
void awayPresetSendsBothChangesUntilChanged() throws Exception {
var result = service.applyPreset("away", "anna").orElseThrow();
void awayPresetSendsBothChangesHeldUntilTheChosenTime() throws Exception {
var result = service.applyPreset("away", RETURN, "anna").orElseThrow();
assertEquals(2, result.results().size());
assertEquals(List.of(new Command("cwu_comfort", 10, null, "anna"), new Command("co_comfort", 15, null, "anna")),
sent);
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
new Command("co_comfort", 15, null, RETURN, "anna")), sent);
}
@Test
void aHeldPresetNeedsAnEndTimeAndOthersRefuseOne() {
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("away", null, "anna"));
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("cwu-now", RETURN, "anna"));
assertTrue(sent.isEmpty());
}
@Test
void aHeldPresetThatWouldNotLowerAnySetpointWritesNothing() throws Exception {
var cold = new Preset("cold", "Zimno", "chosen", List.of(new Change("cwu_comfort", 10, null),
new Change("co_comfort", 19, null)));
var result = new SetpointService(gateway, List.of(cold)).applyPreset("cold", RETURN, "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
assertTrue(result.results().getLast().message().contains("not below the current 19"));
assertTrue(sent.isEmpty());
}
@Test
void unknownPresetIsEmpty() throws Exception {
assertTrue(service.applyPreset("nope", "anna").isEmpty());
assertTrue(service.applyPreset("nope", null, "anna").isEmpty());
assertTrue(sent.isEmpty());
}
@Test
void presetOutsideGatewayLimitsWritesNothing() throws Exception {
var tooHot = new Preset("hot", "Za gorąco", List.of(new Change("co_comfort", 15, null),
var tooHot = new Preset("hot", "Za gorąco", null, List.of(new Change("co_comfort", 15, null),
new Change("cwu_economy", 60, 2.0)));
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", "anna").orElseThrow();
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", null, "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
@@ -134,9 +182,9 @@ class SetpointServiceTest {
@Test
void presetWithUnknownSetpointWritesNothing() throws Exception {
var odd = new Preset("odd", "Dziwny", List.of(new Change("garage_heater", 1, null)));
var odd = new Preset("odd", "Dziwny", null, List.of(new Change("garage_heater", 1, null)));
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", "anna").orElseThrow();
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", null, "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertTrue(sent.isEmpty());
@@ -150,7 +198,7 @@ class SetpointServiceTest {
void presetReportsEveryChangeAndStopsAfterTheFirstThatIsNotApplied() throws Exception {
script.add(Status.FAILED);
var result = service.applyPreset("away", "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, "anna").orElseThrow();
assertEquals(PresetResult.Status.FAILED, result.status());
assertEquals(List.of(ChangeStatus.FAILED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
@@ -161,7 +209,7 @@ class SetpointServiceTest {
void secondChangeRejectedLeavesThePresetPartial() throws Exception {
script.addAll(List.of(Status.APPLIED, Status.REJECTED));
var result = service.applyPreset("away", "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, "anna").orElseThrow();
assertEquals(PresetResult.Status.PARTIAL, result.status());
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.REJECTED), statuses(result));
@@ -171,7 +219,7 @@ class SetpointServiceTest {
void firstChangeRejectedByTheGatewayIsRejectedAsAWhole() throws Exception {
script.add(Status.REJECTED);
var result = service.applyPreset("away", "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.REJECTED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
@@ -181,7 +229,7 @@ class SetpointServiceTest {
void lostAnswerOnTheSecondChangeKeepsTheFirstResult() throws Exception {
script.addAll(List.of(Status.APPLIED, new IOException("tunnel dropped")));
var result = service.applyPreset("away", "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, "anna").orElseThrow();
assertEquals(PresetResult.Status.PARTIAL, result.status());
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.UNKNOWN), statuses(result));
@@ -191,7 +239,7 @@ class SetpointServiceTest {
void lostAnswerOnTheFirstChangeIsFailedNotUnreachable() throws Exception {
script.add(new IOException("tunnel dropped"));
var result = service.applyPreset("away", "anna").orElseThrow();
var result = service.applyPreset("away", RETURN, "anna").orElseThrow();
assertEquals(PresetResult.Status.FAILED, result.status());
assertEquals(List.of(ChangeStatus.UNKNOWN, ChangeStatus.NOT_ATTEMPTED), statuses(result));
@@ -203,7 +251,7 @@ class SetpointServiceTest {
assertThrows(IOException.class, service::current);
assertThrows(IOException.class, () -> service.apply("cwu_economy", 45, null, "anna"));
assertThrows(IOException.class, () -> service.applyPreset("away", "anna"));
assertThrows(IOException.class, () -> service.applyPreset("away", RETURN, "anna"));
assertTrue(sent.isEmpty());
}
}
@@ -0,0 +1 @@
{ "until": "2026-11-02T18:00:00+01:00" }
@@ -0,0 +1 @@
{ "outcome": "RESTORED", "message": "restored to 8" }
@@ -17,6 +17,7 @@
{
"id": "away",
"name": "Wyjazd",
"until": "chosen",
"changes": [
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
{ "setpoint": "co_comfort", "value": 15, "hours": null }
@@ -0,0 +1,38 @@
# ADR-012: Away is held until a chosen date, without renewals
Status: accepted · 2026-10-24
Amends [ADR-011](011-setpoint-leases.md) for held lowering changes; everything else there stands.
## Context
With ADR-011 the preset "Wyjazd" (CWU 10, CO 15) lasts only while the backend and the tunnel
renew its lease. A server or tunnel outage during a trip would end the away mode and heat an
empty house. The away values only lower temperatures, so keeping them is the safe direction.
## Decision
- The user picks a return date and time on a calendar. "Wyjazd" is held until then and the
gateway restores the previous values by itself, with no renewals.
- `POST /commands` takes an optional `until` (ISO-8601 with offset), exclusive with `minutes`.
The change is stored as a restore with `leaseUntil = due = until`: no new file field, `runAt`
is the end, and a restart keeps it. Backend: preset field `"until": "chosen"` and
`POST /api/presets/{id}` with `{"until": …}`; 400 when it is missing, invalid or not wanted.
- The gateway enforces the safety, not the backend. A held change is rejected, with no write,
unless the end is in the future and at most 30 days ahead (`Command.MAX_HOLD`), and the new
value is lower than the register's current value and lower than the original that will come
back. Raising stays on renewed leases.
- `renewLeases` only extends: `leaseUntil = max(leaseUntil, now + 15 min)`. A held change keeps
its end, and a restore that is due, lapsed or failing is still never revived.
- A pending boost: a held change may replace it, keeping the first original. Because it must be
below that original, nothing above the original outlives the boost. A later boost on a held
change is an ordinary lease again, which can only shorten the hold.
- The restore at the end uses the usual path: conflict check, read-back, backoff.
## Consequences
- During a trip the house stays on away values even if the backend, tunnel or server are lost.
If the gateway or the Pi is lost, the heat pump keeps the away values until someone
intervenes; the 30 days and the lowering-only rule bound the cost.
- A held away cannot be cancelled by lapsing; the user sets the values back, which replaces it.
- Wyjazd is refused when a value is already at or below the away value (nothing to lower).
+1 -1
View File
@@ -39,7 +39,7 @@ their names and fields; new data goes into new measurements (ADR-005). The heat
|---|---|---|
| M2 Understand the controller | Timing (safe poll rate, response times), which pumps run in which state, open questions in `docs/frisko/` | `docs/frisko/` reviewed; F3 limits agreed |
| M3 Backend and web UI, read-only | Backend in Docker in Kraków: history from Influx, snapshot from the gateway; F1, F2, F5, F7 with the schematic and charts after `research/ui/`; Traefik route, Krosno only and no login for now (ADR-009). Backend, frontend and their images are on master (ADR-008 proposed, ADR-010); open: `REGISTRY_TOKEN`, first release, the stack in homelab-compose (Arcane) | My father sees the heat pump and the energy on his phone |
| M4 Setpoints | F3 through gateway commands with timed restore (ADR-006) and 15 min leases the backend renews (ADR-011); CWU first, then CO; S1–S6. On master and wired, off until `commands.enabled` (gateway/README.md: Enabling commands): command service with restores, FC6, token-protected `/commands`, backend `/api/setpoints` and presets, UI tab Ustawienia | Father boosts hot water from his phone, value restored on time |
| M4 Setpoints | F3 through gateway commands with timed restore (ADR-006) and 15 min leases the backend renews (ADR-011), away held until a chosen date (ADR-012); CWU first, then CO; S1–S6. On master and wired, off until `commands.enabled` (gateway/README.md: Enabling commands): command service with restores, FC6, token-protected `/commands`, backend `/api/setpoints` and presets, UI tab Ustawienia | Father boosts hot water from his phone, value restored on time |
| M5 Alarms | F4; decide the channel first (e.g. ntfy, Telegram) | Simulated alarm reaches the phone |
| M6 Native app? | Decide: keep the web UI, WebView wrapper or React Native | Decision recorded |
+5 -2
View File
@@ -81,9 +81,12 @@ Functional:
the backend config: CWU 5–48 °C (decided; the gateway's ceiling stays 50), CO 5–25 °C.
- **Boost**: raise the economy temperature for a duration the user picks; the previous value
is restored automatically.
- **Away / not needed**: lower the comfort temperature (e.g. CWU 10 °C, CO 15 °C) until changed back.
- **Lease** (ADR-011): every change also needs the backend to renew it every 5 minutes; after
- **Away**: lower the comfort temperature (CWU 10 °C, CO 15 °C) until a return date and time the
user picks (at most 30 days). Held by the gateway without renewals (ADR-012), lowering only.
- **Lease** (ADR-011): every other change also needs the backend to renew it every 5 minutes; after
15 minutes without, the gateway restores the previous value.
- **Restore now**: the user can end any pending change at once; the previous value comes back
through the same checks.
- F4 Alarms with notifications: heat pump alarm, controller link lost, grid outage (house on battery).
- F5 Health of backend, device links and tunnel.
- F6 Later, low priority: show the weekly program (reading needs selector writes).
+149 -3
View File
@@ -1,7 +1,8 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { answer, example, serve } from "./fixtures";
import { localInput } from "./format";
import type { Setpoints } from "./types";
// A fresh module per test: the poller keeps its answers between screens, not between tests.
@@ -60,10 +61,10 @@ describe("Settings", () => {
render(<Settings />);
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
expect(screen.getByText(paragraph("Ciepła woda, komfort: 10 °C do odwołania."))).toBeInTheDocument();
expect(screen.getByText(paragraph("Ciepła woda, komfort: 10 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
expect(posts(fetch)).toHaveLength(0);
await userEvent.click(screen.getByRole("button", { name: "Tak, ustaw" }));
await userEvent.click(screen.getByRole("button", { name: "Ustaw wyjazd" }));
expect(posts(fetch)[0][0]).toBe("api/presets/away");
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Tylko część zmian jest zapisana.");
@@ -71,6 +72,82 @@ describe("Settings", () => {
expect(alert).toHaveTextContent("Ogrzewanie domu, komfort: brak odpowiedzi, nie wiadomo, czy doszło do pompy");
});
it("asks for the return time of a trip and sends the chosen time with the local offset", async () => {
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-preset-applied")));
const Settings = await settings();
render(<Settings />);
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
const input = screen.getByLabelText("Powrót") as HTMLInputElement;
expect(input.type).toBe("datetime-local");
const hours = (value: string) => (Date.parse(value) - Date.now()) / 3600_000;
expect(hours(input.min)).toBeGreaterThan(0.9);
expect(hours(input.min)).toBeLessThan(1.1);
expect(hours(input.max) / 24).toBeGreaterThan(29.9);
expect(hours(input.max) / 24).toBeLessThan(30.1);
expect(input.value.endsWith("T18:00")).toBe(true);
expect(hours(input.value) / 24).toBeGreaterThan(6);
expect(hours(input.value) / 24).toBeLessThan(8);
const chosen = localInput(new Date(Date.now() + 3 * 24 * 3600_000));
fireEvent.change(input, { target: { value: chosen } });
await userEvent.click(screen.getByRole("button", { name: "Ustaw wyjazd" }));
const sent = JSON.parse(String(posts(fetch)[0][1]!.body)).until as string;
expect(sent).toMatch(/^\d{4}-\d\d-\d\dT\d\d:\d\d:00[+-]\d\d:\d\d$/);
expect(Date.parse(sent)).toBe(new Date(chosen).getTime());
expect(await screen.findByText(/^Zapisano\./)).toBeInTheDocument();
});
it("does not send a return time that is too early or too late", async () => {
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-preset-applied")));
const Settings = await settings();
render(<Settings />);
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
for (const days of [0.01, 31]) {
fireEvent.change(screen.getByLabelText("Powrót"), { target: { value: localInput(new Date(Date.now() + days * 24 * 3600_000)) } });
expect(screen.getByRole("button", { name: "Ustaw wyjazd" })).toBeDisabled();
}
expect(posts(fetch)).toHaveLength(0);
});
it("closes the return form on cancel without sending anything", async () => {
const fetch = serve({ "api/setpoints": example("setpoints") });
const Settings = await settings();
render(<Settings />);
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
await userEvent.click(screen.getByRole("button", { name: "Anuluj" }));
expect(screen.queryByLabelText("Powrót")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Wyjazd" })).toBeInTheDocument();
expect(posts(fetch)).toHaveLength(0);
});
it("keeps the plain confirmation for a preset without a chosen end", async () => {
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-preset-applied")));
const Settings = await settings();
render(<Settings />);
await userEvent.click(await screen.findByRole("button", { name: "Ciepła woda teraz" }));
expect(screen.queryByLabelText("Powrót")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Tak, ustaw" }));
expect(posts(fetch)[0][0]).toBe("api/presets/cwu-now");
expect(JSON.parse(String(posts(fetch)[0][1]!.body))).toEqual({});
});
it("shows a held change with its end date and the value that comes back", async () => {
const data = example<Setpoints>("setpoints");
data.restores = [{ setpoint: "cwu_comfort", register: 4144, originalValue: 43, appliedValue: 10, due: "2030-11-02T17:00:00Z" }];
serve({ "api/setpoints": data });
const Settings = await settings();
render(<Settings />);
expect(await screen.findByText(paragraph("Ciepła woda, komfort: 10 °C do 2 lis, 18:00, potem 43 °C."))).toBeInTheDocument();
});
it("labels a change without an end time as lasting while the server runs", async () => {
const data = example<Setpoints>("setpoints");
data.restores = [{ setpoint: "cwu_comfort", register: 4144, originalValue: 45, appliedValue: 10, due: null }];
@@ -94,4 +171,73 @@ describe("Settings", () => {
expect(screen.getByLabelText("Mniej: Ciepła woda, oszczędnie")).toBeDisabled();
expect(screen.getByRole("button", { name: "Ciepła woda teraz (brak odczytu)" })).toBeDisabled();
});
describe("restore now", () => {
const restoreButton = () => screen.findByRole("button", { name: "Przywróć teraz: Ciepła woda, oszczędnie" });
const restoreAnswer = (outcome: string, status: number) => () => answer({ outcome, message: "x" }, status);
it("asks first, then restores and reloads the state", async () => {
const fetch = serve({ "api/setpoints": example("setpoints") }, restoreAnswer("RESTORED", 200));
const Settings = await settings();
render(<Settings />);
await userEvent.click(await restoreButton());
expect(screen.getByText("Przywrócić 8 °C teraz?")).toBeInTheDocument();
expect(posts(fetch)).toHaveLength(0);
const loads = fetch.mock.calls.length;
await userEvent.click(screen.getByRole("button", { name: "Tak, przywróć" }));
expect(posts(fetch)).toHaveLength(1);
expect(posts(fetch)[0][0]).toBe("api/setpoints/cwu_economy/restore");
expect(await screen.findByText("Przywrócono poprzednią wartość.")).toBeInTheDocument();
expect(fetch.mock.calls.length).toBeGreaterThan(loads + 1);
});
it("sends nothing when cancelled", async () => {
const fetch = serve({ "api/setpoints": example("setpoints") }, restoreAnswer("RESTORED", 200));
const Settings = await settings();
render(<Settings />);
await userEvent.click(await restoreButton());
await userEvent.click(screen.getByRole("button", { name: "Anuluj" }));
expect(posts(fetch)).toHaveLength(0);
expect(await restoreButton()).toBeInTheDocument();
});
it("has a button large enough to tap", async () => {
serve({ "api/setpoints": example("setpoints") });
const Settings = await settings();
render(<Settings />);
expect((await restoreButton()).className).toContain("min-h-12");
});
it.each([
["CONFLICT", 409, "zostawiliśmy ją bez zmian"],
["TOO_SOON", 429, "Za wcześnie"],
["FAILED", 502, "Zmiana nadal czeka"],
["NOT_PENDING", 404, "już się zakończyła"],
])("explains %s in Polish", async (outcome, status, text) => {
serve({ "api/setpoints": example("setpoints") }, restoreAnswer(outcome, status));
const Settings = await settings();
render(<Settings />);
await userEvent.click(await restoreButton());
await userEvent.click(screen.getByRole("button", { name: "Tak, przywróć" }));
expect(await screen.findByRole("alert")).toHaveTextContent(text);
});
it("never claims nothing changed when the answer is lost", async () => {
serve({ "api/setpoints": example("setpoints") }, () => Promise.reject(new TypeError("network")));
const Settings = await settings();
render(<Settings />);
await userEvent.click(await restoreButton());
await userEvent.click(screen.getByRole("button", { name: "Tak, przywróć" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Nie wiadomo, czy zmiana doszła do pompy");
});
});
});
+96 -16
View File
@@ -1,8 +1,8 @@
import { useEffect, useState, type ReactNode } from "react";
import { postJson, WriteError } from "./api";
import { degrees, duration, when } from "./format";
import { dateTime, degrees, duration, localInput, withOffset } from "./format";
import { reload, usePoll } from "./poll";
import type { Change, CommandResult, LineStatus, Setpoint, Setpoints } from "./types";
import type { Change, CommandResult, LineStatus, RestoreOutcome, RestoreResult, Setpoint, Setpoints } from "./types";
import { Card, Freshness, Loading, PageTitle } from "./ui";
const URL = "api/setpoints";
@@ -27,6 +27,13 @@ const RESULT_TEXT: Record<string, string> = {
FAILED: "Nie wiadomo, czy zmiana doszła do pompy. Sprawdź wartości poniżej, zanim spróbujesz ponownie.",
PARTIAL: "Tylko część zmian jest zapisana. Sprawdź wartości poniżej, zanim spróbujesz ponownie.",
};
const RESTORE_TEXT: Record<RestoreOutcome, string> = {
RESTORED: "Przywrócono poprzednią wartość.",
NOT_PENDING: "Ta zmiana już się zakończyła. Nic nie zostało zmienione.",
CONFLICT: "Wartość została w międzyczasie zmieniona (np. na panelu pompy), więc zostawiliśmy ją bez zmian.",
TOO_SOON: "Za wcześnie po poprzednim zapisie. Spróbuj za minutę.",
FAILED: "Nie udało się przywrócić. Zmiana nadal czeka, spróbujemy ponownie sami. Sprawdź wartości poniżej.",
};
const LINE_TEXT: Record<LineStatus, string> = {
APPLIED: "zapisano",
REJECTED: "pompa nie przyjęła zmiany",
@@ -75,23 +82,54 @@ function ResultBox({ result }: { result: Result }) {
{result.results.map((x, i) => (
<li key={i}>
{result.changes?.[i] ? name(result.changes[i].setpoint) : `Zmiana ${i + 1}`}: {LINE_TEXT[x.status]}
{x.status === "APPLIED" && x.restoreAt ? `, wróci o ${when(x.restoreAt)}` : ""}
{x.status === "APPLIED" && x.restoreAt ? `, wróci ${dateTime(x.restoreAt)}` : ""}
</li>
))}
</ul>
</>
);
} else if (ok && result.text) {
body = result.text;
} else if (ok) {
body = <>Zapisano.{result.restoreAt ? ` Wrócimy do poprzedniej wartości o ${when(result.restoreAt)}.` : ""}</>;
body = <>Zapisano.{result.restoreAt ? ` Wrócimy do poprzedniej wartości ${dateTime(result.restoreAt)}.` : ""}</>;
} else {
body = result.text ?? RESULT_TEXT[result.status];
}
return <div role={ok ? "status" : "alert"} className={`mb-4 rounded-xl border-2 px-4 py-3 font-semibold ${tone}`}>{body}</div>;
}
function changeSentence(data: Setpoints, c: Change) {
const DAY_MS = 24 * 3600_000;
// Away is held by the gateway until this moment, at most 30 days ahead (ADR-012).
function ReturnForm({ busy, onCancel, onConfirm }: { busy: boolean; onCancel: () => void; onConfirm: (until: string) => void }) {
const [now] = useState(() => Date.now());
const min = localInput(new Date(now + 3600_000));
const max = localInput(new Date(now + 30 * DAY_MS));
const [until, setUntil] = useState(() => {
const week = new Date(now + 7 * DAY_MS);
week.setHours(18, 0, 0, 0);
return localInput(week);
});
const valid = until >= min && until <= max;
return (
<>
<label className="my-3 block font-semibold">
Powrót
<input type="datetime-local" value={until} min={min} max={max} onChange={(e) => setUntil(e.target.value)}
className="mt-1 block min-h-12 w-full rounded-xl border-2 border-edge bg-panel px-3 text-lg" />
</label>
<div className="flex gap-3">
<Button onClick={onCancel} disabled={busy}>Anuluj</Button>
<Button primary onClick={() => onConfirm(withOffset(until))} disabled={busy || !valid}>Ustaw wyjazd</Button>
</div>
</>
);
}
function changeSentence(data: Setpoints, c: Change, held: boolean) {
const back = degrees(returnValue(data, c.setpoint));
const tail = c.hours == null ? "do odwołania" : `przez ${duration(c.hours)}, potem wróci ${back}`;
const tail = held ? `do powrotu, potem wróci ${back}`
: c.hours == null ? "do odwołania" : `przez ${duration(c.hours)}, potem wróci ${back}`;
return <>{name(c.setpoint)}: <b>{degrees(c.value)}</b> {tail}.</>;
}
@@ -103,6 +141,8 @@ export function Settings() {
const [presetId, setPresetId] = useState<string | null>(null);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
// The setpoint whose pending change is about to be restored, waiting for "Tak, przywróć".
const [restoring, setRestoring] = useState<string | null>(null);
const [result, setResult] = useState<Result>(null);
// A refresh can lose the reading while a change is half-made; drop what was built on it.
@@ -116,7 +156,7 @@ export function Settings() {
const title = <PageTitle title="Ustawienia" subtitle="Temperatury zadane pompy ciepła" />;
if (!data) return <>{title}<Freshness offline={offline} />{!offline && <Loading />}</>;
const cancel = () => { setDraft(null); setPresetId(null); setConfirming(false); setResult(null); };
const cancel = () => { setDraft(null); setPresetId(null); setConfirming(false); setRestoring(null); setResult(null); };
async function send(url: string, payload: unknown, changes?: Change[]) {
setBusy(true);
@@ -137,6 +177,23 @@ export function Settings() {
reload(URL);
}
async function restore(id: string) {
setBusy(true);
let answer: Result;
try {
const { outcome } = await postJson<RestoreResult>(`${URL}/${id}/restore`);
answer = { status: outcome === "RESTORED" ? "APPLIED" : "FAILED", text: RESTORE_TEXT[outcome] };
} catch (error) {
const status = error instanceof WriteError ? error.status : 0;
answer = { status: "FAILED", text: ERROR_TEXT[status] ?? WRITE_LOST };
}
setResult(answer);
setBusy(false);
setRestoring(null);
window.scrollTo(0, 0);
reload(URL);
}
function step(s: Setpoint, direction: number) {
if (unread(s)) return;
setResult(null);
@@ -153,12 +210,19 @@ export function Settings() {
{title}
<ResultBox result={result} />
<Card title={preset.name} className="max-w-2xl">
{preset.changes.map((c) => <p key={c.setpoint} className="my-2 text-lg font-semibold">{changeSentence(data, c)}</p>)}
<p className="my-3">Potwierdzasz?</p>
<div className="flex gap-3">
<Button onClick={cancel} disabled={busy}>Anuluj</Button>
<Button primary onClick={() => send(`api/presets/${preset.id}`, undefined, preset.changes)} disabled={busy}>Tak, ustaw</Button>
</div>
{preset.changes.map((c) => <p key={c.setpoint} className="my-2 text-lg font-semibold">{changeSentence(data, c, !!preset.until)}</p>)}
{preset.until ? (
<ReturnForm busy={busy} onCancel={cancel}
onConfirm={(until) => send(`api/presets/${preset.id}`, { until }, preset.changes)} />
) : (
<>
<p className="my-3">Potwierdzasz?</p>
<div className="flex gap-3">
<Button onClick={cancel} disabled={busy}>Anuluj</Button>
<Button primary onClick={() => send(`api/presets/${preset.id}`, undefined, preset.changes)} disabled={busy}>Tak, ustaw</Button>
</div>
</>
)}
</Card>
</>
);
@@ -175,9 +239,25 @@ export function Settings() {
{data.restores.length > 0 && (
<Card title="Zmiany czasowe">
{data.restores.map((r) => (
<p key={r.setpoint} className="my-1">
{name(r.setpoint)}: <b>{degrees(r.appliedValue)}</b> {r.due ? `do ${when(r.due)}` : "do odwołania (dopóki serwer działa)"}, potem <b>{degrees(r.originalValue)}</b>.
</p>
<div key={r.setpoint} className="border-t border-edge py-3 first:border-t-0 first:pt-0">
<p className="my-1">
{name(r.setpoint)}: <b>{degrees(r.appliedValue)}</b> {r.due ? `do ${dateTime(r.due)}` : "do odwołania (dopóki serwer działa)"}, potem <b>{degrees(r.originalValue)}</b>.
</p>
{restoring === r.setpoint ? (
<>
<p className="my-2 text-lg font-semibold">Przywrócić {degrees(r.originalValue)} teraz?</p>
<div className="flex gap-3">
<Button onClick={() => setRestoring(null)} disabled={busy}>Anuluj</Button>
<Button primary onClick={() => restore(r.setpoint)} disabled={busy}>Tak, przywróć</Button>
</div>
</>
) : (
<div className="mt-2 flex">
<Button aria-label={`Przywróć teraz: ${name(r.setpoint)}`} disabled={busy}
onClick={() => { setResult(null); setRestoring(r.setpoint); }}>Przywróć teraz</Button>
</div>
)}
</div>
))}
</Card>
)}
+6 -4
View File
@@ -16,10 +16,12 @@ export class WriteError extends Error {
}
}
// A write is sent once and never retried. Resolves with the {status, ...} answer, also for a
// refusal (422) or a failed write (502); rejects with the HTTP status on 400, 404 and 503,
// A write is sent once and never retried. Resolves with the {status, ...} (or {outcome, ...}) answer, also for a
// refusal (422), a conflict (409) or a failed write (502); rejects with the HTTP status on 400, 404 and 503,
// and with status 0 when the network fails or the answer does not come in time.
export async function postJson(url: string, body?: unknown): Promise<CommandResult> {
export async function postJson<T extends { status?: string; outcome?: string } = CommandResult>(
url: string, body?: unknown,
): Promise<T> {
const response = await fetch(url, {
method: "POST",
// The custom header is what the backend uses to tell this page from another site's form.
@@ -30,6 +32,6 @@ export async function postJson(url: string, body?: unknown): Promise<CommandResu
throw new WriteError(0);
});
const answer = await response.json().catch(() => null);
if (!answer?.status) throw new WriteError(response.status);
if (!answer?.status && !answer?.outcome) throw new WriteError(response.status);
return answer;
}
+20
View File
@@ -68,6 +68,26 @@ export function when(iso: string): string {
return sameDay(date, new Date()) ? hour(date) : dayAndHour(date);
}
// "14:05" for today, "2 lis, 18:00" for another day: the end of a trip is a date, not a weekday.
export function dateTime(iso: string): string {
const date = new Date(iso);
if (sameDay(date, new Date())) return hour(date);
return `${date.toLocaleDateString("pl-PL", { day: "numeric", month: "short", timeZone: ZONE })}, ${hour(date)}`;
}
const two = (n: number) => String(n).padStart(2, "0");
// The value of <input type="datetime-local"> for a moment on the phone's own clock.
export const localInput = (date: Date) =>
`${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}T${two(date.getHours())}:${two(date.getMinutes())}`;
// "2026-11-02T18:00" on the phone's clock -> "2026-11-02T18:00:00+01:00", an exact moment for the backend.
export function withOffset(local: string): string {
const offset = -new Date(local).getTimezoneOffset();
const sign = offset < 0 ? "-" : "+";
return `${local}:00${sign}${two(Math.floor(Math.abs(offset) / 60))}:${two(Math.abs(offset) % 60)}`;
}
export function duration(hours: number): string {
return hours < 1 ? `${Math.round(hours * 60)} min` : `${number(hours, 1).replace(/,0$/, "")} godz.`;
}
+9
View File
@@ -102,6 +102,8 @@ export interface Change {
export interface Preset {
id: string;
name: string;
// "chosen": the user picks the end on a calendar and the gateway holds the changes until then (ADR-012)
until?: "chosen";
changes: Change[];
}
@@ -113,6 +115,13 @@ export interface Setpoints {
export type LineStatus = "APPLIED" | "REJECTED" | "FAILED" | "UNKNOWN" | "NOT_ATTEMPTED";
export type RestoreOutcome = "RESTORED" | "NOT_PENDING" | "CONFLICT" | "TOO_SOON" | "FAILED";
export interface RestoreResult {
outcome: RestoreOutcome;
message?: string;
}
export interface CommandResult {
status: "APPLIED" | "REJECTED" | "FAILED" | "PARTIAL";
message?: string;
+16
View File
@@ -70,6 +70,22 @@ change therefore ends 15 minutes after the last renewal. `restores.json` entries
without it (older file) counts as lapsed and is restored on start. `GET /commands` lists both `due`
(absent for "until changed") and `leaseUntil`.
**Held changes (ADR-012).** `POST /commands` with `until` (ISO-8601 with offset, not together with `minutes`)
holds the value until then without renewals: the restore has `leaseUntil = due = until`, survives a restart
and runs at its end. The gateway accepts it only if the end is in the future and at most 30 days ahead and the
new value is lower than both the current value and the original that will come back; otherwise 422 and no
write. A renewal only ever extends a lease, so it never shortens a held change. A held change may replace a
pending boost (the original stays the first one); a later ordinary change on a held one is a lease again.
**Restore now.** `POST /commands/restore` with `{"setpoint": "cwu_economy"}` (same token and throttle; the
requester comes from `X-Requested-By`) runs that setpoint's pending restore at once through the usual path.
200 restored; 404 nothing pending; 409 the register no longer holds our value, so it is left alone and the
restore dropped (as a due one); 429 too soon after the last write to it (S4); 502 failed, the restore stays
pending with its backoff.
**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.
## Version
`/health` has a top-level `version`: the release tag the image was built with (`VERSION` build arg and
@@ -9,24 +9,32 @@ import home.gateway.application.Snapshot;
import home.gateway.domain.Command;
import home.gateway.domain.CommandResult;
import home.gateway.domain.Restore;
import home.gateway.domain.RestoreResult;
import home.gateway.domain.Setpoint;
import home.gateway.domain.VerifiedValue;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.util.List;
import java.util.Objects;
import java.util.Map;
import java.util.regex.Pattern;
import static home.gateway.adapters.http.GatewayHttpServer.JSON;
import static home.gateway.adapters.http.GatewayHttpServer.respond;
/**
* {@code POST /commands} applies one setpoint change, {@code GET /commands} lists the setpoints
* {@code POST /commands} applies one setpoint change (body: setpoint, value, optional minutes or
* until, the end of a held change, ADR-012), {@code GET /commands} lists the setpoints
* and pending restores, {@code POST /commands/renew} keeps the live restores alive (ADR-011)
* and answers with how many it extended. Only the backend may call it (S6): every request needs the shared token.
* and answers with how many it extended, {@code POST /commands/restore} (body: setpoint) restores one
* setpoint now: 200 restored, 404 nothing pending, 409 the register no longer holds our value,
* 429 too soon after the last write, 502 failed (the restore stays pending). Only the backend may call it (S6): every request needs the shared token.
* HTTP status follows the outcome: 200 applied, 422 rejected, 502 failed on the controller side.
* 503 when the write path is unavailable ({@link CommandsUnavailableException}).
* Repeated bad tokens lead to 429 for a while ({@link FailedLoginThrottle}).
@@ -42,6 +50,7 @@ class CommandsEndpoint implements HttpHandler {
private static final String HEAT_PUMP = "heat_pump";
private static final String COMMANDS_PATH = "/commands";
private static final String RENEW_PATH = "/commands/renew";
private static final String RESTORE_PATH = "/commands/restore";
private record SetpointView(String name, int register, int min, int max, int step, Integer value) {
}
@@ -73,7 +82,7 @@ class CommandsEndpoint implements HttpHandler {
try (exchange) {
var method = exchange.getRequestMethod();
var path = exchange.getRequestURI().getPath();
if (!path.equals(COMMANDS_PATH) && !path.equals(RENEW_PATH)) {
if (!List.of(COMMANDS_PATH, RENEW_PATH, RESTORE_PATH).contains(path)) {
respond(exchange, 404, new GatewayHttpServer.Error("not found"));
} else if (throttle.isBlocked()) {
exchange.getResponseHeaders().set("Retry-After",
@@ -93,6 +102,8 @@ class CommandsEndpoint implements HttpHandler {
try {
if (path.equals(RENEW_PATH)) {
renew(exchange, method);
} else if (path.equals(RESTORE_PATH)) {
restoreNow(exchange, method);
} else if (method.equals("GET")) {
respond(exchange, 200, listing());
} else if (method.equals("POST")) {
@@ -115,6 +126,25 @@ class CommandsEndpoint implements HttpHandler {
}
}
private void restoreNow(HttpExchange exchange, String method) throws IOException {
if (!method.equals("POST")) {
exchange.getResponseHeaders().set("Allow", "POST");
respond(exchange, 405, new GatewayHttpServer.Error("method not allowed"));
return;
}
var body = readJsonBody(exchange);
if (body == null) {
return;
}
var setpoint = body.path("setpoint");
if (!setpoint.isTextual()) {
respond(exchange, 400, new GatewayHttpServer.Error("bad request: need a text \"setpoint\""));
return;
}
var result = commands.restoreNow(setpoint.textValue(), requester(exchange));
respond(exchange, httpStatus(result.outcome()), result);
}
private boolean authorized(HttpExchange exchange) {
var header = exchange.getRequestHeaders().getFirst("Authorization");
if (header == null || !header.startsWith(BEARER)) {
@@ -125,22 +155,14 @@ class CommandsEndpoint implements HttpHandler {
}
private void post(HttpExchange exchange) throws IOException {
var contentType = exchange.getRequestHeaders().getFirst("Content-Type");
if (contentType == null || !JSON_TYPE.matcher(contentType).matches()) {
respond(exchange, 415, new GatewayHttpServer.Error("content type must be application/json"));
return;
}
var body = readLimited(exchange);
var body = readJsonBody(exchange);
if (body == null) {
respond(exchange, 413, new GatewayHttpServer.Error("body larger than " + MAX_BODY_BYTES + " bytes"));
return;
}
var requestedBy = exchange.getRequestHeaders().getFirst("X-Requested-By");
Command command;
try {
command = parse(JSON.readTree(body),
isPlainName(requestedBy) ? requestedBy : DEFAULT_REQUESTER);
} catch (IOException | IllegalArgumentException e) {
command = parse(body, requester(exchange));
} catch (IllegalArgumentException e) {
respond(exchange, 400, new GatewayHttpServer.Error("bad request: " + e.getMessage()));
return;
}
@@ -148,6 +170,31 @@ class CommandsEndpoint implements HttpHandler {
respond(exchange, httpStatus(result.status()), result);
}
/** The parsed body, or null after answering 415, 413 or 400. */
private static JsonNode readJsonBody(HttpExchange exchange) throws IOException {
var contentType = exchange.getRequestHeaders().getFirst("Content-Type");
if (contentType == null || !JSON_TYPE.matcher(contentType).matches()) {
respond(exchange, 415, new GatewayHttpServer.Error("content type must be application/json"));
return null;
}
var body = readLimited(exchange);
if (body == null) {
respond(exchange, 413, new GatewayHttpServer.Error("body larger than " + MAX_BODY_BYTES + " bytes"));
return null;
}
try {
return JSON.readTree(body);
} catch (IOException e) {
respond(exchange, 400, new GatewayHttpServer.Error("bad request: " + e.getMessage()));
return null;
}
}
private static String requester(HttpExchange exchange) {
var requestedBy = exchange.getRequestHeaders().getFirst("X-Requested-By");
return isPlainName(requestedBy) ? requestedBy : DEFAULT_REQUESTER;
}
/** The body, or null when it is larger than the limit; never reads more than limit + 1 bytes. */
private static byte[] readLimited(HttpExchange exchange) throws IOException {
var declared = exchange.getRequestHeaders().getFirst("Content-Length");
@@ -175,7 +222,19 @@ class CommandsEndpoint implements HttpHandler {
throw new IllegalArgumentException("\"minutes\" must be an integer");
}
var duration = minutes.isInt() ? Duration.ofMinutes(minutes.intValue()) : null;
return new Command(setpoint.textValue(), value.intValue(), duration, requestedBy);
return new Command(setpoint.textValue(), value.intValue(), duration, heldUntil(body.path("until")),
requestedBy);
}
private static Instant heldUntil(JsonNode until) {
if (until.isMissingNode() || until.isNull()) {
return null;
}
try {
return OffsetDateTime.parse(until.asText()).toInstant();
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("\"until\" must be an ISO-8601 date and time with offset");
}
}
private static int httpStatus(CommandResult.Status status) {
@@ -186,24 +245,40 @@ class CommandsEndpoint implements HttpHandler {
};
}
private static int httpStatus(RestoreResult.Outcome outcome) {
return switch (outcome) {
case RESTORED -> 200;
case NOT_PENDING -> 404;
case CONFLICT -> 409;
case TOO_SOON -> 429;
case FAILED -> 502;
};
}
private Listing listing() {
var views = commands.setpoints().stream().map(this::view).toList();
var verified = commands.verifiedValues();
var views = commands.setpoints().stream().map(setpoint -> view(setpoint, verified)).toList();
return new Listing(views, commands.pendingRestores());
}
private SetpointView view(Setpoint setpoint) {
private SetpointView view(Setpoint setpoint, Map<String, VerifiedValue> verified) {
return new SetpointView(setpoint.name(), setpoint.register(), setpoint.min(),
setpoint.max(), setpoint.step(), currentValue(setpoint.name()));
setpoint.max(), setpoint.step(), currentValue(setpoint.name(), verified.get(setpoint.name())));
}
/** The last polled value, or null while the heat pump has not been read yet. */
private Integer currentValue(String setpoint) {
return snapshot.states().stream()
/**
* Whichever is newer: the last poll or the value we read back after our own write (a poll takes
* minutes, and a panel change after our write shows up in a later poll). Null while neither exists.
*/
private Integer currentValue(String setpoint, VerifiedValue verified) {
var polled = snapshot.states().stream()
.flatMap(state -> state.readings().stream())
.filter(reading -> reading.measurement().equals(HEAT_PUMP))
.map(reading -> reading.fields().get("setpoint_" + setpoint))
.filter(Objects::nonNull)
.map(Double::intValue)
.findFirst().orElse(null);
.filter(reading -> reading.fields().get("setpoint_" + setpoint) != null)
.findFirst();
if (verified != null && (polled.isEmpty() || verified.at().isAfter(polled.get().time()))) {
return verified.value();
}
return polled.map(reading -> reading.fields().get("setpoint_" + setpoint).intValue()).orElse(null);
}
}
@@ -5,6 +5,9 @@ import home.gateway.domain.CommandResult;
import home.gateway.domain.CommandResult.Status;
import home.gateway.domain.Reading;
import home.gateway.domain.Restore;
import home.gateway.domain.RestoreResult;
import home.gateway.domain.RestoreResult.Outcome;
import home.gateway.domain.VerifiedValue;
import home.gateway.domain.SafetyLimits;
import home.gateway.domain.Setpoint;
import org.slf4j.Logger;
@@ -53,6 +56,7 @@ public class CommandService implements Commands {
private final Map<Integer, Restore> restores = new LinkedHashMap<>();
private final Map<Integer, Long> lastWrite = new HashMap<>();
private final Map<Integer, Backoff> backoffs = new HashMap<>();
private final Map<String, VerifiedValue> verified = new HashMap<>();
private boolean unsaved;
private record Backoff(int failures, long retryAtNanos) {
@@ -104,12 +108,20 @@ public class CommandService implements Commands {
if (now.isBefore(earliestPlausible)) {
return rejected(command, setpoint, "clock not set");
}
if (command.heldUntil() != null && !isHoldable(command.heldUntil(), now)) {
return rejected(command, setpoint, "a held change must end in the future, at most "
+ Command.MAX_HOLD.toDays() + " days ahead");
}
if (tooSoon(setpoint.register())) {
return rejected(command, setpoint, "too soon after the last write to " + setpoint.name());
}
return write(command, setpoint, now);
}
private static boolean isHoldable(Instant end, Instant now) {
return end.isAfter(now) && !end.isAfter(now.plus(Command.MAX_HOLD));
}
private CommandResult write(Command command, Setpoint setpoint, Instant now) {
int register = setpoint.register();
var previous = restores.get(register);
@@ -117,12 +129,20 @@ public class CommandService implements Commands {
Integer old = null;
try {
old = registers.read(register);
var due = command.duration() == null ? null : now.plus(command.duration());
var due = command.heldUntil() != null ? command.heldUntil()
: command.duration() == null ? null : now.plus(command.duration());
// The first original stays even if the panel changed the register since (ADR-006).
var original = previous == null ? old : previous.originalValue();
// ADR-012: a held change is never renewed, so it may only lower. Below the original too:
// it replaces a pending boost's restore, and nothing above the original may outlive the boost.
if (command.heldUntil() != null && (command.value() >= old || command.value() >= original)) {
return rejected(command, setpoint, "a held change must lower " + setpoint.name()
+ " below " + Math.min(old, original));
}
var lease = command.heldUntil() != null ? command.heldUntil() : now.plus(Restore.LEASE);
// Saved before the write, so a write never happens without its restore on disk.
remember(new Restore(setpoint.name(), register, original, command.value(), due,
now.plus(Restore.LEASE)), previous);
lease), previous);
lastWrite.put(register, nanoTime.getAsLong());
attempted = true;
registers.write(register, command.value());
@@ -132,6 +152,7 @@ public class CommandService implements Commands {
return failedWrite(command, setpoint, old, previous, "read back " + actual + ", expected "
+ command.value());
}
verified.put(setpoint.name(), new VerifiedValue(command.value(), clock.instant()));
audit(setpoint.name(), setpoint.register(), command.requestedBy(), "APPLIED", old, command.value());
return new CommandResult(Status.APPLIED, "applied", due);
} catch (IOException e) {
@@ -163,7 +184,8 @@ public class CommandService implements Commands {
/**
* Extends the lease of every restore that has not reached its run time yet (ADR-011); one that
* is due, lapsed or failing is never revived. Returns how many leases were extended.
* is due, lapsed or failing is never revived, and a held change keeps its end (ADR-012).
* Returns how many leases were extended.
*/
@Override
public int renewLeases() {
@@ -172,6 +194,7 @@ public class CommandService implements Commands {
var now = clock.instant();
var renewed = restores.values().stream()
.filter(restore -> restore.runAt().isAfter(now))
.filter(restore -> restore.leaseUntil().isBefore(now.plus(Restore.LEASE)))
.toList();
renewed.forEach(restore -> restores.put(restore.register(), restore.leasedUntil(now.plus(Restore.LEASE))));
if (!renewed.isEmpty()) {
@@ -199,7 +222,7 @@ public class CommandService implements Commands {
.filter(restore -> !isWaiting(restore))
.sorted(Comparator.comparing(Restore::runAt))
.toList()
.forEach(this::tryRestore);
.forEach(restore -> tryRestore(restore, RESTORE_USER));
} finally {
lock.unlock();
}
@@ -212,13 +235,34 @@ public class CommandService implements Commands {
}
/** One restore that is bad or blows up must not keep the others from running. */
private void tryRestore(Restore restore) {
private RestoreResult tryRestore(Restore restore, String requestedBy) {
try {
if (isValid(restore)) {
restore(restore);
}
return isValid(restore) ? restore(restore, requestedBy)
: new RestoreResult(Outcome.NOT_PENDING, "the restore was invalid and is dropped");
} catch (RuntimeException e) {
restoreFailed(restore, null, e.toString());
return restoreFailed(restore, null, requestedBy, e.toString());
}
}
/**
* Runs the pending restore of one setpoint now, on the user's request. It takes the normal path:
* conflict check, read-back, audit, rate limit (S4); a failure leaves it pending with its backoff.
*/
@Override
public RestoreResult restoreNow(String setpoint, String requestedBy) {
lock.lock();
try {
var pending = restores.values().stream()
.filter(restore -> restore.setpoint().equals(setpoint)).findFirst();
if (pending.isEmpty()) {
return new RestoreResult(Outcome.NOT_PENDING, "no pending restore for " + setpoint);
}
if (tooSoon(pending.get().register())) {
return new RestoreResult(Outcome.TOO_SOON, "too soon after the last write to " + setpoint);
}
return tryRestore(pending.get(), requestedBy);
} finally {
lock.unlock();
}
}
@@ -229,7 +273,7 @@ public class CommandService implements Commands {
return true;
}
forget(restore.register());
auditRestore(restore, "INVALID", null);
auditRestore(restore, "INVALID", null, RESTORE_USER);
LOGGER.error("restore dropped, never written: {} ({})", problem, restore);
return false;
}
@@ -249,44 +293,48 @@ public class CommandService implements Commands {
return null;
}
private void restore(Restore restore) {
private RestoreResult restore(Restore restore, String requestedBy) {
Integer current = null;
try {
current = registers.read(restore.register());
if (!restore.isOurs(current)) {
forget(restore.register());
auditRestore(restore, "CONFLICT", current);
auditRestore(restore, "CONFLICT", current, requestedBy);
LOGGER.warn("{} holds {}, not our {}: left alone, restore dropped", restore.setpoint(),
current, restore.appliedValue());
return;
return new RestoreResult(Outcome.CONFLICT, restore.setpoint() + " holds " + current
+ ", not our " + restore.appliedValue() + ": left alone");
}
lastWrite.put(restore.register(), nanoTime.getAsLong());
registers.write(restore.register(), restore.originalValue());
pause.await();
var actual = registers.read(restore.register());
if (actual != restore.originalValue()) {
restoreFailed(restore, current, "read back " + actual + ", expected " + restore.originalValue());
return;
return restoreFailed(restore, current, requestedBy,
"read back " + actual + ", expected " + restore.originalValue());
}
forget(restore.register());
auditRestore(restore, "APPLIED", current);
verified.put(restore.setpoint(), new VerifiedValue(restore.originalValue(), clock.instant()));
auditRestore(restore, "APPLIED", current, requestedBy);
LOGGER.info("{} restored to {}", restore.setpoint(), restore.originalValue());
return new RestoreResult(Outcome.RESTORED, "restored to " + restore.originalValue());
} catch (IOException e) {
restoreFailed(restore, current, e.getMessage());
return restoreFailed(restore, current, requestedBy, e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
restoreFailed(restore, current, "interrupted");
return restoreFailed(restore, current, requestedBy, "interrupted");
}
}
private void restoreFailed(Restore restore, Integer old, String problem) {
auditRestore(restore, "FAILED", old);
private RestoreResult restoreFailed(Restore restore, Integer old, String requestedBy, String problem) {
auditRestore(restore, "FAILED", old, requestedBy);
var earlier = backoffs.get(restore.register());
var failures = earlier == null ? 1 : earlier.failures() + 1;
var wait = RETRY_WAITS.get(Math.min(failures, RETRY_WAITS.size()) - 1);
backoffs.put(restore.register(), new Backoff(failures, nanoTime.getAsLong() + wait.toNanos()));
LOGGER.error("restore of {} failed (attempt {}), next try in {}: {}", restore.setpoint(), failures,
wait, problem);
return new RestoreResult(Outcome.FAILED, problem);
}
@Override
@@ -294,6 +342,16 @@ public class CommandService implements Commands {
return List.copyOf(allowlist.values());
}
@Override
public Map<String, VerifiedValue> verifiedValues() {
lock.lock();
try {
return Map.copyOf(verified);
} finally {
lock.unlock();
}
}
@Override
public List<Restore> pendingRestores() {
lock.lock();
@@ -348,8 +406,8 @@ public class CommandService implements Commands {
return new CommandResult(Status.FAILED, message, null);
}
private void auditRestore(Restore restore, String status, Integer old) {
audit(restore.setpoint(), restore.register(), RESTORE_USER, status, old, restore.originalValue());
private void auditRestore(Restore restore, String status, Integer old, String requestedBy) {
audit(restore.setpoint(), restore.register(), requestedBy, status, old, restore.originalValue());
}
private void audit(String setpoint, int register, String requestedBy, String status, Integer old,
@@ -3,9 +3,12 @@ package home.gateway.application;
import home.gateway.domain.Command;
import home.gateway.domain.CommandResult;
import home.gateway.domain.Restore;
import home.gateway.domain.RestoreResult;
import home.gateway.domain.Setpoint;
import home.gateway.domain.VerifiedValue;
import java.util.List;
import java.util.Map;
/** What the HTTP adapter may ask of the setpoint service (F3); the service does every check. */
public interface Commands {
@@ -15,7 +18,13 @@ public interface Commands {
/** Keeps the live restores alive (ADR-011); returns how many leases were extended. */
int renewLeases();
/** Runs the pending restore of a setpoint now; the same checks as a due one. */
RestoreResult restoreNow(String setpoint, String requestedBy);
List<Setpoint> setpoints();
/** The value last read back after our own write or restore, by setpoint name. */
Map<String, VerifiedValue> verifiedValues();
List<Restore> pendingRestores();
}
@@ -3,9 +3,12 @@ package home.gateway.application;
import home.gateway.domain.Command;
import home.gateway.domain.CommandResult;
import home.gateway.domain.Restore;
import home.gateway.domain.RestoreResult;
import home.gateway.domain.Setpoint;
import home.gateway.domain.VerifiedValue;
import java.util.List;
import java.util.Map;
/** Stands in for the write path when it cannot be trusted: it never touches the controller. */
public record DisabledCommands(String reason) implements Commands {
@@ -20,6 +23,16 @@ public record DisabledCommands(String reason) implements Commands {
throw new CommandsUnavailableException(reason);
}
@Override
public RestoreResult restoreNow(String setpoint, String requestedBy) {
throw new CommandsUnavailableException(reason);
}
@Override
public Map<String, VerifiedValue> verifiedValues() {
throw new CommandsUnavailableException(reason);
}
@Override
public List<Setpoint> setpoints() {
throw new CommandsUnavailableException(reason);
@@ -6,10 +6,13 @@ import org.slf4j.LoggerFactory;
import home.gateway.domain.Command;
import home.gateway.domain.CommandResult;
import home.gateway.domain.Restore;
import home.gateway.domain.RestoreResult;
import home.gateway.domain.Setpoint;
import home.gateway.domain.VerifiedValue;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
@@ -43,13 +46,21 @@ public class RestoreScheduler implements AutoCloseable {
return new Commands() {
@Override
public CommandResult apply(Command command) {
if (firstTick.getCount() > 0) {
throw new CommandsUnavailableException("starting: restores that fell due while we were "
+ "down are still being checked, retry shortly");
}
requireFirstTick();
return inner.apply(command);
}
@Override
public RestoreResult restoreNow(String setpoint, String requestedBy) {
requireFirstTick();
return inner.restoreNow(setpoint, requestedBy);
}
@Override
public Map<String, VerifiedValue> verifiedValues() {
return inner.verifiedValues();
}
@Override
public int renewLeases() {
return inner.renewLeases();
@@ -67,6 +78,13 @@ public class RestoreScheduler implements AutoCloseable {
};
}
private void requireFirstTick() {
if (firstTick.getCount() > 0) {
throw new CommandsUnavailableException("starting: restores that fell due while we were "
+ "down are still being checked, retry shortly");
}
}
boolean awaitFirstTick(Duration timeout) throws InterruptedException {
return firstTick.await(timeout.toMillis(), TimeUnit.MILLISECONDS);
}
@@ -1,22 +1,35 @@
package home.gateway.domain;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
/**
* A request to set one setpoint (F3). With a duration the previous value comes back when it
* ends (boost); without one the value stays until changed (away).
* ends (boost); without one the value stays until changed, on a lease the backend renews.
* With {@code heldUntil} the value is held until that instant without any renewal (ADR-012);
* the service accepts it only for lowering changes.
*/
public record Command(String setpoint, int value, Duration duration, String requestedBy) {
public record Command(String setpoint, int value, Duration duration, Instant heldUntil, String requestedBy) {
/** A restore this far away would be forgotten by everyone, not a boost. */
public static final Duration MAX_BOOST = Duration.ofHours(72);
/** A held change this far away is a trip, not a forgotten setting. */
public static final Duration MAX_HOLD = Duration.ofDays(30);
public Command(String setpoint, int value, Duration duration, String requestedBy) {
this(setpoint, value, duration, null, requestedBy);
}
public Command {
Objects.requireNonNull(setpoint, "setpoint");
Objects.requireNonNull(requestedBy, "requestedBy");
if (duration != null && (duration.isNegative() || duration.isZero())) {
throw new IllegalArgumentException("duration must be positive");
}
if (duration != null && heldUntil != null) {
throw new IllegalArgumentException("a command has a duration or a held end, not both");
}
}
}
@@ -10,7 +10,8 @@ import java.time.Instant;
* still holds {@code appliedValue}; otherwise someone changed it and we leave it.
* It runs at {@link #runAt()}: {@code due}, where a change is timed (null: until changed), or the
* end of the lease, whichever comes first. The backend keeps the lease alive; a lost backend or
* tunnel lets it lapse. {@code otherApplied} is set when a later boost failed after its write may
* tunnel lets it lapse. A held change (ADR-012) has {@code leaseUntil == due}: it needs no renewal
* and runs at its end. {@code otherApplied} is set when a later boost failed after its write may
* have landed (a lost reply): the register then holds either value and both are ours.
*/
public record Restore(String setpoint, int register, int originalValue, int appliedValue,
@@ -37,7 +38,9 @@ public record Restore(String setpoint, int register, int originalValue, int appl
return new Restore(setpoint, register, originalValue, appliedValue, due, leaseUntil, value);
}
/** The lease only ever grows: a renewal must not shorten a held change. */
public Restore leasedUntil(Instant newLeaseUntil) {
return new Restore(setpoint, register, originalValue, appliedValue, due, newLeaseUntil, otherApplied);
var longest = newLeaseUntil.isAfter(leaseUntil) ? newLeaseUntil : leaseUntil;
return new Restore(setpoint, register, originalValue, appliedValue, due, longest, otherApplied);
}
}
@@ -0,0 +1,12 @@
package home.gateway.domain;
/** The outcome of restoring one setpoint on request. */
public record RestoreResult(Outcome outcome, String message) {
/**
* NOT_PENDING: nothing to restore. CONFLICT: the register no longer holds our value, so it was left
* alone and the restore dropped. TOO_SOON: the rate limit of S4. FAILED: write or read-back failed,
* the restore stays pending.
*/
public enum Outcome { RESTORED, NOT_PENDING, CONFLICT, TOO_SOON, FAILED }
}
@@ -0,0 +1,7 @@
package home.gateway.domain;
import java.time.Instant;
/** A setpoint value the gateway read back after its own write, and when. */
public record VerifiedValue(int value, Instant at) {
}
@@ -10,6 +10,9 @@ import home.gateway.domain.CommandResult;
import home.gateway.domain.CommandResult.Status;
import home.gateway.domain.Reading;
import home.gateway.domain.Restore;
import home.gateway.domain.RestoreResult;
import home.gateway.domain.RestoreResult.Outcome;
import home.gateway.domain.VerifiedValue;
import home.gateway.domain.Setpoint;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -32,6 +35,7 @@ import java.time.ZoneOffset;
import java.util.concurrent.atomic.AtomicReference;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -69,6 +73,9 @@ class CommandsEndpointTest {
private final List<Command> received = new ArrayList<>();
private CommandResult nextResult;
private int renewals;
private RestoreResult nextRestore;
private final List<String> restoreRequests = new ArrayList<>();
private final Map<String, VerifiedValue> verified = new HashMap<>();
private GatewayHttpServer server;
private final Commands commands = new Commands() {
@@ -78,6 +85,17 @@ class CommandsEndpointTest {
return nextResult;
}
@Override
public RestoreResult restoreNow(String setpoint, String requestedBy) {
restoreRequests.add(setpoint + " by " + requestedBy);
return nextRestore;
}
@Override
public Map<String, VerifiedValue> verifiedValues() {
return verified;
}
@Override
public int renewLeases() {
renewals++;
@@ -164,6 +182,16 @@ class CommandsEndpointTest {
assertEquals(List.of(new Command("cwu_comfort", 10, null, "mariusz")), received);
}
@Test
void postWithUntilAsksForAHeldChange() throws Exception {
nextResult = new CommandResult(Status.APPLIED, "ok", null);
authorized("POST", example("request-held.json"));
assertEquals(List.of(new Command("cwu_comfort", 10, null, Instant.parse("2026-11-02T17:00:00Z"),
"backend")), received);
}
@Test
void unusualRequesterNamesFallBackToBackend() throws Exception {
nextResult = new CommandResult(Status.APPLIED, "ok", null);
@@ -206,7 +234,10 @@ class CommandsEndpointTest {
"{\"setpoint\":5,\"value\":40}", "{\"setpoint\":\"cwu_economy\",\"value\":\"40\"}",
"{\"setpoint\":\"cwu_economy\",\"value\":40.5}",
"{\"setpoint\":\"cwu_economy\",\"value\":40,\"minutes\":0}",
"{\"setpoint\":\"cwu_economy\",\"value\":40,\"minutes\":\"x\"}")) {
"{\"setpoint\":\"cwu_economy\",\"value\":40,\"minutes\":\"x\"}",
"{\"setpoint\":\"cwu_comfort\",\"value\":10,\"until\":\"tomorrow\"}",
"{\"setpoint\":\"cwu_comfort\",\"value\":10,\"until\":5}",
"{\"setpoint\":\"cwu_comfort\",\"value\":10,\"minutes\":60,\"until\":\"2026-11-02T18:00:00Z\"}")) {
assertEquals(400, authorized("POST", body).statusCode(), body);
}
assertTrue(received.isEmpty());
@@ -306,6 +337,111 @@ class CommandsEndpointTest {
assertEquals(503, sendTo("/commands/renew", "POST", null, "Authorization", "Bearer " + BEARER).statusCode());
}
private Integer shownValue(String setpoint) throws Exception {
for (var view : json.readTree(authorized("GET", null).body()).get("setpoints")) {
if (view.get("name").asText().equals(setpoint)) {
return view.get("value").isNull() ? null : view.get("value").asInt();
}
}
throw new AssertionError(setpoint);
}
private void polled(Instant time, double cwuEconomy) {
snapshot.register("pump", Duration.ofSeconds(10), T0);
snapshot.recordSuccess("pump", time, List.of(new Reading("db", "heat_pump", Map.of(),
Map.of("setpoint_cwu_economy", cwuEconomy), time)));
}
@Test
void aVerifiedValueNewerThanThePollIsShown() throws Exception {
polled(T0, 8);
verified.put("cwu_economy", new VerifiedValue(45, T0.plusSeconds(30)));
assertEquals(45, shownValue("cwu_economy"));
}
@Test
void aVerifiedValueIsShownBeforeTheFirstPoll() throws Exception {
verified.put("cwu_economy", new VerifiedValue(45, T0));
assertEquals(45, shownValue("cwu_economy"));
}
@Test
void aNewerPollOverridesTheVerifiedValue() throws Exception {
polled(T0.plusSeconds(300), 12);
verified.put("cwu_economy", new VerifiedValue(45, T0));
assertEquals(12, shownValue("cwu_economy"));
}
private HttpResponse<String> restoreNow(String body, String... headers) throws Exception {
var all = new ArrayList<>(List.of("Authorization", "Bearer " + BEARER));
all.addAll(List.of(headers));
return sendTo("/commands/restore", "POST", body, all.toArray(String[]::new));
}
@Test
void restoreNowAsksForTheSetpointAndTakesTheRequesterFromTheHeader() throws Exception {
nextRestore = new RestoreResult(Outcome.RESTORED, "restored to 8");
var response = restoreNow(example("request-restore.json"), "X-Requested-By", "mariusz");
assertEquals(200, response.statusCode());
assertEquals(exampleJson("response-restored.json"), json.readTree(response.body()));
assertEquals(List.of("cwu_economy by mariusz"), restoreRequests);
}
@Test
void restoreNowStatusFollowsTheOutcome() throws Exception {
var expected = Map.of(Outcome.NOT_PENDING, 404, Outcome.CONFLICT, 409, Outcome.TOO_SOON, 429,
Outcome.FAILED, 502);
for (var entry : expected.entrySet()) {
nextRestore = new RestoreResult(entry.getKey(), "x");
assertEquals(entry.getValue(), restoreNow("{\"setpoint\":\"cwu_economy\"}").statusCode(),
entry.getKey().name());
}
}
@Test
void restoreConflictMatchesExample() throws Exception {
nextRestore = new RestoreResult(Outcome.CONFLICT, "cwu_economy holds 12, not our 45: left alone");
var response = restoreNow(example("request-restore.json"));
assertEquals(409, response.statusCode());
assertEquals(exampleJson("response-restore-conflict.json"), json.readTree(response.body()));
}
@Test
void restoreNowRejectsBadInputAndOtherMethods() throws Exception {
for (var body : List.of("not json", "{}", "{\"setpoint\":5}")) {
assertEquals(400, restoreNow(body).statusCode(), body);
}
assertEquals(405, sendTo("/commands/restore", "GET", null, "Authorization", "Bearer " + BEARER).statusCode());
assertTrue(restoreRequests.isEmpty());
}
@Test
void restoreNowNeedsTheTokenAndSharesTheThrottle() throws Exception {
for (int i = 0; i < 5; i++) {
assertEquals(401, sendTo("/commands/restore", "POST", "{\"setpoint\":\"cwu_economy\"}").statusCode());
}
assertEquals(429, restoreNow("{\"setpoint\":\"cwu_economy\"}").statusCode());
assertTrue(restoreRequests.isEmpty());
}
@Test
void restoreNowIs503WhenTheWritePathIsUnavailable() throws Exception {
server.close();
server = new GatewayHttpServer(snapshot, clock, new InetSocketAddress(0), Set.of());
server.serveCommands(new DisabledCommands("restore file was corrupt"), BEARER);
server.start();
assertEquals(503, restoreNow("{\"setpoint\":\"cwu_economy\"}").statusCode());
}
@Test
void getHasNullValuesBeforeTheFirstPoll() throws Exception {
var body = json.readTree(authorized("GET", null).body());
@@ -4,6 +4,8 @@ import home.gateway.domain.Command;
import home.gateway.domain.CommandResult.Status;
import home.gateway.domain.Reading;
import home.gateway.domain.Restore;
import home.gateway.domain.RestoreResult.Outcome;
import home.gateway.domain.VerifiedValue;
import home.gateway.domain.Setpoint;
import org.junit.jupiter.api.Test;
@@ -412,6 +414,7 @@ class CommandServiceTest {
assertTrue(inPause.await(5, TimeUnit.SECONDS), "the overdue restore is in its pause");
assertThrows(CommandsUnavailableException.class, () -> guarded.apply(command("co_comfort", 25, null)));
assertThrows(CommandsUnavailableException.class, () -> guarded.restoreNow("cwu_comfort", "app"));
assertEquals(20, registers.values.get(4132));
pauseGate.countDown();
assertTrue(scheduler.awaitFirstTick(Duration.ofSeconds(5)));
@@ -616,6 +619,119 @@ class CommandServiceTest {
assertEquals(45, registers.values.get(4144));
}
@Test
void remembersTheVerifiedValueOfASuccessfulWrite() throws IOException {
var service = service();
service.apply(command("cwu_comfort", 50, null));
assertEquals(new VerifiedValue(50, T0), service.verifiedValues().get("cwu_comfort"));
assertNull(service.verifiedValues().get("co_comfort"));
}
@Test
void aFailedWriteDoesNotChangeTheVerifiedValue() throws IOException {
var service = service();
service.apply(command("cwu_comfort", 50, null));
clock.advance(Duration.ofSeconds(60));
registers.ignoreWrites = true;
service.apply(command("cwu_comfort", 40, null));
assertEquals(new VerifiedValue(50, T0), service.verifiedValues().get("cwu_comfort"));
}
@Test
void aSuccessfulRestoreSetsTheVerifiedValueToTheOriginal() throws IOException {
var service = boosted();
clock.advance(HOUR);
service.runDueRestores();
assertEquals(new VerifiedValue(45, T0.plus(HOUR)), service.verifiedValues().get("cwu_comfort"));
}
@Test
void restoreNowPutsTheOriginalBackAndAuditsTheRequester() throws IOException {
var service = boosted();
clock.advance(Duration.ofSeconds(60));
var result = service.restoreNow("cwu_comfort", "mariusz");
assertEquals(Outcome.RESTORED, result.outcome());
assertEquals(45, registers.values.get(4144));
assertTrue(service.pendingRestores().isEmpty());
assertTrue(stored.isEmpty());
assertEquals("APPLIED", lastStatus());
assertEquals("mariusz", audit.getLast().tags().get("requested_by"));
assertEquals(45, service.verifiedValues().get("cwu_comfort").value());
}
@Test
void restoreNowWithoutAPendingRestoreDoesNothing() throws IOException {
var service = service();
var result = service.restoreNow("cwu_comfort", "mariusz");
assertEquals(Outcome.NOT_PENDING, result.outcome());
assertEquals(0, registers.reads);
assertEquals(0, registers.writes);
}
@Test
void restoreNowLeavesAChangedValueAndDropsTheRestore() throws IOException {
var service = boosted();
registers.values.put(4144, 38);
clock.advance(Duration.ofSeconds(60));
var writes = registers.writes;
var result = service.restoreNow("cwu_comfort", "mariusz");
assertEquals(Outcome.CONFLICT, result.outcome());
assertEquals(38, registers.values.get(4144));
assertEquals(writes, registers.writes);
assertTrue(service.pendingRestores().isEmpty());
assertEquals("CONFLICT", lastStatus());
assertEquals("mariusz", audit.getLast().tags().get("requested_by"));
}
@Test
void restoreNowObeysTheRateLimit() throws IOException {
var service = boosted();
clock.advance(Duration.ofSeconds(59));
var writes = registers.writes;
var result = service.restoreNow("cwu_comfort", "mariusz");
assertEquals(Outcome.TOO_SOON, result.outcome());
assertEquals(writes, registers.writes);
assertEquals(1, service.pendingRestores().size());
}
@Test
void aFailedRestoreNowStaysPendingWithItsBackoff() throws IOException {
var service = service();
service.apply(command("cwu_comfort", 50, Duration.ofSeconds(90)));
clock.advance(Duration.ofSeconds(60));
registers.failWrites = true;
var result = service.restoreNow("cwu_comfort", "mariusz");
assertEquals(Outcome.FAILED, result.outcome());
assertEquals(1, service.pendingRestores().size());
assertEquals(1, stored.size());
assertEquals("FAILED", lastStatus());
assertEquals(new VerifiedValue(50, T0), service.verifiedValues().get("cwu_comfort"));
registers.failWrites = false;
clock.advance(Duration.ofSeconds(30));
service.runDueRestores();
assertEquals(1, service.pendingRestores().size());
clock.advance(Duration.ofSeconds(30));
service.runDueRestores();
assertEquals(45, registers.values.get(4144));
}
@Test
void aRestoreCountsAsALastWrite() throws IOException {
var service = boosted();
@@ -841,6 +957,155 @@ class CommandServiceTest {
assertEquals(0, registers.writes);
}
private static Command held(String setpoint, int value, Duration fromNow) {
return new Command(setpoint, value, null, T0.plus(fromNow), "app");
}
@Test
void aHeldLoweringStaysWithoutRenewalsAndIsRestoredAtItsEnd() throws IOException {
var service = service();
var result = service.apply(held("cwu_comfort", 35, Duration.ofDays(7)));
assertEquals(Status.APPLIED, result.status());
assertEquals(T0.plus(Duration.ofDays(7)), result.restoreAt());
assertEquals(T0.plus(Duration.ofDays(7)), stored.getFirst().leaseUntil());
clock.advance(Duration.ofDays(7).minusSeconds(1));
service.runDueRestores();
assertEquals(35, registers.values.get(4144));
clock.advance(Duration.ofSeconds(1));
service.runDueRestores();
assertEquals(45, registers.values.get(4144));
assertTrue(stored.isEmpty());
}
@Test
void aHeldRaiseIsRejectedWithoutAWrite() throws IOException {
var service = service();
for (var value : List.of(45, 46, 50)) {
assertEquals(Status.REJECTED, service.apply(held("cwu_comfort", value, Duration.ofDays(1))).status());
}
assertEquals(0, registers.writes);
assertTrue(stored.isEmpty());
assertEquals("REJECTED", lastStatus());
}
@Test
void aHeldEndInThePastOrBeyondThirtyDaysIsRejectedWithoutIo() throws IOException {
var service = service();
assertEquals(Status.REJECTED, service.apply(held("cwu_comfort", 35, Duration.ZERO)).status());
assertEquals(Status.REJECTED, service.apply(held("cwu_comfort", 35, Duration.ofSeconds(-5))).status());
assertEquals(Status.REJECTED,
service.apply(held("cwu_comfort", 35, Command.MAX_HOLD.plusSeconds(1))).status());
assertEquals(0, registers.reads);
assertEquals(0, registers.writes);
assertEquals(Status.APPLIED, service.apply(held("cwu_comfort", 35, Command.MAX_HOLD)).status());
}
@Test
void renewingNeverShortensAHeldChange() throws IOException {
var service = service();
service.apply(held("cwu_comfort", 35, Duration.ofDays(7)));
clock.advance(Duration.ofDays(3));
assertEquals(0, service.renewLeases());
assertEquals(T0.plus(Duration.ofDays(7)), service.pendingRestores().getFirst().leaseUntil());
clock.advance(Duration.ofDays(4).minusMinutes(5));
service.renewLeases();
clock.advance(Duration.ofMinutes(5));
service.runDueRestores();
assertEquals(45, registers.values.get(4144));
}
@Test
void aHeldChangeKeepsItsEndWhenRenewedInItsLastMinutes() throws IOException {
var service = service();
service.apply(held("cwu_comfort", 35, Duration.ofMinutes(20)));
clock.advance(Duration.ofMinutes(10));
service.renewLeases();
clock.advance(Duration.ofMinutes(10));
service.runDueRestores();
assertEquals(45, registers.values.get(4144));
}
@Test
void aHeldChangeSurvivesARestartAndRunsAtItsEnd() throws IOException {
service().apply(held("cwu_comfort", 35, Duration.ofDays(7)));
clock.advance(Duration.ofDays(3));
var restarted = service();
restarted.runDueRestores();
assertEquals(35, registers.values.get(4144));
clock.advance(Duration.ofDays(4));
restarted.runDueRestores();
assertEquals(45, registers.values.get(4144));
}
@Test
void aFailedRestoreAtTheEndOfAHeldChangeGoesThroughTheBackoff() throws IOException {
var service = service();
service.apply(held("cwu_comfort", 35, Duration.ofDays(7)));
clock.advance(Duration.ofDays(7));
registers.failWrites = true;
service.runDueRestores();
var writes = registers.writes;
clock.advance(Duration.ofSeconds(59));
service.runDueRestores();
assertEquals(writes, registers.writes);
registers.failWrites = false;
clock.advance(Duration.ofSeconds(1));
service.runDueRestores();
assertEquals(45, registers.values.get(4144));
assertEquals("FAILED", audit.get(audit.size() - 2).tags().get("status"));
}
@Test
void aHeldChangeReplacesABoostButKeepsItsOriginalAndMustLowerBelowIt() throws IOException {
var service = service();
service.apply(command("cwu_comfort", 50, HOUR));
clock.advance(Duration.ofMinutes(5));
assertEquals(Status.REJECTED, service.apply(held("cwu_comfort", 47, Duration.ofDays(7))).status());
assertEquals(50, registers.values.get(4144));
assertEquals(Status.APPLIED, service.apply(held("cwu_comfort", 40, Duration.ofDays(7))).status());
var restore = service.pendingRestores().getFirst();
assertEquals(45, restore.originalValue());
assertEquals(T0.plus(Duration.ofDays(7)), restore.runAt());
}
@Test
void aBoostOnAHeldChangeIsAnOrdinaryLeaseAgain() throws IOException {
var service = service();
service.apply(held("cwu_comfort", 35, Duration.ofDays(7)));
clock.advance(Duration.ofMinutes(5));
service.apply(command("cwu_comfort", 50, null));
assertEquals(clock.instant().plus(Restore.LEASE), service.pendingRestores().getFirst().runAt());
assertEquals(45, service.pendingRestores().getFirst().originalValue());
}
@Test
void renewingStillExtendsANormalLease() throws IOException {
var service = service();
service.apply(command("cwu_comfort", 50, null));
clock.advance(Duration.ofMinutes(10));
assertEquals(1, service.renewLeases());
assertEquals(clock.instant().plus(Restore.LEASE), service.pendingRestores().getFirst().leaseUntil());
}
private static final class MutableClock extends Clock {
private Instant now = T0;
private long nanos;
@@ -0,0 +1 @@
{ "setpoint": "cwu_comfort", "value": 10, "until": "2026-11-02T17:00:00Z" }
@@ -0,0 +1 @@
{ "setpoint": "cwu_economy" }
@@ -0,0 +1 @@
{ "outcome": "CONFLICT", "message": "cwu_economy holds 12, not our 45: left alone" }
@@ -0,0 +1 @@
{ "outcome": "RESTORED", "message": "restored to 8" }