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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user