Merge PR #75: feat: show a change at once and restore a setpoint now

This commit is contained in:
root committed 2026-10-24 13:37:34 +02:00
commit 289a1fa037
29 files changed
+802 -56

No files matched your search

+6
View File
@@ -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" }
+2
View File
@@ -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).
+69
View File
@@ -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");
});
});
});
+49 -5
View File
@@ -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
View File
@@ -16,10 +16,12 @@ export class WriteError extends Error {
}
}
// A write is sent once and never retried. Resolves with the {status, ...} answer, also for a
// refusal (422) or a failed write (502); rejects with the HTTP status on 400, 404 and 503,
// A write is sent once and never retried. Resolves with the {status, ...} (or {outcome, ...}) answer, also for a
// refusal (422), a conflict (409) or a failed write (502); rejects with the HTTP status on 400, 404 and 503,
// and with status 0 when the network fails or the answer does not come in time.
export async function postJson(url: string, body?: unknown): Promise<CommandResult> {
export async function postJson<T extends { status?: string; outcome?: string } = CommandResult>(
url: string, body?: unknown,
): Promise<T> {
const response = await fetch(url, {
method: "POST",
// The custom header is what the backend uses to tell this page from another site's form.
@@ -30,6 +32,6 @@ export async function postJson(url: string, body?: unknown): Promise<CommandResu
throw new WriteError(0);
});
const answer = await response.json().catch(() => null);
if (!answer?.status) throw new WriteError(response.status);
if (!answer?.status && !answer?.outcome) throw new WriteError(response.status);
return answer;
}
+7
View File
@@ -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;
+9
View File
@@ -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" }