It still reads every message its dashboards show. A message a tile only draws, or a setting is bound to, is no longer writable from the panel.
229 lines
8.4 KiB
Python
229 lines
8.4 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)
|
|
#: 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)
|
|
}
|