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]
|
||||
|
||||
@@ -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")
|
||||
@@ -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})
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user