Added Wyze

This commit is contained in:
2026-06-12 09:59:05 -04:00
parent c156180e03
commit 8843bf77a5
61 changed files with 8331 additions and 1 deletions
+317
View File
@@ -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)
+106
View File
@@ -0,0 +1,106 @@
import asyncio
import os.path
import platform
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
import yaml
from homeassistant.config_entries import ConfigFlow
from homeassistant.const import CONF_URL, CONF_USERNAME, CONF_PASSWORD
from . import DOMAIN, utils
class FlowHandler(ConfigFlow, domain=DOMAIN):
async def async_step_user(self, user_input=None):
# check if only one integration instance
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
# check if we support this arch
if not utils.get_arch():
return self.async_abort(
reason="arch",
description_placeholders={"uname": str(platform.uname())},
)
if user_input is not None:
url = user_input.get(CONF_URL)
if not url:
# create config file first time
config = self.hass.config.path("go2rtc.yaml")
if not os.path.isfile(config):
return await self.async_step_config(None)
# check if go2rtc url from user input available
elif not await utils.check_go2rtc(self.hass, url):
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Optional(CONF_URL, default=url): cv.string,
}
),
errors={"base": "connect"},
)
return self.async_create_entry(title="WebRTC Camera", data=user_input)
# check if go2rtc already exists on same server
tests = await asyncio.gather(
utils.check_go2rtc(self.hass),
# if go2rtc inside frigate addon with closed public port
utils.check_go2rtc(self.hass, "http://ccab4aaf-frigate:1984"),
utils.check_go2rtc(self.hass, "http://ccab4aaf-frigate-fa:1984"),
utils.check_go2rtc(self.hass, "http://ccab4aaf-frigate-beta:1984"),
)
url = next((url for url in tests if url), vol.UNDEFINED)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Optional(CONF_URL, default=url): cv.string,
}
),
)
async def async_step_config(self, user_input: dict = None):
path = self.hass.config.path("go2rtc.yaml")
if user_input:
config = {}
if not user_input["api"]:
config.setdefault("api", {}).setdefault("listen", "127.0.0.1:1984")
if not user_input["rtsp"]:
config.setdefault("rtsp", {}).setdefault("listen", "127.0.0.1:8554")
if user := user_input.get(CONF_USERNAME):
config.setdefault("api", {}).setdefault("username", user)
config.setdefault("rtsp", {}).setdefault("username", user)
if pasw := user_input.get(CONF_PASSWORD):
config.setdefault("api", {}).setdefault("password", pasw)
config.setdefault("rtsp", {}).setdefault("password", pasw)
if config:
with open(path, "w") as f:
yaml.dump(config, f)
return self.async_create_entry(title="WebRTC Camera", data={})
return self.async_show_form(
step_id="config",
data_schema=vol.Schema(
{
vol.Required("api", default=True): cv.boolean,
vol.Required("rtsp", default=True): cv.boolean,
vol.Optional(CONF_USERNAME): cv.string,
vol.Optional(CONF_PASSWORD): cv.string,
}
),
description_placeholders={"path": path},
)
+17
View File
@@ -0,0 +1,17 @@
{
"domain": "webrtc",
"name": "WebRTC Camera",
"codeowners": [
"@AlexxIT"
],
"config_flow": true,
"dependencies": [
"http",
"lovelace"
],
"documentation": "https://github.com/AlexxIT/WebRTC",
"iot_class": "calculated",
"issue_tracker": "https://github.com/AlexxIT/WebRTC/issues",
"requirements": [],
"version": "v3.6.1"
}
+99
View File
@@ -0,0 +1,99 @@
from datetime import timedelta
import voluptuous as vol
from homeassistant.components import media_source
from homeassistant.components.media_player import (
async_process_play_media_url,
BrowseMedia,
MediaPlayerEntity,
MediaPlayerEntityFeature,
PLATFORM_SCHEMA,
)
from homeassistant.const import STATE_PLAYING, STATE_IDLE, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.typing import ConfigType
from . import utils
from .utils import DOMAIN
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_NAME): cv.string,
vol.Required("stream"): cv.string,
vol.Required("audio"): cv.string,
},
extra=vol.REMOVE_EXTRA,
)
SCAN_INTERVAL = timedelta(seconds=60)
async def async_setup_platform(
hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None
) -> None:
await async_setup_reload_service(hass, DOMAIN, ["media_player"])
player = WebRTCPlayer(**config)
async_add_entities([player])
class WebRTCPlayer(MediaPlayerEntity):
def __init__(self, name: str, stream: str, audio: str, **kwargs):
self._attr_supported_features = (
MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.STOP
)
self._attr_name = name
self._attr_unique_id = stream
self.audio = audio
async def async_play_media(self, media_type: str, media_id: str, **kwargs) -> None:
if media_source.is_media_source_id(media_id):
sourced_media = await media_source.async_resolve_media(
self.hass, media_id, self.entity_id
)
media_id = sourced_media.url
media_id = async_process_play_media_url(self.hass, media_id)
if not media_type.startswith("#"):
media_type = "#input=file"
r = await async_get_clientsession(self.hass).post(
utils.api_streams(self.hass),
params={
"dst": self.unique_id,
"src": f"ffmpeg:{media_id}#audio={self.audio}{media_type}",
},
timeout=9,
)
assert r.ok
async def async_media_stop(self) -> None:
r = await async_get_clientsession(self.hass).post(
utils.api_streams(self.hass),
params={"dst": self.unique_id, "src": ""},
timeout=3,
)
assert r.ok
async def async_update(self):
try:
r = await async_get_clientsession(self.hass).get(
utils.api_streams(self.hass), params={"src": self.unique_id}, timeout=9
)
self._attr_available = r.ok
resp = await r.json(content_type=None)
playing = any("type" in p for p in resp["producers"])
self._attr_state = STATE_PLAYING if playing else STATE_IDLE
except:
pass
async def async_browse_media(
self, media_content_type: str = None, media_content_id: str = None
) -> BrowseMedia:
return await media_source.async_browse_media(self.hass, media_content_id)
+59
View File
@@ -0,0 +1,59 @@
create_link:
fields:
link_id:
example: fd0a53ca-e9ab-4e7a-86a2-441642b16ae1
required: true
selector:
text:
url:
example: rtsp://rtsp:12345678@192.168.1.123:554/av_stream/ch0
selector:
text:
entity:
example: camera.generic_stream
selector:
entity:
domain: camera
open_limit:
default: 1
selector:
number:
min: 0
max: 100
unit_of_measurement: times
time_to_live:
default: 60
selector:
number:
min: 0
max: 100000
unit_of_measurement: seconds
dash_cast:
fields:
entity_id:
example: media_player.mibox4
required: true
selector:
entity:
integration: cast
domain: media_player
url:
example: rtsp://rtsp:12345678@192.168.1.123:554/av_stream/ch0
selector:
text:
entity:
example: camera.generic_stream
selector:
entity:
domain: camera
extra:
selector:
object:
force:
selector:
boolean:
hass_url:
example: http://192.168.1.123:8123
selector:
text:
@@ -0,0 +1,88 @@
{
"config": {
"abort": {
"arch": "Unsupported OS architecture: {uname}",
"single_instance_allowed": "Already configured. Only a single configuration possible."
},
"error": {
"connect": "Can't connect to go2rtc server"
},
"step": {
"user": {
"title": "go2rtc url",
"description": "Select [go2rtc](https://github.com/AlexxIT/go2rtc) streaming server version:\n1. Leave blank if you want the built-in go2rtc version (*basic users*)\n2. Set `http://localhost:1984` if you install [go2rtc](https://github.com/AlexxIT/go2rtc#go2rtc-home-assistant-add-on) or [Frigate 12+](https://docs.frigate.video/) add-on (*advanced users*)\n3. Set custom address if you install go2rtc on another server (*hackers*)",
"data": {
"url": "URL"
}
},
"config": {
"title": "go2rtc config",
"description": "Path: `{path}`",
"data": {
"api": "Public WebUI on port 1984",
"rtsp": "Public RTSP on port 8554",
"username": "Public Username (Web and RTSP)",
"password": "Public Password (Web and RTSP)"
}
}
}
},
"services": {
"create_link": {
"name": "Create Link",
"description": "Create a temporary or permanent link to a stream (enter `url` or `entity`)",
"fields": {
"link_id": {
"name": "Link ID",
"description": "Create a random or permanent ID for your link"
},
"url": {
"name": "URL",
"description": "Link to RTSP-stream"
},
"entity": {
"name": "Entity",
"description": "Camera entity_id"
},
"open_limit": {
"name": "Open limit",
"description": "How many times a link can be opened (0 - unlimit)"
},
"time_to_live": {
"name": "Time to live",
"description": "How many seconds will the link live (0 - unlimit)"
}
}
},
"dash_cast": {
"name": "DashCast",
"description": "Cast stream to Chromecast device via DashCast application",
"fields": {
"entity_id": {
"name": "Media Entity",
"description": "Media player entity_id"
},
"url": {
"name": "URL",
"description": "Link to RTSP-stream"
},
"entity": {
"name": "Entity",
"description": "Camera entity_id"
},
"extra": {
"name": "Extra",
"description": "Additional card params"
},
"force": {
"name": "Force",
"description": "Force restart DashCast application"
},
"hass_url": {
"name": "Hass URL",
"description": "Manual base URL to Hass server"
}
}
}
}
}
@@ -0,0 +1,88 @@
{
"config": {
"abort": {
"arch": "Arquitectura del sistema operativo no compatible: {uname}",
"single_instance_allowed": "Ya está configurado. Solo es posible una única configuración."
},
"error": {
"connect": "No se puede conectar al servidor go2rtc"
},
"step": {
"user": {
"title": "URL de go2rtc",
"description": "Selecciona la versión del servidor de streaming [go2rtc](https://github.com/AlexxIT/go2rtc):\n1. Deja vacío si quieres la versión go2rtc integrada (*usuarios básicos*)\n2. Establece `http://localhost:1984` si instalas el complemento [go2rtc](https://github.com/AlexxIT/go2rtc#go2rtc-home-assistant-add-on) o [Frigate 12+](https://docs.frigate.video/) (*usuarios avanzados*)\n3. Establece una dirección personalizada si instalas go2rtc en otro servidor (*hackers*)",
"data": {
"url": "URL"
}
},
"config": {
"title": "Configuración de go2rtc",
"description": "Ruta: `{path}`",
"data": {
"api": "WebUI pública en el puerto 1984",
"rtsp": "RTSP público en el puerto 8554",
"username": "Nombre de usuario público (Web y RTSP)",
"password": "Contraseña pública (Web y RTSP)"
}
}
}
},
"services": {
"create_link": {
"name": "Crear enlace",
"description": "Crea un enlace temporal o permanente a un stream (ingresa `url` o `entidad`)",
"fields": {
"link_id": {
"name": "ID del enlace",
"description": "Crea una ID aleatoria o permanente para tu enlace"
},
"url": {
"name": "URL",
"description": "Enlace al stream RTSP"
},
"entity": {
"name": "Entidad",
"description": "entity_id de la cámara"
},
"open_limit": {
"name": "Límite de aperturas",
"description": "Cuántas veces se puede abrir un enlace (0 - ilimitado)"
},
"time_to_live": {
"name": "Tiempo de vida",
"description": "Cuántos segundos vivirá el enlace (0 - ilimitado)"
}
}
},
"dash_cast": {
"name": "DashCast",
"description": "Transmite stream al dispositivo Chromecast a través de la aplicación DashCast",
"fields": {
"entity_id": {
"name": "Entidad multimedia",
"description": "entity_id del reproductor multimedia"
},
"url": {
"name": "URL",
"description": "Enlace al stream RTSP"
},
"entity": {
"name": "Entidad",
"description": "entity_id de la cámara"
},
"extra": {
"name": "Extra",
"descripción": "Parámetros adicionales de la tarjeta"
},
"force": {
"name": "Forzar",
"description": "Forzar el reinicio de la aplicación DashCast"
},
"hass_url": {
"name": "URL de Hass",
"description": "URL base manual al servidor Hass"
}
}
}
}
}
@@ -0,0 +1,88 @@
{
"config": {
"abort": {
"arch": "Architecture de l'OS non prise en charge : {uname}",
"single_instance_allowed": "Déjà configuré. Une seule configuration possible."
},
"error": {
"connect": "Impossible de se connecter au serveur go2rtc"
},
"step": {
"user": {
"title": "URL de go2rtc",
"description": "Sélectionnez la version du serveur de streaming [go2rtc](https://github.com/AlexxIT/go2rtc) :\n1. Laissez vide si vous souhaitez la version intégrée de go2rtc (*utilisateurs basiques*).\n2. Définissez « http://localhost:1984 » si vous installez le module complémentaire [go2rtc](https://github.com/AlexxIT/go2rtc#go2rtc-home-assistant-add-on) ou [Frigate 12+](https://docs.frigate.video/) (*utilisateurs avancés*).\n3. Définissez une adresse personnalisée si vous installez go2rtc sur un autre serveur (*hackers*).",
"data": {
"url": "URL"
}
},
"config": {
"title": "configuration de go2rtc",
"description": "Chemin : `{path}`",
"data": {
"api": "Interface Web publique sur le port 1984",
"rtsp": "RTSP public sur le port 8554",
"username": "Nom d'utilisateur public (Web et RTSP)",
"password": "Mot de passe public (Web et RTSP)"
}
}
}
},
"services": {
"create_link": {
"name": "Créer un lien",
"description": "Créez un lien temporaire ou permanent vers un flux (saisissez `url` ou `entity`)",
"fields": {
"link_id": {
"name": "ID du lien",
"description": "Créez un ID aléatoire ou permanent pour votre lien"
},
"url": {
"name": "URL",
"description": "Lien vers le flux RTSP"
},
"entity": {
"name": "Entité",
"description": "Caméra entity_id"
},
"open_limit": {
"name": "Limite ouverte",
"description": "Combien de fois un lien peut être ouvert (0 - illimité)"
},
"time_to_live": {
"name": "Temps du direct",
"description": "Combien de secondes le lien restera-t-il actif (0 - illimité)"
}
}
},
"dash_cast": {
"name": "DashCast",
"description": "Diffuser le flux sur un appareil Chromecast via l'application DashCast",
"fields": {
"entity_id": {
"name": "Entité multimédia",
"description": "Multimédia entity_id"
},
"url": {
"name": "URL",
"description": "Link to RTSP-stream"
},
"entity": {
"name": "Entité",
"description": "Caméra entity_id"
},
"extra": {
"name": "Supplément",
"description": "Paramètres de carte supplémentaires"
},
"force": {
"name": "Forcer",
"description": "Forcer le redémarrage de l'application DashCast"
},
"hass_url": {
"name": "URL Hass",
"description": "URL de base manuelle vers le serveur Hass"
}
}
}
}
}
@@ -0,0 +1,88 @@
{
"config": {
"abort": {
"arch": "Arquitetura de SO não suportada: {uname}",
"single_instance_allowed": "Já configurado. Apenas é possível uma única configuração."
},
"error": {
"connect": "Não é possível ligar ao servidor go2rtc"
},
"step": {
"user": {
"title": "URL do go2rtc",
"description": "Selecione a versão do servidor de streaming [go2rtc](https://github.com/AlexxIT/go2rtc):\n1. Deixe em branco se desejar utilizar a versão go2rtc incorporada (*utilizadores básicos*)\n2. Defina `http://localhost:1984` se instalar o add-on [go2rtc](https://github.com/AlexxIT/go2rtc#go2rtc-home-assistant-add-on) ou [Frigate 12+](https://docs.frigate.video/) (*utilizadores avançados*)\n3. Defina um endereço personalizado se instalar go2rtc num outro servidor (*utilizadores experientes*)",
"data": {
"url": "URL"
}
},
"config": {
"title": "Configuração go2rtc",
"description": "Caminho: `{path}`",
"data": {
"api": "Public WebUI na porta 1984",
"rtsp": "RTSP público na porta 8554",
"username": "Nome de utilizador público (Web e RTSP)",
"password": "Palavra-passe pública (Web e RTSP)"
}
}
}
},
"services": {
"create_link": {
"name": "Criar Link",
"description": "Crie um link temporário ou permanente para um stream (introduza `url` ou `entidade`)",
"fields": {
"link_id": {
"name": "ID do Link",
"description": "Crie um ID aleatório ou permanente para o seu link"
},
"url": {
"name": "URL",
"description": "Link para o stream RTSP"
},
"entity": {
"name": "Entidade",
"description": "ID de entidade da câmara"
},
"open_limit": {
"name": "Limite de aberturas",
"description": "Quantas vezes o link pode ser aberto (0 - sem limite)"
},
"time_to_live": {
"name": "Tempo de vida",
"description": "Quantos segundos o link permanecerá ativo (0 - sem limite)"
}
}
},
"dash_cast": {
"name": "DashCast",
"description": "Transmitir stream para dispositivo Chromecast através da aplicação DashCast",
"fields": {
"entity_id": {
"name": "Entidade de Média",
"description": "ID de entidade do leitor de média"
},
"url": {
"name": "URL",
"description": "Link para o stream RTSP"
},
"entity": {
"name": "Entidade",
"description": "ID de entidade da câmara"
},
"extra": {
"name": "Extra",
"description": "Parâmetros adicionais do cartão"
},
"force": {
"name": "Forçar",
"description": "Forçar reinício da aplicação DashCast"
},
"hass_url": {
"name": "URL do Hass",
"description": "URL base manual para o servidor Hass"
}
}
}
}
}
@@ -0,0 +1,88 @@
{
"config": {
"abort": {
"arch": "غیر تعاون یافتہ OS آرکیٹیکچر: {uname}",
"single_instance_allowed": "پہلے سے ترتیب شدہ۔ صرف ایک ہی ترتیب ممکن ہے۔."
},
"error": {
"connect": "go2rtc سرور سے منسلک نہیں ہو سکتا"
},
"step": {
"user": {
"title": "go2rtc یو آر ایل",
"description": "[go2rtc](https://github.com/AlexxIT/go2rtc) اسٹریمنگ سرور ورژن منتخب کریں:\n1۔ اگر آپ بلٹ ان go2rtc ورژن (*بنیادی صارفین*) چاہتے ہیں تو خالی چھوڑ دیں\n2۔ اگر آپ [go2rtc](https://github.com/AlexxIT/go2rtc#go2rtc-home-assistant-add-on) یا [Frigate 12+](https://) کو انسٹال کرتے ہیں تو `http://localhost:1984` سیٹ کریں docs.frigate.video/) ایڈ آن (*جدید صارفین*)\n3۔ اگر آپ کسی دوسرے سرور (*ہیکرز*) پر go2rtc انسٹال کرتے ہیں تو اپنی مرضی کے مطابق پتہ سیٹ کریں۔",
"data": {
"url": "URL"
}
},
"config": {
"title": "go2rtc تشکیل",
"description": "راستہ: `{path}`",
"data": {
"api": "Public WebUI on port 1984",
"rtsp": "Public RTSP on port 8554",
"username": "عوامی صارف کا نام (ویب اور آر ٹی ایس پی)",
"password": "پبلک پاس ورڈ (ویب اور آر ٹی ایس پی)"
}
}
}
},
"services": {
"create_link": {
"name": "لنک بنائیں",
"description": "سٹریم کا عارضی یا مستقل لنک بنائیں ('url' یا 'entity' درج کریں)",
"fields": {
"link_id": {
"name": "لنک آئی ڈی",
"description": "اپنے لنک کے لیے ایک بے ترتیب یا مستقل ID بنائیں"
},
"url": {
"name": "URL",
"description": "RTSP اسٹریم سے لنک کریں۔"
},
"entity": {
"name": "ہستی",
"description": "کیمرہ entity_id"
},
"open_limit": {
"name": "کھلی حد",
"description": "ایک لنک کتنی بار کھولا جا سکتا ہے (0 - لا محدود)"
},
"time_to_live": {
"name": "زندہ رہنے کا وقت",
"description": "لنک کتنے سیکنڈ تک زندہ رہے گا (0 - لامحدود)"
}
}
},
"dash_cast": {
"name": "ڈیش کاسٹ",
"description": "DashCast ایپلیکیشن کے ذریعے کروم کاسٹ ڈیوائس پر اسٹریم کاسٹ کریں۔",
"fields": {
"entity_id": {
"name": "میڈیا ہستی",
"description": "میڈیا پلیئر کی entity_id"
},
"url": {
"name": "URL",
"description": "RTSP اسٹریم سے لنک کریں۔"
},
"entity": {
"name": "ہستی",
"description": "کیمرہ entity_id"
},
"extra": {
"name": "اضافی",
"description": "کارڈ کے اضافی پیرامز"
},
"force": {
"name": "زبردستی",
"description": "ڈیش کاسٹ ایپلیکیشن کو زبردستی دوبارہ شروع کریں۔"
},
"hass_url": {
"name": " بیس یو آر ایل",
"description": "ہاس سرور کے لیے دستی بیس یو آر ایل"
}
}
}
}
}
+260
View File
@@ -0,0 +1,260 @@
import io
import logging
import os
import platform
import re
import stat
import subprocess
import zipfile
from threading import Thread
from typing import Optional
from urllib.parse import urljoin
import aiohttp
import jwt
import requests
from aiohttp import web
from homeassistant.components.frontend import add_extra_js_url
from homeassistant.components.http.auth import DATA_SIGN_SECRET, SIGN_QUERY_PARAM
from homeassistant.components.lovelace.resources import ResourceStorageCollection
from homeassistant.const import MAJOR_VERSION, MINOR_VERSION
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_component import DATA_INSTANCES
_LOGGER = logging.getLogger(__name__)
DOMAIN = "webrtc"
BINARY_VERSION = "1.9.9"
SYSTEM = {
"Windows": {"AMD64": "go2rtc_win64.zip", "ARM64": "go2rtc_win_arm64.zip"},
"Darwin": {"x86_64": "go2rtc_mac_amd64.zip", "arm64": "go2rtc_mac_arm64.zip"},
"Linux": {
"armv7l": "go2rtc_linux_arm",
"armv8l": "go2rtc_linux_arm", # https://github.com/AlexxIT/WebRTC/issues/18
"aarch64": "go2rtc_linux_arm64",
"x86_64": "go2rtc_linux_amd64",
"i386": "go2rtc_linux_386",
"i486": "go2rtc_linux_386",
"i586": "go2rtc_linux_386",
"i686": "go2rtc_linux_386",
},
}
DEFAULT_URL = "http://localhost:1984/"
BINARY_NAME = re.compile(
r"^(go2rtc-\d\.\d\.\d+|go2rtc_v0\.1-rc\.[5-9]|rtsp2webrtc_v[1-5])(\.exe)?$"
)
def get_arch() -> Optional[str]:
system = SYSTEM.get(platform.system())
if not system:
return None
return system.get(platform.machine())
def unzip(content: bytes) -> bytes:
with zipfile.ZipFile(io.BytesIO(content)) as zf:
for filename in zf.namelist():
with zf.open(filename) as f:
return f.read()
def validate_binary(hass: HomeAssistant) -> Optional[str]:
filename = f"go2rtc-{BINARY_VERSION}"
if platform.system() == "Windows":
filename += ".exe"
filename = hass.config.path(filename)
try:
if os.path.isfile(filename) and subprocess.check_output(
[filename, "-v"]
).startswith(b"go2rtc"):
return filename
except:
pass
# remove all old binaries
for file in os.listdir(hass.config.config_dir):
if BINARY_NAME.match(file):
_LOGGER.debug(f"Remove old binary: {file}")
os.remove(hass.config.path(file))
# download new binary
url = (
f"https://github.com/AlexxIT/go2rtc/releases/download/"
f"v{BINARY_VERSION}/{get_arch()}"
)
_LOGGER.debug(f"Download new binary: {url}")
r = requests.get(url)
if not r.ok:
return None
raw = r.content
# unzip binary for windows
if url.endswith(".zip"):
raw = unzip(raw)
# save binary to config folder
with open(filename, "wb") as f:
f.write(raw)
# change binary access rights
os.chmod(filename, os.stat(filename).st_mode | stat.S_IEXEC)
return filename
async def register_static_path(hass: HomeAssistant, url_path: str, path: str):
if (MAJOR_VERSION, MINOR_VERSION) >= (2024, 7):
from homeassistant.components.http import StaticPathConfig
await hass.http.async_register_static_paths(
[StaticPathConfig(url_path, path, True)]
)
else:
hass.http.register_static_path(url_path, path)
async def init_resource(hass: HomeAssistant, url: str, ver: str) -> bool:
"""Add extra JS module for lovelace mode YAML and new lovelace resource
for mode GUI. It's better to add extra JS for all modes, because it has
random url to avoid problems with the cache. But chromecast don't support
extra JS urls and can't load custom card.
"""
lovelace = hass.data["lovelace"]
resources: ResourceStorageCollection = (
lovelace.resources if hasattr(lovelace, "resources") else lovelace["resources"]
)
# force load storage
await resources.async_get_info()
url2 = f"{url}?v={ver}"
for item in resources.async_items():
if not item.get("url", "").startswith(url):
continue
# no need to update
if item["url"].endswith(ver):
return False
_LOGGER.debug(f"Update lovelace resource to: {url2}")
if isinstance(resources, ResourceStorageCollection):
await resources.async_update_item(
item["id"], {"res_type": "module", "url": url2}
)
else:
# not the best solution, but what else can we do
item["url"] = url2
return True
if isinstance(resources, ResourceStorageCollection):
_LOGGER.debug(f"Add new lovelace resource: {url2}")
await resources.async_create_item({"res_type": "module", "url": url2})
else:
_LOGGER.debug(f"Add extra JS module: {url2}")
add_extra_js_url(hass, url2)
return True
# noinspection PyProtectedMember
def dash_cast(hass: HomeAssistant, entities: list, url: str, force: bool):
"""Cast webpage to chromecast device via DashCast application."""
try:
for entity in hass.data[DATA_INSTANCES]["media_player"].entities:
if entity.entity_id not in entities or not hasattr(entity, "_chromecast"):
continue
if not hasattr(entity, "dashcast"):
from pychromecast.controllers.dashcast import DashCastController
entity.dashcast = DashCastController()
entity._chromecast.register_handler(entity.dashcast)
_LOGGER.debug(f"DashCast to {entity.entity_id}")
entity.dashcast.load_url(url, force=force)
except Exception as e:
_LOGGER.error(f"Can't DashCast to {entities}", exc_info=e)
def validate_signed_request(request: web.Request) -> bool:
try:
hass = request.app["hass"]
secret = hass.data.get(DATA_SIGN_SECRET)
signature = request.query.get(SIGN_QUERY_PARAM)
claims = jwt.decode(signature, secret, algorithms=["HS256"])
return claims["path"] == request.path
except Exception:
return False
async def check_go2rtc(hass: HomeAssistant, url: str = DEFAULT_URL) -> Optional[str]:
session = async_get_clientsession(hass)
try:
r = await session.head(url, timeout=2, allow_redirects=False)
return url if r.status < 300 else None
except Exception:
return None
def api_streams(hass: HomeAssistant) -> str:
entry = hass.data[DOMAIN]
go_url = "http://localhost:1984/" if isinstance(entry, Server) else entry
return urljoin(go_url, "api/streams")
# copied from homeassistant.components.hassio.ingress import _websocket_forward
async def websocket_forward(ws_from, ws_to) -> None:
try:
async for msg in ws_from:
if msg.type is aiohttp.WSMsgType.TEXT:
await ws_to.send_str(msg.data)
elif msg.type is aiohttp.WSMsgType.BINARY:
await ws_to.send_bytes(msg.data)
elif msg.type is aiohttp.WSMsgType.PING:
await ws_to.ping()
elif msg.type is aiohttp.WSMsgType.PONG:
await ws_to.pong()
elif ws_to.closed:
await ws_to.close(code=ws_to.close_code, message=msg.extra) # type: ignore[arg-type]
except Exception as e:
_LOGGER.debug(f"WebSocket forward exception: {repr(e)}")
class Server(Thread):
def __init__(self, binary: str):
super().__init__(name=DOMAIN, daemon=True)
self.binary = binary
self.process = None
@property
def available(self):
return self.process.poll() is None if self.process else False
def run(self):
while self.binary:
self.process = subprocess.Popen(
[self.binary], stdout=subprocess.PIPE, stderr=subprocess.STDOUT
)
# check alive
while self.process.poll() is None:
line = self.process.stdout.readline()
if line == b"":
break
_LOGGER.debug(line[:-1].decode())
def stop(self, *args):
self.binary = None
self.process.terminate()
+346
View File
@@ -0,0 +1,346 @@
// js version generated from https://github.com/dbuezas/pan-zoom-controller/blob/main/src/digital-ptz.ts
const ONE_FINGER_ZOOM_SPEED = 1 / 200; // 1 scale every 200px
const DBL_CLICK_MS = 400;
const MAX_ZOOM = 10;
const DEFAULT_OPTIONS = {
touch_drag_pan: true,
touch_tap_drag_zoom: true,
mouse_drag_pan: true,
mouse_wheel_zoom: true,
mouse_double_click_zoom: true,
touch_pinch_zoom: true,
persist_key: "",
persist: true,
};
export class DigitalPTZ {
constructor(containerEl, transformEl, videoEl, options) {
this.offHandles = [];
this.recomputeRects = () => {
this.transform.updateRects(this.videoEl, this.containerEl);
this.transform.zoomAtCoords(1, 0, 0); // clamp transform
this.render();
};
this.render = (transition = false) => {
if (transition) {
// transition is used to animate dbl click zoom
this.transformEl.style.transition = "transform 200ms";
setTimeout(() => {
this.transformEl.style.transition = "";
}, 200);
}
this.transformEl.style.transform = this.transform.render();
};
this.containerEl = containerEl;
this.transformEl = transformEl;
this.videoEl = videoEl;
this.options = Object.assign({}, DEFAULT_OPTIONS, options);
this.transform = new Transform({
persist_key: this.options.persist_key,
persist: this.options.persist,
});
const o = this.options;
const gestureParam = {
containerEl: this.containerEl,
transform: this.transform,
render: this.render,
};
const h = this.offHandles;
if (o.mouse_drag_pan) h.push(startMouseDragPan(gestureParam));
if (o.mouse_wheel_zoom) h.push(startMouseWheel(gestureParam));
if (o.mouse_double_click_zoom) h.push(startDoubleClickZoom(gestureParam));
if (o.touch_tap_drag_zoom) h.push(startTouchTapDragZoom(gestureParam));
if (o.touch_drag_pan) h.push(startTouchDragPan(gestureParam));
if (o.touch_pinch_zoom) h.push(startTouchPinchZoom(gestureParam));
this.videoEl.addEventListener("loadedmetadata", this.recomputeRects);
this.resizeObserver = new ResizeObserver(this.recomputeRects);
this.resizeObserver.observe(this.containerEl);
this.recomputeRects();
}
destroy() {
for (const off of this.offHandles) off();
this.videoEl.removeEventListener("loadedmetadata", this.recomputeRects);
this.resizeObserver.unobserve(this.containerEl);
}
}
/* Gestures */
const preventScroll = (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
};
const getCenter = (touches) => ({
x: (touches[0].pageX + touches[1].pageX) / 2,
y: (touches[0].pageY + touches[1].pageY) / 2,
});
const getSpread = (touches) =>
Math.hypot(
touches[0].pageX - touches[1].pageX,
touches[0].pageY - touches[1].pageY
);
function startTouchPinchZoom({ containerEl, transform, render }) {
const onTouchStart = (downEvent) => {
const relevant = downEvent.touches.length === 2;
if (!relevant) return;
let lastTouches = downEvent.touches;
const onTouchMove = (moveEvent) => {
const newTouches = moveEvent.touches;
const oldCenter = getCenter(lastTouches);
const newCenter = getCenter(newTouches);
const dx = newCenter.x - oldCenter.x;
const dy = newCenter.y - oldCenter.y;
transform.move(dx, dy);
const oldSpread = getSpread(lastTouches);
const newSpread = getSpread(newTouches);
const zoom = newSpread / oldSpread;
transform.zoomAtCoords(zoom, newCenter.x, newCenter.y);
lastTouches = moveEvent.touches;
render();
preventScroll(moveEvent);
};
const onTouchEnd = () =>
containerEl.removeEventListener("touchmove", onTouchMove);
containerEl.addEventListener("touchmove", onTouchMove);
containerEl.addEventListener("touchend", onTouchEnd, { once: true });
};
containerEl.addEventListener("touchstart", onTouchStart);
return () => containerEl.removeEventListener("touchstart", onTouchStart);
}
const getDist = (t1, t2) =>
Math.hypot(
t1.touches[0].pageX - t2.touches[0].pageX,
t1.touches[0].pageY - t2.touches[0].pageY
);
function startTouchTapDragZoom({ containerEl, transform, render }) {
let lastEvent;
let fastClicks = 0;
const onTouchStart = (downEvent) => {
const isFastClick =
lastEvent && downEvent.timeStamp - lastEvent.timeStamp < DBL_CLICK_MS;
if (!isFastClick) fastClicks = 0;
fastClicks++;
if (downEvent.touches.length > 1) fastClicks = 0;
lastEvent = downEvent;
};
const onTouchMove = (moveEvent) => {
if (fastClicks === 2) {
const lastY = lastEvent.touches[0].pageY;
const currY = moveEvent.touches[0].pageY;
transform.zoom(1 - (lastY - currY) * ONE_FINGER_ZOOM_SPEED);
lastEvent = moveEvent;
render();
preventScroll(moveEvent);
} else if (getDist(lastEvent, moveEvent) > 10) {
fastClicks = 0;
}
};
containerEl.addEventListener("touchmove", onTouchMove);
containerEl.addEventListener("touchstart", onTouchStart);
return () => {
containerEl.removeEventListener("touchmove", onTouchMove);
containerEl.removeEventListener("touchstart", onTouchStart);
};
}
function startMouseWheel({ containerEl, transform, render }) {
const onWheel = (e) => {
const zoom = 1 - e.deltaY / 1000;
transform.zoomAtCoords(zoom, e.pageX, e.pageY);
render();
preventScroll(e);
};
containerEl.addEventListener("wheel", onWheel);
return () => containerEl.removeEventListener("wheel", onWheel);
}
function startDoubleClickZoom({ containerEl, transform, render }) {
let lastDown = 0;
let clicks = 0;
const onDown = (downEvent) => {
const isFastClick = downEvent.timeStamp - lastDown < DBL_CLICK_MS;
lastDown = downEvent.timeStamp;
if (!isFastClick) clicks = 0;
clicks++;
if (clicks !== 2) return;
const onUp = (upEvent) => {
const isQuickRelease = upEvent.timeStamp - lastDown < DBL_CLICK_MS;
const dist = Math.hypot(
upEvent.pageX - downEvent.pageX,
upEvent.pageY - downEvent.pageY
);
if (!isQuickRelease || dist > 20) return;
const zoom = transform.scale == 1 ? 2 : 0.01;
transform.zoomAtCoords(zoom, upEvent.pageX, upEvent.pageY);
render(true);
};
window.addEventListener("mouseup", onUp, { once: true });
};
containerEl.addEventListener("mousedown", onDown);
return () => containerEl.removeEventListener("mousedown", onDown);
}
function startGesturePan({ containerEl, transform, render }, type) {
const [downName, moveName, upName] =
type === "mouse"
? ["mousedown", "mousemove", "mouseup"]
: ["touchstart", "touchmove", "touchend"];
const isTouchEvent = ev => 'TouchEvent' in window && ev instanceof TouchEvent;
const onDown = (downEvt) => {
let last = isTouchEvent(downEvt) ? downEvt.touches[0] : downEvt;
const onMove = (moveEvt) => {
if (isTouchEvent(moveEvt) && moveEvt.touches.length !== 1) return;
const curr = isTouchEvent(moveEvt) ? moveEvt.touches[0] : moveEvt;
transform.move(curr.pageX - last.pageX, curr.pageY - last.pageY);
last = curr;
render();
if (transform.scale !== 1) preventScroll(moveEvt);
};
containerEl.addEventListener(moveName, onMove);
const onUp = () => containerEl.removeEventListener(moveName, onMove);
window.addEventListener(upName, onUp, { once: true });
};
containerEl.addEventListener(downName, onDown);
return () => containerEl.removeEventListener(downName, onDown);
}
function startTouchDragPan(params) {
return startGesturePan(params, "touch");
}
function startMouseDragPan(params) {
return startGesturePan(params, "mouse");
}
/** Transform */
const PERSIST_KEY_PREFIX = "webrtc-digital-ptc:";
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
function getTransformedDimensions(video) {
const { videoWidth, videoHeight } = video;
if (!videoHeight || !videoWidth) return undefined;
var transform = window.getComputedStyle(video).getPropertyValue("transform");
const match = transform.match(/matrix\((.+)\)/);
if (!match || !match[1]) return { videoWidth, videoHeight }; // the video isn't transformed
const matrix = new DOMMatrix(match[1].split(", ").map(Number));
const points = [
new DOMPoint(0, 0),
new DOMPoint(videoWidth, 0),
new DOMPoint(0, videoHeight),
new DOMPoint(videoWidth, videoHeight),
].map((point) => point.matrixTransform(matrix));
const minX = Math.min(...points.map((point) => point.x));
const maxX = Math.max(...points.map((point) => point.x));
const minY = Math.min(...points.map((point) => point.y));
const maxY = Math.max(...points.map((point) => point.y));
return { videoWidth: maxX - minX, videoHeight: maxY - minY };
}
class Transform {
constructor(settings) {
this.scale = 1;
this.x = 0;
this.y = 0;
this.loadPersistedTransform = () => {
const { persist_key, persist } = this.settings;
if (!persist) return;
try {
const loaded = JSON.parse(localStorage[persist_key]);
const isValid = [loaded.scale, loaded.x, loaded.y].every(
Number.isFinite
);
if (!isValid) {
throw new Error("Broken local storage");
}
this.x = loaded.x;
this.y = loaded.y;
this.scale = loaded.scale;
} catch (e) {
delete localStorage[persist_key];
}
};
this.persistTransform = () => {
const { persist_key, persist } = this.settings;
if (!persist) return;
const { x, y, scale } = this;
localStorage[persist_key] = JSON.stringify({
x,
y,
scale,
});
};
this.settings = Object.assign(Object.assign({}, settings), {
persist_key: PERSIST_KEY_PREFIX + settings.persist_key,
});
this.loadPersistedTransform();
}
updateRects(videoEl, containerEl) {
const containerRect = containerEl.getBoundingClientRect();
if (containerRect.width === 0 || containerRect.height === 0) {
// The container rect has no size yet.
// This happens when coming back to a tab that was already opened.
// The card will get size shortly and the size observer will call this function again.
return;
}
this.containerRect = containerRect;
const transformed = getTransformedDimensions(videoEl);
if (!transformed) {
// The video hasn't loaded yet.
// Once it loads, the videometadata listener will call this function again.
return;
}
// When in full screen, and if the aspect ratio of the screen differs from that of the video,
// black bars will be shown either to the sides or above/below the video.
// This needs to be accounted for when panning, the code below keeps track of that.
const screenAspectRatio =
this.containerRect.width / this.containerRect.height;
const videoAspectRatio = transformed.videoWidth / transformed.videoHeight;
if (videoAspectRatio > screenAspectRatio) {
// Black bars on the top and bottom
const videoHeight = this.containerRect.width / videoAspectRatio;
const blackBarHeight = (this.containerRect.height - videoHeight) / 2;
this.videoRect = new DOMRect(
this.containerRect.x,
blackBarHeight + this.containerRect.y,
this.containerRect.width,
videoHeight
);
} else {
// Black bars on the sides
const videoWidth = this.containerRect.height * videoAspectRatio;
const blackBarWidth = (this.containerRect.width - videoWidth) / 2;
this.videoRect = new DOMRect(
blackBarWidth + this.containerRect.x,
this.containerRect.y,
videoWidth,
this.containerRect.height
);
}
}
// dx,dy are deltas.
move(dx, dy) {
if (!this.videoRect) return;
const bound = (this.scale - 1) / 2;
this.x += dx / this.videoRect.width;
this.y += dy / this.videoRect.height;
this.x = clamp(this.x, -bound, bound);
this.y = clamp(this.y, -bound, bound);
this.persistTransform();
}
// x,y are relative to viewport (clientX, clientY)
zoomAtCoords(zoom, x, y) {
if (!this.containerRect || !this.videoRect) return;
const oldScale = this.scale;
this.scale *= zoom;
this.scale = clamp(this.scale, 1, MAX_ZOOM);
zoom = this.scale / oldScale;
x = x - this.containerRect.x - this.containerRect.width / 2;
y = y - this.containerRect.y - this.containerRect.height / 2;
const dx = x - this.x * this.videoRect.width;
const dy = y - this.y * this.videoRect.height;
this.move(dx * (1 - zoom), dy * (1 - zoom));
}
zoom(zoom) {
if (!this.containerRect || !this.videoRect) return;
const x = this.containerRect.width / 2;
const y = this.containerRect.height / 2;
this.zoomAtCoords(zoom, x, y);
}
render() {
if (!this.videoRect) return "";
const { x, y, scale } = this;
return `translate(${x * this.videoRect.width}px, ${
y * this.videoRect.height
}px) scale(${scale})`;
}
}
+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta content="width=device-width, initial-scale=1" name="viewport">
<title>WebRTC Camera</title>
<style>
body {
margin: 0;
padding: 0;
font-family: Arial, Helvetica, sans-serif;
}
html, body, webrtc-camera {
height: 100%;
width: 100%;
}
ha-card {
display: block;
}
</style>
</head>
<body>
<script type="module" src="/webrtc/webrtc-camera.js"></script>
<script type="module">
const config = {};
for (const [k, v] of new URLSearchParams(location.search)) {
if (v === 'true') config[k] = true;
else if (v === 'false') config[k] = false;
else config[k] = v;
}
const card = document.createElement('webrtc-camera');
card.setConfig(config);
card.hass = {
callWS: () => new Promise(resolve => {
resolve('');
}),
hassUrl: () => location.origin + '/api/webrtc/ws?embed=1'
};
document.body.appendChild(card);
</script>
</body>
</html>
+676
View File
@@ -0,0 +1,676 @@
/**
* VideoRTC v1.6.0 - Video player for go2rtc streaming application.
*
* All modern web technologies are supported in almost any browser except Apple Safari.
*
* Support:
* - ECMAScript 2017 (ES8) = ES6 + async
* - RTCPeerConnection for Safari iOS 11.0+
* - IntersectionObserver for Safari iOS 12.2+
* - ManagedMediaSource for Safari 17+
*
* Doesn't support:
* - MediaSource for Safari iOS
* - Customized built-in elements (extends HTMLVideoElement) because Safari
* - Autoplay for WebRTC in Safari
*/
export class VideoRTC extends HTMLElement {
constructor() {
super();
this.DISCONNECT_TIMEOUT = 5000;
this.RECONNECT_TIMEOUT = 15000;
this.CODECS = [
'avc1.640029', // H.264 high 4.1 (Chromecast 1st and 2nd Gen)
'avc1.64002A', // H.264 high 4.2 (Chromecast 3rd Gen)
'avc1.640033', // H.264 high 5.1 (Chromecast with Google TV)
'hvc1.1.6.L153.B0', // H.265 main 5.1 (Chromecast Ultra)
'mp4a.40.2', // AAC LC
'mp4a.40.5', // AAC HE
'flac', // FLAC (PCM compatible)
'opus', // OPUS Chrome, Firefox
];
/**
* [config] Supported modes (webrtc, webrtc/tcp, mse, hls, mp4, mjpeg).
* @type {string}
*/
this.mode = 'webrtc,mse,hls,mjpeg';
/**
* [Config] Requested medias (video, audio, microphone).
* @type {string}
*/
this.media = 'video,audio';
/**
* [config] Run stream when not displayed on the screen. Default `false`.
* @type {boolean}
*/
this.background = false;
/**
* [config] Run stream only when player in the viewport. Stop when user scroll out player.
* Value is percentage of visibility from `0` (not visible) to `1` (full visible).
* Default `0` - disable;
* @type {number}
*/
this.visibilityThreshold = 0;
/**
* [config] Run stream only when browser page on the screen. Stop when user change browser
* tab or minimise browser windows.
* @type {boolean}
*/
this.visibilityCheck = true;
/**
* [config] WebRTC configuration
* @type {RTCConfiguration}
*/
this.pcConfig = {
bundlePolicy: 'max-bundle',
iceServers: [{urls: 'stun:stun.l.google.com:19302'}],
sdpSemantics: 'unified-plan', // important for Chromecast 1
};
/**
* [info] WebSocket connection state. Values: CONNECTING, OPEN, CLOSED
* @type {number}
*/
this.wsState = WebSocket.CLOSED;
/**
* [info] WebRTC connection state.
* @type {number}
*/
this.pcState = WebSocket.CLOSED;
/**
* @type {HTMLVideoElement}
*/
this.video = null;
/**
* @type {WebSocket}
*/
this.ws = null;
/**
* @type {string|URL}
*/
this.wsURL = '';
/**
* @type {RTCPeerConnection}
*/
this.pc = null;
/**
* @type {number}
*/
this.connectTS = 0;
/**
* @type {string}
*/
this.mseCodecs = '';
/**
* [internal] Disconnect TimeoutID.
* @type {number}
*/
this.disconnectTID = 0;
/**
* [internal] Reconnect TimeoutID.
* @type {number}
*/
this.reconnectTID = 0;
/**
* [internal] Handler for receiving Binary from WebSocket.
* @type {Function}
*/
this.ondata = null;
/**
* [internal] Handlers list for receiving JSON from WebSocket.
* @type {Object.<string,Function>}
*/
this.onmessage = null;
}
/**
* Set video source (WebSocket URL). Support relative path.
* @param {string|URL} value
*/
set src(value) {
if (typeof value !== 'string') value = value.toString();
if (value.startsWith('http')) {
value = 'ws' + value.substring(4);
} else if (value.startsWith('/')) {
value = 'ws' + location.origin.substring(4) + value;
}
this.wsURL = value;
this.onconnect();
}
/**
* Play video. Support automute when autoplay blocked.
* https://developer.chrome.com/blog/autoplay/
*/
play() {
this.video.play().catch(() => {
if (!this.video.muted) {
this.video.muted = true;
this.video.play().catch(er => {
console.warn(er);
});
}
});
}
/**
* Send message to server via WebSocket
* @param {Object} value
*/
send(value) {
if (this.ws) this.ws.send(JSON.stringify(value));
}
/** @param {Function} isSupported */
codecs(isSupported) {
return this.CODECS
.filter(codec => this.media.indexOf(codec.indexOf('vc1') > 0 ? 'video' : 'audio') >= 0)
.filter(codec => isSupported(`video/mp4; codecs="${codec}"`)).join();
}
/**
* `CustomElement`. Invoked each time the custom element is appended into a
* document-connected element.
*/
connectedCallback() {
if (this.disconnectTID) {
clearTimeout(this.disconnectTID);
this.disconnectTID = 0;
}
// because video autopause on disconnected from DOM
if (this.video) {
const seek = this.video.seekable;
if (seek.length > 0) {
this.video.currentTime = seek.end(seek.length - 1);
}
this.play();
} else {
this.oninit();
}
this.onconnect();
}
/**
* `CustomElement`. Invoked each time the custom element is disconnected from the
* document's DOM.
*/
disconnectedCallback() {
if (this.background || this.disconnectTID) return;
if (this.wsState === WebSocket.CLOSED && this.pcState === WebSocket.CLOSED) return;
this.disconnectTID = setTimeout(() => {
if (this.reconnectTID) {
clearTimeout(this.reconnectTID);
this.reconnectTID = 0;
}
this.disconnectTID = 0;
this.ondisconnect();
}, this.DISCONNECT_TIMEOUT);
}
/**
* Creates child DOM elements. Called automatically once on `connectedCallback`.
*/
oninit() {
this.video = document.createElement('video');
this.video.controls = true;
this.video.playsInline = true;
this.video.preload = 'auto';
this.video.style.display = 'block'; // fix bottom margin 4px
this.video.style.width = '100%';
this.video.style.height = '100%';
this.appendChild(this.video);
this.video.addEventListener('error', ev => {
console.warn(ev);
if (this.ws) this.ws.close(); // run reconnect for broken MSE stream
});
// all Safari lies about supported audio codecs
const m = window.navigator.userAgent.match(/Version\/(\d+).+Safari/);
if (m) {
// AAC from v13, FLAC from v14, OPUS - unsupported
const skip = m[1] < '13' ? 'mp4a.40.2' : m[1] < '14' ? 'flac' : 'opus';
this.CODECS.splice(this.CODECS.indexOf(skip));
}
if (this.background) return;
if ('hidden' in document && this.visibilityCheck) {
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.disconnectedCallback();
} else if (this.isConnected) {
this.connectedCallback();
}
});
}
if ('IntersectionObserver' in window && this.visibilityThreshold) {
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (!entry.isIntersecting) {
this.disconnectedCallback();
} else if (this.isConnected) {
this.connectedCallback();
}
});
}, {threshold: this.visibilityThreshold});
observer.observe(this);
}
}
/**
* Connect to WebSocket. Called automatically on `connectedCallback`.
* @return {boolean} true if the connection has started.
*/
onconnect() {
if (!this.isConnected || !this.wsURL || this.ws || this.pc) return false;
// CLOSED or CONNECTING => CONNECTING
this.wsState = WebSocket.CONNECTING;
this.connectTS = Date.now();
this.ws = new WebSocket(this.wsURL);
this.ws.binaryType = 'arraybuffer';
this.ws.addEventListener('open', () => this.onopen());
this.ws.addEventListener('close', () => this.onclose());
return true;
}
ondisconnect() {
this.wsState = WebSocket.CLOSED;
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.pcState = WebSocket.CLOSED;
if (this.pc) {
this.pc.getSenders().forEach(sender => {
if (sender.track) sender.track.stop();
});
this.pc.close();
this.pc = null;
}
this.video.src = '';
this.video.srcObject = null;
}
/**
* @returns {Array.<string>} of modes (mse, webrtc, etc.)
*/
onopen() {
// CONNECTING => OPEN
this.wsState = WebSocket.OPEN;
this.ws.addEventListener('message', ev => {
if (typeof ev.data === 'string') {
const msg = JSON.parse(ev.data);
for (const mode in this.onmessage) {
this.onmessage[mode](msg);
}
} else {
this.ondata(ev.data);
}
});
this.ondata = null;
this.onmessage = {};
const modes = [];
if (this.mode.indexOf('mse') >= 0 && ('MediaSource' in window || 'ManagedMediaSource' in window)) {
modes.push('mse');
this.onmse();
} else if (this.mode.indexOf('hls') >= 0 && this.video.canPlayType('application/vnd.apple.mpegurl')) {
modes.push('hls');
this.onhls();
} else if (this.mode.indexOf('mp4') >= 0) {
modes.push('mp4');
this.onmp4();
}
if (this.mode.indexOf('webrtc') >= 0 && 'RTCPeerConnection' in window) {
modes.push('webrtc');
this.onwebrtc();
}
if (this.mode.indexOf('mjpeg') >= 0) {
if (modes.length) {
this.onmessage['mjpeg'] = msg => {
if (msg.type !== 'error' || msg.value.indexOf(modes[0]) !== 0) return;
this.onmjpeg();
};
} else {
modes.push('mjpeg');
this.onmjpeg();
}
}
return modes;
}
/**
* @return {boolean} true if reconnection has started.
*/
onclose() {
if (this.wsState === WebSocket.CLOSED) return false;
// CONNECTING, OPEN => CONNECTING
this.wsState = WebSocket.CONNECTING;
this.ws = null;
// reconnect no more than once every X seconds
const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - this.connectTS), 0);
this.reconnectTID = setTimeout(() => {
this.reconnectTID = 0;
this.onconnect();
}, delay);
return true;
}
onmse() {
/** @type {MediaSource} */
let ms;
if ('ManagedMediaSource' in window) {
const MediaSource = window.ManagedMediaSource;
ms = new MediaSource();
ms.addEventListener('sourceopen', () => {
this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)});
}, {once: true});
this.video.disableRemotePlayback = true;
this.video.srcObject = ms;
} else {
ms = new MediaSource();
ms.addEventListener('sourceopen', () => {
URL.revokeObjectURL(this.video.src);
this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)});
}, {once: true});
this.video.src = URL.createObjectURL(ms);
this.video.srcObject = null;
}
this.play();
this.mseCodecs = '';
this.onmessage['mse'] = msg => {
if (msg.type !== 'mse') return;
this.mseCodecs = msg.value;
const sb = ms.addSourceBuffer(msg.value);
sb.mode = 'segments'; // segments or sequence
sb.addEventListener('updateend', () => {
if (!sb.updating && bufLen > 0) {
try {
const data = buf.slice(0, bufLen);
sb.appendBuffer(data);
bufLen = 0;
} catch (e) {
// console.debug(e);
}
}
if (!sb.updating && sb.buffered && sb.buffered.length) {
const end = sb.buffered.end(sb.buffered.length - 1);
const start = end - 5;
const start0 = sb.buffered.start(0);
if (start > start0) {
sb.remove(start0, start);
ms.setLiveSeekableRange(start, end);
}
if (this.video.currentTime < start) {
this.video.currentTime = start;
}
const gap = end - this.video.currentTime;
this.video.playbackRate = gap > 0.1 ? gap : 0.1;
// console.debug('VideoRTC.buffered', gap, this.video.playbackRate, this.video.readyState);
}
});
const buf = new Uint8Array(2 * 1024 * 1024);
let bufLen = 0;
this.ondata = data => {
if (sb.updating || bufLen > 0) {
const b = new Uint8Array(data);
buf.set(b, bufLen);
bufLen += b.byteLength;
// console.debug('VideoRTC.buffer', b.byteLength, bufLen);
} else {
try {
sb.appendBuffer(data);
} catch (e) {
// console.debug(e);
}
}
};
};
}
onwebrtc() {
const pc = new RTCPeerConnection(this.pcConfig);
pc.addEventListener('icecandidate', ev => {
if (ev.candidate && this.mode.indexOf('webrtc/tcp') >= 0 && ev.candidate.protocol === 'udp') return;
const candidate = ev.candidate ? ev.candidate.toJSON().candidate : '';
this.send({type: 'webrtc/candidate', value: candidate});
});
pc.addEventListener('connectionstatechange', () => {
if (pc.connectionState === 'connected') {
const tracks = pc.getTransceivers()
.filter(tr => tr.currentDirection === 'recvonly') // skip inactive
.map(tr => tr.receiver.track);
/** @type {HTMLVideoElement} */
const video2 = document.createElement('video');
video2.addEventListener('loadeddata', () => this.onpcvideo(video2), {once: true});
video2.srcObject = new MediaStream(tracks);
} else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
pc.close(); // stop next events
this.pcState = WebSocket.CLOSED;
this.pc = null;
this.onconnect();
}
});
this.onmessage['webrtc'] = msg => {
switch (msg.type) {
case 'webrtc/candidate':
if (this.mode.indexOf('webrtc/tcp') >= 0 && msg.value.indexOf(' udp ') > 0) return;
pc.addIceCandidate({candidate: msg.value, sdpMid: '0'}).catch(er => {
console.warn(er);
});
break;
case 'webrtc/answer':
pc.setRemoteDescription({type: 'answer', sdp: msg.value}).catch(er => {
console.warn(er);
});
break;
case 'error':
if (msg.value.indexOf('webrtc/offer') < 0) return;
pc.close();
}
};
this.createOffer(pc).then(offer => {
this.send({type: 'webrtc/offer', value: offer.sdp});
});
this.pcState = WebSocket.CONNECTING;
this.pc = pc;
}
/**
* @param pc {RTCPeerConnection}
* @return {Promise<RTCSessionDescriptionInit>}
*/
async createOffer(pc) {
try {
if (this.media.indexOf('microphone') >= 0) {
const media = await navigator.mediaDevices.getUserMedia({audio: true});
media.getTracks().forEach(track => {
pc.addTransceiver(track, {direction: 'sendonly'});
});
}
} catch (e) {
console.warn(e);
}
for (const kind of ['video', 'audio']) {
if (this.media.indexOf(kind) >= 0) {
pc.addTransceiver(kind, {direction: 'recvonly'});
}
}
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
return offer;
}
/**
* @param video2 {HTMLVideoElement}
*/
onpcvideo(video2) {
if (this.pc) {
// Video+Audio > Video, H265 > H264, Video > Audio, WebRTC > MSE
let rtcPriority = 0, msePriority = 0;
/** @type {MediaStream} */
const stream = video2.srcObject;
if (stream.getVideoTracks().length > 0) rtcPriority += 0x220;
if (stream.getAudioTracks().length > 0) rtcPriority += 0x102;
if (this.mseCodecs.indexOf('hvc1.') >= 0) msePriority += 0x230;
if (this.mseCodecs.indexOf('avc1.') >= 0) msePriority += 0x210;
if (this.mseCodecs.indexOf('mp4a.') >= 0) msePriority += 0x101;
if (rtcPriority >= msePriority) {
this.video.srcObject = stream;
this.play();
this.pcState = WebSocket.OPEN;
this.wsState = WebSocket.CLOSED;
if (this.ws) {
this.ws.close();
this.ws = null;
}
} else {
this.pcState = WebSocket.CLOSED;
if (this.pc) {
this.pc.close();
this.pc = null;
}
}
}
video2.srcObject = null;
}
onmjpeg() {
this.ondata = data => {
this.video.controls = false;
this.video.poster = 'data:image/jpeg;base64,' + VideoRTC.btoa(data);
};
this.send({type: 'mjpeg'});
}
onhls() {
this.onmessage['hls'] = msg => {
if (msg.type !== 'hls') return;
const url = 'http' + this.wsURL.substring(2, this.wsURL.indexOf('/ws')) + '/hls/';
const playlist = msg.value.replace('hls/', url);
this.video.src = 'data:application/vnd.apple.mpegurl;base64,' + btoa(playlist);
this.play();
};
this.send({type: 'hls', value: this.codecs(type => this.video.canPlayType(type))});
}
onmp4() {
/** @type {HTMLCanvasElement} **/
const canvas = document.createElement('canvas');
/** @type {CanvasRenderingContext2D} */
let context;
/** @type {HTMLVideoElement} */
const video2 = document.createElement('video');
video2.autoplay = true;
video2.playsInline = true;
video2.muted = true;
video2.addEventListener('loadeddata', () => {
if (!context) {
canvas.width = video2.videoWidth;
canvas.height = video2.videoHeight;
context = canvas.getContext('2d');
}
context.drawImage(video2, 0, 0, canvas.width, canvas.height);
this.video.controls = false;
this.video.poster = canvas.toDataURL('image/jpeg');
});
this.ondata = data => {
video2.src = 'data:video/mp4;base64,' + VideoRTC.btoa(data);
};
this.send({type: 'mp4', value: this.codecs(this.video.canPlayType)});
}
static btoa(buffer) {
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
let binary = '';
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
}
@@ -0,0 +1,691 @@
/** Chrome 63+, Safari 11.1+ */
import {VideoRTC} from './video-rtc.js?v=1.9.9';
import {DigitalPTZ} from './digital-ptz.js?v=3.3.0';
class WebRTCCamera extends VideoRTC {
/**
* Step 1. Called by the Hass, when config changed.
* @param {Object} config
*/
setConfig(config) {
if (!config.url && !config.entity && !config.streams) throw new Error('Missing `url` or `entity` or `streams`');
if (config.background) this.background = config.background;
if (config.intersection === 0) this.visibilityThreshold = 0;
else this.visibilityThreshold = config.intersection || 0.75;
/**
* @type {{
* url: string,
* entity: string,
* mode: string,
* media: string,
*
* streams: Array<{
* name: string,
* url: string,
* entity: string,
* mode: string,
* media: string,
* }>,
*
* title: string,
* poster: string,
* poster_remote: boolean,
* muted: boolean,
* intersection: number,
* ui: boolean,
* style: string,
* background: boolean,
*
* server: string,
*
* mse: boolean,
* webrtc: boolean,
*
* digital_ptz:{
* mouse_drag_pan: boolean,
* mouse_wheel_zoom: boolean,
* mouse_double_click_zoom: boolean,
* touch_pinch_zoom: boolean,
* touch_drag_pan: boolean,
* touch_tap_drag_zoom: boolean,
* persist: boolean|string,
* },
* ptz:{
* opacity: number|string,
* service: string,
* data_left, data_up, data_right, data_down, data_zoom_in, data_zoom_out, data_home
* },
* shortcuts:Array<{ name:string, icon:string }>,
* }} config
*/
this.config = Object.assign({
mode: config.mse === false ? 'webrtc' : config.webrtc === false ? 'mse' : this.mode,
media: this.media,
streams: [{url: config.url, entity: config.entity}],
poster_remote: config.poster && (config.poster.indexOf('://') > 0 || config.poster.charAt(0) === '/'),
}, config);
this.streamID = -1;
this.nextStream(false);
this.onhass = [];
}
set hass(hass) {
this._hass = hass;
this.onhass.forEach(fn => fn());
// if card in vertical stack - `hass` property assign after `onconnect`
// this.onconnect();
}
get hass() {
return this._hass;
}
/**
* Called by the Hass to calculate default card height.
*/
getCardSize() {
return 5; // x 50px
}
/**
* Called by the Hass to get defaul card config
* @return {{url: string}}
*/
static getStubConfig() {
return {'url': ''};
}
setStatus(mode, status) {
const divMode = this.querySelector('.mode').innerText;
if (mode === 'error' && divMode !== 'Loading..' && divMode !== 'Loading...') return;
this.querySelector('.mode').innerText = mode;
this.querySelector('.status').innerText = status || '';
}
/** @param reload {boolean} */
nextStream(reload) {
this.streamID = (this.streamID + 1) % this.config.streams.length;
const stream = this.config.streams[this.streamID];
this.config.url = stream.url;
this.config.entity = stream.entity;
this.mode = stream.mode || this.config.mode;
this.media = stream.media || this.config.media;
if (reload) {
this.ondisconnect();
setTimeout(() => this.onconnect(), 100); // wait ws.close event
}
}
/** @return {string} */
get streamName() {
return this.config.streams[this.streamID].name || `S${this.streamID}`;
}
oninit() {
super.oninit();
this.renderMain();
this.renderDigitalPTZ();
this.renderPTZ();
this.renderCustomUI();
this.renderShortcuts();
this.renderStyle();
}
onconnect() {
if (!this.config || !this.hass) return false;
if (!this.isConnected || this.ws || this.pc) return false;
const divMode = this.querySelector('.mode').innerText;
if (divMode === 'Loading..') return;
this.setStatus('Loading..');
this.hass.callWS({
type: 'auth/sign_path', path: '/api/webrtc/ws'
}).then(data => {
if (this.config.poster && !this.config.poster_remote) {
this.video.poster = this.hass.hassUrl(data.path) + '&poster=' + encodeURIComponent(this.config.poster);
}
this.wsURL = 'ws' + this.hass.hassUrl(data.path).substring(4);
if (this.config.entity) {
this.wsURL += '&entity=' + this.config.entity;
} else if (this.config.url) {
this.wsURL += '&url=' + encodeURIComponent(this.config.url);
} else {
this.setStatus('IMG');
return;
}
if (this.config.server) {
this.wsURL += '&server=' + encodeURIComponent(this.config.server);
}
if (super.onconnect()) {
this.setStatus('Loading...');
} else {
this.setStatus('error', 'unable to connect');
}
}).catch(er => {
this.setStatus('error', er);
});
}
onopen() {
const result = super.onopen();
this.onmessage['stream'] = msg => {
switch (msg.type) {
case 'error':
this.setStatus('error', msg.value);
break;
case 'mse':
case 'hls':
case 'mp4':
case 'mjpeg':
this.setStatus(msg.type.toUpperCase(), this.config.title || '');
break;
}
};
return result;
}
onpcvideo(ev) {
super.onpcvideo(ev);
if (this.pcState !== WebSocket.CLOSED) {
this.setStatus('RTC', this.config.title || '');
}
}
renderMain() {
const shadow = this.attachShadow({mode: 'open'});
shadow.innerHTML = `
<style>
ha-card {
width: 100%;
height: 100%;
margin: auto;
overflow: hidden;
position: relative;
}
ha-icon {
color: white;
cursor: pointer;
}
.player {
background-color: black;
height: 100%;
position: relative; /* important for Safari */
}
.player:active {
cursor: move; /* important for zoom-controller */
}
.player .ptz-transform {
height: 100%;
}
.header {
position: absolute;
top: 6px;
left: 10px;
right: 10px;
color: white;
display: flex;
justify-content: space-between;
pointer-events: none;
}
.mode {
cursor: pointer;
opacity: 0.6;
pointer-events: auto;
}
</style>
<ha-card class="card">
<div class="player">
<div class="ptz-transform"></div>
</div>
<div class="header">
<div class="status"></div>
<div class="mode"></div>
</div>
</ha-card>
`;
this.querySelector = selectors => this.shadowRoot.querySelector(selectors);
this.querySelector('.ptz-transform').appendChild(this.video);
const mode = this.querySelector('.mode');
mode.addEventListener('click', () => this.nextStream(true));
if (this.config.muted) this.video.muted = true;
if (this.config.poster_remote) this.video.poster = this.config.poster;
}
renderDigitalPTZ() {
if (this.config.digital_ptz === false) return;
new DigitalPTZ(
this.querySelector('.player'),
this.querySelector('.player .ptz-transform'),
this.video,
Object.assign({}, this.config.digital_ptz, {persist_key: this.config.url})
);
}
renderPTZ() {
if (!this.config.ptz || !this.config.ptz.service) return;
let hasMove = false;
let hasZoom = false;
let hasHome = false;
for (const prefix of ['', '_start', '_end', '_long']) {
hasMove = hasMove || this.config.ptz['data' + prefix + '_right'];
hasMove = hasMove || this.config.ptz['data' + prefix + '_left'];
hasMove = hasMove || this.config.ptz['data' + prefix + '_up'];
hasMove = hasMove || this.config.ptz['data' + prefix + '_down'];
hasZoom = hasZoom || this.config.ptz['data' + prefix + '_zoom_in'];
hasZoom = hasZoom || this.config.ptz['data' + prefix + '_zoom_out'];
hasHome = hasHome || this.config.ptz['data' + prefix + '_home'];
}
const card = this.querySelector('.card');
card.insertAdjacentHTML('beforebegin', `
<style>
.ptz {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 10px;
transition: opacity .3s ease-in-out;
opacity: ${parseFloat(this.config.ptz.opacity) || 0.4};
}
.ptz:hover {
opacity: 1 !important;
}
.ptz-move {
position: relative;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 50%;
width: 80px;
height: 80px;
display: ${hasMove ? 'block' : 'none'};
}
.ptz-zoom {
position: relative;
width: 80px;
height: 40px;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 4px;
display: ${hasZoom ? 'block' : 'none'};
}
.ptz-home {
position: relative;
width: 40px;
height: 40px;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 4px;
align-self: center;
display: ${hasHome ? 'block' : 'none'};
}
.up {
position: absolute;
top: 5px;
left: 50%;
transform: translateX(-50%);
}
.down {
position: absolute;
bottom: 5px;
left: 50%;
transform: translateX(-50%);
}
.left {
position: absolute;
left: 5px;
top: 50%;
transform: translateY(-50%);
}
.right {
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
}
.zoom_out {
position: absolute;
left: 5px;
top: 50%;
transform: translateY(-50%);
}
.zoom_in {
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
}
.home {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
</style>
`);
card.insertAdjacentHTML('beforeend', `
<div class="ptz">
<div class="ptz-move">
<ha-icon class="right" icon="mdi:arrow-right"></ha-icon>
<ha-icon class="left" icon="mdi:arrow-left"></ha-icon>
<ha-icon class="up" icon="mdi:arrow-up"></ha-icon>
<ha-icon class="down" icon="mdi:arrow-down"></ha-icon>
</div>
<div class="ptz-zoom">
<ha-icon class="zoom_in" icon="mdi:plus"></ha-icon>
<ha-icon class="zoom_out" icon="mdi:minus"></ha-icon>
</div>
<div class="ptz-home">
<ha-icon class="home" icon="mdi:home"></ha-icon>
</div>
</div>
`);
const template = JSON.stringify(this.config.ptz);
const handle = path => {
if (!this.config.ptz['data_' + path]) return;
const config = template.indexOf('${') < 0 ? this.config.ptz : JSON.parse(eval('`' + template + '`'));
const [domain, service] = config.service.split('.', 2);
const data = config['data_' + path];
this.hass.callService(domain, service, data);
};
const ptz = this.querySelector('.ptz');
for (const [start, end] of [['touchstart', 'touchend'], ['mousedown', 'mouseup']]) {
ptz.addEventListener(start, startEvt => {
const {className} = startEvt.target;
startEvt.preventDefault();
handle('start_' + className);
window.addEventListener(end, endEvt => {
endEvt.preventDefault();
handle('end_' + className);
if (endEvt.timeStamp - startEvt.timeStamp > 400) {
handle('long_' + className);
} else {
handle(className);
}
}, {once: true});
});
}
}
saveScreenshot() {
const a = document.createElement('a');
if (this.video.videoWidth && this.video.videoHeight) {
const canvas = document.createElement('canvas');
canvas.width = this.video.videoWidth;
canvas.height = this.video.videoHeight;
canvas.getContext('2d').drawImage(this.video, 0, 0, canvas.width, canvas.height);
a.href = canvas.toDataURL('image/jpeg');
} else if (this.video.poster && this.video.poster.startsWith('data:image/jpeg')) {
a.href = this.video.poster;
} else {
return;
}
const ts = new Date().toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '');
a.download = `snapshot_${ts}.jpeg`;
a.click();
}
renderCustomUI() {
if (!this.config.ui) return;
this.video.controls = false;
this.video.style.pointerEvents = 'none';
const card = this.querySelector('.card');
card.insertAdjacentHTML('beforebegin', `
<style>
.spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.controls {
position: absolute;
left: 5px;
right: 5px;
bottom: 5px;
display: flex;
}
.space {
width: 100%;
}
.volume {
display: none;
}
.stream {
padding-top: 2px;
margin-left: 2px;
font-weight: 400;
font-size: 20px;
color: white;
display: none;
cursor: pointer;
}
</style>
`);
card.insertAdjacentHTML('beforeend', `
<div class="ui">
<ha-circular-progress class="spinner"></ha-circular-progress>
<div class="controls">
<ha-icon class="fullscreen" icon="mdi:fullscreen"></ha-icon>
<ha-icon class="screenshot" icon="mdi:floppy"></ha-icon>
<ha-icon class="pictureinpicture" icon="mdi:picture-in-picture-bottom-right"></ha-icon>
<span class="stream">${this.streamName}</span>
<span class="space"></span>
<ha-icon class="play" icon="mdi:play"></ha-icon>
<ha-icon class="volume" icon="mdi:volume-high"></ha-icon>
</div>
</div>
`);
const video = this.video;
const fullscreen = this.querySelector('.fullscreen');
if (this.requestFullscreen) {
this.addEventListener('fullscreenchange', () => {
fullscreen.icon = document.fullscreenElement ? 'mdi:fullscreen-exit' : 'mdi:fullscreen';
});
} else if (video.webkitEnterFullscreen) {
this.requestFullscreen = () => new Promise((resolve, reject) => {
try {
video.webkitEnterFullscreen();
} catch (e) {
reject(e);
}
});
video.addEventListener('webkitendfullscreen', () => {
setTimeout(() => this.play(), 1000); // fix bug in iOS
});
} else {
fullscreen.style.display = 'none';
}
const pip = this.querySelector('.pictureinpicture');
if (video.requestPictureInPicture) {
video.addEventListener('enterpictureinpicture', () => {
pip.icon = 'mdi:rectangle';
this.background = true;
});
video.addEventListener('leavepictureinpicture', () => {
pip.icon = 'mdi:picture-in-picture-bottom-right';
this.background = this.config.background;
this.play();
});
} else {
pip.style.display = 'none';
}
const ui = this.querySelector('.ui');
ui.addEventListener('click', ev => {
const icon = ev.target.icon;
if (icon === 'mdi:play') {
this.play();
} else if (icon === 'mdi:volume-mute') {
video.muted = false;
} else if (icon === 'mdi:volume-high') {
video.muted = true;
} else if (icon === 'mdi:fullscreen') {
this.requestFullscreen().catch(console.warn);
} else if (icon === 'mdi:fullscreen-exit') {
document.exitFullscreen().catch(console.warn);
} else if (icon === 'mdi:floppy') {
this.saveScreenshot();
} else if (icon === 'mdi:picture-in-picture-bottom-right') {
video.requestPictureInPicture().catch(console.warn);
} else if (icon === 'mdi:rectangle') {
document.exitPictureInPicture().catch(console.warn);
} else if (ev.target.className === 'stream') {
this.nextStream(true);
ev.target.innerText = this.streamName;
}
});
const spinner = this.querySelector('.spinner');
video.addEventListener('waiting', () => {
spinner.style.display = 'block';
});
video.addEventListener('playing', () => {
spinner.style.display = 'none';
});
const play = this.querySelector('.play');
video.addEventListener('play', () => {
play.style.display = 'none';
});
video.addEventListener('pause', () => {
play.style.display = 'block';
});
const volume = this.querySelector('.volume');
video.addEventListener('loadeddata', () => {
volume.style.display = this.hasAudio ? 'block' : 'none';
});
video.addEventListener('volumechange', () => {
volume.icon = video.muted ? 'mdi:volume-mute' : 'mdi:volume-high';
});
const stream = this.querySelector('.stream');
stream.style.display = this.config.streams.length > 1 ? 'block' : 'none';
}
renderShortcuts() {
if (!this.config.shortcuts) return;
const card = this.querySelector('.card');
card.insertAdjacentHTML('beforebegin', `
<style>
.shortcuts {
position: absolute;
top: 5px;
left: 5px;
}
</style>
`);
card.insertAdjacentHTML('beforeend', '<div class="shortcuts"></div>');
const shortcuts = this.querySelector('.shortcuts');
shortcuts.addEventListener('click', ev => {
const value = this.config.shortcuts[ev.target.dataset.index];
if (value.more_info !== undefined) {
const event = new Event('hass-more-info', {
bubbles: true,
cancelable: true,
composed: true,
});
event.detail = {entityId: value.more_info};
ev.target.dispatchEvent(event);
}
if (value.service !== undefined) {
const [domain, name] = value.service.split('.');
this.hass.callService(domain, name, value.service_data || {});
}
});
this.renderTemplate('shortcuts', () => {
shortcuts.innerHTML = this.config.shortcuts.map((value, index) => `
<ha-icon data-index="${index}" icon="${value.icon}" title="${value.name}"></ha-icon>
`).join('');
});
}
renderStyle() {
if (!this.config.style) return;
const style = document.createElement('style');
const card = this.querySelector('.card');
card.insertAdjacentElement('beforebegin', style);
this.renderTemplate('style', () => {
style.innerText = this.config.style;
});
}
renderTemplate(name, renderHTML) {
const config = this.config[name];
// support config param as string or as object
const template = typeof config === 'string' ? config : JSON.stringify(config);
// check if config param has template
if (template.indexOf('${') >= 0) {
const render = () => {
try {
const states = this.hass ? this.hass.states : undefined;
this.config[name] = JSON.parse(eval('`' + template + '`'));
renderHTML();
} catch (e) {
console.debug(e);
}
};
this.onhass.push(render);
render();
} else {
renderHTML();
}
}
get hasAudio() {
return (
(this.video.srcObject && this.video.srcObject.getAudioTracks && this.video.srcObject.getAudioTracks().length) ||
(this.video.mozHasAudio || this.video.webkitAudioDecodedByteCount) ||
(this.video.audioTracks && this.video.audioTracks.length)
);
}
}
customElements.define('webrtc-camera', WebRTCCamera);
const card = {
type: 'webrtc-camera',
name: 'WebRTC Camera',
preview: false,
description: 'WebRTC camera allows you to view the stream of almost any camera without delay',
};
// Apple iOS 12 doesn't support `||=`
if (window.customCards) window.customCards.push(card);
else window.customCards = [card];