"""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 ``:`` 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, superuser: bool = False, ) -> 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 "". A person watches a device whose ``viewers`` are members, or any as a ``superuser``. 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, superuser) 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, superuser: bool, ) -> 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, superuser) 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, superuser: bool) -> str: """Why this device cannot serve this viewer a live view now, or "".""" if not superuser: found = devices.find(device_id) if devices is not None else None if found is None or found.viewers != "members": return "forbidden" 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()