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