diff --git a/.cache/brands/integrations/wyzeapi/logo.png b/.cache/brands/integrations/wyzeapi/logo.png new file mode 100644 index 0000000..57f0105 Binary files /dev/null and b/.cache/brands/integrations/wyzeapi/logo.png differ diff --git a/.ha_run.lock b/.ha_run.lock index 24dcae9..2bffdf1 100644 --- a/.ha_run.lock +++ b/.ha_run.lock @@ -1 +1 @@ -{"pid": 72, "version": 1, "ha_version": "2026.6.2", "start_ts": 1781098592.353074} \ No newline at end of file +{"pid": 71, "version": 1, "ha_version": "2026.6.2", "start_ts": 1781272512.3211384} \ No newline at end of file diff --git a/custom_components/webrtc/__init__.py b/custom_components/webrtc/__init__.py new file mode 100644 index 0000000..f084e2a --- /dev/null +++ b/custom_components/webrtc/__init__.py @@ -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) diff --git a/custom_components/webrtc/__pycache__/__init__.cpython-314.pyc b/custom_components/webrtc/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..0b10584 Binary files /dev/null and b/custom_components/webrtc/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/webrtc/__pycache__/config_flow.cpython-314.pyc b/custom_components/webrtc/__pycache__/config_flow.cpython-314.pyc new file mode 100644 index 0000000..ec31a00 Binary files /dev/null and b/custom_components/webrtc/__pycache__/config_flow.cpython-314.pyc differ diff --git a/custom_components/webrtc/__pycache__/utils.cpython-314.pyc b/custom_components/webrtc/__pycache__/utils.cpython-314.pyc new file mode 100644 index 0000000..ef8b205 Binary files /dev/null and b/custom_components/webrtc/__pycache__/utils.cpython-314.pyc differ diff --git a/custom_components/webrtc/config_flow.py b/custom_components/webrtc/config_flow.py new file mode 100644 index 0000000..65563c0 --- /dev/null +++ b/custom_components/webrtc/config_flow.py @@ -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}, + ) diff --git a/custom_components/webrtc/manifest.json b/custom_components/webrtc/manifest.json new file mode 100644 index 0000000..a196b57 --- /dev/null +++ b/custom_components/webrtc/manifest.json @@ -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" +} \ No newline at end of file diff --git a/custom_components/webrtc/media_player.py b/custom_components/webrtc/media_player.py new file mode 100644 index 0000000..5bed7ef --- /dev/null +++ b/custom_components/webrtc/media_player.py @@ -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) diff --git a/custom_components/webrtc/services.yaml b/custom_components/webrtc/services.yaml new file mode 100644 index 0000000..f952c67 --- /dev/null +++ b/custom_components/webrtc/services.yaml @@ -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: diff --git a/custom_components/webrtc/translations/en.json b/custom_components/webrtc/translations/en.json new file mode 100644 index 0000000..080ec1a --- /dev/null +++ b/custom_components/webrtc/translations/en.json @@ -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" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/webrtc/translations/es.json b/custom_components/webrtc/translations/es.json new file mode 100644 index 0000000..1305564 --- /dev/null +++ b/custom_components/webrtc/translations/es.json @@ -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" + } + } + } + } + } \ No newline at end of file diff --git a/custom_components/webrtc/translations/fr.json b/custom_components/webrtc/translations/fr.json new file mode 100644 index 0000000..cb18714 --- /dev/null +++ b/custom_components/webrtc/translations/fr.json @@ -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" + } + } + } + } +} diff --git a/custom_components/webrtc/translations/pt.json b/custom_components/webrtc/translations/pt.json new file mode 100644 index 0000000..2f8dd2b --- /dev/null +++ b/custom_components/webrtc/translations/pt.json @@ -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" + } + } + } + } +} diff --git a/custom_components/webrtc/translations/ur.json b/custom_components/webrtc/translations/ur.json new file mode 100644 index 0000000..1c006ae --- /dev/null +++ b/custom_components/webrtc/translations/ur.json @@ -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": "ہاس سرور کے لیے دستی بیس یو آر ایل" + } + } + } + } +} diff --git a/custom_components/webrtc/utils.py b/custom_components/webrtc/utils.py new file mode 100644 index 0000000..7c1be1c --- /dev/null +++ b/custom_components/webrtc/utils.py @@ -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() diff --git a/custom_components/webrtc/www/digital-ptz.js b/custom_components/webrtc/www/digital-ptz.js new file mode 100644 index 0000000..684bcce --- /dev/null +++ b/custom_components/webrtc/www/digital-ptz.js @@ -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})`; + } +} diff --git a/custom_components/webrtc/www/embed.html b/custom_components/webrtc/www/embed.html new file mode 100644 index 0000000..376f396 --- /dev/null +++ b/custom_components/webrtc/www/embed.html @@ -0,0 +1,45 @@ + + + + + WebRTC Camera + + + + + + + diff --git a/custom_components/webrtc/www/video-rtc.js b/custom_components/webrtc/www/video-rtc.js new file mode 100644 index 0000000..fb872b4 --- /dev/null +++ b/custom_components/webrtc/www/video-rtc.js @@ -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.} + */ + 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.} 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} + */ + 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); + } +} diff --git a/custom_components/webrtc/www/webrtc-camera.js b/custom_components/webrtc/www/webrtc-camera.js new file mode 100644 index 0000000..6f2d659 --- /dev/null +++ b/custom_components/webrtc/www/webrtc-camera.js @@ -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 = ` + + +
+
+
+
+
+
+
+
+ `; + + 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', ` + + `); + card.insertAdjacentHTML('beforeend', ` +
+
+ + + + +
+
+ + +
+
+ +
+
+ `); + + 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', ` + + `); + card.insertAdjacentHTML('beforeend', ` +
+ +
+ + + + ${this.streamName} + + + +
+
+ `); + + 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', ` + + `); + card.insertAdjacentHTML('beforeend', '
'); + + 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) => ` + + `).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]; + diff --git a/custom_components/wyzeapi/__init__.py b/custom_components/wyzeapi/__init__.py new file mode 100644 index 0000000..49d2dae --- /dev/null +++ b/custom_components/wyzeapi/__init__.py @@ -0,0 +1,215 @@ +"""The Wyze Home Assistant Integration.""" + +from __future__ import annotations + +import logging + +from aiohttp.client_exceptions import ClientConnectorError +from homeassistant.config_entries import ConfigEntry, ConfigEntryNotReady, SOURCE_IMPORT +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.check_config import HomeAssistantConfig +from homeassistant.components import bluetooth +from wyzeapy import Wyzeapy +from wyzeapy.exceptions import AccessTokenError +from wyzeapy.wyze_auth_lib import Token + +from .const import ( + DOMAIN, + CONF_CLIENT, + ACCESS_TOKEN, + REFRESH_TOKEN, + REFRESH_TIME, + WYZE_NOTIFICATION_TOGGLE, + BULB_LOCAL_CONTROL, + DEFAULT_LOCAL_CONTROL, + KEY_ID, + API_KEY, +) +from .coordinator import WyzeLockBoltCoordinator +from .token_manager import TokenManager + +PLATFORMS = [ + "light", + "switch", + "lock", + "climate", + "alarm_control_panel", + "sensor", + "siren", + "cover", + "number", + "button", + "camera", +] # Fixme: Re add scene +_LOGGER = logging.getLogger(__name__) + + +# noinspection PyUnusedLocal +async def async_setup( + hass: HomeAssistant, config: HomeAssistantConfig, discovery_info=None +): + # pylint: disable=unused-argument + """Set up the WyzeApi domain.""" + if hass.config_entries.async_entries(DOMAIN): + _LOGGER.debug( + "Nothing to import from configuration.yaml, loading from Integrations", + ) + return True + + # noinspection SpellCheckingInspection + domainconfig = config.get(DOMAIN) + # pylint: disable=logging-not-lazy + _LOGGER.debug( + "Importing config information for %s from configuration.yml" + % domainconfig[CONF_USERNAME] + ) + if hass.config_entries.async_entries(DOMAIN): + _LOGGER.debug("Found existing config entries") + for entry in hass.config_entries.async_entries(DOMAIN): + if entry: + entry_data = entry.as_dict().get("data") + hass.config_entries.async_update_entry( + entry, + data=entry_data, + ) + break + else: + _LOGGER.debug("Creating new config entry") + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_USERNAME: domainconfig[CONF_USERNAME], + CONF_PASSWORD: domainconfig[CONF_PASSWORD], + ACCESS_TOKEN: domainconfig[ACCESS_TOKEN], + REFRESH_TOKEN: domainconfig[REFRESH_TOKEN], + REFRESH_TIME: domainconfig[REFRESH_TIME], + KEY_ID: domainconfig[KEY_ID], + API_KEY: domainconfig[API_KEY], + }, + ) + ) + return True + + +# noinspection DuplicatedCode +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up Wyze Home Assistant Integration from a config entry.""" + + hass.data.setdefault(DOMAIN, {}) + + key_id = config_entry.data.get(KEY_ID) + api_key = config_entry.data.get(API_KEY) + + client = await Wyzeapy.create() + token = None + if config_entry.data.get(ACCESS_TOKEN): + token = Token( + config_entry.data.get(ACCESS_TOKEN), + config_entry.data.get(REFRESH_TOKEN), + float(config_entry.data.get(REFRESH_TIME)), + ) + a_tkn_manager = TokenManager(hass, config_entry) + client.register_for_token_callback(a_tkn_manager.token_callback) + # We should probably try/catch here to invalidate the login credentials and throw a notification if we cannot get + # a login with the token + try: + await client.login( + config_entry.data.get(CONF_USERNAME), + config_entry.data.get(CONF_PASSWORD), + key_id, + api_key, + token, + ) + except ClientConnectorError as e: + raise ConfigEntryNotReady("Unable to login due to network issues.") from e + except AccessTokenError as e: + _LOGGER.error( + "Wyzeapi: Could not login. Please re-login through integration configuration" + ) + _LOGGER.error(e) + raise ConfigEntryAuthFailed("Unable to login, please re-login.") from None + + hass.data[DOMAIN][config_entry.entry_id] = { + CONF_CLIENT: client, + "key_id": KEY_ID, + "api_key": API_KEY, + "coordinators": {}, + } + await setup_coordinators(hass, config_entry, client) + + options_dict = { + BULB_LOCAL_CONTROL: config_entry.options.get( + BULB_LOCAL_CONTROL, DEFAULT_LOCAL_CONTROL + ) + } + hass.config_entries.async_update_entry(config_entry, options=options_dict) + + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + mac_addresses = await client.unique_device_ids + + mac_addresses.add(WYZE_NOTIFICATION_TOGGLE) + + hms_service = await client.hms_service + hms_id = hms_service.hms_id + if hms_id is not None: + mac_addresses.add(hms_id) + + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ): + for identifier in device.identifiers: + # domain has to remain here. If it is removed the integration will remove all entities for not being in + # the mac address list each boot. + domain, mac = identifier + if mac not in mac_addresses: + _LOGGER.warning( + "%s is not in the mac_addresses list, removing the entry", mac + ) + device_registry.async_remove_device(device.id) + return True + + +async def options_update_listener(hass: HomeAssistant, config_entry: ConfigEntry): + """Handle options update.""" + _LOGGER.debug("Updated options") + entry_data = config_entry.as_dict().get("data") + hass.config_entries.async_update_entry( + config_entry, + data=entry_data, + ) + _LOGGER.debug("Reload entry: " + config_entry.entry_id) + await hass.config_entries.async_reload(config_entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def setup_coordinators( + hass: HomeAssistant, config_entry: ConfigEntry, client: Wyzeapy +): + """Set up coordinators for Wyze devices that require Bluetooth.""" + # Check if Bluetooth is active and functioning + if bluetooth.async_scanner_count(hass, connectable=True) == 0: + _LOGGER.info( + "Bluetooth is not active or no scanners available. Skipping WyzeLockBoltCoordinator setup." + ) + return + + lock_service = await client.lock_service + for lock in await lock_service.get_locks(): + if lock.product_model == "YD_BT1": + coordinators = hass.data[DOMAIN][config_entry.entry_id].setdefault( + "coordinators", {} + ) + coordinators[lock.mac] = WyzeLockBoltCoordinator(hass, lock_service, lock) + await coordinators[lock.mac].update_lock_info() diff --git a/custom_components/wyzeapi/__pycache__/__init__.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..a9ed5cf Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/wyzeapi/__pycache__/alarm_control_panel.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/alarm_control_panel.cpython-314.pyc new file mode 100644 index 0000000..97fb6bc Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/alarm_control_panel.cpython-314.pyc differ diff --git a/custom_components/wyzeapi/__pycache__/button.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/button.cpython-314.pyc new file mode 100644 index 0000000..1c7a311 Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/button.cpython-314.pyc differ diff --git a/custom_components/wyzeapi/__pycache__/camera.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/camera.cpython-314.pyc new file mode 100644 index 0000000..6953f3f Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/camera.cpython-314.pyc differ diff --git a/custom_components/wyzeapi/__pycache__/climate.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/climate.cpython-314.pyc new file mode 100644 index 0000000..f7e5a1a Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/climate.cpython-314.pyc differ diff --git a/custom_components/wyzeapi/__pycache__/config_flow.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/config_flow.cpython-314.pyc new file mode 100644 index 0000000..535cae9 Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/config_flow.cpython-314.pyc differ diff --git a/custom_components/wyzeapi/__pycache__/const.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/const.cpython-314.pyc new file mode 100644 index 0000000..6ade165 Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/const.cpython-314.pyc differ diff --git a/custom_components/wyzeapi/__pycache__/coordinator.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/coordinator.cpython-314.pyc new file mode 100644 index 0000000..26a97ee Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/coordinator.cpython-314.pyc differ diff --git a/custom_components/wyzeapi/__pycache__/cover.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/cover.cpython-314.pyc new file mode 100644 index 0000000..2b3e466 Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/cover.cpython-314.pyc differ diff --git a/custom_components/wyzeapi/__pycache__/light.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/light.cpython-314.pyc new file mode 100644 index 0000000..c1ac49a Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/light.cpython-314.pyc differ diff --git a/custom_components/wyzeapi/__pycache__/lock.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/lock.cpython-314.pyc new file mode 100644 index 0000000..d447812 Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/lock.cpython-314.pyc differ diff --git a/custom_components/wyzeapi/__pycache__/number.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/number.cpython-314.pyc new file mode 100644 index 0000000..e0779eb Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/number.cpython-314.pyc differ diff --git a/custom_components/wyzeapi/__pycache__/sensor.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/sensor.cpython-314.pyc new file mode 100644 index 0000000..8731761 Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/sensor.cpython-314.pyc differ diff --git a/custom_components/wyzeapi/__pycache__/siren.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/siren.cpython-314.pyc new file mode 100644 index 0000000..460fc0d Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/siren.cpython-314.pyc differ diff --git a/custom_components/wyzeapi/__pycache__/switch.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/switch.cpython-314.pyc new file mode 100644 index 0000000..0e00750 Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/switch.cpython-314.pyc differ diff --git a/custom_components/wyzeapi/__pycache__/token_manager.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/token_manager.cpython-314.pyc new file mode 100644 index 0000000..129b9ea Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/token_manager.cpython-314.pyc differ diff --git a/custom_components/wyzeapi/__pycache__/ydble_utils.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/ydble_utils.cpython-314.pyc new file mode 100644 index 0000000..ece5844 Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/ydble_utils.cpython-314.pyc differ diff --git a/custom_components/wyzeapi/alarm_control_panel.py b/custom_components/wyzeapi/alarm_control_panel.py new file mode 100644 index 0000000..5768f39 --- /dev/null +++ b/custom_components/wyzeapi/alarm_control_panel.py @@ -0,0 +1,166 @@ +""" +This module handles the Wyze Home Monitoring system +""" + +import logging +from datetime import timedelta +from typing import Optional, Callable, List, Any +from aiohttp.client_exceptions import ClientConnectionError + +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelState, + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from wyzeapy import Wyzeapy, HMSService +from wyzeapy.services.hms_service import HMSMode +from wyzeapy.exceptions import AccessTokenError, ParameterError, UnknownApiError +from .token_manager import token_exception_handler +from homeassistant.helpers.entity import DeviceInfo + +from .const import DOMAIN, CONF_CLIENT + +_LOGGER = logging.getLogger(__name__) +ATTRIBUTION = "Data provided by Wyze" +SCAN_INTERVAL = timedelta(seconds=15) + + +@token_exception_handler +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: Callable[[List[Any], bool], None], +): + """ + This function sets up the integration + + :param hass: Reference to the HomeAssistant instance + :param config_entry: Reference to the config entry we are setting up + :param async_add_entities: + """ + + _LOGGER.debug("""Creating new WyzeApi Home Monitoring System component""") + client: Wyzeapy = hass.data[DOMAIN][config_entry.entry_id][CONF_CLIENT] + + hms_service = await client.hms_service + if await hms_service.has_hms: + async_add_entities([WyzeHomeMonitoring(hms_service)], True) + + +class WyzeHomeMonitoring(AlarmControlPanelEntity): + """ + A representation of the Wyze Home Monitoring system that works for wyze + """ + + DEVICE_MODEL = "HMS" + MANUFACTURER = "WyzeLabs" + NAME = "Wyze Home Monitoring System" + AVAILABLE = True + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, hms_service: HMSService): + self._attr_unique_id = hms_service.hms_id + + self._hms_service = hms_service + self._state = AlarmControlPanelState.DISARMED + self._server_out_of_sync = False + + @property + def alarm_state(self) -> str: + return self._state + + # NotImplemented Methods + def alarm_arm_vacation(self, code: Optional[str] = None) -> None: + raise NotImplementedError + + def alarm_arm_night(self, code: Optional[str] = None) -> None: + raise NotImplementedError + + def alarm_trigger(self, code: Optional[str] = None) -> None: + raise NotImplementedError + + def alarm_arm_custom_bypass(self, code: Optional[str] = None) -> None: + raise NotImplementedError + + # Implemented Methods + @token_exception_handler + async def async_alarm_disarm(self, code: Optional[str] = None) -> None: + """Send disarm command.""" + try: + await self._hms_service.set_mode(HMSMode.DISARMED) + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._state = "disarmed" + self._server_out_of_sync = True + + @token_exception_handler + async def async_alarm_arm_home(self, code: Optional[str] = None) -> None: + try: + await self._hms_service.set_mode(HMSMode.HOME) + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._state = "armed_home" + self._server_out_of_sync = True + + @token_exception_handler + async def async_alarm_arm_away(self, code: Optional[str] = None) -> None: + try: + await self._hms_service.set_mode(HMSMode.AWAY) + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._state = "armed_away" + self._server_out_of_sync = True + + @property + def supported_features(self) -> int: + return ( + AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.ARM_AWAY + ) + + @property + def device_info(self) -> DeviceInfo: + return DeviceInfo( + identifiers={(DOMAIN, self.unique_id)}, + name=self.NAME, + manufacturer=self.MANUFACTURER, + model=self.DEVICE_MODEL, + ) + + @property + def extra_state_attributes(self) -> dict | None: + """Return device attributes of the entity.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION, "mac": self.unique_id} + + @token_exception_handler + async def async_update(self) -> None: + """Update the entity with data from the Wyze servers""" + + if not self._server_out_of_sync: + state = await self._hms_service.update(self._hms_service.hms_id) + if state is HMSMode.DISARMED: + self._state = AlarmControlPanelState.DISARMED + elif state is HMSMode.AWAY: + self._state = AlarmControlPanelState.ARMED_AWAY + elif state is HMSMode.HOME: + self._state = AlarmControlPanelState.ARMED_HOME + elif state is HMSMode.CHANGING: + self._state = AlarmControlPanelState.DISARMED + else: + _LOGGER.warning(f"Received {state} from server") + + self._server_out_of_sync = False diff --git a/custom_components/wyzeapi/binary_sensor.py b/custom_components/wyzeapi/binary_sensor.py new file mode 100644 index 0000000..c748f43 --- /dev/null +++ b/custom_components/wyzeapi/binary_sensor.py @@ -0,0 +1,224 @@ +""" +This module describes the connection between Home Assistant and Wyze for the Sensors +""" + +import logging +import time +from typing import Callable, List, Any + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorDeviceClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.core import HomeAssistant +from wyzeapy import Wyzeapy, CameraService, SensorService +from wyzeapy.services.camera_service import Camera +from wyzeapy.services.sensor_service import Sensor +from wyzeapy.types import DeviceTypes +from .token_manager import token_exception_handler + +from .const import DOMAIN, CONF_CLIENT + +_LOGGER = logging.getLogger(__name__) +ATTRIBUTION = "Data provided by Wyze" + + +@token_exception_handler +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: Callable[[List[Any], bool], None], +): + """ + This function sets up the config entry for use in Home Assistant + + :param hass: Home Assistant instance + :param config_entry: The current config_entry + :param async_add_entities: This function adds entities to the config_entry + :return: + """ + + _LOGGER.debug("""Creating new WyzeApi binary sensor component""") + client: Wyzeapy = hass.data[DOMAIN][config_entry.entry_id][CONF_CLIENT] + + sensor_service = await client.sensor_service + camera_service = await client.camera_service + + cameras = [ + WyzeCameraMotion(camera_service, camera) + for camera in await camera_service.get_cameras() + ] + sensors = [ + WyzeSensor(sensor_service, sensor) + for sensor in await sensor_service.get_sensors() + ] + + async_add_entities(cameras, True) + async_add_entities(sensors, True) + + +class WyzeSensor(BinarySensorEntity): + """ + A representation of the WyzeSensor for use in Home Assistant + """ + + def __init__(self, sensor_service: SensorService, sensor: Sensor): + """Initializes the class""" + self._sensor_service = sensor_service + self._sensor = sensor + self._last_event = int(str(int(time.time())) + "000") + + async def async_added_to_hass(self) -> None: + """Registers for updates when the entity is added to Home Assistant""" + await self._sensor_service.register_for_updates( + self._sensor, self.process_update + ) + + async def async_will_remove_from_hass(self) -> None: + await self._sensor_service.deregister_for_updates(self._sensor) + + def process_update(self, sensor: Sensor): + """ + This function processes an update for the Wyze Sensor + + :param sensor: The sensor with the updated values + """ + self._sensor = sensor + self.schedule_update_ha_state() + + @property + def device_info(self): + return { + "identifiers": {(DOMAIN, self._sensor.mac)}, + "name": self.name, + "manufacturer": "WyzeLabs", + "model": self._sensor.product_model, + } + + @property + def available(self) -> bool: + return True + + @property + def name(self): + """Return the display name of this switch.""" + return self._sensor.nickname + + @property + def should_poll(self) -> bool: + return False + + @property + def is_on(self): + """Return true if sensor detects motion""" + return self._sensor.detected + + @property + def unique_id(self): + return "{}-motion".format(self._sensor.mac) + + @property + def extra_state_attributes(self): + """Return device attributes of the entity.""" + return { + ATTR_ATTRIBUTION: ATTRIBUTION, + "device model": self._sensor.product_model, + "mac": self.unique_id, + } + + @property + def device_class(self): + # pylint: disable=R1705 + if self._sensor.type is DeviceTypes.MOTION_SENSOR: + return BinarySensorDeviceClass.MOTION + elif self._sensor.type is DeviceTypes.CONTACT_SENSOR: + return BinarySensorDeviceClass.DOOR + else: + raise RuntimeError( + f"The device type {self._sensor.type} is not supported by this class" + ) + + +class WyzeCameraMotion(BinarySensorEntity): + """ + A representation of the Wyze Camera for use as a binary sensor in Home Assistant + """ + + _is_on = False + _last_event = time.time() * 1000 + + def __init__(self, camera_service: CameraService, camera: Camera): + self._camera_service = camera_service + self._camera = camera + + @property + def device_info(self): + return { + "identifiers": {(DOMAIN, self._camera.mac)}, + "name": self.name, + "manufacturer": "WyzeLabs", + "model": self._camera.product_model, + } + + @property + def available(self) -> bool: + return self._camera.available + + @property + def name(self): + """Return the display name of this switch.""" + return self._camera.nickname + + @property + def should_poll(self) -> bool: + return False + + @property + def is_on(self): + """Return true if the binary sensor is on""" + return self._is_on + + @property + def unique_id(self): + return "{}-motion".format(self._camera.mac) + + @property + def extra_state_attributes(self): + """Return device attributes of the entity.""" + return { + ATTR_ATTRIBUTION: ATTRIBUTION, + "device model": self._camera.product_model, + "mac": self.unique_id, + } + + @property + def device_class(self): + return BinarySensorDeviceClass.MOTION + + async def async_added_to_hass(self) -> None: + await self._camera_service.register_for_updates( + self._camera, self.process_update + ) + + async def async_will_remove_from_hass(self) -> None: + await self._camera_service.deregister_for_updates(self._camera) + + @token_exception_handler + def process_update(self, camera: Camera) -> None: + """ + Is called by the update worker for events to update the values in this sensor + + :param camera: An updated version of the current camera + """ + self._camera = camera + + if camera.last_event_ts > self._last_event: + self._is_on = True + self._last_event = camera.last_event_ts + else: + self._is_on = False + self._last_event = camera.last_event_ts + + self.schedule_update_ha_state() diff --git a/custom_components/wyzeapi/button.py b/custom_components/wyzeapi/button.py new file mode 100644 index 0000000..6d4413f --- /dev/null +++ b/custom_components/wyzeapi/button.py @@ -0,0 +1,317 @@ +"""Platform for button integration.""" + +from collections.abc import Callable +import logging +from typing import Any + +from aiohttp.client_exceptions import ClientConnectionError +from wyzeapy import Wyzeapy +from wyzeapy.services.irrigation_service import Irrigation, IrrigationService, Zone +from wyzeapy.services.switch_service import Switch + +from homeassistant.components.button import ButtonDeviceClass, ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_registry import EntityCategory + +from .const import CONF_CLIENT, DOMAIN, RESET_BUTTON_PRESSED +from .token_manager import token_exception_handler + +_LOGGER = logging.getLogger(__name__) +ATTRIBUTION = "Data provided by Wyze" +OUTDOOR_PLUGS = ["WLPPO"] + + +@token_exception_handler +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: Callable[[list[Any], bool], None], +) -> None: + """This function sets up the config entry. + + :param hass: The Home Assistant Instance + :param config_entry: The current config entry + :param async_add_entities: This function adds entities to the config entry + :return: + """ + + _LOGGER.debug("""Creating new Wyze button component""") + client: Wyzeapy = hass.data[DOMAIN][config_entry.entry_id][CONF_CLIENT] + irrigation_service = await client.irrigation_service + switch_service = await client.switch_service + + # Get all irrigation devices + irrigation_devices = await irrigation_service.get_irrigations() + + # Create a button entity for each zone in each irrigation device + buttons = [] + for device in irrigation_devices: + # Update the device to get its zones + device = await irrigation_service.update(device) + # Add a button entity for each enabled zone in the irrigation device + buttons.extend( + [ + WyzeIrrigationZoneButton(irrigation_service, device, zone) + for zone in device.zones + if zone.enabled + ] + ) + # Add a stop all schedules button for each irrigation device, not each zone + buttons.append(WyzeIrrigationStopAllButton(irrigation_service, device)) + + plugs = await switch_service.get_switches() + buttons.extend( + [ + WyzePowerSensorResetButton(plug) + for plug in plugs + if plug.product_model in OUTDOOR_PLUGS + ] + ) + + async_add_entities(buttons, True) + + +class WyzeIrrigationZoneButton(ButtonEntity): + """Representation of a Wyze Irrigation Zone Button.""" + + _attr_has_entity_name = True + + def __init__( + self, irrigation_service: IrrigationService, irrigation: Irrigation, zone: Zone + ) -> None: + """Initialize the irrigation zone button.""" + self._irrigation_service = irrigation_service + self._device = irrigation + self._zone = zone + + @property + def name(self) -> str: + """Return the name of the zone.""" + return f"{self._zone.name}" + + @property + def unique_id(self) -> str: + """Return a unique ID for the zone.""" + return f"Start {self._device.mac}-zone-{self._zone.zone_number}" + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this entity.""" + return DeviceInfo( + identifiers={(DOMAIN, self._device.mac)}, + name=self._device.nickname, + manufacturer="WyzeLabs", + model=self._device.product_model, + connections={(dr.CONNECTION_NETWORK_MAC, self._device.mac)}, + ) + + @property + def device_class(self) -> str: + """Return the device class of the button.""" + return ButtonDeviceClass.RESTART + + @property + def icon(self) -> str: + """Return the icon for the zone start button.""" + return "mdi:sprinkler" + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return entity specific state attributes.""" + return { + "zone_number": self._zone.zone_number, + "zone_id": self._zone.zone_id, + "enabled": self._zone.enabled, + "quickrun_duration": self._zone.quickrun_duration, + } + + async def async_press(self) -> None: + """Start the zone with its quickrun duration. + + This method is called when the button is pressed in Home Assistant. + It starts the irrigation zone for the duration specified in the corresponding number entity. + + The number entity is found using the exact unique_id pattern that follows the format: + {device.mac}-zone-{zone.zone_number}-quickrun-duration + + Raises: + HomeAssistantError: If the zone cannot be started or the number entity is invalid. + """ + try: + # The quickrun duration field doesnt exist in the Wyze API + # It has been created in Home Assistant as a number entity + # to conveniently trigger a zone start with a specific duration + + # Get the device registry and find the device + device_registry = dr.async_get(self.hass) + device = device_registry.async_get_device( + identifiers={(DOMAIN, self._device.mac)} + ) + if not device: + raise HomeAssistantError(f"Device not found for MAC {self._device.mac}") + + # Get the entity registry + entity_registry = er.async_get(self.hass) + + # Find the matching number entity using the zone number and device MAC + # The number entities have unique_id pattern: {device.mac}-zone-{zone.zone_number}-quickrun-duration + expected_unique_id = ( + f"{self._device.mac}-zone-{self._zone.zone_number}-quickrun-duration" + ) + + matching_entity = None + for entity_id, entity in entity_registry.entities.items(): + if ( + entity.device_id == device.id + and entity.platform == DOMAIN + and entity_id.startswith("number.") + and entity.unique_id == expected_unique_id + ): + matching_entity = entity_id + break + + _LOGGER.debug( + "Looking for number entity with unique_id: %s", expected_unique_id + ) + _LOGGER.debug("Found matching entity: %s", matching_entity) + + if not matching_entity: + raise HomeAssistantError( + f"No number entity found for zone {self._zone.name} (zone {self._zone.zone_number}, device: {self._device.mac})" + ) + + # Get the current state of the number entity + state = self.hass.states.get(matching_entity) + if state is None or state.state in ["unavailable", "unknown"]: + raise HomeAssistantError( + f"Number entity {matching_entity} is unavailable or unknown" + ) + + # Convert duration from minutes to seconds + try: + duration_minutes = float(state.state) + if duration_minutes <= 0: + raise ValueError("Duration must be greater than 0") + duration_seconds = int(duration_minutes * 60) + except ValueError as err: + raise HomeAssistantError( + f"Invalid duration {state.state} for {matching_entity}" + ) from err + + _LOGGER.debug( + "Starting zone %s (zone %s) for %s minutes (%s seconds)", + self._zone.name, + self._zone.zone_number, + duration_minutes, + duration_seconds, + ) + + # Start the zone with the specified duration + await self._irrigation_service.start_zone( + self._device, self._zone.zone_number, duration_seconds + ) + + except HomeAssistantError as err: + _LOGGER.error("Failed to start zone %s: %s", self._zone.name, err) + raise + except Exception as err: + _LOGGER.error("Unexpected error starting zone %s: %s", self._zone.name, err) + raise HomeAssistantError( + f"Failed to start zone {self._zone.name}: {err}" + ) from err + + +class WyzeIrrigationStopAllButton(ButtonEntity): + """Representation of a Wyze Irrigation Stop All Schedules Button.""" + + _attr_name = "Stop All Zones" + + def __init__( + self, irrigation_service: IrrigationService, irrigation: Irrigation + ) -> None: + """Initialize the irrigation stop all button.""" + self._irrigation_service = irrigation_service + self._device = irrigation + + @property + def unique_id(self) -> str: + """Return a unique ID for the button.""" + return f"Stop All {self._device.mac}" + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this entity.""" + return DeviceInfo( + identifiers={(DOMAIN, self._device.mac)}, + name=self._device.nickname, + manufacturer="WyzeLabs", + model=self._device.product_model, + serial_number=self._device.sn, + connections={(dr.CONNECTION_NETWORK_MAC, self._device.mac)}, + ) + + @property + def device_class(self) -> str: + """Return the device class of the button.""" + return ButtonDeviceClass.RESTART + + @property + def icon(self) -> str: + """Return the icon for the stop all button.""" + return "mdi:octagon" + + async def async_press(self) -> None: + """Stop all running irrigation schedules. + + This method is called when the button is pressed in Home Assistant. + It will stop all running irrigation schedules for the device. + + Raises: + HomeAssistantError: If the schedules cannot be stopped. + """ + try: + await self._irrigation_service.stop_running_schedule(self._device) + except ClientConnectionError as err: + raise HomeAssistantError(f"Failed to stop schedules: {err}") from err + except Exception as err: + _LOGGER.error("Error stopping schedules: %s", err) + raise HomeAssistantError(f"Failed to stop schedules: {err}") from err + + +class WyzePowerSensorResetButton(ButtonEntity): + """Wyze Power Sensor Reset Button.""" + + _attr_has_entity_name = True + _attr_should_poll = False + _attr_entity_category = EntityCategory.CONFIG + _attr_name = "Energy Usage Reset" + + def __init__(self, switch: Switch) -> None: + """Initialize a power sensor reset button.""" + self._switch = switch + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this entity.""" + return DeviceInfo( + identifiers={(DOMAIN, self._switch.mac)}, + name=self._switch.nickname, + ) + + @property + def unique_id(self) -> str: + """Create a unique ID for the button.""" + return f"{self._switch.mac} Reset button" + + async def async_press(self) -> None: + """Reset the sensor usage.""" + async_dispatcher_send( + self.hass, + f"{RESET_BUTTON_PRESSED}-{self._switch.mac}", + self._switch, + ) diff --git a/custom_components/wyzeapi/camera.py b/custom_components/wyzeapi/camera.py new file mode 100644 index 0000000..277febe --- /dev/null +++ b/custom_components/wyzeapi/camera.py @@ -0,0 +1,521 @@ +"""Wyze Camera integration for Home Assistant.""" + +import base64 +import json +import asyncio +from dataclasses import asdict +from collections.abc import Callable +from typing import Any +import logging +import uuid +import re + +from homeassistant.config_entries import ConfigEntry +from homeassistant.components.camera import Camera as CameraEntity, CameraEntityFeature +from homeassistant.components.camera.webrtc import ( + WebRTCClientConfiguration, + WebRTCSendMessage, + WebRTCAnswer, + WebRTCCandidate, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util.ssl import get_default_context +from propcache.api import cached_property +from webrtc_models import RTCConfiguration, RTCIceCandidateInit, RTCIceServer +from websockets.asyncio.client import connect as websocket_connect +from wyzeapy import Wyzeapy, CameraService +from wyzeapy.services.camera_service import Camera + +from .const import CAMERA_UPDATED, CONF_CLIENT, DOMAIN +from .token_manager import token_exception_handler + +_LOGGER = logging.getLogger(__name__) + + +@token_exception_handler +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: Callable[[list[Any], bool], None], +) -> None: + """This function sets up the config entry. + + :param hass: The Home Assistant Instance + :param config_entry: The current config entry + :param async_add_entities: This function adds entities to the config entry + :return: + """ + + _LOGGER.debug("Creating new Wyze camera component") + client: Wyzeapy = hass.data[DOMAIN][config_entry.entry_id][CONF_CLIENT] + camera_service = await client.camera_service + camera_devices = await camera_service.get_cameras() + + # Create a camera entity for each camera device + cameras = [] + for device in camera_devices: + # Update the device to get its zones + device = await camera_service.update(device) + cameras.extend([WyzeCamera(camera_service, device)]) + + for camera in cameras: + # Pre-seed the ICE server config by fetching it during setup, so the frontend can collect ICE servers before the offer + try: + await camera.config_fetch() + except Exception as e: + # Don't block startup if the config fetch fails, but log the error + _LOGGER.warning( + "Error fetching WebRTC session configuration for camera %s: %s", + camera.name, + e, + ) + + _LOGGER.debug("Wyze camera component setup complete") + async_add_entities(cameras, True) + + +class WyzeCamera(CameraEntity): + """Representation of a Wyze Camera.""" + + def __init__(self, camera_service: CameraService, camera: Camera): + """Initialize the camera.""" + super().__init__() + self._camera_service = camera_service + self._camera = camera + self.name = camera.nickname + self._attr_unique_id = camera.mac + self.brand = "Wyze" + self.model = camera.product_model + self.supported_features = CameraEntityFeature.STREAM + self._webrtc_provider = None + self.sessions: dict[str, WyzeCameraWebRTCSession] = {} + self._pending_candidates: dict[str, list[RTCIceCandidateInit]] = {} + # Always holds an in-flight Task[dict] for the next config fetch. + # _async_get_webrtc_client_configuration reads the result when ready; + # async_handle_async_webrtc_offer awaits it to guarantee a fresh config. + self._cached_config: dict | None = None + self._config_task: asyncio.Task | None = None + + async def config_fetch(self) -> None: + """Fetch the WebRTC session configuration for this camera and cache it for future use.""" + self._cached_config = await self._camera_service.get_stream_info(self._camera) + _LOGGER.debug( + "Initial fetch of WebRTC session configuration complete for camera %s", + self.name, + ) + + @cached_property + def device_info(self) -> DeviceInfo | None: + """Return the device info.""" + return { + "identifiers": {(DOMAIN, self._camera.mac)}, + "name": self._camera.nickname, + "manufacturer": "WyzeLabs", + "model": self._camera.product_model, + } + + @property + def available(self) -> bool: + """Return if the camera is available.""" + return self._camera.available + + @cached_property + def is_streaming(self) -> bool: + """Return True if the camera is currently streaming.""" + return self._camera.on + + @callback + def handle_camera_update(self, camera: Camera) -> None: + """Update the camera whenever there is an update.""" + self._camera = camera + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Listen for camera updates.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{CAMERA_UPDATED}-{self._camera.mac}", + self.handle_camera_update, + ) + ) + + @property + def is_on(self) -> bool: + """Return True if the camera is currently on.""" + return self._camera.on + + async def async_turn_on(self) -> None: + """Turn the camera on.""" + await self._camera_service.turn_on(self._camera) + + async def async_turn_off(self) -> None: + """Turn the camera off.""" + await self._camera_service.turn_off(self._camera) + + async def async_disable_motion_detection(self) -> None: + """Disable motion detection.""" + await self._camera_service.turn_off_motion_detection(self._camera) + + async def async_enable_motion_detection(self) -> None: + """Enable motion detection.""" + await self._camera_service.turn_on_motion_detection(self._camera) + + @property + def motion_detection_enabled(self) -> bool | None: + """Return True if motion detection is enabled, False if disabled, or None if unknown/not supported.""" + motion = getattr(self._camera, "motion", None) + if isinstance(motion, bool): + return motion + # Some Wyze camera models / API responses don't expose motion state. + # Return None so HA omits/marks the attribute as unknown instead of crashing. + return None + + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: + """Return bytes of camera image. + Currently not implemented""" + return None + + def _async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration: + """Return the WebRTC client configuration for this camera, including ICE servers.""" + # This shouldn't happen, but throw an error if we don't have a config ready yet + if self._cached_config is None: + raise HomeAssistantError("WebRTC session configuration not available yet") + + config = self._cached_config + + ice_servers = [] + for server in config.get("ice_servers", []): + _LOGGER.debug("Adding ICE server for camera %s: %s", self.name, server) + ice_servers.append( + RTCIceServer.from_dict( + { + "urls": server["url"], + "username": server["username"], + "credential": server["credential"], + } + ) + ) + + _LOGGER.debug("ICE servers for camera %s: %s", self.name, ice_servers) + configuration = RTCConfiguration(ice_servers=ice_servers) + return WebRTCClientConfiguration( + configuration=configuration, data_channel="data" + ) + + async def async_handle_async_webrtc_offer( + self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage + ) -> None: + """Handle an incoming WebRTC offer from the frontend.""" + _LOGGER.debug( + "Handling WebRTC offer for camera %s with session ID %s", + self.name, + session_id, + ) + + # Always fetch a truly fresh config so the signaling URL and ICE servers + # are never stale — KVS signed URLs are single-use and short-lived. + config = await self._camera_service.get_stream_info(self._camera) + + # Update cached config with the new ICE servers + self._cached_config = config + _LOGGER.debug("Fresh config for offer on camera %s: %s", self.name, config) + + self.sessions[session_id] = WyzeCameraWebRTCSession( + session_id, self, send_message, config + ) + await self.sessions[session_id].send_offer(offer_sdp) + + pending = self._pending_candidates.pop(session_id, None) + if pending: + _LOGGER.debug( + "Flushing %d buffered ICE candidates for camera %s session %s", + len(pending), + self.name, + session_id, + ) + for cand in pending: + await self.sessions[session_id].send_candidate(cand) + + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidateInit + ) -> None: + """Handle an incoming ICE candidate for a WebRTC session.""" + if session_id not in self.sessions: + self._pending_candidates.setdefault(session_id, []).append(candidate) + _LOGGER.debug( + "Buffered ICE candidate for camera %s session %s (session not ready yet)", + self.name, + session_id, + ) + return + + await self.sessions[session_id].send_candidate(candidate) + + def close_webrtc_session(self, session_id: str) -> None: + """Close a WebRTC session and clean up resources.""" + _LOGGER.debug("Closing WebRTC session %s", session_id) + self._pending_candidates.pop(session_id, None) + if session_id in self.sessions: + session = self.sessions[session_id] + session.close_connection() + del self.sessions[session_id] + + +class WyzeCameraWebRTCSession: + """Represents a WebRTC session for a Wyze camera.""" + + def __init__( + self, + session_id: str, + camera: WyzeCamera, + callback: WebRTCSendMessage, + config: dict, + ): + self.session_id = session_id + self.camera = camera + self.websocket = None # This will hold the WebSocket connection + self.camera_service = None + self.callback = callback + self.close = None + self.lock = asyncio.Lock() + self.task = None + self.config = config + self.sdp_offer = None + self.sdp_answer = None + # Set once connect() succeeds; send_candidate waits on this instead of reconnecting + self._connected = asyncio.Event() + + async def connect(self): + """Establish the WebSocket connection to the KVS signaling URL. + This is called lazily from send_offer() to ensure we have the latest config + and don't connect too early before the offer is ready.""" + # The signaling_url from get_stream_info() is often *double*-percent-encoded + # (e.g. "%253A" instead of "%3A"). We must NOT fully URL-decode it because + # that can change SigV4 canonical encoding and make KVS reject the handshake. + # Instead, only "undouble" percent-escapes by converting "%25xx" -> "%xx", + # leaving "%3A", "%2F", etc. intact. + signaling_url = self.config["signaling_url"] + for _ in range(3): + if "%25" not in signaling_url: + break + signaling_url = signaling_url.replace("%25", "%") + self.websocket = await websocket_connect( + signaling_url, ssl=get_default_context(), logger=_LOGGER + ) + _LOGGER.debug( + "WebSocket connection established for camera %s with session ID %s", + self.camera.name, + self.session_id, + ) + self._connected.set() + asyncio.create_task(self.run_loop()) + + async def send_offer(self, offer_sdp: str): + """Send an SDP offer to the Kinesis Video Streams signaling channel.""" + async with self.lock: + if self.websocket is None: + _LOGGER.debug("Connecting to websocket from send_offer") + await self.connect() + if self.websocket is None: + raise ConnectionError("WebSocket connection not established") + # Create an offer for Kinesis + self.sdp_offer = offer_sdp + offer = {"type": "offer", "sdp": offer_sdp} + payload = { + "action": "SDP_OFFER", + "recipientClientId": "ada06f08-87f4-4e13-b699-e82db8517ae5", + "messagePayload": base64.b64encode( + json.dumps(offer, separators=(",", ":")).encode() + ).decode(), + "correlationId": str(uuid.uuid4()), + } + str_payload = json.dumps(payload) + _LOGGER.debug( + "Sending SDP offer for camera %s with session ID %s, %s", + self.camera.name, + self.session_id, + str_payload, + ) + await self.websocket.send(str_payload) + + async def send_candidate(self, candidate: RTCIceCandidateInit): + """Send an ICE candidate to the Kinesis Video Streams signaling channel.""" + # Take RTCIceCandidateInit, convert it to the format in the messagePayload above, and send it to the client using the callback + # Wait for send_offer to establish the connection — never reconnect (KVS URLs are single-use) + try: + await asyncio.wait_for(self._connected.wait(), timeout=10.0) + except asyncio.TimeoutError as exc: + raise ConnectionError( + "WebSocket connection not established within timeout" + ) from exc + if self.websocket is None: + raise ConnectionError("WebSocket connection not established") + candidate_dict = asdict(candidate) + candidate_payload = { + "candidate": candidate_dict["candidate"], + "sdpMid": candidate_dict["sdp_mid"], + "sdpMLineIndex": candidate_dict["sdp_m_line_index"], + "usernameFragment": candidate_dict["user_fragment"], + } + match = re.search(r"ufrag (\w{4})", candidate_payload["candidate"]) + if match is not None: + candidate_payload["usernameFragment"] = match.group(1) + payload = { + "action": "ICE_CANDIDATE", + "recipientClientId": "ada06f08-87f4-4e13-b699-e82db8517ae5", + "messagePayload": base64.b64encode( + json.dumps(candidate_payload, separators=(",", ":")).encode() + ).decode(), + } + str_payload = json.dumps(payload) + _LOGGER.debug( + "Sending ICE candidate for camera %s with session ID %s: %s", + self.camera.name, + self.session_id, + str_payload, + ) + await self.websocket.send(str_payload) + + def close_connection(self): + """Close the WebSocket connection to the Kinesis Video Streams signaling channel.""" + if self.close is not None: + self.close() + + def force_correct_sdp_answer(self) -> None: + """Force the sdp response to have the valid answer. + + The Kinesis WebRTC Stream responses to certain offers do not + follow the spec defined in https://www.ietf.org/rfc/rfc3264.txt + An offer of recvonly must be answered with sendonly or inactive. + """ + _LOGGER.debug("Attempt to fix sdp answer...") + if isinstance(self.sdp_answer, str) and isinstance(self.sdp_offer, str): + sdp_kinds = ["audio", "video", "application"] + sdp_directions = ["sendrecv", "sendonly", "recvonly", "inactive"] + sdp_pattern = ( + "m=(?P{})(.|\n)+?a=(?P{})(\r|\n|\r\n)".format( + "|".join(sdp_kinds), "|".join(sdp_directions) + ) + ) + + sdp_direction_offers = re.finditer(sdp_pattern, self.sdp_offer) + + for offer in sdp_direction_offers: + sdp_answers = re.finditer(sdp_pattern, self.sdp_answer) + for answer in sdp_answers: + if ( + offer.group("kind") == answer.group("kind") + and offer.group("direction") == "recvonly" + and answer.group("direction") == "sendrecv" + ): + correct_answer = re.sub( + "a=sendrecv", "a=sendonly", answer.group(0) + ) + _LOGGER.debug("Replacing answer with: %s", str(correct_answer)) + self.sdp_answer = self.sdp_answer.replace( + answer.group(0), correct_answer + ) + + async def run_loop(self): + """Listen for messages from the Kinesis Video Streams signaling channel and handle them appropriately.""" + if self.websocket is None: + raise ConnectionError("WebSocket connection not established") + + loop = asyncio.get_running_loop() + + def close(): + if self.websocket is not None: + return loop.create_task(self.websocket.close()) + return None + + self.close = close + _LOGGER.debug( + "run_loop starting for camera %s session %s", + self.camera.name, + self.session_id, + ) + try: + async for message in self.websocket: + if len(message) == 0: + _LOGGER.debug( + "Received empty message (type=%s) for camera %s session %s", + type(message).__name__, + self.camera.name, + self.session_id, + ) + continue + _LOGGER.debug( + "Received message for camera %s with session ID %s: %s", + self.camera.name, + self.session_id, + message, + ) + try: + data = json.loads(message) + except json.JSONDecodeError as e: + _LOGGER.error( + "Failed to decode JSON message for camera %s with session ID %s: %s", + self.camera.name, + self.session_id, + e, + ) + continue + match data.get("messageType"): + case "ICE_CANDIDATE": + # Decode messagePayload (base64 JSON) → RTCIceCandidateInit → WebRTCCandidate + # KVS uses camelCase keys; map them to RTCIceCandidateInit's snake_case fields + candidate_str = base64.b64decode( + data["messagePayload"] + ).decode() + candidate_data = json.loads(candidate_str) + rtccandidate = RTCIceCandidateInit( + candidate=candidate_data["candidate"], + sdp_mid=candidate_data.get("sdpMid"), + sdp_m_line_index=candidate_data.get("sdpMLineIndex"), + user_fragment=candidate_data.get("usernameFragment"), + ) + self.callback(WebRTCCandidate(candidate=rtccandidate)) + case "SDP_ANSWER": + # Decode messagePayload (base64 JSON with "type"/"sdp" keys) → extract sdp string + answer_str = base64.b64decode(data["messagePayload"]).decode() + try: + answer_obj = json.loads(answer_str) + sdp = answer_obj.get("sdp", answer_str) + except json.JSONDecodeError: + sdp = answer_str + self.sdp_answer = sdp + self.force_correct_sdp_answer() + self.callback(WebRTCAnswer(answer=self.sdp_answer)) + case "STATUS_RESPONSE" | "GO_AWAY" | "RECONNECT_ICE_SERVER": + _LOGGER.debug( + "KVS control message '%s' for session %s: %s", + data.get("messageType"), + self.session_id, + data, + ) + case other: + _LOGGER.debug( + "Unhandled KVS message type '%s' for session %s: %s", + other, + self.session_id, + data, + ) + except Exception as e: + _LOGGER.error( + "run_loop error for camera %s session %s: %s", + self.camera.name, + self.session_id, + e, + exc_info=True, + ) + _LOGGER.debug( + "run_loop exited for camera %s session %s", + self.camera.name, + self.session_id, + ) diff --git a/custom_components/wyzeapi/climate.py b/custom_components/wyzeapi/climate.py new file mode 100644 index 0000000..8365163 --- /dev/null +++ b/custom_components/wyzeapi/climate.py @@ -0,0 +1,391 @@ +"""Platform for light integration.""" + +import logging + +# Import the device class from the component that you want to support +from datetime import timedelta +from typing import List, Optional, Callable, Any +from aiohttp.client_exceptions import ClientConnectionError + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.components.climate.const import ( + FAN_AUTO, + FAN_ON, + PRESET_HOME, + PRESET_AWAY, + PRESET_SLEEP, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr +from wyzeapy import Wyzeapy, ThermostatService +from wyzeapy.exceptions import AccessTokenError, ParameterError, UnknownApiError +from wyzeapy.services.thermostat_service import ( + Thermostat, + TemperatureUnit, + Preset, + FanMode, + HVACState, + HVACMode as WyzeHVACMode, +) +from .token_manager import token_exception_handler + +from .const import DOMAIN, CONF_CLIENT + +_LOGGER = logging.getLogger(__name__) +ATTRIBUTION = "Data provided by Wyze" +SCAN_INTERVAL = timedelta(seconds=30) + + +@token_exception_handler +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: Callable[[List[Any], bool], None], +): + """ + This function sets up the config entry so that it is available to Home Assistant + + :param hass: The Home Assistant instance + :param config_entry: The current config entry + :param async_add_entities: A function to add entities + :return: + """ + + _LOGGER.debug("""Creating new WyzeApi thermostat component""") + client: Wyzeapy = hass.data[DOMAIN][config_entry.entry_id][CONF_CLIENT] + + thermostat_service = await client.thermostat_service + thermostats = [ + WyzeThermostat(thermostat_service, thermostat) + for thermostat in await thermostat_service.get_thermostats() + ] + + async_add_entities(thermostats, True) + + +class WyzeThermostat(ClimateEntity): + """ + This class defines a representation of a Wyze Thermostat that can be used for Home Assistant + """ + + # pylint: disable=R0902 + _server_out_of_sync = False + + def __init__(self, thermostat_service: ThermostatService, thermostat: Thermostat): + self._thermostat_service = thermostat_service + self._thermostat = thermostat + + def set_temperature(self, **kwargs) -> None: + raise NotImplementedError + + def set_humidity(self, humidity: int) -> None: + raise NotImplementedError + + def set_fan_mode(self, fan_mode: str) -> None: + raise NotImplementedError + + def set_hvac_mode(self, hvac_mode: str) -> None: + raise NotImplementedError + + def set_swing_mode(self, swing_mode: str) -> None: + raise NotImplementedError + + def set_preset_mode(self, preset_mode: str) -> None: + raise NotImplementedError + + def turn_aux_heat_on(self) -> None: + raise NotImplementedError + + def turn_aux_heat_off(self) -> None: + raise NotImplementedError + + @property + def current_temperature(self) -> float: + return self._thermostat.temperature + + @property + def current_humidity(self) -> Optional[int]: + return self._thermostat.humidity + + @property + def temperature_unit(self) -> str: + # if self._thermostat.temp_unit == TemperatureUnit.FAHRENHEIT: + return UnitOfTemperature.FAHRENHEIT + # return TEMP_CELSIUS + + @property + def unit_of_measurement(self) -> str: + if self._thermostat.temp_unit == TemperatureUnit.FAHRENHEIT: + return UnitOfTemperature.FAHRENHEIT + return UnitOfTemperature.CELSIUS + + @property + def hvac_mode(self) -> str: + # pylint: disable=R1705 + if self._thermostat.hvac_mode == WyzeHVACMode.AUTO: + return HVACMode.AUTO + elif self._thermostat.hvac_mode == WyzeHVACMode.HEAT: + return HVACMode.HEAT + elif self._thermostat.hvac_mode == WyzeHVACMode.COOL: + return HVACMode.COOL + else: + return HVACMode.OFF + + @property + def hvac_modes(self) -> List[str]: + return [HVACMode.AUTO, HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF] + + @property + def target_temperature_high(self) -> Optional[float]: + return self._thermostat.cool_set_point + + @property + def target_temperature_low(self) -> Optional[float]: + return self._thermostat.heat_set_point + + @property + def preset_mode(self) -> Optional[str]: + match self._thermostat.preset: + case Preset.HOME: + return PRESET_HOME + case Preset.AWAY: + return PRESET_AWAY + case Preset.SLEEP: + return PRESET_SLEEP + case _: + raise NotImplementedError + + @property + def preset_modes(self) -> Optional[List[str]]: + return [PRESET_HOME, PRESET_AWAY, PRESET_SLEEP] + + @property + def is_aux_heat(self) -> Optional[bool]: + raise NotImplementedError + + @property + def fan_mode(self) -> Optional[str]: + if self._thermostat.fan_mode == FanMode.AUTO: + return FAN_AUTO + else: + return FAN_ON + + @property + def fan_modes(self) -> Optional[List[str]]: + return [FAN_AUTO, FAN_ON] + + @property + def swing_mode(self) -> Optional[str]: + raise NotImplementedError + + @property + def swing_modes(self) -> Optional[str]: + raise NotImplementedError + + @property + def hvac_action(self) -> str: + # pylint: disable=R1705 + if self._thermostat.hvac_state == HVACState.IDLE: + return HVACAction.IDLE + elif self._thermostat.hvac_state == HVACState.HEATING: + return HVACAction.HEATING + elif self._thermostat.hvac_state == HVACState.COOLING: + return HVACAction.COOLING + else: + return HVACAction.OFF + + @token_exception_handler + async def async_set_temperature(self, **kwargs) -> None: + target_temp_low = kwargs["target_temp_low"] + target_temp_high = kwargs["target_temp_high"] + + try: + # Always send setpoints unconditionally rather than guarding against + # the cached heat_set_point/cool_set_point value. The equality check + # causes a silent no-op whenever the cache is stale (e.g. after a mode + # change resets the physical device to firmware defaults, or after a + # Wyze cloud/app-driven reset), leaving the physical thermostat at the + # wrong setpoint while HA and the Wyze cloud both report the correct one. + # The _server_out_of_sync flag compounds this by delaying the cache + # refresh by up to 60 s after any command. The underlying API call + # (set_iot_prop_by_topic) is idempotent, so always sending is safe. + # See: https://github.com/SecKatie/ha-wyzeapi/issues/813 + await self._thermostat_service.set_heat_point( + self._thermostat, int(target_temp_low) + ) + self._thermostat.heat_set_point = int(target_temp_low) + await self._thermostat_service.set_cool_point( + self._thermostat, int(target_temp_high) + ) + self._thermostat.cool_set_point = int(target_temp_high) + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._server_out_of_sync = True + self.async_schedule_update_ha_state() + + async def async_set_humidity(self, humidity: int) -> None: + raise NotImplementedError + + @token_exception_handler + async def async_set_fan_mode(self, fan_mode: str) -> None: + try: + if fan_mode == FAN_ON: + await self._thermostat_service.set_fan_mode( + self._thermostat, FanMode.ON + ) + self._thermostat.fan_mode = FanMode.ON + elif fan_mode == FAN_AUTO: + await self._thermostat_service.set_fan_mode( + self._thermostat, FanMode.AUTO + ) + self._thermostat.fan_mode = FanMode.AUTO + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._server_out_of_sync = True + self.async_schedule_update_ha_state() + + @token_exception_handler + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + try: + if hvac_mode == HVACMode.OFF: + await self._thermostat_service.set_hvac_mode( + self._thermostat, WyzeHVACMode.OFF + ) + self._thermostat.hvac_mode = HVACMode.OFF + elif hvac_mode == HVACMode.HEAT: + await self._thermostat_service.set_hvac_mode( + self._thermostat, WyzeHVACMode.HEAT + ) + self._thermostat.hvac_mode = HVACMode.HEAT + elif hvac_mode == HVACMode.COOL: + await self._thermostat_service.set_hvac_mode( + self._thermostat, WyzeHVACMode.COOL + ) + self._thermostat.hvac_mode = HVACMode.COOL + elif hvac_mode == HVACMode.AUTO: + await self._thermostat_service.set_hvac_mode( + self._thermostat, WyzeHVACMode.AUTO + ) + self._thermostat.hvac_mode = HVACMode.AUTO + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._server_out_of_sync = True + self.async_schedule_update_ha_state() + + async def async_set_swing_mode(self, swing_mode: str) -> None: + raise NotImplementedError + + @token_exception_handler + async def async_set_preset_mode(self, preset_mode: str) -> None: + try: + if preset_mode == PRESET_SLEEP: + await self._thermostat_service.set_preset( + self._thermostat, Preset.SLEEP + ) + self._thermostat.preset = Preset.SLEEP + elif preset_mode == PRESET_AWAY: + await self._thermostat_service.set_preset(self._thermostat, Preset.AWAY) + self._thermostat.preset = Preset.AWAY + elif preset_mode == PRESET_HOME: + await self._thermostat_service.set_preset(self._thermostat, Preset.HOME) + self._thermostat.preset = Preset.HOME + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._server_out_of_sync = True + self.async_schedule_update_ha_state() + + async def async_turn_aux_heat_on(self) -> None: + raise NotImplementedError + + async def async_turn_aux_heat_off(self) -> None: + raise NotImplementedError + + @property + def supported_features(self) -> int: + return ( + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE + ) + + @property + def device_info(self) -> dict: + return { + "identifiers": {(DOMAIN, self._thermostat.mac)}, + "name": self._thermostat.nickname, + "connections": { + ( + dr.CONNECTION_NETWORK_MAC, + self._thermostat.mac, + ) + }, + "manufacturer": "WyzeLabs", + "model": self._thermostat.product_model, + } + + @property + def should_poll(self) -> bool: + return False + + @property + def name(self) -> str: + """Return the display name of this lock.""" + return self._thermostat.nickname + + @property + def unique_id(self) -> str: + return self._thermostat.mac + + @property + def available(self) -> bool: + """Return the connection status of this light""" + return self._thermostat.available + + @token_exception_handler + async def async_update(self) -> None: + """ + This function updates the state of the Thermostat + + :return: None + """ + + if not self._server_out_of_sync: + self._thermostat = await self._thermostat_service.update(self._thermostat) + else: + self._server_out_of_sync = False + + @callback + def async_update_callback(self, thermostat: Thermostat): + """Update the thermostat's state.""" + self._thermostat = thermostat + self.async_schedule_update_ha_state() + + async def async_added_to_hass(self) -> None: + """Subscribe to update events.""" + self._thermostat.callback_function = self.async_update_callback + self._thermostat_service.register_updater(self._thermostat, 30) + await self._thermostat_service.start_update_manager() + return await super().async_added_to_hass() + + async def async_will_remove_from_hass(self) -> None: + self._thermostat_service.unregister_updater(self._thermostat) diff --git a/custom_components/wyzeapi/config_flow.py b/custom_components/wyzeapi/config_flow.py new file mode 100644 index 0000000..13a6bef --- /dev/null +++ b/custom_components/wyzeapi/config_flow.py @@ -0,0 +1,182 @@ +"""Config flow for Wyze Home Assistant Integration integration.""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_ACCESS_TOKEN +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from wyzeapy import Wyzeapy, exceptions + +from .const import ( + DOMAIN, + ACCESS_TOKEN, + REFRESH_TOKEN, + REFRESH_TIME, + BULB_LOCAL_CONTROL, + DEFAULT_LOCAL_CONTROL, + KEY_ID, + API_KEY, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(KEY_ID): str, + vol.Required(API_KEY): str, + } +) +STEP_2FA_DATA_SCHEMA = vol.Schema({CONF_ACCESS_TOKEN: str}) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Wyze Home Assistant Integration.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + client: Wyzeapy = None + user_params = {} + + def __init__(self): + """Initialize.""" + self.email = None + self.password = None + self.key_id = None + self.api_key = None + + async def get_client(self): + if not self.client: + self.client = await Wyzeapy.create() + + async def async_step_user( + self, user_input: Optional[dict[str, any]] = None + ) -> dict[str, Any]: + """Handle the initial step.""" + await self.get_client() + + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + # noinspection PyBroadException + try: + await self.client.login( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + user_input[KEY_ID], + user_input[API_KEY], + ) + except CannotConnect: + errors["base"] = "cannot_connect" + except exceptions.AccessTokenError: + errors["base"] = "invalid_auth" + except exceptions.TwoFactorAuthenticationEnabled: + self.user_params[CONF_USERNAME] = user_input[CONF_USERNAME] + self.user_params[CONF_PASSWORD] = user_input[CONF_PASSWORD] + self.user_params[KEY_ID] = user_input[KEY_ID] + self.user_params[API_KEY] = user_input[API_KEY] + return await self.async_step_2fa() + else: + if self.hass.config_entries.async_entries(DOMAIN): + for entry in self.hass.config_entries.async_entries(DOMAIN): + self.hass.config_entries.async_update_entry(entry, data=user_input) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + else: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_2fa( + self, user_input: Optional[dict[str, Any]] = None + ) -> dict[str, Any]: + if user_input is None: + return self.async_show_form(step_id="2fa", data_schema=STEP_2FA_DATA_SCHEMA) + + errors = {} + + try: + token = await self.client.login_with_2fa( + user_input[CONF_ACCESS_TOKEN], + ) + except exceptions.LoginError: + errors["base"] = "invalid_auth" + else: + self.user_params[ACCESS_TOKEN] = token.access_token + self.user_params[REFRESH_TOKEN] = token.refresh_token + self.user_params[REFRESH_TIME] = token.refresh_time + if self.hass.config_entries.async_entries(DOMAIN): + for entry in self.hass.config_entries.async_entries(DOMAIN): + self.hass.config_entries.async_update_entry( + entry, data=self.user_params + ) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + else: + return self.async_create_entry(title="", data=self.user_params) + + return self.async_show_form( + step_id="2fa", data_schema=STEP_2FA_DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_config) + + async def async_step_reauth(self, user_input=None): + """Perform reauth upon an API authentication error.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({}), + ) + return await self.async_step_user() + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: + """Create the Wyze options flow.""" + return OptionsFlowHandler() + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle an option flow for Wyze.""" + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Optional( + BULB_LOCAL_CONTROL, + default=self.config_entry.options.get( + BULB_LOCAL_CONTROL, DEFAULT_LOCAL_CONTROL + ), + ): bool + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/custom_components/wyzeapi/const.py b/custom_components/wyzeapi/const.py new file mode 100644 index 0000000..84bd7a7 --- /dev/null +++ b/custom_components/wyzeapi/const.py @@ -0,0 +1,28 @@ +"""Constants for the Wyze Home Assistant Integration integration.""" + +DOMAIN = "wyzeapi" +CONF_CLIENT = "wyzeapi_client" + +ACCESS_TOKEN = "access_token" +REFRESH_TOKEN = "refresh_token" +REFRESH_TIME = "refresh_time" +KEY_ID = "key_id" +API_KEY = "api_key" + +WYZE_NOTIFICATION_TOGGLE = f"{DOMAIN}.wyze.notification.toggle" + +LOCK_UPDATED = f"{DOMAIN}.lock_updated" +CAMERA_UPDATED = f"{DOMAIN}.camera_updated" +LIGHT_UPDATED = f"{DOMAIN}.light_updated" +COVER_UPDATED = f"{DOMAIN}.cover_updated" +RESET_BUTTON_PRESSED = f"{DOMAIN}.reset_button_pressed" +# EVENT NAMES +WYZE_CAMERA_EVENT = "wyze_camera_event" + +BULB_LOCAL_CONTROL = "bulb_local_control" +DEFAULT_LOCAL_CONTROL = True + +# Yunding (YD) is the provider for Wyze Lock Bolt +YDBLE_LOCK_STATE_UUID = "00002220-0000-6b63-6f6c-2e6b636f6f6c" +YDBLE_UART_RX_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e" +YDBLE_UART_TX_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e" diff --git a/custom_components/wyzeapi/coordinator.py b/custom_components/wyzeapi/coordinator.py new file mode 100644 index 0000000..4829b1b --- /dev/null +++ b/custom_components/wyzeapi/coordinator.py @@ -0,0 +1,196 @@ +import asyncio +import binascii +import logging +from datetime import datetime, timedelta +from typing import Dict + +from bleak import BleakClient +from bleak_retry_connector import establish_connection + +from homeassistant.components import bluetooth +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from wyzeapy.services.lock_service import LockService, Lock + +from .const import YDBLE_LOCK_STATE_UUID, YDBLE_UART_RX_UUID, YDBLE_UART_TX_UUID +from .token_manager import token_exception_handler +from .ydble_utils import ( + decrypt_ecb, + pack_l1, + pack_l2_dict, + pack_l2_lock_unlock, + parse_l1, + parse_l2_dict, +) + +_LOGGER = logging.getLogger(__name__) + + +class WyzeLockBoltCoordinator(DataUpdateCoordinator): + """Manages fetching data from BLE periodically.""" + + def __init__( + self, hass: HomeAssistant, lock_service: LockService, lock: Lock + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Wyze Lock State Updater", + update_interval=timedelta(seconds=300), + ) + self._lock_service = lock_service + self._lock = lock + # The `mac` in the original response should be UUID. + # The actual MAC address should be retrieved from another API. + self._uuid = lock.mac + self._mac = None + self._bleak_client = None + self._current_command = None + + @token_exception_handler + async def update_lock_info(self): + self._lock = await self._lock_service.update(self._lock) + mac = self._lock.raw_dict["hardware_info"]["mac"] + # The mac is stored reverse ordered and no colon, e.g. mac="ab8967452301" + self._mac = ":".join(mac[i - 2 : i] for i in range(12, 0, -2)).upper() + + async def _async_update_data(self): + """Fetch the latest data from BLE device.""" + # Skip if running a command + if self._current_command: + return self.data + + client = await self._get_ble_client() + if client is None: + raise UpdateFailed( + f"Could not find BLE device {self._lock.nickname} with address {self._mac}. Device may not be in range." + ) + + try: + value = await client.read_gatt_char(YDBLE_LOCK_STATE_UUID) + return self._parse_state(value) + finally: + await self._disconnect() + + async def lock_unlock(self, command="lock"): + if self._current_command: + self.async_update_listeners() + raise Exception(f"Waiting for {self._current_command} command to complete") + self._current_command = command + self.async_update_listeners() + client = await self._get_ble_client() + if client is None: + raise Exception( + f"Could not find BLE device {self._lock.nickname} with address {self._mac}. Device may not be in range." + ) + + # disconnect in 10 seconds in case of error + asyncio.create_task(self._disconnect(delay=10)) + + context = {"command": command, "stage": 0} + + async def _handle_uart_rx_context(sender, data): + await self._handle_uart_rx(sender, data, client, context) + + await client.start_notify(YDBLE_UART_RX_UUID, _handle_uart_rx_context) + await client.start_notify(YDBLE_LOCK_STATE_UUID, self._handle_state) + await self._request_challenge(client) + + async def _request_challenge(self, client: BleakClient): + l2_content = pack_l2_dict(0x91, 0, {10: b"\x27"}) + req = pack_l1(0, 1, l2_content) + await client.write_gatt_char(YDBLE_UART_TX_UUID, req, response=False) + + async def _send_lock_unlock(self, client: BleakClient, challenge, command): + l2_content = pack_l2_lock_unlock( + self._lock.ble_id, self._lock.ble_token, challenge, command + ) + req = pack_l1(0, 2, l2_content) + await client.write_gatt_char(YDBLE_UART_TX_UUID, req, response=False) + + async def _send_ack(self, client: BleakClient, seq_no: int): + req = pack_l1(0x08, seq_no, b"") + await client.write_gatt_char(YDBLE_UART_TX_UUID, req, response=False) + + async def _handle_state(self, sender, data: bytearray): + self.data = self._parse_state(data) + self._current_command = None + self.async_update_listeners() + + def _parse_state(self, state_data): + data = decrypt_ecb(self._uuid[-16:].lower(), state_data) + result = { + "state": data[0], + "timestamp": datetime.fromtimestamp(int.from_bytes(data[1:5])), + } + return result + + async def _handle_uart_rx( + self, sender, data: bytearray, client: BleakClient, context: Dict + ): + # Process for unfinished data + if "l1_unfinished" in context: + data = context["l1_unfinished"] + data + del context["l1_unfinished"] + l2_data, l1_flags, seq_no, remain = parse_l1(data) + if remain: + context["l1_unfinished"] = data + return + + # Process messages + if context["stage"] == 0: + # Ack for request chanllenge + if seq_no == 1 and l1_flags == 0x48: + context["stage"] = 1 + return + if context["stage"] == 1: + if l1_flags == 0x40: + # Process L2 dict + cmd, l2_flags, l2_dict = parse_l2_dict(l2_data) + if cmd == 0x86 and 0xD2 in l2_dict: + # Got generated chanllenge + challenge = l2_dict[0xD2] + await self._send_ack(client, seq_no=seq_no) + await self._send_lock_unlock(client, challenge, context["command"]) + context["stage"] = 2 + return + if context["stage"] == 2: + # Ack for send_lock_unlock + if seq_no == 2 and l1_flags == 0x48: + context["stage"] = 3 + return + if context["stage"] == 3: + if l1_flags == 0x40: + cmd, l2_flags, l2_dict = parse_l2_dict(l2_data) + if cmd == 0x04: + await self._send_ack(client, seq_no=seq_no) + return + _LOGGER.warning( + f"Unexpected message: stage={context['stage']}" + f" flags={l1_flags:01x}, seq_no={seq_no:02x}," + f" l2_data={binascii.hexlify(l2_data)}" + ) + + async def _get_ble_client(self) -> BleakClient | None: + if not self._bleak_client or not self._bleak_client.is_connected: + if not self._mac: + raise PlatformNotReady("Not initialized") + ble_device = bluetooth.async_ble_device_from_address( + self.hass, self._mac, connectable=True + ) + if ble_device is None: + return None + + self._bleak_client = await establish_connection( + BleakClient, ble_device, ble_device.address + ) + return self._bleak_client + + async def _disconnect(self, delay=0): + await asyncio.sleep(delay) + if self._bleak_client and self._bleak_client.is_connected: + await self._bleak_client.disconnect() + self._current_command = None + self.async_update_listeners() diff --git a/custom_components/wyzeapi/cover.py b/custom_components/wyzeapi/cover.py new file mode 100644 index 0000000..eea8e7e --- /dev/null +++ b/custom_components/wyzeapi/cover.py @@ -0,0 +1,152 @@ +"""Platform for the cover integration.""" + +from abc import ABC +import logging +from typing import Any, Callable, List + +from wyzeapy import Wyzeapy, CameraService +from wyzeapy.services.camera_service import Camera +from wyzeapy.exceptions import AccessTokenError, ParameterError, UnknownApiError +from wyzeapy.types import DeviceTypes + +import homeassistant.components.cover +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers import device_registry as dr +from homeassistant.exceptions import HomeAssistantError +from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature + + +from .const import CAMERA_UPDATED, CONF_CLIENT, DOMAIN +from .token_manager import token_exception_handler + +_LOGGER = logging.getLogger(__name__) +ATTRIBUTION = "Data provided by Wyze" + + +@token_exception_handler +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: Callable[[List[Any], bool], None], +) -> None: + """ + This function sets up the config_entry + + :param hass: Home Assistant instance + :param config_entry: The current config_entry + :param async_add_entities: This function adds entities to the config_entry + :return: + """ + + _LOGGER.debug("""Creating new WyzeApi cover component""") + client: Wyzeapy = hass.data[DOMAIN][config_entry.entry_id][CONF_CLIENT] + camera_service = await client.camera_service + cameras: List[Camera] = await camera_service.get_cameras() + garages = [] + for camera in cameras: + if camera.device_params["dongle_product_model"] == "HL_CGDC": + garages.append(WyzeGarageDoor(camera_service, camera)) + + async_add_entities(garages, True) + + +class WyzeGarageDoor(homeassistant.components.cover.CoverEntity, ABC): + """Representation of a Wyze Garage Door.""" + + _attr_device_class = CoverDeviceClass.GARAGE + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + _attr_has_entity_name = True + + def __init__(self, camera_service: CameraService, camera: Camera): + """Initialize a Wyze garage door.""" + self._camera = camera + if self._camera.type not in [DeviceTypes.CAMERA]: + raise HomeAssistantError(f"Invalid device type: {self._camera.type}") + + self._camera_service = camera_service + self._available = self._camera.available + + @property + def device_info(self): + """Return device information about this entity.""" + return { + "identifiers": {(DOMAIN, self._camera.mac)}, + "name": f"{self._camera.nickname}", + "connections": {(dr.CONNECTION_NETWORK_MAC, self._camera.mac)}, + } + + @property + def extra_state_attributes(self): + """Return device attributes of the entity.""" + return { + ATTR_ATTRIBUTION: ATTRIBUTION, + "device model": f"{self._camera.product_model}.{self._camera.device_params['dongle_product_model']}", + } + + @property + def should_poll(self) -> bool: + return False + + @token_exception_handler + async def async_open_cover(self, **kwargs): + """Open the cover.""" + try: + await self._camera_service.garage_door_open(self._camera) + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except Exception as err: + raise HomeAssistantError(err) from err + else: + self._camera.garage = True + self.async_write_ha_state() + + @token_exception_handler + async def async_close_cover(self, **kwargs): + """Close the cover.""" + try: + await self._camera_service.garage_door_close(self._camera) + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except Exception as err: + raise HomeAssistantError(err) from err + else: + self._camera.garage = False + self.async_write_ha_state() + + @property + def is_closed(self): + """Return if the cover is closed.""" + return not self._camera.garage + + @property + def available(self): + """Return the connection status of this cover.""" + return self._camera.available + + @property + def unique_id(self): + """Define a unique id for this entity.""" + return f"{self._camera.mac}_GarageDoor" + + @property + def name(self): + """Return the name of the garage door.""" + return "Garage Door" + + async def async_added_to_hass(self) -> None: + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{CAMERA_UPDATED}-{self._camera.mac}", + self.handle_camera_update, + ) + ) + + @callback + def handle_camera_update(self, camera: Camera) -> None: + """Update the cover whenever there is an update""" + self._camera = camera + self.async_write_ha_state() diff --git a/custom_components/wyzeapi/light.py b/custom_components/wyzeapi/light.py new file mode 100644 index 0000000..caa3258 --- /dev/null +++ b/custom_components/wyzeapi/light.py @@ -0,0 +1,493 @@ +"""Platform for light integration.""" + +from collections.abc import Callable +from datetime import timedelta +import logging +from typing import Any + +from aiohttp.client_exceptions import ClientConnectionError +from wyzeapy import BulbService, CameraService, Wyzeapy +from wyzeapy.exceptions import AccessTokenError, ParameterError, UnknownApiError +from wyzeapy.services.bulb_service import Bulb +from wyzeapy.services.camera_service import Camera +from wyzeapy.types import DeviceTypes, PropertyIDs +from wyzeapy.utils import create_pid_pair + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_EFFECT, + ATTR_HS_COLOR, + ColorMode, + LightEntity, + LightEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +import homeassistant.util.color as color_util + +from .const import ( + BULB_LOCAL_CONTROL, + CAMERA_UPDATED, + CONF_CLIENT, + DOMAIN, + LIGHT_UPDATED, +) +from .token_manager import token_exception_handler + +_LOGGER = logging.getLogger(__name__) +ATTRIBUTION = "Data provided by Wyze" +SCAN_INTERVAL = timedelta(seconds=30) +EFFECT_MODE = "effects mode" +EFFECT_SUN_MATCH = "sun match" +EFFECT_SHADOW = "shadow" +EFFECT_LEAP = "leap" +EFFECT_FLICKER = "flicker" + + +@token_exception_handler +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: Callable[[list[Any], bool], None], +) -> None: + """Set up the entities in the config entry.""" + + _LOGGER.debug("""Creating new WyzeApi light component""") + client: Wyzeapy = hass.data[DOMAIN][config_entry.entry_id][CONF_CLIENT] + camera_service = await client.camera_service + + bulb_service = await client.bulb_service + + lights = [ + WyzeLight(bulb_service, light, config_entry) + for light in await bulb_service.get_bulbs() + ] + + for camera in await camera_service.get_cameras(): + if camera.product_model == "HL_BC": + # Wyze Bulb Cam has integrated light + lights.append(WyzeCamerafloodlight(camera, camera_service, "bulbcam")) + elif ( + camera.product_model == "WYZE_CAKP2JFUS" + and camera.device_params["dongle_product_model"] == "HL_CFL" + or camera.product_model in ("LD_CFP", "HL_CFL2") # Floodlight v2 + ): + lights.append(WyzeCamerafloodlight(camera, camera_service, "floodlight")) + + elif ( + camera.product_model in ("WYZE_CAKP2JFUS", "HL_CAM4") + ) and camera.device_params[ + "dongle_product_model" + ] == "HL_CAM3SS": # Cam v3 with lamp socket accessory + lights.append(WyzeCamerafloodlight(camera, camera_service, "lampsocket")) + + elif ( + camera.product_model == "AN_RSCW" + ): # Battery cam pro (integrated spotlight) + lights.append(WyzeCamerafloodlight(camera, camera_service, "spotlight")) + + async_add_entities(lights, True) + + +class WyzeLight(LightEntity): + """Representation of a Wyze Bulb.""" + + _just_updated = False + _attr_should_poll = False + + def __init__(self, bulb_service: BulbService, bulb: Bulb, config_entry) -> None: + """Initialize a Wyze Bulb.""" + self._bulb = bulb + self._device_type = DeviceTypes(self._bulb.product_type) + self._config_entry = config_entry + self._local_control = config_entry.options.get(BULB_LOCAL_CONTROL) + if self._device_type not in [ + DeviceTypes.LIGHT, + DeviceTypes.MESH_LIGHT, + DeviceTypes.LIGHTSTRIP, + ]: + raise AttributeError("Device type not supported") + + self._bulb_service = bulb_service + self._attr_min_color_temp_kelvin = ( + 1800 + if self._device_type in [DeviceTypes.MESH_LIGHT, DeviceTypes.LIGHTSTRIP] + else 2700 + ) + self._attr_max_color_temp_kelvin = 6500 + self._attr_name = self._bulb.nickname + self._attr_unique_id = self._bulb.mac + self._attr_supported_features = LightEntityFeature.EFFECT + + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(DOMAIN, self._bulb.mac)}, + "name": self._bulb.nickname, + "connections": { + ( + dr.CONNECTION_NETWORK_MAC, + self._bulb.mac, + ) + }, + "manufacturer": "WyzeLabs", + "model": self._bulb.product_model, + } + + @token_exception_handler + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + options = [] + self._local_control = self._config_entry.options.get(BULB_LOCAL_CONTROL) + + if kwargs.get(ATTR_BRIGHTNESS) is not None: + brightness = round((kwargs.get(ATTR_BRIGHTNESS) / 255) * 100) + + options.append(create_pid_pair(PropertyIDs.BRIGHTNESS, str(brightness))) + + _LOGGER.debug("Setting brightness to %s", brightness) + _LOGGER.debug("Options: %s", options) + + self._bulb.brightness = brightness + + if ( + self._bulb.sun_match + ): # Turn off sun match if we're changing anything other than brightness + if any([kwargs.get(ATTR_COLOR_TEMP_KELVIN, kwargs.get(ATTR_HS_COLOR))]): + options.append(create_pid_pair(PropertyIDs.SUN_MATCH, str(0))) + self._bulb.sun_match = False + _LOGGER.debug("Turning off sun match") + + if kwargs.get(ATTR_COLOR_TEMP_KELVIN) is not None: + _LOGGER.debug("Setting color temp") + color_temp = kwargs.get(ATTR_COLOR_TEMP_KELVIN) + + options.append(create_pid_pair(PropertyIDs.COLOR_TEMP, str(color_temp))) + + if self._device_type in [DeviceTypes.MESH_LIGHT, DeviceTypes.LIGHTSTRIP]: + options.append( + create_pid_pair(PropertyIDs.COLOR_MODE, str(2)) + ) # Put bulb in White Mode + self._bulb.color_mode = "2" + + self._bulb.color_temp = color_temp + self._bulb.color = color_util.color_rgb_to_hex( + *color_util.color_temperature_to_rgb(color_temp) + ) + + if kwargs.get(ATTR_HS_COLOR) is not None and ( + self._device_type is DeviceTypes.MESH_LIGHT + or self._device_type is DeviceTypes.LIGHTSTRIP + ): + _LOGGER.debug("Setting color") + color = color_util.color_rgb_to_hex( + *color_util.color_hs_to_RGB(*kwargs.get(ATTR_HS_COLOR)) + ) + + options.extend( + [ + create_pid_pair(PropertyIDs.COLOR, str(color)), + create_pid_pair( + PropertyIDs.COLOR_MODE, str(1) + ), # Put bulb in Color Mode + ] + ) + + self._bulb.color = color + self._bulb.color_mode = "1" + + if kwargs.get(ATTR_EFFECT) is not None: + if kwargs.get(ATTR_EFFECT) == EFFECT_SUN_MATCH: + _LOGGER.debug("Setting Sun Match") + options.append(create_pid_pair(PropertyIDs.SUN_MATCH, str(1))) + self._bulb.sun_match = True + else: + if ( + self._bulb.type is DeviceTypes.MESH_LIGHT + ): # Handle mesh light effects + self._local_control = False + options.append(create_pid_pair(PropertyIDs.COLOR_MODE, str(3))) + self._bulb.color_mode = "3" + if kwargs.get(ATTR_EFFECT) == EFFECT_SHADOW: + _LOGGER.debug("Setting Shadow Effect") + options.append( + create_pid_pair(PropertyIDs.LIGHTSTRIP_EFFECTS, str(1)) + ) + self._bulb.effects = "1" + elif kwargs.get(ATTR_EFFECT) == EFFECT_LEAP: + _LOGGER.debug("Setting Leap Effect") + options.append( + create_pid_pair(PropertyIDs.LIGHTSTRIP_EFFECTS, str(2)) + ) + self._bulb.effects = "2" + elif kwargs.get(ATTR_EFFECT) == EFFECT_FLICKER: + _LOGGER.debug("Setting Flicker Effect") + options.append( + create_pid_pair(PropertyIDs.LIGHTSTRIP_EFFECTS, str(3)) + ) + self._bulb.effects = "3" + + _LOGGER.debug("Turning on light") + try: + await self._bulb_service.turn_on(self._bulb, self._local_control, options) + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._bulb.on = True + self._just_updated = True + self.async_schedule_update_ha_state() + + @token_exception_handler + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + self._local_control = self._config_entry.options.get(BULB_LOCAL_CONTROL) + try: + await self._bulb_service.turn_off(self._bulb, self._local_control) + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._bulb.on = False + self._just_updated = True + self.async_schedule_update_ha_state() + + @property + def supported_color_modes(self): + """Return the supported color modes.""" + if self._bulb.type in [DeviceTypes.MESH_LIGHT, DeviceTypes.LIGHTSTRIP]: + return {ColorMode.COLOR_TEMP, ColorMode.HS} + return {ColorMode.COLOR_TEMP} + + @property + def color_mode(self): + """Return the current color mode.""" + if self._bulb.type is DeviceTypes.LIGHT: + return ColorMode.COLOR_TEMP + return ColorMode.COLOR_TEMP if self._bulb.color_mode == "2" else ColorMode.HS + + @property + def available(self): + """Return the connection status of this light.""" + return self._bulb.available + + @property + def hs_color(self): + """Return the HS color.""" + return color_util.color_RGB_to_hs( + *color_util.rgb_hex_to_rgb_list(self._bulb.color) + ) + + @property + def extra_state_attributes(self): + """Return device attributes of the entity.""" + dev_info = {} + + # noinspection DuplicatedCode + if self._bulb.device_params.get("ip"): + dev_info["IP"] = str(self._bulb.device_params.get("ip")) + if self._bulb.device_params.get("rssi"): + dev_info["RSSI"] = str(self._bulb.device_params.get("rssi")) + if self._bulb.device_params.get("ssid"): + dev_info["SSID"] = str(self._bulb.device_params.get("ssid")) + dev_info["Sun Match"] = self._bulb.sun_match + dev_info["local_control"] = ( + self._local_control and not self._bulb.cloud_fallback + ) + + if self._device_type is DeviceTypes.LIGHTSTRIP and self._bulb.color_mode == "3": + if self._bulb.effects == "1": + dev_info["effect_mode"] = "Shadow" + elif self._bulb.effects == "2": + dev_info["effect_mode"] = "Leap" + elif self._bulb.effects == "3": + dev_info["effect_mode"] = "Flicker" + + if ( + self._device_type is DeviceTypes.MESH_LIGHT + or self._device_type is DeviceTypes.LIGHTSTRIP + ): + if self._bulb.color_mode == "1": + dev_info["mode"] = "Color" + elif self._bulb.color_mode == "2": + dev_info["mode"] = "White" + elif self._bulb.color_mode == "3": + dev_info["mode"] = "Effect" + + return dev_info + + @property + def brightness(self): + """Return the brightness of the light.""" + return round(self._bulb.brightness * 2.55, 1) + + @property + def color_temp_kelvin(self): + """Return the color temp in Kelvin.""" + return self._bulb.color_temp + + @property + def effect_list(self): + """Return the list of effects.""" + if self._device_type is DeviceTypes.LIGHTSTRIP: + return [EFFECT_SHADOW, EFFECT_LEAP, EFFECT_FLICKER, EFFECT_SUN_MATCH] + return [EFFECT_SUN_MATCH] + + @property + def is_on(self): + """Return true if light is on.""" + return self._bulb.on + + @token_exception_handler + async def async_update(self): + """Update the lock to be up to date with the Wyze Servers.""" + if not self._just_updated: + self._bulb = await self._bulb_service.update(self._bulb) + else: + self._just_updated = False + + @callback + def async_update_callback(self, bulb: Bulb): + """Update the bulb's state.""" + self._bulb = bulb + self._local_control = self._config_entry.options.get(BULB_LOCAL_CONTROL) + async_dispatcher_send(self.hass, f"{LIGHT_UPDATED}-{self._bulb.mac}", bulb) + self.async_schedule_update_ha_state() + + async def async_added_to_hass(self) -> None: + """Subscribe to update events.""" + self._bulb.callback_function = self.async_update_callback + self._bulb_service.register_updater(self._bulb, 30) + await self._bulb_service.start_update_manager() + return await super().async_added_to_hass() + + async def async_will_remove_from_hass(self) -> None: + """Unregister the updater.""" + self._bulb_service.unregister_updater(self._bulb) + + +class WyzeCamerafloodlight(LightEntity): + """Representation of a Wyze Camera floodlight.""" + + _available: bool + _just_updated = False + _attr_should_poll = False + + def __init__( + self, camera: Camera, camera_service: CameraService, light_type: str + ) -> None: + """Initialize the camera floodlight.""" + self._device = camera + self._service = camera_service + self._light_type = light_type + self._attr_unique_id = f"{self._device.mac}-{self._light_type}" + self._is_on = self._device.floodlight + + @token_exception_handler + async def async_turn_on(self, **kwargs) -> None: + """Turn the floodlight on.""" + try: + await self._service.floodlight_on(self._device) + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._is_on = True + self._just_updated = True + self.async_schedule_update_ha_state() + + @token_exception_handler + async def async_turn_off(self, **kwargs): + """Turn the floodlight off.""" + try: + await self._service.floodlight_off(self._device) + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._is_on = False + self._just_updated = True + self.async_schedule_update_ha_state() + + @property + def is_on(self): + """Return true if floodlight is on.""" + return self._device.floodlight + + @property + def name(self) -> str: + """Return the device name.""" + light_type_names = { + "lampsocket": "Lamp Socket", + "floodlight": "Floodlight", + "spotlight": "Spotlight", + "bulbcam": "Light", + } + return ( + f"{self._device.nickname} {light_type_names.get(self._light_type, 'Light')}" + ) + + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(DOMAIN, self._device.mac)}, + "name": self._device.nickname, + "connections": { + ( + dr.CONNECTION_NETWORK_MAC, + self._device.mac, + ) + }, + "manufacturer": "WyzeLabs", + "model": self._device.product_model, + } + + @callback + def handle_camera_update(self, camera: Camera) -> None: + """Update the camera object whenever there is an update.""" + self._device = camera + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Add listener on startup.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{CAMERA_UPDATED}-{self._device.mac}", + self.handle_camera_update, + ) + ) + + @property + def icon(self): + """Return the icon to use in the frontend.""" + icons = { + "lampsocket": "mdi:lightbulb", + "floodlight": "mdi:track-light", + "spotlight": "mdi:spotlight", + "bulbcam": "mdi:lightbulb", + } + return icons.get(self._light_type, "mdi:lightbulb") + + @property + def color_mode(self): + """Return the color mode.""" + return ColorMode.ONOFF + + @property + def supported_color_modes(self): + """Return the supported color mode.""" + return ColorMode.ONOFF diff --git a/custom_components/wyzeapi/lock.py b/custom_components/wyzeapi/lock.py new file mode 100644 index 0000000..afe4b98 --- /dev/null +++ b/custom_components/wyzeapi/lock.py @@ -0,0 +1,270 @@ +#!/usr/bin/python3 + +"""Platform for light integration.""" + +from abc import ABC +from datetime import timedelta +import logging +from typing import Any, Callable, List +from aiohttp.client_exceptions import ClientConnectionError + +from wyzeapy import LockService, Wyzeapy +from wyzeapy.services.lock_service import Lock +from wyzeapy.types import DeviceTypes +from wyzeapy.exceptions import AccessTokenError, ParameterError, UnknownApiError + +import homeassistant.components.lock +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers import device_registry as dr +from homeassistant.exceptions import HomeAssistantError + +from .const import CONF_CLIENT, DOMAIN, LOCK_UPDATED +from .token_manager import token_exception_handler + +_LOGGER = logging.getLogger(__name__) +ATTRIBUTION = "Data provided by Wyze" +SCAN_INTERVAL = timedelta(seconds=10) +MAX_OUT_OF_SYNC_COUNT = 5 + + +@token_exception_handler +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: Callable[[List[Any], bool], None], +) -> None: + """ + This function sets up the config_entry + + :param hass: Home Assistant instance + :param config_entry: The current config_entry + :param async_add_entities: This function adds entities to the config_entry + :return: + """ + + _LOGGER.debug("""Creating new WyzeApi lock component""") + client: Wyzeapy = hass.data[DOMAIN][config_entry.entry_id][CONF_CLIENT] + lock_service = await client.lock_service + + all_locks = await lock_service.get_locks() + + locks = [ + WyzeLock(lock_service, lock) + for lock in all_locks + if lock.product_model != "YD_BT1" + ] + lock_bolts = [] + coordinators = hass.data[DOMAIN][config_entry.entry_id].get("coordinators", {}) + for lock in all_locks: + if lock.product_model == "YD_BT1": + coordinator = coordinators.get(lock.mac) + if coordinator is None: + _LOGGER.warning( + "No coordinator found for Lock Bolt %s (%s), skipping", + lock.nickname, + lock.mac, + ) + continue + lock_bolts.append(WyzeLockBolt(coordinator)) + + async_add_entities(locks + lock_bolts, True) + + +class WyzeLock(homeassistant.components.lock.LockEntity, ABC): + """Representation of a Wyze Lock.""" + + def __init__(self, lock_service: LockService, lock: Lock): + """Initialize a Wyze lock.""" + self._lock = lock + if self._lock.type not in [DeviceTypes.LOCK]: + raise AttributeError("Device type not supported") + + self._lock_service = lock_service + + self._out_of_sync_count = 0 + + @property + def device_info(self): + return { + "identifiers": {(DOMAIN, self._lock.mac)}, + "name": self._lock.nickname, + "connections": { + ( + dr.CONNECTION_NETWORK_MAC, + self._lock.mac, + ) + }, + "manufacturer": "WyzeLabs", + "model": self._lock.product_model, + } + + def lock(self, **kwargs): + raise NotImplementedError + + def unlock(self, **kwargs): + raise NotImplementedError + + @property + def should_poll(self) -> bool: + return False + + @token_exception_handler + async def async_lock(self, **kwargs): + _LOGGER.debug("Turning on lock") + try: + await self._lock_service.lock(self._lock) + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._lock.unlocked = False + self.async_schedule_update_ha_state() + + @token_exception_handler + async def async_unlock(self, **kwargs): + try: + await self._lock_service.unlock(self._lock) + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._lock.unlocked = True + self.async_schedule_update_ha_state() + + @property + def is_locked(self): + return not self._lock.unlocked + + @property + def name(self): + """Return the display name of this lock.""" + return self._lock.nickname + + @property + def unique_id(self): + return self._lock.mac + + @property + def available(self): + """Return the connection status of this lock""" + return self._lock.available + + @property + def extra_state_attributes(self): + """Return device attributes of the entity.""" + dev_info = { + ATTR_ATTRIBUTION: ATTRIBUTION, + "door_open": self._lock.door_open, + } + + # Add the lock battery value if it exists + if self._lock.raw_dict.get("power"): + dev_info["lock_battery"] = str(self._lock.raw_dict.get("power")) + + # Add the keypad's battery value if it exists + if self._lock.raw_dict.get("keypad", {}).get("power"): + dev_info["keypad_battery"] = str( + self._lock.raw_dict.get("keypad", {}).get("power") + ) + + return dev_info + + @property + def supported_features(self): + return None + + @token_exception_handler + async def async_update(self): + """ + This function updates the entity + """ + lock = await self._lock_service.update(self._lock) + if ( + lock.unlocked == self._lock.unlocked + or self._out_of_sync_count >= MAX_OUT_OF_SYNC_COUNT + ): + self._lock = lock + self._out_of_sync_count = 0 + else: + self._out_of_sync_count += 1 + + @callback + def async_update_callback(self, lock: Lock): + """Update the switch's state.""" + self._lock = lock + async_dispatcher_send( + self.hass, + f"{LOCK_UPDATED}-{self._lock.mac}", + lock, + ) + self.async_schedule_update_ha_state() + + async def async_added_to_hass(self) -> None: + """Subscribe to update events.""" + self._lock.callback_function = self.async_update_callback + self._lock_service.register_updater(self._lock, 10) + await self._lock_service.start_update_manager() + return await super().async_added_to_hass() + + async def async_will_remove_from_hass(self) -> None: + self._lock_service.unregister_updater(self._lock) + + +class WyzeLockBolt(CoordinatorEntity, homeassistant.components.lock.LockEntity): + def __init__(self, coordinator): + super().__init__(coordinator) + self._lock = coordinator._lock + + @property + def name(self): + """Return the display name of this lock.""" + return self._lock.nickname + + @property + def unique_id(self): + return self._lock.mac + + @property + def device_info(self): + return { + "identifiers": {(DOMAIN, self._lock.mac)}, + "name": self._lock.nickname, + "connections": { + ( + dr.CONNECTION_NETWORK_MAC, + self.coordinator._mac, + ), + ("uuid", self.coordinator._uuid), + ("serial_number", self._lock.raw_dict["hardware_info"]["sn"]), + }, + "manufacturer": "WyzeLabs", + "model": self._lock.product_model, + } + + @property + def is_locked(self): + return self.coordinator.data["state"] == 1 + + async def async_lock(self, **kwargs): + return await self.coordinator.lock_unlock(command="lock") + + async def async_unlock(self, **kwargs): + return await self.coordinator.lock_unlock(command="unlock") + + @property + def is_locking(self, **kwargs): + return self.coordinator._current_command == "lock" + + @property + def is_unlocking(self, **kwargs): + return self.coordinator._current_command == "unlock" + + @property + def state_attributes(self): + return {"last_operated": self.coordinator.data["timestamp"]} diff --git a/custom_components/wyzeapi/manifest.json b/custom_components/wyzeapi/manifest.json new file mode 100644 index 0000000..4ab3d47 --- /dev/null +++ b/custom_components/wyzeapi/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "wyzeapi", + "name": "Wyze", + "codeowners": [ + "@SecKatie" + ], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://github.com/SecKatie/ha-wyzeapi#readme", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/SecKatie/ha-wyzeapi/issues", + "loggers": ["custom_components.wyzeapi"], + "requirements": [ + "wyzeapy>=0.5.33,<0.6", + "websockets" + ], + "version": "0.1.37" +} diff --git a/custom_components/wyzeapi/number.py b/custom_components/wyzeapi/number.py new file mode 100644 index 0000000..edb8bcb --- /dev/null +++ b/custom_components/wyzeapi/number.py @@ -0,0 +1,150 @@ +"""Platform for number integration.""" + +import logging +from typing import Any, Callable, List + +from homeassistant.components.number import RestoreNumber +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers import device_registry as dr +from wyzeapy import Wyzeapy +from wyzeapy.services.irrigation_service import IrrigationService, Irrigation, Zone + +from .const import DOMAIN, CONF_CLIENT +from .token_manager import token_exception_handler + +_LOGGER = logging.getLogger(__name__) + + +@token_exception_handler +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: Callable[[List[Any], bool], None], +) -> None: + """Set up the WyzeApi number platform.""" + _LOGGER.debug("Creating new WyzeApi number component") + client: Wyzeapy = hass.data[DOMAIN][config_entry.entry_id][CONF_CLIENT] + irrigation_service = await client.irrigation_service + + # Get all irrigation devices + irrigation_devices = await irrigation_service.get_irrigations() + + # Create a number entity for each zone in each irrigation device + entities = [] + for device in irrigation_devices: + # Update the device to get its zones + device = await irrigation_service.update(device) + for zone in device.zones: + if zone.enabled: + entities.append( + WyzeIrrigationQuickrunDuration(irrigation_service, device, zone) + ) + + async_add_entities(entities, True) + + +class WyzeIrrigationQuickrunDuration(RestoreNumber): + """Representation of a Wyze Irrigation Zone Quickrun Duration.""" + + _attr_has_entity_name = True + + def __init__( + self, irrigation_service: IrrigationService, irrigation: Irrigation, zone: Zone + ) -> None: + """Initialize the irrigation zone quickrun duration.""" + self._irrigation_service = irrigation_service + self._device = irrigation + self._zone = zone + + @property + def name(self) -> str: + """Return the name of the zone quickrun duration.""" + return f"{self._zone.name}" + + @property + def unique_id(self) -> str: + """Return a unique ID for the zone quickrun duration.""" + return f"{self._device.mac}-zone-{self._zone.zone_number}-quickrun-duration" + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this entity.""" + return DeviceInfo( + identifiers={(DOMAIN, self._device.mac)}, + name=self._device.nickname, + manufacturer="WyzeLabs", + model=self._device.product_model, + serial_number=self._device.sn, + connections={(dr.CONNECTION_NETWORK_MAC, self._device.mac)}, + ) + + @property + def native_value(self) -> float: + """Return the current value in minutes.""" + return float(self._zone.quickrun_duration) / 60.0 + + @property + def native_min_value(self) -> float: + """Return the minimum value in minutes.""" + return 1.0 # 1 minute + + @property + def native_max_value(self) -> float: + """Return the maximum value in minutes.""" + return 180.0 # 3 hours in minutes + + @property + def native_step(self) -> float: + """Return the step value in minutes.""" + return 1.0 # 1 minute steps + + @property + def mode(self) -> str: + """Return the mode of the number entity.""" + return "box" + + @property + def native_unit_of_measurement(self) -> str: + """Return the unit of measurement.""" + return "min" + + @property + def icon(self) -> str: + """Return the icon for the quickrun duration number.""" + return "mdi:timer" + + async def async_set_native_value(self, value: float) -> None: + """Set the value in minutes.""" + # Convert minutes to seconds for the API + seconds = int(value * 60) + await self._irrigation_service.set_zone_quickrun_duration( + self._device, self._zone.zone_number, seconds + ) + self._zone.quickrun_duration = seconds + self.async_write_ha_state() + + async def _async_load_value(self) -> None: + """Load the value from Home Assistant state or update from irrigation service.""" + # Try to get the last number data from Home Assistant + state = await self.async_get_last_number_data() + if state and state.native_value is not None: + try: + # Convert minutes to seconds for storage + self._zone.quickrun_duration = int(state.native_value * 60) + return + except (ValueError, TypeError): + pass + + # If no valid state exists, update from irrigation service + self._device = await self._irrigation_service.update(self._device) + for zone in self._device.zones: + if zone.zone_number == self._zone.zone_number: + self._zone = zone + break + + async def async_added_to_hass(self) -> None: + """Subscribe to updates.""" + await self._async_load_value() + return await super().async_added_to_hass() diff --git a/custom_components/wyzeapi/sensor.py b/custom_components/wyzeapi/sensor.py new file mode 100644 index 0000000..bb954e9 --- /dev/null +++ b/custom_components/wyzeapi/sensor.py @@ -0,0 +1,626 @@ +"""Platform for sensor integration.""" + +from collections.abc import Callable +import datetime +import json +import logging +from typing import Any + +from wyzeapy import Wyzeapy +from wyzeapy.services.camera_service import Camera +from wyzeapy.services.irrigation_service import Irrigation, IrrigationService +from wyzeapy.services.lock_service import Lock +from wyzeapy.services.switch_service import Switch, SwitchUsageService + +from homeassistant.components.sensor import ( + RestoreSensor, + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_ATTRIBUTION, + PERCENTAGE, + EntityCategory, + UnitOfEnergy, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo +import homeassistant.helpers.entity_registry as er +from homeassistant.helpers.event import ( + async_track_state_change_event, + async_track_time_change, +) + +from .const import ( + CAMERA_UPDATED, + CONF_CLIENT, + DOMAIN, + LOCK_UPDATED, + RESET_BUTTON_PRESSED, +) +from .token_manager import token_exception_handler + +_LOGGER = logging.getLogger(__name__) +ATTRIBUTION = "Data provided by Wyze" +CAMERAS_WITH_BATTERIES = ["WVOD1", "HL_WCO2", "AN_RSCW", "GW_BE1"] +OUTDOOR_PLUGS = ["WLPPO"] + + +@token_exception_handler +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: Callable[[list[Any], bool], None], +) -> None: + """This function sets up the config_entry. + + :param hass: Home Assistant instance + :param config_entry: The current config_entry + :param async_add_entities: This function adds entities to the config_entry + :return: + """ + _LOGGER.debug("""Creating new WyzeApi sensor component""") + client: Wyzeapy = hass.data[DOMAIN][config_entry.entry_id][CONF_CLIENT] + + # Get the list of locks so that we can create lock and keypad battery sensors + lock_service = await client.lock_service + camera_service = await client.camera_service + switch_usage_service = await client.switch_usage_service + irrigation_service = await client.irrigation_service + + locks = await lock_service.get_locks() + sensors = [] + for lock in locks: + sensors.append(WyzeLockBatterySensor(lock, WyzeLockBatterySensor.LOCK_BATTERY)) + sensors.append( + WyzeLockBatterySensor(lock, WyzeLockBatterySensor.KEYPAD_BATTERY) + ) + + cameras = await camera_service.get_cameras() + sensors.extend( + [ + WyzeCameraBatterySensor(camera) + for camera in cameras + if camera.product_model in CAMERAS_WITH_BATTERIES + ] + ) + + plugs = await switch_usage_service.get_switches() + for plug in plugs: + if plug.product_model in OUTDOOR_PLUGS: + sensors.append(WyzePlugEnergySensor(plug, switch_usage_service)) + sensors.append(WyzePlugDailyEnergySensor(plug)) + + # Get all irrigation devices + irrigation_devices = await irrigation_service.get_irrigations() + + # Create sensor entities for each irrigation device + for device in irrigation_devices: + # Update the device to get its properties + device = await irrigation_service.update(device) + sensors.extend( + [ + WyzeIrrigationRSSI(irrigation_service, device), + WyzeIrrigationIP(irrigation_service, device), + WyzeIrrigationSSID(irrigation_service, device), + ] + ) + + async_add_entities(sensors, True) + + +class WyzeLockBatterySensor(SensorEntity): + """Representation of a Wyze Lock or Lock Keypad Battery.""" + + @property + def enabled(self): + """Return if the sensor is enabled.""" + return self._enabled + + LOCK_BATTERY = "lock_battery" + KEYPAD_BATTERY = "keypad_battery" + + _attr_device_class = SensorDeviceClass.BATTERY + _attr_native_unit_of_measurement = PERCENTAGE + _attr_should_poll = False + + def __init__(self, lock, battery_type) -> None: + """Initialize the sensor.""" + self._enabled = None + self._lock = lock + self._battery_type = battery_type + # make the battery unavailable by default, this will be toggled after the first update from the battery entity that + # has battery data. + self._available = False + + @callback + def handle_lock_update(self, lock: Lock) -> None: + """Helper function to Enable lock when Keypad has a battery. + + Make it avaliable when either the lock battery or keypad battery exists. + """ + self._lock = lock + if self._lock.raw_dict.get("power") and self._battery_type == self.LOCK_BATTERY: + self._available = True + if ( + self._lock.raw_dict.get("keypad", {}).get("power") + and self._battery_type == self.KEYPAD_BATTERY + ): + if self.enabled is False: + self.enabled = True + self._available = True + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Add listener on startup.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{LOCK_UPDATED}-{self._lock.mac}", + self.handle_lock_update, + ) + ) + + @property + def name(self) -> str: + """Name of the Sensor.""" + battery_type = self._battery_type.replace("_", " ").title() + return f"{self._lock.nickname} {battery_type}" + + @property + def unique_id(self): + """Unique ID of the sensor.""" + return f"{self._lock.nickname}.{self._battery_type}" + + @property + def available(self) -> bool: + """Return if the sensor is available.""" + return self._available + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled.""" + if self._battery_type == self.KEYPAD_BATTERY: + # The keypad battery may not be available if the lock has no keypad + return False + # The battery voltage will always be available for the lock + return True + + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(DOMAIN, self._lock.mac)}, + "connections": { + ( + dr.CONNECTION_NETWORK_MAC, + self._lock.mac, + ) + }, + "name": f"{self._lock.nickname}.{self._battery_type}", + } + + @property + def extra_state_attributes(self): + """Return device attributes of the entity.""" + return { + ATTR_ATTRIBUTION: ATTRIBUTION, + "device model": f"{self._lock.product_model}.{self._battery_type}", + } + + @property + def native_value(self): + """Return the state of the device.""" + if self._battery_type == self.LOCK_BATTERY: + return str(self._lock.raw_dict.get("power")) + if self._battery_type == self.KEYPAD_BATTERY: + return str(self._lock.raw_dict.get("keypad", {}).get("power")) + return 0 + + @enabled.setter + def enabled(self, value): + self._enabled = value + + +class WyzeCameraBatterySensor(SensorEntity): + """Representation of a Wyze Camera Battery.""" + + _attr_device_class = SensorDeviceClass.BATTERY + _attr_native_unit_of_measurement = PERCENTAGE + _attr_should_poll = False + + def __init__(self, camera) -> None: + """Initialize the sensor.""" + self._camera = camera + + @callback + def handle_camera_update(self, camera: Camera) -> None: + """Handle camera updates.""" + self._camera = camera + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Add listener on startup.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{CAMERA_UPDATED}-{self._camera.mac}", + self.handle_camera_update, + ) + ) + + @property + def name(self) -> str: + """Return the entity name.""" + return f"{self._camera.nickname} Battery" + + @property + def unique_id(self): + """Unique ID of the sensor.""" + return f"{self._camera.nickname}.battery" + + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(DOMAIN, self._camera.mac)}, + "connections": { + ( + dr.CONNECTION_NETWORK_MAC, + self._camera.mac, + ) + }, + "name": self._camera.nickname, + "model": self._camera.product_model, + "manufacturer": "WyzeLabs", + } + + @property + def extra_state_attributes(self): + """Return device attributes of the entity.""" + return { + ATTR_ATTRIBUTION: ATTRIBUTION, + "device model": f"{self._camera.product_model}.battery", + } + + @property + def native_value(self): + """Return the value of the sensor.""" + return self._camera.device_params.get("electricity") + + +class WyzePlugEnergySensor(RestoreSensor): + """Respresents an Outdoor Plug Total Energy Sensor.""" + + _attr_device_class = SensorDeviceClass.ENERGY + _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR + _attr_state_class = SensorStateClass.TOTAL_INCREASING + _attr_suggested_display_precision = 3 + _attr_should_poll = False + _attr_name = "Total Energy Usage" + _previous_hour = None + _previous_value = None + _past_hours_previous_value = None + _current_value = 0 + _past_hours_value = 0 + _hourly_energy_usage_added = 0 + + def __init__( + self, switch: Switch, switch_usage_service: SwitchUsageService + ) -> None: + """Initialize an energy sensor.""" + self._switch = switch + self._switch_usage_service = switch_usage_service + self._switch.usage_history = None # type: ignore[attr-defined] + + @property + def unique_id(self): + """Get the unique ID of the sensor.""" + return f"{self._switch.nickname}.energy-{self._switch.mac}" + + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(DOMAIN, self._switch.mac)}, + "name": self._switch.nickname, + } + + def update_energy(self): + """Update the energy sensor.""" + _now = int(datetime.datetime.now(datetime.UTC).hour) + self._hourly_energy_usage_added = 0 + + if ( + self._switch.usage_history and len(self._switch.usage_history) > 0 + ): # Confirm there is data + _raw_data = self._switch.usage_history + _LOGGER.debug(_raw_data) + _current_day_list = json.loads(_raw_data[0]["data"]) + if _now == 0: # Handle rolling to the next UTC day + self._past_hours_value = _current_day_list[23] / 1000 + if len(_raw_data) > 1: # New Day's value + _next_day_list = json.loads(_raw_data[1]["data"]) + self._current_value = _next_day_list[_now] / 1000 + else: + self._current_value = 0 + else: + self._past_hours_value = _current_day_list[_now - 1] / 1000 + self._current_value = _current_day_list[_now] / 1000 + + # Set inital values to current values on startup. + # Has to be done after we check for current or next UTC day + if self._previous_hour is None: + self._previous_hour = _now + if self._past_hours_previous_value is None: + self._past_hours_previous_value = self._past_hours_value + if self._previous_value is None: + self._previous_value = self._current_value + + if _now != self._previous_hour: # New Hour + if self._past_hours_value > self._previous_value: + self._hourly_energy_usage_added = ( + self._past_hours_value - self._previous_value + ) + self._hourly_energy_usage_added += self._current_value + self._previous_value = self._current_value + self._previous_hour = _now + self._past_hours_previous_value = self._past_hours_value + + else: # Current Hour + if self._current_value > self._previous_value: + self._hourly_energy_usage_added += round( + self._current_value - self._previous_value, 3 + ) + self._previous_value = self._current_value + + if self._past_hours_value > self._past_hours_previous_value: + self._hourly_energy_usage_added += round( + self._past_hours_value - self._past_hours_previous_value, 3 + ) + self._past_hours_previous_value = self._past_hours_value + + _LOGGER.debug( + "Total Value Added to device %s is %s", + self._switch.mac, + self._hourly_energy_usage_added, + ) + + return self._hourly_energy_usage_added + + @callback + def async_update_callback(self, switch: Switch): + """Update the sensor's state.""" + self._switch = switch + self.update_energy() + self._attr_native_value += self._hourly_energy_usage_added + self.async_write_ha_state() + + @callback + def reset_energy_use(self, switch: Switch): + """Reset the Energy Usage.""" + _LOGGER.debug("Resetting Usage of %s to 0", self._switch.nickname) + self._switch = switch + self._attr_native_value = 0 + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register Updater for the sensor and get previous data.""" + state = await self.async_get_last_sensor_data() + if state: + self._attr_native_value = state.native_value + else: + self._attr_native_value = 0 + self._switch.callback_function = self.async_update_callback + self._switch_usage_service.register_updater( + self._switch, 120 + ) # Every 2 minutes seems to work fine, probably could be longer + await self._switch_usage_service.start_update_manager() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{RESET_BUTTON_PRESSED}-{self._switch.mac}", + self.reset_energy_use, + ) + ) + + async def async_will_remove_from_hass(self) -> None: + """Remove updater.""" + self._switch_usage_service.unregister_updater(self._switch) + + +class WyzePlugDailyEnergySensor(RestoreSensor): + """Respresents an Outdoor Plug Daily Energy Sensor.""" + + _attr_device_class = SensorDeviceClass.ENERGY + _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR + _attr_state_class = SensorStateClass.TOTAL_INCREASING + _attr_suggested_display_precision = 3 + _attr_name = "Daily Energy Usage" + + def __init__(self, switch: Switch) -> None: + """Initialize a daily energy sensor.""" + self._switch = switch + + @property + def unique_id(self): + """Get the unique ID of the sensor.""" + return f"{self._switch.nickname}.daily_energy-{self._switch.mac}" + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(DOMAIN, self._switch.mac)}, + "name": self._switch.nickname, + } + + @callback + def _update_daily_sensor(self, event): + """Update the sensor when the total sensor updates.""" + event_data = event.data + new_state = event_data["new_state"] + old_state = event_data["old_state"] + + if not old_state or not new_state: + return + + updated_energy = float(new_state.state) - float(old_state.state) + self._attr_native_value += updated_energy + self.async_write_ha_state() + + async def _async_reset_at_midnight(self, now: datetime) -> None: + """Reset the daily sensor.""" + self._attr_native_value = 0 + _LOGGER.debug("Resetting daily energy sensor %s to 0", self._switch.mac) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Get previous data and add listeners.""" + + state = await self.async_get_last_sensor_data() + if state: + self._attr_native_value = state.native_value + else: + self._attr_native_value = 0 + + registry = er.async_get(self.hass) + entity_id_total_sensor = registry.async_get_entity_id( + "sensor", DOMAIN, f"{self._switch.nickname}.energy-{self._switch.mac}" + ) + + self.async_on_remove( + async_track_state_change_event( + self.hass, [entity_id_total_sensor], self._update_daily_sensor + ) + ) + + self.async_on_remove( + async_track_time_change( + self.hass, self._async_reset_at_midnight, hour=0, minute=0, second=0 + ) + ) + + +class WyzeIrrigationBaseSensor(SensorEntity): + """Base class for Wyze Irrigation sensors.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, irrigation_service: IrrigationService, irrigation: Irrigation + ) -> None: + """Initialize the irrigation base sensor.""" + self._irrigation_service = irrigation_service + self._device = irrigation + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this entity.""" + return DeviceInfo( + identifiers={(DOMAIN, self._device.mac)}, + name=self._device.nickname, + manufacturer="WyzeLabs", + model=self._device.product_model, + serial_number=self._device.sn, + connections={(dr.CONNECTION_NETWORK_MAC, self._device.mac)}, + ) + + @callback + def async_update_callback(self, irrigation: Irrigation) -> None: + """Update the irrigation's state.""" + self._device = self._irrigation_service.update_device_props(irrigation) + self.async_schedule_update_ha_state() + + async def async_added_to_hass(self) -> None: + """Subscribe to updates.""" + self._device.callback_function = self.async_update_callback + self._irrigation_service.register_updater(self._device, 30) + await self._irrigation_service.start_update_manager() + return await super().async_added_to_hass() + + async def async_will_remove_from_hass(self) -> None: + """Clean up when removed.""" + self._irrigation_service.unregister_updater(self._device) + + +class WyzeIrrigationRSSI(WyzeIrrigationBaseSensor): + """Representation of a Wyze Irrigation RSSI sensor.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return "RSSI" + + @property + def unique_id(self) -> str: + """Return a unique ID for the sensor.""" + return f"{self._device.mac}-rssi" + + @property + def native_value(self) -> int: + """Return the RSSI value.""" + return self._device.RSSI + + @property + def native_unit_of_measurement(self) -> str: + """Return the unit of measurement.""" + return "dBm" + + +class WyzeIrrigationIP(WyzeIrrigationBaseSensor): + """Representation of a Wyze Irrigation IP sensor.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return "IP Address" + + @property + def unique_id(self) -> str: + """Return a unique ID for the sensor.""" + return f"{self._device.mac}-ip" + + @property + def native_value(self) -> str: + """Return the IP address.""" + return self._device.IP + + +class WyzeIrrigationSSID(WyzeIrrigationBaseSensor): + """Representation of a Wyze Irrigation SSID sensor.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return "SSID" + + @property + def unique_id(self) -> str: + """Return a unique ID for the sensor.""" + return f"{self._device.mac}-ssid" + + @property + def native_value(self) -> str: + """Return the SSID.""" + return self._device.ssid diff --git a/custom_components/wyzeapi/siren.py b/custom_components/wyzeapi/siren.py new file mode 100644 index 0000000..0bb98f2 --- /dev/null +++ b/custom_components/wyzeapi/siren.py @@ -0,0 +1,142 @@ +#!/usr/bin/python3 + +"""Platform for siren integration.""" + +import logging +from typing import Any, Callable +from aiohttp.client_exceptions import ClientConnectionError + +from wyzeapy import CameraService, Wyzeapy +from wyzeapy.services.camera_service import Camera +from wyzeapy.exceptions import AccessTokenError, ParameterError, UnknownApiError + +from homeassistant.components.siren import ( + SirenEntity, + SirenEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import CAMERA_UPDATED, CONF_CLIENT, DOMAIN +from .token_manager import token_exception_handler + +_LOGGER = logging.getLogger(__name__) +ATTRIBUTION = "Data provided by Wyze" + + +@token_exception_handler +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: Callable[[list[Any], bool], None], +) -> None: + """ + This function sets up the config entry + + :param hass: The Home Assistant Instance + :param config_entry: The current config entry + :param async_add_entities: This function adds entities to the config entry + :return: + """ + + _LOGGER.debug("""Creating new WyzeApi siren component""") + client: Wyzeapy = hass.data[DOMAIN][config_entry.entry_id][CONF_CLIENT] + camera_service = await client.camera_service + sirens = [] + for camera in await camera_service.get_cameras(): + # The campan v1, v2 camera, and video doorbell pro don't have sirens + if camera.product_model not in ["WYZECP1_JEF", "WYZEC1-JZ", "GW_BE1"]: + sirens.append(WyzeCameraSiren(camera, camera_service)) + + async_add_entities(sirens, True) + + +class WyzeCameraSiren(SirenEntity): + """Representation of a Wyze Camera Siren.""" + + _available: bool + _just_updated = False + + def __init__(self, camera: Camera, camera_service: CameraService) -> None: + self._device = camera + self._service = camera_service + + self._attr_supported_features = ( + SirenEntityFeature.TURN_OFF | SirenEntityFeature.TURN_ON + ) + + @token_exception_handler + async def async_turn_on(self, **kwargs) -> None: + """Turn the siren on.""" + try: + await self._service.siren_on(self._device) + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._device.siren = True + self._just_updated = True + self.async_schedule_update_ha_state() + + @token_exception_handler + async def async_turn_off(self, **kwargs): + """Turn the siren off.""" + try: + await self._service.siren_off(self._device) + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._device.siren = False + self._just_updated = True + self.async_schedule_update_ha_state() + + @property + def should_poll(self) -> bool: + return False + + @property + def is_on(self): + """Return true if siren is on.""" + return self._device.siren + + @property + def available(self): + """Return the connection status of this switch""" + return self._device.available + + @property + def name(self) -> str: + return f"{self._device.nickname} Siren" + + @property + def unique_id(self): + return f"{self._device.mac}-siren" + + @property + def device_info(self): + return { + "identifiers": {(DOMAIN, self._device.mac)}, + "name": self._device.nickname, + "manufacturer": "WyzeLabs", + "model": self._device.product_model, + } + + @callback + def handle_camera_update(self, camera: Camera) -> None: + """Update the camera object whenever there is an update""" + self._device = camera + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{CAMERA_UPDATED}-{self._device.mac}", + self.handle_camera_update, + ) + ) diff --git a/custom_components/wyzeapi/strings.json b/custom_components/wyzeapi/strings.json new file mode 100644 index 0000000..d3db476 --- /dev/null +++ b/custom_components/wyzeapi/strings.json @@ -0,0 +1,77 @@ +{ + "title": "[%key:common::config_flow::data::title%]", + "config": { + "step": { + "user": { + "title": "[%key:common::config_flow::data::title%]", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "keyid": "[%key:common::config_flow::data::key_id%]", + "apikey": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "username": "Wyze or 3rd party OAuth email address", + "password": "Wyze or 3rd party OAuth password" + } + }, + "2fa": { + "title": "[%key:common::config_flow::data::title%]", + "data": { + "verification_code": "[%key:common::config_flow::data::verification_code%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "bulb_local_control": "Use Local Control for Color Bulbs and Light Strips" + } + }, + "user": { + "title": "[%key:common::config_flow::data::title%]", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "keyid": "[%key:common::config_flow::data::key_id%]", + "apikey": "[%key:common::config_flow::data::api_key%]" + } + }, + "2fa": { + "title": "[%key:common::config_flow::data::title%]", + "data": { + "verification_code": "[%key:common::config_flow::data::verification_code%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + }, + "issues": { + "entity_changed": { + "title": "Wyze Entity Error", + "description": "{automation} may need attention. {entity} may have changed or been removed. This is due to a change in outdoor plug devices. Once fixed you can ignore this issue." + }, + "device_changed": { + "title": "Wyze Device Error", + "description": "{automation} may need attention. A device has changed or been removed. This is due to a change in outdoor plug devices. Once fixed you can ignore this issue." + } + } +} diff --git a/custom_components/wyzeapi/switch.py b/custom_components/wyzeapi/switch.py new file mode 100644 index 0000000..38ebe6b --- /dev/null +++ b/custom_components/wyzeapi/switch.py @@ -0,0 +1,730 @@ +#!/usr/bin/python3 + +"""Platform for switch integration.""" + +from collections.abc import Callable +from datetime import timedelta +import logging +from typing import Any + +from aiohttp.client_exceptions import ClientConnectionError +from wyzeapy import BulbService, CameraService, SwitchService, Wyzeapy +from wyzeapy.exceptions import AccessTokenError, ParameterError, UnknownApiError +from wyzeapy.services.bulb_service import Bulb +from wyzeapy.services.camera_service import Camera +from wyzeapy.services.switch_service import Switch +from wyzeapy.types import Device, DeviceTypes, Event + +from homeassistant.components.automation import ( + automations_with_device, + automations_with_entity, +) +from homeassistant.components.script import scripts_with_device, scripts_with_entity +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.issue_registry import IssueSeverity + +from .const import ( + CAMERA_UPDATED, + CONF_CLIENT, + DOMAIN, + LIGHT_UPDATED, + WYZE_CAMERA_EVENT, + WYZE_NOTIFICATION_TOGGLE, +) +from .token_manager import token_exception_handler + +_LOGGER = logging.getLogger(__name__) +ATTRIBUTION = "Data provided by Wyze" +SCAN_INTERVAL = timedelta(seconds=30) +OUTDOOR_PLUGS = "WLPPO" +OUTDOOR_PLUG_INDIVUAL_OUTLETS = "WLPPO-SUB" +MOTION_SWITCH_UNSUPPORTED = [ + "GW_BE1", + "GW_GC1", + "GW_GC2", +] # Video doorbell pro, OG, OG 3x Telephoto +POWER_SWITCH_UNSUPPORTED = ["GW_BE1"] # Video doorbell pro (device has no off function) +NOTIFICATION_SWITCH_UNSUPPORTED = { + "GW_GC1", + "GW_GC2", +} # OG and OG 3x Telephoto models currently unsupported due to InvalidSignature2 error + + +# noinspection DuplicatedCode +@token_exception_handler +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: Callable[[list[Any], bool], None], +) -> None: + """This function sets up the config entry. + + :param hass: The Home Assistant Instance + :param config_entry: The current config entry + :param async_add_entities: This function adds entities to the config entry + :return: + """ + + _LOGGER.debug("""Creating new WyzeApi light component""") + client: Wyzeapy = hass.data[DOMAIN][config_entry.entry_id][CONF_CLIENT] + switch_service = await client.switch_service + wall_switch_service = await client.wall_switch_service + camera_service = await client.camera_service + bulb_service = await client.bulb_service + + switches: list[SwitchEntity] = [] + has_outdoor_plug: bool = False + devices_to_migrate: list[str] = [] + devices = [] + device_registry = dr.async_get(hass) + + base_switches = await switch_service.get_switches() + # The outdoor plug has a dummy switch that doesn't control anything + # on the device. So we add non-outdoor plug switches and then + # the switches for each individual outlet on the outdoor plug. + switches.extend( + WyzeSwitch(switch_service, switch) + for switch in base_switches + if switch.product_model not in [OUTDOOR_PLUGS, OUTDOOR_PLUG_INDIVUAL_OUTLETS] + ) + + for switch in base_switches: + if switch.product_model in [OUTDOOR_PLUG_INDIVUAL_OUTLETS]: + has_outdoor_plug = True + switches.append(WyzeSwitch(switch_service, switch)) + + switches.extend( + WyzeSwitch(wall_switch_service, switch) + for switch in await wall_switch_service.get_switches() + ) + + camera_switches = await camera_service.get_cameras() + for switch in camera_switches: + # Notification toggle switch + if switch.product_model not in NOTIFICATION_SWITCH_UNSUPPORTED: + switches.append(WyzeCameraNotificationSwitch(camera_service, switch)) + + # IoT Power switch + if switch.product_model not in POWER_SWITCH_UNSUPPORTED: + switches.append(WyzeSwitch(camera_service, switch)) + + # Motion toggle switch + if switch.product_model not in MOTION_SWITCH_UNSUPPORTED: + switches.append(WyzeCameraMotionSwitch(camera_service, switch)) + + switches.append(WyzeNotifications(client)) + + bulb_switches = await bulb_service.get_bulbs() + switches.extend( + WzyeLightstripSwitch(bulb_service, bulb) + for bulb in bulb_switches + if bulb.type is DeviceTypes.LIGHTSTRIP + ) + + # Catch old outdoor plug devices and entities and remove. + # This can be removed at a later date. + if has_outdoor_plug: + devices = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + for device in devices: + for identifier in device.identifiers: + mac = identifier[1] + if ( + "-" in mac and device.model == OUTDOOR_PLUG_INDIVUAL_OUTLETS + ): # The old devices have a '-' in the mac + devices_to_migrate.append(device.id) + + # Also catch the old dummy switch to remove below. + # Should only happen once while the old devices are still around. + if devices_to_migrate: + devices_to_migrate.extend( + device.id for device in devices if device.model == OUTDOOR_PLUGS + ) + await async_migrate_switch_data( + hass, config_entry, devices_to_migrate, device_registry + ) + + async_add_entities(switches, True) + + +async def async_migrate_switch_data( + hass: HomeAssistant, + config_entry: ConfigEntry, + device_list, + device_registry, +): + """Remove redundant switch devices and entities and flag for repair. + + This can be removed in the future. + """ + + entity_registry = er.async_get(hass) + entities = er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + + for entity in entities: + if entity.device_id in device_list and entity.domain == "switch": + entity_automations = automations_with_entity(hass, entity.entity_id) + entity_automations.extend(scripts_with_entity(hass, entity.entity_id)) + if entity_automations: + for issue in entity_automations: + ir.async_create_issue( + hass, + DOMAIN, + issue_id=f"{issue}-entity-issue", + is_fixable=False, + is_persistent=True, + severity=IssueSeverity.ERROR, + learn_more_url="https://github.com/SecKatie/ha-wyzeapi/issues/789", + translation_key="entity_changed", + translation_placeholders={ + "entity": entity.entity_id, + "automation": issue, + }, + ) + entity_registry.async_remove(entity.id) + + for device in device_list: + device_automations = automations_with_device(hass, device) + device_automations.extend(scripts_with_device(hass, device)) + if device_automations: + for issue in device_automations: + ir.async_create_issue( + hass, + DOMAIN, + issue_id=f"{issue}-device-issue", + is_fixable=False, + is_persistent=True, + severity=IssueSeverity.ERROR, + learn_more_url="https://github.com/SecKatie/ha-wyzeapi/issues/789", + translation_key="device_changed", + translation_placeholders={ + "automation": issue, + }, + ) + device_registry.async_remove_device(device) + + +class WyzeNotifications(SwitchEntity): + """Class for notification switch.""" + + _attr_should_poll = False + _attr_name = "Wyze Notifications" + + def __init__(self, client: Wyzeapy) -> None: + """Initialize the switch.""" + self._client = client + self._is_on = False + self._uid = WYZE_NOTIFICATION_TOGGLE + self._just_updated = False + + @property + def is_on(self) -> bool: + """Return if the switch is on.""" + return self._is_on + + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(DOMAIN, self._uid)}, + "name": "Wyze Notifications", + "manufacturer": "WyzeLabs", + "model": "WyzeNotificationToggle", + } + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + try: + await self._client.enable_notifications() + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._is_on = True + self._just_updated = True + self.async_schedule_update_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + try: + await self._client.disable_notifications() + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._is_on = False + self._just_updated = True + self.async_schedule_update_ha_state() + + @property + def available(self): + """Return the connection status of this switch.""" + return True + + @property + def unique_id(self): + """Return the unique ID.""" + return self._uid + + async def async_update(self): + """Update the switch.""" + if not self._just_updated: + self._is_on = await self._client.notifications_are_on + else: + self._just_updated = False + + +class WyzeSwitch(SwitchEntity): + """Representation of a Wyze Switch.""" + + _on: bool + _available: bool + _just_updated = False + _old_event_ts: int = 0 # preload with 0 so that we know when it's been updated + _attr_should_poll = False + + def __init__(self, service: CameraService | SwitchService, device: Device) -> None: + """Initialize a Wyze Bulb.""" + self._device = device + self._service = service + + if type(self._device) is Camera: + self._device = Camera(self._device.raw_dict) + elif type(self._device) is Switch: + self._device = Switch(self._device.raw_dict) + + @property + def device_info(self): + """Return the device info. + + Outdoor plug needs its own setup based on how the MAC's are + displayed and to keep the plugs organized by device. + """ + if self._device.product_model == OUTDOOR_PLUG_INDIVUAL_OUTLETS: + mac = self._device.mac.split("-")[0] + return { + "identifiers": {(DOMAIN, mac)}, + "connections": { + ( + dr.CONNECTION_NETWORK_MAC, + mac, + ) + }, + "name": f"Outdoor Plug {mac}", + "manufacturer": "WyzeLabs", + "model": self._device.product_model, + } + return { + "identifiers": {(DOMAIN, self._device.mac)}, + "connections": { + ( + dr.CONNECTION_NETWORK_MAC, + self._device.mac, + ) + }, + "name": self._device.nickname, + "manufacturer": "WyzeLabs", + "model": self._device.product_model, + } + + @token_exception_handler + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + try: + await self._service.turn_on(self._device) + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._device.on = True + self._just_updated = True + self.async_schedule_update_ha_state() + + @token_exception_handler + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + try: + await self._service.turn_off(self._device) + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._device.on = False + self._just_updated = True + self.async_schedule_update_ha_state() + + @property + def name(self): + """Return the display name of this switch.""" + if type(self._device) is Camera: + return f"{self._device.nickname} Power" + return self._device.nickname + + @property + def available(self): + """Return the connection status of this switch.""" + return self._device.available + + @property + def is_on(self): + """Return true if switch is on.""" + return self._device.on + + @property + def unique_id(self): + """Return the unique ID.""" + return f"{self._device.mac}-switch" + + @property + def extra_state_attributes(self): + """Return device attributes of the entity.""" + dev_info = {} + + if self._device.device_params.get("electricity"): + dev_info["Battery"] = str( + self._device.device_params.get("electricity") + "%" + ) + # noinspection DuplicatedCode + if self._device.device_params.get("ip"): + dev_info["IP"] = str(self._device.device_params.get("ip")) + if self._device.device_params.get("rssi"): + dev_info["RSSI"] = str(self._device.device_params.get("rssi")) + if self._device.device_params.get("ssid"): + dev_info["SSID"] = str(self._device.device_params.get("ssid")) + + return dev_info + + @token_exception_handler + async def async_update(self): + """Update the entity.""" + if not self._just_updated: + self._device = await self._service.update(self._device) + else: + self._just_updated = False + + @callback + def async_update_callback(self, switch: Switch): + """Update the switch's state.""" + self._device = switch + async_dispatcher_send( + self.hass, + f"{CAMERA_UPDATED}-{switch.mac}", + switch, + ) + self.async_schedule_update_ha_state() + # if the switch is from a camera, lets check for new events + if isinstance(switch, Camera): + if ( + self._old_event_ts > 0 + and self._old_event_ts != switch.last_event_ts + and switch.last_event is not None + ): + event: Event = switch.last_event + # The screenshot/video urls are not always in the same positions in the lists, so we have to loop + # through them + _screenshot_url = None + _video_url = None + _ai_tag_list = [] + for resource in event.file_list: + _ai_tag_list = _ai_tag_list + resource["ai_tag_list"] + if resource["type"] == 1: + _screenshot_url = resource["url"] + elif resource["type"] == 2: + _video_url = resource["url"] + _LOGGER.debug("Camera: %s has a new event", switch.nickname) + self.hass.bus.fire( + WYZE_CAMERA_EVENT, + { + "device_name": switch.nickname, + "device_mac": switch.mac, + "ai_tag_list": _ai_tag_list, + "tag_list": event.tag_list, + "event_screenshot": _screenshot_url, + "event_video": _video_url, + }, + ) + self._old_event_ts = switch.last_event_ts + + async def async_added_to_hass(self) -> None: + """Subscribe to update events.""" + self._device.callback_function = self.async_update_callback + self._service.register_updater(self._device, 30) + await self._service.start_update_manager() + return await super().async_added_to_hass() + + async def async_will_remove_from_hass(self) -> None: + """Unregister updated on removal.""" + self._service.unregister_updater(self._device) + + +class WyzeCameraNotificationSwitch(SwitchEntity): + """Representation of a Wyze Camera Notification Switch.""" + + _available: bool + + def __init__(self, service: CameraService, device: Camera) -> None: + """Initialize a Wyze Notification Switch.""" + self._service = service + self._device = device + + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(DOMAIN, self._device.mac)}, + "name": self._device.nickname, + "manufacturer": "WyzeLabs", + "model": self._device.product_model, + } + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + try: + await self._service.turn_on_notifications(self._device) + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._device.notify = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + try: + await self._service.turn_off_notifications(self._device) + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._device.notify = False + self.async_write_ha_state() + + @property + def name(self): + """Return the display name of this switch.""" + return f"{self._device.nickname} Notifications" + + @property + def available(self): + """Return the connection status of this switch.""" + return self._device.available + + @property + def is_on(self): + """Return true if switch is on.""" + return self._device.notify + + @property + def unique_id(self): + """Add a unique ID to the switch.""" + return f"{self._device.mac}-notification_switch" + + @callback + def handle_camera_update(self, camera: Camera) -> None: + """Update the switch whenever there is an update.""" + self._device = camera + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Listen for camera updates.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{CAMERA_UPDATED}-{self._device.mac}", + self.handle_camera_update, + ) + ) + + +class WyzeCameraMotionSwitch(SwitchEntity): + """Representation of a Wyze Camera Motion Detection Switch.""" + + _available: bool + + def __init__(self, service: CameraService, device: Camera) -> None: + """Initialize a Wyze Notification Switch.""" + self._service = service + self._device = device + + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(DOMAIN, self._device.mac)}, + "name": self._device.nickname, + "manufacturer": "WyzeLabs", + "model": self._device.product_model, + } + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + try: + await self._service.turn_on_motion_detection(self._device) + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._device.motion = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + try: + await self._service.turn_off_motion_detection(self._device) + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._device.motion = False + self.async_write_ha_state() + + @property + def name(self): + """Return the display name of this switch.""" + return f"{self._device.nickname} Motion Detection" + + @property + def available(self): + """Return the connection status of this switch.""" + return self._device.available + + @property + def is_on(self): + """Return true if switch is on.""" + return self._device.motion + + @property + def unique_id(self): + """Add a unique ID to the switch.""" + return f"{self._device.mac}-motion_switch" + + @callback + def handle_camera_update(self, camera: Camera) -> None: + """Update the switch whenever there is an update.""" + self._device = camera + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Listen for camera updates.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{CAMERA_UPDATED}-{self._device.mac}", + self.handle_camera_update, + ) + ) + + +class WzyeLightstripSwitch(SwitchEntity): + """Music Mode Switch for Wyze Light Strip.""" + + def __init__(self, service: BulbService, device: Device) -> None: + """Initialize a Wyze Music Mode Switch.""" + self._service = service + self._device = Bulb(device.raw_dict) + + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(DOMAIN, self._device.mac)}, + "name": self._device.nickname, + "manufacturer": "WyzeLabs", + "model": self._device.product_model, + } + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + try: + await self._service.music_mode_on(self._device) + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._device.music_mode = True + self.async_schedule_update_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + try: + await self._service.music_mode_off(self._device) + except (AccessTokenError, ParameterError, UnknownApiError) as err: + raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err + except ClientConnectionError as err: + raise HomeAssistantError(err) from err + else: + self._device.music_mode = False + self.async_schedule_update_ha_state() + + @property + def name(self): + """Return the display name of this switch.""" + return f"{self._device.nickname} Music Mode for Effects" + + @property + def available(self): + """Return the connection status of this switch.""" + return self._device.available + + @property + def is_on(self): + """Return true if switch is on.""" + return self._device.music_mode + + @property + def unique_id(self): + """Add a unique ID to the switch.""" + return f"{self._device.mac}-music_mode" + + @callback + def handle_light_update(self, bulb: Bulb) -> None: + """Update the switch whenever there is an update.""" + self._device = bulb + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Listen for light updates.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{LIGHT_UPDATED}-{self._device.mac}", + self.handle_light_update, + ) + ) diff --git a/custom_components/wyzeapi/token_manager.py b/custom_components/wyzeapi/token_manager.py new file mode 100644 index 0000000..3f0005c --- /dev/null +++ b/custom_components/wyzeapi/token_manager.py @@ -0,0 +1,52 @@ +import logging +from inspect import iscoroutinefunction + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from wyzeapy.exceptions import AccessTokenError, LoginError +from wyzeapy.wyze_auth_lib import Token + +from .const import DOMAIN, ACCESS_TOKEN, REFRESH_TOKEN, REFRESH_TIME + +_LOGGER = logging.getLogger(__name__) + + +class TokenManager: + hass: HomeAssistant = None + config_entry: ConfigEntry = None + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry): + TokenManager.hass = hass + TokenManager.config_entry = config_entry + + @staticmethod + async def token_callback(token: Token = None): + _LOGGER.debug("TokenManager: Received new token, updating config entry.") + if TokenManager.hass.config_entries.async_entries(DOMAIN): + for entry in TokenManager.hass.config_entries.async_entries(DOMAIN): + TokenManager.hass.config_entries.async_update_entry( + entry, + data={ + CONF_USERNAME: entry.data.get(CONF_USERNAME), + CONF_PASSWORD: entry.data.get(CONF_PASSWORD), + ACCESS_TOKEN: token.access_token, + REFRESH_TOKEN: token.refresh_token, + REFRESH_TIME: str(token.refresh_time), + }, + ) + + +def token_exception_handler(func): + async def inner_function(*args, **kwargs): + try: + if iscoroutinefunction(func): + await func(*args, **kwargs) + else: + func(*args, **kwargs) + except (AccessTokenError, LoginError) as err: + _LOGGER.error("TokenManager detected a login issue please re-login.") + raise ConfigEntryAuthFailed("Unable to login, please re-login.") from err + + return inner_function diff --git a/custom_components/wyzeapi/translations/en.json b/custom_components/wyzeapi/translations/en.json new file mode 100644 index 0000000..548dcb1 --- /dev/null +++ b/custom_components/wyzeapi/translations/en.json @@ -0,0 +1,78 @@ +{ + "title": "Wyze Home Assistant Integration", + "config": { + "abort": { + "already_configured": "Device is already configured", + "reauth_successful": "Reauthentication Successful" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "title": "Enter Wyze Login Credentials", + "data": { + "username": "Email", + "password": "Password", + "key_id": "Keyid", + "api_key": "Apikey" + }, + "data_description": { + "username": "Wyze or 3rd party OAuth email address", + "password": "Wyze or 3rd party OAuth password" + } + }, + "2fa": { + "title": "Enter Wyze 2FA Verification Code", + "data": { + "verification_code": "2FA Verification Code" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "bulb_local_control": "Use Local Control for Color Bulbs and Light Strips" + } + }, + "user": { + "title": "Enter Wyze Login Credentials", + "data": { + "username": "Email", + "password": "Password", + "key_id": "Keyid", + "api_key": "Apikey" + } + }, + "2fa": { + "title": "Enter Wyze 2FA Verification Code", + "data": { + "verification_code": "2FA Verification Code" + } + } + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Device is already configured", + "reauth_successful": "Reauthentication Successful" + } + }, + "issues": { + "entity_changed": { + "title": "Wyze Entity Error", + "description": "{automation} may need attention. {entity} may have changed or been removed. This is due to a change in outdoor plug devices. Once fixed you can ignore this issue." + }, + "device_changed": { + "title": "Wyze Device Error", + "description": "{automation} may need attention. A device has changed or been removed. This is due to a change in outdoor plug devices. Once fixed you can ignore this issue." + } + } +} diff --git a/custom_components/wyzeapi/translations/pt-BR.json b/custom_components/wyzeapi/translations/pt-BR.json new file mode 100644 index 0000000..0ba61f6 --- /dev/null +++ b/custom_components/wyzeapi/translations/pt-BR.json @@ -0,0 +1,54 @@ +{ + "title": "Integração Wyze para Home Assistant", + "config": { + "abort": { + "already_configured": "O dispositivo já está configurado", + "reauth_successful": "Reautenticação bem-sucedida" + }, + "error": { + "cannot_connect": "Falhou ao conectar", + "invalid_auth": "Autenticação inválida", + "unknown": "Erro inesperado" + }, + "step": { + "user": { + "title": "Coloque as credenciais de login do Wyze", + "data": { + "password": "Senha", + "username": "Nome de usuário" + } + }, + "2fa": { + "title": "Insira o código de verificação Wyze 2FA", + "data": { + "verification_code": "Código de verificação 2FA" + } + } + } + }, + "options": { + "abort": { + "already_configured": "O dispositivo já está configurado" + }, + "error": { + "cannot_connect": "Falhou ao conectar", + "invalid_auth": "Autenticação inválida", + "unknown": "Erro inesperado" + }, + "step": { + "user": { + "title": "Coloque as credenciais de login do Wyze", + "data": { + "password": "Senha", + "username": "Nome de usuário" + } + }, + "2fa": { + "title": "Insira o código de verificação Wyze 2FA", + "data": { + "verification_code": "Código de verificação 2FA" + } + } + } + } +} diff --git a/custom_components/wyzeapi/translations/pt_br.json b/custom_components/wyzeapi/translations/pt_br.json new file mode 100644 index 0000000..133c984 --- /dev/null +++ b/custom_components/wyzeapi/translations/pt_br.json @@ -0,0 +1,54 @@ +{ + "title": "Integração Wyze para Home Assistant", + "config": { + "abort": { + "already_configured": "O dispositivo já está configurado", + "reauth_successful": "Reautenticação bem-sucedida" + }, + "error": { + "cannot_connect": "Falhou ao conectar", + "invalid_auth": "Autenticação inválida", + "unknown": "Erro inesperado" + }, + "step": { + "user": { + "title": "Coloque as credenciais de login do Wyze", + "data": { + "password": "Senha", + "username": "Nome de usuário" + } + }, + "2fa": { + "title": "Insira o código de verificação Wyze 2FA", + "data": { + "verification_code": "Código de verificação 2FA" + } + } + } + }, + "options": { + "abort": { + "already_configured": "O dispositivo já está configurado" + }, + "error": { + "cannot_connect": "Falhou ao conectar", + "invalid_auth": "Autenticação inválida", + "unknown": "Erro inesperado" + }, + "step": { + "user": { + "title": "Coloque as credenciais de login do Wyze", + "data": { + "password": "Senha", + "username": "Nome de usuário" + } + }, + "2fa": { + "title": "Insira o código de verificação Wyze 2FA", + "data": { + "verification_code": "Código de verificação 2FA" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/wyzeapi/translations/ru.json b/custom_components/wyzeapi/translations/ru.json new file mode 100644 index 0000000..4c40c1b --- /dev/null +++ b/custom_components/wyzeapi/translations/ru.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Устройство уже сконфигурировано" + }, + "error": { + "cannot_connect": "Не могу подключиться", + "invalid_auth": "Ошибка автризации", + "unknown": "Неизвестная ошибка" + }, + "step": { + "user": { + "data": { + "host": "Хост", + "password": "Пароль", + "username": "Логин" + } + } + } + }, + "title": "Интеграция Wyze Home Assistant" +} \ No newline at end of file diff --git a/custom_components/wyzeapi/ydble_utils.py b/custom_components/wyzeapi/ydble_utils.py new file mode 100644 index 0000000..b9b793e --- /dev/null +++ b/custom_components/wyzeapi/ydble_utils.py @@ -0,0 +1,116 @@ +import binascii +from typing import Dict + +from Crypto.Cipher import AES + + +def decrypt_ecb(key: str, data: bytes) -> bytes: + key_bytes = key.encode() + cipher = AES.new(key_bytes, AES.MODE_ECB) + decrypted_data = cipher.decrypt(data) + return decrypted_data + + +def encrypt_ecb(key: str, data: bytes) -> bytes: + key_bytes = key.encode() + cipher = AES.new(key_bytes, AES.MODE_ECB) + encrypted_data = cipher.encrypt(data) + return encrypted_data + + +def pack_l1(flags: int, seq_no: int, data: bytes): + data_crc = crc(data) + result = b"\xab" + result += flags.to_bytes(1) + result += len(data).to_bytes(2) + result += data_crc.to_bytes(2) + result += seq_no.to_bytes(2) + result += data + return result + + +def parse_l1(data: bytes): + if data[0] != 0xAB: + raise ValueError("Unexpected data") + flags = data[1] + length = int.from_bytes(data[2:4]) + data_crc = int.from_bytes(data[4:6]) + seq_no = int.from_bytes(data[6:8]) + l2_content = data[8:] + if len(l2_content) > length: + l2_content = l2_content[:length] + if len(l2_content) == length and crc(l2_content) != data_crc: + raise ValueError(f"CRC Checksum failed! {data_crc} != {crc(l2_content)}") + return l2_content, flags, seq_no, length - len(l2_content) + + +def pack_l2_dict(cmd: int, flags: int, content: Dict[int, bytes]): + result = cmd.to_bytes(1) + result += flags.to_bytes(1) + for k, v in content.items(): + result += k.to_bytes(1) + result += len(v).to_bytes(2) + result += v + return result + + +def parse_l2_dict(data: bytes): + result_dict: Dict[int, bytes] = {} + cmd = data[0] + flags = data[1] + cur = 2 + while cur < len(data): + key = data[cur] + length = int.from_bytes(data[cur + 1 : cur + 3]) + value = data[cur + 3 : cur + 3 + length] + result_dict[key] = value + cur += 3 + length + return cmd, flags, result_dict + + +def pack_l2_lock_unlock(ble_id: int, ble_token: str, challenge: bytes, command): + if command == "unlock": + magic_bytes = binascii.unhexlify("01000000000000000000006C6F6F636B") + elif command == "lock": + magic_bytes = binascii.unhexlify("02000000000000000000006C6F6F636B") + else: + raise ValueError(f"Only accept `lock` or `unlock`, but got `{command}`") + encrypted_challenge = encrypt_ecb(ble_token[16:], challenge) + encrypted_challenge = b"".join( + (x ^ y).to_bytes(1) for x, y in zip(encrypted_challenge, magic_bytes) + ) + result = (0x0400050002).to_bytes(5) + result += ble_id.to_bytes(2) + result += (0x040010).to_bytes(3) + result += encrypted_challenge + result += (0xAD000100F4000101F7000101).to_bytes(12) + return result + + +def crc(data): + magic = ( + "0000c0c1c1810140c30103c00280c241c60106c00780c7410500c5c1c4810440" + "cc010cc00d80cd410f00cfc1ce810e400a00cac1cb810b40c90109c00880c841" + "d80118c01980d9411b00dbc1da811a401e00dec1df811f40dd011dc01c80dc41" + "1400d4c1d5811540d70117c01680d641d20112c01380d3411100d1c1d0811040" + "f00130c03180f1413300f3c1f28132403600f6c1f7813740f50135c03480f441" + "3c00fcc1fd813d40ff013fc03e80fe41fa013ac03b80fb413900f9c1f8813840" + "2800e8c1e9812940eb012bc02a80ea41ee012ec02f80ef412d00edc1ec812c40" + "e40124c02580e5412700e7c1e68126402200e2c1e3812340e10121c02080e041" + "a00160c06180a1416300a3c1a28162406600a6c1a7816740a50165c06480a441" + "6c00acc1ad816d40af016fc06e80ae41aa016ac06b80ab416900a9c1a8816840" + "7800b8c1b9817940bb017bc07a80ba41be017ec07f80bf417d00bdc1bc817c40" + "b40174c07580b5417700b7c1b68176407200b2c1b3817340b10171c07080b041" + "500090c191815140930153c052809241960156c057809741550095c194815440" + "9c015cc05d809d415f009fc19e815e405a009ac19b815b40990159c058809841" + "880148c0498089414b008bc18a814a404e008ec18f814f408d014dc04c808c41" + "440084c185814540870147c046808641820142c043808341410081c180814040" + ) + magic = [ + int.from_bytes(binascii.unhexlify(magic[x : x + 4])) + for x in range(0, len(magic), 4) + ] + result = 0 + for b in data: + result = magic[(result ^ (b & 255)) & 255] ^ (result >> 8) + return 65535 & result