Files
app/backend/tests/flow/test_device_node.py
T
Melvin Strobl 29d3ec467f 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
2026-10-16 16:40:31 +02:00

95 lines
3.2 KiB
Python

"""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")