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, config_dir: str = ""): super().__init__(name=DOMAIN, daemon=True) self.binary = binary self.config_dir = config_dir self.process = None @property def available(self): return self.process.poll() is None if self.process else False def run(self): env = os.environ.copy() if self.config_dir: env["CONFIG"] = self.config_dir + "/go2rtc.yaml" while self.binary: self.process = subprocess.Popen( [self.binary], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env ) # 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()