Cameras on devices (openpanel C1): frames in memory, a binary uplink, demand

- Volatile frames are held in memory with a byte-bounded queue instead of
  the /dev/shm ring scanned on every put; workers keep writing to the
  directory, which is trimmed on a timer. A volatile upload no longer spools
  to disk. ARTIFACT_VOLATILE_BYTES=0 turns the ring off.
- A device sends camera frames as binary messages on its socket, held and
  published in one step and credited to its device node; refused frames are
  logged and dropped. Its snapshot carries demand and the uplink (binary, or
  http through the portal).
- One demand registry for every socket's wanted media, the tunnel's
  included, plus what running flows read, mapped onto on-demand ports and
  published as device_demand; at most 8 media names per socket.
- Per-message deflate off in serve and dev; a device may fetch the last 8
  digests of a message; published dashboards cached per store revision; a
  revoked device socket is closed with 1008.
- Media tiles want bytes only while on screen; the Devices tab previews a
  wired camera; the device node warns when a flow reads an on-demand port.
This commit is contained in:
Melvin Strobl
2026-10-16 20:43:14 +02:00
parent d79203a5f7
commit ecc4bee4bb
29 changed files with 1425 additions and 220 deletions
+78 -14
View File
@@ -22,7 +22,7 @@ import logging
import random
import time
import uuid
from collections.abc import MutableMapping
from collections.abc import Callable, MutableMapping
from dataclasses import replace
from datetime import timedelta
from typing import Any
@@ -72,6 +72,8 @@ ALLOWED_PATHS = frozenset(
#: Neither dict was bounded, and an id the hub reused silently dropped the
#: reference to a task that was still running.
MAX_IN_FLIGHT = 256
#: How many of a client's frames are kept for a stream not listening yet.
MAX_EARLY = 8
#: How often to look for a config that appeared while the engine was up.
#: Enrolment is something a person does and then waits on, so this is the
#: delay they sit through — short enough not to be worth a restart.
@@ -160,6 +162,10 @@ class CloudConnector:
self.app_url: str | None = None
#: What the hub sent each bridged hook socket, waiting to be received.
self._inboxes: dict[str, asyncio.Queue[dict[str, Any]]] = {}
#: What reads a client's frames on each bridged live stream, and what
#: a client said before its stream was listening.
self._listeners: dict[str, Callable[[str], None]] = {}
self._early: dict[str, list[str]] = {}
@property
def revoked(self) -> bool:
@@ -279,14 +285,21 @@ class CloudConnector:
elif op == "ws_open":
self._start_stream(socket, frame)
elif op == "ws_msg":
inbox = self._inboxes.get(str(frame.get("id") or ""))
stream = str(frame.get("id") or "")
text = str(frame.get("text") or "")
inbox = self._inboxes.get(stream)
listener = self._listeners.get(stream)
if inbox is not None:
inbox.put_nowait(
{
"type": "websocket.receive",
"text": str(frame.get("text") or ""),
}
{"type": "websocket.receive", "text": text}
)
elif listener is not None:
listener(text)
elif stream in self._streams:
# A browser speaks as soon as the hub accepts it,
# before this end has checked its credential.
early = self._early.setdefault(stream, [])
early[:] = [*early[-(MAX_EARLY - 1) :], text]
elif op == "ws_close":
self._cancel_stream(str(frame.get("id") or ""))
elif op == "member_removed":
@@ -638,13 +651,17 @@ class CloudConnector:
from fluksio.api.deps import user_from_token
from fluksio.api.routes.flows import (
demand_frame,
device_of,
event_for_panel,
panel_scope,
revoked,
snapshot_payload,
wanted_names,
)
from fluksio.api.socket_auth import redeem_ticket
from fluksio.core.db import engine
from fluksio.flow.demand import demand
from fluksio.flow.devices import presence
from fluksio.flow.events import event_bus
@@ -682,24 +699,64 @@ class CloudConnector:
await socket.send(_dump({"op": "ws_open_ok", "id": stream_id}))
controller = getattr(self._app.state, "flow_controller", None)
told: list[str] = []
if controller is not None:
# A device here sends its frames over HTTP: the tunnel carries
# text only.
snapshot = snapshot_payload(controller, only, device, "http")
told = list(snapshot.get("demand", []))
await socket.send(
_dump(
{
"op": "ws_msg",
"id": stream_id,
"text": _dump(snapshot_payload(controller, only)),
}
)
_dump({"op": "ws_msg", "id": stream_id, "text": _dump(snapshot)})
)
# The media this client's tiles are drawing count as demand on the
# devices sending them, as a direct socket's do. The bytes themselves
# are fetched one by one through the portal.
viewer = ("tunnel", stream_id)
wanted: set[str] = set()
def heard(text: str) -> None:
nonlocal wanted
asked = wanted_names(text, only)
if asked is not None:
wanted = asked
demand.want(viewer, asked)
self._listeners[stream_id] = heard
for text in self._early.pop(stream_id, []):
heard(text)
if device:
presence.opened(device)
try:
async with event_bus.subscribe() as queue:
if device and (ports := demand.ports(device)) != told:
await socket.send(
_dump(
{
"op": "ws_msg",
"id": stream_id,
"text": demand_frame(ports),
}
)
)
while True:
event = await queue.get()
if event.get("type") == "device_demand":
# For the device it names, and for nobody else.
if device and event.get("device") == device:
text = demand_frame(list(event.get("ports") or []))
await socket.send(
_dump({"op": "ws_msg", "id": stream_id, "text": text})
)
continue
if only is not None and event.get("type") == "dashboard_changed":
if device and revoked(token, self._app):
# Unpaired or deleted: 1008 sends it to check.
await socket.send(
_dump({"op": "ws_close", "id": stream_id, "code": 1008})
)
return
# What this screen shows may have just changed under
# it. Rescope, then resend the snapshot so a dashboard
# it has only now been assigned draws values instead of
@@ -707,8 +764,12 @@ class CloudConnector:
# deleted scopes to nothing, and None would widen this
# socket to everything on the bus.
only = panel_scope(token, self._app) or set()
wanted &= only
demand.want(viewer, wanted)
if controller is not None:
catch_up = snapshot_payload(controller, only)
catch_up = snapshot_payload(
controller, only, device, "http"
)
await socket.send(
_dump(
{
@@ -732,6 +793,9 @@ class CloudConnector:
_dump({"op": "ws_close", "id": stream_id, "code": 1011})
)
finally:
self._listeners.pop(stream_id, None)
self._early.pop(stream_id, None)
demand.drop(viewer)
if device:
presence.closed(device)