Files
HomeAssistantVS/custom_components/webrtc/__init__.py
T

318 lines
11 KiB
Python

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)