diff --git a/NOTEPAD.md b/NOTEPAD.md index 5f74dc6..4bfdffa 100644 --- a/NOTEPAD.md +++ b/NOTEPAD.md @@ -12,6 +12,22 @@ Deferring because out of scope is fine, but don't mention deferring than. ### To be sorted +- CHORE/UI: the house panels are laid out for 1280x800 — twelve columns, twelve + rows — and that is as much as fits: a chart spends about eighty pixels on its + title, range picker and legend whatever height it is given, so two of them + read on that panel and three do not. The temperature history was the one + dropped; `history.climate_*` still answers, so it is a tile away. +- FEAT/UI: a bar's nested reading is captioned by its port name, which is + chosen for the graph rather than for somebody reading it across a room. + `Segment` now takes an optional `label`; the house dashboard sets one + (`Solar`), and it shows on the next frontend build. +- BUG/INFRA: the running `fluksio-frontend` image calls `https://api.localhost` + while `app/.env` says `VITE_API_URL=http://api.localhost` — the image predates + the environment being switched to `local`. Anything served from that bundle + reaches the API only where something terminates TLS for `api.localhost`. The + next frontend build changes the origin, so check how the app is actually + reached before making one. + - BUG/FLOW: **a cancelled request can leave `FlowController._lock` held forever.** Seeding nineteen flows over a client that timed out mid-request left the next `POST /flows/{name}/start` waiting on the lock indefinitely — ten minutes, until diff --git a/frontend/scripts/capture-panels.mjs b/frontend/scripts/capture-panels.mjs new file mode 100644 index 0000000..dfa4358 --- /dev/null +++ b/frontend/scripts/capture-panels.mjs @@ -0,0 +1,116 @@ +/** + * The house panels, at the size of the screen they hang on. + * + * `/view/{name}` is the wall-panel route: no sidebar, no editor. Shooting it + * at exactly the panel's pixels is the only way to see whether a tile fell off + * the bottom, because a panel does not scroll. + * + * Env: APP_URL, FIRST_SUPERUSER, FIRST_SUPERUSER_PASSWORD, + * PANELS (comma-separated), PANEL_SIZE ("1280x800"), SCREENSHOT_DIR + */ +import { mkdir } from "node:fs/promises" +import { chromium } from "@playwright/test" + +const APP_URL = process.env.APP_URL || "http://app.localhost" +const EMAIL = process.env.FIRST_SUPERUSER +const PASSWORD = process.env.FIRST_SUPERUSER_PASSWORD +const OUT = process.env.SCREENSHOT_DIR || "screenshots/panels" +const NAMES = (process.env.PANELS || "home,comfort,energy").split(",") +const [width, height] = (process.env.PANEL_SIZE || "1280x800") + .split("x") + .map(Number) + +if (!EMAIL || !PASSWORD) { + console.error("FIRST_SUPERUSER / FIRST_SUPERUSER_PASSWORD are unset.") + process.exit(1) +} + +/** What the bundle asks for, and where it is really answered. */ +const API_PUBLIC = process.env.API_PUBLIC || "https://api.localhost" +const API_ORIGIN = process.env.API_ORIGIN || "" +/** Where the bundle is really served from, for fetches outside the browser. */ +const APP_ORIGIN = process.env.APP_ORIGIN || "" + +const RESOLVER = process.env.HOST_RESOLVER_RULES +const browser = await chromium.launch( + RESOLVER ? { args: [`--host-resolver-rules=${RESOLVER}`] } : {}, +) + +for (const theme of ["light", "dark"]) { + const dir = `${OUT}/${theme}` + await mkdir(dir, { recursive: true }) + const context = await browser.newContext({ + viewport: { width, height }, + colorScheme: theme, + // A panel is a touch screen, and several controls draw a taller target + // for one. Shooting it as a mouse would misreport the layout. + hasTouch: true, + }) + await context.addInitScript((t) => { + localStorage.setItem("fluksio-ui-theme", t) + }, theme) + + // The bundle addresses the API at whatever origin it was built for, and + // this stack has no TLS in front of it — so the calls are re-issued at the + // address the container actually answers on. API_ORIGIN is that address; + // without it nothing is intercepted and the page is left alone. + if (API_ORIGIN) { + // The origin is baked into the bundle at build time, and this stack has no + // TLS in front of it. Rewriting it in the JavaScript as it is served fixes + // the websocket too — routing only the HTTP calls would leave every widget + // showing an em dash, since live values arrive over the socket. + const wsPublic = API_PUBLIC.replace(/^http/, "ws") + const wsOrigin = API_ORIGIN.replace(/^http/, "ws") + await context.route("**/*.js", async (route) => { + // route.fetch runs in Playwright's own process, which does not have the + // browser's resolver rules — so it is told the address directly. + const response = await route.fetch( + APP_ORIGIN + ? { url: route.request().url().replace(APP_URL, APP_ORIGIN) } + : {}, + ) + const body = (await response.text()) + .split(API_PUBLIC) + .join(API_ORIGIN) + .split(wsPublic) + .join(wsOrigin) + await route.fulfill({ response, body }) + }) + } + + const page = await context.newPage() + page.on("console", (m) => { + if (m.type() === "error") console.log(` console: ${m.text()}`) + }) + page.on("requestfailed", (r) => + console.log(` failed: ${r.url()} ${r.failure()?.errorText}`), + ) + await page.goto(`${APP_URL}/login`, { waitUntil: "networkidle" }) + await page.getByTestId("email-input").fill(EMAIL) + await page.getByTestId("password-input").fill(PASSWORD) + await page.getByRole("button", { name: /log in/i }).click() + await page.waitForURL(`${APP_URL}/`, { timeout: 20000 }) + + for (const name of NAMES) { + await page.goto(`${APP_URL}/view/${name}`, { waitUntil: "networkidle" }) + // Widgets fetch their own values; the chart draws after its answer lands. + await page.waitForTimeout(3000) + await page.screenshot({ path: `${dir}/${name}.png` }) + const overflow = await page.evaluate(() => ({ + scrollH: document.documentElement.scrollHeight, + clientH: document.documentElement.clientHeight, + scrollW: document.documentElement.scrollWidth, + clientW: document.documentElement.clientWidth, + })) + const spills = + overflow.scrollH > overflow.clientH + 1 || + overflow.scrollW > overflow.clientW + 1 + console.log( + ` ${theme}/${name}: ${spills ? "SPILLS " : "fits "} ` + + `${overflow.scrollW}x${overflow.scrollH} in ${overflow.clientW}x${overflow.clientH}`, + ) + } + await context.close() +} + +await browser.close() diff --git a/frontend/src/components/Dashboard/BarWidget.tsx b/frontend/src/components/Dashboard/BarWidget.tsx index b2e12d6..e14574b 100644 --- a/frontend/src/components/Dashboard/BarWidget.tsx +++ b/frontend/src/components/Dashboard/BarWidget.tsx @@ -34,7 +34,7 @@ export const MAX_SEGMENTS = 3 /** Outer fill left around a segment, as `inset-y-1` leaves it above and below. */ const GUTTER = "2px" -export type Segment = { message?: string; dtype?: string } +export type Segment = { message?: string; dtype?: string; label?: string } /** A segment as drawn: where it runs on the fill, and what it reads. */ type Band = { start: number; end: number; level: number; name: string } @@ -48,7 +48,13 @@ export const segmentsOf = (widget: WidgetProps["widget"]): Segment[] => { if (Array.isArray(cfg.inner)) return (cfg.inner as Segment[]).slice(0, MAX_SEGMENTS) return cfg.inner - ? [{ message: text(cfg.inner), dtype: text(cfg.inner_dtype) }] + ? [ + { + message: text(cfg.inner), + dtype: text(cfg.inner_dtype), + label: text(cfg.inner_label), + }, + ] : [] } @@ -108,8 +114,10 @@ export function BarWidget({ widget }: WidgetProps) { end: cursor, level, // The panel already carries the widget's title, so the caption names the - // reading by its port rather than repeating the flow it comes from. - name: displayName(flowOf(name), name), + // reading by its port rather than repeating the flow it comes from — or + // by whatever the author called it, since a port name is chosen for the + // graph and not for somebody reading it across a room. + name: text(segment.label) || displayName(flowOf(name), name), }) } const detail = drawn diff --git a/scripts/tinyhouse/__main__.py b/scripts/tinyhouse/__main__.py index e3d4116..c41d8cb 100644 --- a/scripts/tinyhouse/__main__.py +++ b/scripts/tinyhouse/__main__.py @@ -108,6 +108,7 @@ def check(flows: list[Flow]) -> list[str]: problems += _check_types(flows) problems += _check_cycles(flows, provided) problems += _check_widgets(known) + problems += _check_layout() problems += _check_sources(flows) problems += _check_schemas(flows) problems += _check_injects(flows) @@ -273,6 +274,40 @@ def _check_types(flows: list[Flow]) -> list[str]: return problems +def _check_layout() -> list[str]: + """Every tile inside the panel, and none of them on top of another. + + A panel does not scroll: a widget past the last row is simply not on the + screen, and two in one cell draw over each other. Both are silent, and + both are arithmetic. + """ + problems = [] + for _, title, _, widgets in dashboards.SCREENS: + taken: dict[tuple[int, int], str] = {} + for widget in widgets: + box = widget["layout"]["lg"] + x, y, w, h = box["x"], box["y"], box["w"], box["h"] + if x + w > dashboards.COLUMNS: + problems.append( + f"{title}: '{widget['id']}' runs to column {x + w} of " + f"{dashboards.COLUMNS}" + ) + if y + h > dashboards.ROWS: + problems.append( + f"{title}: '{widget['id']}' runs to row {y + h} of " + f"{dashboards.ROWS} — off the bottom of the panel" + ) + for cell in ((cx, cy) for cx in range(x, x + w) for cy in range(y, y + h)): + other = taken.get(cell) + if other: + problems.append( + f"{title}: '{widget['id']}' overlaps '{other}' at {cell}" + ) + break + taken[cell] = widget["id"] + return problems + + def _check_widgets(known: set[str]) -> list[str]: """A tile bound to a message nothing carries draws nothing, quietly.""" problems = [] diff --git a/scripts/tinyhouse/api.py b/scripts/tinyhouse/api.py index 22d4024..491f90d 100644 --- a/scripts/tinyhouse/api.py +++ b/scripts/tinyhouse/api.py @@ -15,6 +15,8 @@ from typing import Any import httpx +from . import dashboards + API = os.environ.get("API_URL", "http://api.localhost") EMAIL = os.environ.get("FIRST_SUPERUSER", "") PASSWORD = os.environ.get("FIRST_SUPERUSER_PASSWORD", "") @@ -112,9 +114,9 @@ class Api: **current, "title": title, "icon": icon, - "columns": 16, - "canvas_width": 2560, - "canvas_height": 1600, + "columns": dashboards.COLUMNS, + "canvas_width": dashboards.CANVAS[0], + "canvas_height": dashboards.CANVAS[1], "pages": [ { "id": "main", diff --git a/scripts/tinyhouse/dashboards.py b/scripts/tinyhouse/dashboards.py index 2558569..56d8616 100644 --- a/scripts/tinyhouse/dashboards.py +++ b/scripts/tinyhouse/dashboards.py @@ -13,7 +13,10 @@ from __future__ import annotations from typing import Any -COLUMNS = 16 +#: A ten-inch panel: 1280x800, twelve columns of 96px, twelve rows of 51px. +COLUMNS = 12 +CANVAS = (1280, 800) +ROWS = 12 def _at(x: int, y: int, w: int, h: int) -> dict[str, Any]: @@ -133,19 +136,34 @@ def dropdown(id_, title, target, options, **at): } -def _cover(prefix, title, target, y): - """Three buttons, because a shutter with no position sensor has three states.""" - return [ - button(f"{prefix}_up", f"{title} up", target, "UP", x=0, y=y, w=2, h=2), - button(f"{prefix}_stop", "Stop", target, "STOP", x=2, y=y, w=2, h=2), - button(f"{prefix}_down", f"{title} down", target, "DOWN", x=4, y=y, w=2, h=2), - ] +def cover(id_, title, target, labels, **at): + """A motor, as one control that both moves it and says where it is. + + Three buttons could only ever publish; this reads back as well, because + the motor writes what it is doing to the same message the control sets — + so the segment that is held is the direction it actually went. + """ + up, stop, down = labels + return dropdown( + id_, + title, + target, + [(up, "UP"), (stop, "STOP"), (down, "DOWN")], + **at, + ) + + +# Three screens, each on the 1280x800 of a ten-inch panel: twelve columns of +# 96px and twelve rows of 51px. Nothing runs past the bottom, because a panel +# does not scroll — what does not fit is not on the screen. HOME = [ - stat("indoor", "Inside", "climate.indoor", "°C", 1, x=0, y=0, w=3, h=2), - stat("outdoor", "Outside", "weather.outdoor_temp", "°C", 1, x=3, y=0, w=3, h=2), - stat("humidity", "Humidity", "weather.indoor_hum", "%", 0, x=6, y=0, w=3, h=2), + # The top band is three rows because the status icon draws a glyph *and* a + # word, and in two the word falls off the bottom of the tile. + stat("indoor", "Inside", "climate.indoor", "°C", 1, x=0, y=0, w=3, h=3), + stat("outdoor", "Outside", "weather.outdoor_temp", "°C", 1, x=3, y=0, w=3, h=3), + stat("humidity", "Humidity", "weather.indoor_hum", "%", 0, x=6, y=0, w=3, h=3), icon( "power_state", "Power", @@ -158,15 +176,8 @@ HOME = [ x=9, y=0, w=3, - h=2, + h=3, ), - { - "id": "alert", - "type": "notification", - "title": "What is happening", - "layout": _at(x=12, y=0, w=4, h=4), - "config": {"message": "power.alert"}, - }, dropdown( "scene", "Scene", @@ -179,10 +190,11 @@ HOME = [ ("Outside", "outside"), ], x=0, - y=2, - w=8, - h=2, + y=3, + w=6, + h=3, ), + # Three rows here too: a slider is a number, a track and its tick labels. slider( "brightness", "Brightness", @@ -191,24 +203,23 @@ HOME = [ 100, 5, "%", - x=8, - y=2, - w=4, - h=2, + x=6, + y=3, + w=6, + h=3, ), - stat("lit", "Lit", "lights.lit", dtype="str", precision=0, x=0, y=4, w=4, h=2), switch( - "appliances", "Appliances", "appliances.appliances_manual", x=4, y=4, w=3, h=2 + "appliances", "Appliances", "appliances.appliances_manual", x=0, y=6, w=3, h=2 ), - switch("bath", "Bathroom plug", "plugs.bath_manual", x=7, y=4, w=3, h=2), + switch("bath", "Bathroom plug", "plugs.bath_manual", x=3, y=6, w=3, h=2), stat( "floor", "Floor heating", "oven.floor_heating", dtype="bool", precision=0, - x=10, - y=4, + x=6, + y=6, w=3, h=2, ), @@ -218,28 +229,69 @@ HOME = [ "presence.state", dtype="str", precision=0, - x=13, - y=4, + x=9, + y=6, w=3, h=2, ), - *_cover("door", "Door", "shutters.door_shutter_cmd", y=6), - *_cover("bed", "Bed", "shutters.bed_shutter_cmd", y=8), + cover( + "door", + "Door", + "shutters.door_shutter_cmd", + ("Up", "Stop", "Down"), + x=0, + y=8, + w=6, + h=2, + ), + cover( + "bed", + "Bed", + "shutters.bed_shutter_cmd", + ("Up", "Stop", "Down"), + x=6, + y=8, + w=6, + h=2, + ), { "id": "load", "type": "bar", "title": "House draw", - "layout": _at(x=6, y=6, w=6, h=4), + "layout": _at(x=0, y=10, w=4, h=2), "config": { "message": "power.out_w", "min": 0, "max": 3000, "unit": "W", "precision": 0, - "inner": [{"message": "power.pv_w", "dtype": "float"}], + # How much of what the house is drawing came off the roof. + "inner": [{"message": "power.pv_w", "dtype": "float", "label": "Solar"}], }, }, - gauge("soc", "Battery", "power.soc", 0, 100, "%", x=12, y=6, w=4, h=4), + # A bar rather than a gauge: two rows is not enough arc to read across a + # room, and the same number in a bar is. The gauge is on Energy, where it + # has the height for one. + { + "id": "soc", + "type": "bar", + "title": "Battery", + "layout": _at(x=4, y=10, w=4, h=2), + "config": { + "message": "power.soc", + "min": 0, + "max": 100, + "unit": "%", + "precision": 0, + }, + }, + { + "id": "alert", + "type": "notification", + "title": "What is happening", + "layout": _at(x=8, y=10, w=4, h=2), + "config": {"message": "power.alert"}, + }, ] COMFORT = [ @@ -247,42 +299,65 @@ COMFORT = [ "id": "forecast", "type": "forecast", "title": "The next few days", - "layout": _at(x=0, y=0, w=8, h=3), + "layout": _at(x=0, y=0, w=6, h=3), "config": {"message": "weather.forecast", "count": 4}, }, { "id": "agenda", "type": "agenda", "title": "Coming up", - "layout": _at(x=8, y=0, w=8, h=3), - "config": {"message": "calendar.events", "count": 5}, + "layout": _at(x=6, y=0, w=6, h=3), + "config": {"message": "calendar.events", "count": 4}, }, - slider("preset", "Wanted", "climate.preset", 16, 26, 0.5, "°C", x=0, y=3, w=5, h=2), - stat("setpoint", "Band", "climate.t_max", "°C", 1, x=5, y=3, w=3, h=2), - switch("ac", "Heat pump enabled", "hvac.enabled", x=8, y=3, w=4, h=2), + slider("preset", "Wanted", "climate.preset", 16, 26, 0.5, "°C", x=0, y=3, w=4, h=3), + stat("band", "Up to", "climate.t_max", "°C", 1, x=4, y=3, w=2, h=3), + switch("ac", "Heat pump", "hvac.enabled", x=6, y=3, w=3, h=3), + switch("oven", "Pellet stove", "oven.oven_manual", x=9, y=3, w=3, h=3), + cover( + "window", + "Window", + "window.window_manual", + ("Close", "Stop", "Open"), + x=0, + y=6, + w=4, + h=3, + ), + cover( + "canopy", + "Awning", + "canopy.canopy_manual", + ("In", "Stop", "Out"), + x=4, + y=6, + w=4, + h=3, + ), + switch("outdoor", "Outdoor plug", "outdoor.outdoor_manual", x=8, y=6, w=4, h=3), + # Why each of them is doing what it is doing. A sentence needs the room, + # and this is the screen where "off" without a reason is the annoying bit. stat( "hvac_why", "Heat pump", "hvac.why", dtype="str", precision=0, - x=12, - y=3, - w=4, - h=2, + x=0, + y=9, + w=3, + h=3, ), - switch("oven", "Pellet stove", "oven.oven_manual", x=0, y=5, w=4, h=2), - stat("oven_why", "Stove", "oven.why", dtype="str", precision=0, x=4, y=5, w=4, h=2), + stat("oven_why", "Stove", "oven.why", dtype="str", precision=0, x=3, y=9, w=3, h=3), stat( "window_why", "Window", "window.why", dtype="str", precision=0, - x=8, - y=5, - w=4, - h=2, + x=6, + y=9, + w=3, + h=3, ), stat( "canopy_why", @@ -290,43 +365,24 @@ COMFORT = [ "canopy.why", dtype="str", precision=0, - x=12, - y=5, - w=4, - h=2, - ), - *_cover("window", "Window", "window.window_manual", y=7), - *_cover("canopy", "Awning", "canopy.canopy_manual", y=9), - switch("outdoor", "Outdoor plug", "outdoor.outdoor_manual", x=6, y=7, w=4, h=2), - stat( - "outdoor_why", - "Outdoor plug", - "outdoor.why", - dtype="str", - precision=0, - x=10, - y=7, - w=6, - h=2, - ), - chart( - "climate_chart", - "Inside and out", - "history.climate_request", - "history.climate_series", - "°C", - x=6, + x=9, y=9, - w=10, - h=5, + w=3, + h=3, ), ] +# The three charts share a screen, so the history is read in one place and the +# live figures above it say what "now" is on the same scales. +# A chart spends about eighty pixels on its title, its range picker and its +# legend whatever height it is given, so on an 800px panel two of them read and +# three do not. The temperature history is the one that went; `history` still +# answers for it, so it is a tile away if something else here is worth less. ENERGY = [ - gauge("pv", "Solar", "power.pv_w", 0, 3000, "W", x=0, y=0, w=4, h=4), - gauge("draw", "House", "power.out_w", 0, 3000, "W", x=4, y=0, w=4, h=4), - gauge("grid", "Mains", "power.in_v", 0, 260, "V", x=8, y=0, w=4, h=4), - gauge("charge", "Battery", "power.soc", 0, 100, "%", x=12, y=0, w=4, h=4), + gauge("pv", "Solar", "power.pv_w", 0, 3000, "W", x=0, y=0, w=3, h=3), + gauge("draw", "House", "power.out_w", 0, 3000, "W", x=3, y=0, w=3, h=3), + gauge("grid", "Mains", "power.in_v", 0, 260, "V", x=6, y=0, w=3, h=3), + gauge("charge", "Battery", "power.soc", 0, 100, "%", x=9, y=0, w=3, h=3), chart( "power_chart", "Where the power went", @@ -334,9 +390,9 @@ ENERGY = [ "history.power_series", "W", x=0, - y=4, - w=16, - h=6, + y=3, + w=12, + h=4, ), chart( "battery_chart", @@ -345,8 +401,8 @@ ENERGY = [ "history.battery_series", "%", x=0, - y=10, - w=16, + y=7, + w=12, h=5, ), ] diff --git a/scripts/tinyhouse/library.py b/scripts/tinyhouse/library.py index e74fe7f..7314816 100644 --- a/scripts/tinyhouse/library.py +++ b/scripts/tinyhouse/library.py @@ -87,13 +87,13 @@ def process(cmd, state=None, up_s=26.0, down_s=28.0): if cmd == STOP: if not moving: return None - run, run_for, target = STOP, 0.0, position + run, run_for, target = STOP, 0.0, "" elif moving == cmd: return None elif moving: # Reversing mid-travel drives both relays at once on the way past. # Stop; the next command moves it. - run, run_for, target = STOP, 0.0, position + run, run_for, target = STOP, 0.0, "" elif cmd == position: return None else: @@ -104,9 +104,16 @@ def process(cmd, state=None, up_s=26.0, down_s=28.0): return { "run": run, "run_for": run_for, + # Written back to the message the control publishes on, so one segmented + # button both sets the direction and shows which one it is doing — the + # same trick the arbiter uses. A run that was refused returns nothing at + # all, so the control keeps what the person put there. + "cmd": run, "state": { "moving": "" if run == STOP else run, "moving_until": 0.0 if run == STOP else now + run_for, + # A run stopped part way is at no position anybody knows, so the + # next command in either direction has to be allowed to move it. "position": target, }, } @@ -178,6 +185,7 @@ def motor( "provides": [ {"name": run, "port": "run", "dtype": "str"}, {"name": run_for, "port": "run_for", "dtype": "float"}, + {"name": cmd, "port": "cmd", "dtype": "str"}, {"name": state, "port": "state", "dtype": "record"}, ], }