Files
app/scripts/tinyhouse/dashboards.py
T
stroblmeandClaude Opus 5 436ca7e9af Seed the TinyHouse: nineteen flows in place of eight hundred nodes
The Node-RED installation this replaces is 865 nodes across three tabs, and
roughly a fifth of it is unreachable — the pellet stove's controller, the
scene engine and the awning's logic were all disconnected from the heartbeat
they ran on. What is here is the intent rather than the wiring: nineteen named
flows, 109 nodes, and no heartbeat at all. A sensor value is the event.

The device layer moves with it. `actor/*` and `light/*` were never a device
interface — Node-RED subscribed to its own topics, stamped a DMX channel on
each and encoded one Art-Net universe — so those topics retire with it and the
encoders are five nodes in the `dmx` flow.

Two shared library nodes carry what every actuator needs.

`arbiter` answers the thing this design was missing: a value someone sets on a
screen is not undone by the next evaluation. A manual value wins for a hold,
the house takes over when it expires, and a schedule can force past both — so
"off at two in the morning" still means off. The control binds to the message
the arbiter writes back, so one tile shows what reached the fixture and
setting it is the override.

`motor` is why a stop is now commanded once. A rollershutter has no position
sensor, so time is the only feedback: it says how long to run and a trigger
sends the single STOP that ends it. The reference sent STOP forever.

Everything is seeded stopped, the Art-Net node does not transmit and the heat
pump does not accept commands until house.json says so.

`--dry` checks the whole set without an installation: names nothing provides,
loops, type disagreements, widgets bound to nothing, and every Python node run
once on values of the shape it declared — including whether what it returns
goes anywhere. That last one has already caught a typo that would have
published into silence.

house.json holds this installation's addresses, MAC addresses and DMX map and
is git-ignored, as the Node-RED inventory is.

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

359 lines
8.9 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
COLUMNS = 16
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(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),
]
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),
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=2,
),
{
"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",
"lights.scene",
[
("Off", "off"),
("Day", "day"),
("Night", "night"),
("Sleep", "sleep"),
("Outside", "outside"),
],
x=0,
y=2,
w=8,
h=2,
),
slider(
"brightness",
"Brightness",
"lights.brightness",
0,
100,
5,
"%",
x=8,
y=2,
w=4,
h=2,
),
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
),
switch("bath", "Bathroom plug", "plugs.bath_manual", x=7, y=4, w=3, h=2),
stat(
"floor",
"Floor heating",
"oven.floor_heating",
dtype="bool",
precision=0,
x=10,
y=4,
w=3,
h=2,
),
stat(
"presence",
"Who is in",
"presence.state",
dtype="str",
precision=0,
x=13,
y=4,
w=3,
h=2,
),
*_cover("door", "Door", "shutters.door_shutter_cmd", y=6),
*_cover("bed", "Bed", "shutters.bed_shutter_cmd", y=8),
{
"id": "load",
"type": "bar",
"title": "House draw",
"layout": _at(x=6, y=6, w=6, h=4),
"config": {
"message": "power.out_w",
"min": 0,
"max": 3000,
"unit": "W",
"precision": 0,
"inner": [{"message": "power.pv_w", "dtype": "float"}],
},
},
gauge("soc", "Battery", "power.soc", 0, 100, "%", x=12, y=6, w=4, h=4),
]
COMFORT = [
{
"id": "forecast",
"type": "forecast",
"title": "The next few days",
"layout": _at(x=0, y=0, w=8, 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},
},
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),
stat(
"hvac_why",
"Heat pump",
"hvac.why",
dtype="str",
precision=0,
x=12,
y=3,
w=4,
h=2,
),
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(
"window_why",
"Window",
"window.why",
dtype="str",
precision=0,
x=8,
y=5,
w=4,
h=2,
),
stat(
"canopy_why",
"Awning",
"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,
y=9,
w=10,
h=5,
),
]
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),
chart(
"power_chart",
"Where the power went",
"history.power_request",
"history.power_series",
"W",
x=0,
y=4,
w=16,
h=6,
),
chart(
"battery_chart",
"State of charge",
"history.battery_request",
"history.battery_series",
"%",
x=0,
y=10,
w=16,
h=5,
),
]
SCREENS = (
("home", "Home", "house", HOME),
("comfort", "Comfort", "thermometer", COMFORT),
("energy", "Energy", "zap", ENERGY),
)