A device reports its board, screen, camera and ports from its own credential (16 KB, schema 1, replacing the last report), and the flows are validated against it again. The node panel turns a reported port's config schema into a form, a reported screen is a canvas preset and a touch screen's first report turns Touch friendly on. An offline device node is degraded: amber on the canvas, a warning on Home, never a fault. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138N7k5PT4dcR1iEqXaaQ9k
90 lines
3.2 KiB
Python
90 lines
3.2 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. Degraded 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:
|
|
# Degraded rather than down: a panel switched off or asleep is a thing
|
|
# to notice, not a fault of the flow wiring it.
|
|
if online:
|
|
self.report_health("ok")
|
|
else:
|
|
self.report_health("degraded", "Offline")
|