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 1b1b530cfa
commit 8224d12c8c
12 changed files with 725 additions and 99 deletions
+39 -1
View File
@@ -60,9 +60,17 @@ WidgetType = Literal[
"slider",
"input",
"dropdown",
"color",
]
INPUT_WIDGETS = {"button", "switch", "slider", "input", "dropdown"}
INPUT_WIDGETS = {"button", "switch", "slider", "input", "dropdown", "color"}
#: What a colour widget puts on the wire, by the format it was configured for.
#: The default is what the Node-RED installation this ports already sends its
#: DMX encoders — ``[h, s, v]``, hue in degrees and the other two in percent —
#: and the two alternatives exist because fixtures differ. Mirrored in the
#: client (``frontend/src/components/Dashboard/ColorWidget.tsx``).
COLOR_DTYPES = {"hsv": "list", "rgb": "list", "hex": "str"}
#: What a widget may be pointed at, by payload type. A switch that reads a
#: float has nothing to show and nothing safe to send, so the pairing belongs
@@ -80,6 +88,9 @@ WIDGET_DTYPES: dict[str, set[str]] = {
"notification": {"record"},
"bar": {"float", "int"},
"forecast": {"list"},
# Either shape a colour can travel as; which of the two this widget means
# is its ``format``, checked against ``COLOR_DTYPES`` below.
"color": {"list", "str"},
# An icon maps weather strings, bool hints and numbers alike, and a clock
# binds nothing at all, so neither has a row to be held to.
}
@@ -107,6 +118,19 @@ class WidgetDef(BaseModel):
refresh_s, range_s}``: it publishes ``{range_s, interval_s}`` to
``request`` exactly as a slider publishes a value, and draws the ``series``
a flow answers with on ``message``.
A colour widget picks what it publishes with ``format``, because fixtures
differ and a change node per tile is not the answer:
- ``hsv`` (the default) — ``[h, s, v]``, hue 0-360 degrees, saturation and
value 0-100 percent. What the Node-RED installation this ports feeds its
3CH/4CH DMX encoders, which divide by 360 and by 100.
- ``rgb`` — ``[r, g, b]``, each 0-255. The conventional range; the
reference's own encoders produce it after converting.
- ``hex`` — ``"#rrggbb"``, lowercase. Conventional likewise.
The first two are a ``list`` message, the third a ``str``, which is what
``COLOR_DTYPES`` records and the check below holds a binding to.
"""
id: str
@@ -217,6 +241,19 @@ class WidgetDef(BaseModel):
if isinstance(inner, list) and len(inner) > BAR_SEGMENTS:
raise ValueError(f"a bar nests at most {BAR_SEGMENTS} readings")
if self.type == "color":
# The row above allows both shapes a colour travels as; the format
# is what decides which of them this widget actually sends. An
# unknown one is read as the default, exactly as the client does.
fmt = str(self.config.get("format") or "hsv")
want = COLOR_DTYPES.get(fmt, "list")
bound = str(self.config.get("dtype") or "")
if bound and bound != want:
raise ValueError(
f"a colour widget sending {fmt} needs a '{want}' message, "
f"not a '{bound}'"
)
allowed = WIDGET_DTYPES.get(self.type)
if not allowed:
return self
@@ -558,6 +595,7 @@ def default_dashboard(name: str) -> DashboardDef:
__all__ = [
"BAR_SEGMENTS",
"COLOR_DTYPES",
"DASHBOARD_DIR",
"HISTORY_CAP",
"INPUT_WIDGETS",
+37
View File
@@ -303,6 +303,43 @@ def test_a_querying_chart_asks_with_a_record_and_draws_a_series():
WidgetDef(id="c", type="chart", config=query_chart(request_dtype="json"))
def test_a_colour_widget_publishes_the_shape_its_format_names():
"""The format picks the payload, so the binding is held to that one."""
wheel = WidgetDef(
id="lamp",
type="color",
config={"target": "a.color", "dtype": "list", "format": "hsv"},
)
assert wheel.target == "a.color"
# It publishes rather than reads: nothing is drawn from a message.
assert wheel.messages == []
# Hex is the same widget speaking a string, and rgb is still a list.
WidgetDef(
id="l", type="color", config={"target": "a.c", "dtype": "str", "format": "hex"}
)
WidgetDef(
id="l", type="color", config={"target": "a.c", "dtype": "list", "format": "rgb"}
)
# Nothing recorded binds anything, as everywhere else.
WidgetDef(id="l", type="color", config={"target": "a.c"})
with pytest.raises(ValueError):
WidgetDef(
id="l",
type="color",
config={"target": "a.c", "dtype": "str", "format": "hsv"},
)
with pytest.raises(ValueError):
WidgetDef(
id="l",
type="color",
config={"target": "a.c", "dtype": "list", "format": "hex"},
)
with pytest.raises(ValueError):
WidgetDef(id="l", type="color", config={"target": "a.c", "dtype": "float"})
def test_a_querying_chart_keeps_no_ring():
"""The answer carries its own past; a ring would store it twice."""
widget = WidgetDef(