- 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
88 lines
3.1 KiB
Python
88 lines
3.1 KiB
Python
"""Device: a paired device's own ports, as a node in a flow.
|
|
|
|
A wall panel's button, its backlight, its microphone. The device publishes on
|
|
its ``in`` ports and follows its ``out`` ports, and this node is where a flow
|
|
says which message each one is: its outputs are the ``in`` ports, its inputs
|
|
the ``out`` ports, each named by the port.
|
|
|
|
The engine never runs it. What it provides is published by the device itself,
|
|
through its own credential, and what it requires is read by the device off its
|
|
socket. The same wiring is what that credential may reach, and what the device
|
|
reads back as its ``io`` (see ``fluksio.flow.devices``).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterable
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
from fluksio.flow.devices import PORT_PATTERN, presence
|
|
from fluksio.flow.messages import MessageSpec
|
|
from fluksio.flow.nodes.base import Node
|
|
|
|
if TYPE_CHECKING:
|
|
from fastapi import FastAPI
|
|
|
|
|
|
def _never(**_: Any) -> None:
|
|
"""What the node would compute, were it ever run: nothing."""
|
|
|
|
|
|
class DeviceNode(Node):
|
|
"""A device's ports, wired to messages. Down while the device is away."""
|
|
|
|
passive = True
|
|
|
|
class Params(BaseModel):
|
|
device: str = Field(default="", description="The device this node stands for.")
|
|
config: dict[str, dict[str, Any]] = Field(
|
|
default_factory=dict,
|
|
description=(
|
|
"Per port, settings handed to the device as they are, such as a "
|
|
"pin's pull-up or how long the screen stays lit."
|
|
),
|
|
)
|
|
|
|
__slots__ = ("cfg",)
|
|
|
|
def __init__(
|
|
self,
|
|
requires: MessageSpec | Iterable[MessageSpec] = (),
|
|
provides: MessageSpec | Iterable[MessageSpec] = (),
|
|
params: dict[str, Any] | None = None,
|
|
name: str | None = None,
|
|
):
|
|
self.cfg = self.Params.model_validate(params or {})
|
|
super().__init__(
|
|
f=_never, requires=requires, provides=provides, params=params, name=name
|
|
)
|
|
for spec in [*self.input_ports, *self.output_ports]:
|
|
if not PORT_PATTERN.fullmatch(spec.port):
|
|
raise ValueError(
|
|
f"'{spec.port}' is not a port name. Use lowercase letters, "
|
|
"digits, underscores and dots, such as gpio.4"
|
|
)
|
|
|
|
@classmethod
|
|
def target_key(cls, params: dict[str, Any]) -> str | None:
|
|
"""One neuron per device, however many flows wire its ports."""
|
|
return str(params.get("device") or "") or None
|
|
|
|
async def start(self, app: FastAPI | None = None) -> None:
|
|
if not self.cfg.device:
|
|
return
|
|
presence.watch(self.cfg.device, self._presence)
|
|
self._presence(presence.online(self.cfg.device))
|
|
|
|
async def stop(self, app: FastAPI | None = None) -> None:
|
|
if self.cfg.device:
|
|
presence.unwatch(self.cfg.device, self._presence)
|
|
|
|
def _presence(self, online: bool) -> None:
|
|
if online:
|
|
self.report_health("ok")
|
|
else:
|
|
self.report_health("down", "Offline")
|