Compare commits

..
Author SHA1 Message Date
rootandClaude Sonnet 5.5 7e28a25c22 feat(web): add Ustawienia screen with presets and setpoint steppers
CI / Backend tests (Maven / Java 21) (push) Successful in 1m5s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:45:28 +02:00
13 changed files with 211 additions and 893 deletions

No files matched your search

+14
View File
@@ -3,3 +3,17 @@ export async function getJson(url) {
if (!response.ok) throw new Error(`${url}: ${response.status}`);
return response.json();
}
// A write is sent once and never retried. Resolves with the {status, message, restoreAt}
// answer, also for a refusal; rejects when the gateway is unreachable (503) or the network fails.
export async function postJson(url, body) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body ?? {}),
});
if (response.status === 503) throw new Error(`${url}: 503`);
const answer = await response.json().catch(() => null);
if (!answer?.status) throw new Error(`${url}: ${response.status}`);
return answer;
}
+2 -1
View File
@@ -2,11 +2,12 @@ import { ageMinutes, ago } from "./format.js";
import heatPump from "./heat-pump.js";
import energy from "./energy.js";
import history from "./history.js";
import settings from "./settings.js";
import status from "./status.js";
const REFRESH_MS = 30000;
const STALE_MINUTES = 10;
const screens = { "heat-pump": heatPump, energy, history, status };
const screens = { "heat-pump": heatPump, energy, history, settings, status };
const root = document.getElementById("screen");
const tabs = document.querySelectorAll("#tabs button");
const cache = {}; // last good data per screen, kept while the backend is unreachable
+2 -9
View File
@@ -1,5 +1,5 @@
import { getJson } from "./api.js";
import { celsius, number, hour } from "./format.js";
import { celsius, hour } from "./format.js";
import { schematic } from "./schematic.js";
const HEADLINES = {
@@ -22,7 +22,7 @@ function pumpRows(pumps) {
}
function view(data) {
const { targets, setpoints: s, temperatures: t } = data;
const { targets, temperatures: t } = data;
const alarm = data.alarm
? `<div class="banner bad" role="alert">ALARM pompy ciepła. Sprawdź sterownik w domu.</div>` : "";
return `${alarm}
@@ -40,13 +40,6 @@ function view(data) {
${row("Pokój", celsius(targets.room))}
${row("Woda grzewcza CO", celsius(targets.co))}
${row("Ciepła woda CWU", celsius(targets.cwu))}
</section>
<section class="card rows"><h2>Nastawy</h2>
${row("CO oszczędnie", `${number(s.coEconomy)} °C`)}
${row("CO komfort", `${number(s.coComfort)} °C`)}
${row("CWU oszczędnie", `${number(s.cwuEconomy)} °C`)}
${row("CWU komfort", `${number(s.cwuComfort)} °C`)}
<p class="muted">Tylko podgląd. Zmiana nastaw będzie później.</p>
</section>`;
}
@@ -15,6 +15,7 @@
<button data-screen="heat-pump"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3c3 4 5 6.5 5 10a5 5 0 0 1-10 0c0-2 1-3.5 2-5 1 1 1.5 2 2 2.5C11.5 8.5 11 6 12 3z"/></svg>Pompa ciepła</button>
<button data-screen="energy"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 5 13h6l-1 9 8-11h-6z"/></svg>Energia</button>
<button data-screen="history"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 18 9 11l4 4 8-10" fill="none" stroke-width="2.5"/></svg>Historia</button>
<button data-screen="settings"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h2M10 17h10" fill="none" stroke-width="2.5"/><circle cx="16" cy="7" r="2.5"/><circle cx="8" cy="17" r="2.5"/></svg>Ustawienia</button>
<button data-screen="status"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" fill="none" stroke-width="2.5"/><path d="m8 12 3 3 5-6" fill="none" stroke-width="2.5"/></svg>Stan</button>
</nav>
</body>
+165
View File
@@ -0,0 +1,165 @@
import { getJson, postJson } from "./api.js";
import { number, hour, dayAndHour } from "./format.js";
const NAMES = {
cwu_economy: "Ciepła woda, oszczędnie",
cwu_comfort: "Ciepła woda, komfort",
co_economy: "Ogrzewanie domu, oszczędnie",
co_comfort: "Ogrzewanie domu, komfort",
};
const DURATIONS = [
{ hours: 1, label: "1 godz." }, { hours: 2, label: "2 godz." },
{ hours: 4, label: "4 godz." }, { hours: null, label: "Do odwołania" },
];
const OFFLINE = "Dom nie odpowiada, spróbuj za chwilę.";
const RESULT_TEXT = {
REJECTED: "Pompa nie przyjęła tej zmiany. Nic nie zostało zmienione.",
FAILED: "Nie wiadomo, czy zmiana doszła do pompy. Sprawdź wartości poniżej, zanim spróbujesz ponownie.",
};
// Screen state outlives the 30 s refresh, so a half-made change is not lost.
// draft: the one setpoint being changed; confirming: the summary is shown, waiting for "Tak".
const state = { draft: null, hours: 2, preset: null, confirming: false, busy: false, result: null };
const name = (id) => NAMES[id] ?? id;
const degrees = (value) => `${number(value, 1).replace(/,0$/, "")} °C`;
const disabled = () => (state.busy ? " disabled" : "");
const find = (data, id) => data.setpoints.find((s) => s.name === id);
function when(iso) {
const date = new Date(iso);
return date.toDateString() === new Date().toDateString() ? hour(date) : dayAndHour(date);
}
function restores(data) {
if (!data.restores.length) return "";
const lines = data.restores.map((r) =>
`<p>${name(r.setpoint)}: <b>${degrees(r.appliedValue)}</b> do ${when(r.due)}, potem <b>${degrees(r.originalValue)}</b>.</p>`);
return `<section class="card"><h2>Zmiany czasowe</h2>${lines.join("")}</section>`;
}
function resultBox() {
const r = state.result;
if (!r) return "";
if (r.status === "APPLIED") {
const back = r.restoreAt ? ` Wrócimy do poprzedniej wartości o ${when(r.restoreAt)}.` : "";
return `<div class="banner ok" role="status">Zapisano.${back}</div>`;
}
const detail = r.message ? `<br><span class="detail">${r.message}</span>` : "";
return `<div class="banner bad" role="alert">${r.text ?? RESULT_TEXT[r.status]}${detail}</div>`;
}
function confirmButtons(yes, action) {
return `<div class="seg"><button data-act="cancel"${disabled()}>Anuluj</button>
<button class="primary" data-act="${action}"${disabled()}>${yes}</button></div>`;
}
function presets(data) {
const buttons = data.presets.map((p) =>
`<button class="big" data-preset="${p.id}"${disabled()}>${p.label}</button>`).join("");
return `<section class="card"><h2>Szybkie ustawienia</h2><div class="stack">${buttons}</div></section>`;
}
function presetConfirm(data) {
const preset = data.presets.find((p) => p.id === state.preset);
const effect = preset.description ? `<p class="lead">${preset.description}</p>` : "";
return `<section class="card"><h2>${preset.label}</h2>${effect}<p>Potwierdzasz?</p>
${confirmButtons("Tak, ustaw", "preset-ok")}</section>`;
}
function stepper(s) {
const shown = state.draft?.name === s.name ? state.draft.value : s.value;
const label = name(s.name);
return `<div class="setpoint${shown === s.value ? "" : " changed"}"><span class="name">${label}</span>
<div class="step">
<button data-step="-1" data-name="${s.name}" aria-label="Mniej: ${label}"${disabled()}${shown <= s.min ? " disabled" : ""}>−</button>
<output>${degrees(shown)}</output>
<button data-step="1" data-name="${s.name}" aria-label="Więcej: ${label}"${disabled()}${shown >= s.max ? " disabled" : ""}>+</button>
</div><span class="muted">od ${degrees(s.min)} do ${degrees(s.max)}</span></div>`;
}
function saveBox(data) {
const d = state.draft;
if (!d) return "";
const s = find(data, d.name);
const options = DURATIONS.map((o) =>
`<button data-hours="${o.hours ?? ""}" aria-pressed="${o.hours === state.hours}"${disabled()}>${o.label}</button>`).join("");
const action = state.confirming
? `<p class="lead">${saveSummary(s)}</p>${confirmButtons("Tak, zapisz", "save-ok")}`
: `<div class="seg"><button data-act="cancel"${disabled()}>Anuluj</button>
<button class="primary" data-act="save"${disabled()}>Zapisz</button></div>`;
return `<div class="savebox"><p class="lead">${name(d.name)}: z ${degrees(s.value)} na <b>${degrees(d.value)}</b></p>
<p>Na jak długo?</p><div class="seg wrap">${options}</div>${action}</div>`;
}
function saveSummary(s) {
const value = degrees(state.draft.value);
if (!state.hours) return `Ustawię ${value} do odwołania. Poprzednia wartość (${degrees(s.value)}) nie wróci sama.`;
const back = when(new Date(Date.now() + state.hours * 3600000).toISOString());
return `Ustawię ${value}. O ${back} wrócimy do ${degrees(s.value)}.`;
}
function body(data) {
if (state.preset) return resultBox() + presetConfirm(data);
return `${resultBox()}${restores(data)}${presets(data)}
<section class="card"><h2>Nastawy</h2>${data.setpoints.map(stepper).join("")}${saveBox(data)}</section>`;
}
const view = (data) => `<div id="settings">${body(data)}</div>`;
function change(data, el) {
const { step, name: id, hours, preset, act } = el.dataset;
state.result = null;
if (step) {
const s = find(data, id);
const from = state.draft?.name === id ? state.draft.value : s.value;
const value = Math.min(s.max, Math.max(s.min, from + Number(step) * s.step));
state.draft = value === s.value ? null : { name: id, value };
state.confirming = false;
} else if (hours !== undefined) {
state.hours = hours === "" ? null : Number(hours);
state.confirming = false;
} else if (preset) {
state.preset = preset;
} else if (act === "cancel") {
Object.assign(state, { draft: null, preset: null, confirming: false });
} else if (act === "save") {
state.confirming = true;
}
}
function bind(root, data, reload) {
const box = root.querySelector("#settings");
async function send(url, payload) {
state.busy = true;
box.innerHTML = body(data);
try {
state.result = await postJson(url, payload);
if (state.result.status === "APPLIED") state.draft = null;
} catch {
state.result = { status: "FAILED", text: OFFLINE };
}
Object.assign(state, { busy: false, preset: null, confirming: false });
window.scrollTo(0, 0);
reload();
}
box.addEventListener("click", (event) => {
const el = event.target.closest("button");
if (!el || state.busy) return;
const act = el.dataset.act;
if (act === "save-ok") {
const { name: setpoint, value } = state.draft;
send("api/setpoints", { setpoint, value, hours: state.hours ?? undefined });
} else if (act === "preset-ok") {
send(`api/presets/${state.preset}`);
} else {
change(data, el);
box.innerHTML = body(data);
box.querySelector(".savebox")?.scrollIntoView({ block: "nearest" });
}
});
}
export default { load: () => getJson("api/setpoints"), view, bind };
+27 -3
View File
@@ -2,14 +2,14 @@
--bg: #f1f5f2; --card: #ffffff; --text: #14201a; --muted: #4a5a52; --line: #cfd9d3;
--accent: #0d6b45; --on-accent: #ffffff; --ok: #0d6b45; --warn: #8a5a00; --bad: #b3261e;
--warn-bg: #fff1cc; --bad-bg: #fde0dd; --hot: #c8501a; --cold: #1b6fb5; --off: #8d9a93;
--box: #e8f0eb;
--box: #e8f0eb; --ok-bg: #d9f2e4;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1713; --card: #19241e; --text: #f0f6f2; --muted: #b4c4ba; --line: #34463c;
--accent: #4fcf94; --on-accent: #06210f; --ok: #4fcf94; --warn: #f2c14e; --bad: #ff8a80;
--warn-bg: #3d3210; --bad-bg: #4a1f1c; --hot: #ff9a62; --cold: #6bb6f2; --off: #7b8b82;
--box: #223229;
--box: #223229; --ok-bg: #153d2a;
}
}
* { box-sizing: border-box; }
@@ -41,7 +41,7 @@ nav {
}
nav button {
flex: 1; min-height: 64px; border: 0; background: none; color: var(--muted); font: inherit;
font-size: 0.75rem; font-weight: 600; white-space: nowrap; display: flex; flex-direction: column; align-items: center;
font-size: 0.7rem; font-weight: 600; white-space: nowrap; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 2px; padding: 4px 2px;
}
nav svg { width: 26px; height: 26px; fill: currentColor; stroke: currentColor; stroke-linejoin: round; stroke-linecap: round; }
@@ -76,3 +76,27 @@ svg .sub { fill: var(--muted); }
.chart .mark { fill: var(--accent); }
.chart { touch-action: pan-y; width: 100%; height: auto; display: block; }
.readout { min-height: 2.7em; font-weight: 600; }
.banner.ok { background: var(--ok-bg); color: var(--ok); border: 2px solid var(--ok); }
.detail { font-weight: 400; font-size: 0.85rem; }
.lead { font-size: 1.1rem; font-weight: 600; margin: 8px 0; }
.stack { display: grid; gap: 10px; }
button.big, .seg button.primary {
min-height: 64px; border: 2px solid var(--accent); border-radius: 12px; font: inherit;
font-size: 1.1rem; font-weight: 700; background: var(--card); color: var(--accent);
}
.seg button.primary { background: var(--accent); color: var(--on-accent); }
button:disabled { opacity: 0.45; }
.seg.wrap { flex-wrap: wrap; }
.seg.wrap button { flex: 1 1 40%; }
.setpoint { padding: 10px 0; border-top: 1px solid var(--line); }
.setpoint:first-of-type { border-top: 0; }
.setpoint .name { display: block; font-weight: 600; }
.setpoint.changed { border-left: 5px solid var(--accent); padding-left: 10px; }
.step { display: flex; align-items: center; gap: 10px; margin: 6px 0 2px; }
.step button {
width: 64px; height: 64px; border: 2px solid var(--accent); border-radius: 12px; background: var(--card);
color: var(--accent); font: inherit; font-size: 2rem; font-weight: 700; line-height: 1;
}
.step output { flex: 1; text-align: center; font-size: 1.8rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.savebox { border-top: 2px solid var(--accent); margin-top: 8px; padding-top: 8px; }
.savebox { scroll-margin-bottom: 84px; }
@@ -1,47 +0,0 @@
package home.gateway.adapters.store;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import home.gateway.application.RestoreStore;
import home.gateway.domain.Restore;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
/** The pending restores as one JSON file, replaced atomically (ADR-005). */
public class JsonRestoreStore implements RestoreStore {
private final Path file;
private final ObjectMapper json = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
public JsonRestoreStore(Path file) {
this.file = file;
}
@Override
public List<Restore> load() throws IOException {
if (!Files.exists(file)) {
return List.of();
}
try {
return json.readValue(file.toFile(), new TypeReference<List<Restore>>() { });
} catch (IOException e) {
throw new IOException("restore file " + file + " is unreadable, fix or remove it by hand: "
+ e.getMessage(), e);
}
}
@Override
public void save(List<Restore> restores) throws IOException {
var temp = file.resolveSibling(file.getFileName() + ".tmp");
json.writeValue(temp.toFile(), restores);
Files.move(temp, file, StandardCopyOption.ATOMIC_MOVE);
}
}
@@ -1,225 +0,0 @@
package home.gateway.application;
import home.gateway.domain.Command;
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.Setpoint;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.locks.ReentrantLock;
/**
* The only way the gateway writes to the controller (S1-S4). It checks the allowlist and the rate
* limit, writes, reads back, audits every attempt and keeps the timed restores of ADR-006.
* Calls are serialized: the controller takes one request at a time.
*/
public class CommandService implements Commands {
private static final Logger LOGGER = LoggerFactory.getLogger(CommandService.class);
private static final String AUDIT_DATABASE = "Dom_Pompa";
private static final String AUDIT_MEASUREMENT = "setpoint_audit";
private static final String RESTORE_USER = "restore";
private final Map<String, Setpoint> allowlist = new LinkedHashMap<>();
private final SetpointRegisters registers;
private final RestoreStore store;
private final ReadingSink audit;
private final Clock clock;
private final Duration minInterval;
private final Pause pause;
// Not synchronized: blocking I/O inside a monitor would pin a virtual thread's carrier.
private final ReentrantLock lock = new ReentrantLock();
private final Map<Integer, Restore> restores = new LinkedHashMap<>();
private final Map<Integer, Instant> lastWrite = new HashMap<>();
private final Map<Integer, String> restoreProblems = new HashMap<>();
/** Fails when the store cannot be read: pending restores must never be dropped silently. */
public CommandService(List<Setpoint> setpoints, SetpointRegisters registers, RestoreStore store,
ReadingSink audit, Clock clock, Duration minInterval, Pause pause) throws IOException {
setpoints.forEach(setpoint -> allowlist.put(setpoint.name(), setpoint));
this.registers = registers;
this.store = store;
this.audit = audit;
this.clock = clock;
this.minInterval = minInterval;
this.pause = pause;
store.load().forEach(restore -> restores.put(restore.register(), restore));
}
@Override
public CommandResult apply(Command command) {
lock.lock();
try {
return applyLocked(command);
} finally {
lock.unlock();
}
}
private CommandResult applyLocked(Command command) {
var setpoint = allowlist.get(command.setpoint());
if (setpoint == null) {
LOGGER.warn("rejected unknown setpoint {} from {}", command.setpoint(), command.requestedBy());
return new CommandResult(Status.REJECTED, "unknown setpoint " + command.setpoint(), null);
}
if (!setpoint.allows(command.value())) {
return rejected(command, setpoint, "value " + command.value() + " is not allowed for "
+ setpoint.name());
}
var now = clock.instant();
var previous = lastWrite.get(setpoint.register());
if (previous != null && Duration.between(previous, now).compareTo(minInterval) < 0) {
return rejected(command, setpoint, "too soon after the last write to " + setpoint.name());
}
return write(command, setpoint, now);
}
private CommandResult write(Command command, Setpoint setpoint, Instant now) {
int register = setpoint.register();
Integer old = null;
try {
old = registers.read(register);
var restoreAt = command.duration() == null ? null : now.plus(command.duration());
if (restoreAt != null) {
// Saved before the write, so a write never happens without its restore on disk.
var pending = restores.get(register);
var original = pending == null ? old : pending.originalValue();
remember(new Restore(setpoint.name(), register, original, command.value(), restoreAt));
}
lastWrite.put(register, now);
registers.write(register, command.value());
pause.await();
var actual = registers.read(register);
if (actual != command.value()) {
return failed(command, setpoint, old, "read back " + actual + ", expected " + command.value());
}
if (restoreAt == null && restores.containsKey(register)) {
forget(register);
}
audit(setpoint, command.requestedBy(), "APPLIED", old, command.value());
return new CommandResult(Status.APPLIED, "applied", restoreAt);
} catch (IOException e) {
return failed(command, setpoint, old, e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return failed(command, setpoint, old, "interrupted");
}
}
/** Runs the restores whose time has come, oldest first; one that fails stays pending. */
public void runDueRestores() {
lock.lock();
try {
var now = clock.instant();
restores.values().stream()
.filter(restore -> !restore.due().isAfter(now))
.sorted(Comparator.comparing(Restore::due))
.toList()
.forEach(this::restore);
} finally {
lock.unlock();
}
}
private void restore(Restore restore) {
var setpoint = allowlist.get(restore.setpoint());
Integer current = null;
try {
current = registers.read(restore.register());
if (current != restore.appliedValue()) {
forget(restore.register());
audit(setpoint, RESTORE_USER, "CONFLICT", current, restore.originalValue());
LOGGER.warn("{} holds {}, not our {}: left alone, restore dropped", restore.setpoint(),
current, restore.appliedValue());
return;
}
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;
}
forget(restore.register());
audit(setpoint, RESTORE_USER, "APPLIED", current, restore.originalValue());
restoreProblems.remove(restore.register());
LOGGER.info("{} restored to {}", restore.setpoint(), restore.originalValue());
} catch (IOException e) {
restoreFailed(restore, current, e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
restoreFailed(restore, current, "interrupted");
}
}
private void restoreFailed(Restore restore, Integer old, String problem) {
audit(allowlist.get(restore.setpoint()), RESTORE_USER, "FAILED", old, restore.originalValue());
if (!problem.equals(restoreProblems.put(restore.register(), problem))) {
LOGGER.error("restore of {} failed, will retry: {}", restore.setpoint(), problem);
}
}
@Override
public List<Setpoint> setpoints() {
return List.copyOf(allowlist.values());
}
@Override
public List<Restore> pendingRestores() {
lock.lock();
try {
return List.copyOf(restores.values());
} finally {
lock.unlock();
}
}
private void remember(Restore restore) throws IOException {
restores.put(restore.register(), restore);
store.save(List.copyOf(restores.values()));
}
private void forget(int register) {
restores.remove(register);
try {
store.save(List.copyOf(restores.values()));
} catch (IOException e) {
LOGGER.error("could not save the restore list: {}", e.getMessage());
}
}
private CommandResult rejected(Command command, Setpoint setpoint, String message) {
audit(setpoint, command.requestedBy(), "REJECTED", null, command.value());
return new CommandResult(Status.REJECTED, message, null);
}
private CommandResult failed(Command command, Setpoint setpoint, Integer old, String message) {
audit(setpoint, command.requestedBy(), "FAILED", old, command.value());
LOGGER.error("{} = {} failed: {}", setpoint.name(), command.value(), message);
return new CommandResult(Status.FAILED, message, null);
}
private void audit(Setpoint setpoint, String requestedBy, String status, Integer old, int value) {
var fields = new HashMap<String, Double>();
fields.put("new_value", (double) value);
fields.put("register", (double) setpoint.register());
if (old != null) {
fields.put("old_value", (double) old);
}
audit.write(List.of(new Reading(AUDIT_DATABASE, AUDIT_MEASUREMENT,
Map.of("setpoint", setpoint.name(), "requested_by", requestedBy, "status", status),
fields, clock.instant())));
}
}
@@ -1,8 +0,0 @@
package home.gateway.application;
/** Waits between a write and its read-back, so the controller has time to take the value. */
@FunctionalInterface
public interface Pause {
void await() throws InterruptedException;
}
@@ -1,43 +0,0 @@
package home.gateway.application;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/** Runs the due restores on a schedule, with no network needed (ADR-006). */
public class RestoreScheduler implements AutoCloseable {
private static final Logger LOGGER = LoggerFactory.getLogger(RestoreScheduler.class);
private final CommandService commands;
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("restores").factory());
public RestoreScheduler(CommandService commands) {
this.commands = commands;
}
/** Runs the restores that fell due while we were down before returning, then keeps checking. */
public void start(Duration period) {
tick();
scheduler.scheduleWithFixedDelay(this::tick, period.toMillis(), period.toMillis(),
TimeUnit.MILLISECONDS);
}
void tick() {
try {
commands.runDueRestores();
} catch (RuntimeException e) {
LOGGER.error("restore run failed", e);
}
}
@Override
public void close() {
scheduler.shutdownNow();
}
}
@@ -1,68 +0,0 @@
package home.gateway.adapters.store;
import home.gateway.domain.Restore;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class JsonRestoreStoreTest {
@TempDir
Path dir;
private final Restore restore = new Restore("cwu_comfort", 4144, 450, 500,
Instant.parse("2026-10-24T11:00:00Z"));
@Test
void missingFileIsEmpty() throws IOException {
assertEquals(List.of(), new JsonRestoreStore(dir.resolve("restores.json")).load());
}
@Test
void savedRestoresComeBack() throws IOException {
var file = dir.resolve("restores.json");
new JsonRestoreStore(file).save(List.of(restore));
assertEquals(List.of(restore), new JsonRestoreStore(file).load());
}
@Test
void saveReplacesTheFileAndLeavesNoTempFile() throws IOException {
var file = dir.resolve("restores.json");
var store = new JsonRestoreStore(file);
store.save(List.of(restore));
store.save(List.of());
assertEquals(List.of(), store.load());
try (var files = Files.list(dir)) {
assertEquals(List.of(file), files.toList());
}
}
@Test
void corruptFileFailsWithAClearMessageAndIsKept() throws IOException {
var file = dir.resolve("restores.json");
Files.writeString(file, "{not json");
var error = assertThrows(IOException.class, () -> new JsonRestoreStore(file).load());
assertTrue(error.getMessage().contains(file.toString()));
assertEquals("{not json", Files.readString(file));
}
@Test
void saveFailsWhenTheDirectoryIsMissing() {
var store = new JsonRestoreStore(dir.resolve("missing/restores.json"));
assertThrows(IOException.class, () -> store.save(List.of(restore)));
}
}
@@ -1,454 +0,0 @@
package home.gateway.application;
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.Setpoint;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CommandServiceTest {
private static final Instant T0 = Instant.parse("2026-10-24T10:00:00Z");
private static final Setpoint CWU = new Setpoint("cwu_comfort", 4144, 300, 550, 10);
private static final Setpoint CO = new Setpoint("co_comfort", 4150, 150, 300, 5);
private static final Duration HOUR = Duration.ofHours(1);
private final FakeRegisters registers = new FakeRegisters();
private final List<Restore> stored = new ArrayList<>();
private final List<Reading> audit = new ArrayList<>();
private final MutableClock clock = new MutableClock();
private boolean storeFails;
private boolean pauseInterrupted;
private int pauses;
private CountDownLatch inPause = new CountDownLatch(1);
private CountDownLatch pauseGate = new CountDownLatch(0);
private final RestoreStore store = new RestoreStore() {
@Override
public List<Restore> load() {
return List.copyOf(stored);
}
@Override
public void save(List<Restore> restores) throws IOException {
if (storeFails) {
throw new IOException("disk full");
}
stored.clear();
stored.addAll(restores);
}
};
private CommandService service() throws IOException {
registers.values.putIfAbsent(4144, 450);
registers.values.putIfAbsent(4150, 200);
return new CommandService(List.of(CWU, CO), registers, store, audit::addAll, clock,
Duration.ofSeconds(60), () -> {
pauses++;
inPause.countDown();
pauseGate.await();
if (pauseInterrupted) {
throw new InterruptedException();
}
});
}
private static Command command(String setpoint, int value, Duration duration) {
return new Command(setpoint, value, duration, "app");
}
private String lastStatus() {
return audit.getLast().tags().get("status");
}
@Test
void appliesAndAuditsAnAllowedCommand() throws IOException {
var result = service().apply(command("cwu_comfort", 500, null));
assertEquals(Status.APPLIED, result.status());
assertNull(result.restoreAt());
assertEquals(500, registers.values.get(4144));
assertEquals(1, pauses);
var reading = audit.getFirst();
assertEquals("Dom_Pompa", reading.database());
assertEquals("setpoint_audit", reading.measurement());
assertEquals("cwu_comfort", reading.tags().get("setpoint"));
assertEquals("app", reading.tags().get("requested_by"));
assertEquals("APPLIED", reading.tags().get("status"));
assertEquals(450.0, reading.fields().get("old_value"));
assertEquals(500.0, reading.fields().get("new_value"));
assertEquals(4144.0, reading.fields().get("register"));
assertEquals(T0, reading.time());
}
@Test
void rejectsUnknownSetpointWithoutIo() throws IOException {
var result = service().apply(command("nope", 500, null));
assertEquals(Status.REJECTED, result.status());
assertEquals(0, registers.reads);
assertEquals(0, registers.writes);
}
@Test
void rejectsValueOutsideRangeOrStepWithoutIo() throws IOException {
var service = service();
assertEquals(Status.REJECTED, service.apply(command("cwu_comfort", 560, null)).status());
assertEquals(Status.REJECTED, service.apply(command("cwu_comfort", 295, null)).status());
assertEquals(Status.REJECTED, service.apply(command("cwu_comfort", 455, null)).status());
assertEquals(0, registers.reads);
assertEquals(0, registers.writes);
assertEquals("REJECTED", lastStatus());
}
@Test
void rateLimitsPerRegister() throws IOException {
var service = service();
service.apply(command("cwu_comfort", 500, null));
clock.advance(Duration.ofSeconds(59));
assertEquals(Status.REJECTED, service.apply(command("cwu_comfort", 510, null)).status());
assertEquals(Status.APPLIED, service.apply(command("co_comfort", 210, null)).status());
assertEquals(500, registers.values.get(4144));
clock.advance(Duration.ofSeconds(1));
assertEquals(Status.APPLIED, service.apply(command("cwu_comfort", 510, null)).status());
}
@Test
void failsWhenReadBackDiffers() throws IOException {
registers.ignoreWrites = true;
var result = service().apply(command("cwu_comfort", 500, null));
assertEquals(Status.FAILED, result.status());
assertEquals("FAILED", lastStatus());
}
@Test
void failsOnReadBeforeWriteWithoutWriting() throws IOException {
var service = service();
registers.failReadNumber = 1;
assertEquals(Status.FAILED, service.apply(command("cwu_comfort", 500, HOUR)).status());
assertEquals(0, registers.writes);
assertTrue(service.pendingRestores().isEmpty());
assertEquals("FAILED", lastStatus());
}
@Test
void failsOnWriteError() throws IOException {
registers.failWrites = true;
assertEquals(Status.FAILED, service().apply(command("cwu_comfort", 500, null)).status());
assertEquals("FAILED", lastStatus());
}
@Test
void failsOnReadBackError() throws IOException {
var service = service();
registers.failReadNumber = 2;
assertEquals(Status.FAILED, service.apply(command("cwu_comfort", 500, null)).status());
assertEquals("FAILED", lastStatus());
}
@Test
void failsWhenInterruptedWhileWaiting() throws IOException {
pauseInterrupted = true;
assertEquals(Status.FAILED, service().apply(command("cwu_comfort", 500, null)).status());
assertTrue(Thread.interrupted());
}
@Test
void failedWriteStillCountsForTheRateLimit() throws IOException {
var service = service();
registers.failWrites = true;
service.apply(command("cwu_comfort", 500, null));
registers.failWrites = false;
assertEquals(Status.REJECTED, service.apply(command("cwu_comfort", 500, null)).status());
}
@Test
void boostRecordsTheOriginalReadFromTheControllerAndPersistsIt() throws IOException {
var result = service().apply(command("cwu_comfort", 500, HOUR));
assertEquals(T0.plus(HOUR), result.restoreAt());
var expected = new Restore("cwu_comfort", 4144, 450, 500, T0.plus(HOUR));
assertEquals(List.of(expected), stored);
}
@Test
void secondBoostKeepsTheOriginalAndReplacesDueAndApplied() throws IOException {
var service = service();
service.apply(command("cwu_comfort", 500, HOUR));
clock.advance(Duration.ofMinutes(10));
service.apply(command("cwu_comfort", 520, Duration.ofHours(2)));
var expected = new Restore("cwu_comfort", 4144, 450, 520, clock.instant().plus(Duration.ofHours(2)));
assertEquals(List.of(expected), service.pendingRestores());
assertEquals(List.of(expected), stored);
}
@Test
void lastingCommandCancelsAPendingRestore() throws IOException {
var service = service();
service.apply(command("cwu_comfort", 500, HOUR));
clock.advance(Duration.ofMinutes(5));
service.apply(command("cwu_comfort", 400, null));
assertTrue(service.pendingRestores().isEmpty());
assertTrue(stored.isEmpty());
}
@Test
void failedLastingCommandKeepsThePendingRestore() throws IOException {
var service = service();
service.apply(command("cwu_comfort", 500, HOUR));
clock.advance(Duration.ofMinutes(5));
registers.failWrites = true;
service.apply(command("cwu_comfort", 400, null));
assertEquals(1, service.pendingRestores().size());
}
@Test
void doesNotWriteWhenTheRestoreCannotBeSaved() throws IOException {
var service = service();
storeFails = true;
var result = service.apply(command("cwu_comfort", 500, HOUR));
assertEquals(Status.FAILED, result.status());
assertEquals(0, registers.writes);
assertEquals(450, registers.values.get(4144));
}
@Test
void refusesToStartWhenTheStoreCannotBeRead() {
RestoreStore broken = new RestoreStore() {
@Override
public List<Restore> load() throws IOException {
throw new IOException("corrupt");
}
@Override
public void save(List<Restore> restores) {
}
};
assertThrows(IOException.class, () -> new CommandService(List.of(CWU), registers, broken,
audit::addAll, clock, Duration.ZERO, () -> { }));
}
@Test
void exposesTheAllowlist() throws IOException {
assertEquals(List.of(CWU, CO), service().setpoints());
}
private CommandService boosted() throws IOException {
var service = service();
service.apply(command("cwu_comfort", 500, HOUR));
audit.clear();
return service;
}
@Test
void doesNothingBeforeDue() throws IOException {
var service = boosted();
clock.advance(Duration.ofMinutes(59));
service.runDueRestores();
assertEquals(500, registers.values.get(4144));
assertEquals(1, service.pendingRestores().size());
}
@Test
void restoresTheOriginalWhenOurValueIsStillThere() throws IOException {
var service = boosted();
clock.advance(HOUR);
service.runDueRestores();
assertEquals(450, registers.values.get(4144));
assertTrue(service.pendingRestores().isEmpty());
assertTrue(stored.isEmpty());
assertEquals("APPLIED", lastStatus());
assertEquals("restore", audit.getLast().tags().get("requested_by"));
assertEquals(500.0, audit.getLast().fields().get("old_value"));
assertEquals(450.0, audit.getLast().fields().get("new_value"));
}
@Test
void leavesAChangedValueAndAuditsAConflict() throws IOException {
var service = boosted();
registers.values.put(4144, 380);
clock.advance(HOUR);
var writes = registers.writes;
service.runDueRestores();
assertEquals(380, registers.values.get(4144));
assertEquals(writes, registers.writes);
assertTrue(service.pendingRestores().isEmpty());
assertTrue(stored.isEmpty());
assertEquals("CONFLICT", lastStatus());
}
@Test
void failedRestoreStaysPendingAndIsRetried() throws IOException {
var service = boosted();
clock.advance(HOUR);
registers.failWrites = true;
service.runDueRestores();
service.runDueRestores();
assertEquals(1, service.pendingRestores().size());
assertEquals(1, stored.size());
assertEquals("FAILED", lastStatus());
assertEquals(500, registers.values.get(4144));
registers.failWrites = false;
service.runDueRestores();
assertEquals(450, registers.values.get(4144));
assertTrue(service.pendingRestores().isEmpty());
}
@Test
void restoreWithReadErrorStaysPending() throws IOException {
var service = boosted();
clock.advance(HOUR);
registers.failReadNumber = registers.reads + 1;
service.runDueRestores();
assertEquals(1, service.pendingRestores().size());
assertEquals(500, registers.values.get(4144));
}
@Test
void restoreWithReadBackMismatchStaysPending() throws IOException {
var service = boosted();
clock.advance(HOUR);
registers.ignoreWrites = true;
service.runDueRestores();
assertEquals(1, service.pendingRestores().size());
assertEquals("FAILED", lastStatus());
}
@Test
void restoreInterruptedStaysPending() throws IOException {
var service = boosted();
clock.advance(HOUR);
pauseInterrupted = true;
service.runDueRestores();
assertEquals(1, service.pendingRestores().size());
assertTrue(Thread.interrupted());
}
@Test
void restartRunsOverdueRestoresFirstThenKeepsTheRest() throws IOException {
var first = service();
first.apply(command("cwu_comfort", 500, HOUR));
first.apply(command("co_comfort", 250, Duration.ofHours(3)));
clock.advance(Duration.ofHours(2));
var restarted = service();
try (var scheduler = new RestoreScheduler(restarted)) {
scheduler.start(Duration.ofHours(1));
assertEquals(450, registers.values.get(4144));
assertEquals(250, registers.values.get(4150));
assertEquals(1, restarted.pendingRestores().size());
assertEquals("co_comfort", restarted.pendingRestores().getFirst().setpoint());
}
}
@Test
void aRestoreWaitsWhileACommandIsBetweenWriteAndReadBack() throws Exception {
var service = boosted();
clock.advance(HOUR);
pauseGate = new CountDownLatch(1);
inPause = new CountDownLatch(1);
var command = Thread.ofVirtual().start(() -> service.apply(command("co_comfort", 250, null)));
assertTrue(inPause.await(5, TimeUnit.SECONDS));
var writesDuringCommand = registers.writes;
var restore = Thread.ofVirtual().start(service::runDueRestores);
restore.join(Duration.ofMillis(200));
assertEquals(writesDuringCommand, registers.writes);
assertEquals(500, registers.values.get(4144));
pauseGate.countDown();
command.join();
restore.join();
assertEquals(450, registers.values.get(4144));
assertEquals(250, registers.values.get(4150));
}
@Test
void schedulerSurvivesAnUnexpectedError() throws IOException {
var service = boosted();
clock.advance(HOUR);
registers.values.remove(4144);
try (var scheduler = new RestoreScheduler(service)) {
scheduler.tick();
}
assertEquals(1, service.pendingRestores().size());
}
private static final class MutableClock extends Clock {
private Instant now = T0;
void advance(Duration by) {
now = now.plus(by);
}
@Override
public Instant instant() {
return now;
}
@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(ZoneId zone) {
return this;
}
}
}
@@ -1,35 +0,0 @@
package home.gateway.application;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
/** Registers in memory; a test breaks exactly the read or write it wants to. */
class FakeRegisters implements SetpointRegisters {
final Map<Integer, Integer> values = new HashMap<>();
int writes;
int reads;
int failReadNumber;
boolean failWrites;
boolean ignoreWrites;
@Override
public int read(int register) throws IOException {
if (++reads == failReadNumber) {
throw new IOException("read timeout");
}
return values.get(register);
}
@Override
public void write(int register, int value) throws IOException {
writes++;
if (failWrites) {
throw new IOException("write timeout");
}
if (!ignoreWrites) {
values.put(register, value);
}
}
}