Compare commits

..
3 Commits
Author SHA1 Message Date
root e6d9487f0c Merge PR #95: fix: refresh cooldown counts pending requests, button beside the title
CI / Check (gateway, backend, frontend) (push) Successful in 51s
Release / Image (backend) (push) Successful in 33s
Release / Image (frontend) (push) Successful in 48s
Release / Image (gateway) (push) Successful in 1m15s
2026-10-24 19:10:07 +02:00
rootandClaude Opus 5.5 64ffd035c5 feat(frontend): Odśwież button beside every screen title, with its text
CI / Check (gateway, backend, frontend) (push) Successful in 57s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:08:15 +02:00
rootandClaude Opus 5.5 87d111398c fix(gateway): refresh cooldown counts from the last refresh request
A second refresh within the repoll delay reported the device as requested again
although the poll was coalesced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:08:15 +02:00
8 changed files with 48 additions and 16 deletions

No files matched your search

+1 -1
View File
@@ -20,7 +20,7 @@ and gateway's versions in the "Wersje" card of the Stan screen.
The Energia screen has three views, kept in the URL hash so Back works: `#energy` (Teraz), `#energy/battery` (Bateria, also
opened by tapping the battery in the flow diagram) and `#energy/inverters` (Falowniki, the device details).
Every screen starts with the "Odśwież" button (`Refresh.tsx`): it posts `api/refresh`, waits 5 s for the gateway's extra poll, then
Every screen title (`PageTitle`) has the "Odśwież" button (`Refresh.tsx`) beside it, with its status text below: it posts `api/refresh`, waits 5 s for the gateway's extra poll, then
`refreshAll()` (`poll.ts`) fetches every poller that is on screen again. When the gateway skipped every device (polled within a minute) it says so and
waits out the shortest cooldown.
-2
View File
@@ -3,7 +3,6 @@ import { celsius, hour, longDate } from "./format";
import { Icon, type IconName } from "./icons";
import { Energy } from "./Energy";
import { Overview } from "./Overview";
import { RefreshButton } from "./Refresh";
import { usePoll } from "./poll";
import { useTheme, type Theme } from "./theme";
import type { HeatPump } from "./types";
@@ -120,7 +119,6 @@ export function App() {
<div className="hidden flex-1 lg:flex lg:flex-col"><SideFooter /></div>
</nav>
<main className="mx-auto max-w-[1500px] px-4 pt-5 pb-28 sm:px-6 lg:px-8 lg:pt-8 lg:pb-10">
<RefreshButton />
<Suspense fallback={<Loading />}><View /></Suspense>
<ThemeSwitch className="mx-auto mt-8 max-w-xs border border-edge bg-panel text-ink lg:hidden" />
</main>
+11 -1
View File
@@ -4,6 +4,7 @@ import { answer, example, serve } from "./fixtures";
const button = () => screen.getByRole("button", { name: "Odśwież" });
const status = () => screen.getByRole("status");
const noStatus = () => screen.queryByRole("status");
// The button and a poller that shows api/heat-pump, as a screen would.
async function shown(onPost: () => Promise<Response>) {
@@ -68,7 +69,7 @@ describe("RefreshButton", () => {
expect(status()).toHaveTextContent("Dane są świeże – spróbuj za 20 s");
await act(() => vi.advanceTimersByTimeAsync(20_000));
expect(status()).toBeEmptyDOMElement();
expect(noStatus()).toBeNull();
expect(button()).toBeEnabled();
});
@@ -95,4 +96,13 @@ describe("RefreshButton", () => {
expect(status()).toHaveTextContent("Brak połączenia z siecią");
});
it("sits in the heading row of a screen, next to the title", async () => {
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new TypeError("offline"))));
const { PageTitle } = await import("./ui");
render(<PageTitle title="Stan" />);
expect(screen.getByRole("banner")).toContainElement(button());
expect(button()).toHaveTextContent("Odśwież");
});
});
+5 -5
View File
@@ -56,13 +56,13 @@ export function RefreshButton() {
}[state.kind];
const busy = state.kind === "busy";
return (
<div className="mb-4 flex items-center justify-end gap-3">
<p role="status" className={`text-sm ${state.kind === "error" ? "text-bad" : "text-soft"}`}>{text}</p>
<button onClick={tap} disabled={busy || state.kind === "fresh"} aria-label="Odśwież"
className="flex min-h-11 min-w-11 items-center justify-center gap-2 rounded-xl border-2 border-brand bg-panel px-3 font-bold text-brand transition active:scale-[0.98] disabled:opacity-40">
<div className="flex shrink-0 flex-col items-end gap-1">
<button onClick={tap} disabled={busy || state.kind === "fresh"}
className="flex min-h-11 items-center justify-center gap-2 rounded-xl border-2 border-brand bg-panel px-3 font-bold text-brand transition active:scale-[0.98] disabled:opacity-40">
<Icon name="refresh" className={`size-5 ${busy ? "animate-spin" : ""}`} />
<span className="hidden sm:inline">Odśwież</span>
Odśwież
</button>
{text && <p role="status" className={`max-w-44 text-right text-xs ${state.kind === "error" ? "text-bad" : "text-soft"}`}>{text}</p>}
</div>
);
}
+5 -3
View File
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { ageMinutes, ago } from "./format";
import { RefreshButton } from "./Refresh";
export const STALE_MINUTES = 10;
@@ -103,14 +104,15 @@ export function Row({ label, value, icon }: { label: ReactNode; value: ReactNode
);
}
export function PageTitle({ title, subtitle, aside }: { title: string; subtitle?: ReactNode; aside?: ReactNode }) {
// Every screen starts with this, so the refresh button sits beside each title.
export function PageTitle({ title, subtitle }: { title: string; subtitle?: ReactNode }) {
return (
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
<header className="mb-5 flex items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-soft">{subtitle}</p>}
</div>
{aside}
<RefreshButton />
</header>
);
}
+1 -1
View File
@@ -117,7 +117,7 @@ pending with its backoff.
**Refresh.** `POST /refresh` (same Bearer token and throttle, no body, on when `commands` or `deyeCommands` is enabled, so the token exists)
is the app's "Odśwież" button. It asks every device's poller for one extra poll (after the same 3 s) and answers
`{"requested": ["deye"], "skipped": [{"device": "heat-pump", "retryInSeconds": 41}]}`. A device is polled again only if its
last poll, regular or extra, started at least 60 s ago (`Poller.REFRESH_COOLDOWN`); otherwise it is skipped, not queued. The
last poll (regular or extra) started, and last refresh was requested, at least 60 s ago (`Poller.REFRESH_COOLDOWN`); otherwise it is skipped, not queued. The
heat pump needs the floor most: the Frisko MK01 takes one client and every poll of ours drops the phone app (ADR-003). Read-only.
**Shown value.** `GET /commands` shows, per setpoint, the value read back after the gateway's last write or
@@ -8,12 +8,15 @@ import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.locks.LockSupport;
import java.util.stream.Stream;
/**
* Polls every device on its own virtual thread, so a slow or broken device affects no other.
@@ -42,6 +45,8 @@ public class Poller implements AutoCloseable {
private final Map<String, Long> extraPolls = new ConcurrentHashMap<>();
// Device name to the start of its latest poll, regular or extra.
private final Map<String, Instant> lastAttempts = new ConcurrentHashMap<>();
// Device name to its latest refresh; the extra poll it causes starts only after the repoll delay.
private final Map<String, Instant> lastRefreshes = new ConcurrentHashMap<>();
private final Set<String> devicesWithSinkFailure = ConcurrentHashMap.newKeySet();
// A read may swallow the interrupt, so the loop cannot rely on the thread's flag alone.
private volatile boolean closed;
@@ -75,17 +80,19 @@ public class Poller implements AutoCloseable {
}
}
/** Requests one extra poll of each device whose last poll is at least the cooldown old; skips the others. */
/** Requests one extra poll of each device whose last poll or refresh is at least the cooldown old; skips the others. */
public RefreshResult refresh() {
var now = clock.instant();
var requested = new ArrayList<String>();
var skipped = new ArrayList<RefreshResult.Skipped>();
for (Device device : devices) {
var last = lastAttempts.get(device.name());
var wait = last == null ? Duration.ZERO : REFRESH_COOLDOWN.minus(Duration.between(last, now));
var last = Stream.of(lastAttempts.get(device.name()), lastRefreshes.get(device.name()))
.filter(Objects::nonNull).max(Comparator.naturalOrder());
var wait = last.map(at -> REFRESH_COOLDOWN.minus(Duration.between(at, now))).orElse(Duration.ZERO);
if (wait.isPositive()) {
skipped.add(new RefreshResult.Skipped(device.name(), (wait.toMillis() + 999) / 1000));
} else {
lastRefreshes.put(device.name(), now);
requestPoll(device.name());
requested.add(device.name());
}
@@ -272,6 +272,21 @@ class PollerTest {
assertEquals(List.of(new RefreshResult.Skipped("pump", 60)), poller.refresh().skipped());
}
@Test
void aSecondRefreshBeforeTheExtraPollRunsIsSkippedToo() throws Exception {
var pump = FakeDevice.working("pump", HOURLY);
startWithMovableClock(pump);
passSeconds(120);
assertEquals(List.of("pump"), poller.refresh().requested());
passSeconds(2);
var second = poller.refresh();
assertEquals(List.of(), second.requested());
assertEquals(List.of(new RefreshResult.Skipped("pump", 58)), second.skipped());
}
@Test
void refreshJudgesEachDeviceOnItsOwn() throws Exception {
var pump = FakeDevice.working("pump", HOURLY);