Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c4386cee7 |
No files matched your search
@@ -97,15 +97,6 @@ class InfluxHistoryRepositoryTest {
|
||||
assertEquals(new Point(Instant.ofEpochMilli(1792771200000L), 43.76666666666667), points.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void asksForGroundInAndPumpOutletFromTheirLegacyFields() throws IOException {
|
||||
repository(TIMEOUT).history(Series.GROUND_IN_TEMP, FROM, TO, 360);
|
||||
repository(TIMEOUT).history(Series.PUMP_OUTLET_TEMP, FROM, TO, 360);
|
||||
|
||||
assertTrue(requests.get(0).get("q").startsWith("SELECT mean(\"Temp in\") FROM \"POMPA\""), requests.get(0).get("q"));
|
||||
assertTrue(requests.get(1).get("q").startsWith("SELECT mean(\"Temp pc out\") FROM \"POMPA\""), requests.get(1).get("q"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void asksTheElectricityDatabaseForGridPower() throws IOException {
|
||||
repository(TIMEOUT).history(Series.GRID_POWER, FROM, TO, 72);
|
||||
|
||||
@@ -44,10 +44,6 @@ below therefore come from the PDF, the XML and the MR208 manual, not from code.
|
||||
|
||||
4226 is the effective state; 4140 is the chosen program (4226 can say STOP while 4140 says weekly).
|
||||
|
||||
Open: the gateway test fixture (real output, PR #40) shows 4226 = 0 while the settings backup has
|
||||
4140 = 1 (weekly) and rx910-se21.md observed the weekly program. If 4226 really reads 0 then, the
|
||||
PDF numbering may be shifted by one. Check by reading 4226 and 4140 together, then changing nothing.
|
||||
|
||||
## Modes and programs
|
||||
|
||||
| Reg | Values | Source |
|
||||
@@ -115,6 +111,5 @@ The vendor documents no limit. The app's 200 ms pause is the only known pacing;
|
||||
|
||||
- What are 4222–4225, and the missing values of 4228? (a read-only log over a heating cycle)
|
||||
- Does 4011 exist and move on this firmware? Its relation to 4221.
|
||||
- Is 4226 numbered as the PDF says (see above)?
|
||||
- What does 4119 read when it is implemented, and is the register used at all?
|
||||
- Meaning of 4122 value 2 ("*") and of 4126 `Uklad` (no values given).
|
||||
@@ -23,7 +23,7 @@ unless marked otherwise.
|
||||
|---|---|---|
|
||||
| Temperatures | 4065 outdoor, 4066 indoor, 4067 CO/buffer, 4069 CWU, 4070/4071 ground in/out, 4072 pump outlet, 4199 avg outdoor | X10 |
|
||||
| Targets now | 4201 room, 4202 CO supply, 4203 CWU | X10; show economy vs comfort period |
|
||||
| State | 4227 pump (0 stop, 1 CO, 2 CWU, 3 both), 4226 program, 4221 alarm, 4073 pressure switch | |
|
||||
| State | 4227 pump (0 stop, 1 CO, 2 CWU, 3 both), 4226 program, 4221 alarm, 4073 pressure switch | 4226 numbered as the PDF (1 = weekly), verified 2026-10-24 |
|
||||
| Outputs | 4112 CO, 4113 buffer, 4114 compressor (0/33/66/100), 4115 heater signal (heater not connected), 4116 circulation, 4117 ground, 4118 CWU | 0 or 100 |
|
||||
| Setpoints (F3) | 4131/4132 CO economy/comfort (5–35), 4143/4144 CWU economy/comfort (5–70) | whole °C |
|
||||
| Protection | 4158 TmaxPc = 52 °C, 4149 TmaxCWU = 55, 4148 HistCWU = 5, 4146 PriorCWU = 1, 4147 heater signal delay = 20 | read 2026-10-23 |
|
||||
|
||||
+21
-5
@@ -33,14 +33,30 @@ their names and fields; new data goes into new measurements (ADR-005). The heat
|
||||
meter's converter speaks RTU over TCP); `rpi-measurements` stopped and disabled
|
||||
- [ ] After the week: remove `rpi-measurements` and the old `home-pump.service` files
|
||||
|
||||
## Next
|
||||
## Next: M2 Understand the controller
|
||||
|
||||
Done when: `docs/frisko/` reviewed, each open question answered or marked out of reach. Reads only;
|
||||
a probe against the controller runs on the Pi, with the owner's approval, while the Frisko app is closed.
|
||||
|
||||
- [x] F3 limits agreed: CWU 5–48, CO 5–25 (gateway/README.md); 4226 numbering holds (history)
|
||||
- [ ] DST, 2026-10-26: CWU comfort at ~10:25 / 16:55 local means the clock is still on CEST; then
|
||||
the owner sets it and we note the date
|
||||
- [ ] Timing probe: block response times (p50, p95) and errors at 1, 2 and 5 s cycles, ~10 min
|
||||
each; what our connection does to a running Frisko app. Gives the poll rate and Odśwież limit
|
||||
- [ ] Which pumps run in which state: outputs 4112, 4113, 4116–4118 and stage 4114 against 4227
|
||||
over a week of `heat_pump`, including the first CO cycles
|
||||
- [ ] Over the same week: 4222–4225 and 4228 values; the CWU start condition (40.8–42.8 °C vs none
|
||||
at 38–39 °C)
|
||||
- [ ] Read 4011 beside 4221 and 4073 (a new read-only field): does it exist on SE21 (M5 needs it)
|
||||
- [ ] Weekly program: read 4167–4177 through the shared selector, or keep inferring it (F6); decide
|
||||
|
||||
## Later
|
||||
|
||||
| Milestone | Scope | Done when |
|
||||
|---|---|---|
|
||||
| M2 Understand the controller | Timing (safe poll rate, response times), which pumps run in which state, open questions in `docs/frisko/` | `docs/frisko/` reviewed; F3 limits agreed |
|
||||
| M3 Backend and web UI, read-only | Backend in Docker in Kraków: history from Influx, snapshot from the gateway; F1, F2, F5, F7 with the schematic and charts after `research/ui/`; Traefik route, sign-in (ADR-014), on the internet since v2.0.0 (ADR-016). Backend, frontend and their images are on master (ADR-008 proposed, ADR-010); open: `REGISTRY_TOKEN`, first release, the stack in homelab-compose (Arcane) | My father sees the heat pump and the energy on his phone |
|
||||
| M4 Setpoints | F3 through gateway commands with timed restore (ADR-006) and 15 min leases the backend renews (ADR-011), away held until a chosen date (ADR-012); CWU first, then CO; S1–S6. On master and wired, off until `commands.enabled` (gateway/README.md: Enabling commands): command service with restores, FC6, token-protected `/commands`, backend `/api/setpoints` and presets, UI tab Ustawienia | Father boosts hot water from his phone, value restored on time |
|
||||
| M4b Battery settings | F8 (ADR-013): Time of Use table and backup reserve from the Bateria view, FC16 with read-back, refused on stale or changed state; on branch/master and off until `deyeCommands.enabled` (gateway/README.md: Enabling battery commands) | Father changes the battery program from the app; first live write watched |
|
||||
| M3 Backend and web UI, read-only | Backend in Docker in Kraków: history from Influx, snapshot from the gateway; F1, F2, F5, F7 with the schematic and charts after `research/ui/`; Traefik route, sign-in (ADR-014), on the internet since v2.0.0 (ADR-016). Backend, frontend and their images are on master (ADR-008 proposed, ADR-010); live at `pompa.mariuszpacek.pl` behind the sign-in (2026-10-25) | My father sees the heat pump and the energy on his phone |
|
||||
| M4 Setpoints | F3 through gateway commands with timed restore (ADR-006) and 15 min leases the backend renews (ADR-011), away held until a chosen date (ADR-012); CWU first, then CO; S1–S6. On master and enabled on the Pi since 2026-10-24 (gateway/README.md: Enabling commands): command service with restores, FC6, token-protected `/commands`, backend `/api/setpoints` and presets, UI tab Ustawienia | Father boosts hot water from his phone, value restored on time |
|
||||
| M4b Battery settings | F8 (ADR-013): Time of Use table and backup reserve from the Bateria view, FC16 with read-back, refused on stale or changed state; on master and enabled on the Pi since 2026-10-24 (gateway/README.md: Enabling battery commands) | Father changes the battery program from the app; first live write watched |
|
||||
| M5 Alarms | F4; decide the channel first (e.g. ntfy, Telegram) | Simulated alarm reaches the phone |
|
||||
| M6 Native app? | Decide: keep the web UI, WebView wrapper or React Native | Decision recorded |
|
||||
|
||||
|
||||
@@ -32,10 +32,6 @@ checks the gateway's rules first (levels from the reserve to 100 %, starts incre
|
||||
|
||||
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).
|
||||
|
||||
Temperature charts (`TimeChart.tsx`, plain SVG; recharts stays for Statystyki and Energia) are mobile-first: toggle chips and a readout row above the plot
|
||||
(latest values, or those under the finger while scrubbing; `touch-action: pan-y` keeps vertical swipes for the page) instead of a floating tooltip.
|
||||
`TemperatureChart.tsx` is the four-series chart (CWU, Bufor CO, Do pompy, Wyjście pompy) of Podgląd and Historia > Wykresy.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+26
-13
@@ -1,44 +1,57 @@
|
||||
import { useState } from "react";
|
||||
import { number } from "./format";
|
||||
import { usePoll } from "./poll";
|
||||
import { Program } from "./Program";
|
||||
import { Stats } from "./Stats";
|
||||
import { RangeToggle, SubViewPanel, SubViewSwitch, useSubView } from "./SubView";
|
||||
import { TemperatureChart } from "./TemperatureChart";
|
||||
import { TimeChart, toRows, type Range } from "./TimeChart";
|
||||
import type { History as HistoryData } from "./types";
|
||||
import { Card, Freshness, Loading, PageTitle } from "./ui";
|
||||
|
||||
// Other series than the temperatures; ids as in Series.java.
|
||||
// Ids as in Series.java.
|
||||
const SERIES = {
|
||||
temperatures: ["Temperatury"],
|
||||
ground_out_temp: ["Gruntowe: z pompy", "var(--color-s3)"],
|
||||
grid_power: ["Moc z sieci (+ pobór, − oddawanie)", "var(--color-s5)"],
|
||||
pv_power: ["Produkcja PV", "var(--color-s1)"],
|
||||
load_power: ["Wyjście zapasowe falownika", "var(--color-s2)"],
|
||||
battery_soc: ["Bateria (naładowanie)", "var(--color-s5)"],
|
||||
outdoor_temp: ["Temperatura na zewnątrz", "#3b82f6"],
|
||||
indoor_temp: ["Temperatura w domu", "#10b981"],
|
||||
co_buffer_temp: ["Bufor CO", "#ef4444"],
|
||||
cwu_temp: ["Ciepła woda (CWU)", "#f59e0b"],
|
||||
ground_in_temp: ["Gruntowe: do pompy", "#22a6c9"],
|
||||
ground_out_temp: ["Gruntowe: z pompy", "#2f7fe0"],
|
||||
pump_outlet_temp: ["Wyjście pompy ciepła", "#f26b1d"],
|
||||
grid_power: ["Moc z sieci (+ pobór, − oddawanie)", "#3b82f6"],
|
||||
pv_power: ["Produkcja PV", "#f59e0b"],
|
||||
load_power: ["Wyjście zapasowe falownika", "#10b981"],
|
||||
battery_soc: ["Bateria (naładowanie)", "#8b5cf6"],
|
||||
} as const;
|
||||
type SeriesId = keyof typeof SERIES;
|
||||
|
||||
const RANGES: Record<Range, string> = { "24h": "24 godziny", "7d": "7 dni" };
|
||||
|
||||
function Chart({ series, range }: { series: Exclude<SeriesId, "temperatures">; range: Range }) {
|
||||
function Chart({ series, range }: { series: SeriesId; range: Range }) {
|
||||
// One poller per choice, so an answer is never shown for another one.
|
||||
const { data, offline } = usePoll<HistoryData>(`api/history?series=${series}&range=${range}`);
|
||||
if (!data) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
|
||||
const values = data.points.map((p) => p.v);
|
||||
const digits = data.unit === "°C" ? 1 : 0;
|
||||
const [name, colour] = SERIES[series];
|
||||
return (
|
||||
<>
|
||||
{offline && <Freshness offline />}
|
||||
{data.points.length < 2 ? <p className="py-8 text-soft">Za mało danych w tym okresie.</p> : (
|
||||
<TimeChart rows={toRows({ [series]: data.points })} lines={[{ key: series, name, colour }]}
|
||||
unit={data.unit} range={range} />
|
||||
<>
|
||||
<TimeChart rows={toRows({ [series]: data.points })} lines={[{ key: series, name, colour }]}
|
||||
unit={data.unit} range={range} height={320} area />
|
||||
<p className="mt-2 text-sm text-soft">
|
||||
Najmniej {number(Math.min(...values), digits)} {data.unit} · najwięcej {number(Math.max(...values), digits)} {data.unit}.
|
||||
Dotknij wykresu, aby zobaczyć wartość.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Charts() {
|
||||
const [series, setSeries] = useState<SeriesId>("temperatures");
|
||||
const [series, setSeries] = useState<SeriesId>("cwu_temp");
|
||||
const [range, setRange] = useState<Range>("24h");
|
||||
return (
|
||||
<Card>
|
||||
@@ -49,7 +62,7 @@ function Charts() {
|
||||
</select>
|
||||
<RangeToggle label="Okres" options={RANGES} value={range} onSelect={setRange} />
|
||||
</div>
|
||||
{series === "temperatures" ? <TemperatureChart range={range} /> : <Chart series={series} range={range} />}
|
||||
<Chart series={series} range={range} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { Icon, type IconName } from "./icons";
|
||||
import { flowing } from "./flow";
|
||||
import { usePoll } from "./poll";
|
||||
import { Schematic } from "./Schematic";
|
||||
import { TemperatureChart } from "./TemperatureChart";
|
||||
import type { Energy, HeatPump, PiSensor, PumpState, Weather } from "./types";
|
||||
import { Legend, TimeChart, toRows, type Line } from "./TimeChart";
|
||||
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 { TemperatureDetails } from "./Details";
|
||||
@@ -23,6 +23,14 @@ export const PUMP_NAMES = {
|
||||
cwu: "Pompa CWU", circulation: "Pompa cyrkulacji",
|
||||
} as const;
|
||||
|
||||
const HISTORY_MS = 5 * 60000; // history moves slowly; the backend asks InfluxDB for every request
|
||||
|
||||
const TEMPERATURE_LINES: Line[] = [
|
||||
{ key: "cwu_temp", name: "CWU", colour: "#f59e0b" },
|
||||
{ key: "co_buffer_temp", name: "Bufor CO", colour: "#ef4444" },
|
||||
{ key: "indoor_temp", name: "W domu", colour: "#10b981" },
|
||||
{ key: "outdoor_temp", name: "Na zewnątrz", colour: "#3b82f6", dashed: true },
|
||||
];
|
||||
|
||||
const onOff = (on: boolean | null, yes: string, no: string) => (on == null ? "—" : on ? yes : no);
|
||||
|
||||
@@ -137,6 +145,28 @@ function Devices({ hp }: { hp: HeatPump }) {
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
const indoor = usePoll<History>("api/history?series=indoor_temp&range=24h", HISTORY_MS);
|
||||
const outdoor = usePoll<History>("api/history?series=outdoor_temp&range=24h", HISTORY_MS);
|
||||
const all = [cwu, co, indoor, outdoor].map((p) => p.data);
|
||||
if (all.some((d) => !d)) return null;
|
||||
return toRows(Object.fromEntries(all.map((d) => [d!.series, d!.points])));
|
||||
}
|
||||
|
||||
function TemperatureChart() {
|
||||
const rows = useTemperatureHistory();
|
||||
return (
|
||||
<Card title="Temperatury – ostatnie 24 h" className="lg:col-span-2">
|
||||
<Legend lines={TEMPERATURE_LINES} />
|
||||
{rows == null ? <Loading /> : rows.length < 2
|
||||
? <p className="text-soft">Za mało danych w tym okresie.</p>
|
||||
: <TimeChart rows={rows} lines={TEMPERATURE_LINES} unit="°C" range="24h" />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function NoReading() {
|
||||
return (
|
||||
<Card>
|
||||
@@ -187,7 +217,7 @@ export function Overview() {
|
||||
<TemperatureDetails hp={hp} pi={pi} />
|
||||
</div>
|
||||
<EnergyToday e={e} link />
|
||||
<Card title="Temperatury – ostatnie 24 h" className="lg:col-span-2"><TemperatureChart range="24h" /></Card>
|
||||
<TemperatureChart />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { usePoll } from "./poll";
|
||||
import { TimeChart, toRows, type Line, type Range } from "./TimeChart";
|
||||
import type { History } from "./types";
|
||||
import { Loading } from "./ui";
|
||||
|
||||
const HISTORY_MS = 5 * 60000; // history moves slowly; the backend asks InfluxDB for every request
|
||||
|
||||
// Ids as in Series.java; colours are the series tokens of index.css.
|
||||
export const TEMPERATURE_LINES: Line[] = [
|
||||
{ key: "cwu_temp", name: "Ciepła woda", colour: "var(--color-s1)" },
|
||||
{ key: "co_buffer_temp", name: "Bufor CO", colour: "var(--color-s2)" },
|
||||
{ key: "ground_in_temp", name: "Do pompy", colour: "var(--color-s3)" },
|
||||
{ key: "pump_outlet_temp", name: "Wyjście pompy", colour: "var(--color-s4)" },
|
||||
];
|
||||
|
||||
export function TemperatureChart({ range }: { range: Range }) {
|
||||
const polls = TEMPERATURE_LINES.map((l) => usePoll<History>(`api/history?series=${l.key}&range=${range}`, HISTORY_MS));
|
||||
const data = polls.map((p) => p.data);
|
||||
if (data.some((d) => !d)) return <Loading />;
|
||||
const rows = toRows(Object.fromEntries(data.map((d) => [d!.series, d!.points])));
|
||||
return rows.length < 2
|
||||
? <p className="py-8 text-soft">Za mało danych w tym okresie.</p>
|
||||
: <TimeChart rows={rows} lines={TEMPERATURE_LINES} unit="°C" range={range} />;
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TimeChart, type Line, type Row } from "./TimeChart";
|
||||
import { TemperatureChart } from "./TemperatureChart";
|
||||
import { serve } from "./fixtures";
|
||||
|
||||
const T0 = Date.parse("2026-10-26T08:00:00Z");
|
||||
const MIN = 60000;
|
||||
const rows: Row[] = Array.from({ length: 25 }, (_, i) => ({ t: T0 + i * 30 * MIN, a: 20 + i, b: 50 - i }));
|
||||
const lines: Line[] = [
|
||||
{ key: "a", name: "Ciepła woda", colour: "red" },
|
||||
{ key: "b", name: "Bufor CO", colour: "blue" },
|
||||
];
|
||||
|
||||
const readout = () => screen.getByRole("group", { name: "Odczyt" });
|
||||
const plot = () => screen.getByRole("img", { name: "Wykres temperatur" }).parentElement!;
|
||||
|
||||
function mount() {
|
||||
// jsdom has no layout: the plot is given the width of a phone.
|
||||
Object.defineProperty(HTMLElement.prototype, "clientWidth", { configurable: true, value: 390 });
|
||||
render(<TimeChart rows={rows} lines={lines} unit="°C" range="24h" />);
|
||||
const box = plot();
|
||||
box.getBoundingClientRect = () => ({ left: 0, width: 390 }) as DOMRect;
|
||||
return box;
|
||||
}
|
||||
|
||||
describe("TimeChart", () => {
|
||||
it("reads the latest values until the plot is touched", () => {
|
||||
mount();
|
||||
expect(readout()).toHaveTextContent("Ciepła woda44,0 °C");
|
||||
expect(readout()).toHaveTextContent("Bufor CO26,0 °C");
|
||||
expect(readout()).toHaveTextContent("Ostatni odczyt");
|
||||
expect(screen.queryByTestId("cursor")).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the values under the finger and goes back to the latest on release", () => {
|
||||
const box = mount();
|
||||
fireEvent.pointerDown(box, { clientX: 6 });
|
||||
expect(readout()).toHaveTextContent("Ciepła woda20,0 °C");
|
||||
expect(readout()).toHaveTextContent("Bufor CO50,0 °C");
|
||||
expect(screen.getByTestId("cursor")).toBeInTheDocument();
|
||||
|
||||
fireEvent.pointerMove(box, { clientX: 200 });
|
||||
expect(readout()).not.toHaveTextContent("Ostatni odczyt");
|
||||
expect(readout()).not.toHaveTextContent("Ciepła woda20,0 °C");
|
||||
|
||||
fireEvent.pointerUp(box);
|
||||
expect(readout()).toHaveTextContent("Ciepła woda44,0 °C");
|
||||
expect(screen.queryByTestId("cursor")).toBeNull();
|
||||
});
|
||||
|
||||
it("lets the page scroll vertically and never uses a floating tooltip", () => {
|
||||
const box = mount();
|
||||
expect(box.style.touchAction).toBe("pan-y");
|
||||
expect(document.querySelector("[role=tooltip]")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides and shows a series with its chip, and keeps at least one", async () => {
|
||||
mount();
|
||||
const a = screen.getByRole("button", { name: "Ciepła woda" });
|
||||
const b = screen.getByRole("button", { name: "Bufor CO" });
|
||||
|
||||
await userEvent.click(a);
|
||||
expect(a).toHaveAttribute("aria-pressed", "false");
|
||||
expect(within(readout()).queryByText("Ciepła woda")).toBeNull();
|
||||
|
||||
await userEvent.click(b);
|
||||
expect(b).toHaveAttribute("aria-pressed", "true");
|
||||
expect(within(readout()).getByText("Bufor CO")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(a);
|
||||
expect(within(readout()).getByText("Ciepła woda")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is as wide as its container, not wider", () => {
|
||||
mount();
|
||||
expect(screen.getByRole("img", { name: "Wykres temperatur" })).toHaveAttribute("width", "390");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TemperatureChart", () => {
|
||||
it("asks for ground in and pump outlet beside CWU and the buffer, not for indoor or outdoor", async () => {
|
||||
const answer = { series: "x", unit: "°C", points: [{ t: "2026-10-26T08:00:00Z", v: 30 }, { t: "2026-10-26T09:00:00Z", v: 31 }] };
|
||||
const fetch = serve({ "api/history": answer });
|
||||
render(<TemperatureChart range="24h" />);
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Do pompy" })).toBeInTheDocument();
|
||||
const urls = fetch.mock.calls.map(([url]) => String(url));
|
||||
for (const s of ["cwu_temp", "co_buffer_temp", "ground_in_temp", "pump_outlet_temp"]) {
|
||||
expect(urls).toContain(`api/history?series=${s}&range=24h`);
|
||||
}
|
||||
expect(urls.join()).not.toMatch(/indoor_temp|outdoor_temp/);
|
||||
});
|
||||
});
|
||||
+55
-141
@@ -1,4 +1,4 @@
|
||||
import { useLayoutEffect, useRef, useState, type PointerEvent } from "react";
|
||||
import { Area, CartesianGrid, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { dayAndHour, hour, number, warsaw } from "./format";
|
||||
import type { Point } from "./types";
|
||||
|
||||
@@ -60,152 +60,66 @@ export function valueTicks(min: number, max: number): number[] {
|
||||
export interface Line {
|
||||
key: string;
|
||||
name: string;
|
||||
colour: string; // a CSS colour, normally a series token of index.css
|
||||
colour: string;
|
||||
dashed?: boolean;
|
||||
}
|
||||
|
||||
const MARGIN = { top: 10, bottom: 24, left: 6 };
|
||||
const CHAR_PX = 7; // of a 12 px axis label
|
||||
|
||||
interface Layout {
|
||||
width: number;
|
||||
height: number;
|
||||
right: number; // room for the value labels, from their length
|
||||
x: (t: number) => number;
|
||||
y: (v: number) => number;
|
||||
}
|
||||
|
||||
const segments = (rows: Row[], key: string): Row[][] =>
|
||||
rows.reduce<Row[][]>((all, row) => {
|
||||
if (row[key] == null) return all.length && all[all.length - 1].length ? [...all, []] : all;
|
||||
if (!all.length) return [[row]];
|
||||
all[all.length - 1].push(row);
|
||||
return all;
|
||||
}, []);
|
||||
|
||||
const path = (part: Row[], key: string, { x, y }: Layout) =>
|
||||
part.map((r, i) => `${i ? "L" : "M"}${x(r.t).toFixed(1)} ${y(r[key] as number).toFixed(1)}`).join("");
|
||||
|
||||
const latest = (rows: Row[], key: string) => rows.findLast((r) => r[key] != null);
|
||||
|
||||
/** The row nearest to time t among those that have a value for one of the keys. */
|
||||
function nearest(rows: Row[], keys: string[], t: number): Row | undefined {
|
||||
const candidates = rows.filter((r) => keys.some((k) => r[k] != null));
|
||||
return candidates.reduce<Row | undefined>((best, r) => (!best || Math.abs(r.t - t) < Math.abs(best.t - t) ? r : best), undefined);
|
||||
}
|
||||
|
||||
function useWidth(ref: React.RefObject<HTMLDivElement | null>) {
|
||||
const [width, setWidth] = useState(340);
|
||||
useLayoutEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
const measure = () => element.clientWidth > 0 && setWidth(element.clientWidth);
|
||||
measure();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [ref]);
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
* A touch-first line chart in plain SVG (recharts keeps the readout in a floating tooltip that covers
|
||||
* the plot on a phone). Above the plot: toggle chips and a readout row that shows the latest values or,
|
||||
* while a finger or the mouse is on the plot, the values at that time. `touch-action: pan-y` leaves vertical
|
||||
* swipes to the page, so only horizontal drags scrub.
|
||||
*/
|
||||
export function TimeChart({ rows, lines, unit, range }: { rows: Row[]; lines: Line[]; unit: string; range: Range }) {
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const width = useWidth(box);
|
||||
const [hidden, setHidden] = useState<string[]>([]);
|
||||
const [cursor, setCursor] = useState<Row | null>(null);
|
||||
const shown = lines.filter((l) => !hidden.includes(l.key));
|
||||
const keys = shown.map((l) => l.key);
|
||||
const digits = unit === "°C" ? 1 : 0;
|
||||
|
||||
export function TimeChart({ rows, lines, unit, range, height = 260, area = false }: {
|
||||
rows: Row[]; lines: Line[]; unit: string; range: Range; height?: number; area?: boolean;
|
||||
}) {
|
||||
const times = rows.map((r) => r.t);
|
||||
const t0 = Math.min(...times);
|
||||
const t1 = Math.max(...times);
|
||||
const values = rows.flatMap((r) => keys.map((k) => r[k])).filter((v): v is number => v != null);
|
||||
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 label = (v: number) => number(v, valueAxis.every(Number.isInteger) ? 0 : 1);
|
||||
const right = Math.max(...valueAxis.map((v) => label(v).length)) * CHAR_PX + 6;
|
||||
const height = width < 480 ? 260 : 300;
|
||||
const [lo, hi] = [valueAxis[0], valueAxis[valueAxis.length - 1]];
|
||||
const layout: Layout = {
|
||||
width, height, right,
|
||||
x: (t) => MARGIN.left + ((t - t0) / (t1 - t0)) * (width - MARGIN.left - right),
|
||||
y: (v) => MARGIN.top + ((hi - v) / (hi - lo)) * (height - MARGIN.top - MARGIN.bottom),
|
||||
};
|
||||
const ticks = timeTicks(t0, t1, range);
|
||||
const tickText = (t: number) => (range === "7d" ? DAY_NAMES[warsaw(t + 1).weekday] : hour(new Date(t)));
|
||||
|
||||
const toggle = (key: string) => {
|
||||
if (hidden.includes(key)) setHidden(hidden.filter((k) => k !== key));
|
||||
else if (shown.length > 1) setHidden([...hidden, key]);
|
||||
};
|
||||
const scrub = (e: PointerEvent<HTMLDivElement>) => {
|
||||
const x = e.clientX - e.currentTarget.getBoundingClientRect().left;
|
||||
const fraction = (x - MARGIN.left) / (width - MARGIN.left - right);
|
||||
setCursor(nearest(rows, keys, t0 + Math.min(1, Math.max(0, fraction)) * (t1 - t0)) ?? null);
|
||||
};
|
||||
const release = () => setCursor(null);
|
||||
|
||||
const lastTime = Math.max(...keys.map((k) => latest(rows, k)?.t ?? 0));
|
||||
const readoutRow = cursor ?? rows.find((r) => r.t === lastTime);
|
||||
const digits = unit === "°C" ? 1 : 0;
|
||||
const tick = (t: number) => (range === "7d" ? DAY_NAMES[warsaw(t + 1).weekday] : hour(new Date(t)));
|
||||
return (
|
||||
<div>
|
||||
<div role="group" aria-label="Wielkości na wykresie" className="mb-2 flex flex-wrap gap-2">
|
||||
{lines.map((l) => (
|
||||
<button key={l.key} aria-pressed={!hidden.includes(l.key)} onClick={() => toggle(l.key)}
|
||||
className="flex min-h-11 items-center gap-2 rounded-full border-2 border-edge px-3 text-sm font-semibold text-soft focus-visible:outline-2 focus-visible:outline-brand aria-pressed:border-current aria-pressed:text-ink">
|
||||
<span aria-hidden="true" className="size-3 rounded-full" style={{ background: l.colour, opacity: hidden.includes(l.key) ? 0.35 : 1 }} />
|
||||
{l.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div role="group" aria-label="Odczyt" className="mb-2 rounded-xl bg-page px-3 py-2">
|
||||
<p className="text-sm font-semibold">
|
||||
{cursor ? dayAndHour(new Date(cursor.t)) : <>Ostatni odczyt: {dayAndHour(new Date(lastTime))}</>}
|
||||
</p>
|
||||
<dl className="mt-1 grid grid-cols-2 gap-x-3 gap-y-2 sm:grid-cols-4">
|
||||
{shown.map((l) => (
|
||||
<div key={l.key}>
|
||||
<dt className="flex items-center gap-1.5 text-xs text-soft">
|
||||
<span aria-hidden="true" className="size-2.5 shrink-0 rounded-full" style={{ background: l.colour }} />
|
||||
{l.name}
|
||||
</dt>
|
||||
<dd className="font-bold tabular-nums">{readoutRow?.[l.key] != null ? `${number(readoutRow[l.key], digits)} ${unit}` : "—"}</dd>
|
||||
</div>
|
||||
<div style={{ height }} className="w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -8 }}>
|
||||
<defs>
|
||||
{lines.map((l) => (
|
||||
<linearGradient key={l.key} id={`fill-${l.key}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor={l.colour} stopOpacity={0.35} />
|
||||
<stop offset="1" stopColor={l.colour} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid stroke="var(--color-edge)" vertical={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}
|
||||
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]}
|
||||
contentStyle={{ background: "var(--color-panel)", border: "1px solid var(--color-edge)", borderRadius: 12 }}
|
||||
/>
|
||||
{lines.map((l) => (area
|
||||
? <Area key={l.key} dataKey={l.key} name={l.name} stroke={l.colour} strokeWidth={2.5}
|
||||
fill={`url(#fill-${l.key})`} dot={false} isAnimationActive={false} connectNulls={false} />
|
||||
: <Line key={l.key} dataKey={l.key} name={l.name} stroke={l.colour} strokeWidth={2.5}
|
||||
strokeDasharray={l.dashed ? "5 5" : undefined} dot={false} isAnimationActive={false} connectNulls={false} />
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
<div ref={box} className="w-0 min-w-full select-none" style={{ touchAction: "pan-y", WebkitTouchCallout: "none" }}
|
||||
onPointerDown={scrub} onPointerMove={scrub} onPointerUp={release} onPointerCancel={release} onPointerLeave={release}>
|
||||
<svg width={width} height={height} role="img" aria-label="Wykres temperatur" className="block">
|
||||
{valueAxis.map((v) => (
|
||||
<g key={v}>
|
||||
<line x1={MARGIN.left} x2={width - right} y1={layout.y(v)} y2={layout.y(v)} stroke="var(--color-edge)" />
|
||||
<text x={width - right + 6} y={layout.y(v)} dy="0.32em" fontSize={12} fill="var(--color-soft)">{label(v)}</text>
|
||||
</g>
|
||||
))}
|
||||
{ticks.map((t) => (
|
||||
<text key={t} x={layout.x(t)} y={height - 6} fontSize={12} fill="var(--color-soft)" textAnchor="middle">{tickText(t)}</text>
|
||||
))}
|
||||
{shown.map((l) => segments(rows, l.key).map((part) => (
|
||||
<path key={`${l.key}-${part[0].t}`} d={path(part, l.key, layout)} fill="none" stroke={l.colour} strokeWidth={2.5}
|
||||
strokeLinejoin="round" strokeLinecap="round" />
|
||||
)))}
|
||||
{cursor && (
|
||||
<g data-testid="cursor">
|
||||
<line x1={layout.x(cursor.t)} x2={layout.x(cursor.t)} y1={MARGIN.top} y2={height - MARGIN.bottom} stroke="var(--color-ink)" strokeWidth={1.5} />
|
||||
{shown.filter((l) => cursor[l.key] != null).map((l) => (
|
||||
<circle key={l.key} cx={layout.x(cursor.t)} cy={layout.y(cursor[l.key] as number)} r={4.5} fill={l.colour} stroke="var(--color-panel)" strokeWidth={2} />
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Legend({ lines }: { lines: Line[] }) {
|
||||
return (
|
||||
<ul className="mb-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-soft">
|
||||
{lines.map((l) => (
|
||||
<li key={l.key} className="flex items-center gap-1.5">
|
||||
<svg width="18" height="6" aria-hidden="true">
|
||||
<line x1="1" y1="3" x2="17" y2="3" stroke={l.colour} strokeWidth="3" strokeLinecap="round"
|
||||
strokeDasharray={l.dashed ? "4 3" : undefined} />
|
||||
</svg>
|
||||
{l.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -23,12 +23,6 @@
|
||||
--color-brine: #22a6c9;
|
||||
--color-off: #b9c5bf;
|
||||
--color-alarm: #d92d20;
|
||||
/* Chart series (TimeChart): distinct in hue and lightness, readable on the panel. */
|
||||
--color-s1: #d97706;
|
||||
--color-s2: #dc2626;
|
||||
--color-s3: #0891b2;
|
||||
--color-s4: #7c3aed;
|
||||
--color-s5: #2563eb;
|
||||
}
|
||||
|
||||
:root { color-scheme: light; }
|
||||
@@ -46,11 +40,6 @@
|
||||
--color-side: #08170f;
|
||||
--color-side-hi: #17402f;
|
||||
--color-off: #4b5a52;
|
||||
--color-s1: #fbbf24;
|
||||
--color-s2: #f87171;
|
||||
--color-s3: #22d3ee;
|
||||
--color-s4: #a78bfa;
|
||||
--color-s5: #60a5fa;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
Reference in new issue
Block a user