C4: a device's `viewers` (superusers by default, or members) covers the image and video messages its ports publish. Anyone else gets none of their values on the socket, in the catalogue or a flow's state, 403 for their history, for the frames they hold (the last 8 included) and for TURN credentials, no bytes when asking for them, and `bye: forbidden` for a live view. A device credential stays bounded by its dashboards. The device page sets it in its Camera section, and a tile the viewer may not see says "No access to this camera" and asks the camera for nothing. C3: - A volatile upload is held to MAX_FRAME_BYTES (4 MiB, 413 above it, read without extra copies), and so is a frame on a device's socket. - A frame the engine holds in memory is written to the ring directory before a local worker is handed it, so load_artifact finds it. - A socket the bus dropped events for catches up with a snapshot, demand included, and a device's credential is checked again per frame, cached by the flow store revision and its nonce, so a lost event never keeps an unpaired device's frames coming. - A report is at most 64 KB once its named config_schemas are put in place. - A device away for 60 s has its true bool messages set to false. - Refused frames are logged by fixed keys, a frame is named after its message, `fluksio serve` waits 5 s for open connections, a tile with a stream URL creates no demand, the 8-name cap keeps the newest names. - Every container's log rotates (json-file, 3 x 10 MB). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138N7k5PT4dcR1iEqXaaQ9k
140 lines
5.0 KiB
Python
140 lines
5.0 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
|
|
|
|
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)
|