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:
+167
-62
@@ -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()
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user