From 8843bf77a57b4278eb1c3b8c2d5243415b773a1e Mon Sep 17 00:00:00 2001 From: Franco Pellicciotti Date: Fri, 12 Jun 2026 09:59:05 -0400 Subject: [PATCH] Added Wyze --- .cache/brands/integrations/wyzeapi/logo.png | Bin 0 -> 42175 bytes .ha_run.lock | 2 +- custom_components/webrtc/__init__.py | 317 ++++++++ .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 18860 bytes .../__pycache__/config_flow.cpython-314.pyc | Bin 0 -> 6174 bytes .../webrtc/__pycache__/utils.cpython-314.pyc | Bin 0 -> 16005 bytes custom_components/webrtc/config_flow.py | 106 +++ custom_components/webrtc/manifest.json | 17 + custom_components/webrtc/media_player.py | 99 +++ custom_components/webrtc/services.yaml | 59 ++ custom_components/webrtc/translations/en.json | 88 +++ custom_components/webrtc/translations/es.json | 88 +++ custom_components/webrtc/translations/fr.json | 88 +++ custom_components/webrtc/translations/pt.json | 88 +++ custom_components/webrtc/translations/ur.json | 88 +++ custom_components/webrtc/utils.py | 260 +++++++ custom_components/webrtc/www/digital-ptz.js | 346 +++++++++ custom_components/webrtc/www/embed.html | 45 ++ custom_components/webrtc/www/video-rtc.js | 676 ++++++++++++++++ custom_components/webrtc/www/webrtc-camera.js | 691 +++++++++++++++++ custom_components/wyzeapi/__init__.py | 215 ++++++ .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 10578 bytes .../alarm_control_panel.cpython-314.pyc | Bin 0 -> 11003 bytes .../__pycache__/button.cpython-314.pyc | Bin 0 -> 18544 bytes .../__pycache__/camera.cpython-314.pyc | Bin 0 -> 30155 bytes .../__pycache__/climate.cpython-314.pyc | Bin 0 -> 27526 bytes .../__pycache__/config_flow.cpython-314.pyc | Bin 0 -> 9823 bytes .../wyzeapi/__pycache__/const.cpython-314.pyc | Bin 0 -> 1146 bytes .../__pycache__/coordinator.cpython-314.pyc | Bin 0 -> 12543 bytes .../wyzeapi/__pycache__/cover.cpython-314.pyc | Bin 0 -> 9389 bytes .../wyzeapi/__pycache__/light.cpython-314.pyc | Bin 0 -> 28180 bytes .../wyzeapi/__pycache__/lock.cpython-314.pyc | Bin 0 -> 15583 bytes .../__pycache__/number.cpython-314.pyc | Bin 0 -> 10707 bytes .../__pycache__/sensor.cpython-314.pyc | Bin 0 -> 35472 bytes .../wyzeapi/__pycache__/siren.cpython-314.pyc | Bin 0 -> 8627 bytes .../__pycache__/switch.cpython-314.pyc | Bin 0 -> 38863 bytes .../__pycache__/token_manager.cpython-314.pyc | Bin 0 -> 3958 bytes .../__pycache__/ydble_utils.cpython-314.pyc | Bin 0 -> 7728 bytes .../wyzeapi/alarm_control_panel.py | 166 ++++ custom_components/wyzeapi/binary_sensor.py | 224 ++++++ custom_components/wyzeapi/button.py | 317 ++++++++ custom_components/wyzeapi/camera.py | 521 +++++++++++++ custom_components/wyzeapi/climate.py | 391 ++++++++++ custom_components/wyzeapi/config_flow.py | 182 +++++ custom_components/wyzeapi/const.py | 28 + custom_components/wyzeapi/coordinator.py | 196 +++++ custom_components/wyzeapi/cover.py | 152 ++++ custom_components/wyzeapi/light.py | 493 ++++++++++++ custom_components/wyzeapi/lock.py | 270 +++++++ custom_components/wyzeapi/manifest.json | 18 + custom_components/wyzeapi/number.py | 150 ++++ custom_components/wyzeapi/sensor.py | 626 +++++++++++++++ custom_components/wyzeapi/siren.py | 142 ++++ custom_components/wyzeapi/strings.json | 77 ++ custom_components/wyzeapi/switch.py | 730 ++++++++++++++++++ custom_components/wyzeapi/token_manager.py | 52 ++ .../wyzeapi/translations/en.json | 78 ++ .../wyzeapi/translations/pt-BR.json | 54 ++ .../wyzeapi/translations/pt_br.json | 54 ++ .../wyzeapi/translations/ru.json | 22 + custom_components/wyzeapi/ydble_utils.py | 116 +++ 61 files changed, 8331 insertions(+), 1 deletion(-) create mode 100644 .cache/brands/integrations/wyzeapi/logo.png create mode 100644 custom_components/webrtc/__init__.py create mode 100644 custom_components/webrtc/__pycache__/__init__.cpython-314.pyc create mode 100644 custom_components/webrtc/__pycache__/config_flow.cpython-314.pyc create mode 100644 custom_components/webrtc/__pycache__/utils.cpython-314.pyc create mode 100644 custom_components/webrtc/config_flow.py create mode 100644 custom_components/webrtc/manifest.json create mode 100644 custom_components/webrtc/media_player.py create mode 100644 custom_components/webrtc/services.yaml create mode 100644 custom_components/webrtc/translations/en.json create mode 100644 custom_components/webrtc/translations/es.json create mode 100644 custom_components/webrtc/translations/fr.json create mode 100644 custom_components/webrtc/translations/pt.json create mode 100644 custom_components/webrtc/translations/ur.json create mode 100644 custom_components/webrtc/utils.py create mode 100644 custom_components/webrtc/www/digital-ptz.js create mode 100644 custom_components/webrtc/www/embed.html create mode 100644 custom_components/webrtc/www/video-rtc.js create mode 100644 custom_components/webrtc/www/webrtc-camera.js create mode 100644 custom_components/wyzeapi/__init__.py create mode 100644 custom_components/wyzeapi/__pycache__/__init__.cpython-314.pyc create mode 100644 custom_components/wyzeapi/__pycache__/alarm_control_panel.cpython-314.pyc create mode 100644 custom_components/wyzeapi/__pycache__/button.cpython-314.pyc create mode 100644 custom_components/wyzeapi/__pycache__/camera.cpython-314.pyc create mode 100644 custom_components/wyzeapi/__pycache__/climate.cpython-314.pyc create mode 100644 custom_components/wyzeapi/__pycache__/config_flow.cpython-314.pyc create mode 100644 custom_components/wyzeapi/__pycache__/const.cpython-314.pyc create mode 100644 custom_components/wyzeapi/__pycache__/coordinator.cpython-314.pyc create mode 100644 custom_components/wyzeapi/__pycache__/cover.cpython-314.pyc create mode 100644 custom_components/wyzeapi/__pycache__/light.cpython-314.pyc create mode 100644 custom_components/wyzeapi/__pycache__/lock.cpython-314.pyc create mode 100644 custom_components/wyzeapi/__pycache__/number.cpython-314.pyc create mode 100644 custom_components/wyzeapi/__pycache__/sensor.cpython-314.pyc create mode 100644 custom_components/wyzeapi/__pycache__/siren.cpython-314.pyc create mode 100644 custom_components/wyzeapi/__pycache__/switch.cpython-314.pyc create mode 100644 custom_components/wyzeapi/__pycache__/token_manager.cpython-314.pyc create mode 100644 custom_components/wyzeapi/__pycache__/ydble_utils.cpython-314.pyc create mode 100644 custom_components/wyzeapi/alarm_control_panel.py create mode 100644 custom_components/wyzeapi/binary_sensor.py create mode 100644 custom_components/wyzeapi/button.py create mode 100644 custom_components/wyzeapi/camera.py create mode 100644 custom_components/wyzeapi/climate.py create mode 100644 custom_components/wyzeapi/config_flow.py create mode 100644 custom_components/wyzeapi/const.py create mode 100644 custom_components/wyzeapi/coordinator.py create mode 100644 custom_components/wyzeapi/cover.py create mode 100644 custom_components/wyzeapi/light.py create mode 100644 custom_components/wyzeapi/lock.py create mode 100644 custom_components/wyzeapi/manifest.json create mode 100644 custom_components/wyzeapi/number.py create mode 100644 custom_components/wyzeapi/sensor.py create mode 100644 custom_components/wyzeapi/siren.py create mode 100644 custom_components/wyzeapi/strings.json create mode 100644 custom_components/wyzeapi/switch.py create mode 100644 custom_components/wyzeapi/token_manager.py create mode 100644 custom_components/wyzeapi/translations/en.json create mode 100644 custom_components/wyzeapi/translations/pt-BR.json create mode 100644 custom_components/wyzeapi/translations/pt_br.json create mode 100644 custom_components/wyzeapi/translations/ru.json create mode 100644 custom_components/wyzeapi/ydble_utils.py diff --git a/.cache/brands/integrations/wyzeapi/logo.png b/.cache/brands/integrations/wyzeapi/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..57f0105b77c67bccc9a371283f9e78709b3717f9 GIT binary patch literal 42175 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 Uh`N&o1h4@q%WKG$%b0)qA2g{m1poj5 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0b1058468add811e9910fd2049b363f6868b7170 GIT binary patch literal 18860 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 z zhN&m6JTd)D!m%l?+ca_EW8J1@$L85X3CEtKdC#PJ$!Pw;?v;Y#*Nm?kU$eYwnbju> zw#6;mCdWRuY+EWWPZrnB71t$;8|I1|u9YT=55(;UF7No*eqgx-%&DK8m_8EUeCWpU z8;AeleEe8{+;<|r;pF9(Pc61M+ZcClnk}3)#vP3bwsA7HWOYufr+qWWPc4b$5bw(6v9+njA%!qzz1LVCDt zx~REh&W{&(66S_D+i=HdyWBWkGh_Rk+C_WS%uvGUxmLMo-0`mdMs?zG@1oHgXS{dI zD9heI#y~;=1S)9L)9)3PW4_l6`As7jQOr$KEz&zHAb)?u2F!Q3A^)JP84v$Nbgso(C$mdHSVl@qa>H1sk!n74|S_>l?5Q3Ws+G$l1fH5H&men0r440=0WK8z!4| z1?nfIFhM-4PMe6pQ*csxcVdVJM5Kex*Ck(Od-X%@6%_*Q0Xw*v@v3$Z3zGbBDLcfj z(|lwk_430DRxfHuIKX&u2Sx?BGIp$p=mMbb8jFrwq^l^*PLt3BCd1Hp4kjuZKn5Y8 z0-54w(vL#aDAA)QKx6 zW_Bj>Hpkh`llpmG<1%vYV+r%lIJ>Ck2 zbq~B#>l2h_0b!g*j>rP(gYeV6#XmJ=ZVu0A#rr0A*6j25+S81CC8Y znlwNJFs5vez;*~eTaq5NCY*lFy;9k{3fVUlqMCkXt9{L;ddjb&tP5}zz&b-0-KU9a z`zz%c`Lvu-ewv&q6EyW#%XI+$sXU5Y%d7)E$pe6lm7hfqnmg-~PkBW^Msom-lDohhdY6izS zfXoojKLa<(Uw~{}mkKxNeoG}Xry@a!4+7W(?j6A|T!@CowL}`%kLO8VdY2@eCCT38 z8dtlvxW)~n&~qjV(G6qRsTq;uX|PX2^`!7w91gLY+sPvg5#4aBn)J0BK?czjiP z6omYLz)QN+9Qc>82xY%$l#nACif_f@HY`>{0efC++~;QI#QPN?qvzW2P1qwLg*0XFxa-uYs*#x&XFt zCfZhX#>sQv`|h-3#(A~$jlDdL9UBPr=Qdc>ztGwf^ zo(U$L+mpH5CpF6!+oflJ@a#<9n z68ZZkO-s&-q_cj`S)Xt=By&OWElb-y@q#V0!?P!^Z+|y-W5?f*UmuP;4==Et${^SJ z7c2+k>_PCGf!70feoil%N+w#DYZ|WQy{k>u?4PgMKWC`8ade_(*OTXuX_Mp+v_Gf~#9cWqGkYfH%kwR91#CoDVu z5<~uYzFF4}jy8iC^8&iv$h=qBtc8=Cu2RhR(d}mD=Kh0w;N;eBy4}j&+M`B#zoR{0 z^GiG3ZfAe#s6zVj94No7q1*Gcx3$>CZ8MFzmE=$}UvoQ;(D~Z-t@_&~bi2!ZyVQWR zTZQ>%J=7n7St%(cAE|q|2NnQ4ae@+H;~wJ5|4~posR%eC1cp-y8cr=}Nyrm}Ay1r! z#Fy$gt-$&yL65N>U>o&8C$PIpOfwRN2o~CS4xBbgFKE;GSlb#bjl+XTrtYCo+e2rE zGrjaTvwCANS_t5C4Kx*X7#JCfL0BnyQAHi6oFgO=bNv2+VTf785JkT~hT~RDNPDO# z$;Az8cGcDd5&agf$IvwY1A3&IX-(l>lD}?$v*>m3N@B^wKn6DfUZ5!Uo=KPCg@VHM z2^^5}L!hz(5vm`0kdjZ?r6+!l6x0EyefDh=lnU!o8raK%IhaJj{55(|<`9L=iS+iu zE|^UajO#?Bj;xJbROZ!8`ceQDl+K$eoj)4 zKM6C0=mA)-uROy(CaUVH>37Jz_dxsgsT`iA3_;}CN*=N_M9a88IvdDZ8zu(vju0qD zAU2E)^Huyz$2b`@0T|I`0tMKl!pJU?dR!?Y;{ajlEd0~U&^D^|X?rR&Dx}E%D!Hs5 zmyslSOyGNMR*#QAJe00;wK7`=}1yhfIW*8abQupEJm-9c6v2yy8I+7{DJa zXUyyddaX}&m2t?d(PhkDC(8D3m7Dk!-)S~@=+lvvHGPdcFv)iSzET!oF5RmBpzWgi56Dy1Qo-%WqftYjwx>ldO&>WcXXEVs2xdv@Q~2DY zXaI6|Yi5D)p3_AQ`=Qs$6cwh!nLyt$Zt4!5cS#>oG`S$s00W4nkS*j0DP#*@Bpv~9 zcN>0$0$-R!28JWCkjNq#1?TX%<;g8=5&nFD4~BwBjjQSa?jHqb_Lk;BFapMNGb?z( zxEQx)mh|uuAp#D$pW;w&Kql&j0D{9tFnq*dl(w$?=Z8Y05RD4f227OaBfO}Ao#?TU zyOKC}(_o)Rb5HU_BVY2Ud`X;`sE$UWJQ}~!4tKt?lN}2Wj)sE%6m;QrP(@S+qEQ|J zyM$w`3NDddFkqR0_i7*#IU5d%28jOgw;VZgxT94xBHh~x-?tp;7IjfRgmFm3z0Ch- z>=`!WMj~Kep~V3oaKLhaI~;b~A_27Tk?BO8$p#Q;#G4V#oFPBNF$D}&)r_2AO`E!2%+_$o1RtOZjg zS4t+eDdS?=^hV!o%}@Jhy5r963vAGO z&be_`lW^8gwl7(nS3?Vy%4KK8%cJq!syp_A>HHt@5+v0pxPY5c|UtHIa8KMw!o z?Chayj{o|^Lg{16xp^4wHob8nw_@2@`tq50u4@Hqw_p0si{F7*xXZ_;a%QZHxfL(x zEZOs?bwAoW)4X7>UUn2tbzJF~9$awLfD7?r8$>VJ@{`v3d29Xb`J{E{ymjXtTSdZF z6*pC_Se%!>eev7Vk1bj%CYcqZ{qng5W65%1#Y|(Oupw!0h?^SjIP#MY_q@Y>t@TFh zg5!xv{Ss?Uvc+?3@ztl^E?QuBESd9@=CV0+*^Dk>-jrlF#o0}(OtJn6dbt35DriXB z8YWv-@`|VYSNzGmO>=ph;_jwI-tNh^Pj~LU?3)h$=)}yiH&4EP^5eYiH+J6M_ujrq zoOgj|R!HPGCiC~tcXlkmvvksbaZ2gR-^zP5Jlzr=66V$j{u%<4V++8xcRI5MV*Fp396b(zcWGhPA zs^@HQpR?z$>prmG_{RIrc#St<>qwe97EK*1mI4^)&#OAD{g>G1z{k%Y+|bR?H%pq$ zaCpD2*$IbR>TQ_spgRkgTaPsx;N&(%cNQ_XX$EPnxr?E0JLt|*=5}5U(t8eS;P5M& z?ks12rBWlUY2MShQS%`bRk9zd7^JmExbUHs?yP1$v@u9K)zIIEMKtE6G?tW;ypqQH z)r8)t?V|M`ZlOCH%pcZ+LrwqTE*0jxNe}x~(Dox5hx~{!cGK#QEahE$)E_m_UE8%E zZPg(Cm=^2ofjBtz|E6)ox2W#73>Zm?frk)DY4H0;BEc~dRS6%4eQPW*oVsGcXiGjO zfGi1(M&L`aVpub>k%-}vZQsm-KJZ8Wl3PMb2dbtzMu1*=!jABBFy8)2T~8#3)}`0CH|Z45-k zh58Qhb;-6jTv}^w_VL;QnU7Wv{iH{33D&QC18@WjpdjNk zMP}B3UQx@Fl(wz2t#I0k$!axdGObBs9pWp#F3I*#G{(jOTS_NF%xydZ(}k-wi@D|Csjh9jcJ$r0WNpiQZOfdw<{$DV)yw9*%LCI* zv+QC~L&CiETFat&=k<-rrtZ0>?nQI=r23A%cD6BL2T;~HXK%dLo3!tO{|okA*T1o3 zcT6=-HC}F%(dA0vhS!|0I)73y1qgH5?ws25(w>>VUzmSxUa+@6Fj9^tsVT&dJ1QRN zrE@4s*mwOU_B7n`^DTv~we=RFy@Fsze$7dm_Dw)Fw^b9sK`*=J0)dWN;AKWjA(qkQCw92*cr1(uWo%hzWv| zda!Q5c0e5_F`MCBWOytpj71<6SPe87HQ@+`FiD1#Gj&!E<_vs~lZxz#NxV#qGnD0G z%@F*N0`L<{f25#Z2?&VQOR>UWuZ+ebJlP-@%N|)-+P5MxM`ZU%EO}&AGDY+ji^c2f z;2$?2fuHyeq9ataKU4)?E(@+fE@tT96m9G4fE9?^LCjlwVJI{l1zVSQ;5F62Ik771 z_4?GprHnG^HC}f+sjlirL&Et8f0l$)iKW@Q6H&i{yYNzJT_;Kkch3ouxNtKudo0Y;I9h^ zJUClnPfywzgouq8vDR@>dv3DR}Rns*68_G=n8-GI?KcNafr8fVTD)@xT|CBmB zM;-ozI`Apg_X)N86RPo3ikqXjPpH~Ys9m2>-cP8lpHSQXFJ=FfDqk^JlZK*sL(#N1 zVW^(at>hI6Kv%S_vZ1}kIQ94 zGnxcjGpiz|SE^dleYN92LN>fpx`Y{|4B4|{ATw7}NQ2Icdo60B>c zay3T>6M(>W+kLyi0TakwEui$q3C$`?nJXp?zcyPhS4khaCCp_L#?Q6zry@Qdr{N=% y&l@3>{Btt~5`Vr2BBwrgVra_!rZ$=`j616DQ%KzBR8HDU$IWHGr;s2|_5TAO831(v literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ec31a0015bdf81d4d858408f6687bdae86cee0d5 GIT binary patch literal 6174 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 JJ|di=_rGQ>s8;|0 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ef8b205935511811d545d6c3ab4b31f5be1f1aff GIT binary patch literal 16005 zcmb_@32+-%cHnQI0W=Qc;C+hVDMBJaNjxM`vSdgkO^!s0Zc>yF$_NBNBxDf4bT=i6 z<4G7#5|f@uNX=*>&a7%pZ@Jc-TJMf#$Ei3eXJdJ6$F-HJ1rHl^4^^W{T${METT59| z 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a9ed5cf7019b2addd15184f6b5368ace203ae12c GIT binary patch literal 10578 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6953f3fc0f1983e569072b7a09a68522e654fefa GIT binary patch literal 30155 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

r*L*LMEn^&$*mg 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 bKd0lH&sE;%=>6b%<8yl6w#<>sI`IDi5}Bt% literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f7e5a1a51a9c75624ffd9e3e320d773619753492 GIT binary patch literal 27526 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 BGCKeO literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..535cae9ff8cbce390f20d36c38b332a809ffeb61 GIT binary patch literal 9823 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6ade16564e3237584540e95fb307069c375aeec9 GIT binary patch literal 1146 zcmaKr-*4Jh6vs`{LVg$%9MDL&c3zvQ%UYZWTDquHD#tj96$he=X`4yal`$9M1#Dzr zT7@TG_Ohou^0Gf*|BbaLcAt>;4^-+uuw$3eYLj*)Tc7W_=YG$*I`$(Wy~e@%Re1dK zFUuVFS0w&fOLO=z0vL0MqZ~@m1X`j?XqhgfTl5yXO>d(lO`;S{y-DyKS}85jm6%q6 zR%2QNT8n8NXgww#h>s}^ltuy-P=;pE2HikennfZN(I(wQxd+@%esrLEZa^)U22#iK zC3;DuSJxvVop^mBDM8@Go09HQ()BIsc&_ApVwt7hLAEt;m@o%y_Z;F<##^>cf&f$R zinuK8la5b<%emwi(&-bHydu}wX|ojgVBpb~9GS_kN1cvi$5mzOb-O(>O3OXZzQV&n z+oGgB%E-3WC%&}^2y)NqUeeFB?Y$-bLg!^@gHYTWQtG*Q04gADmW>MHxfgjGT|jIN zdo2vrtRA*Km-=4M9BuExDwRrm#R!U(R;665bSieSL?Q+RG1{(>C((Yn*m`P}i=Fa* zxk&b`{UUj~M@moHwr%Z`MONwmvT8dymcqF0*%-5AwSK1PHMXwSYe!f$bggDGUQt!8 z(ZFW?RI9Nx(vFbUIGIa+A@wtjB~P^rtXEk|IoB~TE4;e+NyD|esUPX8V(Rr8oI5@? zG{zfs^%OVHtBR>rnV>3X8d4TIZRp1*<|nDvUutL}bI^ch;=`tCg5f!+26$xST5}`q zWz@QO*fb6?R8kBKS29uEVEL+cq%;ll-{Fhup`k^=Q`|72Dco$rO~jAkrh-h2UjH{{ zevJLjeTe=z_WdqQvg34j?O{N@KFqa0@LZU7usi>JqlAFUgKM_(W8Z5Jd*r~s4@VR5 z1i!)S{c<9a_>-&7R>g60D%=~d&oXz%{B+~%ae5llY8E!uHpi*iz3s{C@Y{p%%NHS6 zn2Cj(!sKq4c?j9LJ2yW}UWD04V_~|vb@Sas4mWnd;B&qsO$y;RmGFyaA(xxw9)?_Y hmfs0Eaa#B)hiC*yn$ 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d44781201074454537744c0896203acd64c9e132 GIT binary patch literal 15583 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e0779eb62dffa854cbf0d0965821b2baaabbb583 GIT binary patch literal 10707 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8731761efbaee928ca9dc49887b4c6ab3afe90d4 GIT binary patch literal 35472 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 QgSKK*Exvq!7a5`aKP)%#jQ{`u literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..460fc0d24756d3f846f8bb26ce612c0af276aeb3 GIT binary patch literal 8627 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 U30qG_7X-oc!PVap{BoTB8?RY6T>t<8 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0e00750fe0580b62e1564f8cf2b4292ac13d869d GIT binary patch literal 38863 zcmeHw32l|VL1lWaWXq}479cRDrR>)3 z_xC&I4KM&HiPFZq?gIMt>(}Ss*Z+V0chBYwr;UU8&5VZ6zBa;fKcN@Zm=(v$0RzWf z;(VOQJ~9=_Wk8p_CR=rMMiL=$rxd(5I4ex@Evcbb^S{LDSp?sPGoxh*}mZo6pj zc8HE{r|9g?5Hpy6T2E%TOLTQ-iCNul(arpKqevAnxNtYGgMJ(b;6Vij{|_EdM* zh&9aZ>Z$Fn6YIL`#d`Lh)w8L4v$&bL-8~K6jbc-Gv)IzTMcm5#vU^&)w~5=jw~O1^ zyQgPI_fByq+`KQRXIJ-bad-C~aZmSNac}oNabNd-aewy#@c{dt+jFq{ka(z!J82$x zN+osTS^x~I=N>Qh1x2^z&l3uc(O^dwj*d zk`{yb$jL*#(mc*rmdA~|wkZjVM}1!Sm6M;?o5PiJz6!ioo~+ttRPuV1`BuTVTIbux zd~4uatMlz=zIE`e*ZB@G-%aq{tn+=$XRPGv8)v)&;{kDOQaI~{WGv#)ELDi=nf@(crik=)1{F#(CM;0VP! zEC`cAy-BjSPmiBE78K5fMuL*FBXBk-1hm&q$6HiP`o}Xx&3Na59#Y9=*X_0dwmz)6-v~&hp3GDK7@WNCu zESV3VLz(LhQeI$MoIJ~F8}_42XZ)jb@U61)Uqm(HZ&o2Z|BaU!E z%9OK8cw~ga;pw3Ej!g0c;R_QZ{?Sl)Du5Oa3jPtfv80?eU&6tOQDpAyp+t@a&jkgl zvqj42XnXXqug!maptEi8a3|`~-`C~u=C{dBmbGe#OQcXn91o3 zhym}EFnKOC8XWbWy5Obmf*5JxAZjv-<~-3mFwj4f&K`}&j<-veBPaarhqtijkq-5_ zb%X}2GH5v~+%(iyF2cIR4RMbfc72KCM&Vb?J;tBmef%)K$i`?D%_a;*%TFn7$#f@63mW?v`R>(Q&2s9^ zBhz7YeDs8~QFWT~!jP&}ByJX2sj=L7Fp!~(E7sG#8|E^Bjvl^%-_9~Mqih#oW-`B zPR##=+J&>!q63=o_O{L&e`Z4!F zLE@Y0QrE84c}>}d%mYPgsKj%~-OBq+SmCTgHW#PHR&zl+!MAG{$4%LX>;u$`SDE2b z)uWZ)4d-dlwU|4KoP6df3-T;izbBrDKD3G#trh#g;EQzN|g*7yE3^_oJuWmq%8a0*O7|f%fLNT`v4eHg9CyuVAz&fKcVVK(Q9Hh3~ly%68p79ydaG=|)f;D4#Kak+<3;M%=DXY2)o6LZ_t~ zYe)m5X|+jE`zWSt%6%wJqemm_wP~ql3txI3M>SPRlt^cIznaGh{=|iC%G|zs1Ytfg zU=}m`%qr!|Fc(*1K{;&b{VucrgxN1J`>V|M!{&Q`%dAq2tjPMr2m z1kW>83Y@feJaqaD<*u*;eO-)IQprvn1&y1PbFlXD@_IbTbv2>a@mN9CaJpxLT}m)Z ztp=J~l+o|SicD)gGsg@=yxM9{a+l0y+|d@L=W>-CwZ~1q4?=|#Y~bkhi0D5%2|+}1 z_8%YY?C!yfx(_6Wf z?}dpWdAejb|A(I4{9x*aAZ|?85Lwg^!yv9~Qjjw{nViBQgskU7p6h`bU`tn zx;hu}RaYI%;rH)Xsd&6c)ZT?+ZP>feo7udm^e&kkCdnj1J`{3L8p+0_nPY>#o&m{o zV&+1ymxc(~>(G?ihZxzwX+!#y9Au~LKna39g>=I^Vd;8?r~Gnf+@AUD?kl^Wx5x6E zqWMh`dlU7GEeqD-Yr}KfV%C<3wdE~OxzaJb`}jG}K3?J*lW0kOmW|OdIJ6OpiTBsC zHXfh4nI1eJMzb=%A{osnV-kGDTB;c(GaHRUJAzAw&}h9uCX7WER>Q=D)p*%pO3>o# z@C;YLk_U;Q!uG?e&DUyPx_I;Aw?7|k?tP}`a@VsxU+H<_h+OTba<}l`^lahl%cL}a zFNDU!KFK^9JT-kUh2S#8CJ}E*P7TPF{4hn7wOihswjTy;Kf#pQQQdyEnd+bEdU`MzAc;B(( z5KDa|FBj9)Bcl_2lVWHrG{P_kxg$yW1N|os`}`^56+8-A8B%hJPq~RCA4QUr6G~!P zpETvOfsegv0Qn|WBCWw-L2#16QB;-vyWNfS>W1*lB76z&6$Emgja}Jn+Rd%>55G9+S5lVJ z6hFaBv7M&^VKuj4Se~FV)s|!s5)yXGBTso1hA5&fQ4Ps>HZa24JJB7&9t5hXsqrS6 zLx&E@Gk(^ZX@16D!;kG3BlLSWp%# zXowax#IhSM_1>whzSJErsfd_7@v54LDL0 zv~c@;;m(+6=cPxNten~T<^C`B-&Gc$6*CqgyqGO34ldh!*`lpQ3sr5A%J$p#_IO!k z#8Dh~c%JRQ(*OJ;v%miGx%uJ)w;czU(-33Hrlp8k{!Vdotax9vcppNPefhwA@guh#N0;-s?24sArLYU0>e;b* zPve5A=}uuytgt0o*fL+ZHRjoR>Cw2!k)V*LJVBv*F3y_2==Q|it#7znGH@3<;ruKK8}ey--F`kVE)UE7wONami0vlhm!g)wV+)LOpG$#Wnws<7SV z4Cyw2D4BI}hb!Wx+%s+W$a9ITWi)W!)hA~?w@o$go-}Z|TcpB0QcjDM-SDo9%h~mA z1($d5T@-cqvX{$gUEYB>?}h&s{r5N8@_YC4w@Ucl3e&C9Y;temd#g>iwvzjGGv8Zh zdfmd@1$^&j)9Z!X;s2dXzPHKr9oJ5{|Hjc~!sELxzITi5yIEdxBZ+OM?{00|jhFAW zwAu0a{$9R!r|J9qYT*7am2GA`{>?VVgJC*_wF|RAlpUmYM$&mW^ZP8 z6SI*x{I@ZCC$o2x%@#kE&K28X>sABtfS9JYhswoDcS!|((awrs{zAmL?%3aA4b(`1=J_C_HyeEEJJaP9Aise3V6v}ipiE6Q@kA=##GC3$ z)a~dSN^Aq_bP07zPvBZ7(luJzhHNQmso_qEu!*#XY{VOp89l`4MkpMb{6o;q_JDM= zGy3pJ^nq-}=wlpEi^Iw!TSMuI@*aIk;W{0%Bv|j98S@3tPM!;Th2ZG)#3;5-)ZHtE zd+=iGAR!Oa%h-6}G~4D9f>VKz0GXXh&?A8f?{&@Zsj2yLL+% zauWU#Y-*eij)J*lJ3^8zvHw(`CRydZo6sou7w~4GCu!qNa>5Ovgf@u~3mlcQEhA-& z2Lr+cL}4N5pBBcYOi>6-gt4bbyC+WsFG%@%kE!v%Nbt<$_$ZP>on%VMb&@jVH???u z^&ZI(6oiwgz7Qe{;1dL4B^1hM8O5$yXhNW2B&92r1J~`Rz%+^=2MFrs#RvfoK|iY; zW77rdpV)-+`TBj5LuR(*CScsAkVmON7J)RX|ETQE)!P#@wPaVy#5O*>6tA4(ksQFl z5MhR1-AV}))W)WfTpq?QDx9ca_dRS!M6ie|k0?+5Pf!kIv8n$wmLJ9n=5l!jI)-d9 zGb@(a@Kz)cq58p~83-MeRk`K6l%`Y3|%hpTGI}*Q~djyBEuM zz3Pb_?2jJopD!PXI7;J=(wL(<>Zo3*+5Ku&?9f2;(7=4nV+#)7k21^ODrvrYc+ry^ z^VGlLsgLIuFXorVtLkTW&NX7AC01E4e z$+njV=S$inI+kUjV*AUZ^A#Nn*3KWrcNZ@8-Cp8Q2mVcc7N?fmT7 zNXfQ%SyjBEX0c>btfVDc(h{#6idSvBlV3F(TrwJ(a_?n3vfL<0VZ%}}m!2Q9ZjM?v z&vo6lwysno|M$YQJUrEA>&oMAWo3|E)!B;22)s3e<&>F~Kfxn_Rrw?*@=%g6JiaIz zbGb=u5{V|CLBzI*Xc^+v%?Y0wP_9c%J85Np4E|^EZep`O{iM}r&C{Ss(Z)goupGDg zY%I2&`Pi9{gZVfR$El0s#CwMBJ;Rro$BCIfs9FqhVwNupoQPXEjiS$#c8mi`Wb+mF z-!ySindO?g-glFb=S`CGQu9MqNNyjWnj<>K{|c5qpONuECYc8!Oh+Jyq7RG@E&Jew z_TV8o{r(Xu)MyCko1e+_6*54^SRtu&jo8_w-#+|rNF$6b3-0H{E1Aue%Vd51{1QFp zz&YU|b4q&thummX$4Vz0m$(zI2|Q)v-n@NkoSiPCto(aqh zf$-KZ`w6|MV23-XJgz$rKKb zj|mI}qjJ{N$ik!5?++mdzh5d$MH2ZNBM0%Am$*B5CC@nGxkZ;v&)63|4S1wO-n(qN zZ7q}uLX}i2st|-*@xpVXc5UV>xtj*LO{qC=@&a*nN^1(g3-_8f6n+oB|43g*JZ24L zFc(2CF&8~2vPYso2*3;#jI|I=`qa$^Wd9@^9h;vi^07^8Z)i+Mb}vwDrf4F39WhYI z=kZ{?wn9zJ(r5=MZ!Ia=ho4e#;>Q5AS~!>I+0R}19A>DsnhV~XyIva_)Ie2$2RJ%@8>DCpq?WyJzyHfCYyxMBEDprAe8C1$>}&7n3x_5j1VUw zNLDI;Z{U=SsYndF(U0E2$pbvSh*xhYR1lI4l>0pWC5o=XHu=Kg)}=ai8)N!ZO>mpNEBJC^A2awmIB6cK@$ zF~j~Xnk06h(6TsHv`$A%r8q$76&Y+N@%oK>+C0Map>+-RMx4kZP0P!EH6DjPao1jKRlG84emk zd}4zUsX;j)Ikmx*aYEc)V+j}H+2)FACVLPXB^Zmk!P(R|_@xVm zNnwu{T-Y^KNAd(Gh)ebB*QqcY&BE`~cLi_^L-A;I8sX&m6Use)Y>7ds%Z@+-5k?>Z zQXpALGO?aRLhMZ=n-PSU$=ism<*k_p8BLa)GJ8mcqgkSC?Wjl!K>aIx1s21cP3h@d z$s7MrHM5FyJC31LATIjheT0s_0ps@4W5OF58g##9tUr(U9Zyv=K>+7Ix7{W zn3vR#ZlX{c%XyhNn6>MhR=)eOSnucP$~-+0`nBnxx2F?Yyh2KbB3%xG&9YPl)IR1> z0rf1_g)0{#*8H{Qq_y#BT8F;^hrAA_W(QTg=X4E&pHi}4si4i(E3|%;Q>(ER8hOh` zS%E!FnZr2aKF%2Tq~)!v-ozFuWhF~m!u$cCDv$t(FI~fnDVT-4mZcJX;OAn#-NH9i_`=uBe@VFPw>Z zPc^ie`Txkb8BH(cz=cB7%9~hYS@ZVQyCsMo#DGHdu=z=@j1=!ADtY5Bg-EUMZq6FGdVpz>Yth%A4f9Gi*dEzM4plr`WL{W#0$cU zRD^Gl^%rD)hpg|D^#ig1FLP23ld) zj)}p{T7+L1>>c;>FN1tPG}Yb7FNv6P;#t*~9=T_N)W{^rr3}uJanArrWyJ{B61l+o zS~8XmX=a<^2iH9}I7zlh-78dG*e;@Q2D3+t>rvtw%mtcX!9AB2(y0v5f@j(gzY2t~o=hkGLzR&fZ71z44AV9v z2ts#)z@dE8!4O(JC(;2Jp}l9|9k7JAS%F840?CvDNdbZf)`sGZEWj#7)#D$B(V4hy zHSb>LU|n~<;(XJ)e~H|;&ap?tH^e^4^RC*}5Rugr?~avCNDCl|cY=JG0brt1gc~L~ ziJ8>1vMN_3)TAHbMln@GeNm%N>X@lEk<`>MN2}SgmGyy3p=~*7r66X7nU7uyd5xdR z(?LN>Z)%5@L{lG5Jfx{7L9Ck2$fL+zAQ?@v3W4)DP%$D31WT$y${qSncygGU2hcGC zgakHmW)lmbwZ9Akv9>3~Tm^yh%bsz*W6!*5`kLc8$GpAd#oT#&?P5{+Y}Jj1>kYAr zozaS&^F_O`PV_E1v#wT==rZptdvV9Svp#h&AgURg)-KgVT-IfN#=6Wp1l2@egjP%f zXQSP}(KE^du71`%Q|&>f5}6Zv`;T);x~rHg6Pov!CkT%*CPe5|yKqVA1pP~I^}k)${Md9rk?fbGry^07xh?{&DJlg z>3B&^2Wp~0XqSed{EAaoqy-BdSklWGCA8qoJ!1BtTaohArawpU;^MPJINzwi=8$fsv%=&_5`;b7UtkR<5Y_?K*bSmQqccE)*!6< zbm1rDZX>IMtZ7)18HEUmGMqL{9=SNN)OWapX;bwb9z4R< zMo&^%BPK^i?3D4VL)z#BV0ak;fInt%4%f5$uI!81tE2Yng?gsuqT&k`QFlemT^DuN z&1KKK8|MOVx?3)H+;L%y&t=a#uYDozDtRZrFqXe1n!hEUUxeM6oZKa&+3SXAl$Cd_ zan3k99m#KuyGmzmaaZ0o&mRLXr#YI_jI0PMv_BN6@Ie8GAA`Q10ilIgsD1XsN|N6+{O?y0=7ZyZ7-l9Uxyq>P z*J_zVMg>#F&}};qZm6Y+^tF{Mk%`J!X-HuckV1mNm`UJ+!`g69LJDcwVTK`v#D1(Y z>mr4@YCs)QsHJv)q)_-zh+ppz-oZmkQ-C)%0wn{aC4&4u6tuvk2%Ha0tde>lVdB#? z&VCz-Gvv;qAa{+=HOAbvQFrZ}=_SWahaSHxtGwL#Z10ucXZk-e46lj*Qe9gK_btAy z$n?i1xL(=R-o)Q3;^DkilH0!7c&oLb-C=y);eh+~0-o$bM|+L&^%^s|HyaWDlfm$4 zNS&b}^;gtjzW_suAiYm&ND-v>Ne-#k&<5&|vdTkhY)oF&XleNfgX%koE)}dZsK&-f ze_@?b#a1fMOT{;f^%z}Q)%`}*!^7!TQ1+yG9t^9@v=W+iiA$Vzzzmy$RaIy31j`CZ zqHqJmDFs^x)i8c5wvAKgkc!1+*kP@nv69&#FR>V_VZ#}VcVO~-5U1q@CR;E@h6D`Z zpU8RvmTm`-F+){td3?@N37Kkxtue_YD)>gO0hE?W?JOxuC!7CHV9WY#*0(| zm#zYAKl+Q*8rXo-7yj8VT=_!8TDrFA)M?SF9V^5PthaZbio|NS1k3t&h&q$jD3@EM zIB~#hC{DOs62&!y_uo*Vi&+`4iJqM&?}!n{8p*1h-5JT;9IB!Cl_mTk(jX)}jNr-|fLOcqcg$KYNQjGqM)Atz~)J*YIXNn;Rm^FC+h_x`O>1nmt z`cP}3rLX3La+@DQxh-mXA5*!t^woS&?zD$cZmXK!$5d`DeKjAHJDruA$82w#QD=Ka zgeJsX#=7O{J}4`qUTBi+!Es!XB7{b8r-sB=OKI&08^k3KhR~E`z%etEfU7_xlkeEE z9%vVZu`y$UvlGk1PtYwTry_W=!=_=$NN1LeIFP1Du96ksp?fqosTW_RT%2PPl-+h6 z5HFSM=C0I1ev9UXY*=h2_T*l+-oZgNO~Q@Y>!bGixyG1%*Bka-ixt&zmxq9>m}^ti zwJGLmin^NSK0oi;|C;elR|ifoRAyZ6zWV8?qYUV}UQ|{Rs*2g0->^3?mUw49H;S(p z$IACc%lF304@S!mzGj*)KRjR3g#-q#^wD{_)y1fZ*|)r5-?CU%F}v-?zU%v9mHVTW z`(u@D(aN^hYUV3@=F1*I?4c+QxqP(fcQW%XXRe(NF%`<&U*Ce`fwsT4Z@`SCm>w}L zS&;N9qjt*Gf-3lBDiJduXmdsB>l7TSH|bdJ7Ps0gmCJ1!iqc9ov^)a z9{w-+Hn-`oa^Ommm6X-=!BpcgWLi-qp^Ge9ui-&k1?wor{TQEaP(6q*VMu+QdB-vb z>t+{w$lXD|4$;OP8o;q)f}O0oIIJ}cn$(1Ft4socr-eTC1tpO0jYGV8RTR7OWJ8o8 zaXe#)PoN9xK>~H=Jj{>QE1goq9TJ6moZIy~9FEJ6qRhB@f=hK>2}(=kTxE8h2Uz@5 z7K=|MS_R))|J#(oXP7eL-$WOh3>gMA1S63N4ls1mftC|PKBFE)*01u2Yw?-%Ums^j zSVkQzZ;Q{Y|2Abjx*43n>KF#yp_jevdvYw(QS>M}){r=0lJcqp3+O}~*D9m72W!t1 zJ!3$-5G4^Y)x`|M73y~!SV@@^U~xXxzKzd_3l!2pnIh zEO-O#?gE^Hz{%GdHcud$1w`OoP>x2sL+cR6R)!-?8Uh9eaKZxcI!i`VQuQS4GrS zG5gq?t{Py`F;7j@Q!`f++jJnh>A-^LK-^Op^HfJY)o*yV+$pP$m9<36TCSQQnpD`CoHB=Zuu@d9~}W`~SRu z!PQHwO!{-_?|NT6f74t) zdCe?2({gg}nr%SVbGeE-d3}@BI`(spngs6v!ZM?+T{OPe+phK*m{PZLSdo;v`-Jbo zzW>q{rB>9IF0;9n)a_ekt^3wQX;@KU(ZusKEnODGQ*UKREfm35zvw9`B$Q_H-J&L& zc&6G1Or6r>rMjsmby^!Nd#ib(G9AQz48rQKmLybmSBGWlyM<8UwJ-%D!%We!=~Hy` zAC8M*Q%fF6UN%O<*f6#VO(2%cr)R0-%dg%YBg5q``PM#Mz^5=%k_VHYpq?r4Lt{K} z8p`74@HBL6)U!{x+a)+kN95^t8I{#fS9EBUlNdG1NetSKlGTxt26a*rtTWKAGrNT1 zPJLsnz9U-SaoO~&JL9Rb9ku@OM? z^+vpWDur_NWEw%MjLN6W8$L>(Dm^}-_6em&u}xKW7A`Q3Ze^5vj(b`Qr}mwcoVZjZ zbKd7eP^*$p)cMB*-1A3$N2<|Dd>e#gv?@aJnZBCZ8|tFgx;X|2-?r|MuXA~-Zc}pO zQMHM5E5z=np2nIvI(4)cn(MU4DsF?~=Hbr}7;p>9F#;p$bwdqJMF)jSv?N9WfFbY( zxUBU4vmfaXg~7~`2H8^_lq_u-U+h^<<9Hs&=(BR7CeLy@y*M~eUer|ht`je;ODJ-; zECo@%tTF0~g*T9xFi+M3S#Oecn=B?Tv5+x({U%vIB#UH1fw)nfwjAwk%PMm(lIsU# zc_r zKo%p54PoAs73(%ZD$gmz#RvstF(tcuCg>kA?V}|R(~&FFbm`z)f|{G9;M7xwkEdyZDdvH~xH1)rdBTqn{H8%x!J{3M4W|LAo@T!FuHQ*Mr-;I>ko3f) ze51K^8JC)rUUx3h;}xjl;qh7(^OTnjwR0c}r?7F36&k$BGXDv0LKBbRn45-~nzhFy)!7URHgd3M!`#sd5l^QMNmzYZlKd`HExh>PMm*vT&?C z&w}3ntX!(~PCh@EnyX926Ury;)(Ni(lPHj4=sey{i*N(4OmB$oj;+!Sg8F71eg6qr zY*XZ)$kj&H3$XMU*0=C3)on;dMYbe_^}iD(yAcI)k#@pvjn0kxzUnj^oV?Ry0xz=J zXk?I7Aj~3=a2?i$13~CUx!3g5*)STwG!v-0;NVMu(nc&7@5m&J1!sN{OiNg#Rtd!2oZQ zHNB2b88Tg^?NPUibGtiYj*f_<1B_5*t*lkC;3#7U4e8Q{+m71hOwQTKKdf_4>Pf>| z(o)JLEhP_WDdlpnRJPahw>&(Yw{mmatBkiA3fj%=bYYe8bvF;6*Rvh%WyaUb%;c^z zB7D*s{;`fk1LnaLBM~PJX(VFO`sGN30`Mn(URbz=4&K+Lo;iGnhFz|zyOd%~3Vw_V zfiyHf=%GV)v`E_gz~gUhjJnJ^18_&wz2jx^&n~`lao*h-b96==oiqUJ)*XO{AKCyE zPNDK_+<;chXzU_Q$RASw<2^*3$w=a}!(bR0$HSPN)qKtzBFKBc>+-{dq-PKfX@`OV zzp73M=~_mz>gF;cnL8ua6u=3pC^HKmTx&@9pNK2`FS1sb`TOvc(TjD*TuIGAV3#8AG10E- z<_;3{s$L!?K~GVRqn1#O)+*5KLG|?a(tzcn!ml#PEEo9arZrn(-t%X0%`|>-h0aq5 z&_=^H*Mx!!Hjxnc6Qs#zP`F7l6V8ERvZ{x2S&B#g$rR;US_LVTwrtobQ$?vDqU0b8 zZPy2&uzXA9ZL4xk54D!sVhTApCikus_H4+`266JAHf&;%8$*+vK8Y_x5GN21@L zAk0`{KTTpQO>r7%tCmp?!|V^Z|IQ6i%BxJOD-qeAJU8H26%?=PKD`bE#*QevdBZqU z*voEqW4ArYJy>b=u(B(9Gfm2rIZWK^rreWOiBtvZRYDC}tZy(0m((ZM3|qD@(6V7a zkTXcyDPZ(JjN}y{IA$%5T8kGQRm$$zVs?4NT`^le+j*n+dT*?1TeNChq;h-2wF3d< z3r|J1B+*+X{<_ z!EgxW;PVKjKo*ra{5NnY2cHkzvjTf!02H=Y2^38_*}UY2ADw{CQNT{P?peVcGe-Gd zzUo@aR{+&rTDV;r$eId_t<=I*YTO! zvxlq7B4eMd9DQ0j;Lr?B??~`pKi(+}Q28Gt>jji~4ScC%Ka*&n11|Sda~w+|gGvge zIUY=b?m+>^XOVh0<#{|QPnGtRHzPguR2f_pPEbhN)?HIcnr!tvc2XsE;NcOc$<%f8Frs4fF0JF~^aJ;}h^hOzoum%Q5IE9K6sD*lH0}U#n86pBU>~9B1*ANa(WneG z8DrZ3Ok0nT8jMB=&sro_J)L(T!nE!ZRiQQ~^T8P%I!P+rA2lOJ=K_)NJcVZ@B(kYS zBjJaG#)J!$>I_+*gQbVJ1d^fk8v#j8n+nHzl8X83l$O@gcwt5<=EH=nh%moSIbKT2 zQ6&sX8BhPB!BfIhRF|j8T0PJR%#d{3qDifh0!?D1p`)h4mtiG|sajvH0gWCE0uruJ zO4kv7LlIS=Mj?S(_)Q8=-9VsYZGFNs^u8K=8KduHQ4~G+(*BUam%SQ*vR4CD9u$0O z)#A2(5b%YnHG+T57!}vbT{!-KQET&gL^o8~vVA!KTylkeR6TSBjXhKQ=yW(Vf)#r- zsAyg>K$bXtFZ<;`?=c+RoN7wyxE4ewGJY6*1^t%vTOQ!%@V(RkfXXkp_kL+tUIiFwLXq1yI{!YY7w$4VDok~cGSNVy@El9!5SuRd&8Z#nzP6^tnDD$ z=+b&vvZ@%nv>tB>{sY~UnP1{Tj0~R$9^^brQ#<>~V7k$lW*Etq@cRh&RaK-McY3jR zf_0B)kfo{(#_pVF+F*@QTm(1z()i8sdH3F!V{gQ<7gBHSW>p!bGU?iRXoKy0Sju1t zT@-X+W;7P83a_bIFjWs*{=t|B#YJg|d@J=3VLDt}WgV_x4v|k!tY@Si9ZgYp)0{}Z2R!fI7jx{3IQG%#Xjpf2?0@*9 zLmnFs5faMt>X;z>_^$Wx*ckTj!W#fX1u4l$u+XbCtV&SER61Ibx`UC-LlNttwQCQJ zdB*TPd|0S7Wnnc?=r3uF{&+zl)@ffr4fVTYFXA0P$CRo+GDzrkq9koWBq?RpL^Eq5 zR^4%31YicC9}Y}pZ%mB44KSkARMilo?@{TCQKqDRpdv(jBboFg?1^shp{IQ1Or9`+ ztP~}y3^+Fyz-j*fuT(&dMS_9=Q>(gZl9jAHACHn%0!cyvpQj>Ix%GgOl&ye9<01UA zyQ(f#m$I9r2>Ao5RYFUu7{B86Noz?Fao<2O{$GNe>Wwo6I*lmSHmek(a8JUQP;;S@ zT9u$$0k@Xum7sn=Usq2846sVwx+&l)B5Sx4$*!yXBRnEnJHhxn*5!>t~w!AFB|~ zNHB<4Tz$IG-dd$KYpaPK^ktIj(M}pYIbxP!z=yh%0Qz@ylyC#3Wh7 z3sZOsB$(mn?n9IGBd<;J&jI>_PmTnq=ywOg?3Zz*wDW+40#g@c1c#7a`F8+p6xpqO zY|_P|O9~RdawV0hL6qMW2sg>UVXyqk0HGmL*_tr2a-en{J#CMt2qo`0XrZzxlv7O$ zl?;X%XMcdG@KIuvMwU z%D?6-AMh70C_j3O+s$!hTMgw?OI96OZ2Dr?z|c8$$tg}g5yTJs%J)IAbK_DvZhPxR zUq!}j{y0Ga?QMgDzMl5ubgQ}KJl4_H=kMtoJnS24>lNB5;SREbWQ~$_imY>FJweuK zvS^ND3Wp5%W|yKhD3+?RuNr_9Je` z+uY831`}^s=3w2kA;3Q)z`=W_G`w=K{@KOxt#@o$F7&-6`tAUpZFtD)SoHIHf2kc^<&Ak)y6++T3Vt^!TODR_f7^=As$NA^LjD%O*W*$ zJPbTl>Lwdg5gti3@upNWZ%(!FmV@LnNe?<^Rr@Y-xow2nW4^D2bP>hhMwGyaRF*}R zU>o7>3O!8vLQhaQ(?_i)!^{*|VT~JkeQ_JO@Eb&HITpzC58BIXw$2g%g>KZkiVh z*Ytdnu>!OFQ-vkN=I>I=Ei`>D%QHSZotT`lf{C%QF* zNZlyS(UJ1%+}3$e$Lka?_ss~T3%by*-QrAQFaMIgJgJi;y^~}2AL;U zq?11)Bn#3`a)B;Wh0f9MLqGwg!_r}LSAK%dvs}xXXRRp;@gKdtmDJ>tHYT!lNVOhOkriUkcV~5;p;cnb#2uc=I9S~ z#Ws9s^@bHxRV|+{aEe!y4V2_?{a3@ezFq- z&A)TzFy1zawZJsXEe@FlfCo_&I@S)P>?L6(>qO~(AgDIcCULGIcjPGnawjReszES8 zv@p<{c%WCnv_s?|?4o*j>Dr?&zn~B&SsWw_7)7F`Jq}mx)4nS2aB1Acp0*Hl1kTF6 zWkL2aM)?2hD`7J6b3#_>|FC|8V{x^IP{gwoZJwqvAojF0I&)8}zT@nD0RvOC|6-NO zJC#-TRaPNs43<4@0pmrqd6LE`-P0Cgj$q-i!@9jUVdSSYE##6G1*0TU*U)fGJBcJN3jw)*1q2aDiN# z>1(pQ>eTGHb4kT=XZ5RVOV#Ua)z@S?LpQCEX5PqWMB;-X3xU;E#FmNA=ypzIZcs1` zI!j*CIm1|H^*9V_0(4aS0;1Qg5Yrc#ZZ4N)=yOXpIkSQlg|VW;J?%k_TbM6yYCLA= zlX|h3n$dE(t6Juo)lqvUL%WseMe}z+p1{5Q%GdC2=RAqxC!MAH8wsjZaqY_l|FUjimdwUW%VA#!m`msrPiT z_w?4j?hi5__muiai~Xbb&88Bm0Z4?!UN!6^ZgLVKH1Wh!~L9RuItqs=89huH}#isQba1mMd!-RCUo{CeJ}Z zlrN}i6;Ie9Sj7rLiNlS|iq4k{S#|KfNg5W~z0K4Xge;mVPZb?rte?TN^Cgf`YU4`> zS*Squob2R}O3rV>oIfXb!P>(B@Mo!uoCENF4Wi?9x*<=w{eV&k=DX+EGB245$H+oC z50M(|I3amR7CzERR;2|DLebK74p;3J>evJbSHUYqa-9?mh+tUw%7q%k3n@7&yk4$YUmo|f!{;}=GDgQK2!$)t@#1Q>)=Loq&kGtM? zjgSu|5Pa+#A-_cCS5R+%(wG>KKXrE{;_|0)H}V5AsMxpQbx03M=-31hk(L+BX}QhL zyf2*sOW|8MyAcQ!RezOy?s>Z1OoHv3uBf;ueI1q;TnQdtvb;v#Tm`QSe_5T&@P7k! zwf%oHv>Ts95pFfTGzHK^AQDEY71^t1x~Sl-eY+JE!%YzmR>ZE*Zw%EaD$!%LI9lvg z4SuxLoN<+5C1$z7N-zP@QlKD1++zah>Hr?$uM7TB*efW+AhhupPe~lGojL5vdDKpo zud+GN;+;3o0@;=+rGFsxk4W#I$jBFD^br|+L{5J}&Vc04Wc)rE|I*)B@^{?xcic+d X_xEggpL<#<6tX~`9S_iU@r{20`X^ga literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ece5844162d0d243229d80d9d5ac113224966636 GIT binary patch literal 7728 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 literal 0 HcmV?d00001 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