Files
app/backend/fluksio/flow/live.py
T
Melvin Strobl 6a98dcd23f Live view over WebRTC (openpanel L1, server and browser)
A device reporting a `live` port streams its camera to the browser's own
RTCPeerConnection. The flows socket carries the signalling as text, so it
crosses the portal: `live` messages on a person's socket, `signal` on a
device's (start, sdp, candidate, bye, busy). The server routes by session,
holds a device to two viewers, ends a session when either socket closes or
no answer comes within 10 s, and gives each socket an outbox of its own
that ends a session rather than drop a message. TURN credentials are
minted per session in coturn's REST scheme (STUN_URLS, TURN_URLS,
TURN_SECRET), the viewer's with the device's offer; GET /devices/{id}/ice
hands them out too. The tunnel keeps a stream's early frames instead of
dropping them past eight and forgets them when the stream or link goes.

A media tile on a device's camera, and the Devices tab's preview, play
live when tapped, pause frames meanwhile, and fall back to frames with a
short note on busy, failure or no picture within 10 s. coturn is an
optional compose service behind the `turn` profile.
2026-10-17 08:37:39 +02:00

366 lines
13 KiB
Python

"""Live view: WebRTC signalling between a viewer's socket and a device's.
A dashboard tile asks a device's camera for H.264 over WebRTC. The video goes
from the device to the browser directly, or through a TURN relay, never
through here: this only carries the few messages that set a connection up.
Both ends already hold a ``/flows/ws`` socket, which also crosses the portal
tunnel because it carries text, so signalling rides on it.
A person's socket speaks ``{"type": "live", "device", "session", "action"}``
and a device's ``{"type": "signal", "session", "action"}``. ``start`` from
the viewer is handed to the device with ICE servers of its own, the device
offers (``sdp``), which reaches the viewer with ICE servers of the viewer's
own, the viewer answers (``sdp``), both trickle ``candidate``, and ``bye``
from either side ends it. A device past its cap answers ``busy``, and so does
this end once a device has ``MAX_SESSIONS``.
Signalling never rides the event bus, which drops a slow socket's oldest
events: one lost candidate is a connection that never comes up. Each socket
is an :class:`Endpoint` with an outbox of its own that its loop drains, so
only that socket writes to its connection, and a full outbox ends the session
instead of losing a message. One process holds every socket, so one registry
is the whole picture.
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import hmac
import logging
import re
import time
from dataclasses import dataclass, field
from typing import Any
import orjson
from fluksio.core.config import settings
logger = logging.getLogger(__name__)
#: The port a device reports when it can stream live. Signalled, never wired.
LIVE_PORT = "live"
#: Live viewers per device. The NANO encodes once for all of them, and S3
#: measured two at 720p; past that a tile stays on frames.
MAX_SESSIONS = 2
#: How long a session has to get from ``start`` to the viewer's answer.
ANSWER_TIMEOUT_S = 10.0
#: How long TURN credentials stay good. Minted per session, for whoever is
#: one end of it. coturn checks them on every refresh, so a view relayed
#: through it ends this long after it started.
ICE_TTL_S = 3600
#: Signalling messages one socket may have waiting. A session trickles a
#: dozen candidates at most, so a socket this far behind is stuck.
OUTBOX = 64
_SESSION = re.compile(r"^[A-Za-z0-9_-]{8,64}$")
#: An offer is one to three kilobytes, a candidate a line.
MAX_SDP = 16 * 1024
MAX_CANDIDATE = 1024
#: Which actions are passed on once a session stands.
_RELAYED = ("sdp", "candidate")
def ice_servers(who: str) -> list[dict[str, Any]]:
"""The STUN and TURN servers for one end, with TURN credentials of its own.
coturn's REST scheme (``use-auth-secret``): the username is
``<expiry>:<who>`` and the password the base64 HMAC-SHA1 of it under the
shared secret, so coturn checks it without asking anyone.
"""
servers: list[dict[str, Any]] = []
if settings.STUN_URLS:
servers.append({"urls": list(settings.STUN_URLS)})
if settings.TURN_URLS and settings.TURN_SECRET:
username = f"{int(time.time()) + ICE_TTL_S}:{who}"
digest = hmac.new(
settings.TURN_SECRET.encode(), username.encode(), hashlib.sha1
).digest()
servers.append(
{
"urls": list(settings.TURN_URLS),
"username": username,
"credential": base64.b64encode(digest).decode(),
}
)
return servers
def parse(frame: str) -> dict[str, Any] | None:
"""A signalling message a socket sent, or None for anything else."""
try:
payload = orjson.loads(frame)
except orjson.JSONDecodeError:
return None
if isinstance(payload, dict) and payload.get("type") in ("live", "signal"):
return payload
return None
class Endpoint:
"""One socket signalling can reach, and what is waiting to go out on it."""
def __init__(self) -> None:
self._outbox: asyncio.Queue[str] = asyncio.Queue(maxsize=OUTBOX)
def deliver(self, message: dict[str, Any]) -> bool:
"""Put a message in the outbox. False when it is full."""
try:
self._outbox.put_nowait(orjson.dumps(message).decode())
except asyncio.QueueFull:
return False
return True
async def next(self) -> str:
"""The next message for this socket to send, as text."""
return await self._outbox.get()
@dataclass(eq=False)
class Session:
id: str
device: str
viewer: Endpoint
camera: Endpoint
#: Who is watching, whose TURN credentials the viewer is handed.
who: str
offered: bool = False
answered: bool = False
started: float = field(default_factory=time.monotonic)
timer: asyncio.TimerHandle | None = None
class LiveSessions:
"""Every live view being set up or running, and the devices that serve them.
Called only from the event loop, by the sockets themselves.
"""
def __init__(self) -> None:
self._sessions: dict[str, Session] = {}
#: Each device's open sockets, newest last. A device reconnecting can
#: hold two for a moment; a new session goes to the newer one.
self._devices: dict[str, list[Endpoint]] = {}
def attach(self, device_id: str, endpoint: Endpoint) -> None:
"""A device's socket opened."""
self._devices.setdefault(device_id, []).append(endpoint)
def detach(self, endpoint: Endpoint) -> None:
"""A socket closed: every session it was one end of is over."""
for device_id, endpoints in list(self._devices.items()):
if endpoint in endpoints:
endpoints.remove(endpoint)
if not endpoints:
del self._devices[device_id]
for session in list(self._sessions.values()):
if session.viewer is endpoint:
self._end(session, "closed", tell_viewer=False)
elif session.camera is endpoint:
self._end(session, "offline", tell_camera=False)
def count(self, device_id: str) -> int:
return sum(1 for s in self._sessions.values() if s.device == device_id)
def heard(
self,
message: dict[str, Any],
endpoint: Endpoint,
*,
person: str,
device: str,
devices: Any,
) -> None:
"""One signalling message from a socket.
``person`` is the id of the account holding the socket and ``device``
the device whose credential does, one of them "". Any person may watch
any device, camera privacy being deferred. A device speaks only for
the sessions it was asked to serve on this socket.
"""
session_id = str(message.get("session") or "")
if not _SESSION.match(session_id):
return
if message.get("type") == "live" and person:
self._from_viewer(message, session_id, endpoint, person, devices)
elif message.get("type") == "signal" and device:
self._from_device(message, session_id, endpoint, device)
# -- the two directions ---------------------------------------------------
def _from_viewer(
self,
message: dict[str, Any],
session_id: str,
endpoint: Endpoint,
who: str,
devices: Any,
) -> None:
device_id = str(message.get("device") or "")
action = message.get("action")
session = self._sessions.get(session_id)
if action == "start":
if session is not None:
# Said twice, or somebody else's id: neither gets a second one.
return
reason = self._refusal(device_id, devices)
if reason:
endpoint.deliver(
_viewer_message(device_id, session_id, "bye", reason=reason)
)
return
if self.count(device_id) >= MAX_SESSIONS:
endpoint.deliver(_viewer_message(device_id, session_id, "busy"))
return
session = Session(
session_id, device_id, endpoint, self._devices[device_id][-1], who
)
session.timer = asyncio.get_running_loop().call_later(
ANSWER_TIMEOUT_S, self._expire, session
)
self._sessions[session_id] = session
logger.info("Live view %s on %s asked for", session_id, device_id)
self._to_camera(
session,
{
"action": "start",
"ice_servers": ice_servers(f"device-{device_id}"),
},
)
return
if (
session is None
or session.viewer is not endpoint
or session.device != device_id
):
return
if action == "bye":
self._end(session, "closed", tell_viewer=False)
return
relayed = _relayed(message, action)
if relayed is None:
return
if action == "sdp" and session.offered and not session.answered:
session.answered = True
if session.timer is not None:
session.timer.cancel()
self._to_camera(session, relayed)
def _from_device(
self,
message: dict[str, Any],
session_id: str,
endpoint: Endpoint,
device_id: str,
) -> None:
session = self._sessions.get(session_id)
if (
session is None
or session.device != device_id
or session.camera is not endpoint
):
return
action = message.get("action")
if action in ("bye", "busy"):
reason = str(message.get("reason") or "")[:64] or "closed"
self._end(session, reason, tell_camera=False, action=str(action))
return
relayed = _relayed(message, action)
if relayed is None:
return
if action == "sdp" and not session.offered:
# The viewer builds its connection on the offer, so its ICE
# servers come with it: minted only for a session that got a slot.
session.offered = True
relayed["ice_servers"] = ice_servers(session.who)
self._to_viewer(session, relayed)
# -- delivering and ending ------------------------------------------------
def _to_camera(self, session: Session, message: dict[str, Any]) -> None:
sent = {"type": "signal", "session": session.id, **message}
if not session.camera.deliver(sent):
self._end(session, "overflow", tell_camera=False)
def _to_viewer(self, session: Session, message: dict[str, Any]) -> None:
sent = _viewer_message(session.device, session.id, **message)
if not session.viewer.deliver(sent):
self._end(session, "overflow", tell_viewer=False)
def _refusal(self, device_id: str, devices: Any) -> str:
"""Why this device cannot serve a live view now, or ""."""
if not self._devices.get(device_id):
return "offline"
reported = devices.reported_ports(device_id) if devices is not None else None
if LIVE_PORT not in (reported or {}):
return "unsupported"
return ""
def _expire(self, session: Session) -> None:
if self._sessions.get(session.id) is session and not session.answered:
self._end(session, "timeout")
def _end(
self,
session: Session,
reason: str,
*,
tell_viewer: bool = True,
tell_camera: bool = True,
action: str = "bye",
) -> None:
if self._sessions.pop(session.id, None) is not session:
return
if session.timer is not None:
session.timer.cancel()
# Told once, without a retry: a side whose outbox is full is stuck,
# and its own timeout or a closing socket ends it there.
if tell_viewer:
session.viewer.deliver(
_viewer_message(session.device, session.id, action, reason=reason)
)
if tell_camera:
session.camera.deliver(
{
"type": "signal",
"session": session.id,
"action": "bye",
"reason": reason,
}
)
logger.info(
"Live view %s on %s ended after %.1f s: %s",
session.id,
session.device,
time.monotonic() - session.started,
reason,
)
def _relayed(message: dict[str, Any], action: Any) -> dict[str, Any] | None:
"""What of an ``sdp`` or ``candidate`` message is passed on, or None."""
if action not in _RELAYED:
return None
value = message.get(action)
limit = MAX_SDP if action == "sdp" else MAX_CANDIDATE
if not isinstance(value, str) or not value or len(value) > limit:
return None
return {"action": action, action: value}
def _viewer_message(
device: str, session: str, action: str, **rest: Any
) -> dict[str, Any]:
return {
"type": "live",
"device": device,
"session": session,
"action": action,
**rest,
}
sessions = LiveSessions()