Device node: a device's ports are wired in flows

- flow/nodes/device.py: `device` node with params `device` and `config`;
  provides the device's `in` ports, requires its `out` ports without
  waking anything, never run (Node.passive), down "Offline" from presence
- DeviceDef.io and DevicePort go; GET /devices/{id} computes `io` and
  `bindings` from the device nodes of published flows, in a fixed order,
  cached by FlowStore.revision; the credential's scope comes from them
- issues `unknown_device` and `device_port` (wired twice; against a report
  the wrong direction or dtype, or unlisted as advisory), rechecked when a
  device is created or deleted; ValidationIssue.advisory is a field
- a device publishing on a bound port is credited to its node by the
  server; the panel endpoint kind is gone from `_endpoints` and the canvas
- a rebuild that moves a device's bindings sends `dashboard_changed` with
  an empty name, which the firmware refetches on
- startup moves each device's `io` rows into device nodes in the flows
  declaring their messages, draft and published, one commit; rows no
  flow declares are logged and left out
- frontend: Device node settings (device, Publishes, Follows, a port
  picker from a report), palette shortcuts per reported port, DeviceDetail
  lists bindings with "Use in flow" in place of the I/O editor
- docs: devices, flow editor, node types, issues, API
This commit is contained in:
Melvin Strobl
2026-10-16 16:40:31 +02:00
parent 91e3b703f9
commit 29d3ec467f
35 changed files with 2195 additions and 494 deletions
+264 -32
View File
@@ -7,6 +7,7 @@ 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.flow.devices import DeviceState
from fluksio.main import app
PREFIX = f"{settings.API_V1_STR}/devices"
@@ -522,36 +523,115 @@ def test_a_locked_dashboard_entitles_a_panel_to_read_and_not_to_publish(
)
PANEL_IO = [
{
"port": "gpio.4",
"message": "demo.doorbell",
"direction": "in",
"config": {"pull": "up", "debounce_ms": 50},
},
{"port": "backlight", "message": "demo.backlight", "direction": "out"},
]
FLOWS = f"{settings.API_V1_STR}/flows"
def test_a_panels_io_is_saved_and_read_back(
def _device_node(device: str, node: str = "porch", **ports: list) -> dict:
"""A device node: ``provides`` its ``in`` ports, ``requires`` its ``out`` ones."""
return {
"id": node,
"type": "device",
"params": {"device": device, "config": ports.pop("config", {})},
"provides": ports.get("provides", []),
"requires": ports.get("requires", []),
}
def _publish_flow(
client: TestClient, headers: dict[str, str], name: str, nodes: list, **more
) -> dict:
"""Write a flow and publish it, replacing whatever was there."""
client.delete(f"{FLOWS}/{name}", headers=headers)
saved = client.put(
f"{FLOWS}/{name}",
headers=headers,
json={"name": name, "nodes": nodes, "version": 0, **more},
)
assert saved.status_code == 200, saved.text
published = client.post(
f"{FLOWS}/{name}/publish",
headers=headers,
json={"version": saved.json()["definition"]["version"]},
)
assert published.status_code == 200, published.text
return published.json()
#: A doorbell button and the backlight, as a device node in flow ``porch``.
PORCH_NODE = _device_node(
"porch_io",
provides=[{"name": "doorbell", "port": "gpio.4", "dtype": "bool"}],
requires=[
{"name": "backlight", "port": "backlight", "dtype": "int", "trigger": False}
],
config={"gpio.4": {"pull": "up", "debounce_ms": 50}},
)
def test_a_devices_io_comes_from_its_device_nodes(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
_panels(
client,
superuser_token_headers,
{"panels": [{"id": "porch_io", "io": PANEL_IO}]},
"""What the device reads as ``io`` is what published flows wire, in port order.
The same bytes for as long as nothing changes: the firmware compares the
whole document and rebuilds its screen on any difference.
"""
_panels(client, superuser_token_headers, {"panels": [{"id": "porch_io"}]})
_publish_flow(client, superuser_token_headers, "porch", [PORCH_NODE])
# A draft wires nothing until it is published.
client.put(
f"{FLOWS}/porch_draft",
headers=superuser_token_headers,
json={
"name": "porch_draft",
"nodes": [
_device_node(
"porch_io",
provides=[{"name": "mic", "port": "mic", "dtype": "audio"}],
)
],
"version": 0,
},
)
read = client.get(f"{PREFIX}/porch_io", headers=superuser_token_headers).json()
assert read["io"] == [PANEL_IO[0], {**PANEL_IO[1], "config": {}}]
first = client.get(f"{PREFIX}/porch_io", headers=superuser_token_headers)
read = first.json()
assert read["io"] == [
{
"port": "backlight",
"message": "porch.backlight",
"direction": "out",
"config": {},
},
{
"port": "gpio.4",
"message": "porch.doorbell",
"direction": "in",
"config": {"pull": "up", "debounce_ms": 50},
},
]
assert [(b["flow"], b["node"], b["port"]) for b in read["bindings"]] == [
("porch", "porch", "backlight"),
("porch", "porch", "gpio.4"),
]
again = client.get(f"{PANELS}/porch_io", headers=superuser_token_headers)
assert again.content == first.content
# The list carries the same, for the Devices tab.
listed = client.get(f"{PREFIX}/", headers=superuser_token_headers).json()
assert next(d for d in listed["data"] if d["id"] == "porch_io")["io"] == read["io"]
for flow in ("porch", "porch_draft"):
client.delete(f"{FLOWS}/{flow}", headers=superuser_token_headers)
def test_a_panels_own_ports_publish_through_a_lock(
def test_a_devices_own_ports_publish_through_a_lock(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""An ``in`` port is the device's own sensor, which a dashboard lock is not about.
An ``out`` port is the device following a message, so it reads and does
not write — and only an ``in`` port's name opens the artifact upload.
not write, and only an ``in`` port's name opens the artifact upload. A
value published on a bound port is the device node's, whatever the device
says it is from.
"""
_dashboard_with(client, superuser_token_headers, "panel_io_locked", PANEL_WIDGETS)
_publish_settings(
@@ -560,36 +640,65 @@ def test_a_panels_own_ports_publish_through_a_lock(
_panels(
client,
superuser_token_headers,
{"panels": [{"id": "door", "dashboards": ["panel_io_locked"], "io": PANEL_IO}]},
{
"panels": [
{"id": "porch_io", "title": "Porch", "dashboards": ["panel_io_locked"]}
]
},
)
panel_headers = _pair(client, superuser_token_headers, "door")
_publish_flow(client, superuser_token_headers, "porch", [PORCH_NODE])
panel_headers = _pair(client, superuser_token_headers, "porch_io")
messages = f"{settings.API_V1_STR}/messages"
# 404: past the gate, refused by an engine no flow here declares it in.
controller = app.state.flow_controller
sources = []
original = controller.publish_message
def recorded(name, value, source=None): # type: ignore[no-untyped-def]
sources.append(source)
return original(name, value, source)
controller.publish_message = recorded
try:
published = client.post(
f"{messages}/porch.doorbell",
headers=panel_headers,
json={"value": True, "source_kind": "panel", "source_id": "panel:x:y"},
)
finally:
del controller.publish_message
assert published.status_code == 200, published.text
assert sources[0].model_dump() == {
"kind": "node",
"id": "porch.porch",
"label": "Porch",
"detail": "gpio.4",
}
assert (
client.post(
f"{messages}/demo.doorbell", headers=panel_headers, json={"value": True}
).status_code
== 404
)
assert (
client.post(
f"{messages}/demo.backlight", headers=panel_headers, json={"value": 50}
f"{messages}/porch.backlight", headers=panel_headers, json={"value": 50}
).status_code
== 403
)
assert (
client.get(
f"{messages}/demo.backlight/history", headers=panel_headers
f"{messages}/porch.backlight/history", headers=panel_headers
).status_code
== 200
)
assert (
client.post(
f"{messages}/porch.unrelated", headers=panel_headers, json={"value": 1}
).status_code
== 403
)
artifacts = f"{settings.API_V1_STR}/artifacts"
for name, status in (
("demo.doorbell", 200),
("demo.backlight", 403),
("demo.unrelated", 403),
("porch.doorbell", 200),
("porch.backlight", 403),
("porch.unrelated", 403),
):
uploaded = client.put(
artifacts,
@@ -599,6 +708,129 @@ def test_a_panels_own_ports_publish_through_a_lock(
)
assert uploaded.status_code == status, name
# Unwired, the port is nothing the credential reaches any more.
_publish_flow(client, superuser_token_headers, "porch", [])
assert (
client.post(
f"{messages}/porch.doorbell", headers=panel_headers, json={"value": True}
).status_code
== 403
)
client.delete(f"{FLOWS}/porch", headers=superuser_token_headers)
def test_a_port_wired_twice_or_an_unknown_device_is_an_issue(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
_panels(client, superuser_token_headers, {"panels": [{"id": "porch_io"}]})
_publish_flow(client, superuser_token_headers, "porch", [PORCH_NODE])
_publish_flow(
client,
superuser_token_headers,
"porch_too",
[
_device_node(
"porch_io",
provides=[{"name": "bell", "port": "gpio.4", "dtype": "bool"}],
),
_device_node("ghost", node="ghost"),
],
)
def codes(flow: str) -> set[tuple[str, str | None, bool]]:
issues = client.get(f"{FLOWS}/{flow}", headers=superuser_token_headers).json()[
"issues"
]
return {
(i["code"], i["port"], i["advisory"])
for i in issues
if i["code"] in ("device_port", "unknown_device")
}
assert codes("porch") == {("device_port", "gpio.4", False)}
assert codes("porch_too") == {
("device_port", "gpio.4", False),
("unknown_device", None, False),
}
# Creating the device is enough, nothing has to be published again.
_panels(
client,
superuser_token_headers,
{"panels": [{"id": "porch_io"}, {"id": "ghost"}]},
)
assert codes("porch_too") == {("device_port", "gpio.4", False)}
# With a report a port it does not list is a warning, and one it lists the
# other way round an error. Seen in a draft, which is validated as it is.
store = app.state.device_store
with store._state_lock:
states = store._read_states()
states.setdefault("ghost", DeviceState()).report = {
"schema": 1,
"ports": [{"port": "touch", "direction": "in", "dtype": "bool"}],
}
store._write_states(states)
client.put(
f"{FLOWS}/porch_too",
headers=superuser_token_headers,
json={
"name": "porch_too",
"version": client.get(
f"{FLOWS}/porch_too", headers=superuser_token_headers
).json()["definition"]["version"],
"nodes": [
_device_node(
"ghost",
node="ghost",
provides=[{"name": "relay", "port": "relay", "dtype": "bool"}],
requires=[
{
"name": "touch",
"port": "touch",
"dtype": "bool",
"trigger": False,
}
],
)
],
},
)
assert codes("porch_too") == {
("device_port", "relay", True),
("device_port", "touch", False),
}
for flow in ("porch", "porch_too"):
client.delete(f"{FLOWS}/{flow}", headers=superuser_token_headers)
def test_publishing_a_flow_with_device_nodes_tells_the_devices(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""An empty dashboard name is what a device refetches its document on."""
_panels(client, superuser_token_headers, {"panels": [{"id": "porch_io"}]})
controller = app.state.flow_controller
sent = []
original = controller._publish
def recorded(event): # type: ignore[no-untyped-def]
sent.append(event)
original(event)
controller._publish = recorded
try:
_publish_flow(client, superuser_token_headers, "porch", [PORCH_NODE])
told = [e for e in sent if e["type"] == "dashboard_changed"]
assert told and told[-1]["dashboard"] == ""
sent.clear()
# A flow with no device in it moves nothing a device reads.
_publish_flow(client, superuser_token_headers, "porch_plain", [])
assert not [e for e in sent if e["type"] == "dashboard_changed"]
client.delete(f"{FLOWS}/porch", headers=superuser_token_headers)
assert [e for e in sent if e["type"] == "dashboard_changed"]
finally:
del controller._publish
client.delete(f"{FLOWS}/porch_plain", headers=superuser_token_headers)
def test_a_panel_may_read_its_dashboards_own_settings(
client: TestClient, superuser_token_headers: dict[str, str]
+94
View File
@@ -0,0 +1,94 @@
"""A device node: never run by the engine, down while its device is away."""
import asyncio
from pathlib import Path
from fluksio.flow.controller import FlowController
from fluksio.flow.devices import presence
from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.schemas import FlowDef, FlowInput, NodeDef
from fluksio.flow.store import FlowStore
def _controller(tmp_path: Path) -> FlowController:
store = FlowStore(tmp_path / "flows")
store.write_flow(
FlowDef(
name="hall",
inputs=[
FlowInput(spec=MessageSpec(name="level", dtype=DType.INT), initial=50)
],
nodes=[
NodeDef(
id="panel",
type="device",
params={"device": "wall"},
provides=[
MessageSpec(name="bell", port="gpio.4", dtype=DType.BOOL)
],
requires=[
MessageSpec(
name="level",
port="backlight",
dtype=DType.INT,
trigger=False,
)
],
),
NodeDef(
id="ring",
type="change",
requires=[MessageSpec(name="bell", dtype=DType.BOOL)],
provides=[MessageSpec(name="rung", dtype=DType.BOOL)],
),
],
)
)
return FlowController(store)
def test_the_engine_never_runs_a_device_node(tmp_path: Path) -> None:
controller = _controller(tmp_path)
asyncio.run(controller.reload())
assert controller.issues == []
node = controller.get_node("hall.panel")
assert node is not None and node.passive
ran: list[object] = []
node.f = lambda **kwargs: ran.append(kwargs) # type: ignore[method-assign]
# The message its out port follows changes, and a manual run of the flow.
controller.publish_message("hall.level", 80)
controller.run_flow("hall")
assert ran == []
# What the device publishes on its in port runs what reads it.
controller.publish_message("hall.bell", True)
assert controller.values("hall")["hall.rung"]["value"] is True
asyncio.run(controller._teardown())
def test_a_device_node_is_down_while_its_device_is_away(tmp_path: Path) -> None:
controller = _controller(tmp_path)
async def scenario() -> list[tuple[str, str | None]]:
await controller.reload()
entry = controller.loaded["hall.panel"]
seen = [(entry.health, entry.health_detail)]
presence.opened("wall")
seen.append((entry.health, entry.health_detail))
presence.closed("wall")
seen.append((entry.health, entry.health_detail))
await controller._teardown()
# Stopped, it no longer follows the device.
presence.opened("wall")
seen.append((entry.health, entry.health_detail))
presence.closed("wall")
return seen
assert asyncio.run(scenario()) == [
("down", "Offline"),
("ok", None),
("down", "Offline"),
("down", "Offline"),
]
presence.forget("wall")
+183
View File
@@ -56,3 +56,186 @@ def test_panels_move_into_devices_once(tmp_path: Path) -> None:
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})
]
-27
View File
@@ -11,7 +11,6 @@ import pytest
from fluksio.api.routes.flows import _endpoints
from fluksio.flow.controller import FlowController
from fluksio.flow.devices import DeviceDef, DevicePort, DeviceStore
from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.schemas import FlowDef, NodeDef
from fluksio.flow.store import FlowStore
@@ -110,29 +109,3 @@ def test_the_wiring_index_is_read_once_per_store_revision(controller):
controller.store.write_flow(FlowDef(name="shed", nodes=[]))
_endpoints(controller, controller.store.read_flow("house"))
assert reads == 2
def test_a_device_port_is_drawn_on_the_side_it_wires(controller, tmp_path):
controller.devices = DeviceStore(controller.store, tmp_path / "devices.json")
controller.devices.create(
DeviceDef(
id="hall",
title="Hall",
io=[
DevicePort(port="gpio.4", message="house.temp", direction="in"),
DevicePort(port="backlight", message="house.temp", direction="out"),
DevicePort(port="mic", message="garage.open", direction="in"),
],
)
)
endpoints = {
one.id: one
for one in _endpoints(controller, controller.store.read_flow("house"))
}
# The id is what the device publishes under, so its edges pulse for it.
assert endpoints["panel:hall:gpio.4"].provides == ["house.temp"]
assert endpoints["panel:hall:backlight"].requires == ["house.temp"]
assert endpoints["panel:hall:backlight"].detail == "Hall"
assert "panel:hall:mic" not in endpoints
+7
View File
@@ -100,6 +100,13 @@ FIXTURES: dict[str, dict] = {
"requires": [MessageSpec(name="prompt", dtype=DType.STR)],
"provides": [MessageSpec(name="kitchen_light", dtype=DType.BOOL)],
},
"device": {
"params": {"device": "hall", "config": {"gpio.4": {"pull": "up"}}},
"requires": [
MessageSpec(name="level", port="backlight", dtype=DType.INT, trigger=False)
],
"provides": [MessageSpec(name="bell", port="gpio.4", dtype=DType.BOOL)],
},
}