Files
app/backend/fluksio/flow/nodes/device.py
T
Melvin Strobl 72c1d73e96 Camera permission and review fixes (openpanel C3, C4)
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).
2026-10-17 09:20:04 +02:00

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)