Add the dashboard settings channel, wired for theme and lock
A dashboard could only ever receive as a set of tiles. This adds the dashboard itself as a receiver: `settings` maps a name to a value plus an optional binding. Unbound, the setting is simply its value — a wall panel that is always dark costs no flow. Bound, a flow drives it live and the value is the fallback. Two settings are wired: `theme` (system/light/dark) and `locked` (read-only). There is no schedule field on purpose — a node publishing to the bound message on a cron is what a schedule is here, which is the point of a channel. - `messages_for()` now walks a dashboard's bound settings as well as its widgets' bindings. Without this a paired screen is refused its own theme message, on the one surface the setting exists for; it bounds the socket too. - `locked` is gated in `usePublish`, so every control inherits it, and each control also draws itself disabled — a dead button reads as broken otherwise. The panel surface says Read-only in the corner. - The theme is a class on the dashboard's own surface, never the root: inside the app shell it must not flip the chrome. `.light` gains the tokens `.dark` already had (mirrored in the index repo) so both directions work on a subtree. - Settings bindings are type-checked from the document alone, the rule widget bindings follow, and mirrored on the server. - A bound setting is drawn on the flow canvas as a dashboard-level endpoint. - The demo's house flow now publishes `home.panel_theme`, which the demo dashboard's theme binds to: the panel goes dark after sunset, at no tile cost. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
This commit is contained in:
@@ -96,6 +96,42 @@ WIDGET_DTYPES: dict[str, set[str]] = {
|
||||
}
|
||||
|
||||
|
||||
#: What a dashboard-wide setting may be bound to, by payload type. The channel
|
||||
#: is general — a setting is a value plus an optional binding — but the wired
|
||||
#: ones are a closed set, and a name missing here is simply a setting this
|
||||
#: build does not act on. Mirrored in the client
|
||||
#: (``frontend/src/components/Dashboard/settings.tsx``).
|
||||
SETTING_DTYPES: dict[str, str] = {
|
||||
# "system" | "light" | "dark". A panel in a room has no way to set the
|
||||
# device preference the app otherwise inherits.
|
||||
"theme": "str",
|
||||
# Read-only: the input widgets stop publishing.
|
||||
"locked": "bool",
|
||||
}
|
||||
|
||||
|
||||
class SettingDef(BaseModel):
|
||||
"""One dashboard-wide setting: a value, and optionally where it comes from.
|
||||
|
||||
Unbound — no ``message`` — the setting is simply ``value``, which is what
|
||||
makes a panel that is always dark cost no flow at all. Bound, a flow drives
|
||||
it live and ``value`` is the fallback: what the dashboard uses until
|
||||
something arrives, and whenever the message is silent.
|
||||
|
||||
A schedule is not a third case. A node publishing to the bound message on a
|
||||
cron *is* the schedule, which is the whole reason this is a channel rather
|
||||
than a switching rule per setting.
|
||||
"""
|
||||
|
||||
value: Any = None
|
||||
#: The message that drives it, or empty for a setting that is just a value.
|
||||
message: str = ""
|
||||
#: The payload type the editor recorded when it bound that message, so the
|
||||
#: pairing can be judged from the document alone — the rule widget bindings
|
||||
#: are held to.
|
||||
dtype: str = ""
|
||||
|
||||
|
||||
class Placement(BaseModel):
|
||||
"""Where a widget sits in its section's grid, in grid units."""
|
||||
|
||||
@@ -311,6 +347,11 @@ class DashboardDef(BaseModel):
|
||||
#: letters of the title.
|
||||
icon: str = ""
|
||||
pages: list[PageDef] = Field(default_factory=list)
|
||||
#: Settings the whole dashboard carries, by name — see ``SettingDef``. The
|
||||
#: one channel a dashboard consumes as a dashboard rather than as a set of
|
||||
#: tiles, so a screen on a wall can be told things nobody standing at it
|
||||
#: could set.
|
||||
settings: dict[str, SettingDef] = Field(default_factory=dict)
|
||||
#: Bumped on every save; a save based on an older one is refused.
|
||||
version: int = 1
|
||||
#: Whether there are unpublished changes. Reported by the store on read,
|
||||
@@ -322,10 +363,35 @@ class DashboardDef(BaseModel):
|
||||
def _check_name(cls, value: str) -> str:
|
||||
return _validate_name(value)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_settings(self) -> DashboardDef:
|
||||
"""Refuse a setting driven by a message it cannot carry.
|
||||
|
||||
Judged from the document alone, exactly as a widget's binding is: the
|
||||
picker records the payload type beside the name, so neither the editor
|
||||
nor a wall panel has to fetch the catalogue to know the wiring is
|
||||
wrong. A name this build does not know is left alone rather than
|
||||
refused — an older installation reading a newer document simply does
|
||||
not act on it.
|
||||
"""
|
||||
for name, setting in self.settings.items():
|
||||
want = SETTING_DTYPES.get(name)
|
||||
if want and setting.dtype and setting.dtype != want:
|
||||
raise ValueError(
|
||||
f"the '{name}' setting needs a '{want}' message, "
|
||||
f"not a '{setting.dtype}'"
|
||||
)
|
||||
return self
|
||||
|
||||
@property
|
||||
def widgets(self) -> list[WidgetDef]:
|
||||
return [w for p in self.pages for s in p.sections for w in s.widgets]
|
||||
|
||||
@property
|
||||
def setting_messages(self) -> list[str]:
|
||||
"""Every message a bound setting reads. Empty for a static dashboard."""
|
||||
return [s.message for s in self.settings.values() if s.message]
|
||||
|
||||
|
||||
class DashboardSummary(BaseModel):
|
||||
"""A dashboard in a list, without its contents."""
|
||||
@@ -544,6 +610,25 @@ class DashboardStore:
|
||||
defn = DashboardDef.model_validate_json(path.read_text())
|
||||
except Exception:
|
||||
continue
|
||||
# A bound setting is a consumer too — the dashboard itself reading
|
||||
# a message rather than any tile on it — so the canvas accounts for
|
||||
# it the same way. ``widget`` is what the endpoint id is built
|
||||
# from, and no widget id can collide with it: a dot is not a legal
|
||||
# name character.
|
||||
for name, setting in defn.settings.items():
|
||||
if not setting.message.startswith(prefix):
|
||||
continue
|
||||
found.append(
|
||||
{
|
||||
"dashboard": defn.name,
|
||||
"dashboard_title": defn.title or defn.name,
|
||||
"widget": f"settings.{name}",
|
||||
"title": f"{defn.title or defn.name} {name}",
|
||||
"type": "setting",
|
||||
"provides": "",
|
||||
"requires": [setting.message],
|
||||
}
|
||||
)
|
||||
for widget in defn.widgets:
|
||||
# A control produces the message; a tile consumes it.
|
||||
produces = widget.target if widget.target.startswith(prefix) else ""
|
||||
@@ -599,6 +684,7 @@ __all__ = [
|
||||
"DASHBOARD_DIR",
|
||||
"HISTORY_CAP",
|
||||
"INPUT_WIDGETS",
|
||||
"SETTING_DTYPES",
|
||||
"WIDGET_DTYPES",
|
||||
"DashboardDef",
|
||||
"DashboardExists",
|
||||
@@ -609,6 +695,7 @@ __all__ = [
|
||||
"PageDef",
|
||||
"Placement",
|
||||
"SectionDef",
|
||||
"SettingDef",
|
||||
"WidgetDef",
|
||||
"default_dashboard",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user