Added Wyze
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
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)
|
||||
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)
|
||||
Reference in New Issue
Block a user