Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e28a25c22 |
No files matched your search
@@ -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,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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user