Files
IQ.Pilot/iqpilot/system/webrtc/webrtcd.py
2026-09-01 20:15:16 -05:00

279 lines
8.9 KiB
Python
Executable File

#!/usr/bin/env python3
import argparse
import asyncio
import json
import logging
from dataclasses import dataclass, field
from typing import Any
import warnings
warnings.filterwarnings("ignore", category=DeprecationWarning)
warnings.filterwarnings("ignore", category=RuntimeWarning)
import capnp
from aiohttp import web
from iqpilot.system.webrtc.schema import generate_field
from iqpilot.cereal import messaging, log
class CerealOutgoingMessageProxy:
def __init__(self, sm: messaging.SubMaster):
self.sm = sm
self.channels: list[Any] = []
def add_channel(self, channel: Any):
self.channels.append(channel)
def to_json(self, msg_content: Any):
if isinstance(msg_content, capnp._DynamicStructReader):
msg_dict = msg_content.to_dict()
elif isinstance(msg_content, capnp._DynamicListReader):
msg_dict = [self.to_json(msg) for msg in msg_content]
elif isinstance(msg_content, bytes):
msg_dict = msg_content.decode()
else:
msg_dict = msg_content
return msg_dict
def update(self):
# this is blocking in async context...
self.sm.update(0)
for service, updated in self.sm.updated.items():
if not updated:
continue
msg_dict = self.to_json(self.sm[service])
mono_time, valid = self.sm.logMonoTime[service], self.sm.valid[service]
outgoing_msg = {"type": service, "logMonoTime": mono_time, "valid": valid, "data": msg_dict}
encoded_msg = json.dumps(outgoing_msg).encode()
for channel in self.channels:
channel.send(encoded_msg)
class CerealIncomingMessageProxy:
def __init__(self, pm: messaging.PubMaster):
self.pm = pm
def send(self, message: bytes):
msg_json = json.loads(message)
msg_type, msg_data = msg_json["type"], msg_json["data"]
size = None
if not isinstance(msg_data, dict):
size = len(msg_data)
msg = messaging.new_message(msg_type, size=size)
setattr(msg, msg_type, msg_data)
self.pm.send(msg_type, msg)
class AsyncTaskRunner:
def __init__(self):
self.task: asyncio.Task | None = None
self.logger = logging.getLogger("webrtcd")
def start(self):
if self.task is None:
self.task = asyncio.create_task(self.run())
async def stop(self):
if self.task is None:
return
if not self.task.done():
self.task.cancel()
try:
await self.task
except asyncio.CancelledError:
pass
self.task = None
class CerealProxyRunner:
def __init__(self, proxy: CerealOutgoingMessageProxy):
self.proxy = proxy
self.is_running = False
self.task = None
self.logger = logging.getLogger("webrtcd")
def start(self):
assert self.task is None
self.task = asyncio.create_task(self.run())
def stop(self):
if self.task is None or self.task.done():
return
self.task.cancel()
self.task = None
async def run(self):
while True:
try:
self.proxy.update()
except Exception:
self.logger.exception("Cereal outgoing proxy failure")
await asyncio.sleep(0.01)
class DynamicPubMaster(messaging.PubMaster):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.lock = asyncio.Lock()
async def add_services_if_needed(self, services):
async with self.lock:
for service in services:
if service not in self.sock:
self.sock[service] = messaging.pub_sock(service)
def _is_retryable_stream_error(e: Exception) -> bool:
return isinstance(e, (ValueError, OSError))
async def _cleanup_failed_session(session: Any | None, logger: logging.Logger) -> None:
if session is None:
return
try:
await session.stop_async()
except Exception:
logger.exception("Failed to clean up failed stream session")
def _strip_mdns_host_candidates(sdp: str) -> tuple[str, int]:
lines = sdp.split("\r\n")
kept = [line for line in lines if not (line.startswith("a=candidate:") and ".local" in line)]
return "\r\n".join(kept), len(lines) - len(kept)
@dataclass
class StreamRequestBody:
sdp: str
cameras: list[str]
bridge_services_in: list[str] = field(default_factory=list)
bridge_services_out: list[str] = field(default_factory=list)
iceServers: list[dict[str, Any]] = field(default_factory=list)
ui_stream: bool = False
def _new_stream_session(offer_sdp: str, body: StreamRequestBody, debug_mode: bool):
from iqpilot.system.webrtc.session import StreamSession
return StreamSession(
offer_sdp, body.cameras, body.bridge_services_in, body.bridge_services_out, body.iceServers, debug_mode,
ui_stream=body.ui_stream,
)
async def get_stream(request: 'web.Request'):
stream_dict, debug_mode = request.app['streams'], request.app['debug']
logger = logging.getLogger("webrtcd")
session: Any | None = None
try:
raw_body = await request.json()
body = StreamRequestBody(**raw_body)
offer_sdp = body.sdp
# Single active session on the device: tear down any prior session before starting a new
# one. webrtcd is long-lived (manager-owned), so without this, repeated offers would leak
# sessions and contend for the same livestream topics.
for prev in list(stream_dict.values()):
try:
await prev.stop_async()
except Exception:
logger.exception("Failed to stop previous stream session")
stream_dict.clear()
session = _new_stream_session(offer_sdp, body, debug_mode)
# Creating an answer can occasionally stall (ICE gathering, codec negotiation, etc).
# Bound it so the HTTP request doesn't hang forever and athena can surface a useful error.
try:
answer = await asyncio.wait_for(session.get_answer(), timeout=15.0)
except Exception as e:
if not _is_retryable_stream_error(e):
raise
logger.warning("Transient stream creation error (%s); retrying once with a fresh session", e)
await _cleanup_failed_session(session, logger)
retry_offer_sdp, removed_mdns = _strip_mdns_host_candidates(offer_sdp)
if removed_mdns > 0:
logger.info("Retrying with SDP sanitized; removed %d mDNS host ICE candidate(s)", removed_mdns)
else:
logger.info("Retrying with fresh session and original SDP (no mDNS host candidates removed)")
session = _new_stream_session(retry_offer_sdp, body, debug_mode)
answer = await asyncio.wait_for(session.get_answer(), timeout=15.0)
session.start()
stream_dict[session.identifier] = session
return web.json_response({"sdp": answer.sdp, "type": answer.type})
except TimeoutError:
await _cleanup_failed_session(session, logger)
logger.exception("Timed out generating WebRTC answer")
return web.json_response({"error": "answer_timeout", "message": "Timed out generating WebRTC answer"}, status=504)
except Exception as e:
await _cleanup_failed_session(session, logger)
logger.exception("Failed to create WebRTC stream session")
return web.json_response({"error": "stream_create_failed", "message": str(e)}, status=500)
async def add_ice(request: 'web.Request'):
stream_dict = request.app['streams']
try:
body = await request.json()
except Exception:
return web.json_response({"error": "bad_request"}, status=400)
cand = body.get("candidate")
# Single active session on the device; apply to whatever is live.
for session in list(stream_dict.values()):
await session.add_ice_candidate(cand)
return web.json_response({"ok": True})
async def get_schema(request: 'web.Request'):
services = request.query["services"].split(",")
services = [s for s in services if s]
assert all(s in log.Event.schema.fields and not s.endswith("DEPRECATED") for s in services), "Invalid service name"
schema_dict = {s: generate_field(log.Event.schema.fields[s]) for s in services}
return web.json_response(schema_dict)
async def on_shutdown(app: 'web.Application'):
for session in app['streams'].values():
await session.stop_async()
del app['streams']
def webrtcd_thread(host: str, port: int, debug: bool):
logging.basicConfig(level=logging.CRITICAL, handlers=[logging.StreamHandler()])
logging_level = logging.DEBUG if debug else logging.INFO
logging.getLogger("WebRTCStream").setLevel(logging_level)
logging.getLogger("webrtcd").setLevel(logging_level)
logging.getLogger("LiveStreamVideoStreamTrack").setLevel(logging_level)
app = web.Application()
app['streams'] = dict()
app['debug'] = debug
app.on_shutdown.append(on_shutdown)
app.router.add_post("/stream", get_stream)
app.router.add_post("/ice", add_ice)
app.router.add_get("/schema", get_schema)
web.run_app(app, host=host, port=port)
def main():
parser = argparse.ArgumentParser(description="WebRTC daemon")
parser.add_argument("--host", type=str, default="0.0.0.0", help="Host to listen on")
parser.add_argument("--port", type=int, default=5001, help="Port to listen on")
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
args = parser.parse_args()
webrtcd_thread(args.host, args.port, args.debug)
if __name__=="__main__":
main()