"""Panels: which dashboards a given device shows. A wall tablet in the hall and one in the workshop want different dashboards, and the same dashboard may hang on both. Rather than nesting pages inside a dashboard, a panel names an ordered set of whole dashboards — each keeps its own canvas, its own draft and its own version, and the device switches between them through a rail. Stored beside the flows rather than in them, like the alerting configuration: which screen hangs where is the deployment's concern, not any one dashboard's. """ from __future__ import annotations import re import threading from pathlib import Path from typing import Any, Literal from pydantic import BaseModel, Field, field_validator from fluksio.core.config import settings from fluksio.flow.dashboards import DashboardDef, DashboardNotFound, DashboardStore from fluksio.flow.messages import validate_qualified from fluksio.flow.schemas import _validate_name #: Free text the device interprets: ``gpio.4``, ``backlight``, ``mic``. PORT_PATTERN = re.compile(r"^[a-z0-9_.]+$") class PanelPort(BaseModel): """One piece of the device's own hardware, wired to a message. A wall panel is a node as well as a screen: a button on its GPIO, a microphone, a speaker. Which ports exist is the device's business, so the name is free text and ``config`` is passed through to it unread. """ port: str #: Qualified, ``flow.name`` — a flow still has to declare it, as for any #: widget, before a publish to it lands. message: str #: ``in`` is the device publishing into fluksio; ``out`` is the device #: following the message. direction: Literal["in", "out"] config: dict[str, Any] = Field(default_factory=dict) @field_validator("port") @classmethod def _check_port(cls, value: str) -> str: if not PORT_PATTERN.fullmatch(value): raise ValueError("Use lowercase letters, digits, underscores and dots") return value _check_message = field_validator("message")(validate_qualified) class PanelDef(BaseModel): """One device, and what it shows.""" id: str title: str = "" #: Ordered. The first one is what the device opens after pairing, and the #: rail follows this order. A name that no longer resolves is simply a #: dashboard someone deleted; the panel skips it. dashboards: list[str] = Field(default_factory=list) #: Bigger controls, a bigger rail and no hover states, for a screen that is #: touched rather than pointed at. It belongs to the device rather than to #: any dashboard: the same dashboard may hang on a hallway tablet and on a #: desk browser, and only one of them has fingers on it. touch: bool = False #: The device's own inputs and outputs. An ``in`` port may publish whether #: or not a dashboard is locked — a lock covers the controls on a #: dashboard, not the sensors on the wall — and an ``out`` port only reads. io: list[PanelPort] = Field(default_factory=list) #: Which generation of credential this panel honours. A token names the #: nonce it was minted at, so bumping this refuses the screen currently #: hanging here and leaves the panel, its dashboards and their arrangement #: exactly as they are — re-pairing one device without deleting anything. #: Not settable from outside: a save carries the stored value forward. nonce: int = 0 @field_validator("id") @classmethod def _check_id(cls, value: str) -> str: return _validate_name(value) class PanelsConfig(BaseModel): """Every panel this instance knows about.""" panels: list[PanelDef] = Field(default_factory=list) #: Bumped on every save; a save based on an older one is refused, as a #: dashboard's is. A file written before there was one reads as 0. version: int = 0 #: Held across a read-modify-write of the panels file. #: #: Both `save_panels` and `unpair_panel` are one of those, and interleaving #: them silently undid a revocation: a save that read the file before an #: unpair wrote it put the old nonce back, and the screen that had just been #: unpaired kept working. The nonce carry-forward in the save handler was #: written to make that impossible, and the window between its read and its #: write is where it happened anyway. edit_lock = threading.Lock() def _path() -> Path: return settings.PANELS_FILE def read_config() -> PanelsConfig: """The stored panels, or none. Blocking.""" path = _path() if not path.exists(): return PanelsConfig() try: return PanelsConfig.model_validate_json(path.read_text()) except Exception: # A hand-edited file that no longer parses must not lock everyone out. return PanelsConfig() def write_config(config: PanelsConfig) -> None: """Blocking.""" path = _path() path.parent.mkdir(parents=True, exist_ok=True) path.write_text(config.model_dump_json(indent=2)) def find(panel_id: str) -> PanelDef | None: """The panel by that id, or None if it was removed. Read from disk on every call: this is what makes deleting a panel revoke its credential, so it has to see the current file rather than a cache. """ for panel in read_config().panels: if panel.id == panel_id: return panel return None def dashboards_for(panel_id: str, store: DashboardStore) -> list[DashboardDef]: """The published documents this panel shows, in rail order. Published, since that is what a panel draws. A name that no longer resolves is a dashboard someone deleted and is skipped, and a panel that is gone shows nothing. Handed back whole rather than walked here, because what a panel may do with a message depends on the document it came from — a dashboard that says it is locked entitles a screen to read it and not to touch it. """ panel = find(panel_id) if panel is None: return [] found: list[DashboardDef] = [] for name in panel.dashboards: try: found.append(store.read(name)) except DashboardNotFound: continue return found def messages_of(defn: DashboardDef) -> set[str]: """Every message one dashboard reads or writes. A dashboard's own bound settings count, not only its widgets': the theme a panel is driven to is a message no tile on it draws, and a wall panel refused its own theme message is the one surface the setting exists for. """ names = set(defn.setting_messages) for widget in defn.widgets: names.update(widget.messages) if widget.target: names.add(widget.target) return names def requests_of(defn: DashboardDef) -> set[str]: """The publishes this dashboard makes in order to read. A querying chart asks a flow for the series it draws by publishing a request, so that publish is how the tile reads rather than something anyone touched. Every other message a dashboard sends comes from a control, which is what marking it read-only turns off — so this is what a locked dashboard is still entitled to send. """ return {w.target for w in defn.widgets if w.type == "chart" and w.target} def targets_of(defn: DashboardDef) -> set[str]: """The messages this dashboard's inputs publish to. What a screen showing it may write, a narrower set than what it may read. A tile that only draws a message, and a setting bound to one, are there to be looked at, so a panel showing them is not entitled to set them. """ return {w.target for w in defn.widgets if w.target} def io_in(panel_id: str) -> set[str]: """The messages this panel's own hardware publishes to.""" panel = find(panel_id) if panel is None: return set() return {port.message for port in panel.io if port.direction == "in"} def messages_for(panel_id: str, store: DashboardStore) -> set[str]: """Every message this panel's dashboards and its own I/O read or write. What a screen is entitled to see, as its dashboards and ports define it, and empty for a panel that is gone — which is the same answer as "nothing". """ panel = find(panel_id) own = {port.message for port in panel.io} if panel else set() return own | { name for defn in dashboards_for(panel_id, store) for name in messages_of(defn) }