"""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 import logging import threading 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 from fluksio.flow.pipeline import ValueSource if TYPE_CHECKING: from fastapi import FastAPI logger = logging.getLogger(__name__) #: How long a device is away before its flags read false: the floor before #: its node is shown degraded (``controller.HEALTH_FLOOR``), so a reconnect #: or a restart of this instance leaves them as they were. OFFLINE_GRACE_S = 60.0 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__ = ("_away", "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 {}) #: Counting down from the device going away to its flags reading false. self._away: threading.Timer | None = None 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) self._stay() 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._stay() self.report_health("ok") else: self.report_health("degraded", "Offline") self._stay() self._away = threading.Timer(OFFLINE_GRACE_S, self._gone) self._away.daemon = True self._away.start() def _stay(self) -> None: if self._away is not None: self._away.cancel() self._away = None def _gone(self) -> None: """The device has been away for the grace: its flags read false. A ``bool`` it publishes says something is so right now, such as ``person``, and nothing tells a device that went away to say it is no longer so. Credited to this node, as the device's own values are. """ pipeline = self._pipeline if presence.online(self.cfg.device) or pipeline is None: return flags = { spec.name: False for spec in self.output_ports if spec.name and spec.dtype.value == "bool" and pipeline.state.get(spec.name) is True } if not flags: return source = ValueSource( kind="node", id=self.id, label=self.cfg.device, detail="offline" ) try: pipeline.publish(flags, source) except Exception: logger.exception("Could not reset the flags of '%s'", self.cfg.device)