Merge PR #75: feat: show a change at once and restore a setpoint now
This commit is contained in:
commit
289a1fa037
29 files changed
+802
-56
No files matched your search
@@ -31,6 +31,12 @@ needs `{"until": "2026-11-02T18:00:00+01:00"}` (ISO-8601 with offset, example `s
|
||||
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).
|
||||
|
||||
@@ -8,6 +8,7 @@ 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;
|
||||
@@ -25,10 +26,16 @@ 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, Instant until) {
|
||||
}
|
||||
|
||||
private record RestoreRequest(String setpoint) {
|
||||
}
|
||||
|
||||
private record Renewal(int renewed) {
|
||||
}
|
||||
|
||||
@@ -39,6 +46,7 @@ public class GatewayHttpCommands implements GatewayCommands {
|
||||
|
||||
private final URI endpoint;
|
||||
private final URI renewEndpoint;
|
||||
private final URI restoreEndpoint;
|
||||
private final String token;
|
||||
private final Duration timeout;
|
||||
private final HttpClient http;
|
||||
@@ -47,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();
|
||||
@@ -62,6 +71,14 @@ public class GatewayHttpCommands implements GatewayCommands {
|
||||
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,6 +17,7 @@ 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;
|
||||
@@ -49,6 +50,8 @@ public class ApiServer {
|
||||
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";
|
||||
@@ -83,6 +86,17 @@ 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, body, setpoints)))));
|
||||
}
|
||||
@@ -183,6 +197,16 @@ public class ApiServer {
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
@@ -212,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,6 +7,7 @@ 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;
|
||||
@@ -39,6 +40,11 @@ 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.
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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;
|
||||
@@ -153,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) {
|
||||
@@ -321,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;
|
||||
|
||||
@@ -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;
|
||||
@@ -35,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
|
||||
@@ -50,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) {
|
||||
@@ -66,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();
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{ "outcome": "RESTORED", "message": "restored to 8" }
|
||||
@@ -85,6 +85,8 @@ Functional:
|
||||
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).
|
||||
|
||||
@@ -171,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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState, type ReactNode } from "react";
|
||||
import { postJson, WriteError } from "./api";
|
||||
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",
|
||||
@@ -81,6 +88,8 @@ function ResultBox({ result }: { result: Result }) {
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
} else if (ok && result.text) {
|
||||
body = result.text;
|
||||
} else if (ok) {
|
||||
body = <>Zapisano.{result.restoreAt ? ` Wrócimy do poprzedniej wartości ${dateTime(result.restoreAt)}.` : ""}</>;
|
||||
} else {
|
||||
@@ -132,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.
|
||||
@@ -145,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);
|
||||
@@ -166,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);
|
||||
@@ -211,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 ${dateTime(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;
|
||||
}
|
||||
@@ -115,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;
|
||||
|
||||
@@ -77,6 +77,15 @@ new value is lower than both the current value and the original that will come b
|
||||
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,7 +9,9 @@ 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;
|
||||
@@ -20,7 +22,7 @@ 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;
|
||||
@@ -30,7 +32,9 @@ import static home.gateway.adapters.http.GatewayHttpServer.respond;
|
||||
* {@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}).
|
||||
@@ -46,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) {
|
||||
}
|
||||
@@ -77,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",
|
||||
@@ -97,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")) {
|
||||
@@ -119,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)) {
|
||||
@@ -129,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;
|
||||
}
|
||||
@@ -152,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");
|
||||
@@ -202,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) {
|
||||
@@ -148,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) {
|
||||
@@ -217,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();
|
||||
}
|
||||
@@ -230,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,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;
|
||||
}
|
||||
@@ -267,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
|
||||
@@ -312,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();
|
||||
@@ -366,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);
|
||||
}
|
||||
|
||||
@@ -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++;
|
||||
@@ -319,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();
|
||||
|
||||
@@ -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