Merge PR #79: feat: red accent for running parts and a Salon sensor card
This commit is contained in:
commit
a8ef638fcd
17 files changed
+211
-22
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)}</>} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 }) {
|
||||
|
||||
Reference in new issue
Block a user