- POST /devices/pair answers a 2-character `check`, listed by /devices/pending and shown beside each waiting entry; five codes a minute per address, and at 50 waiting the oldest gives way instead of a 429 - portal-minted device tokens are asked for with the device's nonce (`pnc`) and held against it, so unpair and delete revoke them; one without `pnc` stands only while the nonce is 0 - a device credential acts as the device (`device_principal`), not as the approving superuser, so deleting that account unpairs nothing - the report is validated port by port: unknown enum values read as `unknown`, unreadable ports and sections are left out and logged, `config` may name an entry of `config_schemas`; 422 only for no schema 1 or no object - devices.json is parsed once per change (cached by stat), delete drops the report and keeps the nonce, a busy recheck runs in the background - scope and io only from enabled flows (start/stop tells the devices), a port bound twice gives one io row - the Devices tab's saves cancel and hold back device refetches until settled
257 lines
9.2 KiB
Python
257 lines
9.2 KiB
Python
"""The one-time move of an older instance's panels file into devices."""
|
|
|
|
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
from fluksio.flow.devices import DeviceStore
|
|
from fluksio.flow.store import FlowStore
|
|
|
|
|
|
def test_panels_move_into_devices_once(tmp_path: Path) -> None:
|
|
flows = FlowStore(tmp_path / "flows")
|
|
panels = tmp_path / "panels.json"
|
|
panels.write_text(
|
|
json.dumps(
|
|
{
|
|
"panels": [
|
|
{"id": "hall", "title": "Hall", "dashboards": ["home"], "nonce": 2},
|
|
{"id": "knob", "touch": True, "io": [], "nonce": 0},
|
|
],
|
|
"version": 7,
|
|
}
|
|
)
|
|
)
|
|
store = DeviceStore(flows, tmp_path / "devices.json")
|
|
store.migrate(panels)
|
|
|
|
assert not panels.exists()
|
|
assert (tmp_path / "panels.json.migrated").exists()
|
|
hall = store.find("hall")
|
|
assert hall is not None and hall.dashboards == ["home"] and hall.version == 1
|
|
assert store.find("knob").touch is True # type: ignore[union-attr]
|
|
# The nonce stays outside git, so a credential minted at it still stands.
|
|
assert store.nonce("hall") == 2
|
|
assert "nonce" not in (flows.root / "_devices" / "hall.json").read_text()
|
|
log = subprocess.run(
|
|
["git", "-C", str(flows.root), "log", "--format=%s"],
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout
|
|
assert log.splitlines()[0] == "Migrate panels to devices"
|
|
# Nothing in `_devices` is taken for a flow.
|
|
assert flows.list_flows() == []
|
|
|
|
# A second start finds nothing to do, and a panels file restored from a
|
|
# backup does not lower a nonce an unpair has raised since.
|
|
store.bump("hall")
|
|
panels.write_text((tmp_path / "panels.json.migrated").read_text())
|
|
store.migrate(panels)
|
|
assert store.nonce("hall") == 3
|
|
assert (
|
|
log
|
|
== subprocess.run(
|
|
["git", "-C", str(flows.root), "log", "--format=%s"],
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout
|
|
)
|
|
|
|
|
|
def _flow(flows: FlowStore, name: str, draft: dict | None = None, **published) -> None:
|
|
"""Write a published flow document, and a draft beside it if given."""
|
|
folder = flows.root / name
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
(folder / "flow.json").write_text(json.dumps({"name": name, **published}))
|
|
if draft is not None:
|
|
(folder / "flow.draft.json").write_text(json.dumps({"name": name, **draft}))
|
|
|
|
|
|
def test_device_ports_move_into_flows_once(tmp_path: Path) -> None:
|
|
flows = FlowStore(tmp_path / "flows")
|
|
_flow(
|
|
flows,
|
|
"home",
|
|
version=3,
|
|
inputs=[
|
|
{"spec": {"name": "brightness", "dtype": "int"}, "initial": 80},
|
|
],
|
|
nodes=[
|
|
{
|
|
"id": "bell",
|
|
"type": "python",
|
|
"requires": [{"name": "doorbell", "dtype": "bool"}],
|
|
}
|
|
],
|
|
draft={
|
|
"version": 5,
|
|
"inputs": [{"spec": {"name": "brightness", "dtype": "int"}, "initial": 80}],
|
|
"nodes": [
|
|
{
|
|
"id": "bell",
|
|
"type": "python",
|
|
"requires": [{"name": "doorbell", "dtype": "bool"}],
|
|
},
|
|
{"id": "hall", "type": "inject"},
|
|
],
|
|
},
|
|
)
|
|
# Declares `home.light` from outside: a row on it goes to the owner first,
|
|
# and only here when the owner does not declare it.
|
|
_flow(
|
|
flows,
|
|
"lights",
|
|
nodes=[
|
|
{
|
|
"id": "dim",
|
|
"type": "python",
|
|
"provides": [{"name": "home.light", "dtype": "float"}],
|
|
}
|
|
],
|
|
)
|
|
devices = flows.root / "_devices"
|
|
devices.mkdir()
|
|
(devices / "hall.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"id": "hall",
|
|
"title": "Hall",
|
|
"version": 4,
|
|
"io": [
|
|
{
|
|
"port": "backlight",
|
|
"message": "home.brightness",
|
|
"direction": "out",
|
|
},
|
|
{
|
|
"port": "gpio.4",
|
|
"message": "home.doorbell",
|
|
"direction": "in",
|
|
"config": {"pull": "up"},
|
|
},
|
|
{"port": "dimmer", "message": "home.light", "direction": "out"},
|
|
{"port": "mic", "message": "home.nowhere", "direction": "in"},
|
|
],
|
|
}
|
|
)
|
|
)
|
|
store = DeviceStore(flows, tmp_path / "devices.json")
|
|
store.move_io()
|
|
|
|
published = flows.read_flow("home")
|
|
draft = flows.read_flow("home", draft=True)
|
|
assert (published.version, draft.version) == (4, 6)
|
|
for doc in (published, draft):
|
|
# `hall` is taken by an inject node in the draft, so the id moves on in
|
|
# both documents alike.
|
|
node = next(n for n in doc.nodes if n.type == "device")
|
|
assert node.id == "hall_2" and node.title == "Hall"
|
|
assert node.params == {"device": "hall", "config": {"gpio.4": {"pull": "up"}}}
|
|
assert [(s.port, s.name, s.dtype.value) for s in node.provides] == [
|
|
("gpio.4", "doorbell", "bool")
|
|
]
|
|
assert [(s.port, s.name, s.dtype.value, s.trigger) for s in node.requires] == [
|
|
("backlight", "brightness", "int", False)
|
|
]
|
|
lights = flows.read_flow("lights")
|
|
node = next(n for n in lights.nodes if n.type == "device")
|
|
assert node.id == "hall" and lights.version == 2
|
|
assert [(s.port, s.name) for s in node.requires] == [("dimmer", "home.light")]
|
|
|
|
# The device lost `io` and kept its version; the row nothing declares is
|
|
# gone. What it reads back is the same rows, in port order.
|
|
assert "io" not in json.loads((devices / "hall.json").read_text())
|
|
assert store.find("hall").version == 4 # type: ignore[union-attr]
|
|
assert [(r.port, r.message, r.direction, r.config) for r in store.io("hall")] == [
|
|
("backlight", "home.brightness", "out", {}),
|
|
("dimmer", "home.light", "out", {}),
|
|
("gpio.4", "home.doorbell", "in", {"pull": "up"}),
|
|
]
|
|
log = subprocess.run(
|
|
["git", "-C", str(flows.root), "log", "--format=%s"],
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout.splitlines()
|
|
assert log[0] == "Move device ports into flows"
|
|
|
|
# Nothing left to move, and nothing written twice.
|
|
store.move_io()
|
|
assert flows.read_flow("home").version == 4
|
|
assert (
|
|
subprocess.run(
|
|
["git", "-C", str(flows.root), "log", "--format=%s"],
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout.splitlines()
|
|
== log
|
|
)
|
|
|
|
|
|
def test_a_crash_after_the_flows_were_written_adds_nothing_twice(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
flows = FlowStore(tmp_path / "flows")
|
|
_flow(flows, "home", inputs=[{"spec": {"name": "level", "dtype": "int"}}])
|
|
devices = flows.root / "_devices"
|
|
devices.mkdir()
|
|
device = {
|
|
"id": "hall",
|
|
"io": [{"port": "backlight", "message": "home.level", "direction": "out"}],
|
|
}
|
|
(devices / "hall.json").write_text(json.dumps(device))
|
|
store = DeviceStore(flows, tmp_path / "devices.json")
|
|
store.move_io()
|
|
# As if the device file had not been rewritten before the process died.
|
|
(devices / "hall.json").write_text(json.dumps(device))
|
|
store.move_io()
|
|
node = next(n for n in flows.read_flow("home").nodes if n.type == "device")
|
|
assert len(node.requires) == 1
|
|
assert flows.read_flow("home").version == 2
|
|
|
|
|
|
def test_panels_with_io_reach_the_flows_in_one_start(tmp_path: Path) -> None:
|
|
"""An instance from before devices: panels.json, then device nodes."""
|
|
flows = FlowStore(tmp_path / "flows")
|
|
_flow(flows, "kiosk", inputs=[{"spec": {"name": "brightness", "dtype": "int"}}])
|
|
panels = tmp_path / "panels.json"
|
|
panels.write_text(
|
|
json.dumps(
|
|
{
|
|
"panels": [
|
|
{
|
|
"id": "knob",
|
|
"io": [
|
|
{
|
|
"port": "backlight",
|
|
"message": "kiosk.brightness",
|
|
"direction": "out",
|
|
"config": {"wake_s": 30},
|
|
}
|
|
],
|
|
}
|
|
]
|
|
}
|
|
)
|
|
)
|
|
store = DeviceStore(flows, tmp_path / "devices.json")
|
|
store.migrate(panels)
|
|
store.move_io()
|
|
assert [(r.port, r.message, r.config) for r in store.io("knob")] == [
|
|
("backlight", "kiosk.brightness", {"wake_s": 30})
|
|
]
|
|
|
|
|
|
def test_the_nonces_are_read_again_only_when_the_file_changes(tmp_path: Path) -> None:
|
|
"""Every device request reads its nonce, so the file is parsed once per
|
|
change, and a change from outside this process is still seen."""
|
|
store = DeviceStore(FlowStore(tmp_path / "flows"), tmp_path / "devices.json")
|
|
assert store.nonce("hall") == 0
|
|
store.bump("hall")
|
|
assert store.nonce("hall") == 1
|
|
assert store._states() is store._states()
|
|
|
|
raw = json.loads(store.state_file.read_text())
|
|
raw["hall"]["nonce"] = 7
|
|
store.state_file.write_text(json.dumps(raw, indent=4))
|
|
assert store.nonce("hall") == 7
|