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.
This commit is contained in:
Melvin Strobl
2026-10-17 08:37:39 +02:00
parent efd883d092
commit 6a98dcd23f
20 changed files with 1535 additions and 24 deletions
+32
View File
@@ -71,6 +71,7 @@ from fluksio.flow.devices import (
read_report,
)
from fluksio.flow.events import event_bus
from fluksio.flow.live import ICE_TTL_S, ice_servers
from fluksio.flow.state import CONNECT_TIMEOUT_S, SOCKET_TIMEOUT_S
from fluksio.flow.store import StaleVersion
from fluksio.models import Message, User
@@ -387,6 +388,20 @@ class DevicesPublic(BaseModel):
frontend_host: str = ""
class IceServer(BaseModel):
"""One STUN or TURN server, as `RTCPeerConnection` takes it."""
urls: list[str]
username: str | None = None
credential: str | None = None
class IceServers(BaseModel):
ice_servers: list[IceServer] = Field(default_factory=list)
#: Seconds the TURN credentials stay good.
ttl: int = 0
def _describe(request: Request) -> str:
"""A line naming the device behind a pairing request.
@@ -665,6 +680,23 @@ async def read_device(device_id: str, store: DeviceStoreDep) -> Any:
return await run_in_threadpool(_document, device, store)
@router.get(
"/{device_id}/ice",
response_model=IceServers,
response_model_exclude_none=True,
)
async def read_ice(device_id: str, store: DeviceStoreDep, user: CurrentUser) -> Any:
"""Where a browser looks for a way to this device's live view.
STUN and TURN servers, the TURN ones with credentials of the caller's own
that expire. Empty when none are set, which is enough on one network.
"""
if await run_in_threadpool(store.find, device_id) is None:
raise _not_found(device_id)
servers = [IceServer(**server) for server in ice_servers(str(user.id))]
return IceServers(ice_servers=servers, ttl=ICE_TTL_S)
panels_router.add_api_route(
"/{device_id}",
read_device,
+33 -3
View File
@@ -37,6 +37,7 @@ from fluksio.api.routes.messages import port_source
from fluksio.api.socket_auth import TICKET_TTL_S, issue_ticket, socket_token
from fluksio.core.config import settings
from fluksio.core.db import engine
from fluksio.flow import live as live_view
from fluksio.flow.artifacts import is_reference
from fluksio.flow.controller import NODE_TYPES, FlowController, follow_header
from fluksio.flow.dashboards import DashboardStore
@@ -1369,6 +1370,9 @@ async def flow_events(websocket: WebSocket, ticket: str = "", token: str = "") -
[...]}``) and is pushed those frames. A device also sends its camera's
frames here, as binary frames in the layout it is pushed them in, and is
told when its on-demand ports have demand.
A live view's signalling rides here too (``fluksio.flow.live``): ``live``
messages on a person's socket, ``signal`` messages on a device's.
"""
token = socket_token(websocket.headers, ticket, token)
@@ -1425,6 +1429,9 @@ async def flow_events(websocket: WebSocket, ticket: str = "", token: str = "") -
async with event_bus.subscribe() as queue:
receiver = asyncio.create_task(_receive(websocket))
# Where a live view this socket is one end of reaches it. Its own
# outbox rather than the bus's queue, which drops when full.
endpoint = live_view.Endpoint()
if device:
presence.opened(device)
# Demand that moved between the snapshot and the subscription
@@ -1432,12 +1439,23 @@ async def flow_events(websocket: WebSocket, ticket: str = "", token: str = "") -
ports = demand.ports(device)
if ports != told:
await websocket.send_text(demand_frame(ports))
live_view.sessions.attach(device, endpoint)
outbox = asyncio.create_task(endpoint.next())
try:
while True:
sender = asyncio.create_task(queue.get())
done, _ = await asyncio.wait(
{sender, receiver}, return_when=asyncio.FIRST_COMPLETED
{sender, receiver, outbox}, return_when=asyncio.FIRST_COMPLETED
)
if outbox in done:
# Signalling, for this socket alone.
if sender not in done:
sender.cancel()
text = outbox.result()
outbox = asyncio.create_task(endpoint.next())
await websocket.send_text(text)
if receiver not in done and sender not in done:
continue
if receiver in done:
if sender not in done:
# Nobody will ever read this one: the next turn makes
@@ -1479,11 +1497,21 @@ async def flow_events(websocket: WebSocket, ticket: str = "", token: str = "") -
if asked is not None:
wanted = asked
demand.want(viewer, wanted)
elif (signal := live_view.parse(frame)) is not None:
live_view.sessions.heard(
signal,
endpoint,
person=str(user.id) if only is None else "",
device=device,
devices=getattr(
websocket.app.state, "device_store", None
),
)
receiver = asyncio.create_task(_receive(websocket))
if sender not in done:
continue
# `sender` is done from here: `FIRST_COMPLETED` returns with at
# least one of the two, so `receiver` missing means this one is.
# `sender` is done from here: the turns where it is not have
# gone round again above.
# Everything the bus has right now, not just the one event
# that woke this: a cascade puts a dozen in at once, and one
@@ -1544,7 +1572,9 @@ async def flow_events(websocket: WebSocket, ticket: str = "", token: str = "") -
pass
finally:
receiver.cancel()
outbox.cancel()
demand.drop(viewer)
live_view.sessions.detach(endpoint)
if device:
presence.closed(device)
+65 -7
View File
@@ -73,7 +73,9 @@ ALLOWED_PATHS = frozenset(
#: 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
#: One past it closes the stream rather than losing a frame: a live view's
#: candidates are worth nothing with one missing.
MAX_EARLY = 64
#: 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.
@@ -299,7 +301,20 @@ class CloudConnector:
# 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]
if len(early) < MAX_EARLY:
early.append(text)
else:
logger.warning(
"Closing stream %s: %d frames before it listened",
stream,
MAX_EARLY,
)
self._cancel_stream(stream)
await socket.send(
_dump(
{"op": "ws_close", "id": stream, "code": 1011}
)
)
elif op == "ws_close":
self._cancel_stream(str(frame.get("id") or ""))
elif op == "member_removed":
@@ -321,6 +336,7 @@ class CloudConnector:
task.cancel()
self._calls.clear()
self._streams.clear()
self._early.clear()
return attached
@staticmethod
@@ -631,12 +647,16 @@ class CloudConnector:
"Refusing stream %s: %d already open", stream_id, len(self._streams)
)
return
existing = self._streams.pop(stream_id, None)
if existing is not None:
existing.cancel()
self._cancel_stream(stream_id)
task = asyncio.create_task(self._stream(socket, frame))
self._streams[stream_id] = task
task.add_done_callback(lambda _t: self._streams.pop(stream_id, None))
task.add_done_callback(lambda done: self._stream_done(stream_id, done))
def _stream_done(self, stream_id: str, task: asyncio.Task[None]) -> None:
# Its own entry only: an id the hub reused belongs to the newer task.
if self._streams.get(stream_id) is task:
del self._streams[stream_id]
self._early.pop(stream_id, None)
async def _stream(self, socket: Any, frame: dict[str, Any]) -> None:
"""Feed the engine's live events down the tunnel.
@@ -661,6 +681,7 @@ class CloudConnector:
)
from fluksio.api.socket_auth import redeem_ticket
from fluksio.core.db import engine
from fluksio.flow import live as live_view
from fluksio.flow.demand import demand
from fluksio.flow.devices import presence
from fluksio.flow.events import event_bus
@@ -714,6 +735,11 @@ class CloudConnector:
# are fetched one by one through the portal.
viewer = ("tunnel", stream_id)
wanted: set[str] = set()
# Where a live view this stream is one end of reaches it. Its
# signalling is text, so it crosses, on an outbox of its own.
endpoint = live_view.Endpoint()
outbox: asyncio.Task[str] | None = None
sender: asyncio.Task[dict[str, Any]] | None = None
def heard(text: str) -> None:
nonlocal wanted
@@ -721,6 +747,14 @@ class CloudConnector:
if asked is not None:
wanted = asked
demand.want(viewer, asked)
elif (signal := live_view.parse(text)) is not None:
live_view.sessions.heard(
signal,
endpoint,
person=str(user.id) if only is None else "",
device=device,
devices=getattr(self._app.state, "device_store", None),
)
self._listeners[stream_id] = heard
for text in self._early.pop(stream_id, []):
@@ -728,8 +762,10 @@ class CloudConnector:
if device:
presence.opened(device)
live_view.sessions.attach(device, endpoint)
try:
async with event_bus.subscribe() as queue:
outbox = asyncio.create_task(endpoint.next())
if device and (ports := demand.ports(device)) != told:
await socket.send(
_dump(
@@ -741,7 +777,22 @@ class CloudConnector:
)
)
while True:
event = await queue.get()
sender = asyncio.create_task(queue.get())
done, _ = await asyncio.wait(
{sender, outbox}, return_when=asyncio.FIRST_COMPLETED
)
if outbox in done:
# Signalling, for this stream alone.
if sender not in done:
sender.cancel()
text = outbox.result()
outbox = asyncio.create_task(endpoint.next())
await socket.send(
_dump({"op": "ws_msg", "id": stream_id, "text": text})
)
if sender not in done:
continue
event = sender.result()
if event.get("type") == "device_demand":
# For the device it names, and for nobody else.
if device and event.get("device") == device:
@@ -793,9 +844,13 @@ class CloudConnector:
_dump({"op": "ws_close", "id": stream_id, "code": 1011})
)
finally:
for task in (outbox, sender):
if task is not None:
task.cancel()
self._listeners.pop(stream_id, None)
self._early.pop(stream_id, None)
demand.drop(viewer)
live_view.sessions.detach(endpoint)
if device:
presence.closed(device)
@@ -867,6 +922,9 @@ class CloudConnector:
)
def _cancel_stream(self, stream_id: str) -> None:
# The frames it was holding go with it, including those of a stream
# cancelled before it ever ran its own cleanup.
self._early.pop(stream_id, None)
task = self._streams.pop(stream_id, None)
if task is not None:
task.cancel()
+12
View File
@@ -229,6 +229,18 @@ class Settings(BaseSettings):
list[AnyUrl] | str, BeforeValidator(parse_cors)
] = []
#: Where a live view's two ends look for a way to each other, comma
#: separated. None by default: on one network the peers find each other
#: without, and a third-party server would learn who watches what. coturn
#: serves STUN as well, so its `stun:` address goes here.
STUN_URLS: Annotated[list[str] | str, BeforeValidator(parse_cors)] = []
#: The relay a live view falls back to when the two ends cannot reach each
#: other, as `turn:` and `turns:` URLs. Used only with `TURN_SECRET`, the
#: secret the TURN server shares (coturn's `static-auth-secret`), from
#: which each viewer and device gets credentials of its own.
TURN_URLS: Annotated[list[str] | str, BeforeValidator(parse_cors)] = []
TURN_SECRET: str = ""
@model_validator(mode="before")
@classmethod
def _derive_data_paths(cls, data: Any) -> Any:
+365
View File
@@ -0,0 +1,365 @@
"""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()
+441
View File
@@ -0,0 +1,441 @@
"""Live view: WebRTC signalling relayed between a viewer's socket and a device's.
openpanel ROADMAP L1. The server routes by session, holds a device to two
viewers, ends a session when either socket goes or no answer comes, and hands
out TURN credentials in coturn's REST scheme.
"""
import asyncio
import base64
import hashlib
import hmac
import json
import time
from typing import Any
import pytest
from fastapi.testclient import TestClient
from fluksio.api.routes import oauth
from fluksio.cloud.connector import CloudConnector
from fluksio.core.config import settings
from fluksio.flow import live as live_view
from tests.api.routes.test_cameras import REPORT, wait_for
from tests.api.routes.test_devices import _pair, _panels
PREFIX = f"{settings.API_V1_STR}/devices"
SOCKET = f"{settings.API_V1_STR}/flows/ws"
LIVE_REPORT = {
**REPORT,
"ports": [
*REPORT["ports"],
{"port": "live", "direction": "in", "dtype": "video", "on_demand": True},
],
}
@pytest.fixture(scope="module")
def cams(client: TestClient, superuser_token_headers: dict[str, str]):
"""``cam1`` reports a live port, ``cam2`` does not. Yields their headers."""
for bucket in [b for b in oauth._hits if b.startswith("pair:")]:
del oauth._hits[bucket]
_panels(
client, superuser_token_headers, {"panels": [{"id": "cam1"}, {"id": "cam2"}]}
)
cam1 = _pair(client, superuser_token_headers, "cam1")
cam2 = _pair(client, superuser_token_headers, "cam2")
for headers, report in ((cam1, LIVE_REPORT), (cam2, REPORT)):
device = "cam1" if headers is cam1 else "cam2"
put = client.put(f"{PREFIX}/{device}/report", headers=headers, json=report)
assert put.status_code == 200, put.text
yield cam1, cam2
for device in ("cam1", "cam2"):
client.delete(f"{PREFIX}/{device}", headers=superuser_token_headers)
def _viewer(client: TestClient, headers: dict[str, str]) -> Any:
ticket = client.post(f"{settings.API_V1_STR}/flows/ws-ticket", headers=headers)
return client.websocket_connect(f"{SOCKET}?ticket={ticket.json()['ticket']}")
def _next(ws: Any, kind: str) -> dict[str, Any]:
"""The next message of this type on a socket, skipping the rest."""
while True:
message = ws.receive_json()
if message.get("type") == kind:
return message
def _start(ws: Any, session: str, device: str = "cam1") -> None:
ws.send_json(
{"type": "live", "device": device, "session": session, "action": "start"}
)
def test_signalling_is_relayed_both_ways(
client: TestClient, superuser_token_headers: dict[str, str], cams
) -> None:
"""start, the device's offer, the answer, candidates both ways, bye."""
cam1, _ = cams
with (
client.websocket_connect(SOCKET, headers=cam1) as device,
_viewer(client, superuser_token_headers) as viewer,
):
device.receive_json()
_start(viewer, "session-one")
start = _next(device, "signal")
assert start["action"] == "start"
assert start["session"] == "session-one"
assert start["ice_servers"] == []
device.send_json(
{
"type": "signal",
"session": "session-one",
"action": "sdp",
"sdp": "v=0 offer",
}
)
assert _next(viewer, "live") == {
"type": "live",
"device": "cam1",
"session": "session-one",
"action": "sdp",
"sdp": "v=0 offer",
"ice_servers": [],
}
viewer.send_json(
{
"type": "live",
"device": "cam1",
"session": "session-one",
"action": "sdp",
"sdp": "v=0 answer",
}
)
assert _next(device, "signal")["sdp"] == "v=0 answer"
viewer.send_json(
{
"type": "live",
"device": "cam1",
"session": "session-one",
"action": "candidate",
"candidate": "candidate:1 1 udp 1 10.0.0.2 5000 typ host",
}
)
assert _next(device, "signal")["candidate"].startswith("candidate:1 ")
device.send_json(
{
"type": "signal",
"session": "session-one",
"action": "candidate",
"candidate": "candidate:2 1 udp 1 10.0.0.3 5000 typ host",
}
)
assert _next(viewer, "live")["candidate"].startswith("candidate:2 ")
viewer.send_json(
{
"type": "live",
"device": "cam1",
"session": "session-one",
"action": "bye",
}
)
bye = _next(device, "signal")
assert (bye["action"], bye["session"]) == ("bye", "session-one")
assert live_view.sessions.count("cam1") == 0
def test_a_third_viewer_is_busy_and_closing_ends_the_sessions(
client: TestClient, superuser_token_headers: dict[str, str], cams
) -> None:
cam1, _ = cams
with client.websocket_connect(SOCKET, headers=cam1) as device:
device.receive_json()
with _viewer(client, superuser_token_headers) as viewer:
_start(viewer, "session-a")
_start(viewer, "session-b")
_start(viewer, "session-c")
busy = _next(viewer, "live")
assert (busy["session"], busy["action"]) == ("session-c", "busy")
asked = {_next(device, "signal")["session"] for _ in range(2)}
assert asked == {"session-a", "session-b"}
# The viewer's socket closed: the device hears bye for both.
ended = {_next(device, "signal")["session"] for _ in range(2)}
assert ended == {"session-a", "session-b"}
wait_for(lambda: live_view.sessions.count("cam1") == 0)
with _viewer(client, superuser_token_headers) as viewer:
with client.websocket_connect(SOCKET, headers=cam1) as device:
device.receive_json()
_start(viewer, "session-d")
_next(device, "signal")
# The device answers busy itself, past its own cap.
device.send_json(
{"type": "signal", "session": "session-d", "action": "busy"}
)
assert _next(viewer, "live")["action"] == "busy"
_start(viewer, "session-e")
_next(device, "signal")
# The device's socket closed.
bye = _next(viewer, "live")
assert (bye["session"], bye["action"], bye["reason"]) == (
"session-e",
"bye",
"offline",
)
def test_a_session_without_an_answer_times_out(
client: TestClient,
superuser_token_headers: dict[str, str],
cams,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(live_view, "ANSWER_TIMEOUT_S", 0.2)
cam1, _ = cams
with (
client.websocket_connect(SOCKET, headers=cam1) as device,
_viewer(client, superuser_token_headers) as viewer,
):
device.receive_json()
_start(viewer, "session-slow")
_next(device, "signal")
bye = _next(viewer, "live")
assert (bye["action"], bye["reason"]) == ("bye", "timeout")
told = _next(device, "signal")
assert (told["action"], told["reason"]) == ("bye", "timeout")
def test_only_a_person_starts_and_only_the_device_speaks_for_its_session(
client: TestClient, superuser_token_headers: dict[str, str], cams
) -> None:
cam1, cam2 = cams
with _viewer(client, superuser_token_headers) as viewer:
# Not connected, then connected without a live port.
_start(viewer, "session-off")
assert _next(viewer, "live")["reason"] == "offline"
with client.websocket_connect(SOCKET, headers=cam2) as other:
other.receive_json()
_start(viewer, "session-old", device="cam2")
assert _next(viewer, "live")["reason"] == "unsupported"
with client.websocket_connect(SOCKET, headers=cam1) as device:
device.receive_json()
# A device's credential cannot start one.
_start(other, "session-dev")
_start(viewer, "session-ok")
assert _next(device, "signal")["session"] == "session-ok"
assert live_view.sessions.count("cam1") == 1
# Another device cannot speak for it.
other.send_json(
{
"type": "signal",
"session": "session-ok",
"action": "sdp",
"sdp": "x",
}
)
time.sleep(0.1)
device.send_json(
{
"type": "signal",
"session": "session-ok",
"action": "sdp",
"sdp": "y",
}
)
assert _next(viewer, "live")["sdp"] == "y"
# Nor can another viewer, for a session it does not hold.
with _viewer(client, superuser_token_headers) as stranger:
stranger.send_json(
{
"type": "live",
"device": "cam1",
"session": "session-ok",
"action": "bye",
}
)
time.sleep(0.1)
assert live_view.sessions.count("cam1") == 1
def test_ice_servers_carry_coturn_credentials(
client: TestClient,
superuser_token_headers: dict[str, str],
cams,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``<expiry>:<who>``, and the base64 HMAC-SHA1 of it under the secret."""
monkeypatch.setattr(settings, "STUN_URLS", ["stun:turn.example.com:3478"])
monkeypatch.setattr(
settings,
"TURN_URLS",
["turn:turn.example.com:3478", "turns:turn.example.com:5349?transport=tcp"],
)
monkeypatch.setattr(settings, "TURN_SECRET", "s3cret")
cam1, _ = cams
def check(server: dict[str, Any], who: str) -> None:
expiry, _, name = server["username"].partition(":")
assert name == who
assert 0 < int(expiry) - time.time() <= live_view.ICE_TTL_S
mac = hmac.new(b"s3cret", server["username"].encode(), hashlib.sha1)
assert server["credential"] == base64.b64encode(mac.digest()).decode()
answer = client.get(f"{PREFIX}/cam1/ice", headers=superuser_token_headers)
assert answer.status_code == 200, answer.text
stun, turn = answer.json()["ice_servers"]
assert stun == {"urls": ["stun:turn.example.com:3478"]}
me = client.get(f"{settings.API_V1_STR}/users/me", headers=superuser_token_headers)
check(turn, me.json()["id"])
assert (
client.get(f"{PREFIX}/nope/ice", headers=superuser_token_headers).status_code
== 404
)
# A device gets its servers in `start`, not here.
assert client.get(f"{PREFIX}/cam1/ice", headers=cam1).status_code == 403
with (
client.websocket_connect(SOCKET, headers=cam1) as device,
_viewer(client, superuser_token_headers) as viewer,
):
device.receive_json()
_start(viewer, "session-ice")
check(_next(device, "signal")["ice_servers"][1], "device-cam1")
# The viewer's come with the offer, minted for this session's user.
device.send_json(
{"type": "signal", "session": "session-ice", "action": "sdp", "sdp": "v=0"}
)
check(_next(viewer, "live")["ice_servers"][1], me.json()["id"])
def test_a_full_outbox_ends_the_session_instead_of_dropping() -> None:
"""A socket that stops reading loses its session, never a message."""
class Devices:
def reported_ports(self, _device: str) -> dict:
return {"live": {}}
async def run() -> None:
registry = live_view.LiveSessions()
camera, viewer = live_view.Endpoint(), live_view.Endpoint()
registry.attach("cam9", camera)
registry.heard(
{
"type": "live",
"device": "cam9",
"session": "s-overflow",
"action": "start",
},
viewer,
person="someone",
device="",
devices=Devices(),
)
assert registry.count("cam9") == 1
# The viewer never reads, and the device trickles on.
for index in range(live_view.OUTBOX + 1):
registry.heard(
{
"type": "signal",
"session": "s-overflow",
"action": "candidate",
"candidate": f"candidate:{index} 1 udp 1 10.0.0.9 5000 typ host",
},
camera,
person="",
device="cam9",
devices=None,
)
assert registry.count("cam9") == 0
start, bye = [json.loads(await camera.next()) for _ in range(2)]
assert start["action"] == "start"
assert (bye["action"], bye["reason"]) == ("bye", "overflow")
asyncio.run(run())
def test_the_tunnel_carries_signalling(
client: TestClient, superuser_token_headers: dict[str, str], cams
) -> None:
"""A viewer through the portal, a device on this network, and back."""
cam1, _ = cams
ticket = client.post(
f"{settings.API_V1_STR}/flows/ws-ticket", headers=superuser_token_headers
).json()["ticket"]
connector = CloudConnector(client.app)
sent: list[dict[str, Any]] = []
class Socket:
async def send(self, text: str) -> None:
sent.append(json.loads(text))
def said() -> list[dict[str, Any]]:
return [
json.loads(frame["text"])
for frame in sent
if frame["op"] == "ws_msg" and '"live"' in frame["text"]
]
frame = {"id": "t1", "path": "/api/v1/flows/ws", "query": f"ticket={ticket}"}
assert client.portal is not None
with client.websocket_connect(SOCKET, headers=cam1) as device:
device.receive_json()
stream = client.portal.start_task_soon(connector._stream, Socket(), frame)
try:
wait_for(lambda: "t1" in connector._listeners)
hear = connector._listeners["t1"]
# The stream subscribes to the bus once it has said its snapshot.
wait_for(lambda: any(f["op"] == "ws_msg" for f in sent))
time.sleep(0.1)
client.portal.call(
hear,
json.dumps(
{
"type": "live",
"device": "cam1",
"session": "tunnel-1",
"action": "start",
}
),
)
assert _next(device, "signal")["session"] == "tunnel-1"
device.send_json(
{"type": "signal", "session": "tunnel-1", "action": "sdp", "sdp": "v=0"}
)
offer = wait_for(lambda: said())[0]
assert (offer["session"], offer["sdp"]) == ("tunnel-1", "v=0")
finally:
stream.cancel()
wait_for(stream.done)
# The stream went: the device hears bye.
assert _next(device, "signal")["action"] == "bye"
def test_frames_held_for_a_stream_go_with_it(client: TestClient) -> None:
"""A stream refused before it listened leaves nothing behind."""
connector = CloudConnector(client.app)
sent: list[str] = []
class Socket:
async def send(self, text: str) -> None:
sent.append(text)
frame = {"id": "t2", "path": "/api/v1/flows/ws", "query": "ticket=spent"}
assert client.portal is not None
def open_and_speak() -> None:
connector._start_stream(Socket(), frame)
# What the link does with a frame for a stream not listening yet,
# here before the stream has even started to run.
connector._early["t2"] = ['{"type": "media", "names": []}']
client.portal.call(open_and_speak)
wait_for(lambda: "t2" not in connector._streams)
assert "t2" not in connector._early