Add a colour-wheel widget to the dashboard

A custom hue ring — a conic gradient, not a canvas — with saturation and
brightness sliders beside or under it depending on the tile's shape, sized
for a wall panel and reachable from a keyboard. It publishes [h, s, v] by
default, which is what the reference installation's DMX encoders read, and
`format` switches that to [r, g, b] or "#rrggbb".

`usePublish` moves to its own module so a widget in a file of its own can
reach it without importing `widgets.tsx` back.
This commit is contained in:
2026-08-22 12:50:59 +02:00
parent 3cc2d353cd
commit 9927577cec
13 changed files with 804 additions and 101 deletions
+79 -2
View File
@@ -15,7 +15,7 @@ What it builds::
The panel is a small-house energy and climate display — the thing a person
would actually hang in a hallway — and it is also the widget gallery: all
fifteen types are on it, including the variants no editor field can write
sixteen types are on it, including the variants no editor field can write
(a switch drawn as a button, a segmented dropdown, a bar with a nested
reading, an icon ladder with labels, fixed chart axes, decimal places).
@@ -236,6 +236,41 @@ def process(day_start, climate=None):
return {"forecast": forecast, "agenda": agenda}
'''
LAMP_SOURCE = '''"""What the hallway lamp was set to, said in words.
The wheel on the panel publishes ``[h, s, v]`` — hue in degrees, saturation and
value in percent, which is what a DMX encoder divides down. A real installation
puts an MQTT or Art-Net node here; this only names the colour, so the panel can
show that the control reached something.
"""
#: Where each name starts, in degrees. Read from the bottom up, so the last row
#: a hue has passed wins — and red is at both ends, because the wheel wraps.
NAMES = [
(0, "Red"),
(15, "Amber"),
(45, "Yellow"),
(70, "Lime"),
(100, "Green"),
(160, "Cyan"),
(200, "Blue"),
(260, "Violet"),
(290, "Magenta"),
(320, "Pink"),
(350, "Red"),
]
def process(light_color):
hue, saturation, value = (list(light_color) + [0, 0, 0])[:3]
if value < 2:
return {"light_state": "Off"}
if saturation < 10:
return {"light_state": "White at {:.0f}%".format(value)}
name = next(label for at, label in reversed(NAMES) if hue >= at)
return {"light_state": "{} at {:.0f}%".format(name, value)}
'''
HOUSE_INPUTS = [
# What the controls on the panel write to. The initial values are what the
# dashboard reads back before anyone touches anything, which is why the
@@ -244,6 +279,12 @@ HOUSE_INPUTS = [
{"spec": {"name": "away", "dtype": "bool"}, "initial": False},
{"spec": {"name": "setpoint", "dtype": "float"}, "initial": 21.0},
{"spec": {"name": "tariff", "dtype": "float"}, "initial": 32.0},
# Three numbers rather than a record: [hue, saturation, value], which is
# what the colour wheel sends and what a DMX encoder reads.
{
"spec": {"name": "light_color", "dtype": "list", "item": "float"},
"initial": [38, 72, 80],
},
# False rather than nothing: a declared message with no starting value is
# a node that can never run, and the engine says so on the canvas.
{"spec": {"name": "boost", "dtype": "bool"}, "initial": False},
@@ -368,6 +409,13 @@ HOUSE_NODES = [
"requires": [{"name": "boost", "dtype": "bool"}],
"provides": [{"name": "hot_water", "dtype": "bool"}],
},
{
"id": "lamp",
"type": "python",
"title": "Name the lamp's colour",
"requires": [{"name": "light_color", "dtype": "list", "item": "float"}],
"provides": [{"name": "light_state", "dtype": "str"}],
},
{
"id": "water_label",
"type": "change",
@@ -961,6 +1009,30 @@ ENERGY_WIDGETS = [
"label": "Boost 15 min",
},
},
{
"id": "mood",
"type": "color",
"title": "Hallway lamp",
# Wider than it is tall, which is what the wheel lays itself out for:
# the ring takes the row's height and the two components sit beside it.
"layout": at(0, 6, 11, 2),
"config": {
"target": msg(HOUSE, "light_color"),
"dtype": "list",
# The default, said out loud: [h, s, v] as a DMX encoder reads it.
# "rgb" sends three 0-255 channels, "hex" a "#rrggbb" string.
"format": "hsv",
},
},
{
"id": "lamp",
"type": "stat",
"title": "Lamp",
"layout": at(11, 6, 5, 2),
# What the flow made of the colour — the proof that the wheel reached
# something, the way the hot-water button has its own state beside it.
"config": {"message": msg(HOUSE, "light_state"), "dtype": "str"},
},
]
MODEL_WIDGETS = [
@@ -1249,7 +1321,12 @@ def main() -> int:
HOUSE,
"House",
HOUSE_NODES,
{"house": HOUSE_SOURCE, "meter": METER_SOURCE, "plan": PLAN_SOURCE},
{
"house": HOUSE_SOURCE,
"meter": METER_SOURCE,
"plan": PLAN_SOURCE,
"lamp": LAMP_SOURCE,
},
inputs=HOUSE_INPUTS,
)
seed_flow(