Compare commits

..
23 Commits
Author SHA1 Message Date
root 655e84be8d Merge PR #91: fix(frontend): stale states, consistent setpoint names, contrast, 44 px theme switch
Release / Images (push) Successful in 3m18s
CI / Check (gateway, backend, frontend) (push) Successful in 1m30s
2026-10-24 18:06:10 +02:00
root 4cb8bfd27b Merge master into fix/settings-status-polish
CI / Check (gateway, backend, frontend) (push) Successful in 1m30s
# Conflicts:
#	frontend/src/ui.tsx
2026-10-24 18:02:41 +02:00
root bdea2a177d Merge PR #92: fix(frontend): readable energy diagram, battery view and inverter rows
CI / Check (gateway, backend, frontend) (push) Successful in 3m27s
2026-10-24 17:59:27 +02:00
root d8b06e2a89 Merge master into fix/energy-polish
CI / Check (gateway, backend, frontend) (push) Successful in 1m26s
# Conflicts:
#	frontend/src/BatterySettings.tsx
2026-10-24 17:53:29 +02:00
root 1405695da5 Merge PR #90: fix(gateway): cap the lease after a failed boost over a held change, battery hardening
CI / Check (gateway, backend, frontend) (push) Successful in 1m25s
2026-10-24 17:50:15 +02:00
root ea2862ae04 Merge PR #89: fix: weather across midnight and IMGW back-off, review findings
CI / Check (gateway, backend, frontend) (push) Successful in 1m25s
2026-10-24 17:47:52 +02:00
rootandClaude Opus 5.5 317950cb75 fix(ui): name setpoints consistently in Details after merging master
CI / Check (gateway, backend, frontend) (push) Successful in 1m24s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:46:46 +02:00
root 8423d723a1 Merge PR #88: fix(frontend): readable stats and history charts, 44 px range toggles
CI / Check (gateway, backend, frontend) (push) Successful in 1m27s
2026-10-24 17:45:30 +02:00
rootandClaude Opus 5.5 cb93f24365 fix(energy): readable flow diagram, calmer battery view, device status rows
CI / Check (gateway, backend, frontend) (push) Successful in 1m26s
Vertical flow diagram with 13+ px text, battery flow in the brand colour,
action-first IMGW hint, clear schedule table, status and values per device
and dimmed values for stale sources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:45:18 +02:00
rootandClaude Opus 5.5 f181d0bbe3 fix(ui): stale status, clearer Ustawienia and history, contrast, 44 px theme switch
CI / Check (gateway, backend, frontend) (push) Successful in 1m25s
Stale devices and the side chip show a warning with an icon; setpoints are
named "Ciepła woda – oszczędnie" etc. in the UI and the audit labels; quick
actions get a one-line description; failed history rows get red or amber
chips; soft text and dark cards get more contrast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:44:50 +02:00
rootandClaude Opus 5.5 f57c3adc58 fix(http): share one failed-token throttle between endpoints
CI / Check (gateway, backend, frontend) (push) Successful in 1m23s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:43:40 +02:00
rootandClaude Opus 5.5 56a457085e fix(battery): decide a raised reserve on the live table
The cached reading can be 30 minutes old. A reading missing fields now
counts as no usable settings instead of failing with a 500.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:43:40 +02:00
rootandClaude Opus 5.5 6dcbb5b8e4 fix(battery): ignore a reserve file outside 0 to 100
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:43:40 +02:00
rootandClaude Opus 5.5 ace189f5e2 fix(commands): cap a held lease when a later boost may have landed
A boost that failed after its write could leave a raised value under the
held change's far lease. The restore now keeps both values but runs
within one lease (ADR-012).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:43:40 +02:00
rootandClaude Opus 5.5 d356fb2a2c perf(stats): poll statistics every 5 minutes
CI / Check (gateway, backend, frontend) (push) Successful in 1m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:42:49 +02:00
rootandClaude Opus 5.5 0fb139192d fix(frontend): hash public file contents into the cache version
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:42:49 +02:00
rootandClaude Opus 5.5 40823683be fix(sw): fall back to the cached shell when a navigation stalls
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:42:49 +02:00
rootandClaude Opus 5.5 1f43987db5 fix(battery): key schedule rows by slot, not by start time
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:42:49 +02:00
root e88c28899f Merge PR #87: feat(frontend): tidy the main screen, each value once
CI / Check (gateway, backend, frontend) (push) Successful in 1m25s
2026-10-24 17:42:32 +02:00
rootandClaude Opus 5.5 d5ab64aa61 fix(frontend): readable chart axes, tiles and range toggles
CI / Check (gateway, backend, frontend) (push) Successful in 1m30s
Statystyki: integer hour ticks with unit and axis labels, a legend, tiles without
a grid hole or a wrapping big number. Range toggles are 44 px, equal width and
filled when selected. Historia: 4 to 6 time ticks and value ticks that hug the data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:41:38 +02:00
rootandClaude Opus 5.5 01c5e1efe5 feat(ui): rearrange the overview around four key figures
CI / Check (gateway, backend, frontend) (push) Successful in 1m29s
Warnings first as one-line collapsible banners, then CWU, home, outdoor and
power once; schematic with device rows, weather as rows and one temperature
details card. The side panel icon follows today's forecast. Identical IMGW
warnings show once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:39:21 +02:00
rootandClaude Opus 5.5 0819ed8261 fix(weather): keep forecast across midnight and back off failing sources
The forecast expires at local midnight and covers four days; a failed
refresh serves the older value without retrying for 60 s, and IMGW has a
shorter timeout so it cannot hold the forecast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:39:16 +02:00
rootandClaude Opus 5.5 338befaffa fix(battery): return no schedule when a slot start is missing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:39:16 +02:00
54 changed files with 922 additions and 374 deletions

No files matched your search

