#!/usr/bin/env python3 from abc import abstractmethod import os import socket import time import argparse import asyncio import contextlib import json import uuid import logging from typing import Any, TYPE_CHECKING # aiortc and its dependencies have lots of internal warnings :( import warnings warnings.filterwarnings("ignore", category=DeprecationWarning) warnings.filterwarnings("ignore", category=RuntimeWarning) # TODO: remove this when google-crc32c publish a python3.12 wheel import capnp from aiohttp import web if TYPE_CHECKING: from aiortc.rtcdatachannel import RTCDataChannel import aioice.ice from openpilot.system.webrtc.helpers import StreamRequestBody from openpilot.system.webrtc.schema import generate_field from openpilot.common.params import Params from cereal import messaging, log # socket trick: route lookup for 8.8.8.8 (nothing is sent or actually connected to) # return the source interfaces IP which is the default interface of the device def _default_route_ip() -> str | None: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: s.connect(("8.8.8.8", 53)) # selects a route, sends nothing return s.getsockname()[0] except OSError: return None finally: s.close() # aioice patch: gather ICE candidates only on the default-route interface _get_host_addresses = aioice.ice.get_host_addresses def _primary_host_addresses(use_ipv4: bool, use_ipv6: bool) -> list[str]: addresses = _get_host_addresses(use_ipv4, use_ipv6) primary = _default_route_ip() if primary not in addresses: return addresses return [primary, ] aioice.ice.get_host_addresses = _primary_host_addresses class AsyncTaskRunner: def __init__(self): 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()) async def stop(self): if self.task is None: return task = self.task self.task = None if task.done(): return task.cancel() with contextlib.suppress(asyncio.CancelledError): await task @abstractmethod async def run(self): pass class CerealOutgoingMessageProxy(AsyncTaskRunner): def __init__(self, services: list[str], enabled: bool = True): super().__init__() self.services = list(services) self.sm = messaging.SubMaster(self.services) self.channels: list[RTCDataChannel] = [] self._enabled = enabled def add_channel(self, channel: 'RTCDataChannel'): self.channels.append(channel) def enable(self, enable: bool): self._enabled = enable 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) async def run(self): from aiortc.exceptions import InvalidStateError while True: if not self._enabled: await asyncio.sleep(0.01) continue try: self.update() except InvalidStateError: self.logger.warning("Cereal outgoing proxy invalid state (connection closed)") break except Exception: self.logger.exception("Cereal outgoing proxy failure") await asyncio.sleep(0.01) 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 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) class LivestreamBitrateController(AsyncTaskRunner): bitrates = [500_000, 1_500_000, int(os.environ.get("STREAM_BITRATE", 5_000_000))] label_to_bitrate = { "high": bitrates[2], "med": bitrates[1], "low": bitrates[0]} sample_interval = 0.2 high_level = 0.1 # drop immediately med_level = 0.05 # drop after # of samples low_level = 0 # raise after # of samples down_samples = 5 # 1s param_name = "LivestreamEncoderBitrate" def __init__(self, peer_connection: Any, params: Params, enabled: bool = True): super().__init__() self.pc = peer_connection self.params = params self.level = 2 self._publish(self.bitrates[self.level]) self.prev_lost, self.prev_sent = None, None self.counter = 0 self.up_samples = 5 # 1s self._auto = True self._enabled = enabled def enable(self, enable: bool): self._enabled = enable async def run(self): while True: await asyncio.sleep(self.sample_interval) if not self._enabled: continue if not self._auto: continue loss_rate = await self._sample() if loss_rate is None: continue if loss_rate >= self.med_level and self.level > 0: self.counter += 1 if self.counter >= self.down_samples or loss_rate >= self.high_level: self.level -= 1 self.up_samples *= 2 # exponential backoff before raising again self.counter = 0 self._publish(self.bitrates[self.level]) elif loss_rate <= self.low_level and self.level < len(self.bitrates) - 1: self.counter -= 1 if -self.counter >= self.up_samples: self.level += 1 self.counter = 0 self._publish(self.bitrates[self.level]) async def _sample(self) -> float | None: report = await self.pc.getStats() packets_lost = packets_sent = 0 for s in report.values(): if s.type == "remote-inbound-rtp": packets_lost += s.packetsLost elif s.type == "outbound-rtp": packets_sent += s.packetsSent if self.prev_lost is None: self.prev_lost, self.prev_sent = packets_lost, packets_sent return None lost_delta = max(0, packets_lost - self.prev_lost) sent_delta = max(0, packets_sent - self.prev_sent) self.prev_lost, self.prev_sent = packets_lost, packets_sent return lost_delta / sent_delta if sent_delta else 0.0 def _publish(self, bitrate: float): self.params.put(self.param_name, bitrate) def set_quality(self, quality): if quality in self.label_to_bitrate: self._publish(self.label_to_bitrate[quality]) self._auto = False elif quality == "auto": self._auto = True class StreamSession: shared_pub_master = DynamicPubMaster([]) def __init__(self, body: StreamRequestBody, debug_mode: bool = False): if debug_mode: from aiortc.mediastreams import AudioStreamTrack, VideoStreamTrack from aiortc.contrib.media import MediaBlackhole from openpilot.system.webrtc.device.video import LiveStreamVideoStreamTrack from openpilot.system.webrtc.device.audio import AudioInputStreamTrack, AudioOutputSpeaker from teleoprtc.builder import WebRTCAnswerBuilder from teleoprtc.info import parse_info_from_offer self.identifier = str(uuid.uuid4()) self.params = Params() builder = WebRTCAnswerBuilder(body.sdp) config = parse_info_from_offer(body.sdp) self.enabled = body.enabled self.video_track = LiveStreamVideoStreamTrack(body.init_camera, self.enabled) if not debug_mode else VideoStreamTrack() builder.add_video_stream(body.init_camera, self.video_track) if config.expected_audio_track: builder.add_audio_stream(AudioInputStreamTrack() if not debug_mode else AudioStreamTrack()) if config.incoming_audio_track: self.audio_output_cls = AudioOutputSpeaker if not debug_mode else MediaBlackhole builder.offer_to_receive_audio_stream() self.stream = builder.stream() self.incoming_bridge: CerealIncomingMessageProxy | None = None self.incoming_bridge_services = body.bridge_services_in self.outgoing_bridge: CerealOutgoingMessageProxy | None = None self.bitrate_controller: LivestreamBitrateController | None = None self.audio_output: AudioOutputSpeaker | MediaBlackhole | None = None if len(body.bridge_services_in) > 0: self.incoming_bridge = CerealIncomingMessageProxy(self.shared_pub_master) if len(body.bridge_services_out) > 0: self.outgoing_bridge = CerealOutgoingMessageProxy(body.bridge_services_out, self.enabled) self.bitrate_controller = LivestreamBitrateController(self.stream.peer_connection, self.params, self.enabled) self.run_task: asyncio.Task | None = None self._cleanup_lock = asyncio.Lock() self._cleanup_done = False self.logger = logging.getLogger("webrtcd") self.logger.info( "New stream session (%s), init camera %s, video enabled %s, incoming services %s, outgoing services %s", self.identifier, body.init_camera, body.enabled, body.bridge_services_in, body.bridge_services_out, ) def start(self): self.run_task = asyncio.create_task(self.run()) async def stop(self): if self.run_task is not None and not self.run_task.done() and self.run_task is not asyncio.current_task(): self.run_task.cancel() with contextlib.suppress(asyncio.CancelledError): await self.run_task self.run_task = None await self.post_run_cleanup() async def get_answer(self): return await self.stream.start() def message_handler(self, message: bytes): try: payload = json.loads(message) if isinstance(message, (bytes, str)) else None if isinstance(payload, dict): msg_type = payload.get("type") match msg_type: case "livestreamCameraSwitch": self.video_track.switch_camera(payload["data"]["camera"]) case "livestreamSettings": self.bitrate_controller.set_quality(payload["data"]["quality"]) case "livestreamVideoEnable": enabled = payload["data"]["enabled"] self.enabled = enabled self.video_track.enable(enabled) if self.outgoing_bridge is not None: self.outgoing_bridge.enable(enabled) self.bitrate_controller.enable(enabled) if not enabled: self.params.put("LivestreamRequestKeyframe", True) case "clockSync": pong = json.dumps({"type": "clockSync", "data": { "action": "pong", "browserSendTime": payload["data"]["browserSendTime"], "deviceTime": time.time() * 1000, # noqa: TID251 }}) self.stream.get_messaging_channel().send(pong) case "enableTimingSei": if hasattr(self.video_track, 'timing_sei_enabled'): self.video_track.timing_sei_enabled = bool(payload["data"]["enabled"]) case _: if payload.get("type") not in self.incoming_bridge_services: return self.incoming_bridge.send(message) except Exception: self.logger.exception("Cereal incoming proxy failure") async def run(self): try: self.params.put("LivestreamRequestKeyframe", True) await asyncio.wait_for(self.stream.wait_for_connection(), timeout=15) if self.stream.has_messaging_channel(): self.stream.set_message_handler(self.message_handler) if self.incoming_bridge is not None: await self.shared_pub_master.add_services_if_needed(self.incoming_bridge_services) if self.outgoing_bridge is not None: channel = self.stream.get_messaging_channel() self.outgoing_bridge.add_channel(channel) self.outgoing_bridge.start() if self.stream.has_incoming_audio_track(): track = self.stream.get_incoming_audio_track(buffered=False) self.audio_output = self.audio_output_cls() self.audio_output.addTrack(track) self.audio_output.start() self.bitrate_controller.start() self.logger.info("Stream session (%s) connected", self.identifier) await self.stream.wait_for_disconnection() self.logger.info("Stream session (%s) ended", self.identifier) except Exception: self.logger.exception("Stream session failure") finally: await self.post_run_cleanup() async def post_run_cleanup(self): async with self._cleanup_lock: if self._cleanup_done: return self._cleanup_done = True self.params.put("LivestreamRequestKeyframe", False) await self.bitrate_controller.stop() if self.outgoing_bridge is not None: await self.outgoing_bridge.stop() if self.video_track is not None: self.video_track.stop() self.video_track = None if self.audio_output is not None: self.audio_output.stop() self.audio_output = None await self.stream.stop() def schedule_teardown(app): # if nothing connects for 5 seconds, tear down livestreaming processes h = app.get('teardown') if h: h.cancel() def clear(): if not app['streams']: Params().put_bool("IsLiveStreaming", False) app['teardown'] = asyncio.get_running_loop().call_later(5.0, clear) async def get_stream(request: 'web.Request'): stream_dict, debug_mode = request.app['streams'], request.app['debug'] raw_body = await request.json() body = StreamRequestBody(**raw_body) async with request.app['stream_lock']: # don't remove existing connection on prewarm request enabled = any(s.run_task and not s.run_task.done() and s.enabled for s in stream_dict.values()) if enabled and not body.enabled: return web.json_response({"error": "busy", "message": "someone else is connected."}) for sid, s in list(stream_dict.items()): if s.run_task and not s.run_task.done(): try: ch = s.stream.get_messaging_channel() ch.send(json.dumps({"type": "disconnect", "data": "Another device has connected, closing this session."})) except Exception: pass await s.stop() stream_dict.pop(sid, None) session = StreamSession(body, debug_mode) stream_dict[session.identifier] = session try: answer = await session.get_answer() except Exception: await session.stop() stream_dict.pop(session.identifier, None) logging.getLogger("webrtcd").exception("Failed to create stream answer") raise session.start() def remove_finished_session(_: asyncio.Task) -> None: stream_dict.pop(session.identifier, None) schedule_teardown(request.app) session.run_task.add_done_callback(remove_finished_session) return web.json_response({"sdp": answer.sdp, "type": answer.type}) async def get_schema(request: 'web.Request'): services = request.query.get("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 post_notify(request: 'web.Request'): try: payload = await request.json() except Exception as e: raise web.HTTPBadRequest(text="Invalid JSON") from e for session in list(request.app.get('streams', {}).values()): try: ch = session.stream.get_messaging_channel() ch.send(json.dumps(payload)) except Exception: continue return web.Response(status=200, text="OK") async def on_shutdown(app: 'web.Application'): for session in list(app['streams'].values()): try: ch = session.stream.get_messaging_channel() ch.send(json.dumps({"type": "disconnect", "data": "device streaming has been stopped."})) except Exception: pass await session.stop() del app['streams'] @web.middleware async def error_middleware(request: 'web.Request', handler): try: return await handler(request) except Exception as e: logging.getLogger("webrtcd").exception("Unhandled error handling %s", request.path) return web.json_response({"error": "exception", "message": f"{type(e).__name__}: {e}"}, status=500) def prewarm_stream_session_imports(debug_mode: bool = False) -> None: if debug_mode: from aiortc.mediastreams import VideoStreamTrack assert VideoStreamTrack from openpilot.system.webrtc.device.video import LiveStreamVideoStreamTrack from teleoprtc.builder import WebRTCAnswerBuilder assert LiveStreamVideoStreamTrack assert WebRTCAnswerBuilder def webrtcd_thread(host: str, port: int, debug: bool): logging.basicConfig(level=logging.CRITICAL, handlers=[logging.StreamHandler()]) prewarm_start = time.monotonic() prewarm_stream_session_imports(debug) prewarm_end = time.monotonic() logging.getLogger("webrtcd").info(f"webrtc prewarm finished in {(prewarm_end - prewarm_start) * 1000} ms") app = web.Application(middlewares=[error_middleware]) app['streams'] = dict() app['stream_lock'] = asyncio.Lock() app['debug'] = debug app.on_shutdown.append(on_shutdown) app.router.add_post("/stream", get_stream) app.router.add_post("/notify", post_notify) 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()