import asyncio import logging import time import uuid from pathlib import Path from urllib.parse import urlencode, urljoin import voluptuous as vol from aiohttp import web from aiohttp.web_exceptions import HTTPUnauthorized, HTTPGone, HTTPNotFound from homeassistant.components.binary_sensor import HomeAssistant # fix tests from homeassistant.components.camera import async_get_stream_source, async_get_image from homeassistant.components.http import HomeAssistantView from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_URL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.network import get_url from homeassistant.helpers.template import Template from . import utils from .utils import DOMAIN, Server _LOGGER = logging.getLogger(__name__) CREATE_LINK_SCHEMA = vol.Schema( { vol.Required("link_id"): cv.string, vol.Exclusive("url", "url"): cv.string, vol.Exclusive("entity", "url"): cv.entity_id, vol.Optional("open_limit", default=1): cv.positive_int, vol.Optional("time_to_live", default=60): cv.positive_int, }, required=True, ) DASH_CAST_SCHEMA = vol.Schema( { vol.Required(ATTR_ENTITY_ID): cv.entity_ids, vol.Exclusive("url", "url"): cv.string, vol.Exclusive("entity", "url"): cv.entity_id, vol.Optional("extra"): dict, vol.Optional("force", default=False): bool, vol.Optional("hass_url"): str, }, required=True, ) LINKS = {} # 2 3 4 # DDoS protection against requests to HLS proxy # streams are additionally protected by a random playlist identifier HLS_COOKIE = "webrtc-hls-session" HLS_SESSION = str(uuid.uuid4()) async def async_setup(hass: HomeAssistant, config: dict): # 1. Serve lovelace card path = Path(__file__).parent / "www" for name in ("video-rtc.js", "webrtc-camera.js", "digital-ptz.js"): await utils.register_static_path(hass, "/webrtc/" + name, str(path / name)) # 2. Add card to resources version = getattr(hass.data["integrations"][DOMAIN], "version", 0) await utils.init_resource(hass, "/webrtc/webrtc-camera.js", str(version)) # 3. Serve html page await utils.register_static_path(hass, "/webrtc/embed", str(path / "embed.html")) # 4. Serve WebSocket API hass.http.register_view(WebSocketView) # 5. Serve HLS proxy hass.http.register_view(HLSView) # 6. Register webrtc.create_link and webrtc.dash_cast services: async def create_link(call: ServiceCall): link_id = call.data["link_id"] ttl = call.data["time_to_live"] LINKS[link_id] = { "url": call.data.get("url"), "entity": call.data.get("entity"), "limit": call.data["open_limit"], "ts": time.time() + ttl if ttl else 0, } async def dash_cast(call: ServiceCall): link_id = uuid.uuid4().hex LINKS[link_id] = { "url": call.data.get("url"), # camera URL (rtsp...) "entity": call.data.get("entity"), # camera entity id "limit": 1, # 1 attempt "ts": time.time() + 30, # for 30 seconds } hass_url = call.data.get("hass_url") or get_url(hass) query = call.data.get("extra", {}) query["url"] = link_id cast_url = hass_url + "/webrtc/embed?" + urlencode(query) _LOGGER.debug(f"dash_cast: {cast_url}") await hass.async_add_executor_job( utils.dash_cast, hass, call.data[ATTR_ENTITY_ID], cast_url, call.data.get("force", False), ) hass.services.async_register(DOMAIN, "create_link", create_link, CREATE_LINK_SCHEMA) hass.services.async_register(DOMAIN, "dash_cast", dash_cast, DASH_CAST_SCHEMA) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): # 1. If user set custom url go_url = entry.data.get(CONF_URL) # 2. Check if go2rtc running on same server if not go_url: go_url = await utils.check_go2rtc(hass) if go_url: # netloc example: admin:admin@192.168.1.123:1984 hass.data[DOMAIN] = go_url return True # 3. Serve go2rtc binary manually binary = await hass.async_add_executor_job(utils.validate_binary, hass) if not binary: return False hass.data[DOMAIN] = server = Server(binary, hass.config.config_dir) server.start() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, server.stop) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): server = hass.data[DOMAIN] if isinstance(server, Server): server.stop() return True async def ws_connect(hass: HomeAssistant, params: dict) -> str: # 1. Server URL from card param server: str = params.get("server") # 2. Server URL from integration settings if not server: server: str | Server = hass.data[DOMAIN] # 3. Server is manual binary if isinstance(server, Server): assert server.available, "WebRTC server not available" server = "http://localhost:1984/" if entity_id := params.get("entity"): src = await async_get_stream_source(hass, entity_id) if src is None: # build link to MJPEG stream if state := hass.states.get(entity_id): if token := state.attributes.get("access_token"): src = f"{get_url(hass)}/api/camera_proxy_stream/{entity_id}?token={token}" assert src, f"Can't get URL for {entity_id}" query = {"src": src, "name": entity_id} elif src := params.get("url"): if "{{" in src or "{%" in src: src = Template(src, hass).async_render() query = {"src": src} else: raise Exception("Missing url or entity") return urljoin("ws" + server[4:], "api/ws") + "?" + urlencode(query) def _get_image_from_entity_id(hass: HomeAssistant, entity_id: str): """Get camera component from entity_id.""" if (component := hass.data.get("image")) is None: raise Exception("Image integration not set up") if (image := component.get_entity(entity_id)) is None: raise Exception("Image not found") return image async def ws_poster(hass: HomeAssistant, params: dict) -> web.Response: poster: str = params["poster"] if "{{" in poster or "{%" in poster: # support Jinja2 tempaltes inside poster poster = Template(poster, hass).async_render() if poster.startswith("camera."): # support entity_id as poster image = await async_get_image(hass, poster) return web.Response(body=image.content, content_type=image.content_type) if poster.startswith("image."): # support entity_id as poster image_entity = _get_image_from_entity_id(hass, poster) image = await image_entity.async_image() _LOGGER.debug(f"webrtc image_entity: {image_entity} - {len(image)}") return web.Response(body=image, content_type="image/jpeg") # support poster from go2rtc stream name entry = hass.data[DOMAIN] url = "http://localhost:1984/" if isinstance(entry, Server) else entry url = urljoin(url, "api/frame.jpeg") + "?" + urlencode({"src": poster}) async with async_get_clientsession(hass).get(url) as r: body = await r.read() return web.Response(body=body, content_type=r.content_type) class WebSocketView(HomeAssistantView): url = "/api/webrtc/ws" name = "api:webrtc:ws" requires_auth = False async def get(self, request: web.Request): params = request.query _LOGGER.debug(f"New client: {dict(params)}") if request.query.get("embed"): link_id = request.query.get("url") if link_id not in LINKS: raise HTTPNotFound() link = LINKS[link_id] if link["ts"] and time.time() > link["ts"]: LINKS.pop(link_id) raise HTTPGone() if link["limit"]: link["limit"] -= 1 if link["limit"] == 0: LINKS.pop(link_id) params = link # fix for https://github.com/AlexxIT/WebRTC/pull/320 elif not utils.validate_signed_request(request): # you shall not pass raise HTTPUnauthorized() hass = request.app["hass"] if "poster" in params: return await ws_poster(hass, params) ws_server = web.WebSocketResponse(autoclose=False, autoping=False) ws_server.set_cookie(HLS_COOKIE, HLS_SESSION) await ws_server.prepare(request) try: url = await ws_connect(hass, params) remote = request.headers.get("X-Forwarded-For") remote = remote + ", " + request.remote if remote else request.remote # https://www.nginx.com/resources/wiki/start/topics/examples/forwarded/ async with async_get_clientsession(hass).ws_connect( url, autoclose=False, autoping=False, headers={ "User-Agent": request.headers.get("User-Agent"), "X-Forwarded-For": remote, "X-Forwarded-Host": request.host, "X-Forwarded-Proto": request.scheme, }, ) as ws_client: # Proxy requests await asyncio.wait( [ asyncio.create_task(utils.websocket_forward(ws_server, ws_client)), asyncio.create_task(utils.websocket_forward(ws_client, ws_server)), ], return_when=asyncio.FIRST_COMPLETED, ) except Exception as e: await ws_server.send_json({"type": "error", "value": str(e)}) return ws_server class HLSView(HomeAssistantView): url = "/api/webrtc/hls/{filename}" name = "api:webrtc:hls" requires_auth = False async def get(self, request: web.Request, filename: str): if request.cookies.get(HLS_COOKIE) != HLS_SESSION: raise HTTPUnauthorized() if filename not in ("playlist.m3u8", "init.mp4", "segment.m4s", "segment.ts"): raise HTTPNotFound() hass: HomeAssistant = request.app["hass"] entry = hass.data[DOMAIN] url = "http://localhost:1984/" if isinstance(entry, Server) else entry url = urljoin(url, "api/hls/" + filename) + "?" + request.query_string async with async_get_clientsession(hass).get(url) as r: if not r.ok: raise HTTPNotFound() body = await r.read() return web.Response(body=body, content_type=r.content_type)