Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6997560d2 | ||
|
|
09042c6338 | ||
|
|
f9fbff4eea | ||
|
|
7e50d1203b | ||
|
|
934330e157 | ||
|
|
2e273f05c2 | ||
|
|
9a48442220 | ||
|
|
b3038aeef7 |
No files matched your search
+12
-1
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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.`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }
|
||||
Reference in new issue
Block a user