Files
app/backend/fluksio/flow/devices.py
T
Melvin StroblandClaude Opus 5.5 ed06bdec6b Panels become devices: per-device documents in the flow repo, a Devices tab
- flow/devices.py: DeviceDef in FLOWS_DIR/_devices/<id>.json, committed under
  the flow store's write lock, 409 on a stale version; nonces in DEVICES_FILE
  outside git; in-memory presence announced as device_status
- api/routes/devices.py: CRUD, pairing with an optional {name, board,
  firmware}, a pending list adopted without a code from the LAN only, unpair;
  /panels/pair, /panels/pair/{code} and GET /panels/{id} kept as aliases,
  PUT /panels/ gone
- _device_may replaces _panel_may, JWT claims unchanged
- panels.json moves into devices once at startup, then .migrated
- MCP list_devices, get_device; search category device
- Devices tab (list, detail with presence, pairing, ordered dashboards,
  touch, I/O), PanelsDialog removed, serialised optimistic saves

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:48:36 +02:00

446 lines
16 KiB
Python

"""Devices: the screens and boards paired with this instance.
A device is a wall panel, a knob, a camera board: something without a keyboard
that pairs with a code, holds a scoped credential and shows an ordered set of
whole dashboards. A hallway tablet and a workshop one carry different sets, the
same dashboard may hang on both, and each dashboard keeps its own canvas, draft
and version.
The definition is stored in the flow repository as ``_devices/<id>.json`` and
committed like a dashboard, so it is versioned with the dashboards it names.
What a revert must never undo lives outside git, in ``devices.json``: the nonce
a credential is held against, and the device's own report of what it has.
Whether a device is connected right now is kept in memory only.
"""
from __future__ import annotations
import ipaddress
import json
import logging
import os
import re
import threading
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Literal
from pydantic import BaseModel, Field, TypeAdapter, ValidationError, field_validator
from fluksio.flow.dashboards import DashboardDef, DashboardNotFound, DashboardStore
from fluksio.flow.events import event_bus
from fluksio.flow.messages import validate_qualified
from fluksio.flow.schemas import _validate_name
from fluksio.flow.store import FlowStore, InvalidName, StaleVersion, checked
logger = logging.getLogger(__name__)
#: Sibling of the dashboards, and likewise not a flow.
DEVICE_DIR = "_devices"
#: Free text the device interprets: ``gpio.4``, ``backlight``, ``mic``.
PORT_PATTERN = re.compile(r"^[a-z0-9_.]+$")
class DevicePort(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 DeviceDef(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 a dashboard
#: someone deleted, and the device 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. A fact about the screen, not about any
#: dashboard on it.
touch: bool = False
#: The device's own inputs and outputs. An ``in`` port may publish whether
#: or not a dashboard is locked, since a lock covers the controls on a
#: dashboard and not the sensors on the wall. An ``out`` port only reads.
io: list[DevicePort] = Field(default_factory=list)
#: Bumped on every save. A save based on an older one is refused.
version: int = 0
@field_validator("id")
@classmethod
def _check_id(cls, value: str) -> str:
return _validate_name(value)
class DeviceState(BaseModel):
"""What this instance keeps about a device outside the flow repository."""
#: Which generation of credential the device honours. A token names the
#: nonce it was minted at, so bumping this refuses the screen hanging there
#: and leaves the device and its dashboards as they are.
nonce: int = 0
#: What the device says it has, written by the device itself.
report: dict[str, Any] | None = None
reported_at: datetime | None = None
_STATES = TypeAdapter(dict[str, DeviceState])
class DeviceNotFound(KeyError):
def __init__(self, device_id: str) -> None:
super().__init__(device_id)
self.id = device_id
class DeviceExists(ValueError):
def __init__(self, device_id: str) -> None:
super().__init__(device_id)
self.id = device_id
class DeviceStore:
"""Device definitions in the flow repository, their nonces beside it.
Shares the flow store's write lock and commit, as the dashboards do, so a
device save cannot interleave with a flow or dashboard save.
"""
def __init__(self, flows: FlowStore, state_file: Path) -> None:
self.flows = flows
self.root = flows.root / DEVICE_DIR
self.state_file = state_file
# Read-modify-write of a device's version.
self._lock = threading.Lock()
# Read-modify-write of the nonce file. An unpair interleaved with
# anything else writing it must not be undone by that write.
self._state_lock = threading.Lock()
def _file(self, device_id: str) -> Path:
return self.root / f"{checked(device_id)}.json"
def _write(self, device: DeviceDef) -> None:
path = self._file(device.id)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(device.model_dump_json(indent=2) + "\n")
def list(self) -> list[DeviceDef]:
found = []
for path in sorted(self.root.glob("*.json")):
try:
found.append(DeviceDef.model_validate_json(path.read_text()))
except (OSError, ValidationError):
logger.warning("Skipping unreadable device %s", path.name)
return found
def find(self, device_id: str) -> DeviceDef | None:
"""The device by that id, or None if it is gone.
Read from disk on every call: this is what makes deleting a device
revoke its credential, so it has to see the current file.
"""
try:
path = self._file(device_id)
except InvalidName:
return None
if not path.exists():
return None
try:
return DeviceDef.model_validate_json(path.read_text())
except (OSError, ValidationError):
return None
def create(self, device: DeviceDef) -> DeviceDef:
with self._lock, self.flows._write_lock:
if self._file(device.id).exists():
raise DeviceExists(device.id)
saved = device.model_copy(update={"version": 1})
self._write(saved)
self.flows._commit(f"Add device '{device.id}'")
return saved
def write(self, device: DeviceDef, base_version: int) -> DeviceDef:
"""Save a device, refusing a save someone else has moved past."""
with self._lock, self.flows._write_lock:
current = self.find(device.id)
if current is None:
raise DeviceNotFound(device.id)
if base_version != current.version:
raise StaleVersion(device.id, current.version)
saved = device.model_copy(update={"version": current.version + 1})
self._write(saved)
self.flows._commit(f"Save device '{device.id}'")
return saved
def delete(self, device_id: str) -> None:
"""Remove the device, and refuse every credential minted for it.
The nonce is bumped rather than dropped: a device created again under
the same id, or brought back by a revert, must not take the old
screen's credential back with it.
"""
with self.flows._write_lock:
path = self._file(device_id)
if not path.exists():
raise DeviceNotFound(device_id)
path.unlink()
self.flows._commit(f"Delete device '{device_id}'")
self.bump(device_id)
presence.forget(device_id)
# -- outside git -------------------------------------------------------
def _read_states(self) -> dict[str, DeviceState]:
"""Raises on a file that does not parse, rather than reading it as empty.
Empty would put every nonce back to 0, which honours again every
credential an unpair refused.
"""
if not self.state_file.exists():
return {}
return _STATES.validate_json(self.state_file.read_bytes())
def _write_states(self, states: dict[str, DeviceState]) -> None:
# Through a temporary file, so a crash mid-write cannot leave a torn
# file behind.
self.state_file.parent.mkdir(parents=True, exist_ok=True)
tmp = self.state_file.with_name(self.state_file.name + ".tmp")
tmp.write_bytes(_STATES.dump_json(states, indent=2))
os.replace(tmp, self.state_file)
def state(self, device_id: str) -> DeviceState:
return self._read_states().get(device_id, DeviceState())
def nonce(self, device_id: str) -> int:
return self.state(device_id).nonce
def bump(self, device_id: str) -> int:
"""Refuse the credential this device holds now. Returns the new nonce."""
with self._state_lock:
states = self._read_states()
state = states.setdefault(device_id, DeviceState())
state.nonce += 1
self._write_states(states)
return state.nonce
# -- the panels file of an older instance -------------------------------
def migrate(self, panels_file: Path) -> None:
"""Move ``panels.json`` into the store and the nonce file, once.
Each panel becomes ``_devices/<id>.json`` at version 1, in one commit,
and its nonce goes to ``devices.json`` (the higher one wins, should
both have it). The file is then renamed ``.migrated``. Running it again
after a crash part-way skips the devices already written.
"""
if not panels_file.exists():
return
try:
panels = json.loads(panels_file.read_text()).get("panels", [])
except (OSError, ValueError, AttributeError) as exc:
logger.error("Could not read %s to migrate it: %s", panels_file, exc)
return
devices: list[tuple[DeviceDef, int]] = []
for raw in panels:
try:
device = DeviceDef.model_validate({**raw, "version": 1})
except (ValidationError, TypeError) as exc:
logger.error("Not migrating panel %r: %s", raw, exc)
continue
devices.append((device, int(raw.get("nonce") or 0)))
with self._lock, self.flows._write_lock:
written = [d.id for d, _ in devices if not self._file(d.id).exists()]
for device, _ in devices:
if device.id in written:
self._write(device)
if written:
self.flows._commit("Migrate panels to devices")
with self._state_lock:
states = self._read_states()
for device, nonce in devices:
state = states.setdefault(device.id, DeviceState())
state.nonce = max(state.nonce, nonce)
self._write_states(states)
panels_file.replace(panels_file.with_name(panels_file.name + ".migrated"))
logger.warning(
"Moved %d panels from %s into devices (%s)",
len(devices),
panels_file,
", ".join(written) or "all were there already",
)
class Presence:
"""Which devices are connected now, and when each was last heard from.
In memory: a restart forgets it, and a device that is still there
reconnects within seconds. One process serves the API, so one dictionary
is the whole answer.
"""
def __init__(self) -> None:
self._sockets: dict[str, int] = {}
self._seen: dict[str, float] = {}
self._lock = threading.Lock()
def seen(self, device_id: str) -> None:
self._seen[device_id] = time.time()
def opened(self, device_id: str) -> None:
with self._lock:
count = self._sockets.get(device_id, 0) + 1
self._sockets[device_id] = count
self._seen[device_id] = time.time()
if count == 1:
self._announce(device_id)
def closed(self, device_id: str) -> None:
with self._lock:
count = max(0, self._sockets.get(device_id, 0) - 1)
if count:
self._sockets[device_id] = count
else:
self._sockets.pop(device_id, None)
self._seen[device_id] = time.time()
if not count:
self._announce(device_id)
def forget(self, device_id: str) -> None:
with self._lock:
self._sockets.pop(device_id, None)
self._seen.pop(device_id, None)
def online(self, device_id: str) -> bool:
return self._sockets.get(device_id, 0) > 0
def last_seen(self, device_id: str) -> datetime | None:
seen = self._seen.get(device_id)
return datetime.fromtimestamp(seen, UTC) if seen else None
def _announce(self, device_id: str) -> None:
last = self.last_seen(device_id)
event_bus.publish(
{
"type": "device_status",
"device": device_id,
"online": self.online(device_id),
"last_seen": last.isoformat() if last else None,
"ts": time.time(),
}
)
presence = Presence()
def is_lan(address: str) -> bool:
"""Whether a request came from a private network rather than the internet."""
try:
ip = ipaddress.ip_address(address)
except ValueError:
return False
return ip.is_private or ip.is_loopback
# -- what a device's credential reaches --------------------------------------
def dashboards_for(
device: DeviceDef | None, store: DashboardStore
) -> list[DashboardDef]:
"""The published documents this device shows, in rail order.
Published, since that is what a device draws. A name that no longer
resolves is skipped, and a device that is gone shows nothing.
Handed back whole rather than walked here, because what a device 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.
"""
if device is None:
return []
found: list[DashboardDef] = []
for name in device.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.
"""
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. Every other message a
dashboard sends comes from a control, which is what locking it turns off,
so this is what a locked dashboard may still 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.
"""
return {w.target for w in defn.widgets if w.target}
def io_in(device: DeviceDef | None) -> set[str]:
"""The messages this device's own hardware publishes to."""
if device is None:
return set()
return {port.message for port in device.io if port.direction == "in"}
def messages_for(device: DeviceDef | None, store: DashboardStore) -> set[str]:
"""Every message this device's dashboards and its own I/O read or write.
Empty for a device that is gone, which is the same answer as "nothing".
"""
own = {port.message for port in device.io} if device else set()
return own | {
name for defn in dashboards_for(device, store) for name in messages_of(defn)
}