Files
app/scripts/tinyhouse/dashboards.py
T
stroblmeandClaude Opus 5 4bdc0ea04a
Docs / docs (push) Canceled after 0s
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Test Backend / test-backend (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s
Panels for a ten-inch screen, and a motor button that says where it is
Both screens the house is looked at on are 1280x800, so that is what the three
dashboards are laid out for: twelve columns of 96px, twelve rows of 51px, and
nothing past the bottom, because a panel does not scroll.

The motors are one control each instead of three buttons. A button could only
publish; a segmented control reads back as well — so the motor writes what it
is doing to the same message the control sets, and the segment that is held is
the direction it actually went. Up, Stop, Down for the shutters; Close/Open for
the window and In/Out for the awning, which is what those two are for.

A run stopped part way now leaves the position unknown rather than claiming the
target it never reached, so the next command in either direction moves it.

The preflight gained the two checks this needed. One runs each sample shape
past the port that would receive it. The other is arithmetic: every tile inside
the panel and none on top of another — both silent failures on a screen with no
scrollbar, and both caught before anything is written.

Sizes were settled by looking. A slider needs three rows or its tick labels
fall off; a status icon needs three or it loses the word under the glyph; a
gauge in two rows has no arc worth reading, so the battery is a bar on Home and
a gauge on Energy where there is height for one. A chart spends eighty pixels
on its chrome whatever it is given, so two of them read on this panel and three
did not — the temperature history is the one that went, and `history` still
answers for it.

`capture-panels.mjs` is how that was checked: the three panels at the screen's
own pixels, in both themes, reporting whether anything spilled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 18:50:03 +02:00

415 lines
11 KiB
Python

"""Three screens on one panel: the house, its comfort, and its electricity.
One dashboard each rather than three pages of one, because a panel carries
several whole dashboards and switches between them on a rail — which is the
thing that exists, and pages are not.
Every control binds to the message an arbiter both reads and writes, so a tile
shows what actually reached the fixture and setting it is what overrides the
automation. There is no second tile saying what the first one really did.
"""
from __future__ import annotations
from typing import Any
#: 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]:
return {"lg": {"x": x, "y": y, "w": w, "h": h}}
def stat(id_, title, message, unit="", precision=1, dtype="float", **at):
return {
"id": id_,
"type": "stat",
"title": title,
"layout": _at(**at),
"config": {
"message": message,
"dtype": dtype,
"unit": unit,
"precision": precision,
},
}
def switch(id_, title, target, **at):
return {
"id": id_,
"type": "switch",
"title": title,
"layout": _at(**at),
"config": {"target": target, "dtype": "bool", "style": "button"},
}
def button(id_, title, target, value, **at):
return {
"id": id_,
"type": "button",
"title": title,
"layout": _at(**at),
"config": {"target": target, "dtype": "str", "value": value, "label": title},
}
def slider(id_, title, target, lo, hi, step=1, unit="", **at):
return {
"id": id_,
"type": "slider",
"title": title,
"layout": _at(**at),
"config": {
"target": target,
"dtype": "float",
"min": lo,
"max": hi,
"step": step,
"unit": unit,
},
}
def gauge(id_, title, message, lo, hi, unit="", **at):
return {
"id": id_,
"type": "gauge",
"title": title,
"layout": _at(**at),
"config": {
"message": message,
"dtype": "float",
"min": lo,
"max": hi,
"unit": unit,
"precision": 0,
},
}
def chart(id_, title, request, series, unit="", **at):
return {
"id": id_,
"type": "chart",
"title": title,
"layout": _at(**at),
"config": {
"source": "query",
"request": request,
"request_dtype": "record",
"message": series,
"dtype": "series",
"range_s": 21600,
"refresh_s": 300,
"unit": unit,
},
}
def icon(id_, title, message, rules, dtype="int", **at):
return {
"id": id_,
"type": "icon",
"title": title,
"layout": _at(**at),
"config": {"message": message, "dtype": dtype, "rules": rules},
}
def dropdown(id_, title, target, options, **at):
return {
"id": id_,
"type": "dropdown",
"title": title,
"layout": _at(**at),
"config": {
"target": target,
"dtype": "str",
"style": "segmented",
"options": [{"label": label, "value": value} for label, value in options],
},
}
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 = [
# 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",
"power.watch",
[
{"at": 0, "icon": "check", "color": "success", "label": "Normal"},
{"at": 1, "icon": "triangle-alert", "color": "warning", "label": "Watch"},
{"at": 3, "icon": "zap-off", "color": "danger", "label": "Mains"},
],
x=9,
y=0,
w=3,
h=3,
),
dropdown(
"scene",
"Scene",
"lights.scene",
[
("Off", "off"),
("Day", "day"),
("Night", "night"),
("Sleep", "sleep"),
("Outside", "outside"),
],
x=0,
y=3,
w=6,
h=3,
),
# Three rows here too: a slider is a number, a track and its tick labels.
slider(
"brightness",
"Brightness",
"lights.brightness",
0,
100,
5,
"%",
x=6,
y=3,
w=6,
h=3,
),
switch(
"appliances", "Appliances", "appliances.appliances_manual", x=0, y=6, 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=6,
y=6,
w=3,
h=2,
),
stat(
"presence",
"Who is in",
"presence.state",
dtype="str",
precision=0,
x=9,
y=6,
w=3,
h=2,
),
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=0, y=10, w=4, h=2),
"config": {
"message": "power.out_w",
"min": 0,
"max": 3000,
"unit": "W",
"precision": 0,
# How much of what the house is drawing came off the roof.
"inner": [{"message": "power.pv_w", "dtype": "float", "label": "Solar"}],
},
},
# 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 = [
{
"id": "forecast",
"type": "forecast",
"title": "The next few days",
"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=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=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=0,
y=9,
w=3,
h=3,
),
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=6,
y=9,
w=3,
h=3,
),
stat(
"canopy_why",
"Awning",
"canopy.why",
dtype="str",
precision=0,
x=9,
y=9,
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=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",
"history.power_request",
"history.power_series",
"W",
x=0,
y=3,
w=12,
h=4,
),
chart(
"battery_chart",
"State of charge",
"history.battery_request",
"history.battery_series",
"%",
x=0,
y=7,
w=12,
h=5,
),
]
SCREENS = (
("home", "Home", "house", HOME),
("comfort", "Comfort", "thermometer", COMFORT),
("energy", "Energy", "zap", ENERGY),
)