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
This commit is contained in:
Melvin Strobl
2026-10-16 15:48:36 +02:00
parent 3c0912a3fe
commit 28a565e501
56 changed files with 3539 additions and 2404 deletions
@@ -1,22 +1,40 @@
"""Panels: assignment, pairing, and what a paired credential may reach."""
"""Devices: assignment, pairing, and what a paired credential may reach."""
import threading
from fastapi.testclient import TestClient
from fluksio.api.routes import devices as devices_route
from fluksio.cloud import config as cloud_config
from fluksio.core.config import settings
from fluksio.main import app
PREFIX = f"{settings.API_V1_STR}/panels"
PREFIX = f"{settings.API_V1_STR}/devices"
#: What the openpanel firmware calls, kept as aliases.
PANELS = f"{settings.API_V1_STR}/panels"
DASHBOARDS = f"{settings.API_V1_STR}/dashboards"
def _panels(client: TestClient, headers: dict[str, str], config: dict) -> None:
"""Replace the panels, based on whatever is stored now."""
version = client.get(f"{PREFIX}/", headers=headers).json()["version"]
response = client.put(
f"{PREFIX}/", headers=headers, json={**config, "version": version}
)
assert response.status_code == 200, response.text
"""Make the stored devices exactly ``config["panels"]``, one call each."""
wanted = {device["id"]: device for device in config["panels"]}
stored = client.get(f"{PREFIX}/", headers=headers).json()["data"]
for device in stored:
if device["id"] not in wanted:
gone = client.delete(f"{PREFIX}/{device['id']}", headers=headers)
assert gone.status_code == 200, gone.text
versions = {device["id"]: device["version"] for device in stored}
for device_id, device in wanted.items():
if device_id not in versions:
created = client.post(f"{PREFIX}/{device_id}", headers=headers)
assert created.status_code == 200, created.text
versions[device_id] = created.json()["version"]
saved = client.put(
f"{PREFIX}/{device_id}",
headers=headers,
json={**device, "version": versions[device_id]},
)
assert saved.status_code == 200, saved.text
def _dashboard(client: TestClient, headers: dict[str, str], name: str) -> dict:
@@ -51,13 +69,14 @@ def _pair(client: TestClient, headers: dict[str, str], panel: str) -> dict[str,
collected = client.get(
f"{PREFIX}/pair/{started['code']}", params={"secret": started["secret"]}
).json()
assert collected["panel"] == panel
assert collected["device"] == collected["panel"] == panel
return {"Authorization": f"Bearer {collected['access_token']}"}
def test_panels_require_authentication(client: TestClient) -> None:
def test_devices_require_authentication(client: TestClient) -> None:
assert client.get(f"{PREFIX}/").status_code == 401
assert client.put(f"{PREFIX}/", json={"panels": []}).status_code == 401
assert client.put(f"{PREFIX}/hall", json={"id": "hall"}).status_code == 401
assert client.get(f"{PREFIX}/pending").status_code == 401
def test_assign_and_read_back(
@@ -77,7 +96,9 @@ def test_assign_and_read_back(
)
stored = client.get(f"{PREFIX}/", headers=superuser_token_headers).json()
assert stored["panels"][0]["dashboards"] == ["hall_a", "hall_b"]
hall = next(device for device in stored["data"] if device["id"] == "hall")
assert hall["dashboards"] == ["hall_a", "hall_b"]
assert hall["online"] is False
# The address a device is pointed at comes from the server, because the
# browser's own origin is the portal's when someone administers remotely.
assert stored["frontend_host"] == settings.FRONTEND_HOST.rstrip("/")
@@ -91,7 +112,7 @@ def test_assign_and_read_back(
)
def test_touch_belongs_to_the_panel(
def test_touch_belongs_to_the_device(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""Whether a screen is touched is a fact about the screen, not the document.
@@ -112,59 +133,61 @@ def test_touch_belongs_to_the_panel(
)
by_id = {
panel["id"]: panel
for panel in client.get(f"{PREFIX}/", headers=superuser_token_headers).json()[
"panels"
device["id"]: device
for device in client.get(f"{PREFIX}/", headers=superuser_token_headers).json()[
"data"
]
}
assert by_id["wall"]["touch"] is True
# Absent is pointed at, which is what a panels file written before this
# field existed comes back as.
# Absent is pointed at.
assert by_id["desk"]["touch"] is False
def test_duplicate_panel_is_refused(
def test_creating_twice_or_a_reserved_id_is_refused(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
response = client.put(
f"{PREFIX}/",
headers=superuser_token_headers,
json={"panels": [{"id": "twice"}, {"id": "twice"}]},
assert (
client.post(f"{PREFIX}/twice", headers=superuser_token_headers).status_code
== 200
)
assert (
client.post(f"{PREFIX}/twice", headers=superuser_token_headers).status_code
== 409
)
assert (
client.post(f"{PREFIX}/pending", headers=superuser_token_headers).status_code
== 422
)
assert response.status_code == 422
def test_a_stale_save_is_refused(
def test_concurrent_saves_of_one_version_give_one_409(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""Two clients writing back the whole list must not drop each other's panels."""
read = client.get(f"{PREFIX}/", headers=superuser_token_headers).json()
base = read["version"]
"""The race `PUT /panels/` had: one of two saves of the same version wins."""
created = client.post(
f"{PREFIX}/racy", headers=superuser_token_headers, json={"title": "Racy"}
).json()
statuses: list[int] = []
saved = client.put(
f"{PREFIX}/",
headers=superuser_token_headers,
json={"panels": [*read["panels"], {"id": "mine"}], "version": base},
)
assert saved.status_code == 200, saved.text
assert saved.json()["version"] == base + 1
def save(title: str) -> None:
statuses.append(
client.put(
f"{PREFIX}/racy",
headers=superuser_token_headers,
json={**created, "title": title},
).status_code
)
stale = client.put(
f"{PREFIX}/",
headers=superuser_token_headers,
json={"panels": [*read["panels"], {"id": "theirs"}], "version": base},
)
assert stale.status_code == 409
assert stale.json()["detail"]["current_version"] == base + 1
threads = [threading.Thread(target=save, args=(t,)) for t in ("A", "B")]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert sorted(statuses) == [200, 409]
ids = [
p["id"]
for p in client.get(f"{PREFIX}/", headers=superuser_token_headers).json()[
"panels"
]
]
assert "mine" in ids
assert "theirs" not in ids
stored = client.get(f"{PREFIX}/racy", headers=superuser_token_headers).json()
assert stored["version"] == created["version"] + 1
assert stored["title"] in ("A", "B")
def test_polling_needs_the_secret(client: TestClient) -> None:
@@ -224,6 +247,15 @@ def test_paired_panel_reaches_only_what_it_shows(
== 200
)
assert client.get(f"{PREFIX}/hall", headers=panel_headers).status_code == 200
# Where the firmware from before devices reads itself.
assert client.get(f"{PANELS}/hall", headers=panel_headers).status_code == 200
# Not another device.
_panels(
client,
superuser_token_headers,
{"panels": [{"id": "hall", "dashboards": ["panel_shown"]}, {"id": "other"}]},
)
assert client.get(f"{PREFIX}/other", headers=panel_headers).status_code == 403
# But not the catalogue, which is the whole namespace at once and no
# screen's business.
assert (
@@ -680,8 +712,8 @@ def test_unpairing_a_screen_leaves_its_panel_standing(
assert fresh != panel_headers
assert client.get(f"{DASHBOARDS}/panel_kept", headers=fresh).status_code == 200
# A client holding an older copy of the panels cannot undo the revocation
# by writing the nonce back.
# A save of the device cannot undo the revocation: the nonce is not part
# of what is saved.
_panels(client, superuser_token_headers, assigned)
assert (
client.get(f"{DASHBOARDS}/panel_kept", headers=panel_headers).status_code == 401
@@ -728,6 +760,9 @@ class _FakeRedis:
def zcard(self, key: str) -> int:
return len(self.zset)
def zrange(self, key: str, start: int, end: int) -> list[str]:
return sorted(self.zset, key=self.zset.__getitem__)
def zrem(self, key: str, member: str) -> None:
self.zset.pop(member, None)
@@ -740,16 +775,14 @@ def test_a_pending_code_is_found_by_whichever_worker_is_polled() -> None:
"""A poll lands wherever the proxy sent it, not on the minting worker."""
import time
from fluksio.api.routes import panels as panels_route
shared = _FakeRedis()
minted = panels_route._PendingStore()
minted = devices_route._PendingStore()
minted._redis = shared # type: ignore[assignment]
polled = panels_route._PendingStore()
polled = devices_route._PendingStore()
polled._redis = shared # type: ignore[assignment]
entry = panels_route._Pending(
secret="s", expires=time.time() + panels_route.PAIR_TTL, device="a tablet"
entry = devices_route._Pending(
secret="s", expires=time.time() + devices_route.PAIR_TTL, device="a tablet"
)
assert minted.add("ABC123", entry)
# The cap and the collision check mean the same thing on both.
@@ -770,7 +803,7 @@ def test_a_pending_code_is_found_by_whichever_worker_is_polled() -> None:
assert polled.count() == 0
# An entry past its own expiry is nothing, whatever the key still says.
stale = panels_route._Pending(secret="s", expires=time.time() - 1)
stale = devices_route._Pending(secret="s", expires=time.time() - 1)
assert minted.add("STALE1", stale)
assert polled.get("STALE1") is None
assert polled.count() == 0
@@ -797,6 +830,80 @@ def test_removing_the_panel_revokes_its_credential(
client.get(f"{DASHBOARDS}/panel_gone", headers=panel_headers).status_code == 401
)
# Nor does a device created again under the same id take it back.
_panels(
client,
superuser_token_headers,
{"panels": [{"id": "workshop", "dashboards": ["panel_gone"]}]},
)
assert (
client.get(f"{DASHBOARDS}/panel_gone", headers=panel_headers).status_code == 401
)
def test_firmware_from_before_devices_pairs_through_the_panel_aliases(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""The openpanel firmware's exact calls: an empty JSON body, then a poll."""
_panels(client, superuser_token_headers, {"panels": [{"id": "porch"}]})
started = client.post(
f"{PANELS}/pair", content=b"", headers={"Content-Type": "application/json"}
)
assert started.status_code == 200, started.text
code, secret = started.json()["code"], started.json()["secret"]
approved = client.post(
f"{PREFIX}/porch/pair", headers=superuser_token_headers, json={"code": code}
)
assert approved.status_code == 200, approved.text
collected = client.get(f"{PANELS}/pair/{code}", params={"secret": secret}).json()
assert collected["panel"] == "porch"
headers = {"Authorization": f"Bearer {collected['access_token']}"}
assert client.get(f"{PANELS}/porch", headers=headers).json()["id"] == "porch"
def test_a_device_on_this_network_is_adopted_from_the_pending_list(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""No code to type for a request from the LAN, but one through the portal."""
_panels(client, superuser_token_headers, {"panels": [{"id": "knob"}]})
lan = TestClient(app, client=("192.168.1.50", 50000))
started = lan.post(
f"{PREFIX}/pair", json={"name": "knob-3f2a", "board": "knob", "firmware": "1.4"}
).json()
remote = client.post(
f"{PREFIX}/pair", headers=_via_portal(), json={"name": "x" * 500}
).json()
pending = client.get(f"{PREFIX}/pending", headers=superuser_token_headers).json()
mine = next(entry for entry in pending if entry["name"] == "knob-3f2a")
assert mine["board"] == "knob" and mine["firmware"] == "1.4"
assert mine["needs_code"] is False
portal = next(entry for entry in pending if entry["remote"])
assert portal["needs_code"] is True
assert len(portal["name"]) == devices_route.HELLO_LENGTH
# The list never hands a code out.
assert started["code"] not in str(pending) and remote["code"] not in str(pending)
refused = client.post(
f"{PREFIX}/knob/pair",
headers=superuser_token_headers,
json={"request": portal["request"]},
)
assert refused.status_code == 403
adopted = client.post(
f"{PREFIX}/knob/pair",
headers=superuser_token_headers,
json={"request": mine["request"]},
)
assert adopted.status_code == 200, adopted.text
collected = lan.get(
f"{PREFIX}/pair/{started['code']}", params={"secret": started["secret"]}
).json()
assert collected["device"] == "knob"
headers = {"Authorization": f"Bearer {collected['access_token']}"}
assert client.get(f"{PREFIX}/knob", headers=headers).status_code == 200
# --------------------------------------------------------------------------
# A screen that reached the portal but not this instance
@@ -855,8 +962,6 @@ def test_a_remote_device_is_paired_at_the_portal(
"""
import httpx
from fluksio.api.routes import panels as panels_route
calls: list[dict[str, object]] = []
def fake_post(url: str, **kwargs: object) -> httpx.Response:
@@ -867,7 +972,7 @@ def test_a_remote_device_is_paired_at_the_portal(
request=httpx.Request("POST", url),
)
monkeypatch.setattr(panels_route.httpx, "post", fake_post)
monkeypatch.setattr(devices_route.httpx, "post", fake_post)
_panels(client, superuser_token_headers, {"panels": [{"id": "hallway"}]})
started = client.post(f"{PREFIX}/pair", headers=_via_portal()).json()
+3 -1
View File
@@ -519,7 +519,9 @@ async def test_the_live_socket_ends_when_its_client_goes(
background tasks to complete" and nothing else, leaving `serve.pid` behind
because only SIGKILL ended the process. This returning is what ends that.
"""
monkeypatch.setattr(flows_route, "user_from_token", lambda session, token: object())
monkeypatch.setattr(
flows_route, "user_from_token", lambda session, token, **_: object()
)
class Peer:
"""One frame, then gone — the way a closed tab leaves."""