Merge PR #79: feat: red accent for running parts and a Salon sensor card
CI / Check (gateway, backend, frontend) (push) Successful in 1m22s
Release / Images (push) Successful in 2m53s

This commit is contained in:
root committed 2026-10-24 14:10:27 +02:00
commit a8ef638fcd
17 files changed
+211 -22

No files matched your search

+3
View File
@@ -4,6 +4,9 @@ The phone API in Kraków (ADR-008): live state from the gateway, history from In
changes passed to the gateway. JSON under `/api/` only; the web UI is the frontend's (ADR-010).
The records in `home.backend.domain` and the examples in `src/test/resources/api/` are the contract.
`GET /api/energy/details` lists the Deye, Fronius and house meter values in labelled groups (a stale device has none); the label, unit and place of every field are the catalog in `EnergyDetailsService`: a new field is one line there.
`GET /api/pi-sensor` is the BME280 sensor on the Raspberry Pi (the living room), example `src/test/resources/api/pi-sensor.json`:
`time`, `stale`, `temperature` (°C), `humidity` (%), `pressure` (hPa). When it is stale (device down, no reading or older than 10 min)
the three values are null (`pi-sensor-stale.json`).
Settings are environment variables (`.env` next to `compose.yaml`); the backend refuses to start
without the two URLs.
@@ -12,6 +12,7 @@ import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService;
import home.backend.application.LeaseRenewer;
import home.backend.application.PiSensorService;
import home.backend.application.SetpointService;
import java.io.IOException;
@@ -38,7 +39,7 @@ final class Backend {
new EnergyService(gateway, clock),
new EnergyDetailsService(gateway, clock),
new HealthService(gateway, clock, Objects.requireNonNullElse(System.getenv("VERSION"), "dev")),
new HistoryService(influx, clock), setpoints);
new HistoryService(influx, clock), setpoints, new PiSensorService(gateway, clock));
}
/** Keeps the gateway from restoring our setpoint changes while we are up (ADR-011). */
@@ -14,6 +14,7 @@ import home.backend.application.EnergyService;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService;
import home.backend.application.PiSensorService;
import home.backend.application.SetpointService;
import home.backend.domain.CommandResult.Status;
import home.backend.domain.PresetResult;
@@ -78,7 +79,7 @@ public class ApiServer {
/** Binds the port now; 0 picks a free one. */
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
EnergyDetailsService energyDetails, HealthService health,
HistoryService history, SetpointService setpoints) throws IOException {
HistoryService history, SetpointService setpoints, PiSensorService piSensor) throws IOException {
server = HttpServer.create(new InetSocketAddress(port), 0);
server.setExecutor(executor);
// Everything outside the API, with the security headers too: the web UI is the frontend's (ADR-010).
@@ -90,6 +91,7 @@ public class ApiServer {
api("/api/heat-pump", query -> heatPump.current());
api("/api/energy", query -> energy.current());
api("/api/energy/details", query -> energyDetails.current());
api("/api/pi-sensor", query -> piSensor.current());
api("/api/health", query -> health.current());
route("/api/history", exchange -> handleHistory(exchange, history));
route("/api/setpoints", exchange -> handle(exchange, Map.of(
@@ -0,0 +1,27 @@
package home.backend.application;
import home.backend.application.SnapshotFields.Live;
import home.backend.domain.PiSensorView;
import java.io.IOException;
import java.time.Clock;
import java.util.Map;
/** The BME280 on the Pi, read by the gateway every 5 minutes. */
public class PiSensorService {
private final GatewayClient gateway;
private final Clock clock;
public PiSensorService(GatewayClient gateway, Clock clock) {
this.gateway = gateway;
this.clock = clock;
}
public PiSensorView current() throws IOException {
Live live = SnapshotFields.live(gateway.snapshot(), "bme280", "MEASUREMENT", clock.instant());
Map<String, Double> f = live.fields();
return new PiSensorView(live.source().time(), live.source().stale(),
f.get("temperature"), f.get("humidity"), f.get("pressure"));
}
}
@@ -0,0 +1,11 @@
package home.backend.domain;
import java.time.Instant;
/**
* The BME280 sensor on the Raspberry Pi: °C, % and hPa. When it is stale (device down, no reading
* or older than 2 × 300 s) the values are null and {@code stale} is true; {@code time} is null
* without any reading.
*/
public record PiSensorView(Instant time, boolean stale, Double temperature, Double humidity, Double pressure) {
}
@@ -151,6 +151,11 @@ class BackendTest {
assertServesExactly("/api/heat-pump", "heat-pump");
}
@Test
void servesPiSensorFromTheGatewayReadings() throws Exception {
assertServesExactly("/api/pi-sensor", "pi-sensor");
}
@Test
void servesEnergyFromTheGatewayReadings() throws Exception {
assertServesExactly("/api/energy", "energy");
@@ -20,6 +20,7 @@ import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryRepository;
import home.backend.application.HistoryService;
import home.backend.application.PiSensorService;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
@@ -134,7 +135,7 @@ class ApiServerTest {
server = new ApiServer(0, new HeatPumpService(gateway, clock), new EnergyService(gateway, clock),
new EnergyDetailsService(gateway, clock),
new HealthService(gateway, clock, "v1.3.0"), new HistoryService(history, clock),
new SetpointService(commands, PresetFile.load(null)));
new SetpointService(commands, PresetFile.load(null)), new PiSensorService(gateway, clock));
server.start();
}
@@ -169,7 +170,10 @@ class ApiServerTest {
new DeviceSnapshot("house-meter", true, energyTime, null,
List.of(new Measurement("electricity", "PRAD2", Map.of("p1_voltage", 235.69,
"p1_current", 0.32, "p1_power_active", 55.94, "p1_power_factor", 0.75,
"frequency", 50.01, "import_energy_active", 67514.27), energyTime)))));
"frequency", 50.01, "import_energy_active", 67514.27), energyTime))),
new DeviceSnapshot("bme280", true, energyTime, null,
List.of(new Measurement("measurement", "MEASUREMENT",
Map.of("temperature", 20.17, "humidity", 45.82, "pressure", 985.8), energyTime)))));
}
private HttpResponse<String> get(String path) throws Exception {
@@ -236,6 +240,22 @@ class ApiServerTest {
assertMatchesExample("/api/energy", "energy-deye-stale");
}
@Test
void piSensorMatchesExample() throws Exception {
assertMatchesExample("/api/pi-sensor", "pi-sensor");
}
@Test
void stalePiSensorMatchesExample() throws Exception {
Instant old = Instant.parse("2026-10-23T21:00:00Z");
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
Measurement bme = devices.getLast().measurements().get(0);
devices.set(devices.size() - 1, new DeviceSnapshot("bme280", true, old, null,
List.of(new Measurement(bme.database(), bme.measurement(), bme.fields(), old))));
snapshot = new GatewaySnapshot("v1.2.1", devices);
assertMatchesExample("/api/pi-sensor", "pi-sensor-stale");
}
@Test
void historyMatchesExample() throws Exception {
assertMatchesExample("/api/history?series=cwu_temp&range=24h", "history");
@@ -13,6 +13,7 @@ import home.backend.domain.HealthView;
import home.backend.domain.HeatPumpView;
import home.backend.domain.HeatPumpView.State;
import home.backend.domain.HistoryView;
import home.backend.domain.PiSensorView;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
import org.junit.jupiter.api.Test;
@@ -170,6 +171,33 @@ class ServicesTest {
assertThrows(IOException.class, () -> new HeatPumpService(unreachable(), CLOCK).current());
}
private static PiSensorView piSensor(DeviceSnapshot... devices) throws IOException {
return new PiSensorService(gateway(devices), CLOCK).current();
}
@Test
void piSensorReadsTemperatureHumidityAndPressure() throws IOException {
PiSensorView view = piSensor(device("bme280", "measurement", "MEASUREMENT",
Map.of("temperature", 20.17, "humidity", 45.82, "pressure", 985.8)));
assertEquals(READ, view.time());
assertFalse(view.stale());
assertEquals(20.17, view.temperature());
assertEquals(45.82, view.humidity());
assertEquals(985.8, view.pressure());
}
@Test
void stalePiSensorShowsNoValues() throws IOException {
var old = new Measurement("measurement", "MEASUREMENT", Map.of("temperature", 20.0), NOW.minusSeconds(601));
PiSensorView view = piSensor(new DeviceSnapshot("bme280", true, old.time(), null, List.of(old)));
assertTrue(view.stale());
assertNull(view.temperature());
assertTrue(piSensor().stale());
assertNull(piSensor().time());
}
private static EnergyView energy(DeviceSnapshot... devices) throws IOException {
return new EnergyService(gateway(devices), CLOCK).current();
}
@@ -0,0 +1,7 @@
{
"time": "2026-10-23T21:00:00Z",
"stale": true,
"temperature": null,
"humidity": null,
"pressure": null
}
@@ -0,0 +1,7 @@
{
"time": "2026-10-23T22:26:00Z",
"stale": false,
"temperature": 20.17,
"humidity": 45.82,
"pressure": 985.8
}
+2 -1
View File
@@ -74,7 +74,8 @@ Layers in both apps, dependencies point inwards only:
Functional:
- F1 Live state of the heat pump: idle / heating CWU / heating CO, compressor stage, pumps,
heater, alarm, current targets and temperatures.
heater, alarm, current targets and temperatures; the BME280 reading (temperature, humidity,
pressure) of the living room.
- F2 History for the last 7 days.
- F3 Change four setpoints: economy and comfort temperature of CWU (4143, 4144) and CO
(4131, 4132). Any value within limits, or one tap on a named preset. Limits and presets live in
+45 -1
View File
@@ -3,12 +3,14 @@ import { describe, expect, it } from "vitest";
import { example, serve } from "./fixtures";
import type { HeatPump } from "./types";
import { PI_SENSOR_TITLE } from "./Overview";
const overview = async () => (await import("./Overview")).Overview;
const fresh = (hp: HeatPump) => ({ ...hp, time: new Date().toISOString() });
const history = { series: "x", unit: "°C", points: [] };
function serveHeatPump(hp: HeatPump) {
return serve({ "api/heat-pump": hp, "api/energy": example("energy"), "api/history": history });
return serve({ "api/heat-pump": hp, "api/energy": example("energy"), "api/history": history,
"api/pi-sensor": example("pi-sensor") });
}
const flowing = (container: HTMLElement) =>
@@ -17,6 +19,48 @@ const glowing = (container: HTMLElement) =>
[...container.querySelectorAll("[data-glow][data-on=true]")].map((p) => p.getAttribute("data-glow"));
describe("Overview", () => {
it("shows the sensor of the Raspberry Pi in the living room", async () => {
serveHeatPump(fresh(example("heat-pump")));
const Overview = await overview();
render(<Overview />);
const card = (await screen.findByRole("heading", { name: PI_SENSOR_TITLE })).closest("section")!;
expect(card).toHaveTextContent("Temperatura20,2 °C");
expect(card).toHaveTextContent("Wilgotność45,8 %");
expect(card).toHaveTextContent("Ciśnienie986 hPa");
});
it("says there is no reading when the sensor is stale", async () => {
serve({ "api/heat-pump": fresh(example("heat-pump")), "api/energy": example("energy"), "api/history": history,
"api/pi-sensor": example("pi-sensor-stale") });
const Overview = await overview();
render(<Overview />);
const card = (await screen.findByRole("heading", { name: PI_SENSOR_TITLE })).closest("section")!;
expect(card).toHaveTextContent("Temperaturabrak odczytu");
expect(card).toHaveTextContent("Wilgotnośćbrak odczytu");
expect(card).toHaveTextContent("Ciśnieniebrak odczytu");
});
it("shows a running part in the red accent and a stopped one in grey", async () => {
const hp = fresh(example<HeatPump>("heat-pump"));
serveHeatPump({ ...hp, state: "HEATING_CWU", compressorPercent: 60, pumps: { ...hp.pumps, cwu: true } });
const Overview = await overview();
render(<Overview />);
expect(await screen.findByTitle("Pompa CWU: pracuje")).toHaveClass("bg-run-soft");
expect(screen.getByTitle("Pompa CO: stoi")).not.toHaveClass("bg-run-soft");
expect(screen.getAllByText("Grzeje wodę (CWU)")[0].closest("span")).toHaveClass("text-run");
});
it("keeps the alarm solid, unlike the red of a running part", async () => {
serveHeatPump({ ...fresh(example("heat-pump")), alarm: true });
const Overview = await overview();
render(<Overview />);
expect((await screen.findAllByText("Alarm"))[0].closest("span")).toHaveClass("bg-alarm", "text-white");
});
it("draws a resting pump with nothing flowing", async () => {
serveHeatPump(fresh(example("heat-pump")));
const Overview = await overview();
+28 -9
View File
@@ -1,10 +1,10 @@
import type { ReactNode } from "react";
import { celsius, degrees, hour, longDate, percent, power } from "./format";
import { celsius, degrees, hour, longDate, number, percent, power } from "./format";
import { Icon, type IconName } from "./icons";
import { usePoll } from "./poll";
import { Schematic } from "./Schematic";
import { Legend, TimeChart, toRows, type Line } from "./TimeChart";
import type { Energy, HeatPump, History, PumpState } from "./types";
import type { Energy, HeatPump, History, PiSensor, PumpState } from "./types";
import { Card, Dot, Freshness, Loading, PageTitle, Pill, Row } from "./ui";
import { EnergyToday, gridText } from "./Energy";
@@ -32,9 +32,9 @@ const TEMPERATURE_LINES: Line[] = [
const onOff = (on: boolean | null, yes: string, no: string) => (on == null ? "—" : on ? yes : no);
function StatusPill({ hp }: { hp: HeatPump }) {
if (hp.alarm) return <Pill tone="bad" live>Alarm</Pill>;
if (hp.alarm) return <Pill tone="bad" live><Icon name="alert" className="size-3.5" />Alarm</Pill>;
if (hp.state == null) return <Pill tone="off">Stan nieznany</Pill>;
return hp.state === "IDLE" ? <Pill tone="ok">Praca prawidłowa</Pill> : <Pill tone="hot" live>{HEADLINES[hp.state]}</Pill>;
return hp.state === "IDLE" ? <Pill tone="off">Praca prawidłowa</Pill> : <Pill tone="run" live>{HEADLINES[hp.state]}</Pill>;
}
function Tile({ icon, title, value, note, status, children }: {
@@ -59,7 +59,7 @@ function Tile({ icon, title, value, note, status, children }: {
}
const running = (on: boolean | null, yes = "Pracuje") =>
on == null ? <Pill tone="off">Brak odczytu</Pill> : on ? <Pill tone="ok" live>{yes}</Pill> : <Pill tone="off">Stoi</Pill>;
on == null ? <Pill tone="off">Brak odczytu</Pill> : on ? <Pill tone="run" live>{yes}</Pill> : <Pill tone="off">Stoi</Pill>;
function Temperatures({ hp }: { hp: HeatPump }) {
const t = hp.temperatures;
@@ -80,7 +80,7 @@ function Temperatures({ hp }: { hp: HeatPump }) {
);
}
// Every moving part as a chip under the picture: what runs now is green and pulses.
// Every moving part as a chip under the picture: what runs now is red and pulses.
function Devices({ hp }: { hp: HeatPump }) {
const compressor = hp.compressorPercent == null ? null : hp.compressorPercent > 0;
const items: [string, boolean | null, string][] = [
@@ -95,11 +95,11 @@ function Devices({ hp }: { hp: HeatPump }) {
<ul aria-label="Urządzenia" className="mt-3 grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
{items.map(([name, on, text]) => (
<li key={name} title={`${name}: ${text}`}
className={`flex items-center gap-2 whitespace-nowrap rounded-full border px-3 py-1.5 text-xs sm:text-sm ${on ? "border-brand/30 bg-brand-soft" : "border-edge text-soft"}`}>
<Dot tone={on ? "ok" : "off"} live={!!on} />
className={`flex items-center gap-2 whitespace-nowrap rounded-full border px-3 py-1.5 text-xs sm:text-sm ${on ? "border-run/30 bg-run-soft" : "border-edge text-soft"}`}>
<Dot tone={on ? "run" : "off"} live={!!on} />
<span>{name}</span>
{/* On a phone the colour says it; the word needs the room of a larger screen. */}
<b className={`hidden sm:inline ${on ? "text-brand" : "text-soft"}`}>{text}</b>
<b className={`hidden sm:inline ${on ? "text-run" : "text-soft"}`}>{text}</b>
</li>
))}
</ul>
@@ -119,6 +119,24 @@ function Targets({ hp }: { hp: HeatPump }) {
);
}
// The Raspberry Pi with the BME280 stands in the living room.
export const PI_SENSOR_TITLE = "Salon";
const NO_READING = "brak odczytu";
const reading = (value: number | null | undefined, digits: number, unit: string) =>
value == null ? NO_READING : `${number(value, digits)} ${unit}`;
function PiSensorCard() {
const { data } = usePoll<PiSensor>("api/pi-sensor");
return (
<Card title={PI_SENSOR_TITLE}>
<Row label="Temperatura" value={reading(data?.temperature, 1, "°C")} />
<Row label="Wilgotność" value={reading(data?.humidity, 1, "%")} />
<Row label="Ciśnienie" value={reading(data?.pressure, 0, "hPa")} />
</Card>
);
}
function useTemperatureHistory() {
const cwu = usePoll<History>("api/history?series=cwu_temp&range=24h", HISTORY_MS);
const co = usePoll<History>("api/history?series=co_buffer_temp&range=24h", HISTORY_MS);
@@ -211,6 +229,7 @@ export function Overview() {
</Tile>
</div>
<TemperatureChart />
<PiSensorCard />
<EnergyToday e={e} />
</div>
)}
+3 -3
View File
@@ -111,10 +111,10 @@ export function Schematic({ hp, pvPowerW }: { hp: HeatPump; pvPowerW: number | n
{/* Photovoltaics: a light sweeps over the panels while they produce */}
{sunny && <g clipPath="url(#pv)" data-pv="on"><rect className="sweep" x={985} y={180} width={400} height={160} fill="url(#sweep)" /></g>}
<Glow id="heat-pump" x={506} y={422} w={102} h={188} colour="#10b981" on={compressor} />
<Glow id="heat-pump" x={506} y={422} w={102} h={188} colour="var(--color-run)" on={compressor} />
<Glow id="cwu" x={639} y={402} w={90} h={216} colour="#f97316" on={cwu} />
<Glow id="buffer" x={746} y={489} w={70} h={134} colour="#ef4444" on={buffer} />
{compressor && <circle className="beat" cx={575} cy={462} r={6} fill="#22c55e" />}
{compressor && <circle className="beat" cx={575} cy={462} r={6} fill="var(--color-run)" />}
{hp.heaterOn && <rect className="glow" x={518} y={560} width={78} height={14} rx={7} fill="#ef4444" />}
{/* Warmth rising from the floor while the CO pump runs */}
@@ -124,7 +124,7 @@ export function Schematic({ hp, pvPowerW }: { hp: HeatPump; pvPowerW: number | n
))}
<Callout x={36} y={40} title="Na zewnątrz" value={celsius(t.outdoor)} />
<Callout x={210} y={400} title="Pompa ciepła" to={[512, 470]} tone={compressor ? "var(--color-brand)" : undefined}
<Callout x={210} y={400} title="Pompa ciepła" to={[512, 470]} tone={compressor ? "var(--color-run)" : undefined}
value={compressor ? `sprężarka ${percent(hp.compressorPercent)}` : "stoi"} />
<Callout x={540} y={215} title="Ciepła woda (CWU)" to={[684, 410]} tone={cwu ? "#ea580c" : undefined}
value={<>{celsius(t.cwu)}{target(targets.cwu)}</>} />
+5
View File
@@ -13,6 +13,9 @@
--color-edge: #e2e9e5;
--color-brand: #0f8a5f;
--color-brand-soft: #e3f4ec;
/* A part that runs; red but never the alarm, which is solid (ui.tsx). */
--color-run: #c8283f;
--color-run-soft: #fde9ec;
--color-side: #0e2a21;
--color-side-hi: #1b4d3b;
--color-hot: #f26b1d;
@@ -32,6 +35,8 @@
--color-edge: #24352c;
--color-brand: #3fd197;
--color-brand-soft: #143628;
--color-run: #ff7a8a;
--color-run-soft: #3d141b;
--color-side: #08170f;
--color-side-hi: #17402f;
--color-off: #4b5a52;
+8
View File
@@ -20,6 +20,14 @@ export interface HeatPump {
setpoints: Record<"coEconomy" | "coComfort" | "cwuEconomy" | "cwuComfort", number | null>;
}
export interface PiSensor {
time: string | null;
stale: boolean;
temperature: number | null;
humidity: number | null;
pressure: number | null;
}
export interface EnergySource {
name: string;
time: string | null;
+5 -4
View File
@@ -19,18 +19,19 @@ export function Card({ title, aside, className = "", children }: {
);
}
type Tone = "ok" | "warn" | "bad" | "off" | "hot";
type Tone = "ok" | "run" | "warn" | "bad" | "off";
const PILL: Record<Tone, string> = {
ok: "bg-brand-soft text-brand",
warn: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
bad: "bg-red-100 text-alarm dark:bg-red-950 dark:text-red-300",
run: "bg-run-soft text-run",
// Solid, unlike the soft "run": an alarm must never be mistaken for a part that works.
bad: "bg-alarm text-white",
off: "bg-edge text-soft",
hot: "bg-orange-100 text-orange-700 dark:bg-orange-950 dark:text-orange-300",
};
const DOT: Record<Tone, string> = {
ok: "bg-brand", warn: "bg-amber-500", bad: "bg-alarm", off: "bg-off", hot: "bg-hot",
ok: "bg-brand", run: "bg-run", warn: "bg-amber-500", bad: "bg-alarm", off: "bg-off",
};
export function Pill({ tone, live = false, children }: { tone: Tone; live?: boolean; children: ReactNode }) {