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