Files
app/backend/fluksio/flow/panels.py
T

216 lines
7.8 KiB
Python

"""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.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
@field_validator("message")
@classmethod
def _check_message(cls, value: str) -> str:
flow, _, name = value.partition(".")
if not name:
raise ValueError("Name the message with its flow, as flow.name")
_validate_name(flow)
return value
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)
#: 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 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)
}