@@ -34,6 +34,8 @@ import java.util.Objects;
final class Backend {
private static final Duration WEATHER_TIMEOUT = Duration.ofSeconds(10);
/** Warnings are optional, so a slow IMGW must not hold the forecast. */
private static final Duration WARNINGS_TIMEOUT = Duration.ofSeconds(3);
private Backend() {
}
@@ -64,7 +66,7 @@ final class Backend {
var place = WeatherSettings.fromEnvironment(System.getenv());
return new WeatherService(
new OpenMeteoClient(URI.create("https://api.open-meteo.com"), place, WEATHER_TIMEOUT),
new ImgwClient(URI.create("https://danepubliczne.imgw.pl"), place, WEATHER_TIMEOUT),
new ImgwClient(URI.create("https://danepubliczne.imgw.pl"), place, WARNINGS_TIMEOUT),
new InfluxPvEnergy(influx), clock);
}
@@ -13,7 +13,7 @@ import java.time.LocalDate;
import java.util.Map;
import java.util.TreeMap;
/** Open-Meteo (free, no key): daily values for the past 14 days and the next 3, and the temperature now. */
/** Open-Meteo (free, no key): daily values for the past 14 days and the next 4, and the temperature now. */
public final class OpenMeteoClient implements ForecastSource {
private static final String DAILY = "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,"
@@ -26,7 +26,7 @@ public final class OpenMeteoClient implements ForecastSource {
this.http = new HttpJson(timeout);
this.uri = URI.create(baseUri.toString().replaceAll("/+$", "") + "/v1/forecast?latitude="
+ place.latitude() + "&longitude=" + place.longitude() + "&daily=" + DAILY
+ "&current=temperature_2m&timezone=Europe%2FWarsaw&past_days=14&forecast_days=3");
+ "&current=temperature_2m&timezone=Europe%2FWarsaw&past_days=14&forecast_days=4");
}
@Override
@@ -19,8 +19,8 @@ public class AuditService {
private static final Map<String, Duration> RANGES =
Map.of("24h", Duration.ofHours(24), "7d", Duration.ofDays(7), "30d", Duration.ofDays(30));
private static final Map<String, String> SETPOINTS = Map.of(
"cwu_economy", "CWU eko", "cwu_comfort", "CWU komfort",
"co_economy", "CO eko", "co_comfort", "CO komfort");
"cwu_economy", "Ciepła woda – oszczędnie", "cwu_comfort", "Ciepła woda – komfort",
"co_economy", "Ogrzewanie – oszczędnie", "co_comfort", "Ogrzewanie – komfort");
private static final String RESTORE_USER = "restore";
private static final String RESTORE_LABEL = "automatycznie (przywrócenie)";
@@ -86,8 +86,13 @@ public class BatteryService {
: "Rezerwa na awarię: bateria nie rozładowuje się na co dzień");
}
/** Slot n runs from its start to the start of slot n + 1; the last one wraps to the first. */
/** Slot n runs from its start to the start of slot n + 1; the last one wraps to the first.
* No schedule at all when a start is missing. */
private static List<Slot> schedule(Map<String, Double> s) {
boolean complete = IntStream.rangeClosed(1, SLOTS).allMatch(n -> whole(s, "prog" + n + "_start_hhmm") != null);
if (!complete) {
return List.of();
}
return IntStream.rangeClosed(1, SLOTS).mapToObj(n -> {
String prefix = "prog" + n + "_";
String next = "prog" + (n % SLOTS + 1) + "_start_hhmm";
@@ -4,8 +4,13 @@ import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
/** One remote answer kept for a while; when a refresh fails the older answer is served and marked stale. */
/**
* One remote answer kept for a while, and no longer than its local day when a zone is given. When a refresh
* fails the older answer is served marked stale, and the source is left alone for a short back-off so a
* source that is down does not make every request wait for its timeout.
*/
final class Cached<T> {
interface Fetch<T> {
@@ -15,33 +20,61 @@ final class Cached<T> {
record Entry<T>(T value, boolean stale) {
}
private static final Duration BACK_OFF = Duration.ofSeconds(60);
private final Duration ttl;
private final ZoneId dayZone;
private final Clock clock;
private final Fetch<T> source;
private T value;
private Instant fetchedAt;
private Instant failedAt;
Cached(Duration ttl, Clock clock, Fetch<T> source) {
this(ttl, null, clock, source);
}
/** @param dayZone the answer expires at midnight of this zone even before the ttl, or null */
Cached(Duration ttl, ZoneId dayZone, Clock clock, Fetch<T> source) {
this.ttl = ttl;
this.dayZone = dayZone;
this.clock = clock;
this.source = source;
}
/** @throws IOException when the refresh fails and there is nothing older to serve */
/** @throws IOException when the refresh fails or is backing off, and there is nothing older to serve */
synchronized Entry<T> get() throws IOException {
Instant now = clock.instant();
if (value != null && now.isBefore(fetchedAt.plus(ttl))) {
if (value != null && now.isBefore(expiry())) {
return new Entry<>(value, false);
}
if (failedAt != null && now.isBefore(failedAt.plus(BACK_OFF))) {
return older(new IOException("source failed recently, not retrying yet"));
}
try {
value = source.fetch();
fetchedAt = now;
failedAt = null;
return new Entry<>(value, false);
} catch (IOException e) {
if (value == null) {
throw e;
}
return new Entry<>(value, true);
failedAt = now;
return older(e);
}
}
private Entry<T> older(IOException failure) throws IOException {
if (value == null) {
throw failure;
}
return new Entry<>(value, true);
}
private Instant expiry() {
Instant byTtl = fetchedAt.plus(ttl);
if (dayZone == null) {
return byTtl;
}
Instant midnight = fetchedAt.atZone(dayZone).toLocalDate().plusDays(1).atStartOfDay(dayZone).toInstant();
return midnight.isBefore(byTtl) ? midnight : byTtl;
}
}
@@ -37,7 +37,7 @@ public class WeatherService {
Clock clock) {
this.clock = clock;
this.pv = pv;
this.forecast = new Cached<>(Duration.ofMinutes(30), clock, () -> calibrated(forecastSource.forecast()));
this.forecast = new Cached<>(Duration.ofMinutes(30), ZONE, clock, () -> calibrated(forecastSource.forecast()));
this.warnings = new Cached<>(Duration.ofMinutes(10), clock, warningSource::warnings);
}
@@ -54,7 +54,7 @@ class AuditServiceTest {
var entries = service.audit(null).entries();
assertEquals(List.of("CWU eko", "Rezerwa baterii", "CO komfort"),
assertEquals(List.of("Ciepła woda – oszczędnie", "Rezerwa baterii", "Ogrzewanie – komfort"),
entries.stream().map(AuditEntry::what).toList());
assertEquals(List.of("heat_pump", "battery", "heat_pump"),
entries.stream().map(AuditEntry::source).toList());
@@ -279,6 +279,14 @@ class ServicesTest {
assertEquals(new BatteryView.Slot("21:00", "01:00", 8000, 60, false), schedule.get(5));
}
@Test
void batteryScheduleIsEmptyWhenASlotStartIsMissing() throws IOException {
Map<String, Double> fields = settings(1);
fields.remove("prog3_start_hhmm");
assertTrue(battery(deyeSettings(fields, READ)).schedule().isEmpty());
}
@Test
void batteryModeFollowsTimeOfUse() throws IOException {
BatteryView backup = battery(deyeSettings(settings(0), READ));
@@ -55,6 +55,7 @@ class WeatherServiceTest {
private boolean warningsDown;
private boolean influxDown;
private int measuredDays = 5;
private LocalDate today = TODAY;
private List<Warning> warned = List.of(warning(START.plus(Duration.ofHours(8))));
private final WeatherService service = new WeatherService(
@@ -76,24 +77,24 @@ class WeatherServiceTest {
if (influxDown) {
throw new IOException("influx down");
}
assertEquals(TODAY.minusDays(14), from);
assertEquals(TODAY, to);
assertEquals(today.minusDays(14), from);
assertEquals(today, to);
Map<LocalDate, Double> measured = new HashMap<>();
for (int i = 1; i <= measuredDays; i++) {
measured.put(TODAY.minusDays(i), 15.0);
measured.put(today.minusDays(i), 15.0);
}
return measured;
},
clock);
private static Forecast forecast() {
private Forecast forecast() {
Map<LocalDate, DailyForecast> days = new TreeMap<>();
for (int i = 1; i <= 14; i++) {
days.put(TODAY.minusDays(i), new DailyForecast(3, 10.0, 2.0, 0.0, 5.0, 3600.0));
days.put(today.minusDays(i), new DailyForecast(3, 10.0, 2.0, 0.0, 5.0, 3600.0));
}
days.put(TODAY, new DailyForecast(0, 12.34, 3.0, 0.0, 6.0, 5400.0));
days.put(TODAY.plusDays(1), new DailyForecast(63, 9.0, 4.0, 5.5, null, null));
days.put(TODAY.plusDays(2), new DailyForecast(null, null, null, null, 1.0, 0.0));
days.put(today, new DailyForecast(0, 12.34, 3.0, 0.0, 6.0, 5400.0));
days.put(today.plusDays(1), new DailyForecast(63, 9.0, 4.0, 5.5, null, null));
days.put(today.plusDays(2), new DailyForecast(null, null, null, null, 1.0, 0.0));
return new Forecast(8.5, days);
}
@@ -130,6 +131,48 @@ class WeatherServiceTest {
assertEquals(TODAY, service.current().today().date());
}
@Test
void aForecastFetchedBeforeLocalMidnightIsNotUsedAfterIt() throws IOException {
now = Instant.parse("2026-10-24T21:50:00Z");
service.current();
now = Instant.parse("2026-10-24T22:05:00Z");
today = TODAY.plusDays(1);
WeatherView view = service.current();
assertEquals(2, forecastCalls.get());
assertEquals(today, view.today().date());
assertEquals(today.plusDays(2), view.dayAfter().date());
}
@Test
void aFailedRefreshServesTheOlderWarningsWithoutAskingAgainFor60Seconds() throws IOException {
service.current();
warningsDown = true;
now = START.plus(Duration.ofMinutes(11));
service.current();
assertEquals(2, warningCalls.get());
now = now.plusSeconds(59);
assertTrue(service.current().stale());
assertEquals(2, warningCalls.get());
now = now.plusSeconds(1);
service.current();
assertEquals(3, warningCalls.get());
}
@Test
void withoutAnyForecastItFailsFastDuringTheBackOff() {
forecastDown = true;
assertThrows(IOException.class, service::current);
now = START.plusSeconds(30);
assertThrows(IOException.class, service::current);
assertEquals(1, forecastCalls.get());
}
@Test
void fewerThanFiveMeasuredDaysGiveNoEstimate() throws IOException {
measuredDays = 4;
+2 -2
View File
@@ -2,11 +2,11 @@
"entries": [
{ "time": "2026-10-24T09:30:00Z", "source": "battery", "what": "Rezerwa baterii", "oldValue": 50, "newValue": 60,
"unit": "%", "who": "mariusz", "status": "APPLIED" },
{ "time": "2026-10-24T08:00:00Z", "source": "heat_pump", "what": "CWU eko", "oldValue": 45, "newValue": 8,
{ "time": "2026-10-24T08:00:00Z", "source": "heat_pump", "what": "Ciepła woda – oszczędnie", "oldValue": 45, "newValue": 8,
"unit": "°C", "who": "automatycznie (przywrócenie)", "status": "APPLIED" },
{ "time": "2026-10-23T18:00:00Z", "source": "battery", "what": "Przedział 3: ładowanie z sieci", "oldValue": 0,
"newValue": 1, "unit": "0/1", "who": "mariusz", "status": "FAILED" },
{ "time": "2026-10-23T07:00:00Z", "source": "heat_pump", "what": "CO komfort", "oldValue": null, "newValue": 22,
{ "time": "2026-10-23T07:00:00Z", "source": "heat_pump", "what": "Ogrzewanie – komfort", "oldValue": null, "newValue": 22,
"unit": "°C", "who": "mariusz", "status": "REJECTED" }
]
}
+1 -1
View File
@@ -26,7 +26,7 @@ In the Bateria view, `editable` from `api/battery` (the gateway takes battery ch
schedule: the user confirms a summary, only changed fields are sent to `api/battery/schedule` and `api/battery/reserve`, and the UI
checks the gateway's rules first (levels from the reserve to 100 %, starts increasing).
Podgląd shows the "Pogoda" card (3 days, PV estimate) and one banner per IMGW warning from `api/weather`; Bateria hints to keep the battery full on a storm or strong-wind warning (a hint only, nothing changes by itself).
Podgląd opens with the alarm and one collapsible line per distinct IMGW warning, then four key figures (CWU, home, outdoor, power), the schematic with the device rows, the "Pogoda" card (3 days, PV estimate, from `api/weather`) and "Szczegóły temperatur"; Bateria hints to keep the battery full on a storm or strong-wind warning (a hint only, nothing changes by itself).
Historia has two views in the hash, like Energia: `#history` (Wykresy) and `#history/stats` (Statystyki: totals and a daily chart of
compressor hours from `api/stats`, 7 or 30 days). `SubView.tsx` holds the shared tab switch.
+16 -10
View File
@@ -6,7 +6,8 @@ import { Overview } from "./Overview";
import { usePoll } from "./poll";
import { useTheme, type Theme } from "./theme";
import type { HeatPump } from "./types";
import { Dot, Loading } from "./ui";
import { Dot, isStale, Loading } from "./ui";
import { useWeather, WeatherIcon } from "./Weather";
// Energy shares code with the first screen; the others load when opened.
const History = lazy(() => import("./History").then((m) => ({ default: m.History })));
@@ -44,7 +45,7 @@ function ThemeSwitch({ className }: { className: string }) {
<div role="group" aria-label="Motyw" className={`flex gap-1 rounded-xl p-1 text-sm ${className}`}>
{Object.entries(THEMES).map(([id, label]) => (
<button key={id} aria-pressed={id === theme} onClick={() => setTheme(id as Theme)}
className="flex-1 rounded-lg px-2.5 py-1.5 font-semibold opacity-70 aria-pressed:bg-white aria-pressed:text-side aria-pressed:opacity-100">
className="min-h-11 flex-1 rounded-lg px-2.5 font-semibold opacity-80 aria-pressed:bg-white aria-pressed:text-side aria-pressed:opacity-100">
{label}
</button>
))}
@@ -56,10 +57,12 @@ function ThemeSwitch({ className }: { className: string }) {
function SideFooter() {
const { data, offline } = usePoll<HeatPump>("api/heat-pump");
const now = useClock();
const old = !offline && data?.time != null && isStale(data.time, data.stale);
const weather = useWeather();
return (
<div className="mt-auto space-y-4 px-2">
<div className="mt-auto space-y-3 border-t border-white/10 px-2 pt-4">
<div className="flex items-center gap-3">
<Icon name="sun" className="size-9 text-amber-400" />
{weather && <WeatherIcon code={weather.today.weatherCode} className="size-9" />}
<div>
<p className="text-2xl font-semibold">{celsius(data?.temperatures.outdoor)}</p>
<p className="text-sm text-white/60">Szczepańcowa</p>
@@ -68,9 +71,9 @@ function SideFooter() {
<p className="text-sm text-white/80">{longDate(now)} · {hour(now)}</p>
<ThemeSwitch className="bg-white/10" />
<div className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/5 p-3">
<Dot tone={offline ? "bad" : "ok"} live={!offline} />
<Dot tone={offline ? "bad" : old ? "warn" : "ok"} live={!offline && !old} />
<div className="text-sm">
<p className="font-semibold">{offline ? "Brak połączenia" : "System online"}</p>
<p className="font-semibold">{offline ? "Brak połączenia" : old ? "Dane nieaktualne" : "System online"}</p>
<p className="text-white/60">{data?.time ? `Ostatni odczyt: ${hour(new Date(data.time))}` : "Czekam na odczyt"}</p>
</div>
</div>
@@ -81,14 +84,17 @@ function SideFooter() {
export function App() {
const [screen, setScreen] = useState<ScreenId>(fromHash);
useEffect(() => {
const onHash = () => setScreen(fromHash());
// A link to another screen, such as "Energia ›", arrives here too.
const onHash = () => {
setScreen(fromHash());
scrollTo(0, 0);
};
addEventListener("hashchange", onHash);
return () => removeEventListener("hashchange", onHash);
}, []);
const show = (id: ScreenId) => {
location.hash = id;
setScreen(id);
scrollTo(0, 0);
};
const View = SCREENS[screen].view;
@@ -99,8 +105,8 @@ export function App() {
<div className="mb-8 hidden items-center gap-3 px-2 lg:flex">
<span className="grid size-10 place-items-center rounded-xl bg-brand/20 text-brand"><Icon name="leaf" className="size-6" /></span>
<div>
<p className="text-lg font-bold leading-tight">Dom Szczepańcowa</p>
<p className="text-xs text-white/60">Pompa ciepła i energia</p>
<p className="text-base font-bold leading-tight">Dom Szczepańcowa</p>
<p className="text-xs text-white/70">Pompa ciepła i energia</p>
</div>
</div>
{Object.entries(SCREENS).map(([id, s]) => (
+3 -3
View File
@@ -24,7 +24,7 @@ describe("Battery view", () => {
await show({ powerW: 3 });
expect(screen.getByRole("img", { name: "Naładowanie baterii: 99 %" })).toBeInTheDocument();
expect(screen.getByText("14,2 kWh z 14,3 kWh")).toBeInTheDocument();
expect(screen.getByText("14,2 / 14,3 kWh")).toBeInTheDocument();
expect(screen.getByText("stoi")).toBeInTheDocument();
});
@@ -47,7 +47,7 @@ describe("Battery view", () => {
expect(screen.getByText("Rezerwa na awarię: bateria nie rozładowuje się na co dzień")).toBeInTheDocument();
const table = screen.getByRole("table");
expect(screen.getByText("wyłączony")).toBeInTheDocument();
expect(screen.getByText("Harmonogram wyłączony – bateria tylko na awarię.")).toBeInTheDocument();
expect(table).toHaveClass("opacity-50");
expect(within(table).getAllByRole("row")).toHaveLength(7);
expect(within(table).getByRole("row", { name: /21:00–01:00/ })).toHaveTextContent("80 %nie");
@@ -101,7 +101,7 @@ describe("Battery view", () => {
total: { chargeKwh: null, dischargeKwh: null } });
expect(screen.getByText(/Dane są nieaktualne/)).toBeInTheDocument();
expect(screen.getByRole("img", { name: "Naładowanie baterii: —" })).toBeInTheDocument();
expect(screen.getByRole("img", { name: "Naładowanie baterii: —" }).closest(".opacity-50")).not.toBeNull();
expect(screen.getByText("Dziś naładowano").parentElement).toHaveTextContent("—");
});
});
+17 -11
View File
@@ -1,10 +1,10 @@
import { useState } from "react";
import { flowing } from "./flow";
import { amps, DASH, hour, kwh, number, percent, power } from "./format";
import { amps, DASH, dateTime, hour, kwh, number, percent, power } from "./format";
import { usePoll } from "./poll";
import type { Battery as BatteryData } from "./types";
import { Reserve, Schedule, type Notice } from "./BatterySettings";
import { Banner, Card, Freshness, Loading, Row } from "./ui";
import { Banner, Card, Freshness, isStale, Loading, Row } from "./ui";
import { threatensPower, useWeather } from "./Weather";
/** What the battery does, in one word: + discharge / − charge. */
@@ -31,21 +31,21 @@ function Gauge({ soc, stored, capacity }: { soc: number | null; stored: number |
<div className="absolute inset-0 grid place-items-center text-center">
<div>
<p className="text-4xl font-bold tabular-nums">{percent(soc)}</p>
{stored != null && capacity != null && <p className="text-sm text-soft">{kwh(stored)} z {kwh(capacity)}</p>}
{stored != null && capacity != null && <p className="text-sm text-soft">{number(stored, 1)} / {kwh(capacity)}</p>}
</div>
</div>
</div>
);
}
function Now({ b }: { b: BatteryData }) {
function Now({ b, stale }: { b: BatteryData; stale: boolean }) {
const stored = b.socPct != null && b.capacityKwh != null ? (b.socPct / 100) * b.capacityKwh : null;
return (
<Card title="Bateria teraz">
<div className="flex flex-wrap items-center gap-5">
<div className={`flex flex-wrap items-center gap-5 ${stale ? "opacity-50" : ""}`}>
<Gauge soc={b.socPct} stored={stored} capacity={b.capacityKwh} />
<div className="min-w-44 flex-1">
<p className={`text-2xl font-bold ${flowing(b.powerW) ? "text-run" : ""}`}>
<p className={`text-2xl font-bold ${flowing(b.powerW) ? "text-brand" : ""}`}>
{batteryAction(b.powerW)}
{flowing(b.powerW) && <span className="tabular-nums"> {power(Math.abs(b.powerW!))}</span>}
</p>
@@ -90,17 +90,23 @@ function Totals({ b }: { b: BatteryData }) {
export function Battery() {
const { data: b, offline } = usePoll<BatteryData>("api/battery");
const [notice, setNotice] = useState<Notice>(null);
const weather = useWeather();
const threats = useWeather()?.warnings.filter(threatensPower) ?? [];
if (!b) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
return (
<>
<Freshness offline={offline} time={b.time} stale={b.stale} />
{weather?.warnings.some(threatensPower) && (
<Banner tone="warn">Ostrzeżenie IMGW: rozważ trzymanie baterii pełnej (rezerwa na awarię).</Banner>
<Freshness offline={offline} time={b.time} stale={b.stale} compact />
{threats.length > 0 && (
<Banner tone="warn" compact>
<p>Zapowiadany silny wiatr lub burza – zostaw baterię naładowaną (rezerwa na awarię).</p>
<details className="mt-1 font-normal">
<summary className="cursor-pointer py-1 font-semibold">Szczegóły ostrzeżenia</summary>
<ul>{threats.map((w) => <li key={`${w.event}${w.level}${w.from}`}>IMGW, stopień {w.level}: {w.event}, {dateTime(w.from)} – {dateTime(w.to)}</li>)}</ul>
</details>
</Banner>
)}
{notice && <Banner tone={notice.ok ? "ok" : "bad"}>{notice.text}</Banner>}
<div className="grid gap-4 lg:grid-cols-2">
<div className="lg:col-span-2"><Now b={b} /></div>
<div className="lg:col-span-2"><Now b={b} stale={isStale(b.time, b.stale)} /></div>
{b.settingsStale || !b.mode || !b.limits ? (
<div className="lg:col-span-2">
<Banner tone="warn">Ustawienia baterii są nieaktualne{b.settingsTime ? ` (ostatnio ${hour(new Date(b.settingsTime))})` : ", brak odczytu"}.</Banner>
+11 -1
View File
@@ -1,6 +1,6 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { answer, example, serve } from "./fixtures";
import type { Battery as BatteryData } from "./types";
@@ -191,6 +191,16 @@ describe("Battery schedule editing", () => {
expect(screen.getByRole("button", { name: "Zapisz" })).toBeEnabled();
});
it("lists slots that share a start without a duplicate key", async () => {
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
const schedule = example<BatteryData>("battery").schedule.map((slot) => ({ ...slot, from: "00:00" }));
await show({ schedule });
expect(screen.getAllByText(/^00:00–/)).toHaveLength(6);
expect(error).not.toHaveBeenCalled();
error.mockRestore();
});
it("requires every start to be filled in", async () => {
await show();
await edit();
+11 -10
View File
@@ -3,7 +3,7 @@ import { postJson, WriteError } from "./api";
import { percent } from "./format";
import { reload } from "./poll";
import type { Battery, BatteryChange, BatteryResult, BatterySlot } from "./types";
import { Button, Card, Pill, Row, Stepper } from "./ui";
import { Button, Card, Row, Stepper } from "./ui";
const URL = "api/battery";
// The gateway polls the settings again 3 s after a write; the backend shows them from then on.
@@ -109,7 +109,7 @@ export function Reserve({ b, onDone }: { b: Battery; onDone: (notice: Notice) =>
<>
<Stepper label="rezerwa na awarię" value={draft ?? reserve.pct} min={reserve.floorPct} max={100}
format={percent} disabled={busy} onStep={step} />
<p className="text-sm text-soft">od {percent(reserve.floorPct)} do {percent(100)}</p>
<p className="text-base text-soft">od {percent(reserve.floorPct)} do {percent(100)}</p>
{breaksSchedule && <p role="alert" className="mt-2 font-semibold text-alarm">{RESERVE_BREAKS_SCHEDULE}</p>}
{draft != null && !confirming && (
<div className="mt-3 flex gap-3">
@@ -275,19 +275,20 @@ export function Schedule({ b, onDone }: { b: Battery; onDone: (notice: Notice) =
}
const edit = b.editable && b.reserve ? <Button onClick={() => setEditing(true)}>Edytuj</Button> : null;
return (
<Card title="Harmonogram" aside={<div className="flex items-center gap-2">{off && <Pill tone="off">wyłączony</Pill>}{edit}</div>}>
<table className={`w-full text-[0.95rem] ${off ? "opacity-50" : ""}`}>
<Card title="Harmonogram" aside={edit}>
{off && <p className="mb-3 font-semibold">Harmonogram wyłączony – bateria tylko na awarię.</p>}
<table className={`w-full text-base ${off ? "opacity-50" : ""}`}>
<caption className="sr-only">Przedziały pracy baterii{off ? " (wyłączone)" : ""}</caption>
<thead>
<tr className="text-left text-sm text-soft">
<th scope="col" className="pb-2 font-medium">Godziny</th>
<th scope="col" className="pb-2 font-medium">Poziom</th>
<th scope="col" className="pb-2 text-right font-medium">Ładowanie z sieci</th>
<tr className="text-left text-soft">
<th scope="col" className="pb-2 font-semibold">Godziny</th>
<th scope="col" className="pb-2 font-semibold">Poziom</th>
<th scope="col" className="pb-2 text-right font-semibold">Z sieci</th>
</tr>
</thead>
<tbody>
{b.schedule.map((slot) => (
<tr key={slot.from} className="border-t border-edge">
{b.schedule.map((slot, i) => (
<tr key={i + 1} className="border-t border-edge">
<th scope="row" className="py-2 text-left font-semibold tabular-nums">{slot.from}–{slot.to}</th>
<td className="py-2 tabular-nums">{percent(slot.socPct)}</td>
<td className="py-2 text-right font-semibold">{slot.gridCharge ? "tak" : "nie"}</td>
+1 -1
View File
@@ -26,7 +26,7 @@ describe("ChangeHistory", () => {
expect(rows[1]).toHaveTextContent("automatycznie (przywrócenie)");
expect(rows[2]).toHaveTextContent("nie → tak");
expect(within(rows[2]).getByText("nieudana")).toBeInTheDocument();
expect(rows[3]).toHaveTextContent("— → 22 °C");
expect(rows[3]).toHaveTextContent("ustawiono 22 °C");
expect(within(rows[3]).getByText("odrzucona")).toBeInTheDocument();
expect(within(rows[0]).queryByText(/nieudana|odrzucona|konflikt|nieważna/)).toBeNull();
});
+12 -7
View File
@@ -6,8 +6,10 @@ import { Card, Pill } from "./ui";
const URL = "api/audit?range=7d";
const PAGE = 20;
const STATUS: Record<string, string> = {
FAILED: "nieudana", REJECTED: "odrzucona", CONFLICT: "konflikt", INVALID: "nieważna",
// A failure may have reached the pump, so it is red; the others changed nothing, so amber.
const STATUS: Record<string, { text: string; tone: "bad" | "warn" }> = {
FAILED: { text: "nieudana", tone: "bad" }, REJECTED: { text: "odrzucona", tone: "warn" },
CONFLICT: { text: "konflikt", tone: "warn" }, INVALID: { text: "nieważna", tone: "warn" },
};
// 1830 is a clock time, 0/1 is off/on.
@@ -20,17 +22,20 @@ const SHOWN: Record<string, (value: number) => string> = {
const shown = (value: number | null, unit: string) => (value == null ? "—" : (SHOWN[unit] ?? number)(value));
function Row({ entry }: { entry: AuditEntry }) {
const status = entry.status === "APPLIED" ? null : (STATUS[entry.status] ?? entry.status);
const status = entry.status === "APPLIED" ? null : (STATUS[entry.status] ?? { text: entry.status, tone: "warn" as const });
return (
<li className="border-t border-edge py-3 first:border-t-0 first:pt-0">
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
<div className="flex items-baseline justify-between gap-3">
<span className="font-semibold">{entry.what}</span>
<span className="text-sm text-soft">{dateTime(entry.time)}</span>
<span className="shrink-0 text-sm text-soft">{dateTime(entry.time)}</span>
</div>
<p className="my-1">{shown(entry.oldValue, entry.unit)} → <b>{shown(entry.newValue, entry.unit)}</b></p>
<p className="my-1">
{entry.oldValue == null ? <>ustawiono <b>{shown(entry.newValue, entry.unit)}</b></>
: <>{shown(entry.oldValue, entry.unit)} → <b>{shown(entry.newValue, entry.unit)}</b></>}
</p>
<p className="flex flex-wrap items-center gap-2 text-sm text-soft">
{entry.who}
{status && <Pill tone="off">{status}</Pill>}
{status && <Pill tone={status.tone}>{status.text}</Pill>}
</p>
</li>
);
+51
View File
@@ -0,0 +1,51 @@
import type { ReactNode } from "react";
import { celsius, degrees, number } from "./format";
import type { HeatPump, PiSensor } from "./types";
import { Card, Row } from "./ui";
// The Raspberry Pi with the BME280 stands in the living room.
const NO_READING = "brak odczytu";
const reading = (value: number | null | undefined, digits: number, unit: string) =>
value == null ? NO_READING : `${number(value, digits)} ${unit}`;
function Group({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="mt-3 first:mt-0 [&>div]:py-1.5">
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-soft">{title}</h3>
{children}
</div>
);
}
const difference = (a: number | null, b: number | null) => (a != null && b != null ? celsius(a - b) : "—");
/** Every temperature and setpoint the overview does not already show in its four key figures. */
export function TemperatureDetails({ hp, pi }: { hp: HeatPump; pi: PiSensor | undefined }) {
const { temperatures: t, targets, setpoints: s } = hp;
return (
<Card title="Szczegóły temperatur">
<Group title="Dom">
<Row label="Salon" value={reading(pi?.temperature, 1, "°C")} />
<Row label="Wilgotność w salonie" value={reading(pi?.humidity, 1, "%")} />
<Row label="Ciśnienie" value={reading(pi?.pressure, 0, "hPa")} />
<Row label="Pokój według pompy" value={celsius(t.indoor)} />
<Row label="Pokój, cel teraz" value={celsius(targets.room)} />
<Row label="Na zewnątrz, średnia" value={celsius(t.outdoorAvg)} />
</Group>
<Group title="Woda i ogrzewanie">
<Row label="Bufor CO" value={celsius(t.coBuffer)} />
<Row label="Bufor CO, cel" value={celsius(targets.co)} />
<Row label="Wyjście pompy" value={celsius(t.pumpOutlet)} />
</Group>
<Group title="Dolne źródło">
<Row label="Do pompy" value={celsius(t.groundIn)} />
<Row label="Z pompy" value={celsius(t.groundOut)} />
<Row label="Różnica" value={difference(t.groundIn, t.groundOut)} />
</Group>
<Group title="Nastawy">
<Row label="Ciepła woda – oszczędnie · komfort" value={`${degrees(s.cwuEconomy)} · ${degrees(s.cwuComfort)}`} />
<Row label="Ogrzewanie – oszczędnie · komfort" value={`${degrees(s.coEconomy)} · ${degrees(s.coComfort)}`} />
</Group>
</Card>
);
}
+11 -6
View File
@@ -1,16 +1,21 @@
import { useEffect, useRef } from "react";
import { quantity, when } from "./format";
import type { EnergyDetailsDevice } from "./types";
import { Row } from "./ui";
import { Dot, isStale, Row } from "./ui";
/** The tappable row that says in words and with a chevron that a device has more to show. */
export function DetailsButton({ device, onOpen }: { device: EnergyDetailsDevice; onOpen: () => void }) {
/** The tappable row: status, the main current value and the read time; the chevron says there is more. */
export function DetailsButton({ device, summary, onOpen }: { device: EnergyDetailsDevice; summary: string; onOpen: () => void }) {
const stale = device.stale || isStale(device.time);
const read = stale ? (device.time ? `brak odczytu od ${when(device.time)}` : "brak odczytu") : device.time && `odczyt ${when(device.time)}`;
return (
<button type="button" onClick={onOpen}
className="flex min-h-14 w-full items-center justify-between gap-3 rounded-xl border border-edge px-4 py-2 text-left hover:bg-brand-soft focus-visible:outline-2 focus-visible:outline-brand">
className="flex min-h-16 w-full items-center justify-between gap-3 rounded-xl border border-edge px-4 py-2 text-left hover:bg-brand-soft focus-visible:outline-2 focus-visible:outline-brand">
<span>
<span className="block font-semibold">{device.name}</span>
{device.stale && <span className="block text-xs text-soft">brak odczytu</span>}
<span className="flex items-center gap-2 font-semibold">
<Dot tone={stale ? "warn" : "ok"} />{device.name}
</span>
{!stale && summary && <span className="block text-lg font-bold tabular-nums">{summary}</span>}
{read && <span className="block text-sm text-soft">{read}</span>}
</span>
<span className="flex shrink-0 items-center gap-1 font-semibold text-brand">
Szczegóły <span aria-hidden="true" className="text-xl leading-none">›</span>
+34 -1
View File
@@ -1,6 +1,6 @@
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { example, serve } from "./fixtures";
import type { Energy as EnergyData, EnergyDetails } from "./types";
@@ -15,6 +15,11 @@ beforeEach(() => {
location.hash = "";
});
afterEach(() => vi.useRealTimers());
// The fixtures are from a fixed evening; this makes them "just read".
const justAfterFixture = () => vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-10-23T22:28:00Z") });
describe("Energy views", () => {
const tab = (name: string) => screen.getByRole("tab", { name });
@@ -90,6 +95,34 @@ describe("Energy details", () => {
expect(screen.queryByRole("dialog")).toBeNull();
});
it("shows the main value and the read time on each row, and says when a device is silent", async () => {
justAfterFixture();
const details = example<EnergyDetails>("energy-details");
serveEnergy({ devices: details.devices.map((d) => (d.id === "fronius" ? { ...d, stale: true, groups: [] } : d)) });
location.hash = "energy/inverters";
const Energy = await energy();
render(<Energy />);
expect(await screen.findByRole("button", { name: /Falownik Deye/ })).toHaveTextContent("Bateria 99 % · PV 0 W");
expect(screen.getByRole("button", { name: /Licznik domu/ })).toHaveTextContent("645 W pobór");
expect(screen.getByRole("button", { name: /Falownik Fronius/ })).toHaveTextContent("brak odczytu od");
expect(screen.getByRole("button", { name: /Falownik Fronius/ })).not.toHaveTextContent("PV");
});
it("dims the values of a source that has gone quiet", async () => {
justAfterFixture();
const e = example<EnergyData>("energy");
serve({ "api/energy/details": example("energy-details"), "api/battery": example("battery"),
"api/energy": { ...e, sources: e.sources.map((s) => (s.name === "deye" ? { ...s, stale: true } : s)) } });
const Energy = await energy();
render(<Energy />);
const stats = (await screen.findByText("Zużycie domu")).closest("section")!;
expect(screen.getByText("Produkcja PV", { selector: "p" }).closest("section")).toHaveClass("opacity-60");
expect(screen.getAllByText("Bateria", { selector: "p" })[0].closest("section")).toHaveClass("opacity-60");
expect(stats).not.toHaveClass("opacity-60");
});
it("opens the groups of a device and closes by button, backdrop and Escape", async () => {
serveEnergy();
location.hash = "energy/inverters";
+69 -34
View File
@@ -5,10 +5,10 @@ import { flowing } from "./flow";
import { kwh, percent, power, when } from "./format";
import { Icon, type IconName } from "./icons";
import { usePoll } from "./poll";
import type { Energy as EnergyData, EnergyDetails } from "./types";
import type { Energy as EnergyData, EnergyDetails, EnergyDetailsDevice } from "./types";
import { DetailsButton, DeviceDetails } from "./DeviceDetails";
import { SubViewPanel, SubViewSwitch, useSubView } from "./SubView";
import { Banner, Card, Freshness, Loading, PageTitle, Pill, Row } from "./ui";
import { Banner, Card, Freshness, isStale, Loading, PageTitle, Pill, Row } from "./ui";
const INVERTERS = { deye: "Falownik Deye", fronius: "Falownik Fronius" } as const;
@@ -17,7 +17,7 @@ export function silentInverters(e: EnergyData) {
return Object.entries(INVERTERS).flatMap(([id, label]) => {
const source = e.sources?.find((s) => s.name === id);
if (source && !source.stale && source.time) return [];
return [{ label, since: source?.time ? `brak odczytu od ${when(source.time)}` : "brak odczytu" }];
return [{ id, label, since: source?.time ? `brak odczytu od ${when(source.time)}` : "brak odczytu" }];
});
}
@@ -38,17 +38,17 @@ function batteryText(watts: number | null) {
// Nodes sit around the house; dots run along a line from the source to the sink of each flow.
const NODES: Record<"pv" | "grid" | "house" | "battery", { x: number; y: number; label: string; icon: IconName; colour: string }> = {
pv: { x: 90, y: 70, label: "Słońce (PV)", icon: "sun", colour: "#f59e0b" },
grid: { x: 510, y: 70, label: "Sieć", icon: "plug", colour: "#3b82f6" },
house: { x: 300, y: 170, label: "Dom", icon: "home", colour: "#10b981" },
battery: { x: 300, y: 300, label: "Bateria", icon: "battery", colour: "#8b5cf6" },
pv: { x: 94, y: 56, label: "Słońce (PV)", icon: "sun", colour: "#f59e0b" },
grid: { x: 286, y: 56, label: "Sieć", icon: "plug", colour: "#3b82f6" },
house: { x: 190, y: 196, label: "Dom", icon: "home", colour: "#10b981" },
battery: { x: 190, y: 336, label: "Bateria", icon: "battery", colour: "#8b5cf6" },
};
// A triangle halfway along the line, pointing at the sink: the direction is readable without the moving dots.
function Arrowhead({ from, to }: { from: { x: number; y: number; colour: string }; to: { x: number; y: number } }) {
const angle = (Math.atan2(to.y - from.y, to.x - from.x) * 180) / Math.PI;
const x = (from.x + to.x) / 2, y = (from.y + to.y) / 2;
return <polygon data-arrow points="-11,-10 11,0 -11,10" fill={from.colour} transform={`translate(${x} ${y}) rotate(${angle})`} />;
return <polygon data-arrow points="-13,-12 13,0 -13,12" fill={from.colour} transform={`translate(${x} ${y}) rotate(${angle})`} />;
}
function Flow({ from, to, watts }: { from: keyof typeof NODES; to: keyof typeof NODES; watts: number | null }) {
@@ -60,15 +60,15 @@ function Flow({ from, to, watts }: { from: keyof typeof NODES; to: keyof typeof
const speed = on ? Math.max(0.6, 2.5 - (Math.min(Math.abs(watts!), 5000) / 5000) * 1.9) : 0;
return (
<g data-flow={`${from}-${to}`} data-on={on} aria-hidden="true">
<path d={d} stroke={on ? s.colour : "var(--color-off)"} strokeWidth={on ? 5 : 3} strokeLinecap="round" opacity={on ? 0.35 : 0.6} />
{on && <path d={d} className="flow" stroke={s.colour} strokeWidth={5} style={{ stroke: s.colour, animationDuration: `${speed}s` }} />}
<path d={d} stroke={on ? s.colour : "var(--color-off)"} strokeWidth={on ? 7 : 4} strokeLinecap="round" opacity={on ? 0.45 : 1} />
{on && <path d={d} className="flow" stroke={s.colour} strokeWidth={7} style={{ stroke: s.colour, animationDuration: `${speed}s` }} />}
{on && <Arrowhead from={s} to={e} />}
</g>
);
}
// With onOpen the node is a button: the chevron says it leads somewhere.
function Node({ id, value, note, onOpen }: { id: keyof typeof NODES; value: string; note?: string; onOpen?: () => void }) {
function Node({ id, value, note, dim, onOpen }: { id: keyof typeof NODES; value: string; note?: string; dim: boolean; onOpen?: () => void }) {
const { x, y, label, icon, colour } = NODES[id];
const button = onOpen && {
role: "button", tabIndex: 0, "aria-label": `${label}: pokaż szczegóły`, onClick: onOpen, className: "cursor-pointer outline-offset-2 focus-visible:outline-2 focus-visible:outline-brand",
@@ -81,12 +81,14 @@ function Node({ id, value, note, onOpen }: { id: keyof typeof NODES; value: stri
};
return (
<g aria-hidden={onOpen ? undefined : true} {...button}>
<rect x={x - 78} y={y - 40} width={156} height={80} rx={18} className="fill-panel" stroke="var(--color-edge)" strokeWidth={1.5} />
<g transform={`translate(${x - 64} ${y - 26})`} color={colour}><Icon name={icon} size={28} /></g>
<text x={x - 28} y={y - 10} fontSize={14} className="scene-soft">{label}</text>
<text x={x - 28} y={y + 16} fontSize={22} fontWeight={700} className="scene-text">{value}</text>
{note && <text x={x - 28} y={y + 33} fontSize={12} className="scene-soft">{note}</text>}
{onOpen && <text x={x + 62} y={y + 8} fontSize={28} textAnchor="middle" className="scene-soft" aria-hidden="true">›</text>}
<rect x={x - 86} y={y - 44} width={172} height={88} rx={18} className="fill-panel" stroke="var(--color-edge)" strokeWidth={2} />
<g transform={`translate(${x - 76} ${y - 38})`} color={colour}><Icon name={icon} size={28} /></g>
<g opacity={dim ? 0.45 : 1}>
<text x={x - 40} y={y - 10} fontSize={16} className="scene-soft">{label}</text>
<text x={x - 40} y={y + 18} fontSize={26} fontWeight={700} className="scene-text">{value}</text>
{note && <text x={x - 40} y={y + 37} fontSize={14} className="scene-soft">{note}</text>}
</g>
{onOpen && <text x={x + 72} y={y + 10} fontSize={32} textAnchor="middle" className="scene-soft" aria-hidden="true">›</text>}
</g>
);
}
@@ -103,25 +105,33 @@ export function flowSummary(e: EnergyData) {
];
}
// A source without a fresh reading keeps its last value on the screen, so the value is dimmed.
function quietNodes(e: EnergyData): string[] {
if (isStale(e.time)) return Object.keys(NODES);
return silentInverters(e).flatMap(({ id }) => (id === "deye" ? ["pv", "battery"] : ["pv"]));
}
function FlowPicture({ e, onBattery }: { e: EnergyData; onBattery: () => void }) {
const quiet = quietNodes(e);
const dim = (id: keyof typeof NODES) => quiet.includes(id);
return (
<>
<ul className="sr-only">{flowSummary(e).map((t) => <li key={t}>{t}</li>)}</ul>
<svg viewBox="0 0 600 350" className="h-auto w-full">
<svg viewBox="0 0 380 400" className="mx-auto h-auto w-full max-w-md">
<Flow from="pv" to="house" watts={e.pvPowerW} />
<Flow from="grid" to="house" watts={e.gridPowerW} />
<Flow from="battery" to="house" watts={e.batteryPowerW} />
<Node id="pv" value={power(e.pvPowerW)} />
<Node id="grid" value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
<Node id="house" value={power(e.loadPowerW)} note="zużycie" />
<Node id="battery" onOpen={onBattery} value={percent(e.batterySocPct)} note={flowing(e.batteryPowerW) ? (e.batteryPowerW! > 0 ? "rozładowuje" : "ładuje") : undefined} />
<Node id="pv" dim={dim("pv")} value={power(e.pvPowerW)} />
<Node id="grid" dim={dim("grid")} value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
<Node id="house" dim={dim("house")} value={power(e.loadPowerW)} note="zużycie" />
<Node id="battery" dim={dim("battery")} onOpen={onBattery} value={percent(e.batterySocPct)} note={flowing(e.batteryPowerW) ? (e.batteryPowerW! > 0 ? "rozładowuje" : "ładuje") : undefined} />
</svg>
</>
);
}
// Today's use split by where it came from; the battery is charged from both, so "own" is what the grid did not give.
export function EnergyToday({ e }: { e: EnergyData | undefined }) {
export function EnergyToday({ e, link = false }: { e: EnergyData | undefined; link?: boolean }) {
const today = e?.today;
const load = today?.loadKwh ?? null;
const bought = today?.gridBoughtKwh ?? null;
@@ -131,19 +141,25 @@ export function EnergyToday({ e }: { e: EnergyData | undefined }) {
{ name: "Z sieci", value: bought ?? 0, colour: "#3b82f6" },
];
const partial = e ? silentInverters(e).length > 0 : false;
const aside = (
<span className="flex items-center gap-3">
{partial && <Pill tone="warn">Tylko część danych</Pill>}
{link && <a href="#energy" className="font-semibold text-brand">Energia ›</a>}
</span>
);
return (
<Card title="Energia – dziś" aside={partial ? <Pill tone="warn">Tylko część danych</Pill> : undefined}>
<Card title="Energia – dziś" aside={aside}>
{!e ? <Loading /> : (
<div className="flex flex-wrap items-center gap-5">
<div className="relative size-36 shrink-0">
<div className={`relative size-36 shrink-0 ${isStale(e?.time) ? "opacity-50" : ""}`}>
<PieChart width={144} height={144}>
<Pie data={parts} dataKey="value" innerRadius={50} outerRadius={68} startAngle={90} endAngle={-270}
<Pie data={parts} dataKey="value" innerRadius={54} outerRadius={68} startAngle={90} endAngle={-270}
stroke="none" isAnimationActive={false}>
{parts.map((p) => <Cell key={p.name} fill={p.colour} />)}
</Pie>
</PieChart>
<div className="absolute inset-0 grid place-items-center text-center">
<div><p className="text-xl font-bold tabular-nums">{kwh(load)}</p><p className="text-xs text-soft">zużycie</p></div>
<div><p className="text-lg font-bold tabular-nums">{kwh(load)}</p><p className="text-xs text-soft">zużycie</p></div>
</div>
</div>
<div className="min-w-44 flex-1 text-sm">
@@ -160,9 +176,9 @@ export function EnergyToday({ e }: { e: EnergyData | undefined }) {
);
}
function Stat({ icon, label, value, note }: { icon: IconName; label: string; value: string; note?: string }) {
function Stat({ icon, label, value, note, dim }: { icon: IconName; label: string; value: string; note?: string; dim: boolean }) {
return (
<Card>
<Card className={dim ? "opacity-60" : ""}>
<div className="flex items-center gap-3">
<span className="grid size-10 place-items-center rounded-xl bg-brand-soft text-brand"><Icon name={icon} /></span>
<div>
@@ -175,8 +191,26 @@ function Stat({ icon, label, value, note }: { icon: IconName; label: string; val
);
}
const reading = (d: EnergyDetailsDevice, group: string, label?: string) =>
d.groups.find((g) => g.title === group)?.items.filter((item) => !label || item.label === label) ?? [];
// A value wraps between its parts, never inside one.
const unbreakable = (text: string) => text.replaceAll(" ", "\u00a0");
/** The main current values of a device, from the details and, for the house meter, from the flow. */
export function deviceSummary(d: EnergyDetailsDevice, e: EnergyData | undefined): string {
if (d.id === "deye") {
const pv = reading(d, "Panele PV").reduce((sum, item) => sum + item.value, 0);
return [`Bateria ${percent(reading(d, "Bateria", "Naładowanie")[0]?.value)}`, `PV ${power(pv)}`].map(unbreakable).join(" · ");
}
if (d.id === "fronius") return `PV ${power(reading(d, "Teraz", "Moc PV")[0]?.value)}`;
if (d.id === "house-meter" && e) return `${power(magnitude(e.gridPowerW))} ${gridText(e.gridPowerW)}`;
return "";
}
function Devices() {
const { data } = usePoll<EnergyDetails>("api/energy/details");
const { data: energy } = usePoll<EnergyData>("api/energy");
const [openId, setOpenId] = useState<string | null>(null);
const close = useCallback(() => setOpenId(null), []);
const open = data?.devices.find((d) => d.id === openId);
@@ -184,7 +218,7 @@ function Devices() {
<Card title="Urządzenia">
{!data ? <Loading /> : (
<div className="grid gap-3 sm:grid-cols-3">
{data.devices.map((d) => <DetailsButton key={d.id} device={d} onOpen={() => setOpenId(d.id)} />)}
{data.devices.map((d) => <DetailsButton key={d.id} device={d} summary={deviceSummary(d, energy)} onOpen={() => setOpenId(d.id)} />)}
</div>
)}
{open && <DeviceDetails device={open} onClose={close} />}
@@ -196,6 +230,7 @@ function Now({ onBattery }: { onBattery: () => void }) {
const { data: e, offline } = usePoll<EnergyData>("api/energy");
if (!e) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
const missing = silentInverters(e);
const quiet = quietNodes(e);
return (
<>
<Freshness offline={offline} time={e.time} />
@@ -204,10 +239,10 @@ function Now({ onBattery }: { onBattery: () => void }) {
<Card title="Energia teraz" className="lg:col-span-2"><FlowPicture e={e} onBattery={onBattery} /></Card>
<EnergyToday e={e} />
<div className="grid gap-4 sm:grid-cols-2 lg:col-span-3 lg:grid-cols-4">
<Stat icon="sun" label="Produkcja PV" value={power(e.pvPowerW)} note={missing.length ? "tylko część: brakuje danych z falownika" : "Deye + Fronius"} />
<Stat icon="home" label="Zużycie domu" value={power(e.loadPowerW)} />
<Stat icon="plug" label="Sieć" value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
<Stat icon="battery" label="Bateria" value={percent(e.batterySocPct)} note={batteryText(e.batteryPowerW)} />
<Stat dim={quiet.includes("pv")} icon="sun" label="Produkcja PV" value={power(e.pvPowerW)} note={missing.length ? "tylko część: brakuje danych z falownika" : "Deye + Fronius"} />
<Stat dim={quiet.includes("house")} icon="home" label="Zużycie domu" value={power(e.loadPowerW)} />
<Stat dim={quiet.includes("grid")} icon="plug" label="Sieć" value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
<Stat dim={quiet.includes("battery")} icon="battery" label="Bateria" value={percent(e.batterySocPct)} note={batteryText(e.batteryPowerW)} />
</div>
</div>
</>
+1 -1
View File
@@ -53,7 +53,7 @@ describe("Statystyki", () => {
it("shows the totals, the starts as a minimum and the heater highlighted when it ran", async () => {
await open();
expect(await screen.findByText("co najmniej 1")).toBeInTheDocument();
expect(await screen.findByText("Załączenia sprężarki, co najmniej")).toBeInTheDocument();
expect(screen.getAllByText("0,2 h").length).toBeGreaterThan(0);
expect(screen.getByText("Grzałka").closest("[data-alert]")).toHaveAttribute("data-alert", "true");
expect(screen.getByText("Prąd z grzałki jest drogi")).toBeInTheDocument();
+2 -9
View File
@@ -2,7 +2,7 @@ import { useState } from "react";
import { number } from "./format";
import { usePoll } from "./poll";
import { Stats } from "./Stats";
import { SubViewPanel, SubViewSwitch, useSubView } from "./SubView";
import { RangeToggle, SubViewPanel, SubViewSwitch, useSubView } from "./SubView";
import { TimeChart, toRows, type Range } from "./TimeChart";
import type { History as HistoryData } from "./types";
import { Card, Freshness, Loading, PageTitle } from "./ui";
@@ -59,14 +59,7 @@ function Charts() {
className="min-h-12 flex-1 rounded-xl border-2 border-edge bg-panel px-3 font-semibold">
{Object.entries(SERIES).map(([id, [name]]) => <option key={id} value={id}>{name}</option>)}
</select>
<div className="flex gap-2 rounded-xl bg-page p-1">
{Object.entries(RANGES).map(([id, name]) => (
<button key={id} onClick={() => setRange(id as Range)} aria-pressed={id === range}
className="min-h-10 rounded-lg px-4 font-semibold text-soft aria-pressed:bg-panel aria-pressed:text-brand aria-pressed:shadow-sm">
{name}
</button>
))}
</div>
<RangeToggle label="Okres" options={RANGES} value={range} onSelect={setRange} />
</div>
<Chart series={series} range={range} />
</Card>
+49 -12
View File
@@ -1,9 +1,8 @@
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
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: [] };
@@ -19,29 +18,67 @@ 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")));
it("shows the four key figures once, with the living room as the home temperature", async () => {
serve({ "api/heat-pump": fresh(example("heat-pump")), "api/energy": example("energy"), "api/history": history,
"api/pi-sensor": example("pi-sensor"), "api/weather": example("weather") });
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");
const now = (await screen.findByRole("region", { name: "Teraz" }));
const [water, home, outdoor, power] = [...now.children];
expect(water).toHaveTextContent("Ciepła woda13,6 °Ccel 8,0 °C");
expect(home).toHaveTextContent("W domu20,2 °Cwilgotność 46 %");
expect(outdoor).toHaveTextContent("Na zewnątrz4,3 °CPochmurno");
expect(power).toHaveTextContent("Prąd");
expect(power).toHaveTextContent(/pobór|oddawanie|bez wymiany/);
});
it("says there is no reading when the sensor is stale", async () => {
it("falls back to the pump's room sensor when the living room has no reading", 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");
const home = (await screen.findByRole("region", { name: "Teraz" })).children[1];
expect(home).toHaveTextContent("W domu21,2 °Cczujnik pompy");
});
it("lists the living room and the other temperatures once in the details card", async () => {
serveHeatPump(fresh(example("heat-pump")));
const Overview = await overview();
render(<Overview />);
const card = (await screen.findByRole("heading", { name: "Szczegóły temperatur" })).closest("section")!;
expect(card).toHaveTextContent("Salon20,2 °C");
expect(card).toHaveTextContent("Wilgotność w salonie45,8 %");
expect(card).toHaveTextContent("Ciśnienie986 hPa");
expect(card).toHaveTextContent("Różnica1,7 °C");
expect(within(card).getAllByRole("heading", { level: 3 }).map((h) => h.textContent))
.toEqual(["Dom", "Woda i ogrzewanie", "Dolne źródło", "Nastawy"]);
});
it("says there is no reading in the details 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: "Szczegóły temperatur" })).closest("section")!;
expect(card).toHaveTextContent("Salonbrak odczytu");
expect(card).toHaveTextContent("Wilgotność w saloniebrak odczytu");
expect(card).toHaveTextContent("Ciśnieniebrak odczytu");
});
it("offers the four devices and a link to the Energia screen", async () => {
serveHeatPump(fresh(example("heat-pump")));
const Overview = await overview();
render(<Overview />);
const devices = await screen.findByRole("region", { name: "Stan urządzeń" });
expect(devices.children).toHaveLength(4);
expect(screen.getByRole("link", { name: "Energia ›" })).toHaveAttribute("href", "#energy");
});
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 } });
+81 -107
View File
@@ -1,13 +1,15 @@
import type { ReactNode } from "react";
import { celsius, degrees, hour, longDate, number, percent, power } from "./format";
import { celsius, hour, longDate, number, percent, power } from "./format";
import { Icon, type IconName } from "./icons";
import { flowing } from "./flow";
import { usePoll } from "./poll";
import { Schematic } from "./Schematic";
import { Legend, TimeChart, toRows, type Line } from "./TimeChart";
import type { Energy, HeatPump, History, PiSensor, PumpState } from "./types";
import { Card, Dot, Freshness, Loading, PageTitle, Pill, Row } from "./ui";
import type { Energy, HeatPump, History, PiSensor, PumpState, Weather } from "./types";
import { Card, Dot, Freshness, Loading, PageTitle, Pill } from "./ui";
import { EnergyToday, gridText } from "./Energy";
import { useWeather, WeatherCard, WeatherWarnings } from "./Weather";
import { TemperatureDetails } from "./Details";
import { useWeather, WeatherCard, WeatherIcon, WeatherWarnings } from "./Weather";
const HEADLINES: Record<PumpState, string> = {
IDLE: "Pompa odpoczywa",
@@ -38,49 +40,76 @@ function StatusPill({ hp }: { hp: HeatPump }) {
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 }: {
icon: IconName; title: string; value: ReactNode; note: string; status: ReactNode; children: ReactNode;
}) {
const running = (on: boolean | null, yes = "Pracuje") =>
on == null ? <Pill tone="off">Brak odczytu</Pill> : on ? <Pill tone="run" live>{yes}</Pill> : <Pill tone="off">Stoi</Pill>;
// One key figure; the number never wraps, so it is sized to fit two across a phone.
function Figure({ icon, label, value, note }: { icon: IconName; label: string; value: string; note: ReactNode }) {
return (
<Card className="flex flex-col">
<div className="flex items-start gap-3">
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-brand-soft text-brand">
<Icon name={icon} className="size-6" />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold">{title}</p>
<p className="text-2xl font-bold tabular-nums tracking-tight">{value}</p>
<p className="text-xs text-soft">{note}</p>
</div>
</div>
<div className="my-3">{status}</div>
<div className="mt-auto text-sm [&>div]:text-sm">{children}</div>
<Card className="min-w-0 p-3! sm:p-5!">
<p className="flex items-center gap-1.5 text-sm font-semibold text-soft"><Icon name={icon} className="size-4 shrink-0" />{label}</p>
<p className="mt-1 whitespace-nowrap text-[1.7rem] font-bold leading-tight tabular-nums tracking-tight sm:text-3xl">{value}</p>
<p className="mt-0.5 flex min-h-5 items-center gap-1.5 text-xs text-soft sm:text-sm">{note}</p>
</Card>
);
}
const running = (on: boolean | null, yes = "Pracuje") =>
on == null ? <Pill tone="off">Brak odczytu</Pill> : on ? <Pill tone="run" live>{yes}</Pill> : <Pill tone="off">Stoi</Pill>;
const gridNote = (watts: number | null | undefined) =>
watts == null ? "brak danych o sieci"
: flowing(watts) ? `${gridText(watts)} ${power(Math.abs(watts))}` : gridText(watts);
function Temperatures({ hp }: { hp: HeatPump }) {
const t = hp.temperatures;
const rows: [IconName, string, number | null, string][] = [
["thermometer", "Na zewnątrz", t.outdoor, "text-cold"],
["thermometer", "Średnia zewnętrzna", t.outdoorAvg, "text-cold"],
["home", "W domu", t.indoor, "text-brand"],
["heat", "Bufor CO", t.coBuffer, "text-hot"],
["drop", "Ciepła woda (CWU)", t.cwu, "text-hot"],
["fan", "Wyjście pompy", t.pumpOutlet, "text-hot"],
];
// The four figures the family checks first; each value is shown here and nowhere else on the screen.
function Now({ hp, e, pi, weather }: { hp: HeatPump; e: Energy | undefined; pi: PiSensor | undefined; weather: Weather | undefined }) {
const home = pi?.temperature ?? hp.temperatures.indoor;
const humidity = pi?.temperature == null ? null : pi.humidity;
return (
<Card title="Temperatury">
{rows.map(([icon, label, value, colour]) => (
<Row key={label} label={label} value={celsius(value)} icon={<span className={colour}><Icon name={icon} /></span>} />
))}
<section aria-label="Teraz" className="grid grid-cols-2 gap-3 sm:gap-4 lg:col-span-3 lg:grid-cols-4">
<Figure icon="drop" label="Ciepła woda" value={celsius(hp.temperatures.cwu)} note={`cel ${celsius(hp.targets.cwu)}`} />
<Figure icon="home" label="W domu" value={celsius(home)}
note={humidity == null ? "czujnik pompy" : `wilgotność ${number(humidity)} %`} />
<Figure icon="thermometer" label="Na zewnątrz" value={celsius(hp.temperatures.outdoor)}
note={weather && <><WeatherIcon code={weather.today.weatherCode} className="size-5" />{weather.today.description}</>} />
<Figure icon="sun" label="Prąd" value={power(e?.pvPowerW)} note={gridNote(e?.gridPowerW)} />
</section>
);
}
// A device on one row: what it is, what it does now, and the one thing only it knows.
function Tile({ icon, title, detail, value, status }: {
icon: IconName; title: string; detail: string; value?: string; status: ReactNode;
}) {
return (
<Card className="flex items-center gap-3 p-3! sm:p-5!">
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-brand-soft text-brand">
<Icon name={icon} className="size-6" />
</span>
<div className="min-w-0 flex-1">
<p className="font-semibold">{title}</p>
<p className="text-xs text-soft sm:text-sm">{detail}</p>
</div>
<div className="flex flex-col items-end gap-1">
{value && <p className="text-xl font-bold tabular-nums">{value}</p>}
{status}
</div>
</Card>
);
}
function DeviceTiles({ hp }: { hp: HeatPump }) {
return (
<section aria-label="Stan urządzeń" className="grid gap-3 sm:grid-cols-2 sm:gap-4">
<Tile icon="fan" title="Pompa ciepła" detail={`sprężarka, grzałka ${onOff(hp.heaterOn, "włączona", "wyłączona")}`}
value={hp.compressorPercent == null ? "—" : `${hp.compressorPercent} %`}
status={running(hp.compressorPercent == null ? null : hp.compressorPercent > 0)} />
<Tile icon="tank" title="Zbiornik CWU" detail={`cyrkulacja ${onOff(hp.pumps.circulation, "pracuje", "stoi")}`}
status={running(hp.pumps.cwu, "Grzeje wodę")} />
<Tile icon="heat" title="Ogrzewanie CO" detail={`pompa bufora ${onOff(hp.pumps.buffer, "pracuje", "stoi")}`}
status={running(hp.pumps.co, "Grzeje dom")} />
<Tile icon="borehole" title="Odwierty" detail="dolne źródło ciepła" status={running(hp.pumps.ground)} />
</section>
);
}
// 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;
@@ -107,37 +136,6 @@ function Devices({ hp }: { hp: HeatPump }) {
);
}
function Targets({ hp }: { hp: HeatPump }) {
const s = hp.setpoints;
return (
<Card title="Cele i nastawy">
<Row label="Pokój, cel teraz" value={celsius(hp.targets.room)} />
<Row label="Woda grzewcza CO, cel" value={celsius(hp.targets.co)} />
<Row label="Ciepła woda CWU, cel" value={celsius(hp.targets.cwu)} />
<Row label="CWU oszczędnie · komfort" value={`${degrees(s.cwuEconomy)} · ${degrees(s.cwuComfort)}`} />
<Row label="CO oszczędnie · komfort" value={`${degrees(s.coEconomy)} · ${degrees(s.coComfort)}`} />
</Card>
);
}
// 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);
@@ -172,6 +170,7 @@ function NoReading() {
export function Overview() {
const { data: hp, offline } = usePoll<HeatPump>("api/heat-pump");
const { data: e } = usePoll<Energy>("api/energy");
const { data: pi } = usePoll<PiSensor>("api/pi-sensor");
const weather = useWeather();
const now = hp?.time ? new Date(hp.time) : null;
const title = (
@@ -193,48 +192,23 @@ export function Overview() {
{weather && <WeatherWarnings warnings={weather.warnings} />}
{hp.time == null ? <NoReading /> : (
<div className="grid gap-4 lg:grid-cols-3">
<Card title="Schemat instalacji" aside={<StatusPill hp={hp} />} className="lg:col-span-2 lg:row-span-2">
<Schematic hp={hp} pvPowerW={e?.pvPowerW} />
<Devices hp={hp} />
<p className="mt-3 text-xs text-soft">
Rury płyną, gdy pracuje pompa obiegu, a stojące są szare. Kolektor słoneczny nie ma czujnika, więc nie wiemy, czy jego obieg pracuje.
</p>
</Card>
<Temperatures hp={hp} />
<Targets hp={hp} />
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:col-span-3 xl:grid-cols-5">
<Tile icon="fan" title="Pompa ciepła" note="sprężarka"
value={hp.compressorPercent == null ? "—" : `${hp.compressorPercent} %`}
status={running(hp.compressorPercent == null ? null : hp.compressorPercent > 0)}>
<Row label="Wyjście" value={celsius(hp.temperatures.pumpOutlet)} />
<Row label="Grzałka" value={onOff(hp.heaterOn, "włączona", "wyłączona")} />
</Tile>
<Tile icon="drop" title="Zbiornik CWU" note="ciepła woda" value={celsius(hp.temperatures.cwu)}
status={running(hp.pumps.cwu, "Grzeje wodę")}>
<Row label="Cel teraz" value={celsius(hp.targets.cwu)} />
<Row label="Cyrkulacja" value={onOff(hp.pumps.circulation, "pracuje", "stoi")} />
</Tile>
<Tile icon="heat" title="Ogrzewanie CO" note="bufor" value={celsius(hp.temperatures.coBuffer)}
status={running(hp.pumps.co, "Grzeje dom")}>
<Row label="Cel bufora" value={celsius(hp.targets.co)} />
<Row label="Pokój" value={celsius(hp.temperatures.indoor)} />
</Tile>
<Tile icon="borehole" title="Odwierty" note="do pompy" value={celsius(hp.temperatures.groundIn)}
status={running(hp.pumps.ground)}>
<Row label="Z pompy" value={celsius(hp.temperatures.groundOut)} />
<Row label="Różnica" value={hp.temperatures.groundIn != null && hp.temperatures.groundOut != null
? celsius(hp.temperatures.groundIn - hp.temperatures.groundOut) : "—"} />
</Tile>
<Tile icon="sun" title="Prąd teraz" note="produkcja PV" value={power(e?.pvPowerW)}
status={e ? <Pill tone={(e.gridPowerW ?? 0) < 0 ? "ok" : "off"}>Sieć: {gridText(e.gridPowerW)}</Pill> : <Pill tone="off">Brak danych</Pill>}>
<Row label="Zużycie domu" value={power(e?.loadPowerW)} />
<Row label="Bateria" value={percent(e?.batterySocPct)} />
</Tile>
<Now hp={hp} e={e} pi={pi} weather={weather} />
<div className="grid content-start gap-4 lg:col-span-2">
<Card title="Schemat instalacji" aside={<StatusPill hp={hp} />}>
<Schematic hp={hp} pvPowerW={e?.pvPowerW} />
<Devices hp={hp} />
<p className="mt-3 text-xs text-soft">
Rury płyną, gdy pracuje pompa obiegu, a stojące są szare. Kolektor słoneczny nie ma czujnika, więc nie wiemy, czy jego obieg pracuje.
</p>
</Card>
<DeviceTiles hp={hp} />
</div>
{weather && <WeatherCard weather={weather} />}
<div className="grid content-start gap-4">
{weather && <WeatherCard weather={weather} />}
<TemperatureDetails hp={hp} pi={pi} />
</div>
<EnergyToday e={e} link />
<TemperatureChart />
<PiSensorCard />
<EnergyToday e={e} />
</div>
)}
</>
+28 -19
View File
@@ -14,12 +14,21 @@ const paragraph = (text: string) => (_: string, el: Element | null) => el?.tagNa
const posts = (fetch: ReturnType<typeof serve>) => fetch.mock.calls.filter(([, init]) => init?.method === "POST");
describe("Settings", () => {
it("describes each quick action in one line", async () => {
serve({ "api/setpoints": example("setpoints") });
const Settings = await settings();
render(<Settings />);
expect(await screen.findByText("Ciepła woda – oszczędnie: 45 °C na 2 godz.")).toBeInTheDocument();
expect(screen.getByText("Obniża temperatury na czas nieobecności, do dnia powrotu")).toBeInTheDocument();
});
it("writes only after the summary is confirmed", async () => {
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-applied")));
const Settings = await settings();
render(<Settings />);
await userEvent.click(await screen.findByLabelText("Więcej: Ciepła woda, oszczędnie"));
await userEvent.click(await screen.findByLabelText("Więcej: Ciepła woda – oszczędnie"));
await userEvent.click(screen.getByRole("button", { name: "Zapisz" }));
expect(posts(fetch)).toHaveLength(0);
expect(screen.getByText("Ustawię 9 °C. Za 2 godz. wrócimy do 8 °C.")).toBeInTheDocument();
@@ -35,7 +44,7 @@ describe("Settings", () => {
const Settings = await settings();
render(<Settings />);
await userEvent.click(await screen.findByLabelText("Mniej: Ogrzewanie domu, komfort"));
await userEvent.click(await screen.findByLabelText("Mniej: Ogrzewanie – komfort"));
await userEvent.click(screen.getByRole("button", { name: "Zapisz" }));
await userEvent.click(screen.getByRole("button", { name: "Tak, zapisz" }));
@@ -48,7 +57,7 @@ describe("Settings", () => {
const Settings = await settings();
render(<Settings />);
await userEvent.click(await screen.findByLabelText("Więcej: Ciepła woda, komfort"));
await userEvent.click(await screen.findByLabelText("Więcej: Ciepła woda – komfort"));
await userEvent.click(screen.getByRole("button", { name: "Zapisz" }));
await userEvent.click(screen.getByRole("button", { name: "Tak, zapisz" }));
@@ -61,15 +70,15 @@ describe("Settings", () => {
render(<Settings />);
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
expect(screen.getByText(paragraph("Ciepła woda, komfort: 10 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
expect(screen.getByText(paragraph("Ciepła woda – komfort: 10 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
expect(posts(fetch)).toHaveLength(0);
await userEvent.click(screen.getByRole("button", { name: "Ustaw wyjazd" }));
expect(posts(fetch)[0][0]).toBe("api/presets/away");
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Tylko część zmian jest zapisana.");
expect(alert).toHaveTextContent("Ciepła woda, komfort: zapisano");
expect(alert).toHaveTextContent("Ogrzewanie domu, komfort: brak odpowiedzi, nie wiadomo, czy doszło do pompy");
expect(alert).toHaveTextContent("Ciepła woda – komfort: zapisano");
expect(alert).toHaveTextContent("Ogrzewanie – komfort: brak odpowiedzi, nie wiadomo, czy doszło do pompy");
});
it("asks for the return time of a trip and sends the chosen time with the local offset", async () => {
@@ -107,10 +116,10 @@ describe("Settings", () => {
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
expect(screen.getAllByRole("status").map((o) => o.textContent)).toEqual(["10 °C", "15 °C"]);
await userEvent.click(screen.getByLabelText("Więcej: Ciepła woda, komfort"));
await userEvent.click(screen.getByLabelText("Więcej: Ciepła woda, komfort"));
await userEvent.click(screen.getByLabelText("Mniej: Ogrzewanie domu, komfort"));
expect(screen.getByText(paragraph("Ciepła woda, komfort: 12 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
await userEvent.click(screen.getByLabelText("Więcej: Ciepła woda – komfort"));
await userEvent.click(screen.getByLabelText("Więcej: Ciepła woda – komfort"));
await userEvent.click(screen.getByLabelText("Mniej: Ogrzewanie – komfort"));
expect(screen.getByText(paragraph("Ciepła woda – komfort: 12 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Ustaw wyjazd" }));
const body = JSON.parse(String(posts(fetch)[0][1]!.body));
@@ -125,8 +134,8 @@ describe("Settings", () => {
render(<Settings />);
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
expect(screen.getByLabelText("Więcej: Ogrzewanie domu, komfort")).toBeDisabled();
expect(screen.getByText(paragraph("Ogrzewanie domu, komfort: 14 °C do powrotu, potem wróci 15 °C."))).toBeInTheDocument();
expect(screen.getByLabelText("Więcej: Ogrzewanie – komfort")).toBeDisabled();
expect(screen.getByText(paragraph("Ogrzewanie – komfort: 14 °C do powrotu, potem wróci 15 °C."))).toBeInTheDocument();
});
it("disables a trip temperature that cannot be lowered and the confirmation with it", async () => {
@@ -137,8 +146,8 @@ describe("Settings", () => {
render(<Settings />);
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
expect(screen.getByLabelText("Mniej: Ogrzewanie domu, komfort")).toBeDisabled();
expect(screen.getByLabelText("Więcej: Ogrzewanie domu, komfort")).toBeDisabled();
expect(screen.getByLabelText("Mniej: Ogrzewanie – komfort")).toBeDisabled();
expect(screen.getByLabelText("Więcej: Ogrzewanie – komfort")).toBeDisabled();
expect(screen.getByText("Już na minimum (5 °C), nie da się obniżyć.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Ustaw wyjazd" })).toBeDisabled();
});
@@ -189,7 +198,7 @@ describe("Settings", () => {
const Settings = await settings();
render(<Settings />);
expect(await screen.findByText(paragraph("Ciepła woda, komfort: 10 °C do 2 lis, 18:00, potem 43 °C."))).toBeInTheDocument();
expect(await screen.findByText(paragraph("Ciepła woda – komfort: 10 °C do 2 lis, 18:00, potem 43 °C."))).toBeInTheDocument();
});
it("labels a change without an end time as lasting while the server runs", async () => {
@@ -200,7 +209,7 @@ describe("Settings", () => {
render(<Settings />);
expect(
await screen.findByText(paragraph("Ciepła woda, komfort: 10 °C do odwołania (dopóki serwer działa), potem 45 °C.")),
await screen.findByText(paragraph("Ciepła woda – komfort: 10 °C do odwołania (dopóki serwer działa), potem 45 °C.")),
).toBeInTheDocument();
});
@@ -211,13 +220,13 @@ describe("Settings", () => {
const Settings = await settings();
render(<Settings />);
expect(await screen.findByLabelText("Więcej: Ciepła woda, oszczędnie")).toBeDisabled();
expect(screen.getByLabelText("Mniej: Ciepła woda, oszczędnie")).toBeDisabled();
expect(await screen.findByLabelText("Więcej: Ciepła woda – oszczędnie")).toBeDisabled();
expect(screen.getByLabelText("Mniej: Ciepła woda – oszczędnie")).toBeDisabled();
expect(screen.getByRole("button", { name: "Ciepła woda teraz (brak odczytu)" })).toBeDisabled();
});
describe("restore now", () => {
const restoreButton = () => screen.findByRole("button", { name: "Przywróć teraz: Ciepła woda, oszczędnie" });
const restoreButton = () => screen.findByRole("button", { name: "Przywróć teraz: Ciepła woda – oszczędnie" });
const restoreAnswer = (outcome: string, status: number) => () => answer({ outcome, message: "x" }, status);
it("asks first, then restores and reloads the state", async () => {
+18 -7
View File
@@ -8,10 +8,10 @@ import { Button, Card, Freshness, Loading, PageTitle, Stepper } from "./ui";
const URL = "api/setpoints";
const NAMES: Record<string, string> = {
cwu_economy: "Ciepła woda, oszczędnie",
cwu_comfort: "Ciepła woda, komfort",
co_economy: "Ogrzewanie domu, oszczędnie",
co_comfort: "Ogrzewanie domu, komfort",
cwu_economy: "Ciepła woda – oszczędnie",
cwu_comfort: "Ciepła woda – komfort",
co_economy: "Ogrzewanie – oszczędnie",
co_comfort: "Ogrzewanie – komfort",
};
const DURATIONS = [
{ hours: 1, label: "1 godz." }, { hours: 2, label: "2 godz." },
@@ -144,6 +144,11 @@ function ReturnForm({ data, preset, busy, onCancel, onConfirm }: {
);
}
// What a preset does, in one line under its button; a held preset has no fixed values (the user picks them).
const presetHint = (p: Preset) => p.until
? "Obniża temperatury na czas nieobecności, do dnia powrotu"
: p.changes.map((c) => `${name(c.setpoint)}: ${degrees(c.value)}${c.hours == null ? " do odwołania" : ` na ${duration(c.hours)}`}`).join(", ");
function changeSentence(data: Setpoints, c: Change, held: boolean) {
const back = degrees(returnValue(data, c.setpoint));
const tail = held ? `do powrotu, potem wróci ${back}`
@@ -284,15 +289,21 @@ export function Settings() {
{data.presets.map((p) => {
const ready = presetReady(data, p.changes);
return (
<Button key={p.id} big onClick={() => { setResult(null); setPresetId(p.id); }} disabled={busy || !ready}>
{p.name}{ready ? "" : " (brak odczytu)"}
</Button>
<div key={p.id} className="flex flex-col gap-1.5">
<Button big onClick={() => { setResult(null); setPresetId(p.id); }} disabled={busy || !ready}>
{p.name}{ready ? "" : " (brak odczytu)"}
</Button>
<p className="text-center text-sm text-soft">{presetHint(p)}</p>
</div>
);
})}
</div>
</Card>
</div>
<Card title="Nastawy">
<p className="mb-3 text-sm text-soft">
„Komfort” i „oszczędnie” to dwie temperatury, między którymi pompa przełącza się sama według tygodniowego programu na sterowniku.
</p>
{data.setpoints.map((s) => {
const shown = draft?.name === s.name ? draft.value : s.value;
const label = name(s.name);
+32 -19
View File
@@ -3,11 +3,13 @@ import { Bar, CartesianGrid, ComposedChart, Line, ResponsiveContainer, Tooltip,
import { DASH, degrees, number } from "./format";
import { usePoll } from "./poll";
import type { Stats as StatsData, StatsDay } from "./types";
import { RangeToggle } from "./SubView";
import { Card, Freshness, Loading } from "./ui";
const RANGES = { "7d": "7 dni", "30d": "30 dni" } as const;
type RangeId = keyof typeof RANGES;
const STATS_MS = 5 * 60000; // the backend reads every raw heat pump row of the range for each request
const TAKTING_STARTS_PER_DAY = 24;
const BAR = "#0f8a5f";
const TEMPERATURE = "#3b82f6";
@@ -26,12 +28,14 @@ export function takting(days: StatsDay[]) {
return days.some((d) => (d.compressorStarts ?? 0) > TAKTING_STARTS_PER_DAY);
}
function Tile({ label, value, hint, alert = false }: { label: string; value: string; hint?: string; alert?: boolean }) {
function Tile({ label, value, hint, alert = false, wide = false }: {
label: string; value: string; hint?: string; alert?: boolean; wide?: boolean;
}) {
return (
<div data-alert={alert} className={`rounded-xl border p-3 ${alert ? "border-alarm bg-run-soft" : "border-edge bg-page"}`}>
<div data-alert={alert} className={`rounded-xl border p-3 ${wide ? "col-span-2 lg:col-span-1" : ""} ${alert ? "border-alarm bg-run-soft" : "border-edge bg-page"}`}>
<p className="text-2xl font-bold tabular-nums">{value}</p>
<p className="text-sm text-soft">{label}</p>
{hint && <p className="text-xs text-soft">{hint}</p>}
{hint && <p className="text-sm text-soft">{hint}</p>}
</div>
);
}
@@ -41,27 +45,43 @@ function Totals({ stats }: { stats: StatsData }) {
return (
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-5">
<Tile label="Sprężarka" value={hours(t.compressorHours)} />
<Tile label="Załączenia sprężarki" value={`co najmniej ${number(t.compressorStarts)}`} />
<Tile label="Załączenia sprężarki, co najmniej" value={number(t.compressorStarts)} />
<Tile label="Ciepła woda (CWU)" value={hours(t.cwuHours)} />
<Tile label="Ogrzewanie (CO)" value={hours(t.coHours)} />
<Tile label="Grzałka" value={hours(t.heaterHours)} alert={t.heaterHours > 0}
<Tile label="Grzałka" value={hours(t.heaterHours)} wide alert={t.heaterHours > 0}
hint={t.heaterHours > 0 ? "Prąd z grzałki jest drogi" : undefined} />
</div>
);
}
function ChartLegend() {
return (
<ul className="mb-2 flex flex-wrap gap-x-4 gap-y-1 text-sm text-soft">
<li className="flex items-center gap-1.5">
<span className="size-3 rounded-sm" style={{ background: BAR }} aria-hidden="true" />słupki: godziny pracy sprężarki
</li>
<li className="flex items-center gap-1.5">
<span className="h-1 w-4 rounded-full" style={{ background: TEMPERATURE }} aria-hidden="true" />linia: średnia temp. zewnętrzna
</li>
</ul>
);
}
function DailyChart({ days }: { days: StatsDay[] }) {
const rows = days.map((d) => ({ label: dayLabel(d.date), hours: d.compressorHours, outdoor: d.avgOutdoorTemp }));
return (
<div style={{ height: 280 }} className="w-full" role="img" aria-label="Godziny pracy sprężarki w kolejnych dniach">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={rows} margin={{ top: 8, right: 0, bottom: 0, left: -8 }}>
<ComposedChart data={rows} margin={{ top: 8, right: 0, bottom: 0, left: 0 }}>
<CartesianGrid stroke="var(--color-edge)" vertical={false} />
<XAxis dataKey="label" stroke="var(--color-soft)" fontSize={12} tickLine={false} minTickGap={12} />
<YAxis yAxisId="hours" stroke="var(--color-soft)" fontSize={12} tickLine={false} axisLine={false} width={40}
domain={[0, "auto"]} tickFormatter={(v: number) => number(v, 0)} />
<YAxis yAxisId="hours" stroke="var(--color-soft)" fontSize={12} tickLine={false} axisLine={false} width={44}
domain={[0, (max: number) => Math.ceil(max / 2) * 2]} tickCount={5} allowDecimals={false}
tickFormatter={(v: number) => number(v, 0)}
label={{ value: "godz. pracy (h)", angle: -90, position: "insideLeft", offset: 4, fill: "var(--color-soft)", fontSize: 12 }} />
<YAxis yAxisId="temperature" orientation="right" stroke="var(--color-soft)" fontSize={12} tickLine={false}
axisLine={false} width={40} tickFormatter={(v: number) => number(v, 0)} />
axisLine={false} width={44} allowDecimals={false} tickFormatter={(v: number) => number(v, 0)}
label={{ value: "°C", angle: 90, position: "insideRight", offset: 4, fill: "var(--color-soft)", fontSize: 12 }} />
<Tooltip
formatter={(v, name) => [name === "Średnia temperatura zewnętrzna" ? degrees(Number(v)) : hours(Number(v)), name]}
contentStyle={{ background: "var(--color-panel)", border: "1px solid var(--color-edge)", borderRadius: 12 }}
@@ -77,7 +97,7 @@ function DailyChart({ days }: { days: StatsDay[] }) {
function StatsFor({ range }: { range: RangeId }) {
// One poller per range, so an answer is never shown for the other one.
const { data, offline } = usePoll<StatsData>(`api/stats?range=${range}`);
const { data, offline } = usePoll<StatsData>(`api/stats?range=${range}`, STATS_MS);
if (!data) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
const withData = data.days.filter((d) => d.compressorHours != null);
return (
@@ -91,9 +111,9 @@ function StatsFor({ range }: { range: RangeId }) {
Dużo załączeń – sprężarka może taktować, czyli często się włączać i wyłączać na krótko. To zużywa ją szybciej.
</p>
)}
<ChartLegend />
<DailyChart days={data.days} />
<p className="mt-2 text-sm text-soft">
Zielone słupki: godziny pracy sprężarki, niebieska linia: średnia temperatura na zewnątrz.
Wartości liczone z pomiarów co 5 minut, więc załączenia są policzone „co najmniej”: krótkie cykle między pomiarami są niewidoczne.
</p>
</>
@@ -106,14 +126,7 @@ export function Stats() {
const [range, setRange] = useState<RangeId>("7d");
return (
<Card>
<div className="mb-4 flex gap-2 rounded-xl bg-page p-1 sm:w-fit">
{(Object.entries(RANGES) as [RangeId, string][]).map(([id, name]) => (
<button key={id} onClick={() => setRange(id)} aria-pressed={id === range}
className="min-h-10 flex-1 rounded-lg px-4 font-semibold text-soft aria-pressed:bg-panel aria-pressed:text-brand aria-pressed:shadow-sm">
{name}
</button>
))}
</div>
<div className="mb-4"><RangeToggle label="Okres" options={RANGES} value={range} onSelect={setRange} /></div>
<StatsFor range={range} />
</Card>
);
+23
View File
@@ -27,4 +27,27 @@ describe("Status", () => {
expect(versions()).toHaveTextContent("Bramkanieznana");
expect(versions()).toHaveTextContent("Serwerv1.3.0");
});
it("does not call a device working when its last reading is old", async () => {
const health = example<Health>("health");
const pump = health.devices[0];
health.devices[0] = { ...pump, lastSuccess: new Date(Date.now() - 17 * 3600_000).toISOString() };
serve({ "api/health": health });
const Status = await status();
render(<Status />);
expect(await screen.findByText("brak świeżych danych")).toBeInTheDocument();
expect(screen.queryByText("działa")).toBeNull();
});
it("says a device works when it answered a moment ago", async () => {
const health = example<Health>("health");
health.devices[0] = { ...health.devices[0], lastSuccess: new Date().toISOString() };
serve({ "api/health": health });
const Status = await status();
render(<Status />);
expect(await screen.findByText("działa")).toBeInTheDocument();
expect(screen.queryByText("brak świeżych danych")).toBeNull();
});
});
+8 -3
View File
@@ -1,7 +1,8 @@
import { ago } from "./format";
import { Icon } from "./icons";
import { usePoll } from "./poll";
import type { Health } from "./types";
import { Card, Dot, Freshness, Loading, PageTitle, Row } from "./ui";
import { Card, Dot, Freshness, isStale, Loading, PageTitle, Row } from "./ui";
const DEVICE_NAMES: Record<string, string> = {
"heat-pump": "Pompa ciepła",
@@ -30,11 +31,15 @@ export function Status() {
{data.devices.length === 0 ? <p className="text-soft">Lista pojawi się, gdy dom odpowie.</p>
: data.devices.map((d) => {
const last = d.lastSuccess ? `ostatni odczyt ${ago(d.lastSuccess)}` : "brak odczytu";
// "Up" says the last try worked; a reading that old is no longer news.
const old = d.up && d.lastSuccess != null && isStale(d.lastSuccess);
return (
<Row key={d.name} icon={<Dot tone={d.up ? "ok" : "bad"} />}
<Row key={d.name} icon={<Dot tone={d.up ? (old ? "warn" : "ok") : "bad"} />}
label={<span><span className="text-ink">{DEVICE_NAMES[d.name] ?? d.name}</span><br />
<span className="text-xs">{d.up ? last : `nie odpowiada, ${last}`}</span></span>}
value={<span className={d.up ? "text-brand" : "text-alarm"}>{d.up ? "działa" : "brak"}</span>} />
value={d.up ? (old ? <span className="inline-flex items-center gap-1 text-amber-700 dark:text-amber-300">
<Icon name="alert" className="size-4" />brak świeżych danych</span> : <span className="text-brand">działa</span>)
: <span className="text-alarm">brak</span>} />
);
})}
</Card>
+16
View File
@@ -41,3 +41,19 @@ export function SubViewSwitch<T extends string>({ screen, label, views, view, on
export function SubViewPanel({ screen, view, children }: { screen: string; view: string; children: ReactNode }) {
return <div role="tabpanel" id={`${screen}-view`} aria-labelledby={`${screen}-tab-${view}`}>{children}</div>;
}
/** Two or three choices of one setting (a range); the chosen one is filled so it reads at a glance. */
export function RangeToggle<T extends string>({ label, options, value, onSelect }: {
label: string; options: Record<T, string>; value: T; onSelect: (id: T) => void;
}) {
return (
<div role="group" aria-label={label} className="flex w-full gap-1 rounded-xl border border-edge bg-page p-1 sm:w-fit">
{(Object.entries(options) as [T, string][]).map(([id, name]) => (
<button key={id} onClick={() => onSelect(id)} aria-pressed={id === value}
className="min-h-11 min-w-28 flex-1 whitespace-nowrap rounded-lg px-4 font-semibold text-soft focus-visible:outline-2 focus-visible:outline-brand aria-pressed:bg-brand aria-pressed:text-white dark:aria-pressed:text-black">
{name}
</button>
))}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { timeTicks, valueTicks } from "./TimeChart";
const HOUR = 3600000;
const start = Date.parse("2026-10-23T00:00:00+02:00");
describe("timeTicks", () => {
it("puts 4 to 6 ticks on a day, at round hours", () => {
const ticks = timeTicks(start + HOUR / 2, start + 24 * HOUR, "24h");
expect(ticks.length).toBeGreaterThanOrEqual(4);
expect(ticks.length).toBeLessThanOrEqual(6);
expect(ticks.map((t) => ((t - start) / HOUR) % 6)).toEqual(ticks.map(() => 0));
});
it("uses finer steps for a short span", () => {
expect(timeTicks(start, start + 2 * HOUR, "24h")).toHaveLength(3);
});
it("puts a tick on every midnight of 7 days (the clocks change on the 25th)", () => {
expect(timeTicks(start + HOUR, start + 7 * 24 * HOUR, "7d")).toHaveLength(6);
});
});
describe("valueTicks", () => {
it("hugs the data with round steps", () => {
expect(valueTicks(14.1, 44.2)).toEqual([10, 20, 30, 40, 50]);
});
it("copes with flat data", () => {
expect(valueTicks(5, 5)).toEqual([4, 6]);
});
});
+25 -9
View File
@@ -5,7 +5,6 @@ import type { Point } from "./types";
export type Range = "24h" | "7d";
const HOUR_MS = 3600000;
const HOURS_BETWEEN_TICKS = 6;
const GAP_FACTOR = 3; // of the median step between points
const DAY_NAMES = ["nd", "pn", "wt", "śr", "cz", "pt", "sb"];
@@ -34,14 +33,28 @@ export function toRows(series: Record<string, Point[]>): Row[] {
return sorted.flatMap((row, i) => (i && row.t - sorted[i - 1].t > limit ? [{ t: row.t - 1 }, row] : [row]));
}
// Warsaw midnights (or every 6th full Warsaw hour) inside the range, so the axis reads like a clock.
const HOUR_STEPS = [1, 2, 3, 6, 12];
const MAX_TICKS = 6;
// Warsaw midnights (7 days) or the finest whole-hour step with at most 6 ticks, so the axis reads like a clock.
export function timeTicks(t0: number, t1: number, range: Range): number[] {
const ticks = [];
for (let t = Math.ceil(t0 / HOUR_MS) * HOUR_MS; t <= t1; t += HOUR_MS) {
const h = warsaw(t).hour;
if (range === "7d" ? h === 0 : h % HOURS_BETWEEN_TICKS === 0) ticks.push(t);
}
return ticks;
const wholeHours: { t: number; h: number }[] = [];
for (let t = Math.ceil(t0 / HOUR_MS) * HOUR_MS; t <= t1; t += HOUR_MS) wholeHours.push({ t, h: warsaw(t).hour });
if (range === "7d") return wholeHours.filter(({ h }) => h === 0).map(({ t }) => t);
const ticksAt = (step: number) => wholeHours.filter(({ h }) => h % step === 0).map(({ t }) => t);
const step = HOUR_STEPS.find((s) => ticksAt(s).length <= MAX_TICKS) ?? HOUR_STEPS[HOUR_STEPS.length - 1];
return ticksAt(step);
}
/** Round values around the data (steps of 1, 2 or 5 times a power of ten), about 4 intervals. */
export function valueTicks(min: number, max: number): number[] {
const span = max - min || Math.abs(max) || 1;
const rough = span / 4;
const magnitude = 10 ** Math.floor(Math.log10(rough));
const step = ([1, 2, 5, 10].find((m) => m * magnitude >= rough) ?? 10) * magnitude;
const first = Math.floor(min / step);
const last = Math.ceil(max / step);
return Array.from({ length: last - first + 1 }, (_, i) => Number(((first + i) * step).toPrecision(12)));
}
export interface Line {
@@ -56,6 +69,8 @@ export function TimeChart({ rows, lines, unit, range, height = 260, area = false
}) {
const times = rows.map((r) => r.t);
const ticks = timeTicks(Math.min(...times), Math.max(...times), range);
const values = rows.flatMap((r) => lines.map((l) => r[l.key])).filter((v): v is number => v != null);
const valueAxis = valueTicks(Math.min(...values), Math.max(...values));
const digits = unit === "°C" ? 1 : 0;
const tick = (t: number) => (range === "7d" ? DAY_NAMES[warsaw(t + 1).weekday] : hour(new Date(t)));
return (
@@ -74,7 +89,8 @@ export function TimeChart({ rows, lines, unit, range, height = 260, area = false
<XAxis dataKey="t" type="number" scale="time" domain={["dataMin", "dataMax"]} ticks={ticks}
tickFormatter={tick} stroke="var(--color-soft)" fontSize={12} tickLine={false} />
<YAxis stroke="var(--color-soft)" fontSize={12} tickLine={false} axisLine={false} width={48}
domain={["auto", "auto"]} tickFormatter={(v: number) => number(v, 0)} />
ticks={valueAxis} domain={[valueAxis[0], valueAxis[valueAxis.length - 1]]}
tickFormatter={(v: number) => number(v, valueAxis.every(Number.isInteger) ? 0 : 1)} />
<Tooltip
labelFormatter={(t) => dayAndHour(new Date(Number(t)))}
formatter={(v, name) => [`${number(Number(v), digits)} ${unit}`, name]}
+22 -10
View File
@@ -1,4 +1,5 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it } from "vitest";
import { example, serve } from "./fixtures";
import type { HeatPump, Weather } from "./types";
@@ -25,7 +26,6 @@ describe("Pogoda card", () => {
const card = (await screen.findByRole("heading", { name: "Pogoda" })).closest("section")!;
const [today, tomorrow, dayAfter] = within(card).getAllByRole("listitem");
expect(card).toHaveTextContent("teraz 10,6 °C");
expect(today).toHaveTextContent("Dziś");
expect(today).toHaveTextContent("Pochmurno");
expect(today).toHaveTextContent("10,9 °C / 3,8 °C");
@@ -64,27 +64,39 @@ describe("Pogoda card", () => {
const { Overview } = await import("./Overview");
render(<Overview />);
await screen.findByRole("heading", { name: "Salon" });
await screen.findByRole("heading", { name: "Szczegóły temperatur" });
expect(screen.queryByRole("heading", { name: "Pogoda" })).not.toBeInTheDocument();
});
});
describe("IMGW warnings on the overview", () => {
it("shows each warning as a banner with its degree, period and text", async () => {
it("shows each warning as one line with its degree and period, and the text on a tap", async () => {
await overview();
const banner = await screen.findByText(/Ostrzeżenie IMGW, stopień 2: Silny wiatr/);
expect(banner.closest("[role=alert]")).toHaveTextContent("Prognozuje się wystąpienie silnego wiatru");
expect(banner.closest("[role=alert]")).toHaveClass("border-orange-500");
const line = await screen.findByText(/Ostrzeżenie IMGW, stopień 2: Silny wiatr/);
const banner = line.closest("details")!;
expect(banner).toHaveClass("border-orange-500");
expect(banner).not.toHaveAttribute("open");
await userEvent.click(line);
expect(banner).toHaveAttribute("open");
expect(banner).toHaveTextContent("Prognozuje się wystąpienie silnego wiatru");
});
it("colours the degrees yellow, orange and red", async () => {
await overview({ warnings: [warning("Mróz", 1), warning("Upał", 2), warning("Burza", 3)] });
await screen.findByText(/stopień 3: Burza/);
expect(screen.getByText(/stopień 1: Mróz/).closest("[role=alert]")).toHaveClass("border-yellow-500");
expect(screen.getByText(/stopień 2: Upał/).closest("[role=alert]")).toHaveClass("border-orange-500");
expect(screen.getByText(/stopień 3: Burza/).closest("[role=alert]")).toHaveClass("border-alarm");
expect(screen.getByText(/stopień 1: Mróz/).closest("details")).toHaveClass("border-yellow-500");
expect(screen.getByText(/stopień 2: Upał/).closest("details")).toHaveClass("border-orange-500");
expect(screen.getByText(/stopień 3: Burza/).closest("details")).toHaveClass("border-alarm");
});
it("shows one banner for identical warnings and keeps ones that differ only in their end", async () => {
const w = warning("Silny wiatr", 1);
await overview({ warnings: [w, { ...w }, { ...w, to: "2026-10-26T18:00:00+01:00" }] });
await screen.findByRole("heading", { name: "Pogoda" });
expect(screen.getAllByText(/stopień 1: Silny wiatr/)).toHaveLength(2);
});
it("shows no banner without warnings", async () => {
@@ -103,7 +115,7 @@ describe("IMGW hint on the battery", () => {
await screen.findByText("Bateria teraz");
}
const HINT = "Ostrzeżenie IMGW: rozważ trzymanie baterii pełnej (rezerwa na awarię).";
const HINT = "Zapowiadany silny wiatr lub burza – zostaw baterię naładowaną (rezerwa na awarię).";
it("suggests keeping the battery full for strong wind", async () => {
await battery([warning("Silny wiatr", 1)]);
+31 -26
View File
@@ -35,12 +35,12 @@ const PICTURES: Record<Kind, { label: string; paths: string[]; colour: string }>
storm: { label: "burza", paths: [CLOUD, "M12.5 14l-2.5 4h3l-1.5 4"], colour: "text-alarm" },
};
export function WeatherIcon({ code }: { code: number | null }) {
export function WeatherIcon({ code, className = "size-10" }: { code: number | null; className?: string }) {
const kind = kindOf(code);
if (!kind) return <span className="grid size-10 place-items-center text-soft">—</span>;
if (!kind) return <span className={`grid shrink-0 place-items-center text-soft ${className}`}>—</span>;
const { label, paths, colour } = PICTURES[kind];
return (
<svg viewBox="0 0 24 24" role="img" aria-label={label} className={`size-10 ${colour}`} fill="none"
<svg viewBox="0 0 24 24" role="img" aria-label={label} className={`shrink-0 ${className} ${colour}`} fill="none"
stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round">
{paths.map((d) => <path key={d} d={d} />)}
</svg>
@@ -52,15 +52,16 @@ const DAY_NAMES = ["Dziś", "Jutro", "Pojutrze"];
function Day({ name, day }: { name: string; day: WeatherDay }) {
const rain = day.precipitationMm;
return (
<li className="flex flex-col items-center gap-1 text-center">
<b>{name}</b>
<WeatherIcon code={day.weatherCode} />
<span className="text-sm text-soft">{day.description}</span>
<span className="tabular-nums"><b>{celsius(day.tMax)}</b> / {celsius(day.tMin)}</span>
<span className="text-sm text-soft">Opady: {rain == null ? "—" : `${number(rain, 1)} mm`}</span>
{day.pvForecastKwh != null && (
<span className="text-sm font-semibold text-brand">PV ok. {number(day.pvForecastKwh)} kWh</span>
)}
<li className="flex items-center gap-3 border-t border-edge py-2.5 first:border-t-0 first:pt-0 last:pb-0">
<WeatherIcon code={day.weatherCode} className="size-9" />
<div className="min-w-0 flex-1">
<p><b>{name}</b> <span className="text-soft">{day.description}</span></p>
<p className="flex flex-wrap gap-x-3 text-xs text-soft">
<span className="whitespace-nowrap">Opady: {rain == null ? "—" : `${number(rain, 1)} mm`}</span>
{day.pvForecastKwh != null && <b className="whitespace-nowrap font-semibold text-brand">PV ok. {number(day.pvForecastKwh)} kWh</b>}
</p>
</div>
<p className="whitespace-nowrap tabular-nums"><b>{celsius(day.tMax)}</b> / {celsius(day.tMin)}</p>
</li>
);
}
@@ -68,9 +69,8 @@ function Day({ name, day }: { name: string; day: WeatherDay }) {
export function WeatherCard({ weather }: { weather: Weather }) {
const days = [weather.today, weather.tomorrow, weather.dayAfter];
return (
<Card title="Pogoda" aside={<span className="text-sm text-soft">teraz {celsius(weather.now.temperature)}</span>}
className="lg:col-span-2">
<ul className="grid grid-cols-3 gap-2">
<Card title="Pogoda">
<ul>
{days.map((day, i) => <Day key={day.date} name={DAY_NAMES[i]} day={day} />)}
</ul>
{weather.stale && <p className="mt-3 text-xs text-soft">Nie udało się odświeżyć pogody, pokazuję starsze dane.</p>}
@@ -84,20 +84,25 @@ const LEVELS: Record<number, string> = {
3: "border-alarm bg-red-50 text-alarm dark:bg-red-950/60 dark:text-red-200",
};
/** The IMGW warnings, one banner each, coloured by degree. The text is IMGW's own, shown as text. */
/** The IMGW warnings, one line each (degree colour, event, period); the text is IMGW's own and opens on a tap. */
export function WeatherWarnings({ warnings }: { warnings: WeatherWarning[] }) {
// IMGW sends one warning per area, so the same one can arrive twice.
const key = (w: WeatherWarning) => `${w.event}|${w.level}|${w.from}|${w.to}`;
const distinct = warnings.filter((w, i) => warnings.findIndex((o) => key(o) === key(w)) === i);
return (
<>
{warnings.map((w) => (
<div key={`${w.event}${w.level}${w.from}`} role="alert"
className={`mb-4 flex items-start gap-3 rounded-xl border-2 px-4 py-3 ${LEVELS[w.level] ?? LEVELS[1]}`}>
<Icon name="alert" className="size-6 shrink-0" />
<div>
<p className="font-semibold">Ostrzeżenie IMGW, stopień {w.level}: {w.event}</p>
<p className="text-sm">{dateTime(w.from)} – {dateTime(w.to)}</p>
<p className="mt-1 text-sm">{w.text}</p>
</div>
</div>
{distinct.map((w) => (
<details key={key(w)} role="alert"
className={`mb-3 rounded-xl border-2 px-4 py-2 ${LEVELS[w.level] ?? LEVELS[1]}`}>
<summary className="flex cursor-pointer items-center gap-3">
<Icon name="alert" className="size-5 shrink-0" />
<span className="min-w-0 flex-1">
<b>Ostrzeżenie IMGW, stopień {w.level}: {w.event}</b>
<span className="block text-sm">{dateTime(w.from)} – {dateTime(w.to)}</span>
</span>
</summary>
<p className="mt-2 text-sm">{w.text}</p>
</details>
))}
</>
);
+3 -3
View File
@@ -9,7 +9,7 @@
--color-page: #f2f6f3;
--color-panel: #ffffff;
--color-ink: #13201a;
--color-soft: #5b6b63;
--color-soft: #4f5f57;
--color-edge: #e2e9e5;
--color-brand: #0f8a5f;
--color-brand-soft: #e3f4ec;
@@ -29,10 +29,10 @@
:root[data-theme="dark"] {
color-scheme: dark;
--color-page: #0b1310;
--color-panel: #142019;
--color-panel: #1a2a21;
--color-ink: #eef5f1;
--color-soft: #a9bab1;
--color-edge: #24352c;
--color-edge: #35493d;
--color-brand: #3fd197;
--color-brand-soft: #143628;
--color-run: #ff7a8a;
+10 -1
View File
@@ -8,11 +8,20 @@ function isShellRequest(request) {
return request.method === "GET" && url.origin === self.location.origin && !url.pathname.includes("/api/");
}
const NAVIGATION_TIMEOUT_MS = 4000;
// A stalled connection must not hang the app: after the timeout the cached shell opens instead.
function fetchWithin(request, ms) {
let timer;
const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("timeout")), ms); });
return Promise.race([fetch(request), timeout]).finally(() => clearTimeout(timer));
}
async function answer(request) {
const cache = await caches.open(CACHE);
if (request.mode === "navigate") {
try {
return await fetch(request); // network first, so an update shows at once
return await fetchWithin(request, NAVIGATION_TIMEOUT_MS); // network first, so an update shows at once
} catch {
return cache.match("./index.html");
}
+13 -1
View File
@@ -40,7 +40,7 @@ async function run(type: "install" | "activate") {
}
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.unstubAllGlobals());
afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); });
describe("service worker", () => {
it("leaves /api/ to the network", () => {
@@ -70,6 +70,18 @@ describe("service worker", () => {
expect(await respondWith.mock.calls[0][0]).toBe(index);
});
it("opens the cached index.html when a navigation stalls", async () => {
vi.useFakeTimers();
const index = new Response("shell");
cacheStore.set("./index.html", index);
vi.stubGlobal("fetch", vi.fn(() => new Promise(() => undefined)));
const respondWith = fetchEvent("https://pompa.test/", { mode: "navigate" });
const answer = respondWith.mock.calls[0][0];
await vi.advanceTimersByTimeAsync(4000);
expect(await answer).toBe(index);
vi.useRealTimers();
});
it("precaches the shell on install", async () => {
await run("install");
expect(cache.addAll).toHaveBeenCalledWith(["./", "./index.html"]);
+13 -7
View File
@@ -36,7 +36,7 @@ const DOT: Record<Tone, string> = {
export function Pill({ tone, live = false, children }: { tone: Tone; live?: boolean; children: ReactNode }) {
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold ${PILL[tone]}`}>
<span className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-semibold ${PILL[tone]}`}>
<Dot tone={tone} live={live} />
{children}
</span>
@@ -58,17 +58,24 @@ const BANNER = {
ok: "border-brand bg-brand-soft text-brand",
};
export function Banner({ tone, children }: { tone: keyof typeof BANNER; children: ReactNode }) {
/** Compact is for a note above a screen that must still show its content on the first page of a phone. */
export function Banner({ tone, compact = false, children }: { tone: keyof typeof BANNER; compact?: boolean; children: ReactNode }) {
return (
<div role={tone === "ok" ? "status" : "alert"}
className={`mb-4 rounded-xl border-2 px-4 py-3 font-semibold ${BANNER[tone]}`}>
className={`rounded-xl border-2 font-semibold ${compact ? "mb-3 px-3 py-2 text-sm" : "mb-4 px-4 py-3"} ${BANNER[tone]}`}>
{children}
</div>
);
}
/** The backend knows best when a source has gone quiet; the age check covers a slow gateway. */
export const isStale = (time: string | null | undefined, stale = false): boolean =>
!!time && (stale || ageMinutes(time) >= STALE_MINUTES);
/** Offline or old data, in words; nothing when the data is fresh. */
export function Freshness({ offline, time, stale = false }: { offline: boolean; time?: string | null; stale?: boolean }) {
export function Freshness({ offline, time, stale = false, compact = false }: {
offline: boolean; time?: string | null; stale?: boolean; compact?: boolean;
}) {
if (offline) {
return (
<Banner tone="bad">
@@ -77,9 +84,8 @@ export function Freshness({ offline, time, stale = false }: { offline: boolean;
</Banner>
);
}
// The backend knows best when a source has gone quiet; the age check covers a slow gateway.
if (time && (stale || ageMinutes(time) >= STALE_MINUTES)) {
return <Banner tone="warn">Dane są nieaktualne: {ago(time)}.</Banner>;
if (isStale(time, stale)) {
return <Banner tone="warn" compact={compact}>Dane są nieaktualne: {ago(time ?? null)}.</Banner>;
}
return null;
}
+6 -3
View File
@@ -5,8 +5,9 @@ import { createHash } from "node:crypto";
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
import type { Plugin } from "vite";
// Writes dist/sw.js from src/sw.js with the built files to precache; the cache name carries a hash
// of that list, so a new build gets a new cache and the old one is removed on activate.
// Writes dist/sw.js from src/sw.js with the built files to precache; the cache name carries a hash of
// that list and of the public files' contents, so a new build gets a new cache and the old one is
// removed on activate.
function precachePlugin(): Plugin {
return {
name: "precache",
@@ -14,7 +15,9 @@ function precachePlugin(): Plugin {
writeBundle(options, bundle) {
const files = [...Object.keys(bundle), ...readdirSync("public")].sort();
const shell = ["./", ...files.map((file) => `./${file}`)];
const version = createHash("sha256").update(shell.join()).digest("hex").slice(0, 12);
const hash = createHash("sha256").update(shell.join());
readdirSync("public").sort().forEach((file) => hash.update(readFileSync(`public/${file}`)));
const version = hash.digest("hex").slice(0, 12);
const source = readFileSync("src/sw.js", "utf8")
.replace("__SHELL__", JSON.stringify(shell))
.replace("__VERSION__", JSON.stringify(version));
+5 -2
View File
@@ -69,7 +69,7 @@ reused; `commands.enabled` may stay false). `deyeCommands`: `reserveFile` (`rese
restore file, `{"pct": 50}`, replaced atomically; missing means 50, an unreadable file is logged and means 50),
`reserveFloorPct` (30; the reserve is never lower, nor below the inverter's shutdown SOC + 10),
`minIntervalSeconds` (60, per register) and `readBackDelayMillis` (1000). Same Bearer token and failed-login
throttle as `/commands`; `X-Requested-By` names the requester. While it is off, `/battery` answers 404.
throttle as `/commands` (one failed-token counter for both); `X-Requested-By` names the requester. While it is off, `/battery` answers 404.
- `GET /battery/reserve` answers `{"pct", "floorPct"}`; `POST /battery/reserve` `{"pct": 60}` changes it.
- `POST /battery/schedule` `{"enabled": true, "slots": [{"slot": 2, "start": 930, "soc": 90, "gridCharge": true}]}`;
@@ -79,7 +79,7 @@ throttle as `/commands`; `X-Requested-By` names the requester. While it is off,
register, 502 failed: the body's `read` holds the registers as read back, nothing is retried.
- Rules: SOC from the reserve to 100; grid charge written as 0 or 1 only; starts are HHMM, strictly increasing
from slot 1 to 6; an enabled schedule needs every slot SOC at or above the reserve; the reserve cannot be
raised above a slot SOC of an enabled schedule. The whole table is checked before anything is written,
raised above a slot SOC of an enabled schedule (decided on the table read live, 502 if that read fails). The whole table is checked before anything is written,
neighbouring registers go in one FC16, the Time of Use switch last, the days (bits 1 to 7 of 146) stay as read.
- Audit: `electricity.deye_settings_audit` (tags `register`, `requested_by`, `status`; fields `old_value`,
`new_value`). After a write the settings are polled again 3 s later. No restore and no lease: these are
@@ -102,6 +102,9 @@ and runs at its end. The gateway accepts it only if the end is in the future and
new value is lower than both the current value and the original that will come back; otherwise 422 and no
write. A renewal only ever extends a lease, so it never shortens a held change. A held change may replace a
pending boost (the original stays the first one); a later ordinary change on a held one is a lease again.
If that ordinary change fails after its write may have landed (lost reply, read-back mismatch), the held restore
keeps the original and accepts both values, but its lease is capped to now + 15 minutes: nothing above the
original outlives the boost, and without the backend the restore runs within 15 minutes.
**Restore now.** `POST /commands/restore` with `{"setpoint": "cwu_economy"}` (same token and throttle; the
requester comes from `X-Requested-By`) runs that setpoint's pending restore at once through the usual path.
@@ -9,7 +9,6 @@ import home.gateway.domain.ScheduleChange;
import home.gateway.domain.SlotChange;
import java.io.IOException;
import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
@@ -30,9 +29,9 @@ class BatteryEndpoint implements HttpHandler {
private final BatteryCommands battery;
private final TokenGuard guard;
BatteryEndpoint(BatteryCommands battery, String token, Clock clock) {
BatteryEndpoint(BatteryCommands battery, String token, FailedLoginThrottle throttle) {
this.battery = battery;
this.guard = new TokenGuard(token, clock);
this.guard = new TokenGuard(token, throttle);
}
@Override
@@ -14,7 +14,6 @@ import home.gateway.domain.Setpoint;
import home.gateway.domain.VerifiedValue;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.OffsetDateTime;
@@ -55,8 +54,8 @@ class CommandsEndpoint implements HttpHandler {
private final Snapshot snapshot;
private final TokenGuard guard;
CommandsEndpoint(Commands commands, Snapshot snapshot, String token, Clock clock) {
this.guard = new TokenGuard(token, clock);
CommandsEndpoint(Commands commands, Snapshot snapshot, String token, FailedLoginThrottle throttle) {
this.guard = new TokenGuard(token, throttle);
this.commands = commands;
this.snapshot = snapshot;
}
@@ -50,11 +50,13 @@ public class GatewayHttpServer implements AutoCloseable {
private final Clock clock;
private final Set<String> criticalDevices;
private final HttpServer server;
private final FailedLoginThrottle throttle;
public GatewayHttpServer(Snapshot snapshot, Clock clock, InetSocketAddress address,
Set<String> criticalDevices) throws IOException {
this.snapshot = snapshot;
this.clock = clock;
this.throttle = new FailedLoginThrottle(clock);
this.criticalDevices = criticalDevices;
this.server = HttpServer.create(address, 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
@@ -66,12 +68,12 @@ public class GatewayHttpServer implements AutoCloseable {
* read-only server needs no token; call before {@link #start()}.
*/
public void serveCommands(Commands commands, String token) {
server.createContext("/commands", new CommandsEndpoint(commands, snapshot, token, clock));
server.createContext("/commands", new CommandsEndpoint(commands, snapshot, token, throttle));
}
/** Adds the battery settings endpoints (ADR-013) with their own token check; call before {@link #start()}. */
public void serveBattery(BatteryCommands battery, String token) {
server.createContext("/battery", new BatteryEndpoint(battery, token, clock));
server.createContext("/battery", new BatteryEndpoint(battery, token, throttle));
}
public void start() {
@@ -5,11 +5,10 @@ import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import static home.gateway.adapters.http.GatewayHttpServer.respond;
/** The shared token of the write endpoints (S6) and the throttle on wrong ones. */
/** The shared token of the write endpoints (S6); wrong ones count on the throttle all endpoints share. */
final class TokenGuard {
static final int MIN_TOKEN_LENGTH = 16;
@@ -18,13 +17,13 @@ final class TokenGuard {
private final byte[] token;
private final FailedLoginThrottle throttle;
TokenGuard(String token, Clock clock) {
TokenGuard(String token, FailedLoginThrottle throttle) {
if (token == null || token.length() < MIN_TOKEN_LENGTH) {
throw new IllegalArgumentException(
"command token must be at least " + MIN_TOKEN_LENGTH + " characters");
}
this.token = token.getBytes(StandardCharsets.UTF_8);
this.throttle = new FailedLoginThrottle(clock);
this.throttle = throttle;
}
/** True if the request may go on; otherwise it has been answered with 429 or 401. */
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.gateway.application.ReserveStore;
import home.gateway.domain.Schedule;
import java.io.IOException;
import java.nio.file.Files;
@@ -24,13 +25,17 @@ public class JsonReserveStore implements ReserveStore {
this.file = file;
}
/** @throws IOException when the file exists but is not a valid reserve */
/** @throws IOException when the file exists but is not a valid reserve (0 to 100 %) */
@Override
public OptionalInt load() throws IOException {
if (!Files.exists(file)) {
return OptionalInt.empty();
}
return OptionalInt.of(json.readValue(file.toFile(), Stored.class).pct());
int pct = json.readValue(file.toFile(), Stored.class).pct();
if (pct < 0 || pct > Schedule.MAX_SOC) {
throw new IOException("reserve " + pct + " % is outside 0 to " + Schedule.MAX_SOC);
}
return OptionalInt.of(pct);
}
@Override
@@ -113,12 +113,16 @@ public class BatterySettings implements BatteryCommands {
"the reserve must be " + current.floorPct() + " to " + Schedule.MAX_SOC + " %");
}
if (pct > current.pct()) {
// Raising it must not break an enabled schedule; lowering never can.
var settings = freshSettings();
if (settings == null) {
return BatteryResult.of(Status.STALE, staleMessage());
// Raising it must not break an enabled schedule; lowering never can. The cached reading may
// predate a schedule change (ours or the Solarman app's), so the table is read live.
Schedule schedule;
try {
schedule = ProgramRegisters.decode(registers.read(ProgramRegisters.FIRST, ProgramRegisters.COUNT));
} catch (IOException e) {
return BatteryResult.of(Status.FAILED, "cannot read the settings before raising the reserve: "
+ e.getMessage());
}
if (settings.schedule().enabled() && lowestSoc(settings.schedule()) < pct) {
if (schedule.enabled() && lowestSoc(schedule) < pct) {
return BatteryResult.of(Status.REJECTED, "the schedule is enabled and a slot SOC is below "
+ pct + " %: raise the slot SOCs first or disable the schedule");
}
@@ -259,8 +263,24 @@ public class BatterySettings implements BatteryCommands {
.findFirst().map(BatterySettings::fromReading).orElse(null);
}
private static final List<String> REQUIRED_FIELDS = requiredFields();
private static List<String> requiredFields() {
var names = new ArrayList<>(List.of("tou_enabled", "tou_days", "shutdown_soc_pct"));
for (int slot = 1; slot <= Schedule.SLOTS; slot++) {
for (var suffix : List.of("start_hhmm", "soc_pct", "grid_charge")) {
names.add("prog" + slot + "_" + suffix);
}
}
return names;
}
/** Null when the reading lacks a field: that is no usable settings, not an error. */
private static Settings fromReading(Reading reading) {
var fields = reading.fields();
if (!REQUIRED_FIELDS.stream().allMatch(fields::containsKey)) {
return null;
}
var slots = new ArrayList<Slot>();
for (int slot = 1; slot <= Schedule.SLOTS; slot++) {
String prefix = "prog" + slot + "_";
@@ -171,12 +171,18 @@ public class CommandService implements Commands {
/**
* The first change's restore must survive a failed second one. The failed write may still have
* landed (a lost reply), so the register can hold either value and both count as ours.
* landed (a lost reply), so the register can hold either value and both count as ours. Unless
* the failed command was itself held, the restore is an ordinary lease from now on, even if it
* was held: a value that may have landed must not outlive the boost (ADR-012).
*/
private CommandResult failedWrite(Command command, Setpoint setpoint, Integer old, Restore previous,
String message) {
if (previous != null) {
restores.put(previous.register(), previous.alsoApplied(command.value()));
var restore = previous.alsoApplied(command.value());
if (command.heldUntil() == null) {
restore = restore.leaseCappedAt(clock.instant().plus(Restore.LEASE));
}
restores.put(previous.register(), restore);
persist();
}
return failed(command, setpoint, old, message);
@@ -43,4 +43,10 @@ public record Restore(String setpoint, int register, int originalValue, int appl
var longest = newLeaseUntil.isAfter(leaseUntil) ? newLeaseUntil : leaseUntil;
return new Restore(setpoint, register, originalValue, appliedValue, due, longest, otherApplied);
}
/** The lease ends no later than {@code limit}: a held change loses its far end (ADR-012). */
public Restore leaseCappedAt(Instant limit) {
var shortest = limit.isBefore(leaseUntil) ? limit : leaseUntil;
return new Restore(setpoint, register, originalValue, appliedValue, due, shortest, otherApplied);
}
}
@@ -3,6 +3,7 @@ package home.gateway.adapters.http;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.gateway.application.BatteryCommands;
import home.gateway.application.DisabledCommands;
import home.gateway.application.Snapshot;
import home.gateway.domain.BatteryResult;
import home.gateway.domain.BatteryResult.Status;
@@ -177,6 +178,16 @@ class BatteryEndpointTest {
assertEquals(List.of(), schedules);
}
@Test
void wrongTokensOnTheCommandsEndpointAlsoBlockTheBatteryOne() throws Exception {
server.serveCommands(new DisabledCommands("test"), BEARER);
for (int i = 0; i < FailedLoginThrottle.MAX_FAILURES; i++) {
send("/commands", "GET", null);
}
assertEquals(429, authorized("/battery/reserve", "GET", null).statusCode());
}
@Test
void knowsOnlyItsPathsAndMethods() throws Exception {
assertEquals(404, authorized("/battery/other", "GET", null).statusCode());
@@ -187,6 +198,6 @@ class BatteryEndpointTest {
@Test
void refusesAShortToken() {
var other = Clock.fixed(Instant.EPOCH, ZoneOffset.UTC);
assertThrows(IllegalArgumentException.class, () -> new BatteryEndpoint(battery, "short", other));
assertThrows(IllegalArgumentException.class, () -> new BatteryEndpoint(battery, "short", new FailedLoginThrottle(other)));
}
}
@@ -37,6 +37,17 @@ class JsonReserveStoreTest {
}
}
@Test
void aReserveOutsideZeroTo100FailsTheLoad() throws IOException {
var file = dir.resolve("reserve.json");
for (var content : List.of("{\"pct\":101}", "{\"pct\":-1}", "{\"pct\":2000000}")) {
Files.writeString(file, content);
assertThrows(IOException.class, () -> new JsonReserveStore(file).load(), content);
}
Files.writeString(file, "{\"pct\":100}");
assertEquals(OptionalInt.of(100), new JsonReserveStore(file).load());
}
@Test
void anUnusableFileFailsTheLoad() throws IOException {
var file = dir.resolve("reserve.json");
@@ -318,7 +318,16 @@ class BatterySettingsTest {
assertEquals(Status.STALE, result.status());
assertNothingWritten();
assertEquals(0, inverter.reads);
assertEquals(Status.STALE, service.changeReserve(60, "ala").status());
}
@Test
void aReadingWithoutTheExpectedFieldsIsNoUsableSettings() {
var service = service();
snapshot.recordSuccess(DeyeSettingsDevice.NAME, clock.instant(), List.of(new Reading("electricity",
"deye_settings", Map.of(), Map.of("tou_enabled", 1.0), clock.instant())));
assertEquals(Status.STALE, service.changeSchedule(change(null, soc(1, 90))).status());
assertNothingWritten();
}
@Test
@@ -515,12 +524,37 @@ class BatterySettingsTest {
}
@Test
void raisingTheReserveNeedsAFreshReadingButLoweringDoesNot() {
void raisingTheReserveDecidesOnTheLiveTableNotTheOldReading() {
var service = service();
clock.advance(Duration.ofMinutes(45));
assertEquals(Status.APPLIED, service.changeReserve(60, "ala").status());
inverter.registers.put(146, 255);
var result = service.changeReserve(85, "ala");
assertEquals(Status.REJECTED, result.status());
assertEquals(60, service.reserve().pct());
}
@Test
void raisingTheReserveFailsWhenTheTableCannotBeRead() {
var service = service();
inverter.failReadNumber = inverter.reads + 1;
var result = service.changeReserve(60, "ala");
assertEquals(Status.FAILED, result.status());
assertEquals(-1, storedReserve);
assertEquals(50, service.reserve().pct());
}
@Test
void loweringTheReserveNeedsNoRead() {
var service = service();
int reads = inverter.reads;
assertEquals(Status.STALE, service.changeReserve(60, "ala").status());
assertEquals(Status.APPLIED, service.changeReserve(40, "ala").status());
assertEquals(reads, inverter.reads);
}
@Test
@@ -545,6 +545,40 @@ class CommandServiceTest {
assertTrue(service.pendingRestores().isEmpty());
}
@Test
void aBoostWithALostReplyOnAHeldChangeCapsTheHeldLease() throws IOException {
var service = service();
service.apply(held("cwu_comfort", 35, Duration.ofDays(7)));
clock.advance(Duration.ofMinutes(5));
registers.failReadNumber = registers.reads + 2;
assertEquals(Status.FAILED, service.apply(command("cwu_comfort", 48, HOUR)).status());
var expected = new Restore("cwu_comfort", 4144, 45, 35, T0.plus(Duration.ofDays(7)),
clock.instant().plus(Restore.LEASE), 48);
assertEquals(List.of(expected), service.pendingRestores());
assertEquals(List.of(expected), stored);
assertEquals(48, registers.values.get(4144));
clock.advance(Restore.LEASE);
service.runDueRestores();
assertEquals(45, registers.values.get(4144));
assertTrue(service.pendingRestores().isEmpty());
}
@Test
void aFailedHeldLoweringOnAHeldChangeKeepsTheHeldEnd() throws IOException {
var service = service();
var end = Duration.ofDays(7);
service.apply(held("cwu_comfort", 40, end));
clock.advance(Duration.ofMinutes(2));
registers.ignoreWrites = true;
assertEquals(Status.FAILED, service.apply(held("cwu_comfort", 35, Duration.ofDays(3))).status());
assertEquals(T0.plus(end), service.pendingRestores().getFirst().runAt());
assertEquals(35, service.pendingRestores().getFirst().otherApplied());
}
@Test
void secondBoostWithAReadBackMismatchKeepsTheFirstRestore() throws IOException {
var service = afterFirstBoost();