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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user