265 lines
8.3 KiB
Python
265 lines
8.3 KiB
Python
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 = None
|
|
if self.config_dir:
|
|
env = {**__import__("os").environ, "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()
|