diff --git a/.cache/brands/integrations/wyzeapi/logo.png b/.cache/brands/integrations/wyzeapi/logo.png
new file mode 100644
index 0000000..57f0105
Binary files /dev/null and b/.cache/brands/integrations/wyzeapi/logo.png differ
diff --git a/.ha_run.lock b/.ha_run.lock
index 24dcae9..2bffdf1 100644
--- a/.ha_run.lock
+++ b/.ha_run.lock
@@ -1 +1 @@
-{"pid": 72, "version": 1, "ha_version": "2026.6.2", "start_ts": 1781098592.353074}
\ No newline at end of file
+{"pid": 71, "version": 1, "ha_version": "2026.6.2", "start_ts": 1781272512.3211384}
\ No newline at end of file
diff --git a/custom_components/webrtc/__init__.py b/custom_components/webrtc/__init__.py
new file mode 100644
index 0000000..f084e2a
--- /dev/null
+++ b/custom_components/webrtc/__init__.py
@@ -0,0 +1,317 @@
+import asyncio
+import logging
+import time
+import uuid
+from pathlib import Path
+from urllib.parse import urlencode, urljoin
+
+import voluptuous as vol
+from aiohttp import web
+from aiohttp.web_exceptions import HTTPUnauthorized, HTTPGone, HTTPNotFound
+from homeassistant.components.binary_sensor import HomeAssistant # fix tests
+from homeassistant.components.camera import async_get_stream_source, async_get_image
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import ATTR_ENTITY_ID, CONF_URL, EVENT_HOMEASSISTANT_STOP
+from homeassistant.core import ServiceCall
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.network import get_url
+from homeassistant.helpers.template import Template
+
+from . import utils
+from .utils import DOMAIN, Server
+
+_LOGGER = logging.getLogger(__name__)
+
+CREATE_LINK_SCHEMA = vol.Schema(
+ {
+ vol.Required("link_id"): cv.string,
+ vol.Exclusive("url", "url"): cv.string,
+ vol.Exclusive("entity", "url"): cv.entity_id,
+ vol.Optional("open_limit", default=1): cv.positive_int,
+ vol.Optional("time_to_live", default=60): cv.positive_int,
+ },
+ required=True,
+)
+
+DASH_CAST_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Exclusive("url", "url"): cv.string,
+ vol.Exclusive("entity", "url"): cv.entity_id,
+ vol.Optional("extra"): dict,
+ vol.Optional("force", default=False): bool,
+ vol.Optional("hass_url"): str,
+ },
+ required=True,
+)
+
+LINKS = {} # 2 3 4
+
+# DDoS protection against requests to HLS proxy
+# streams are additionally protected by a random playlist identifier
+HLS_COOKIE = "webrtc-hls-session"
+HLS_SESSION = str(uuid.uuid4())
+
+
+async def async_setup(hass: HomeAssistant, config: dict):
+ # 1. Serve lovelace card
+ path = Path(__file__).parent / "www"
+ for name in ("video-rtc.js", "webrtc-camera.js", "digital-ptz.js"):
+ await utils.register_static_path(hass, "/webrtc/" + name, str(path / name))
+
+ # 2. Add card to resources
+ version = getattr(hass.data["integrations"][DOMAIN], "version", 0)
+ await utils.init_resource(hass, "/webrtc/webrtc-camera.js", str(version))
+
+ # 3. Serve html page
+ await utils.register_static_path(hass, "/webrtc/embed", str(path / "embed.html"))
+
+ # 4. Serve WebSocket API
+ hass.http.register_view(WebSocketView)
+
+ # 5. Serve HLS proxy
+ hass.http.register_view(HLSView)
+
+ # 6. Register webrtc.create_link and webrtc.dash_cast services:
+
+ async def create_link(call: ServiceCall):
+ link_id = call.data["link_id"]
+ ttl = call.data["time_to_live"]
+ LINKS[link_id] = {
+ "url": call.data.get("url"),
+ "entity": call.data.get("entity"),
+ "limit": call.data["open_limit"],
+ "ts": time.time() + ttl if ttl else 0,
+ }
+
+ async def dash_cast(call: ServiceCall):
+ link_id = uuid.uuid4().hex
+ LINKS[link_id] = {
+ "url": call.data.get("url"), # camera URL (rtsp...)
+ "entity": call.data.get("entity"), # camera entity id
+ "limit": 1, # 1 attempt
+ "ts": time.time() + 30, # for 30 seconds
+ }
+
+ hass_url = call.data.get("hass_url") or get_url(hass)
+ query = call.data.get("extra", {})
+ query["url"] = link_id
+ cast_url = hass_url + "/webrtc/embed?" + urlencode(query)
+
+ _LOGGER.debug(f"dash_cast: {cast_url}")
+
+ await hass.async_add_executor_job(
+ utils.dash_cast,
+ hass,
+ call.data[ATTR_ENTITY_ID],
+ cast_url,
+ call.data.get("force", False),
+ )
+
+ hass.services.async_register(DOMAIN, "create_link", create_link, CREATE_LINK_SCHEMA)
+ hass.services.async_register(DOMAIN, "dash_cast", dash_cast, DASH_CAST_SCHEMA)
+
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
+ # 1. If user set custom url
+ go_url = entry.data.get(CONF_URL)
+
+ # 2. Check if go2rtc running on same server
+ if not go_url:
+ go_url = await utils.check_go2rtc(hass)
+
+ if go_url:
+ # netloc example: admin:admin@192.168.1.123:1984
+ hass.data[DOMAIN] = go_url
+ return True
+
+ # 3. Serve go2rtc binary manually
+ binary = await hass.async_add_executor_job(utils.validate_binary, hass)
+ if not binary:
+ return False
+
+ hass.data[DOMAIN] = server = Server(binary)
+ server.start()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, server.stop)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
+ server = hass.data[DOMAIN]
+ if isinstance(server, Server):
+ server.stop()
+ return True
+
+
+async def ws_connect(hass: HomeAssistant, params: dict) -> str:
+ # 1. Server URL from card param
+ server: str = params.get("server")
+ # 2. Server URL from integration settings
+ if not server:
+ server: str | Server = hass.data[DOMAIN]
+ # 3. Server is manual binary
+ if isinstance(server, Server):
+ assert server.available, "WebRTC server not available"
+ server = "http://localhost:1984/"
+
+ if entity_id := params.get("entity"):
+ src = await async_get_stream_source(hass, entity_id)
+ if src is None:
+ # build link to MJPEG stream
+ if state := hass.states.get(entity_id):
+ if token := state.attributes.get("access_token"):
+ src = f"{get_url(hass)}/api/camera_proxy_stream/{entity_id}?token={token}"
+ assert src, f"Can't get URL for {entity_id}"
+ query = {"src": src, "name": entity_id}
+ elif src := params.get("url"):
+ if "{{" in src or "{%" in src:
+ src = Template(src, hass).async_render()
+ query = {"src": src}
+ else:
+ raise Exception("Missing url or entity")
+
+ return urljoin("ws" + server[4:], "api/ws") + "?" + urlencode(query)
+
+
+def _get_image_from_entity_id(hass: HomeAssistant, entity_id: str):
+ """Get camera component from entity_id."""
+ if (component := hass.data.get("image")) is None:
+ raise Exception("Image integration not set up")
+
+ if (image := component.get_entity(entity_id)) is None:
+ raise Exception("Image not found")
+
+ return image
+
+
+async def ws_poster(hass: HomeAssistant, params: dict) -> web.Response:
+ poster: str = params["poster"]
+
+ if "{{" in poster or "{%" in poster:
+ # support Jinja2 tempaltes inside poster
+ poster = Template(poster, hass).async_render()
+
+ if poster.startswith("camera."):
+ # support entity_id as poster
+ image = await async_get_image(hass, poster)
+ return web.Response(body=image.content, content_type=image.content_type)
+
+ if poster.startswith("image."):
+ # support entity_id as poster
+ image_entity = _get_image_from_entity_id(hass, poster)
+ image = await image_entity.async_image()
+ _LOGGER.debug(f"webrtc image_entity: {image_entity} - {len(image)}")
+ return web.Response(body=image, content_type="image/jpeg")
+
+ # support poster from go2rtc stream name
+ entry = hass.data[DOMAIN]
+ url = "http://localhost:1984/" if isinstance(entry, Server) else entry
+ url = urljoin(url, "api/frame.jpeg") + "?" + urlencode({"src": poster})
+
+ async with async_get_clientsession(hass).get(url) as r:
+ body = await r.read()
+ return web.Response(body=body, content_type=r.content_type)
+
+
+class WebSocketView(HomeAssistantView):
+ url = "/api/webrtc/ws"
+ name = "api:webrtc:ws"
+ requires_auth = False
+
+ async def get(self, request: web.Request):
+ params = request.query
+ _LOGGER.debug(f"New client: {dict(params)}")
+
+ if request.query.get("embed"):
+ link_id = request.query.get("url")
+ if link_id not in LINKS:
+ raise HTTPNotFound()
+
+ link = LINKS[link_id]
+ if link["ts"] and time.time() > link["ts"]:
+ LINKS.pop(link_id)
+ raise HTTPGone()
+
+ if link["limit"]:
+ link["limit"] -= 1
+ if link["limit"] == 0:
+ LINKS.pop(link_id)
+
+ params = link
+
+ # fix for https://github.com/AlexxIT/WebRTC/pull/320
+ elif not utils.validate_signed_request(request):
+ # you shall not pass
+ raise HTTPUnauthorized()
+
+ hass = request.app["hass"]
+
+ if "poster" in params:
+ return await ws_poster(hass, params)
+
+ ws_server = web.WebSocketResponse(autoclose=False, autoping=False)
+ ws_server.set_cookie(HLS_COOKIE, HLS_SESSION)
+ await ws_server.prepare(request)
+
+ try:
+ url = await ws_connect(hass, params)
+
+ remote = request.headers.get("X-Forwarded-For")
+ remote = remote + ", " + request.remote if remote else request.remote
+
+ # https://www.nginx.com/resources/wiki/start/topics/examples/forwarded/
+ async with async_get_clientsession(hass).ws_connect(
+ url,
+ autoclose=False,
+ autoping=False,
+ headers={
+ "User-Agent": request.headers.get("User-Agent"),
+ "X-Forwarded-For": remote,
+ "X-Forwarded-Host": request.host,
+ "X-Forwarded-Proto": request.scheme,
+ },
+ ) as ws_client:
+ # Proxy requests
+ await asyncio.wait(
+ [
+ asyncio.create_task(utils.websocket_forward(ws_server, ws_client)),
+ asyncio.create_task(utils.websocket_forward(ws_client, ws_server)),
+ ],
+ return_when=asyncio.FIRST_COMPLETED,
+ )
+
+ except Exception as e:
+ await ws_server.send_json({"type": "error", "value": str(e)})
+
+ return ws_server
+
+
+class HLSView(HomeAssistantView):
+ url = "/api/webrtc/hls/{filename}"
+ name = "api:webrtc:hls"
+ requires_auth = False
+
+ async def get(self, request: web.Request, filename: str):
+ if request.cookies.get(HLS_COOKIE) != HLS_SESSION:
+ raise HTTPUnauthorized()
+
+ if filename not in ("playlist.m3u8", "init.mp4", "segment.m4s", "segment.ts"):
+ raise HTTPNotFound()
+
+ hass: HomeAssistant = request.app["hass"]
+ entry = hass.data[DOMAIN]
+ url = "http://localhost:1984/" if isinstance(entry, Server) else entry
+ url = urljoin(url, "api/hls/" + filename) + "?" + request.query_string
+
+ async with async_get_clientsession(hass).get(url) as r:
+ if not r.ok:
+ raise HTTPNotFound()
+
+ body = await r.read()
+ return web.Response(body=body, content_type=r.content_type)
diff --git a/custom_components/webrtc/__pycache__/__init__.cpython-314.pyc b/custom_components/webrtc/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000..0b10584
Binary files /dev/null and b/custom_components/webrtc/__pycache__/__init__.cpython-314.pyc differ
diff --git a/custom_components/webrtc/__pycache__/config_flow.cpython-314.pyc b/custom_components/webrtc/__pycache__/config_flow.cpython-314.pyc
new file mode 100644
index 0000000..ec31a00
Binary files /dev/null and b/custom_components/webrtc/__pycache__/config_flow.cpython-314.pyc differ
diff --git a/custom_components/webrtc/__pycache__/utils.cpython-314.pyc b/custom_components/webrtc/__pycache__/utils.cpython-314.pyc
new file mode 100644
index 0000000..ef8b205
Binary files /dev/null and b/custom_components/webrtc/__pycache__/utils.cpython-314.pyc differ
diff --git a/custom_components/webrtc/config_flow.py b/custom_components/webrtc/config_flow.py
new file mode 100644
index 0000000..65563c0
--- /dev/null
+++ b/custom_components/webrtc/config_flow.py
@@ -0,0 +1,106 @@
+import asyncio
+import os.path
+import platform
+
+import homeassistant.helpers.config_validation as cv
+import voluptuous as vol
+import yaml
+from homeassistant.config_entries import ConfigFlow
+from homeassistant.const import CONF_URL, CONF_USERNAME, CONF_PASSWORD
+
+from . import DOMAIN, utils
+
+
+class FlowHandler(ConfigFlow, domain=DOMAIN):
+ async def async_step_user(self, user_input=None):
+ # check if only one integration instance
+ if self._async_current_entries():
+ return self.async_abort(reason="single_instance_allowed")
+
+ # check if we support this arch
+ if not utils.get_arch():
+ return self.async_abort(
+ reason="arch",
+ description_placeholders={"uname": str(platform.uname())},
+ )
+
+ if user_input is not None:
+ url = user_input.get(CONF_URL)
+ if not url:
+ # create config file first time
+ config = self.hass.config.path("go2rtc.yaml")
+ if not os.path.isfile(config):
+ return await self.async_step_config(None)
+
+ # check if go2rtc url from user input available
+ elif not await utils.check_go2rtc(self.hass, url):
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema(
+ {
+ vol.Optional(CONF_URL, default=url): cv.string,
+ }
+ ),
+ errors={"base": "connect"},
+ )
+
+ return self.async_create_entry(title="WebRTC Camera", data=user_input)
+
+ # check if go2rtc already exists on same server
+ tests = await asyncio.gather(
+ utils.check_go2rtc(self.hass),
+ # if go2rtc inside frigate addon with closed public port
+ utils.check_go2rtc(self.hass, "http://ccab4aaf-frigate:1984"),
+ utils.check_go2rtc(self.hass, "http://ccab4aaf-frigate-fa:1984"),
+ utils.check_go2rtc(self.hass, "http://ccab4aaf-frigate-beta:1984"),
+ )
+
+ url = next((url for url in tests if url), vol.UNDEFINED)
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema(
+ {
+ vol.Optional(CONF_URL, default=url): cv.string,
+ }
+ ),
+ )
+
+ async def async_step_config(self, user_input: dict = None):
+ path = self.hass.config.path("go2rtc.yaml")
+
+ if user_input:
+ config = {}
+
+ if not user_input["api"]:
+ config.setdefault("api", {}).setdefault("listen", "127.0.0.1:1984")
+
+ if not user_input["rtsp"]:
+ config.setdefault("rtsp", {}).setdefault("listen", "127.0.0.1:8554")
+
+ if user := user_input.get(CONF_USERNAME):
+ config.setdefault("api", {}).setdefault("username", user)
+ config.setdefault("rtsp", {}).setdefault("username", user)
+
+ if pasw := user_input.get(CONF_PASSWORD):
+ config.setdefault("api", {}).setdefault("password", pasw)
+ config.setdefault("rtsp", {}).setdefault("password", pasw)
+
+ if config:
+ with open(path, "w") as f:
+ yaml.dump(config, f)
+
+ return self.async_create_entry(title="WebRTC Camera", data={})
+
+ return self.async_show_form(
+ step_id="config",
+ data_schema=vol.Schema(
+ {
+ vol.Required("api", default=True): cv.boolean,
+ vol.Required("rtsp", default=True): cv.boolean,
+ vol.Optional(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_PASSWORD): cv.string,
+ }
+ ),
+ description_placeholders={"path": path},
+ )
diff --git a/custom_components/webrtc/manifest.json b/custom_components/webrtc/manifest.json
new file mode 100644
index 0000000..a196b57
--- /dev/null
+++ b/custom_components/webrtc/manifest.json
@@ -0,0 +1,17 @@
+{
+ "domain": "webrtc",
+ "name": "WebRTC Camera",
+ "codeowners": [
+ "@AlexxIT"
+ ],
+ "config_flow": true,
+ "dependencies": [
+ "http",
+ "lovelace"
+ ],
+ "documentation": "https://github.com/AlexxIT/WebRTC",
+ "iot_class": "calculated",
+ "issue_tracker": "https://github.com/AlexxIT/WebRTC/issues",
+ "requirements": [],
+ "version": "v3.6.1"
+}
\ No newline at end of file
diff --git a/custom_components/webrtc/media_player.py b/custom_components/webrtc/media_player.py
new file mode 100644
index 0000000..5bed7ef
--- /dev/null
+++ b/custom_components/webrtc/media_player.py
@@ -0,0 +1,99 @@
+from datetime import timedelta
+
+import voluptuous as vol
+from homeassistant.components import media_source
+from homeassistant.components.media_player import (
+ async_process_play_media_url,
+ BrowseMedia,
+ MediaPlayerEntity,
+ MediaPlayerEntityFeature,
+ PLATFORM_SCHEMA,
+)
+from homeassistant.const import STATE_PLAYING, STATE_IDLE, CONF_NAME
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.reload import async_setup_reload_service
+from homeassistant.helpers.typing import ConfigType
+
+from . import utils
+from .utils import DOMAIN
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
+ {
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required("stream"): cv.string,
+ vol.Required("audio"): cv.string,
+ },
+ extra=vol.REMOVE_EXTRA,
+)
+
+SCAN_INTERVAL = timedelta(seconds=60)
+
+
+async def async_setup_platform(
+ hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None
+) -> None:
+ await async_setup_reload_service(hass, DOMAIN, ["media_player"])
+
+ player = WebRTCPlayer(**config)
+
+ async_add_entities([player])
+
+
+class WebRTCPlayer(MediaPlayerEntity):
+ def __init__(self, name: str, stream: str, audio: str, **kwargs):
+ self._attr_supported_features = (
+ MediaPlayerEntityFeature.PLAY_MEDIA
+ | MediaPlayerEntityFeature.BROWSE_MEDIA
+ | MediaPlayerEntityFeature.STOP
+ )
+ self._attr_name = name
+ self._attr_unique_id = stream
+ self.audio = audio
+
+ async def async_play_media(self, media_type: str, media_id: str, **kwargs) -> None:
+ if media_source.is_media_source_id(media_id):
+ sourced_media = await media_source.async_resolve_media(
+ self.hass, media_id, self.entity_id
+ )
+ media_id = sourced_media.url
+
+ media_id = async_process_play_media_url(self.hass, media_id)
+ if not media_type.startswith("#"):
+ media_type = "#input=file"
+
+ r = await async_get_clientsession(self.hass).post(
+ utils.api_streams(self.hass),
+ params={
+ "dst": self.unique_id,
+ "src": f"ffmpeg:{media_id}#audio={self.audio}{media_type}",
+ },
+ timeout=9,
+ )
+ assert r.ok
+
+ async def async_media_stop(self) -> None:
+ r = await async_get_clientsession(self.hass).post(
+ utils.api_streams(self.hass),
+ params={"dst": self.unique_id, "src": ""},
+ timeout=3,
+ )
+ assert r.ok
+
+ async def async_update(self):
+ try:
+ r = await async_get_clientsession(self.hass).get(
+ utils.api_streams(self.hass), params={"src": self.unique_id}, timeout=9
+ )
+ self._attr_available = r.ok
+ resp = await r.json(content_type=None)
+ playing = any("type" in p for p in resp["producers"])
+ self._attr_state = STATE_PLAYING if playing else STATE_IDLE
+ except:
+ pass
+
+ async def async_browse_media(
+ self, media_content_type: str = None, media_content_id: str = None
+ ) -> BrowseMedia:
+ return await media_source.async_browse_media(self.hass, media_content_id)
diff --git a/custom_components/webrtc/services.yaml b/custom_components/webrtc/services.yaml
new file mode 100644
index 0000000..f952c67
--- /dev/null
+++ b/custom_components/webrtc/services.yaml
@@ -0,0 +1,59 @@
+create_link:
+ fields:
+ link_id:
+ example: fd0a53ca-e9ab-4e7a-86a2-441642b16ae1
+ required: true
+ selector:
+ text:
+ url:
+ example: rtsp://rtsp:12345678@192.168.1.123:554/av_stream/ch0
+ selector:
+ text:
+ entity:
+ example: camera.generic_stream
+ selector:
+ entity:
+ domain: camera
+ open_limit:
+ default: 1
+ selector:
+ number:
+ min: 0
+ max: 100
+ unit_of_measurement: times
+ time_to_live:
+ default: 60
+ selector:
+ number:
+ min: 0
+ max: 100000
+ unit_of_measurement: seconds
+
+dash_cast:
+ fields:
+ entity_id:
+ example: media_player.mibox4
+ required: true
+ selector:
+ entity:
+ integration: cast
+ domain: media_player
+ url:
+ example: rtsp://rtsp:12345678@192.168.1.123:554/av_stream/ch0
+ selector:
+ text:
+ entity:
+ example: camera.generic_stream
+ selector:
+ entity:
+ domain: camera
+ extra:
+ selector:
+ object:
+ force:
+ selector:
+ boolean:
+ hass_url:
+ example: http://192.168.1.123:8123
+ selector:
+ text:
diff --git a/custom_components/webrtc/translations/en.json b/custom_components/webrtc/translations/en.json
new file mode 100644
index 0000000..080ec1a
--- /dev/null
+++ b/custom_components/webrtc/translations/en.json
@@ -0,0 +1,88 @@
+{
+ "config": {
+ "abort": {
+ "arch": "Unsupported OS architecture: {uname}",
+ "single_instance_allowed": "Already configured. Only a single configuration possible."
+ },
+ "error": {
+ "connect": "Can't connect to go2rtc server"
+ },
+ "step": {
+ "user": {
+ "title": "go2rtc url",
+ "description": "Select [go2rtc](https://github.com/AlexxIT/go2rtc) streaming server version:\n1. Leave blank if you want the built-in go2rtc version (*basic users*)\n2. Set `http://localhost:1984` if you install [go2rtc](https://github.com/AlexxIT/go2rtc#go2rtc-home-assistant-add-on) or [Frigate 12+](https://docs.frigate.video/) add-on (*advanced users*)\n3. Set custom address if you install go2rtc on another server (*hackers*)",
+ "data": {
+ "url": "URL"
+ }
+ },
+ "config": {
+ "title": "go2rtc config",
+ "description": "Path: `{path}`",
+ "data": {
+ "api": "Public WebUI on port 1984",
+ "rtsp": "Public RTSP on port 8554",
+ "username": "Public Username (Web and RTSP)",
+ "password": "Public Password (Web and RTSP)"
+ }
+ }
+ }
+ },
+ "services": {
+ "create_link": {
+ "name": "Create Link",
+ "description": "Create a temporary or permanent link to a stream (enter `url` or `entity`)",
+ "fields": {
+ "link_id": {
+ "name": "Link ID",
+ "description": "Create a random or permanent ID for your link"
+ },
+ "url": {
+ "name": "URL",
+ "description": "Link to RTSP-stream"
+ },
+ "entity": {
+ "name": "Entity",
+ "description": "Camera entity_id"
+ },
+ "open_limit": {
+ "name": "Open limit",
+ "description": "How many times a link can be opened (0 - unlimit)"
+ },
+ "time_to_live": {
+ "name": "Time to live",
+ "description": "How many seconds will the link live (0 - unlimit)"
+ }
+ }
+ },
+ "dash_cast": {
+ "name": "DashCast",
+ "description": "Cast stream to Chromecast device via DashCast application",
+ "fields": {
+ "entity_id": {
+ "name": "Media Entity",
+ "description": "Media player entity_id"
+ },
+ "url": {
+ "name": "URL",
+ "description": "Link to RTSP-stream"
+ },
+ "entity": {
+ "name": "Entity",
+ "description": "Camera entity_id"
+ },
+ "extra": {
+ "name": "Extra",
+ "description": "Additional card params"
+ },
+ "force": {
+ "name": "Force",
+ "description": "Force restart DashCast application"
+ },
+ "hass_url": {
+ "name": "Hass URL",
+ "description": "Manual base URL to Hass server"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/custom_components/webrtc/translations/es.json b/custom_components/webrtc/translations/es.json
new file mode 100644
index 0000000..1305564
--- /dev/null
+++ b/custom_components/webrtc/translations/es.json
@@ -0,0 +1,88 @@
+{
+ "config": {
+ "abort": {
+ "arch": "Arquitectura del sistema operativo no compatible: {uname}",
+ "single_instance_allowed": "Ya está configurado. Solo es posible una única configuración."
+ },
+ "error": {
+ "connect": "No se puede conectar al servidor go2rtc"
+ },
+ "step": {
+ "user": {
+ "title": "URL de go2rtc",
+ "description": "Selecciona la versión del servidor de streaming [go2rtc](https://github.com/AlexxIT/go2rtc):\n1. Deja vacío si quieres la versión go2rtc integrada (*usuarios básicos*)\n2. Establece `http://localhost:1984` si instalas el complemento [go2rtc](https://github.com/AlexxIT/go2rtc#go2rtc-home-assistant-add-on) o [Frigate 12+](https://docs.frigate.video/) (*usuarios avanzados*)\n3. Establece una dirección personalizada si instalas go2rtc en otro servidor (*hackers*)",
+ "data": {
+ "url": "URL"
+ }
+ },
+ "config": {
+ "title": "Configuración de go2rtc",
+ "description": "Ruta: `{path}`",
+ "data": {
+ "api": "WebUI pública en el puerto 1984",
+ "rtsp": "RTSP público en el puerto 8554",
+ "username": "Nombre de usuario público (Web y RTSP)",
+ "password": "Contraseña pública (Web y RTSP)"
+ }
+ }
+ }
+ },
+ "services": {
+ "create_link": {
+ "name": "Crear enlace",
+ "description": "Crea un enlace temporal o permanente a un stream (ingresa `url` o `entidad`)",
+ "fields": {
+ "link_id": {
+ "name": "ID del enlace",
+ "description": "Crea una ID aleatoria o permanente para tu enlace"
+ },
+ "url": {
+ "name": "URL",
+ "description": "Enlace al stream RTSP"
+ },
+ "entity": {
+ "name": "Entidad",
+ "description": "entity_id de la cámara"
+ },
+ "open_limit": {
+ "name": "Límite de aperturas",
+ "description": "Cuántas veces se puede abrir un enlace (0 - ilimitado)"
+ },
+ "time_to_live": {
+ "name": "Tiempo de vida",
+ "description": "Cuántos segundos vivirá el enlace (0 - ilimitado)"
+ }
+ }
+ },
+ "dash_cast": {
+ "name": "DashCast",
+ "description": "Transmite stream al dispositivo Chromecast a través de la aplicación DashCast",
+ "fields": {
+ "entity_id": {
+ "name": "Entidad multimedia",
+ "description": "entity_id del reproductor multimedia"
+ },
+ "url": {
+ "name": "URL",
+ "description": "Enlace al stream RTSP"
+ },
+ "entity": {
+ "name": "Entidad",
+ "description": "entity_id de la cámara"
+ },
+ "extra": {
+ "name": "Extra",
+ "descripción": "Parámetros adicionales de la tarjeta"
+ },
+ "force": {
+ "name": "Forzar",
+ "description": "Forzar el reinicio de la aplicación DashCast"
+ },
+ "hass_url": {
+ "name": "URL de Hass",
+ "description": "URL base manual al servidor Hass"
+ }
+ }
+ }
+ }
+ }
\ No newline at end of file
diff --git a/custom_components/webrtc/translations/fr.json b/custom_components/webrtc/translations/fr.json
new file mode 100644
index 0000000..cb18714
--- /dev/null
+++ b/custom_components/webrtc/translations/fr.json
@@ -0,0 +1,88 @@
+{
+ "config": {
+ "abort": {
+ "arch": "Architecture de l'OS non prise en charge : {uname}",
+ "single_instance_allowed": "Déjà configuré. Une seule configuration possible."
+ },
+ "error": {
+ "connect": "Impossible de se connecter au serveur go2rtc"
+ },
+ "step": {
+ "user": {
+ "title": "URL de go2rtc",
+ "description": "Sélectionnez la version du serveur de streaming [go2rtc](https://github.com/AlexxIT/go2rtc) :\n1. Laissez vide si vous souhaitez la version intégrée de go2rtc (*utilisateurs basiques*).\n2. Définissez « http://localhost:1984 » si vous installez le module complémentaire [go2rtc](https://github.com/AlexxIT/go2rtc#go2rtc-home-assistant-add-on) ou [Frigate 12+](https://docs.frigate.video/) (*utilisateurs avancés*).\n3. Définissez une adresse personnalisée si vous installez go2rtc sur un autre serveur (*hackers*).",
+ "data": {
+ "url": "URL"
+ }
+ },
+ "config": {
+ "title": "configuration de go2rtc",
+ "description": "Chemin : `{path}`",
+ "data": {
+ "api": "Interface Web publique sur le port 1984",
+ "rtsp": "RTSP public sur le port 8554",
+ "username": "Nom d'utilisateur public (Web et RTSP)",
+ "password": "Mot de passe public (Web et RTSP)"
+ }
+ }
+ }
+ },
+ "services": {
+ "create_link": {
+ "name": "Créer un lien",
+ "description": "Créez un lien temporaire ou permanent vers un flux (saisissez `url` ou `entity`)",
+ "fields": {
+ "link_id": {
+ "name": "ID du lien",
+ "description": "Créez un ID aléatoire ou permanent pour votre lien"
+ },
+ "url": {
+ "name": "URL",
+ "description": "Lien vers le flux RTSP"
+ },
+ "entity": {
+ "name": "Entité",
+ "description": "Caméra entity_id"
+ },
+ "open_limit": {
+ "name": "Limite ouverte",
+ "description": "Combien de fois un lien peut être ouvert (0 - illimité)"
+ },
+ "time_to_live": {
+ "name": "Temps du direct",
+ "description": "Combien de secondes le lien restera-t-il actif (0 - illimité)"
+ }
+ }
+ },
+ "dash_cast": {
+ "name": "DashCast",
+ "description": "Diffuser le flux sur un appareil Chromecast via l'application DashCast",
+ "fields": {
+ "entity_id": {
+ "name": "Entité multimédia",
+ "description": "Multimédia entity_id"
+ },
+ "url": {
+ "name": "URL",
+ "description": "Link to RTSP-stream"
+ },
+ "entity": {
+ "name": "Entité",
+ "description": "Caméra entity_id"
+ },
+ "extra": {
+ "name": "Supplément",
+ "description": "Paramètres de carte supplémentaires"
+ },
+ "force": {
+ "name": "Forcer",
+ "description": "Forcer le redémarrage de l'application DashCast"
+ },
+ "hass_url": {
+ "name": "URL Hass",
+ "description": "URL de base manuelle vers le serveur Hass"
+ }
+ }
+ }
+ }
+}
diff --git a/custom_components/webrtc/translations/pt.json b/custom_components/webrtc/translations/pt.json
new file mode 100644
index 0000000..2f8dd2b
--- /dev/null
+++ b/custom_components/webrtc/translations/pt.json
@@ -0,0 +1,88 @@
+{
+ "config": {
+ "abort": {
+ "arch": "Arquitetura de SO não suportada: {uname}",
+ "single_instance_allowed": "Já configurado. Apenas é possível uma única configuração."
+ },
+ "error": {
+ "connect": "Não é possível ligar ao servidor go2rtc"
+ },
+ "step": {
+ "user": {
+ "title": "URL do go2rtc",
+ "description": "Selecione a versão do servidor de streaming [go2rtc](https://github.com/AlexxIT/go2rtc):\n1. Deixe em branco se desejar utilizar a versão go2rtc incorporada (*utilizadores básicos*)\n2. Defina `http://localhost:1984` se instalar o add-on [go2rtc](https://github.com/AlexxIT/go2rtc#go2rtc-home-assistant-add-on) ou [Frigate 12+](https://docs.frigate.video/) (*utilizadores avançados*)\n3. Defina um endereço personalizado se instalar go2rtc num outro servidor (*utilizadores experientes*)",
+ "data": {
+ "url": "URL"
+ }
+ },
+ "config": {
+ "title": "Configuração go2rtc",
+ "description": "Caminho: `{path}`",
+ "data": {
+ "api": "Public WebUI na porta 1984",
+ "rtsp": "RTSP público na porta 8554",
+ "username": "Nome de utilizador público (Web e RTSP)",
+ "password": "Palavra-passe pública (Web e RTSP)"
+ }
+ }
+ }
+ },
+ "services": {
+ "create_link": {
+ "name": "Criar Link",
+ "description": "Crie um link temporário ou permanente para um stream (introduza `url` ou `entidade`)",
+ "fields": {
+ "link_id": {
+ "name": "ID do Link",
+ "description": "Crie um ID aleatório ou permanente para o seu link"
+ },
+ "url": {
+ "name": "URL",
+ "description": "Link para o stream RTSP"
+ },
+ "entity": {
+ "name": "Entidade",
+ "description": "ID de entidade da câmara"
+ },
+ "open_limit": {
+ "name": "Limite de aberturas",
+ "description": "Quantas vezes o link pode ser aberto (0 - sem limite)"
+ },
+ "time_to_live": {
+ "name": "Tempo de vida",
+ "description": "Quantos segundos o link permanecerá ativo (0 - sem limite)"
+ }
+ }
+ },
+ "dash_cast": {
+ "name": "DashCast",
+ "description": "Transmitir stream para dispositivo Chromecast através da aplicação DashCast",
+ "fields": {
+ "entity_id": {
+ "name": "Entidade de Média",
+ "description": "ID de entidade do leitor de média"
+ },
+ "url": {
+ "name": "URL",
+ "description": "Link para o stream RTSP"
+ },
+ "entity": {
+ "name": "Entidade",
+ "description": "ID de entidade da câmara"
+ },
+ "extra": {
+ "name": "Extra",
+ "description": "Parâmetros adicionais do cartão"
+ },
+ "force": {
+ "name": "Forçar",
+ "description": "Forçar reinício da aplicação DashCast"
+ },
+ "hass_url": {
+ "name": "URL do Hass",
+ "description": "URL base manual para o servidor Hass"
+ }
+ }
+ }
+ }
+}
diff --git a/custom_components/webrtc/translations/ur.json b/custom_components/webrtc/translations/ur.json
new file mode 100644
index 0000000..1c006ae
--- /dev/null
+++ b/custom_components/webrtc/translations/ur.json
@@ -0,0 +1,88 @@
+{
+ "config": {
+ "abort": {
+ "arch": "غیر تعاون یافتہ OS آرکیٹیکچر: {uname}",
+ "single_instance_allowed": "پہلے سے ترتیب شدہ۔ صرف ایک ہی ترتیب ممکن ہے۔."
+ },
+ "error": {
+ "connect": "go2rtc سرور سے منسلک نہیں ہو سکتا"
+ },
+ "step": {
+ "user": {
+ "title": "go2rtc یو آر ایل",
+ "description": "[go2rtc](https://github.com/AlexxIT/go2rtc) اسٹریمنگ سرور ورژن منتخب کریں:\n1۔ اگر آپ بلٹ ان go2rtc ورژن (*بنیادی صارفین*) چاہتے ہیں تو خالی چھوڑ دیں\n2۔ اگر آپ [go2rtc](https://github.com/AlexxIT/go2rtc#go2rtc-home-assistant-add-on) یا [Frigate 12+](https://) کو انسٹال کرتے ہیں تو `http://localhost:1984` سیٹ کریں docs.frigate.video/) ایڈ آن (*جدید صارفین*)\n3۔ اگر آپ کسی دوسرے سرور (*ہیکرز*) پر go2rtc انسٹال کرتے ہیں تو اپنی مرضی کے مطابق پتہ سیٹ کریں۔",
+ "data": {
+ "url": "URL"
+ }
+ },
+ "config": {
+ "title": "go2rtc تشکیل",
+ "description": "راستہ: `{path}`",
+ "data": {
+ "api": "Public WebUI on port 1984",
+ "rtsp": "Public RTSP on port 8554",
+ "username": "عوامی صارف کا نام (ویب اور آر ٹی ایس پی)",
+ "password": "پبلک پاس ورڈ (ویب اور آر ٹی ایس پی)"
+ }
+ }
+ }
+ },
+ "services": {
+ "create_link": {
+ "name": "لنک بنائیں",
+ "description": "سٹریم کا عارضی یا مستقل لنک بنائیں ('url' یا 'entity' درج کریں)",
+ "fields": {
+ "link_id": {
+ "name": "لنک آئی ڈی",
+ "description": "اپنے لنک کے لیے ایک بے ترتیب یا مستقل ID بنائیں"
+ },
+ "url": {
+ "name": "URL",
+ "description": "RTSP اسٹریم سے لنک کریں۔"
+ },
+ "entity": {
+ "name": "ہستی",
+ "description": "کیمرہ entity_id"
+ },
+ "open_limit": {
+ "name": "کھلی حد",
+ "description": "ایک لنک کتنی بار کھولا جا سکتا ہے (0 - لا محدود)"
+ },
+ "time_to_live": {
+ "name": "زندہ رہنے کا وقت",
+ "description": "لنک کتنے سیکنڈ تک زندہ رہے گا (0 - لامحدود)"
+ }
+ }
+ },
+ "dash_cast": {
+ "name": "ڈیش کاسٹ",
+ "description": "DashCast ایپلیکیشن کے ذریعے کروم کاسٹ ڈیوائس پر اسٹریم کاسٹ کریں۔",
+ "fields": {
+ "entity_id": {
+ "name": "میڈیا ہستی",
+ "description": "میڈیا پلیئر کی entity_id"
+ },
+ "url": {
+ "name": "URL",
+ "description": "RTSP اسٹریم سے لنک کریں۔"
+ },
+ "entity": {
+ "name": "ہستی",
+ "description": "کیمرہ entity_id"
+ },
+ "extra": {
+ "name": "اضافی",
+ "description": "کارڈ کے اضافی پیرامز"
+ },
+ "force": {
+ "name": "زبردستی",
+ "description": "ڈیش کاسٹ ایپلیکیشن کو زبردستی دوبارہ شروع کریں۔"
+ },
+ "hass_url": {
+ "name": " بیس یو آر ایل",
+ "description": "ہاس سرور کے لیے دستی بیس یو آر ایل"
+ }
+ }
+ }
+ }
+}
diff --git a/custom_components/webrtc/utils.py b/custom_components/webrtc/utils.py
new file mode 100644
index 0000000..7c1be1c
--- /dev/null
+++ b/custom_components/webrtc/utils.py
@@ -0,0 +1,260 @@
+import io
+import logging
+import os
+import platform
+import re
+import stat
+import subprocess
+import zipfile
+from threading import Thread
+from typing import Optional
+from urllib.parse import urljoin
+
+import aiohttp
+import jwt
+import requests
+from aiohttp import web
+from homeassistant.components.frontend import add_extra_js_url
+from homeassistant.components.http.auth import DATA_SIGN_SECRET, SIGN_QUERY_PARAM
+from homeassistant.components.lovelace.resources import ResourceStorageCollection
+from homeassistant.const import MAJOR_VERSION, MINOR_VERSION
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.entity_component import DATA_INSTANCES
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = "webrtc"
+
+BINARY_VERSION = "1.9.9"
+
+SYSTEM = {
+ "Windows": {"AMD64": "go2rtc_win64.zip", "ARM64": "go2rtc_win_arm64.zip"},
+ "Darwin": {"x86_64": "go2rtc_mac_amd64.zip", "arm64": "go2rtc_mac_arm64.zip"},
+ "Linux": {
+ "armv7l": "go2rtc_linux_arm",
+ "armv8l": "go2rtc_linux_arm", # https://github.com/AlexxIT/WebRTC/issues/18
+ "aarch64": "go2rtc_linux_arm64",
+ "x86_64": "go2rtc_linux_amd64",
+ "i386": "go2rtc_linux_386",
+ "i486": "go2rtc_linux_386",
+ "i586": "go2rtc_linux_386",
+ "i686": "go2rtc_linux_386",
+ },
+}
+
+DEFAULT_URL = "http://localhost:1984/"
+
+BINARY_NAME = re.compile(
+ r"^(go2rtc-\d\.\d\.\d+|go2rtc_v0\.1-rc\.[5-9]|rtsp2webrtc_v[1-5])(\.exe)?$"
+)
+
+
+def get_arch() -> Optional[str]:
+ system = SYSTEM.get(platform.system())
+ if not system:
+ return None
+ return system.get(platform.machine())
+
+
+def unzip(content: bytes) -> bytes:
+ with zipfile.ZipFile(io.BytesIO(content)) as zf:
+ for filename in zf.namelist():
+ with zf.open(filename) as f:
+ return f.read()
+
+
+def validate_binary(hass: HomeAssistant) -> Optional[str]:
+ filename = f"go2rtc-{BINARY_VERSION}"
+ if platform.system() == "Windows":
+ filename += ".exe"
+
+ filename = hass.config.path(filename)
+ try:
+ if os.path.isfile(filename) and subprocess.check_output(
+ [filename, "-v"]
+ ).startswith(b"go2rtc"):
+ return filename
+ except:
+ pass
+
+ # remove all old binaries
+ for file in os.listdir(hass.config.config_dir):
+ if BINARY_NAME.match(file):
+ _LOGGER.debug(f"Remove old binary: {file}")
+ os.remove(hass.config.path(file))
+
+ # download new binary
+ url = (
+ f"https://github.com/AlexxIT/go2rtc/releases/download/"
+ f"v{BINARY_VERSION}/{get_arch()}"
+ )
+ _LOGGER.debug(f"Download new binary: {url}")
+ r = requests.get(url)
+ if not r.ok:
+ return None
+
+ raw = r.content
+
+ # unzip binary for windows
+ if url.endswith(".zip"):
+ raw = unzip(raw)
+
+ # save binary to config folder
+ with open(filename, "wb") as f:
+ f.write(raw)
+
+ # change binary access rights
+ os.chmod(filename, os.stat(filename).st_mode | stat.S_IEXEC)
+
+ return filename
+
+
+async def register_static_path(hass: HomeAssistant, url_path: str, path: str):
+ if (MAJOR_VERSION, MINOR_VERSION) >= (2024, 7):
+ from homeassistant.components.http import StaticPathConfig
+
+ await hass.http.async_register_static_paths(
+ [StaticPathConfig(url_path, path, True)]
+ )
+ else:
+ hass.http.register_static_path(url_path, path)
+
+
+async def init_resource(hass: HomeAssistant, url: str, ver: str) -> bool:
+ """Add extra JS module for lovelace mode YAML and new lovelace resource
+ for mode GUI. It's better to add extra JS for all modes, because it has
+ random url to avoid problems with the cache. But chromecast don't support
+ extra JS urls and can't load custom card.
+ """
+ lovelace = hass.data["lovelace"]
+ resources: ResourceStorageCollection = (
+ lovelace.resources if hasattr(lovelace, "resources") else lovelace["resources"]
+ )
+
+ # force load storage
+ await resources.async_get_info()
+
+ url2 = f"{url}?v={ver}"
+
+ for item in resources.async_items():
+ if not item.get("url", "").startswith(url):
+ continue
+
+ # no need to update
+ if item["url"].endswith(ver):
+ return False
+
+ _LOGGER.debug(f"Update lovelace resource to: {url2}")
+
+ if isinstance(resources, ResourceStorageCollection):
+ await resources.async_update_item(
+ item["id"], {"res_type": "module", "url": url2}
+ )
+ else:
+ # not the best solution, but what else can we do
+ item["url"] = url2
+
+ return True
+
+ if isinstance(resources, ResourceStorageCollection):
+ _LOGGER.debug(f"Add new lovelace resource: {url2}")
+ await resources.async_create_item({"res_type": "module", "url": url2})
+ else:
+ _LOGGER.debug(f"Add extra JS module: {url2}")
+ add_extra_js_url(hass, url2)
+
+ return True
+
+
+# noinspection PyProtectedMember
+def dash_cast(hass: HomeAssistant, entities: list, url: str, force: bool):
+ """Cast webpage to chromecast device via DashCast application."""
+ try:
+ for entity in hass.data[DATA_INSTANCES]["media_player"].entities:
+ if entity.entity_id not in entities or not hasattr(entity, "_chromecast"):
+ continue
+
+ if not hasattr(entity, "dashcast"):
+ from pychromecast.controllers.dashcast import DashCastController
+
+ entity.dashcast = DashCastController()
+ entity._chromecast.register_handler(entity.dashcast)
+
+ _LOGGER.debug(f"DashCast to {entity.entity_id}")
+ entity.dashcast.load_url(url, force=force)
+
+ except Exception as e:
+ _LOGGER.error(f"Can't DashCast to {entities}", exc_info=e)
+
+
+def validate_signed_request(request: web.Request) -> bool:
+ try:
+ hass = request.app["hass"]
+ secret = hass.data.get(DATA_SIGN_SECRET)
+ signature = request.query.get(SIGN_QUERY_PARAM)
+ claims = jwt.decode(signature, secret, algorithms=["HS256"])
+ return claims["path"] == request.path
+ except Exception:
+ return False
+
+
+async def check_go2rtc(hass: HomeAssistant, url: str = DEFAULT_URL) -> Optional[str]:
+ session = async_get_clientsession(hass)
+ try:
+ r = await session.head(url, timeout=2, allow_redirects=False)
+ return url if r.status < 300 else None
+ except Exception:
+ return None
+
+
+def api_streams(hass: HomeAssistant) -> str:
+ entry = hass.data[DOMAIN]
+ go_url = "http://localhost:1984/" if isinstance(entry, Server) else entry
+ return urljoin(go_url, "api/streams")
+
+
+# copied from homeassistant.components.hassio.ingress import _websocket_forward
+async def websocket_forward(ws_from, ws_to) -> None:
+ try:
+ async for msg in ws_from:
+ if msg.type is aiohttp.WSMsgType.TEXT:
+ await ws_to.send_str(msg.data)
+ elif msg.type is aiohttp.WSMsgType.BINARY:
+ await ws_to.send_bytes(msg.data)
+ elif msg.type is aiohttp.WSMsgType.PING:
+ await ws_to.ping()
+ elif msg.type is aiohttp.WSMsgType.PONG:
+ await ws_to.pong()
+ elif ws_to.closed:
+ await ws_to.close(code=ws_to.close_code, message=msg.extra) # type: ignore[arg-type]
+ except Exception as e:
+ _LOGGER.debug(f"WebSocket forward exception: {repr(e)}")
+
+
+class Server(Thread):
+ def __init__(self, binary: str):
+ super().__init__(name=DOMAIN, daemon=True)
+ self.binary = binary
+ self.process = None
+
+ @property
+ def available(self):
+ return self.process.poll() is None if self.process else False
+
+ def run(self):
+ while self.binary:
+ self.process = subprocess.Popen(
+ [self.binary], stdout=subprocess.PIPE, stderr=subprocess.STDOUT
+ )
+
+ # check alive
+ while self.process.poll() is None:
+ line = self.process.stdout.readline()
+ if line == b"":
+ break
+ _LOGGER.debug(line[:-1].decode())
+
+ def stop(self, *args):
+ self.binary = None
+ self.process.terminate()
diff --git a/custom_components/webrtc/www/digital-ptz.js b/custom_components/webrtc/www/digital-ptz.js
new file mode 100644
index 0000000..684bcce
--- /dev/null
+++ b/custom_components/webrtc/www/digital-ptz.js
@@ -0,0 +1,346 @@
+// js version generated from https://github.com/dbuezas/pan-zoom-controller/blob/main/src/digital-ptz.ts
+const ONE_FINGER_ZOOM_SPEED = 1 / 200; // 1 scale every 200px
+const DBL_CLICK_MS = 400;
+const MAX_ZOOM = 10;
+const DEFAULT_OPTIONS = {
+ touch_drag_pan: true,
+ touch_tap_drag_zoom: true,
+ mouse_drag_pan: true,
+ mouse_wheel_zoom: true,
+ mouse_double_click_zoom: true,
+ touch_pinch_zoom: true,
+ persist_key: "",
+ persist: true,
+};
+export class DigitalPTZ {
+ constructor(containerEl, transformEl, videoEl, options) {
+ this.offHandles = [];
+ this.recomputeRects = () => {
+ this.transform.updateRects(this.videoEl, this.containerEl);
+ this.transform.zoomAtCoords(1, 0, 0); // clamp transform
+ this.render();
+ };
+ this.render = (transition = false) => {
+ if (transition) {
+ // transition is used to animate dbl click zoom
+ this.transformEl.style.transition = "transform 200ms";
+ setTimeout(() => {
+ this.transformEl.style.transition = "";
+ }, 200);
+ }
+ this.transformEl.style.transform = this.transform.render();
+ };
+ this.containerEl = containerEl;
+ this.transformEl = transformEl;
+ this.videoEl = videoEl;
+ this.options = Object.assign({}, DEFAULT_OPTIONS, options);
+ this.transform = new Transform({
+ persist_key: this.options.persist_key,
+ persist: this.options.persist,
+ });
+ const o = this.options;
+ const gestureParam = {
+ containerEl: this.containerEl,
+ transform: this.transform,
+ render: this.render,
+ };
+ const h = this.offHandles;
+ if (o.mouse_drag_pan) h.push(startMouseDragPan(gestureParam));
+ if (o.mouse_wheel_zoom) h.push(startMouseWheel(gestureParam));
+ if (o.mouse_double_click_zoom) h.push(startDoubleClickZoom(gestureParam));
+ if (o.touch_tap_drag_zoom) h.push(startTouchTapDragZoom(gestureParam));
+ if (o.touch_drag_pan) h.push(startTouchDragPan(gestureParam));
+ if (o.touch_pinch_zoom) h.push(startTouchPinchZoom(gestureParam));
+ this.videoEl.addEventListener("loadedmetadata", this.recomputeRects);
+ this.resizeObserver = new ResizeObserver(this.recomputeRects);
+ this.resizeObserver.observe(this.containerEl);
+ this.recomputeRects();
+ }
+ destroy() {
+ for (const off of this.offHandles) off();
+ this.videoEl.removeEventListener("loadedmetadata", this.recomputeRects);
+ this.resizeObserver.unobserve(this.containerEl);
+ }
+}
+/* Gestures */
+const preventScroll = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+};
+const getCenter = (touches) => ({
+ x: (touches[0].pageX + touches[1].pageX) / 2,
+ y: (touches[0].pageY + touches[1].pageY) / 2,
+});
+const getSpread = (touches) =>
+ Math.hypot(
+ touches[0].pageX - touches[1].pageX,
+ touches[0].pageY - touches[1].pageY
+ );
+function startTouchPinchZoom({ containerEl, transform, render }) {
+ const onTouchStart = (downEvent) => {
+ const relevant = downEvent.touches.length === 2;
+ if (!relevant) return;
+ let lastTouches = downEvent.touches;
+ const onTouchMove = (moveEvent) => {
+ const newTouches = moveEvent.touches;
+ const oldCenter = getCenter(lastTouches);
+ const newCenter = getCenter(newTouches);
+ const dx = newCenter.x - oldCenter.x;
+ const dy = newCenter.y - oldCenter.y;
+ transform.move(dx, dy);
+ const oldSpread = getSpread(lastTouches);
+ const newSpread = getSpread(newTouches);
+ const zoom = newSpread / oldSpread;
+ transform.zoomAtCoords(zoom, newCenter.x, newCenter.y);
+ lastTouches = moveEvent.touches;
+ render();
+ preventScroll(moveEvent);
+ };
+ const onTouchEnd = () =>
+ containerEl.removeEventListener("touchmove", onTouchMove);
+ containerEl.addEventListener("touchmove", onTouchMove);
+ containerEl.addEventListener("touchend", onTouchEnd, { once: true });
+ };
+ containerEl.addEventListener("touchstart", onTouchStart);
+ return () => containerEl.removeEventListener("touchstart", onTouchStart);
+}
+const getDist = (t1, t2) =>
+ Math.hypot(
+ t1.touches[0].pageX - t2.touches[0].pageX,
+ t1.touches[0].pageY - t2.touches[0].pageY
+ );
+function startTouchTapDragZoom({ containerEl, transform, render }) {
+ let lastEvent;
+ let fastClicks = 0;
+ const onTouchStart = (downEvent) => {
+ const isFastClick =
+ lastEvent && downEvent.timeStamp - lastEvent.timeStamp < DBL_CLICK_MS;
+ if (!isFastClick) fastClicks = 0;
+ fastClicks++;
+ if (downEvent.touches.length > 1) fastClicks = 0;
+ lastEvent = downEvent;
+ };
+ const onTouchMove = (moveEvent) => {
+ if (fastClicks === 2) {
+ const lastY = lastEvent.touches[0].pageY;
+ const currY = moveEvent.touches[0].pageY;
+ transform.zoom(1 - (lastY - currY) * ONE_FINGER_ZOOM_SPEED);
+ lastEvent = moveEvent;
+ render();
+ preventScroll(moveEvent);
+ } else if (getDist(lastEvent, moveEvent) > 10) {
+ fastClicks = 0;
+ }
+ };
+ containerEl.addEventListener("touchmove", onTouchMove);
+ containerEl.addEventListener("touchstart", onTouchStart);
+ return () => {
+ containerEl.removeEventListener("touchmove", onTouchMove);
+ containerEl.removeEventListener("touchstart", onTouchStart);
+ };
+}
+function startMouseWheel({ containerEl, transform, render }) {
+ const onWheel = (e) => {
+ const zoom = 1 - e.deltaY / 1000;
+ transform.zoomAtCoords(zoom, e.pageX, e.pageY);
+ render();
+ preventScroll(e);
+ };
+ containerEl.addEventListener("wheel", onWheel);
+ return () => containerEl.removeEventListener("wheel", onWheel);
+}
+function startDoubleClickZoom({ containerEl, transform, render }) {
+ let lastDown = 0;
+ let clicks = 0;
+ const onDown = (downEvent) => {
+ const isFastClick = downEvent.timeStamp - lastDown < DBL_CLICK_MS;
+ lastDown = downEvent.timeStamp;
+ if (!isFastClick) clicks = 0;
+ clicks++;
+ if (clicks !== 2) return;
+ const onUp = (upEvent) => {
+ const isQuickRelease = upEvent.timeStamp - lastDown < DBL_CLICK_MS;
+ const dist = Math.hypot(
+ upEvent.pageX - downEvent.pageX,
+ upEvent.pageY - downEvent.pageY
+ );
+ if (!isQuickRelease || dist > 20) return;
+ const zoom = transform.scale == 1 ? 2 : 0.01;
+ transform.zoomAtCoords(zoom, upEvent.pageX, upEvent.pageY);
+ render(true);
+ };
+ window.addEventListener("mouseup", onUp, { once: true });
+ };
+ containerEl.addEventListener("mousedown", onDown);
+ return () => containerEl.removeEventListener("mousedown", onDown);
+}
+function startGesturePan({ containerEl, transform, render }, type) {
+ const [downName, moveName, upName] =
+ type === "mouse"
+ ? ["mousedown", "mousemove", "mouseup"]
+ : ["touchstart", "touchmove", "touchend"];
+ const isTouchEvent = ev => 'TouchEvent' in window && ev instanceof TouchEvent;
+ const onDown = (downEvt) => {
+ let last = isTouchEvent(downEvt) ? downEvt.touches[0] : downEvt;
+ const onMove = (moveEvt) => {
+ if (isTouchEvent(moveEvt) && moveEvt.touches.length !== 1) return;
+ const curr = isTouchEvent(moveEvt) ? moveEvt.touches[0] : moveEvt;
+ transform.move(curr.pageX - last.pageX, curr.pageY - last.pageY);
+ last = curr;
+ render();
+ if (transform.scale !== 1) preventScroll(moveEvt);
+ };
+ containerEl.addEventListener(moveName, onMove);
+ const onUp = () => containerEl.removeEventListener(moveName, onMove);
+ window.addEventListener(upName, onUp, { once: true });
+ };
+ containerEl.addEventListener(downName, onDown);
+ return () => containerEl.removeEventListener(downName, onDown);
+}
+function startTouchDragPan(params) {
+ return startGesturePan(params, "touch");
+}
+function startMouseDragPan(params) {
+ return startGesturePan(params, "mouse");
+}
+/** Transform */
+const PERSIST_KEY_PREFIX = "webrtc-digital-ptc:";
+const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
+function getTransformedDimensions(video) {
+ const { videoWidth, videoHeight } = video;
+ if (!videoHeight || !videoWidth) return undefined;
+ var transform = window.getComputedStyle(video).getPropertyValue("transform");
+ const match = transform.match(/matrix\((.+)\)/);
+ if (!match || !match[1]) return { videoWidth, videoHeight }; // the video isn't transformed
+ const matrix = new DOMMatrix(match[1].split(", ").map(Number));
+ const points = [
+ new DOMPoint(0, 0),
+ new DOMPoint(videoWidth, 0),
+ new DOMPoint(0, videoHeight),
+ new DOMPoint(videoWidth, videoHeight),
+ ].map((point) => point.matrixTransform(matrix));
+ const minX = Math.min(...points.map((point) => point.x));
+ const maxX = Math.max(...points.map((point) => point.x));
+ const minY = Math.min(...points.map((point) => point.y));
+ const maxY = Math.max(...points.map((point) => point.y));
+ return { videoWidth: maxX - minX, videoHeight: maxY - minY };
+}
+class Transform {
+ constructor(settings) {
+ this.scale = 1;
+ this.x = 0;
+ this.y = 0;
+ this.loadPersistedTransform = () => {
+ const { persist_key, persist } = this.settings;
+ if (!persist) return;
+ try {
+ const loaded = JSON.parse(localStorage[persist_key]);
+ const isValid = [loaded.scale, loaded.x, loaded.y].every(
+ Number.isFinite
+ );
+ if (!isValid) {
+ throw new Error("Broken local storage");
+ }
+ this.x = loaded.x;
+ this.y = loaded.y;
+ this.scale = loaded.scale;
+ } catch (e) {
+ delete localStorage[persist_key];
+ }
+ };
+ this.persistTransform = () => {
+ const { persist_key, persist } = this.settings;
+ if (!persist) return;
+ const { x, y, scale } = this;
+ localStorage[persist_key] = JSON.stringify({
+ x,
+ y,
+ scale,
+ });
+ };
+ this.settings = Object.assign(Object.assign({}, settings), {
+ persist_key: PERSIST_KEY_PREFIX + settings.persist_key,
+ });
+ this.loadPersistedTransform();
+ }
+ updateRects(videoEl, containerEl) {
+ const containerRect = containerEl.getBoundingClientRect();
+ if (containerRect.width === 0 || containerRect.height === 0) {
+ // The container rect has no size yet.
+ // This happens when coming back to a tab that was already opened.
+ // The card will get size shortly and the size observer will call this function again.
+ return;
+ }
+ this.containerRect = containerRect;
+ const transformed = getTransformedDimensions(videoEl);
+ if (!transformed) {
+ // The video hasn't loaded yet.
+ // Once it loads, the videometadata listener will call this function again.
+ return;
+ }
+ // When in full screen, and if the aspect ratio of the screen differs from that of the video,
+ // black bars will be shown either to the sides or above/below the video.
+ // This needs to be accounted for when panning, the code below keeps track of that.
+ const screenAspectRatio =
+ this.containerRect.width / this.containerRect.height;
+ const videoAspectRatio = transformed.videoWidth / transformed.videoHeight;
+ if (videoAspectRatio > screenAspectRatio) {
+ // Black bars on the top and bottom
+ const videoHeight = this.containerRect.width / videoAspectRatio;
+ const blackBarHeight = (this.containerRect.height - videoHeight) / 2;
+ this.videoRect = new DOMRect(
+ this.containerRect.x,
+ blackBarHeight + this.containerRect.y,
+ this.containerRect.width,
+ videoHeight
+ );
+ } else {
+ // Black bars on the sides
+ const videoWidth = this.containerRect.height * videoAspectRatio;
+ const blackBarWidth = (this.containerRect.width - videoWidth) / 2;
+ this.videoRect = new DOMRect(
+ blackBarWidth + this.containerRect.x,
+ this.containerRect.y,
+ videoWidth,
+ this.containerRect.height
+ );
+ }
+ }
+ // dx,dy are deltas.
+ move(dx, dy) {
+ if (!this.videoRect) return;
+ const bound = (this.scale - 1) / 2;
+ this.x += dx / this.videoRect.width;
+ this.y += dy / this.videoRect.height;
+ this.x = clamp(this.x, -bound, bound);
+ this.y = clamp(this.y, -bound, bound);
+ this.persistTransform();
+ }
+ // x,y are relative to viewport (clientX, clientY)
+ zoomAtCoords(zoom, x, y) {
+ if (!this.containerRect || !this.videoRect) return;
+ const oldScale = this.scale;
+ this.scale *= zoom;
+ this.scale = clamp(this.scale, 1, MAX_ZOOM);
+ zoom = this.scale / oldScale;
+ x = x - this.containerRect.x - this.containerRect.width / 2;
+ y = y - this.containerRect.y - this.containerRect.height / 2;
+ const dx = x - this.x * this.videoRect.width;
+ const dy = y - this.y * this.videoRect.height;
+ this.move(dx * (1 - zoom), dy * (1 - zoom));
+ }
+ zoom(zoom) {
+ if (!this.containerRect || !this.videoRect) return;
+ const x = this.containerRect.width / 2;
+ const y = this.containerRect.height / 2;
+ this.zoomAtCoords(zoom, x, y);
+ }
+ render() {
+ if (!this.videoRect) return "";
+ const { x, y, scale } = this;
+ return `translate(${x * this.videoRect.width}px, ${
+ y * this.videoRect.height
+ }px) scale(${scale})`;
+ }
+}
diff --git a/custom_components/webrtc/www/embed.html b/custom_components/webrtc/www/embed.html
new file mode 100644
index 0000000..376f396
--- /dev/null
+++ b/custom_components/webrtc/www/embed.html
@@ -0,0 +1,45 @@
+
+
+
+
+ WebRTC Camera
+
+
+
+
+
+
+
diff --git a/custom_components/webrtc/www/video-rtc.js b/custom_components/webrtc/www/video-rtc.js
new file mode 100644
index 0000000..fb872b4
--- /dev/null
+++ b/custom_components/webrtc/www/video-rtc.js
@@ -0,0 +1,676 @@
+/**
+ * VideoRTC v1.6.0 - Video player for go2rtc streaming application.
+ *
+ * All modern web technologies are supported in almost any browser except Apple Safari.
+ *
+ * Support:
+ * - ECMAScript 2017 (ES8) = ES6 + async
+ * - RTCPeerConnection for Safari iOS 11.0+
+ * - IntersectionObserver for Safari iOS 12.2+
+ * - ManagedMediaSource for Safari 17+
+ *
+ * Doesn't support:
+ * - MediaSource for Safari iOS
+ * - Customized built-in elements (extends HTMLVideoElement) because Safari
+ * - Autoplay for WebRTC in Safari
+ */
+export class VideoRTC extends HTMLElement {
+ constructor() {
+ super();
+
+ this.DISCONNECT_TIMEOUT = 5000;
+ this.RECONNECT_TIMEOUT = 15000;
+
+ this.CODECS = [
+ 'avc1.640029', // H.264 high 4.1 (Chromecast 1st and 2nd Gen)
+ 'avc1.64002A', // H.264 high 4.2 (Chromecast 3rd Gen)
+ 'avc1.640033', // H.264 high 5.1 (Chromecast with Google TV)
+ 'hvc1.1.6.L153.B0', // H.265 main 5.1 (Chromecast Ultra)
+ 'mp4a.40.2', // AAC LC
+ 'mp4a.40.5', // AAC HE
+ 'flac', // FLAC (PCM compatible)
+ 'opus', // OPUS Chrome, Firefox
+ ];
+
+ /**
+ * [config] Supported modes (webrtc, webrtc/tcp, mse, hls, mp4, mjpeg).
+ * @type {string}
+ */
+ this.mode = 'webrtc,mse,hls,mjpeg';
+
+ /**
+ * [Config] Requested medias (video, audio, microphone).
+ * @type {string}
+ */
+ this.media = 'video,audio';
+
+ /**
+ * [config] Run stream when not displayed on the screen. Default `false`.
+ * @type {boolean}
+ */
+ this.background = false;
+
+ /**
+ * [config] Run stream only when player in the viewport. Stop when user scroll out player.
+ * Value is percentage of visibility from `0` (not visible) to `1` (full visible).
+ * Default `0` - disable;
+ * @type {number}
+ */
+ this.visibilityThreshold = 0;
+
+ /**
+ * [config] Run stream only when browser page on the screen. Stop when user change browser
+ * tab or minimise browser windows.
+ * @type {boolean}
+ */
+ this.visibilityCheck = true;
+
+ /**
+ * [config] WebRTC configuration
+ * @type {RTCConfiguration}
+ */
+ this.pcConfig = {
+ bundlePolicy: 'max-bundle',
+ iceServers: [{urls: 'stun:stun.l.google.com:19302'}],
+ sdpSemantics: 'unified-plan', // important for Chromecast 1
+ };
+
+ /**
+ * [info] WebSocket connection state. Values: CONNECTING, OPEN, CLOSED
+ * @type {number}
+ */
+ this.wsState = WebSocket.CLOSED;
+
+ /**
+ * [info] WebRTC connection state.
+ * @type {number}
+ */
+ this.pcState = WebSocket.CLOSED;
+
+ /**
+ * @type {HTMLVideoElement}
+ */
+ this.video = null;
+
+ /**
+ * @type {WebSocket}
+ */
+ this.ws = null;
+
+ /**
+ * @type {string|URL}
+ */
+ this.wsURL = '';
+
+ /**
+ * @type {RTCPeerConnection}
+ */
+ this.pc = null;
+
+ /**
+ * @type {number}
+ */
+ this.connectTS = 0;
+
+ /**
+ * @type {string}
+ */
+ this.mseCodecs = '';
+
+ /**
+ * [internal] Disconnect TimeoutID.
+ * @type {number}
+ */
+ this.disconnectTID = 0;
+
+ /**
+ * [internal] Reconnect TimeoutID.
+ * @type {number}
+ */
+ this.reconnectTID = 0;
+
+ /**
+ * [internal] Handler for receiving Binary from WebSocket.
+ * @type {Function}
+ */
+ this.ondata = null;
+
+ /**
+ * [internal] Handlers list for receiving JSON from WebSocket.
+ * @type {Object.}
+ */
+ this.onmessage = null;
+ }
+
+ /**
+ * Set video source (WebSocket URL). Support relative path.
+ * @param {string|URL} value
+ */
+ set src(value) {
+ if (typeof value !== 'string') value = value.toString();
+ if (value.startsWith('http')) {
+ value = 'ws' + value.substring(4);
+ } else if (value.startsWith('/')) {
+ value = 'ws' + location.origin.substring(4) + value;
+ }
+
+ this.wsURL = value;
+
+ this.onconnect();
+ }
+
+ /**
+ * Play video. Support automute when autoplay blocked.
+ * https://developer.chrome.com/blog/autoplay/
+ */
+ play() {
+ this.video.play().catch(() => {
+ if (!this.video.muted) {
+ this.video.muted = true;
+ this.video.play().catch(er => {
+ console.warn(er);
+ });
+ }
+ });
+ }
+
+ /**
+ * Send message to server via WebSocket
+ * @param {Object} value
+ */
+ send(value) {
+ if (this.ws) this.ws.send(JSON.stringify(value));
+ }
+
+ /** @param {Function} isSupported */
+ codecs(isSupported) {
+ return this.CODECS
+ .filter(codec => this.media.indexOf(codec.indexOf('vc1') > 0 ? 'video' : 'audio') >= 0)
+ .filter(codec => isSupported(`video/mp4; codecs="${codec}"`)).join();
+ }
+
+ /**
+ * `CustomElement`. Invoked each time the custom element is appended into a
+ * document-connected element.
+ */
+ connectedCallback() {
+ if (this.disconnectTID) {
+ clearTimeout(this.disconnectTID);
+ this.disconnectTID = 0;
+ }
+
+ // because video autopause on disconnected from DOM
+ if (this.video) {
+ const seek = this.video.seekable;
+ if (seek.length > 0) {
+ this.video.currentTime = seek.end(seek.length - 1);
+ }
+ this.play();
+ } else {
+ this.oninit();
+ }
+
+ this.onconnect();
+ }
+
+ /**
+ * `CustomElement`. Invoked each time the custom element is disconnected from the
+ * document's DOM.
+ */
+ disconnectedCallback() {
+ if (this.background || this.disconnectTID) return;
+ if (this.wsState === WebSocket.CLOSED && this.pcState === WebSocket.CLOSED) return;
+
+ this.disconnectTID = setTimeout(() => {
+ if (this.reconnectTID) {
+ clearTimeout(this.reconnectTID);
+ this.reconnectTID = 0;
+ }
+
+ this.disconnectTID = 0;
+
+ this.ondisconnect();
+ }, this.DISCONNECT_TIMEOUT);
+ }
+
+ /**
+ * Creates child DOM elements. Called automatically once on `connectedCallback`.
+ */
+ oninit() {
+ this.video = document.createElement('video');
+ this.video.controls = true;
+ this.video.playsInline = true;
+ this.video.preload = 'auto';
+
+ this.video.style.display = 'block'; // fix bottom margin 4px
+ this.video.style.width = '100%';
+ this.video.style.height = '100%';
+
+ this.appendChild(this.video);
+
+ this.video.addEventListener('error', ev => {
+ console.warn(ev);
+ if (this.ws) this.ws.close(); // run reconnect for broken MSE stream
+ });
+
+ // all Safari lies about supported audio codecs
+ const m = window.navigator.userAgent.match(/Version\/(\d+).+Safari/);
+ if (m) {
+ // AAC from v13, FLAC from v14, OPUS - unsupported
+ const skip = m[1] < '13' ? 'mp4a.40.2' : m[1] < '14' ? 'flac' : 'opus';
+ this.CODECS.splice(this.CODECS.indexOf(skip));
+ }
+
+ if (this.background) return;
+
+ if ('hidden' in document && this.visibilityCheck) {
+ document.addEventListener('visibilitychange', () => {
+ if (document.hidden) {
+ this.disconnectedCallback();
+ } else if (this.isConnected) {
+ this.connectedCallback();
+ }
+ });
+ }
+
+ if ('IntersectionObserver' in window && this.visibilityThreshold) {
+ const observer = new IntersectionObserver(entries => {
+ entries.forEach(entry => {
+ if (!entry.isIntersecting) {
+ this.disconnectedCallback();
+ } else if (this.isConnected) {
+ this.connectedCallback();
+ }
+ });
+ }, {threshold: this.visibilityThreshold});
+ observer.observe(this);
+ }
+ }
+
+ /**
+ * Connect to WebSocket. Called automatically on `connectedCallback`.
+ * @return {boolean} true if the connection has started.
+ */
+ onconnect() {
+ if (!this.isConnected || !this.wsURL || this.ws || this.pc) return false;
+
+ // CLOSED or CONNECTING => CONNECTING
+ this.wsState = WebSocket.CONNECTING;
+
+ this.connectTS = Date.now();
+
+ this.ws = new WebSocket(this.wsURL);
+ this.ws.binaryType = 'arraybuffer';
+ this.ws.addEventListener('open', () => this.onopen());
+ this.ws.addEventListener('close', () => this.onclose());
+
+ return true;
+ }
+
+ ondisconnect() {
+ this.wsState = WebSocket.CLOSED;
+ if (this.ws) {
+ this.ws.close();
+ this.ws = null;
+ }
+
+ this.pcState = WebSocket.CLOSED;
+ if (this.pc) {
+ this.pc.getSenders().forEach(sender => {
+ if (sender.track) sender.track.stop();
+ });
+ this.pc.close();
+ this.pc = null;
+ }
+
+ this.video.src = '';
+ this.video.srcObject = null;
+ }
+
+ /**
+ * @returns {Array.} of modes (mse, webrtc, etc.)
+ */
+ onopen() {
+ // CONNECTING => OPEN
+ this.wsState = WebSocket.OPEN;
+
+ this.ws.addEventListener('message', ev => {
+ if (typeof ev.data === 'string') {
+ const msg = JSON.parse(ev.data);
+ for (const mode in this.onmessage) {
+ this.onmessage[mode](msg);
+ }
+ } else {
+ this.ondata(ev.data);
+ }
+ });
+
+ this.ondata = null;
+ this.onmessage = {};
+
+ const modes = [];
+
+ if (this.mode.indexOf('mse') >= 0 && ('MediaSource' in window || 'ManagedMediaSource' in window)) {
+ modes.push('mse');
+ this.onmse();
+ } else if (this.mode.indexOf('hls') >= 0 && this.video.canPlayType('application/vnd.apple.mpegurl')) {
+ modes.push('hls');
+ this.onhls();
+ } else if (this.mode.indexOf('mp4') >= 0) {
+ modes.push('mp4');
+ this.onmp4();
+ }
+
+ if (this.mode.indexOf('webrtc') >= 0 && 'RTCPeerConnection' in window) {
+ modes.push('webrtc');
+ this.onwebrtc();
+ }
+
+ if (this.mode.indexOf('mjpeg') >= 0) {
+ if (modes.length) {
+ this.onmessage['mjpeg'] = msg => {
+ if (msg.type !== 'error' || msg.value.indexOf(modes[0]) !== 0) return;
+ this.onmjpeg();
+ };
+ } else {
+ modes.push('mjpeg');
+ this.onmjpeg();
+ }
+ }
+
+ return modes;
+ }
+
+ /**
+ * @return {boolean} true if reconnection has started.
+ */
+ onclose() {
+ if (this.wsState === WebSocket.CLOSED) return false;
+
+ // CONNECTING, OPEN => CONNECTING
+ this.wsState = WebSocket.CONNECTING;
+ this.ws = null;
+
+ // reconnect no more than once every X seconds
+ const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - this.connectTS), 0);
+
+ this.reconnectTID = setTimeout(() => {
+ this.reconnectTID = 0;
+ this.onconnect();
+ }, delay);
+
+ return true;
+ }
+
+ onmse() {
+ /** @type {MediaSource} */
+ let ms;
+
+ if ('ManagedMediaSource' in window) {
+ const MediaSource = window.ManagedMediaSource;
+
+ ms = new MediaSource();
+ ms.addEventListener('sourceopen', () => {
+ this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)});
+ }, {once: true});
+
+ this.video.disableRemotePlayback = true;
+ this.video.srcObject = ms;
+ } else {
+ ms = new MediaSource();
+ ms.addEventListener('sourceopen', () => {
+ URL.revokeObjectURL(this.video.src);
+ this.send({type: 'mse', value: this.codecs(MediaSource.isTypeSupported)});
+ }, {once: true});
+
+ this.video.src = URL.createObjectURL(ms);
+ this.video.srcObject = null;
+ }
+
+ this.play();
+
+ this.mseCodecs = '';
+
+ this.onmessage['mse'] = msg => {
+ if (msg.type !== 'mse') return;
+
+ this.mseCodecs = msg.value;
+
+ const sb = ms.addSourceBuffer(msg.value);
+ sb.mode = 'segments'; // segments or sequence
+ sb.addEventListener('updateend', () => {
+ if (!sb.updating && bufLen > 0) {
+ try {
+ const data = buf.slice(0, bufLen);
+ sb.appendBuffer(data);
+ bufLen = 0;
+ } catch (e) {
+ // console.debug(e);
+ }
+ }
+
+ if (!sb.updating && sb.buffered && sb.buffered.length) {
+ const end = sb.buffered.end(sb.buffered.length - 1);
+ const start = end - 5;
+ const start0 = sb.buffered.start(0);
+ if (start > start0) {
+ sb.remove(start0, start);
+ ms.setLiveSeekableRange(start, end);
+ }
+ if (this.video.currentTime < start) {
+ this.video.currentTime = start;
+ }
+ const gap = end - this.video.currentTime;
+ this.video.playbackRate = gap > 0.1 ? gap : 0.1;
+ // console.debug('VideoRTC.buffered', gap, this.video.playbackRate, this.video.readyState);
+ }
+ });
+
+ const buf = new Uint8Array(2 * 1024 * 1024);
+ let bufLen = 0;
+
+ this.ondata = data => {
+ if (sb.updating || bufLen > 0) {
+ const b = new Uint8Array(data);
+ buf.set(b, bufLen);
+ bufLen += b.byteLength;
+ // console.debug('VideoRTC.buffer', b.byteLength, bufLen);
+ } else {
+ try {
+ sb.appendBuffer(data);
+ } catch (e) {
+ // console.debug(e);
+ }
+ }
+ };
+ };
+ }
+
+ onwebrtc() {
+ const pc = new RTCPeerConnection(this.pcConfig);
+
+ pc.addEventListener('icecandidate', ev => {
+ if (ev.candidate && this.mode.indexOf('webrtc/tcp') >= 0 && ev.candidate.protocol === 'udp') return;
+
+ const candidate = ev.candidate ? ev.candidate.toJSON().candidate : '';
+ this.send({type: 'webrtc/candidate', value: candidate});
+ });
+
+ pc.addEventListener('connectionstatechange', () => {
+ if (pc.connectionState === 'connected') {
+ const tracks = pc.getTransceivers()
+ .filter(tr => tr.currentDirection === 'recvonly') // skip inactive
+ .map(tr => tr.receiver.track);
+ /** @type {HTMLVideoElement} */
+ const video2 = document.createElement('video');
+ video2.addEventListener('loadeddata', () => this.onpcvideo(video2), {once: true});
+ video2.srcObject = new MediaStream(tracks);
+ } else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
+ pc.close(); // stop next events
+
+ this.pcState = WebSocket.CLOSED;
+ this.pc = null;
+
+ this.onconnect();
+ }
+ });
+
+ this.onmessage['webrtc'] = msg => {
+ switch (msg.type) {
+ case 'webrtc/candidate':
+ if (this.mode.indexOf('webrtc/tcp') >= 0 && msg.value.indexOf(' udp ') > 0) return;
+
+ pc.addIceCandidate({candidate: msg.value, sdpMid: '0'}).catch(er => {
+ console.warn(er);
+ });
+ break;
+ case 'webrtc/answer':
+ pc.setRemoteDescription({type: 'answer', sdp: msg.value}).catch(er => {
+ console.warn(er);
+ });
+ break;
+ case 'error':
+ if (msg.value.indexOf('webrtc/offer') < 0) return;
+ pc.close();
+ }
+ };
+
+ this.createOffer(pc).then(offer => {
+ this.send({type: 'webrtc/offer', value: offer.sdp});
+ });
+
+ this.pcState = WebSocket.CONNECTING;
+ this.pc = pc;
+ }
+
+ /**
+ * @param pc {RTCPeerConnection}
+ * @return {Promise}
+ */
+ async createOffer(pc) {
+ try {
+ if (this.media.indexOf('microphone') >= 0) {
+ const media = await navigator.mediaDevices.getUserMedia({audio: true});
+ media.getTracks().forEach(track => {
+ pc.addTransceiver(track, {direction: 'sendonly'});
+ });
+ }
+ } catch (e) {
+ console.warn(e);
+ }
+
+ for (const kind of ['video', 'audio']) {
+ if (this.media.indexOf(kind) >= 0) {
+ pc.addTransceiver(kind, {direction: 'recvonly'});
+ }
+ }
+
+ const offer = await pc.createOffer();
+ await pc.setLocalDescription(offer);
+ return offer;
+ }
+
+ /**
+ * @param video2 {HTMLVideoElement}
+ */
+ onpcvideo(video2) {
+ if (this.pc) {
+ // Video+Audio > Video, H265 > H264, Video > Audio, WebRTC > MSE
+ let rtcPriority = 0, msePriority = 0;
+
+ /** @type {MediaStream} */
+ const stream = video2.srcObject;
+ if (stream.getVideoTracks().length > 0) rtcPriority += 0x220;
+ if (stream.getAudioTracks().length > 0) rtcPriority += 0x102;
+
+ if (this.mseCodecs.indexOf('hvc1.') >= 0) msePriority += 0x230;
+ if (this.mseCodecs.indexOf('avc1.') >= 0) msePriority += 0x210;
+ if (this.mseCodecs.indexOf('mp4a.') >= 0) msePriority += 0x101;
+
+ if (rtcPriority >= msePriority) {
+ this.video.srcObject = stream;
+ this.play();
+
+ this.pcState = WebSocket.OPEN;
+
+ this.wsState = WebSocket.CLOSED;
+ if (this.ws) {
+ this.ws.close();
+ this.ws = null;
+ }
+ } else {
+ this.pcState = WebSocket.CLOSED;
+ if (this.pc) {
+ this.pc.close();
+ this.pc = null;
+ }
+ }
+ }
+
+ video2.srcObject = null;
+ }
+
+ onmjpeg() {
+ this.ondata = data => {
+ this.video.controls = false;
+ this.video.poster = 'data:image/jpeg;base64,' + VideoRTC.btoa(data);
+ };
+
+ this.send({type: 'mjpeg'});
+ }
+
+ onhls() {
+ this.onmessage['hls'] = msg => {
+ if (msg.type !== 'hls') return;
+
+ const url = 'http' + this.wsURL.substring(2, this.wsURL.indexOf('/ws')) + '/hls/';
+ const playlist = msg.value.replace('hls/', url);
+ this.video.src = 'data:application/vnd.apple.mpegurl;base64,' + btoa(playlist);
+ this.play();
+ };
+
+ this.send({type: 'hls', value: this.codecs(type => this.video.canPlayType(type))});
+ }
+
+ onmp4() {
+ /** @type {HTMLCanvasElement} **/
+ const canvas = document.createElement('canvas');
+ /** @type {CanvasRenderingContext2D} */
+ let context;
+
+ /** @type {HTMLVideoElement} */
+ const video2 = document.createElement('video');
+ video2.autoplay = true;
+ video2.playsInline = true;
+ video2.muted = true;
+
+ video2.addEventListener('loadeddata', () => {
+ if (!context) {
+ canvas.width = video2.videoWidth;
+ canvas.height = video2.videoHeight;
+ context = canvas.getContext('2d');
+ }
+
+ context.drawImage(video2, 0, 0, canvas.width, canvas.height);
+
+ this.video.controls = false;
+ this.video.poster = canvas.toDataURL('image/jpeg');
+ });
+
+ this.ondata = data => {
+ video2.src = 'data:video/mp4;base64,' + VideoRTC.btoa(data);
+ };
+
+ this.send({type: 'mp4', value: this.codecs(this.video.canPlayType)});
+ }
+
+ static btoa(buffer) {
+ const bytes = new Uint8Array(buffer);
+ const len = bytes.byteLength;
+ let binary = '';
+ for (let i = 0; i < len; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return window.btoa(binary);
+ }
+}
diff --git a/custom_components/webrtc/www/webrtc-camera.js b/custom_components/webrtc/www/webrtc-camera.js
new file mode 100644
index 0000000..6f2d659
--- /dev/null
+++ b/custom_components/webrtc/www/webrtc-camera.js
@@ -0,0 +1,691 @@
+/** Chrome 63+, Safari 11.1+ */
+import {VideoRTC} from './video-rtc.js?v=1.9.9';
+import {DigitalPTZ} from './digital-ptz.js?v=3.3.0';
+
+class WebRTCCamera extends VideoRTC {
+ /**
+ * Step 1. Called by the Hass, when config changed.
+ * @param {Object} config
+ */
+ setConfig(config) {
+ if (!config.url && !config.entity && !config.streams) throw new Error('Missing `url` or `entity` or `streams`');
+
+ if (config.background) this.background = config.background;
+
+ if (config.intersection === 0) this.visibilityThreshold = 0;
+ else this.visibilityThreshold = config.intersection || 0.75;
+
+ /**
+ * @type {{
+ * url: string,
+ * entity: string,
+ * mode: string,
+ * media: string,
+ *
+ * streams: Array<{
+ * name: string,
+ * url: string,
+ * entity: string,
+ * mode: string,
+ * media: string,
+ * }>,
+ *
+ * title: string,
+ * poster: string,
+ * poster_remote: boolean,
+ * muted: boolean,
+ * intersection: number,
+ * ui: boolean,
+ * style: string,
+ * background: boolean,
+ *
+ * server: string,
+ *
+ * mse: boolean,
+ * webrtc: boolean,
+ *
+ * digital_ptz:{
+ * mouse_drag_pan: boolean,
+ * mouse_wheel_zoom: boolean,
+ * mouse_double_click_zoom: boolean,
+ * touch_pinch_zoom: boolean,
+ * touch_drag_pan: boolean,
+ * touch_tap_drag_zoom: boolean,
+ * persist: boolean|string,
+ * },
+ * ptz:{
+ * opacity: number|string,
+ * service: string,
+ * data_left, data_up, data_right, data_down, data_zoom_in, data_zoom_out, data_home
+ * },
+ * shortcuts:Array<{ name:string, icon:string }>,
+ * }} config
+ */
+ this.config = Object.assign({
+ mode: config.mse === false ? 'webrtc' : config.webrtc === false ? 'mse' : this.mode,
+ media: this.media,
+ streams: [{url: config.url, entity: config.entity}],
+ poster_remote: config.poster && (config.poster.indexOf('://') > 0 || config.poster.charAt(0) === '/'),
+ }, config);
+
+ this.streamID = -1;
+ this.nextStream(false);
+
+ this.onhass = [];
+ }
+
+ set hass(hass) {
+ this._hass = hass;
+ this.onhass.forEach(fn => fn());
+ // if card in vertical stack - `hass` property assign after `onconnect`
+ // this.onconnect();
+ }
+
+ get hass() {
+ return this._hass;
+ }
+
+ /**
+ * Called by the Hass to calculate default card height.
+ */
+ getCardSize() {
+ return 5; // x 50px
+ }
+
+ /**
+ * Called by the Hass to get defaul card config
+ * @return {{url: string}}
+ */
+ static getStubConfig() {
+ return {'url': ''};
+ }
+
+ setStatus(mode, status) {
+ const divMode = this.querySelector('.mode').innerText;
+ if (mode === 'error' && divMode !== 'Loading..' && divMode !== 'Loading...') return;
+
+ this.querySelector('.mode').innerText = mode;
+ this.querySelector('.status').innerText = status || '';
+ }
+
+ /** @param reload {boolean} */
+ nextStream(reload) {
+ this.streamID = (this.streamID + 1) % this.config.streams.length;
+
+ const stream = this.config.streams[this.streamID];
+ this.config.url = stream.url;
+ this.config.entity = stream.entity;
+ this.mode = stream.mode || this.config.mode;
+ this.media = stream.media || this.config.media;
+
+ if (reload) {
+ this.ondisconnect();
+ setTimeout(() => this.onconnect(), 100); // wait ws.close event
+ }
+ }
+
+ /** @return {string} */
+ get streamName() {
+ return this.config.streams[this.streamID].name || `S${this.streamID}`;
+ }
+
+ oninit() {
+ super.oninit();
+ this.renderMain();
+ this.renderDigitalPTZ();
+ this.renderPTZ();
+ this.renderCustomUI();
+ this.renderShortcuts();
+ this.renderStyle();
+ }
+
+ onconnect() {
+ if (!this.config || !this.hass) return false;
+ if (!this.isConnected || this.ws || this.pc) return false;
+
+ const divMode = this.querySelector('.mode').innerText;
+ if (divMode === 'Loading..') return;
+
+ this.setStatus('Loading..');
+
+ this.hass.callWS({
+ type: 'auth/sign_path', path: '/api/webrtc/ws'
+ }).then(data => {
+ if (this.config.poster && !this.config.poster_remote) {
+ this.video.poster = this.hass.hassUrl(data.path) + '&poster=' + encodeURIComponent(this.config.poster);
+ }
+
+ this.wsURL = 'ws' + this.hass.hassUrl(data.path).substring(4);
+
+ if (this.config.entity) {
+ this.wsURL += '&entity=' + this.config.entity;
+ } else if (this.config.url) {
+ this.wsURL += '&url=' + encodeURIComponent(this.config.url);
+ } else {
+ this.setStatus('IMG');
+ return;
+ }
+
+ if (this.config.server) {
+ this.wsURL += '&server=' + encodeURIComponent(this.config.server);
+ }
+
+ if (super.onconnect()) {
+ this.setStatus('Loading...');
+ } else {
+ this.setStatus('error', 'unable to connect');
+ }
+ }).catch(er => {
+ this.setStatus('error', er);
+ });
+ }
+
+ onopen() {
+ const result = super.onopen();
+
+ this.onmessage['stream'] = msg => {
+ switch (msg.type) {
+ case 'error':
+ this.setStatus('error', msg.value);
+ break;
+ case 'mse':
+ case 'hls':
+ case 'mp4':
+ case 'mjpeg':
+ this.setStatus(msg.type.toUpperCase(), this.config.title || '');
+ break;
+ }
+ };
+
+ return result;
+ }
+
+ onpcvideo(ev) {
+ super.onpcvideo(ev);
+
+ if (this.pcState !== WebSocket.CLOSED) {
+ this.setStatus('RTC', this.config.title || '');
+ }
+ }
+
+ renderMain() {
+ const shadow = this.attachShadow({mode: 'open'});
+ shadow.innerHTML = `
+
+
+
+
+
+ `;
+
+ this.querySelector = selectors => this.shadowRoot.querySelector(selectors);
+ this.querySelector('.ptz-transform').appendChild(this.video);
+
+ const mode = this.querySelector('.mode');
+ mode.addEventListener('click', () => this.nextStream(true));
+
+ if (this.config.muted) this.video.muted = true;
+ if (this.config.poster_remote) this.video.poster = this.config.poster;
+ }
+
+ renderDigitalPTZ() {
+ if (this.config.digital_ptz === false) return;
+ new DigitalPTZ(
+ this.querySelector('.player'),
+ this.querySelector('.player .ptz-transform'),
+ this.video,
+ Object.assign({}, this.config.digital_ptz, {persist_key: this.config.url})
+ );
+ }
+
+ renderPTZ() {
+ if (!this.config.ptz || !this.config.ptz.service) return;
+
+ let hasMove = false;
+ let hasZoom = false;
+ let hasHome = false;
+ for (const prefix of ['', '_start', '_end', '_long']) {
+ hasMove = hasMove || this.config.ptz['data' + prefix + '_right'];
+ hasMove = hasMove || this.config.ptz['data' + prefix + '_left'];
+ hasMove = hasMove || this.config.ptz['data' + prefix + '_up'];
+ hasMove = hasMove || this.config.ptz['data' + prefix + '_down'];
+
+ hasZoom = hasZoom || this.config.ptz['data' + prefix + '_zoom_in'];
+ hasZoom = hasZoom || this.config.ptz['data' + prefix + '_zoom_out'];
+
+ hasHome = hasHome || this.config.ptz['data' + prefix + '_home'];
+ }
+
+ const card = this.querySelector('.card');
+ card.insertAdjacentHTML('beforebegin', `
+
+ `);
+ card.insertAdjacentHTML('beforeend', `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `);
+
+ const template = JSON.stringify(this.config.ptz);
+ const handle = path => {
+ if (!this.config.ptz['data_' + path]) return;
+ const config = template.indexOf('${') < 0 ? this.config.ptz : JSON.parse(eval('`' + template + '`'));
+ const [domain, service] = config.service.split('.', 2);
+ const data = config['data_' + path];
+ this.hass.callService(domain, service, data);
+ };
+ const ptz = this.querySelector('.ptz');
+ for (const [start, end] of [['touchstart', 'touchend'], ['mousedown', 'mouseup']]) {
+ ptz.addEventListener(start, startEvt => {
+ const {className} = startEvt.target;
+ startEvt.preventDefault();
+ handle('start_' + className);
+ window.addEventListener(end, endEvt => {
+ endEvt.preventDefault();
+ handle('end_' + className);
+ if (endEvt.timeStamp - startEvt.timeStamp > 400) {
+ handle('long_' + className);
+ } else {
+ handle(className);
+ }
+ }, {once: true});
+ });
+ }
+ }
+
+ saveScreenshot() {
+ const a = document.createElement('a');
+
+ if (this.video.videoWidth && this.video.videoHeight) {
+ const canvas = document.createElement('canvas');
+ canvas.width = this.video.videoWidth;
+ canvas.height = this.video.videoHeight;
+ canvas.getContext('2d').drawImage(this.video, 0, 0, canvas.width, canvas.height);
+ a.href = canvas.toDataURL('image/jpeg');
+ } else if (this.video.poster && this.video.poster.startsWith('data:image/jpeg')) {
+ a.href = this.video.poster;
+ } else {
+ return;
+ }
+
+ const ts = new Date().toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '');
+ a.download = `snapshot_${ts}.jpeg`;
+ a.click();
+ }
+
+ renderCustomUI() {
+ if (!this.config.ui) return;
+
+ this.video.controls = false;
+ this.video.style.pointerEvents = 'none';
+
+ const card = this.querySelector('.card');
+ card.insertAdjacentHTML('beforebegin', `
+
+ `);
+ card.insertAdjacentHTML('beforeend', `
+
+
+
+
+
+
+ ${this.streamName}
+
+
+
+
+
+ `);
+
+ const video = this.video;
+
+ const fullscreen = this.querySelector('.fullscreen');
+ if (this.requestFullscreen) {
+ this.addEventListener('fullscreenchange', () => {
+ fullscreen.icon = document.fullscreenElement ? 'mdi:fullscreen-exit' : 'mdi:fullscreen';
+ });
+ } else if (video.webkitEnterFullscreen) {
+ this.requestFullscreen = () => new Promise((resolve, reject) => {
+ try {
+ video.webkitEnterFullscreen();
+ } catch (e) {
+ reject(e);
+ }
+ });
+ video.addEventListener('webkitendfullscreen', () => {
+ setTimeout(() => this.play(), 1000); // fix bug in iOS
+ });
+ } else {
+ fullscreen.style.display = 'none';
+ }
+
+ const pip = this.querySelector('.pictureinpicture');
+ if (video.requestPictureInPicture) {
+ video.addEventListener('enterpictureinpicture', () => {
+ pip.icon = 'mdi:rectangle';
+ this.background = true;
+ });
+ video.addEventListener('leavepictureinpicture', () => {
+ pip.icon = 'mdi:picture-in-picture-bottom-right';
+ this.background = this.config.background;
+ this.play();
+ });
+ } else {
+ pip.style.display = 'none';
+ }
+
+ const ui = this.querySelector('.ui');
+ ui.addEventListener('click', ev => {
+ const icon = ev.target.icon;
+ if (icon === 'mdi:play') {
+ this.play();
+ } else if (icon === 'mdi:volume-mute') {
+ video.muted = false;
+ } else if (icon === 'mdi:volume-high') {
+ video.muted = true;
+ } else if (icon === 'mdi:fullscreen') {
+ this.requestFullscreen().catch(console.warn);
+ } else if (icon === 'mdi:fullscreen-exit') {
+ document.exitFullscreen().catch(console.warn);
+ } else if (icon === 'mdi:floppy') {
+ this.saveScreenshot();
+ } else if (icon === 'mdi:picture-in-picture-bottom-right') {
+ video.requestPictureInPicture().catch(console.warn);
+ } else if (icon === 'mdi:rectangle') {
+ document.exitPictureInPicture().catch(console.warn);
+ } else if (ev.target.className === 'stream') {
+ this.nextStream(true);
+ ev.target.innerText = this.streamName;
+ }
+ });
+
+ const spinner = this.querySelector('.spinner');
+ video.addEventListener('waiting', () => {
+ spinner.style.display = 'block';
+ });
+ video.addEventListener('playing', () => {
+ spinner.style.display = 'none';
+ });
+
+ const play = this.querySelector('.play');
+ video.addEventListener('play', () => {
+ play.style.display = 'none';
+ });
+ video.addEventListener('pause', () => {
+ play.style.display = 'block';
+ });
+
+ const volume = this.querySelector('.volume');
+ video.addEventListener('loadeddata', () => {
+ volume.style.display = this.hasAudio ? 'block' : 'none';
+ });
+ video.addEventListener('volumechange', () => {
+ volume.icon = video.muted ? 'mdi:volume-mute' : 'mdi:volume-high';
+ });
+
+ const stream = this.querySelector('.stream');
+ stream.style.display = this.config.streams.length > 1 ? 'block' : 'none';
+ }
+
+ renderShortcuts() {
+ if (!this.config.shortcuts) return;
+
+ const card = this.querySelector('.card');
+ card.insertAdjacentHTML('beforebegin', `
+
+ `);
+ card.insertAdjacentHTML('beforeend', '');
+
+ const shortcuts = this.querySelector('.shortcuts');
+ shortcuts.addEventListener('click', ev => {
+ const value = this.config.shortcuts[ev.target.dataset.index];
+ if (value.more_info !== undefined) {
+ const event = new Event('hass-more-info', {
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ });
+ event.detail = {entityId: value.more_info};
+ ev.target.dispatchEvent(event);
+ }
+ if (value.service !== undefined) {
+ const [domain, name] = value.service.split('.');
+ this.hass.callService(domain, name, value.service_data || {});
+ }
+ });
+
+ this.renderTemplate('shortcuts', () => {
+ shortcuts.innerHTML = this.config.shortcuts.map((value, index) => `
+
+ `).join('');
+ });
+ }
+
+ renderStyle() {
+ if (!this.config.style) return;
+
+ const style = document.createElement('style');
+ const card = this.querySelector('.card');
+ card.insertAdjacentElement('beforebegin', style);
+
+ this.renderTemplate('style', () => {
+ style.innerText = this.config.style;
+ });
+ }
+
+ renderTemplate(name, renderHTML) {
+ const config = this.config[name];
+ // support config param as string or as object
+ const template = typeof config === 'string' ? config : JSON.stringify(config);
+ // check if config param has template
+ if (template.indexOf('${') >= 0) {
+ const render = () => {
+ try {
+ const states = this.hass ? this.hass.states : undefined;
+ this.config[name] = JSON.parse(eval('`' + template + '`'));
+ renderHTML();
+ } catch (e) {
+ console.debug(e);
+ }
+ };
+ this.onhass.push(render);
+ render();
+ } else {
+ renderHTML();
+ }
+ }
+
+ get hasAudio() {
+ return (
+ (this.video.srcObject && this.video.srcObject.getAudioTracks && this.video.srcObject.getAudioTracks().length) ||
+ (this.video.mozHasAudio || this.video.webkitAudioDecodedByteCount) ||
+ (this.video.audioTracks && this.video.audioTracks.length)
+ );
+ }
+}
+
+customElements.define('webrtc-camera', WebRTCCamera);
+
+const card = {
+ type: 'webrtc-camera',
+ name: 'WebRTC Camera',
+ preview: false,
+ description: 'WebRTC camera allows you to view the stream of almost any camera without delay',
+};
+// Apple iOS 12 doesn't support `||=`
+if (window.customCards) window.customCards.push(card);
+else window.customCards = [card];
+
diff --git a/custom_components/wyzeapi/__init__.py b/custom_components/wyzeapi/__init__.py
new file mode 100644
index 0000000..49d2dae
--- /dev/null
+++ b/custom_components/wyzeapi/__init__.py
@@ -0,0 +1,215 @@
+"""The Wyze Home Assistant Integration."""
+
+from __future__ import annotations
+
+import logging
+
+from aiohttp.client_exceptions import ClientConnectorError
+from homeassistant.config_entries import ConfigEntry, ConfigEntryNotReady, SOURCE_IMPORT
+from homeassistant.const import CONF_USERNAME, CONF_PASSWORD
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.check_config import HomeAssistantConfig
+from homeassistant.components import bluetooth
+from wyzeapy import Wyzeapy
+from wyzeapy.exceptions import AccessTokenError
+from wyzeapy.wyze_auth_lib import Token
+
+from .const import (
+ DOMAIN,
+ CONF_CLIENT,
+ ACCESS_TOKEN,
+ REFRESH_TOKEN,
+ REFRESH_TIME,
+ WYZE_NOTIFICATION_TOGGLE,
+ BULB_LOCAL_CONTROL,
+ DEFAULT_LOCAL_CONTROL,
+ KEY_ID,
+ API_KEY,
+)
+from .coordinator import WyzeLockBoltCoordinator
+from .token_manager import TokenManager
+
+PLATFORMS = [
+ "light",
+ "switch",
+ "lock",
+ "climate",
+ "alarm_control_panel",
+ "sensor",
+ "siren",
+ "cover",
+ "number",
+ "button",
+ "camera",
+] # Fixme: Re add scene
+_LOGGER = logging.getLogger(__name__)
+
+
+# noinspection PyUnusedLocal
+async def async_setup(
+ hass: HomeAssistant, config: HomeAssistantConfig, discovery_info=None
+):
+ # pylint: disable=unused-argument
+ """Set up the WyzeApi domain."""
+ if hass.config_entries.async_entries(DOMAIN):
+ _LOGGER.debug(
+ "Nothing to import from configuration.yaml, loading from Integrations",
+ )
+ return True
+
+ # noinspection SpellCheckingInspection
+ domainconfig = config.get(DOMAIN)
+ # pylint: disable=logging-not-lazy
+ _LOGGER.debug(
+ "Importing config information for %s from configuration.yml"
+ % domainconfig[CONF_USERNAME]
+ )
+ if hass.config_entries.async_entries(DOMAIN):
+ _LOGGER.debug("Found existing config entries")
+ for entry in hass.config_entries.async_entries(DOMAIN):
+ if entry:
+ entry_data = entry.as_dict().get("data")
+ hass.config_entries.async_update_entry(
+ entry,
+ data=entry_data,
+ )
+ break
+ else:
+ _LOGGER.debug("Creating new config entry")
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data={
+ CONF_USERNAME: domainconfig[CONF_USERNAME],
+ CONF_PASSWORD: domainconfig[CONF_PASSWORD],
+ ACCESS_TOKEN: domainconfig[ACCESS_TOKEN],
+ REFRESH_TOKEN: domainconfig[REFRESH_TOKEN],
+ REFRESH_TIME: domainconfig[REFRESH_TIME],
+ KEY_ID: domainconfig[KEY_ID],
+ API_KEY: domainconfig[API_KEY],
+ },
+ )
+ )
+ return True
+
+
+# noinspection DuplicatedCode
+async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+ """Set up Wyze Home Assistant Integration from a config entry."""
+
+ hass.data.setdefault(DOMAIN, {})
+
+ key_id = config_entry.data.get(KEY_ID)
+ api_key = config_entry.data.get(API_KEY)
+
+ client = await Wyzeapy.create()
+ token = None
+ if config_entry.data.get(ACCESS_TOKEN):
+ token = Token(
+ config_entry.data.get(ACCESS_TOKEN),
+ config_entry.data.get(REFRESH_TOKEN),
+ float(config_entry.data.get(REFRESH_TIME)),
+ )
+ a_tkn_manager = TokenManager(hass, config_entry)
+ client.register_for_token_callback(a_tkn_manager.token_callback)
+ # We should probably try/catch here to invalidate the login credentials and throw a notification if we cannot get
+ # a login with the token
+ try:
+ await client.login(
+ config_entry.data.get(CONF_USERNAME),
+ config_entry.data.get(CONF_PASSWORD),
+ key_id,
+ api_key,
+ token,
+ )
+ except ClientConnectorError as e:
+ raise ConfigEntryNotReady("Unable to login due to network issues.") from e
+ except AccessTokenError as e:
+ _LOGGER.error(
+ "Wyzeapi: Could not login. Please re-login through integration configuration"
+ )
+ _LOGGER.error(e)
+ raise ConfigEntryAuthFailed("Unable to login, please re-login.") from None
+
+ hass.data[DOMAIN][config_entry.entry_id] = {
+ CONF_CLIENT: client,
+ "key_id": KEY_ID,
+ "api_key": API_KEY,
+ "coordinators": {},
+ }
+ await setup_coordinators(hass, config_entry, client)
+
+ options_dict = {
+ BULB_LOCAL_CONTROL: config_entry.options.get(
+ BULB_LOCAL_CONTROL, DEFAULT_LOCAL_CONTROL
+ )
+ }
+ hass.config_entries.async_update_entry(config_entry, options=options_dict)
+
+ await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
+
+ mac_addresses = await client.unique_device_ids
+
+ mac_addresses.add(WYZE_NOTIFICATION_TOGGLE)
+
+ hms_service = await client.hms_service
+ hms_id = hms_service.hms_id
+ if hms_id is not None:
+ mac_addresses.add(hms_id)
+
+ device_registry = dr.async_get(hass)
+ for device in dr.async_entries_for_config_entry(
+ device_registry, config_entry.entry_id
+ ):
+ for identifier in device.identifiers:
+ # domain has to remain here. If it is removed the integration will remove all entities for not being in
+ # the mac address list each boot.
+ domain, mac = identifier
+ if mac not in mac_addresses:
+ _LOGGER.warning(
+ "%s is not in the mac_addresses list, removing the entry", mac
+ )
+ device_registry.async_remove_device(device.id)
+ return True
+
+
+async def options_update_listener(hass: HomeAssistant, config_entry: ConfigEntry):
+ """Handle options update."""
+ _LOGGER.debug("Updated options")
+ entry_data = config_entry.as_dict().get("data")
+ hass.config_entries.async_update_entry(
+ config_entry,
+ data=entry_data,
+ )
+ _LOGGER.debug("Reload entry: " + config_entry.entry_id)
+ await hass.config_entries.async_reload(config_entry.entry_id)
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Unload a config entry."""
+
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def setup_coordinators(
+ hass: HomeAssistant, config_entry: ConfigEntry, client: Wyzeapy
+):
+ """Set up coordinators for Wyze devices that require Bluetooth."""
+ # Check if Bluetooth is active and functioning
+ if bluetooth.async_scanner_count(hass, connectable=True) == 0:
+ _LOGGER.info(
+ "Bluetooth is not active or no scanners available. Skipping WyzeLockBoltCoordinator setup."
+ )
+ return
+
+ lock_service = await client.lock_service
+ for lock in await lock_service.get_locks():
+ if lock.product_model == "YD_BT1":
+ coordinators = hass.data[DOMAIN][config_entry.entry_id].setdefault(
+ "coordinators", {}
+ )
+ coordinators[lock.mac] = WyzeLockBoltCoordinator(hass, lock_service, lock)
+ await coordinators[lock.mac].update_lock_info()
diff --git a/custom_components/wyzeapi/__pycache__/__init__.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000..a9ed5cf
Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/__init__.cpython-314.pyc differ
diff --git a/custom_components/wyzeapi/__pycache__/alarm_control_panel.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/alarm_control_panel.cpython-314.pyc
new file mode 100644
index 0000000..97fb6bc
Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/alarm_control_panel.cpython-314.pyc differ
diff --git a/custom_components/wyzeapi/__pycache__/button.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/button.cpython-314.pyc
new file mode 100644
index 0000000..1c7a311
Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/button.cpython-314.pyc differ
diff --git a/custom_components/wyzeapi/__pycache__/camera.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/camera.cpython-314.pyc
new file mode 100644
index 0000000..6953f3f
Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/camera.cpython-314.pyc differ
diff --git a/custom_components/wyzeapi/__pycache__/climate.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/climate.cpython-314.pyc
new file mode 100644
index 0000000..f7e5a1a
Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/climate.cpython-314.pyc differ
diff --git a/custom_components/wyzeapi/__pycache__/config_flow.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/config_flow.cpython-314.pyc
new file mode 100644
index 0000000..535cae9
Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/config_flow.cpython-314.pyc differ
diff --git a/custom_components/wyzeapi/__pycache__/const.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/const.cpython-314.pyc
new file mode 100644
index 0000000..6ade165
Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/const.cpython-314.pyc differ
diff --git a/custom_components/wyzeapi/__pycache__/coordinator.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/coordinator.cpython-314.pyc
new file mode 100644
index 0000000..26a97ee
Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/coordinator.cpython-314.pyc differ
diff --git a/custom_components/wyzeapi/__pycache__/cover.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/cover.cpython-314.pyc
new file mode 100644
index 0000000..2b3e466
Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/cover.cpython-314.pyc differ
diff --git a/custom_components/wyzeapi/__pycache__/light.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/light.cpython-314.pyc
new file mode 100644
index 0000000..c1ac49a
Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/light.cpython-314.pyc differ
diff --git a/custom_components/wyzeapi/__pycache__/lock.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/lock.cpython-314.pyc
new file mode 100644
index 0000000..d447812
Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/lock.cpython-314.pyc differ
diff --git a/custom_components/wyzeapi/__pycache__/number.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/number.cpython-314.pyc
new file mode 100644
index 0000000..e0779eb
Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/number.cpython-314.pyc differ
diff --git a/custom_components/wyzeapi/__pycache__/sensor.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/sensor.cpython-314.pyc
new file mode 100644
index 0000000..8731761
Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/sensor.cpython-314.pyc differ
diff --git a/custom_components/wyzeapi/__pycache__/siren.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/siren.cpython-314.pyc
new file mode 100644
index 0000000..460fc0d
Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/siren.cpython-314.pyc differ
diff --git a/custom_components/wyzeapi/__pycache__/switch.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/switch.cpython-314.pyc
new file mode 100644
index 0000000..0e00750
Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/switch.cpython-314.pyc differ
diff --git a/custom_components/wyzeapi/__pycache__/token_manager.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/token_manager.cpython-314.pyc
new file mode 100644
index 0000000..129b9ea
Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/token_manager.cpython-314.pyc differ
diff --git a/custom_components/wyzeapi/__pycache__/ydble_utils.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/ydble_utils.cpython-314.pyc
new file mode 100644
index 0000000..ece5844
Binary files /dev/null and b/custom_components/wyzeapi/__pycache__/ydble_utils.cpython-314.pyc differ
diff --git a/custom_components/wyzeapi/alarm_control_panel.py b/custom_components/wyzeapi/alarm_control_panel.py
new file mode 100644
index 0000000..5768f39
--- /dev/null
+++ b/custom_components/wyzeapi/alarm_control_panel.py
@@ -0,0 +1,166 @@
+"""
+This module handles the Wyze Home Monitoring system
+"""
+
+import logging
+from datetime import timedelta
+from typing import Optional, Callable, List, Any
+from aiohttp.client_exceptions import ClientConnectionError
+
+from homeassistant.components.alarm_control_panel import (
+ AlarmControlPanelState,
+ AlarmControlPanelEntity,
+ AlarmControlPanelEntityFeature,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import ATTR_ATTRIBUTION
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from wyzeapy import Wyzeapy, HMSService
+from wyzeapy.services.hms_service import HMSMode
+from wyzeapy.exceptions import AccessTokenError, ParameterError, UnknownApiError
+from .token_manager import token_exception_handler
+from homeassistant.helpers.entity import DeviceInfo
+
+from .const import DOMAIN, CONF_CLIENT
+
+_LOGGER = logging.getLogger(__name__)
+ATTRIBUTION = "Data provided by Wyze"
+SCAN_INTERVAL = timedelta(seconds=15)
+
+
+@token_exception_handler
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: Callable[[List[Any], bool], None],
+):
+ """
+ This function sets up the integration
+
+ :param hass: Reference to the HomeAssistant instance
+ :param config_entry: Reference to the config entry we are setting up
+ :param async_add_entities:
+ """
+
+ _LOGGER.debug("""Creating new WyzeApi Home Monitoring System component""")
+ client: Wyzeapy = hass.data[DOMAIN][config_entry.entry_id][CONF_CLIENT]
+
+ hms_service = await client.hms_service
+ if await hms_service.has_hms:
+ async_add_entities([WyzeHomeMonitoring(hms_service)], True)
+
+
+class WyzeHomeMonitoring(AlarmControlPanelEntity):
+ """
+ A representation of the Wyze Home Monitoring system that works for wyze
+ """
+
+ DEVICE_MODEL = "HMS"
+ MANUFACTURER = "WyzeLabs"
+ NAME = "Wyze Home Monitoring System"
+ AVAILABLE = True
+ _attr_has_entity_name = True
+ _attr_name = None
+
+ def __init__(self, hms_service: HMSService):
+ self._attr_unique_id = hms_service.hms_id
+
+ self._hms_service = hms_service
+ self._state = AlarmControlPanelState.DISARMED
+ self._server_out_of_sync = False
+
+ @property
+ def alarm_state(self) -> str:
+ return self._state
+
+ # NotImplemented Methods
+ def alarm_arm_vacation(self, code: Optional[str] = None) -> None:
+ raise NotImplementedError
+
+ def alarm_arm_night(self, code: Optional[str] = None) -> None:
+ raise NotImplementedError
+
+ def alarm_trigger(self, code: Optional[str] = None) -> None:
+ raise NotImplementedError
+
+ def alarm_arm_custom_bypass(self, code: Optional[str] = None) -> None:
+ raise NotImplementedError
+
+ # Implemented Methods
+ @token_exception_handler
+ async def async_alarm_disarm(self, code: Optional[str] = None) -> None:
+ """Send disarm command."""
+ try:
+ await self._hms_service.set_mode(HMSMode.DISARMED)
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._state = "disarmed"
+ self._server_out_of_sync = True
+
+ @token_exception_handler
+ async def async_alarm_arm_home(self, code: Optional[str] = None) -> None:
+ try:
+ await self._hms_service.set_mode(HMSMode.HOME)
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._state = "armed_home"
+ self._server_out_of_sync = True
+
+ @token_exception_handler
+ async def async_alarm_arm_away(self, code: Optional[str] = None) -> None:
+ try:
+ await self._hms_service.set_mode(HMSMode.AWAY)
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._state = "armed_away"
+ self._server_out_of_sync = True
+
+ @property
+ def supported_features(self) -> int:
+ return (
+ AlarmControlPanelEntityFeature.ARM_HOME
+ | AlarmControlPanelEntityFeature.ARM_AWAY
+ )
+
+ @property
+ def device_info(self) -> DeviceInfo:
+ return DeviceInfo(
+ identifiers={(DOMAIN, self.unique_id)},
+ name=self.NAME,
+ manufacturer=self.MANUFACTURER,
+ model=self.DEVICE_MODEL,
+ )
+
+ @property
+ def extra_state_attributes(self) -> dict | None:
+ """Return device attributes of the entity."""
+ return {ATTR_ATTRIBUTION: ATTRIBUTION, "mac": self.unique_id}
+
+ @token_exception_handler
+ async def async_update(self) -> None:
+ """Update the entity with data from the Wyze servers"""
+
+ if not self._server_out_of_sync:
+ state = await self._hms_service.update(self._hms_service.hms_id)
+ if state is HMSMode.DISARMED:
+ self._state = AlarmControlPanelState.DISARMED
+ elif state is HMSMode.AWAY:
+ self._state = AlarmControlPanelState.ARMED_AWAY
+ elif state is HMSMode.HOME:
+ self._state = AlarmControlPanelState.ARMED_HOME
+ elif state is HMSMode.CHANGING:
+ self._state = AlarmControlPanelState.DISARMED
+ else:
+ _LOGGER.warning(f"Received {state} from server")
+
+ self._server_out_of_sync = False
diff --git a/custom_components/wyzeapi/binary_sensor.py b/custom_components/wyzeapi/binary_sensor.py
new file mode 100644
index 0000000..c748f43
--- /dev/null
+++ b/custom_components/wyzeapi/binary_sensor.py
@@ -0,0 +1,224 @@
+"""
+This module describes the connection between Home Assistant and Wyze for the Sensors
+"""
+
+import logging
+import time
+from typing import Callable, List, Any
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorEntity,
+ BinarySensorDeviceClass,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import ATTR_ATTRIBUTION
+from homeassistant.core import HomeAssistant
+from wyzeapy import Wyzeapy, CameraService, SensorService
+from wyzeapy.services.camera_service import Camera
+from wyzeapy.services.sensor_service import Sensor
+from wyzeapy.types import DeviceTypes
+from .token_manager import token_exception_handler
+
+from .const import DOMAIN, CONF_CLIENT
+
+_LOGGER = logging.getLogger(__name__)
+ATTRIBUTION = "Data provided by Wyze"
+
+
+@token_exception_handler
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: Callable[[List[Any], bool], None],
+):
+ """
+ This function sets up the config entry for use in Home Assistant
+
+ :param hass: Home Assistant instance
+ :param config_entry: The current config_entry
+ :param async_add_entities: This function adds entities to the config_entry
+ :return:
+ """
+
+ _LOGGER.debug("""Creating new WyzeApi binary sensor component""")
+ client: Wyzeapy = hass.data[DOMAIN][config_entry.entry_id][CONF_CLIENT]
+
+ sensor_service = await client.sensor_service
+ camera_service = await client.camera_service
+
+ cameras = [
+ WyzeCameraMotion(camera_service, camera)
+ for camera in await camera_service.get_cameras()
+ ]
+ sensors = [
+ WyzeSensor(sensor_service, sensor)
+ for sensor in await sensor_service.get_sensors()
+ ]
+
+ async_add_entities(cameras, True)
+ async_add_entities(sensors, True)
+
+
+class WyzeSensor(BinarySensorEntity):
+ """
+ A representation of the WyzeSensor for use in Home Assistant
+ """
+
+ def __init__(self, sensor_service: SensorService, sensor: Sensor):
+ """Initializes the class"""
+ self._sensor_service = sensor_service
+ self._sensor = sensor
+ self._last_event = int(str(int(time.time())) + "000")
+
+ async def async_added_to_hass(self) -> None:
+ """Registers for updates when the entity is added to Home Assistant"""
+ await self._sensor_service.register_for_updates(
+ self._sensor, self.process_update
+ )
+
+ async def async_will_remove_from_hass(self) -> None:
+ await self._sensor_service.deregister_for_updates(self._sensor)
+
+ def process_update(self, sensor: Sensor):
+ """
+ This function processes an update for the Wyze Sensor
+
+ :param sensor: The sensor with the updated values
+ """
+ self._sensor = sensor
+ self.schedule_update_ha_state()
+
+ @property
+ def device_info(self):
+ return {
+ "identifiers": {(DOMAIN, self._sensor.mac)},
+ "name": self.name,
+ "manufacturer": "WyzeLabs",
+ "model": self._sensor.product_model,
+ }
+
+ @property
+ def available(self) -> bool:
+ return True
+
+ @property
+ def name(self):
+ """Return the display name of this switch."""
+ return self._sensor.nickname
+
+ @property
+ def should_poll(self) -> bool:
+ return False
+
+ @property
+ def is_on(self):
+ """Return true if sensor detects motion"""
+ return self._sensor.detected
+
+ @property
+ def unique_id(self):
+ return "{}-motion".format(self._sensor.mac)
+
+ @property
+ def extra_state_attributes(self):
+ """Return device attributes of the entity."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ "device model": self._sensor.product_model,
+ "mac": self.unique_id,
+ }
+
+ @property
+ def device_class(self):
+ # pylint: disable=R1705
+ if self._sensor.type is DeviceTypes.MOTION_SENSOR:
+ return BinarySensorDeviceClass.MOTION
+ elif self._sensor.type is DeviceTypes.CONTACT_SENSOR:
+ return BinarySensorDeviceClass.DOOR
+ else:
+ raise RuntimeError(
+ f"The device type {self._sensor.type} is not supported by this class"
+ )
+
+
+class WyzeCameraMotion(BinarySensorEntity):
+ """
+ A representation of the Wyze Camera for use as a binary sensor in Home Assistant
+ """
+
+ _is_on = False
+ _last_event = time.time() * 1000
+
+ def __init__(self, camera_service: CameraService, camera: Camera):
+ self._camera_service = camera_service
+ self._camera = camera
+
+ @property
+ def device_info(self):
+ return {
+ "identifiers": {(DOMAIN, self._camera.mac)},
+ "name": self.name,
+ "manufacturer": "WyzeLabs",
+ "model": self._camera.product_model,
+ }
+
+ @property
+ def available(self) -> bool:
+ return self._camera.available
+
+ @property
+ def name(self):
+ """Return the display name of this switch."""
+ return self._camera.nickname
+
+ @property
+ def should_poll(self) -> bool:
+ return False
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on"""
+ return self._is_on
+
+ @property
+ def unique_id(self):
+ return "{}-motion".format(self._camera.mac)
+
+ @property
+ def extra_state_attributes(self):
+ """Return device attributes of the entity."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ "device model": self._camera.product_model,
+ "mac": self.unique_id,
+ }
+
+ @property
+ def device_class(self):
+ return BinarySensorDeviceClass.MOTION
+
+ async def async_added_to_hass(self) -> None:
+ await self._camera_service.register_for_updates(
+ self._camera, self.process_update
+ )
+
+ async def async_will_remove_from_hass(self) -> None:
+ await self._camera_service.deregister_for_updates(self._camera)
+
+ @token_exception_handler
+ def process_update(self, camera: Camera) -> None:
+ """
+ Is called by the update worker for events to update the values in this sensor
+
+ :param camera: An updated version of the current camera
+ """
+ self._camera = camera
+
+ if camera.last_event_ts > self._last_event:
+ self._is_on = True
+ self._last_event = camera.last_event_ts
+ else:
+ self._is_on = False
+ self._last_event = camera.last_event_ts
+
+ self.schedule_update_ha_state()
diff --git a/custom_components/wyzeapi/button.py b/custom_components/wyzeapi/button.py
new file mode 100644
index 0000000..6d4413f
--- /dev/null
+++ b/custom_components/wyzeapi/button.py
@@ -0,0 +1,317 @@
+"""Platform for button integration."""
+
+from collections.abc import Callable
+import logging
+from typing import Any
+
+from aiohttp.client_exceptions import ClientConnectionError
+from wyzeapy import Wyzeapy
+from wyzeapy.services.irrigation_service import Irrigation, IrrigationService, Zone
+from wyzeapy.services.switch_service import Switch
+
+from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import device_registry as dr, entity_registry as er
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.entity import DeviceInfo
+from homeassistant.helpers.entity_registry import EntityCategory
+
+from .const import CONF_CLIENT, DOMAIN, RESET_BUTTON_PRESSED
+from .token_manager import token_exception_handler
+
+_LOGGER = logging.getLogger(__name__)
+ATTRIBUTION = "Data provided by Wyze"
+OUTDOOR_PLUGS = ["WLPPO"]
+
+
+@token_exception_handler
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: Callable[[list[Any], bool], None],
+) -> None:
+ """This function sets up the config entry.
+
+ :param hass: The Home Assistant Instance
+ :param config_entry: The current config entry
+ :param async_add_entities: This function adds entities to the config entry
+ :return:
+ """
+
+ _LOGGER.debug("""Creating new Wyze button component""")
+ client: Wyzeapy = hass.data[DOMAIN][config_entry.entry_id][CONF_CLIENT]
+ irrigation_service = await client.irrigation_service
+ switch_service = await client.switch_service
+
+ # Get all irrigation devices
+ irrigation_devices = await irrigation_service.get_irrigations()
+
+ # Create a button entity for each zone in each irrigation device
+ buttons = []
+ for device in irrigation_devices:
+ # Update the device to get its zones
+ device = await irrigation_service.update(device)
+ # Add a button entity for each enabled zone in the irrigation device
+ buttons.extend(
+ [
+ WyzeIrrigationZoneButton(irrigation_service, device, zone)
+ for zone in device.zones
+ if zone.enabled
+ ]
+ )
+ # Add a stop all schedules button for each irrigation device, not each zone
+ buttons.append(WyzeIrrigationStopAllButton(irrigation_service, device))
+
+ plugs = await switch_service.get_switches()
+ buttons.extend(
+ [
+ WyzePowerSensorResetButton(plug)
+ for plug in plugs
+ if plug.product_model in OUTDOOR_PLUGS
+ ]
+ )
+
+ async_add_entities(buttons, True)
+
+
+class WyzeIrrigationZoneButton(ButtonEntity):
+ """Representation of a Wyze Irrigation Zone Button."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self, irrigation_service: IrrigationService, irrigation: Irrigation, zone: Zone
+ ) -> None:
+ """Initialize the irrigation zone button."""
+ self._irrigation_service = irrigation_service
+ self._device = irrigation
+ self._zone = zone
+
+ @property
+ def name(self) -> str:
+ """Return the name of the zone."""
+ return f"{self._zone.name}"
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID for the zone."""
+ return f"Start {self._device.mac}-zone-{self._zone.zone_number}"
+
+ @property
+ def device_info(self) -> DeviceInfo:
+ """Return device information about this entity."""
+ return DeviceInfo(
+ identifiers={(DOMAIN, self._device.mac)},
+ name=self._device.nickname,
+ manufacturer="WyzeLabs",
+ model=self._device.product_model,
+ connections={(dr.CONNECTION_NETWORK_MAC, self._device.mac)},
+ )
+
+ @property
+ def device_class(self) -> str:
+ """Return the device class of the button."""
+ return ButtonDeviceClass.RESTART
+
+ @property
+ def icon(self) -> str:
+ """Return the icon for the zone start button."""
+ return "mdi:sprinkler"
+
+ @property
+ def extra_state_attributes(self) -> dict[str, Any]:
+ """Return entity specific state attributes."""
+ return {
+ "zone_number": self._zone.zone_number,
+ "zone_id": self._zone.zone_id,
+ "enabled": self._zone.enabled,
+ "quickrun_duration": self._zone.quickrun_duration,
+ }
+
+ async def async_press(self) -> None:
+ """Start the zone with its quickrun duration.
+
+ This method is called when the button is pressed in Home Assistant.
+ It starts the irrigation zone for the duration specified in the corresponding number entity.
+
+ The number entity is found using the exact unique_id pattern that follows the format:
+ {device.mac}-zone-{zone.zone_number}-quickrun-duration
+
+ Raises:
+ HomeAssistantError: If the zone cannot be started or the number entity is invalid.
+ """
+ try:
+ # The quickrun duration field doesnt exist in the Wyze API
+ # It has been created in Home Assistant as a number entity
+ # to conveniently trigger a zone start with a specific duration
+
+ # Get the device registry and find the device
+ device_registry = dr.async_get(self.hass)
+ device = device_registry.async_get_device(
+ identifiers={(DOMAIN, self._device.mac)}
+ )
+ if not device:
+ raise HomeAssistantError(f"Device not found for MAC {self._device.mac}")
+
+ # Get the entity registry
+ entity_registry = er.async_get(self.hass)
+
+ # Find the matching number entity using the zone number and device MAC
+ # The number entities have unique_id pattern: {device.mac}-zone-{zone.zone_number}-quickrun-duration
+ expected_unique_id = (
+ f"{self._device.mac}-zone-{self._zone.zone_number}-quickrun-duration"
+ )
+
+ matching_entity = None
+ for entity_id, entity in entity_registry.entities.items():
+ if (
+ entity.device_id == device.id
+ and entity.platform == DOMAIN
+ and entity_id.startswith("number.")
+ and entity.unique_id == expected_unique_id
+ ):
+ matching_entity = entity_id
+ break
+
+ _LOGGER.debug(
+ "Looking for number entity with unique_id: %s", expected_unique_id
+ )
+ _LOGGER.debug("Found matching entity: %s", matching_entity)
+
+ if not matching_entity:
+ raise HomeAssistantError(
+ f"No number entity found for zone {self._zone.name} (zone {self._zone.zone_number}, device: {self._device.mac})"
+ )
+
+ # Get the current state of the number entity
+ state = self.hass.states.get(matching_entity)
+ if state is None or state.state in ["unavailable", "unknown"]:
+ raise HomeAssistantError(
+ f"Number entity {matching_entity} is unavailable or unknown"
+ )
+
+ # Convert duration from minutes to seconds
+ try:
+ duration_minutes = float(state.state)
+ if duration_minutes <= 0:
+ raise ValueError("Duration must be greater than 0")
+ duration_seconds = int(duration_minutes * 60)
+ except ValueError as err:
+ raise HomeAssistantError(
+ f"Invalid duration {state.state} for {matching_entity}"
+ ) from err
+
+ _LOGGER.debug(
+ "Starting zone %s (zone %s) for %s minutes (%s seconds)",
+ self._zone.name,
+ self._zone.zone_number,
+ duration_minutes,
+ duration_seconds,
+ )
+
+ # Start the zone with the specified duration
+ await self._irrigation_service.start_zone(
+ self._device, self._zone.zone_number, duration_seconds
+ )
+
+ except HomeAssistantError as err:
+ _LOGGER.error("Failed to start zone %s: %s", self._zone.name, err)
+ raise
+ except Exception as err:
+ _LOGGER.error("Unexpected error starting zone %s: %s", self._zone.name, err)
+ raise HomeAssistantError(
+ f"Failed to start zone {self._zone.name}: {err}"
+ ) from err
+
+
+class WyzeIrrigationStopAllButton(ButtonEntity):
+ """Representation of a Wyze Irrigation Stop All Schedules Button."""
+
+ _attr_name = "Stop All Zones"
+
+ def __init__(
+ self, irrigation_service: IrrigationService, irrigation: Irrigation
+ ) -> None:
+ """Initialize the irrigation stop all button."""
+ self._irrigation_service = irrigation_service
+ self._device = irrigation
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID for the button."""
+ return f"Stop All {self._device.mac}"
+
+ @property
+ def device_info(self) -> DeviceInfo:
+ """Return device information about this entity."""
+ return DeviceInfo(
+ identifiers={(DOMAIN, self._device.mac)},
+ name=self._device.nickname,
+ manufacturer="WyzeLabs",
+ model=self._device.product_model,
+ serial_number=self._device.sn,
+ connections={(dr.CONNECTION_NETWORK_MAC, self._device.mac)},
+ )
+
+ @property
+ def device_class(self) -> str:
+ """Return the device class of the button."""
+ return ButtonDeviceClass.RESTART
+
+ @property
+ def icon(self) -> str:
+ """Return the icon for the stop all button."""
+ return "mdi:octagon"
+
+ async def async_press(self) -> None:
+ """Stop all running irrigation schedules.
+
+ This method is called when the button is pressed in Home Assistant.
+ It will stop all running irrigation schedules for the device.
+
+ Raises:
+ HomeAssistantError: If the schedules cannot be stopped.
+ """
+ try:
+ await self._irrigation_service.stop_running_schedule(self._device)
+ except ClientConnectionError as err:
+ raise HomeAssistantError(f"Failed to stop schedules: {err}") from err
+ except Exception as err:
+ _LOGGER.error("Error stopping schedules: %s", err)
+ raise HomeAssistantError(f"Failed to stop schedules: {err}") from err
+
+
+class WyzePowerSensorResetButton(ButtonEntity):
+ """Wyze Power Sensor Reset Button."""
+
+ _attr_has_entity_name = True
+ _attr_should_poll = False
+ _attr_entity_category = EntityCategory.CONFIG
+ _attr_name = "Energy Usage Reset"
+
+ def __init__(self, switch: Switch) -> None:
+ """Initialize a power sensor reset button."""
+ self._switch = switch
+
+ @property
+ def device_info(self) -> DeviceInfo:
+ """Return device information about this entity."""
+ return DeviceInfo(
+ identifiers={(DOMAIN, self._switch.mac)},
+ name=self._switch.nickname,
+ )
+
+ @property
+ def unique_id(self) -> str:
+ """Create a unique ID for the button."""
+ return f"{self._switch.mac} Reset button"
+
+ async def async_press(self) -> None:
+ """Reset the sensor usage."""
+ async_dispatcher_send(
+ self.hass,
+ f"{RESET_BUTTON_PRESSED}-{self._switch.mac}",
+ self._switch,
+ )
diff --git a/custom_components/wyzeapi/camera.py b/custom_components/wyzeapi/camera.py
new file mode 100644
index 0000000..277febe
--- /dev/null
+++ b/custom_components/wyzeapi/camera.py
@@ -0,0 +1,521 @@
+"""Wyze Camera integration for Home Assistant."""
+
+import base64
+import json
+import asyncio
+from dataclasses import asdict
+from collections.abc import Callable
+from typing import Any
+import logging
+import uuid
+import re
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.components.camera import Camera as CameraEntity, CameraEntityFeature
+from homeassistant.components.camera.webrtc import (
+ WebRTCClientConfiguration,
+ WebRTCSendMessage,
+ WebRTCAnswer,
+ WebRTCCandidate,
+)
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.util.ssl import get_default_context
+from propcache.api import cached_property
+from webrtc_models import RTCConfiguration, RTCIceCandidateInit, RTCIceServer
+from websockets.asyncio.client import connect as websocket_connect
+from wyzeapy import Wyzeapy, CameraService
+from wyzeapy.services.camera_service import Camera
+
+from .const import CAMERA_UPDATED, CONF_CLIENT, DOMAIN
+from .token_manager import token_exception_handler
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@token_exception_handler
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: Callable[[list[Any], bool], None],
+) -> None:
+ """This function sets up the config entry.
+
+ :param hass: The Home Assistant Instance
+ :param config_entry: The current config entry
+ :param async_add_entities: This function adds entities to the config entry
+ :return:
+ """
+
+ _LOGGER.debug("Creating new Wyze camera component")
+ client: Wyzeapy = hass.data[DOMAIN][config_entry.entry_id][CONF_CLIENT]
+ camera_service = await client.camera_service
+ camera_devices = await camera_service.get_cameras()
+
+ # Create a camera entity for each camera device
+ cameras = []
+ for device in camera_devices:
+ # Update the device to get its zones
+ device = await camera_service.update(device)
+ cameras.extend([WyzeCamera(camera_service, device)])
+
+ for camera in cameras:
+ # Pre-seed the ICE server config by fetching it during setup, so the frontend can collect ICE servers before the offer
+ try:
+ await camera.config_fetch()
+ except Exception as e:
+ # Don't block startup if the config fetch fails, but log the error
+ _LOGGER.warning(
+ "Error fetching WebRTC session configuration for camera %s: %s",
+ camera.name,
+ e,
+ )
+
+ _LOGGER.debug("Wyze camera component setup complete")
+ async_add_entities(cameras, True)
+
+
+class WyzeCamera(CameraEntity):
+ """Representation of a Wyze Camera."""
+
+ def __init__(self, camera_service: CameraService, camera: Camera):
+ """Initialize the camera."""
+ super().__init__()
+ self._camera_service = camera_service
+ self._camera = camera
+ self.name = camera.nickname
+ self._attr_unique_id = camera.mac
+ self.brand = "Wyze"
+ self.model = camera.product_model
+ self.supported_features = CameraEntityFeature.STREAM
+ self._webrtc_provider = None
+ self.sessions: dict[str, WyzeCameraWebRTCSession] = {}
+ self._pending_candidates: dict[str, list[RTCIceCandidateInit]] = {}
+ # Always holds an in-flight Task[dict] for the next config fetch.
+ # _async_get_webrtc_client_configuration reads the result when ready;
+ # async_handle_async_webrtc_offer awaits it to guarantee a fresh config.
+ self._cached_config: dict | None = None
+ self._config_task: asyncio.Task | None = None
+
+ async def config_fetch(self) -> None:
+ """Fetch the WebRTC session configuration for this camera and cache it for future use."""
+ self._cached_config = await self._camera_service.get_stream_info(self._camera)
+ _LOGGER.debug(
+ "Initial fetch of WebRTC session configuration complete for camera %s",
+ self.name,
+ )
+
+ @cached_property
+ def device_info(self) -> DeviceInfo | None:
+ """Return the device info."""
+ return {
+ "identifiers": {(DOMAIN, self._camera.mac)},
+ "name": self._camera.nickname,
+ "manufacturer": "WyzeLabs",
+ "model": self._camera.product_model,
+ }
+
+ @property
+ def available(self) -> bool:
+ """Return if the camera is available."""
+ return self._camera.available
+
+ @cached_property
+ def is_streaming(self) -> bool:
+ """Return True if the camera is currently streaming."""
+ return self._camera.on
+
+ @callback
+ def handle_camera_update(self, camera: Camera) -> None:
+ """Update the camera whenever there is an update."""
+ self._camera = camera
+ self.async_write_ha_state()
+
+ async def async_added_to_hass(self) -> None:
+ """Listen for camera updates."""
+ self.async_on_remove(
+ async_dispatcher_connect(
+ self.hass,
+ f"{CAMERA_UPDATED}-{self._camera.mac}",
+ self.handle_camera_update,
+ )
+ )
+
+ @property
+ def is_on(self) -> bool:
+ """Return True if the camera is currently on."""
+ return self._camera.on
+
+ async def async_turn_on(self) -> None:
+ """Turn the camera on."""
+ await self._camera_service.turn_on(self._camera)
+
+ async def async_turn_off(self) -> None:
+ """Turn the camera off."""
+ await self._camera_service.turn_off(self._camera)
+
+ async def async_disable_motion_detection(self) -> None:
+ """Disable motion detection."""
+ await self._camera_service.turn_off_motion_detection(self._camera)
+
+ async def async_enable_motion_detection(self) -> None:
+ """Enable motion detection."""
+ await self._camera_service.turn_on_motion_detection(self._camera)
+
+ @property
+ def motion_detection_enabled(self) -> bool | None:
+ """Return True if motion detection is enabled, False if disabled, or None if unknown/not supported."""
+ motion = getattr(self._camera, "motion", None)
+ if isinstance(motion, bool):
+ return motion
+ # Some Wyze camera models / API responses don't expose motion state.
+ # Return None so HA omits/marks the attribute as unknown instead of crashing.
+ return None
+
+ async def async_camera_image(
+ self, width: int | None = None, height: int | None = None
+ ) -> bytes | None:
+ """Return bytes of camera image.
+ Currently not implemented"""
+ return None
+
+ def _async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration:
+ """Return the WebRTC client configuration for this camera, including ICE servers."""
+ # This shouldn't happen, but throw an error if we don't have a config ready yet
+ if self._cached_config is None:
+ raise HomeAssistantError("WebRTC session configuration not available yet")
+
+ config = self._cached_config
+
+ ice_servers = []
+ for server in config.get("ice_servers", []):
+ _LOGGER.debug("Adding ICE server for camera %s: %s", self.name, server)
+ ice_servers.append(
+ RTCIceServer.from_dict(
+ {
+ "urls": server["url"],
+ "username": server["username"],
+ "credential": server["credential"],
+ }
+ )
+ )
+
+ _LOGGER.debug("ICE servers for camera %s: %s", self.name, ice_servers)
+ configuration = RTCConfiguration(ice_servers=ice_servers)
+ return WebRTCClientConfiguration(
+ configuration=configuration, data_channel="data"
+ )
+
+ async def async_handle_async_webrtc_offer(
+ self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage
+ ) -> None:
+ """Handle an incoming WebRTC offer from the frontend."""
+ _LOGGER.debug(
+ "Handling WebRTC offer for camera %s with session ID %s",
+ self.name,
+ session_id,
+ )
+
+ # Always fetch a truly fresh config so the signaling URL and ICE servers
+ # are never stale — KVS signed URLs are single-use and short-lived.
+ config = await self._camera_service.get_stream_info(self._camera)
+
+ # Update cached config with the new ICE servers
+ self._cached_config = config
+ _LOGGER.debug("Fresh config for offer on camera %s: %s", self.name, config)
+
+ self.sessions[session_id] = WyzeCameraWebRTCSession(
+ session_id, self, send_message, config
+ )
+ await self.sessions[session_id].send_offer(offer_sdp)
+
+ pending = self._pending_candidates.pop(session_id, None)
+ if pending:
+ _LOGGER.debug(
+ "Flushing %d buffered ICE candidates for camera %s session %s",
+ len(pending),
+ self.name,
+ session_id,
+ )
+ for cand in pending:
+ await self.sessions[session_id].send_candidate(cand)
+
+ async def async_on_webrtc_candidate(
+ self, session_id: str, candidate: RTCIceCandidateInit
+ ) -> None:
+ """Handle an incoming ICE candidate for a WebRTC session."""
+ if session_id not in self.sessions:
+ self._pending_candidates.setdefault(session_id, []).append(candidate)
+ _LOGGER.debug(
+ "Buffered ICE candidate for camera %s session %s (session not ready yet)",
+ self.name,
+ session_id,
+ )
+ return
+
+ await self.sessions[session_id].send_candidate(candidate)
+
+ def close_webrtc_session(self, session_id: str) -> None:
+ """Close a WebRTC session and clean up resources."""
+ _LOGGER.debug("Closing WebRTC session %s", session_id)
+ self._pending_candidates.pop(session_id, None)
+ if session_id in self.sessions:
+ session = self.sessions[session_id]
+ session.close_connection()
+ del self.sessions[session_id]
+
+
+class WyzeCameraWebRTCSession:
+ """Represents a WebRTC session for a Wyze camera."""
+
+ def __init__(
+ self,
+ session_id: str,
+ camera: WyzeCamera,
+ callback: WebRTCSendMessage,
+ config: dict,
+ ):
+ self.session_id = session_id
+ self.camera = camera
+ self.websocket = None # This will hold the WebSocket connection
+ self.camera_service = None
+ self.callback = callback
+ self.close = None
+ self.lock = asyncio.Lock()
+ self.task = None
+ self.config = config
+ self.sdp_offer = None
+ self.sdp_answer = None
+ # Set once connect() succeeds; send_candidate waits on this instead of reconnecting
+ self._connected = asyncio.Event()
+
+ async def connect(self):
+ """Establish the WebSocket connection to the KVS signaling URL.
+ This is called lazily from send_offer() to ensure we have the latest config
+ and don't connect too early before the offer is ready."""
+ # The signaling_url from get_stream_info() is often *double*-percent-encoded
+ # (e.g. "%253A" instead of "%3A"). We must NOT fully URL-decode it because
+ # that can change SigV4 canonical encoding and make KVS reject the handshake.
+ # Instead, only "undouble" percent-escapes by converting "%25xx" -> "%xx",
+ # leaving "%3A", "%2F", etc. intact.
+ signaling_url = self.config["signaling_url"]
+ for _ in range(3):
+ if "%25" not in signaling_url:
+ break
+ signaling_url = signaling_url.replace("%25", "%")
+ self.websocket = await websocket_connect(
+ signaling_url, ssl=get_default_context(), logger=_LOGGER
+ )
+ _LOGGER.debug(
+ "WebSocket connection established for camera %s with session ID %s",
+ self.camera.name,
+ self.session_id,
+ )
+ self._connected.set()
+ asyncio.create_task(self.run_loop())
+
+ async def send_offer(self, offer_sdp: str):
+ """Send an SDP offer to the Kinesis Video Streams signaling channel."""
+ async with self.lock:
+ if self.websocket is None:
+ _LOGGER.debug("Connecting to websocket from send_offer")
+ await self.connect()
+ if self.websocket is None:
+ raise ConnectionError("WebSocket connection not established")
+ # Create an offer for Kinesis
+ self.sdp_offer = offer_sdp
+ offer = {"type": "offer", "sdp": offer_sdp}
+ payload = {
+ "action": "SDP_OFFER",
+ "recipientClientId": "ada06f08-87f4-4e13-b699-e82db8517ae5",
+ "messagePayload": base64.b64encode(
+ json.dumps(offer, separators=(",", ":")).encode()
+ ).decode(),
+ "correlationId": str(uuid.uuid4()),
+ }
+ str_payload = json.dumps(payload)
+ _LOGGER.debug(
+ "Sending SDP offer for camera %s with session ID %s, %s",
+ self.camera.name,
+ self.session_id,
+ str_payload,
+ )
+ await self.websocket.send(str_payload)
+
+ async def send_candidate(self, candidate: RTCIceCandidateInit):
+ """Send an ICE candidate to the Kinesis Video Streams signaling channel."""
+ # Take RTCIceCandidateInit, convert it to the format in the messagePayload above, and send it to the client using the callback
+ # Wait for send_offer to establish the connection — never reconnect (KVS URLs are single-use)
+ try:
+ await asyncio.wait_for(self._connected.wait(), timeout=10.0)
+ except asyncio.TimeoutError as exc:
+ raise ConnectionError(
+ "WebSocket connection not established within timeout"
+ ) from exc
+ if self.websocket is None:
+ raise ConnectionError("WebSocket connection not established")
+ candidate_dict = asdict(candidate)
+ candidate_payload = {
+ "candidate": candidate_dict["candidate"],
+ "sdpMid": candidate_dict["sdp_mid"],
+ "sdpMLineIndex": candidate_dict["sdp_m_line_index"],
+ "usernameFragment": candidate_dict["user_fragment"],
+ }
+ match = re.search(r"ufrag (\w{4})", candidate_payload["candidate"])
+ if match is not None:
+ candidate_payload["usernameFragment"] = match.group(1)
+ payload = {
+ "action": "ICE_CANDIDATE",
+ "recipientClientId": "ada06f08-87f4-4e13-b699-e82db8517ae5",
+ "messagePayload": base64.b64encode(
+ json.dumps(candidate_payload, separators=(",", ":")).encode()
+ ).decode(),
+ }
+ str_payload = json.dumps(payload)
+ _LOGGER.debug(
+ "Sending ICE candidate for camera %s with session ID %s: %s",
+ self.camera.name,
+ self.session_id,
+ str_payload,
+ )
+ await self.websocket.send(str_payload)
+
+ def close_connection(self):
+ """Close the WebSocket connection to the Kinesis Video Streams signaling channel."""
+ if self.close is not None:
+ self.close()
+
+ def force_correct_sdp_answer(self) -> None:
+ """Force the sdp response to have the valid answer.
+
+ The Kinesis WebRTC Stream responses to certain offers do not
+ follow the spec defined in https://www.ietf.org/rfc/rfc3264.txt
+ An offer of recvonly must be answered with sendonly or inactive.
+ """
+ _LOGGER.debug("Attempt to fix sdp answer...")
+ if isinstance(self.sdp_answer, str) and isinstance(self.sdp_offer, str):
+ sdp_kinds = ["audio", "video", "application"]
+ sdp_directions = ["sendrecv", "sendonly", "recvonly", "inactive"]
+ sdp_pattern = (
+ "m=(?P{})(.|\n)+?a=(?P{})(\r|\n|\r\n)".format(
+ "|".join(sdp_kinds), "|".join(sdp_directions)
+ )
+ )
+
+ sdp_direction_offers = re.finditer(sdp_pattern, self.sdp_offer)
+
+ for offer in sdp_direction_offers:
+ sdp_answers = re.finditer(sdp_pattern, self.sdp_answer)
+ for answer in sdp_answers:
+ if (
+ offer.group("kind") == answer.group("kind")
+ and offer.group("direction") == "recvonly"
+ and answer.group("direction") == "sendrecv"
+ ):
+ correct_answer = re.sub(
+ "a=sendrecv", "a=sendonly", answer.group(0)
+ )
+ _LOGGER.debug("Replacing answer with: %s", str(correct_answer))
+ self.sdp_answer = self.sdp_answer.replace(
+ answer.group(0), correct_answer
+ )
+
+ async def run_loop(self):
+ """Listen for messages from the Kinesis Video Streams signaling channel and handle them appropriately."""
+ if self.websocket is None:
+ raise ConnectionError("WebSocket connection not established")
+
+ loop = asyncio.get_running_loop()
+
+ def close():
+ if self.websocket is not None:
+ return loop.create_task(self.websocket.close())
+ return None
+
+ self.close = close
+ _LOGGER.debug(
+ "run_loop starting for camera %s session %s",
+ self.camera.name,
+ self.session_id,
+ )
+ try:
+ async for message in self.websocket:
+ if len(message) == 0:
+ _LOGGER.debug(
+ "Received empty message (type=%s) for camera %s session %s",
+ type(message).__name__,
+ self.camera.name,
+ self.session_id,
+ )
+ continue
+ _LOGGER.debug(
+ "Received message for camera %s with session ID %s: %s",
+ self.camera.name,
+ self.session_id,
+ message,
+ )
+ try:
+ data = json.loads(message)
+ except json.JSONDecodeError as e:
+ _LOGGER.error(
+ "Failed to decode JSON message for camera %s with session ID %s: %s",
+ self.camera.name,
+ self.session_id,
+ e,
+ )
+ continue
+ match data.get("messageType"):
+ case "ICE_CANDIDATE":
+ # Decode messagePayload (base64 JSON) → RTCIceCandidateInit → WebRTCCandidate
+ # KVS uses camelCase keys; map them to RTCIceCandidateInit's snake_case fields
+ candidate_str = base64.b64decode(
+ data["messagePayload"]
+ ).decode()
+ candidate_data = json.loads(candidate_str)
+ rtccandidate = RTCIceCandidateInit(
+ candidate=candidate_data["candidate"],
+ sdp_mid=candidate_data.get("sdpMid"),
+ sdp_m_line_index=candidate_data.get("sdpMLineIndex"),
+ user_fragment=candidate_data.get("usernameFragment"),
+ )
+ self.callback(WebRTCCandidate(candidate=rtccandidate))
+ case "SDP_ANSWER":
+ # Decode messagePayload (base64 JSON with "type"/"sdp" keys) → extract sdp string
+ answer_str = base64.b64decode(data["messagePayload"]).decode()
+ try:
+ answer_obj = json.loads(answer_str)
+ sdp = answer_obj.get("sdp", answer_str)
+ except json.JSONDecodeError:
+ sdp = answer_str
+ self.sdp_answer = sdp
+ self.force_correct_sdp_answer()
+ self.callback(WebRTCAnswer(answer=self.sdp_answer))
+ case "STATUS_RESPONSE" | "GO_AWAY" | "RECONNECT_ICE_SERVER":
+ _LOGGER.debug(
+ "KVS control message '%s' for session %s: %s",
+ data.get("messageType"),
+ self.session_id,
+ data,
+ )
+ case other:
+ _LOGGER.debug(
+ "Unhandled KVS message type '%s' for session %s: %s",
+ other,
+ self.session_id,
+ data,
+ )
+ except Exception as e:
+ _LOGGER.error(
+ "run_loop error for camera %s session %s: %s",
+ self.camera.name,
+ self.session_id,
+ e,
+ exc_info=True,
+ )
+ _LOGGER.debug(
+ "run_loop exited for camera %s session %s",
+ self.camera.name,
+ self.session_id,
+ )
diff --git a/custom_components/wyzeapi/climate.py b/custom_components/wyzeapi/climate.py
new file mode 100644
index 0000000..8365163
--- /dev/null
+++ b/custom_components/wyzeapi/climate.py
@@ -0,0 +1,391 @@
+"""Platform for light integration."""
+
+import logging
+
+# Import the device class from the component that you want to support
+from datetime import timedelta
+from typing import List, Optional, Callable, Any
+from aiohttp.client_exceptions import ClientConnectionError
+
+from homeassistant.components.climate import (
+ ClimateEntity,
+ ClimateEntityFeature,
+ HVACAction,
+ HVACMode,
+)
+from homeassistant.components.climate.const import (
+ FAN_AUTO,
+ FAN_ON,
+ PRESET_HOME,
+ PRESET_AWAY,
+ PRESET_SLEEP,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import UnitOfTemperature
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import device_registry as dr
+from wyzeapy import Wyzeapy, ThermostatService
+from wyzeapy.exceptions import AccessTokenError, ParameterError, UnknownApiError
+from wyzeapy.services.thermostat_service import (
+ Thermostat,
+ TemperatureUnit,
+ Preset,
+ FanMode,
+ HVACState,
+ HVACMode as WyzeHVACMode,
+)
+from .token_manager import token_exception_handler
+
+from .const import DOMAIN, CONF_CLIENT
+
+_LOGGER = logging.getLogger(__name__)
+ATTRIBUTION = "Data provided by Wyze"
+SCAN_INTERVAL = timedelta(seconds=30)
+
+
+@token_exception_handler
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: Callable[[List[Any], bool], None],
+):
+ """
+ This function sets up the config entry so that it is available to Home Assistant
+
+ :param hass: The Home Assistant instance
+ :param config_entry: The current config entry
+ :param async_add_entities: A function to add entities
+ :return:
+ """
+
+ _LOGGER.debug("""Creating new WyzeApi thermostat component""")
+ client: Wyzeapy = hass.data[DOMAIN][config_entry.entry_id][CONF_CLIENT]
+
+ thermostat_service = await client.thermostat_service
+ thermostats = [
+ WyzeThermostat(thermostat_service, thermostat)
+ for thermostat in await thermostat_service.get_thermostats()
+ ]
+
+ async_add_entities(thermostats, True)
+
+
+class WyzeThermostat(ClimateEntity):
+ """
+ This class defines a representation of a Wyze Thermostat that can be used for Home Assistant
+ """
+
+ # pylint: disable=R0902
+ _server_out_of_sync = False
+
+ def __init__(self, thermostat_service: ThermostatService, thermostat: Thermostat):
+ self._thermostat_service = thermostat_service
+ self._thermostat = thermostat
+
+ def set_temperature(self, **kwargs) -> None:
+ raise NotImplementedError
+
+ def set_humidity(self, humidity: int) -> None:
+ raise NotImplementedError
+
+ def set_fan_mode(self, fan_mode: str) -> None:
+ raise NotImplementedError
+
+ def set_hvac_mode(self, hvac_mode: str) -> None:
+ raise NotImplementedError
+
+ def set_swing_mode(self, swing_mode: str) -> None:
+ raise NotImplementedError
+
+ def set_preset_mode(self, preset_mode: str) -> None:
+ raise NotImplementedError
+
+ def turn_aux_heat_on(self) -> None:
+ raise NotImplementedError
+
+ def turn_aux_heat_off(self) -> None:
+ raise NotImplementedError
+
+ @property
+ def current_temperature(self) -> float:
+ return self._thermostat.temperature
+
+ @property
+ def current_humidity(self) -> Optional[int]:
+ return self._thermostat.humidity
+
+ @property
+ def temperature_unit(self) -> str:
+ # if self._thermostat.temp_unit == TemperatureUnit.FAHRENHEIT:
+ return UnitOfTemperature.FAHRENHEIT
+ # return TEMP_CELSIUS
+
+ @property
+ def unit_of_measurement(self) -> str:
+ if self._thermostat.temp_unit == TemperatureUnit.FAHRENHEIT:
+ return UnitOfTemperature.FAHRENHEIT
+ return UnitOfTemperature.CELSIUS
+
+ @property
+ def hvac_mode(self) -> str:
+ # pylint: disable=R1705
+ if self._thermostat.hvac_mode == WyzeHVACMode.AUTO:
+ return HVACMode.AUTO
+ elif self._thermostat.hvac_mode == WyzeHVACMode.HEAT:
+ return HVACMode.HEAT
+ elif self._thermostat.hvac_mode == WyzeHVACMode.COOL:
+ return HVACMode.COOL
+ else:
+ return HVACMode.OFF
+
+ @property
+ def hvac_modes(self) -> List[str]:
+ return [HVACMode.AUTO, HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF]
+
+ @property
+ def target_temperature_high(self) -> Optional[float]:
+ return self._thermostat.cool_set_point
+
+ @property
+ def target_temperature_low(self) -> Optional[float]:
+ return self._thermostat.heat_set_point
+
+ @property
+ def preset_mode(self) -> Optional[str]:
+ match self._thermostat.preset:
+ case Preset.HOME:
+ return PRESET_HOME
+ case Preset.AWAY:
+ return PRESET_AWAY
+ case Preset.SLEEP:
+ return PRESET_SLEEP
+ case _:
+ raise NotImplementedError
+
+ @property
+ def preset_modes(self) -> Optional[List[str]]:
+ return [PRESET_HOME, PRESET_AWAY, PRESET_SLEEP]
+
+ @property
+ def is_aux_heat(self) -> Optional[bool]:
+ raise NotImplementedError
+
+ @property
+ def fan_mode(self) -> Optional[str]:
+ if self._thermostat.fan_mode == FanMode.AUTO:
+ return FAN_AUTO
+ else:
+ return FAN_ON
+
+ @property
+ def fan_modes(self) -> Optional[List[str]]:
+ return [FAN_AUTO, FAN_ON]
+
+ @property
+ def swing_mode(self) -> Optional[str]:
+ raise NotImplementedError
+
+ @property
+ def swing_modes(self) -> Optional[str]:
+ raise NotImplementedError
+
+ @property
+ def hvac_action(self) -> str:
+ # pylint: disable=R1705
+ if self._thermostat.hvac_state == HVACState.IDLE:
+ return HVACAction.IDLE
+ elif self._thermostat.hvac_state == HVACState.HEATING:
+ return HVACAction.HEATING
+ elif self._thermostat.hvac_state == HVACState.COOLING:
+ return HVACAction.COOLING
+ else:
+ return HVACAction.OFF
+
+ @token_exception_handler
+ async def async_set_temperature(self, **kwargs) -> None:
+ target_temp_low = kwargs["target_temp_low"]
+ target_temp_high = kwargs["target_temp_high"]
+
+ try:
+ # Always send setpoints unconditionally rather than guarding against
+ # the cached heat_set_point/cool_set_point value. The equality check
+ # causes a silent no-op whenever the cache is stale (e.g. after a mode
+ # change resets the physical device to firmware defaults, or after a
+ # Wyze cloud/app-driven reset), leaving the physical thermostat at the
+ # wrong setpoint while HA and the Wyze cloud both report the correct one.
+ # The _server_out_of_sync flag compounds this by delaying the cache
+ # refresh by up to 60 s after any command. The underlying API call
+ # (set_iot_prop_by_topic) is idempotent, so always sending is safe.
+ # See: https://github.com/SecKatie/ha-wyzeapi/issues/813
+ await self._thermostat_service.set_heat_point(
+ self._thermostat, int(target_temp_low)
+ )
+ self._thermostat.heat_set_point = int(target_temp_low)
+ await self._thermostat_service.set_cool_point(
+ self._thermostat, int(target_temp_high)
+ )
+ self._thermostat.cool_set_point = int(target_temp_high)
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._server_out_of_sync = True
+ self.async_schedule_update_ha_state()
+
+ async def async_set_humidity(self, humidity: int) -> None:
+ raise NotImplementedError
+
+ @token_exception_handler
+ async def async_set_fan_mode(self, fan_mode: str) -> None:
+ try:
+ if fan_mode == FAN_ON:
+ await self._thermostat_service.set_fan_mode(
+ self._thermostat, FanMode.ON
+ )
+ self._thermostat.fan_mode = FanMode.ON
+ elif fan_mode == FAN_AUTO:
+ await self._thermostat_service.set_fan_mode(
+ self._thermostat, FanMode.AUTO
+ )
+ self._thermostat.fan_mode = FanMode.AUTO
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._server_out_of_sync = True
+ self.async_schedule_update_ha_state()
+
+ @token_exception_handler
+ async def async_set_hvac_mode(self, hvac_mode: str) -> None:
+ try:
+ if hvac_mode == HVACMode.OFF:
+ await self._thermostat_service.set_hvac_mode(
+ self._thermostat, WyzeHVACMode.OFF
+ )
+ self._thermostat.hvac_mode = HVACMode.OFF
+ elif hvac_mode == HVACMode.HEAT:
+ await self._thermostat_service.set_hvac_mode(
+ self._thermostat, WyzeHVACMode.HEAT
+ )
+ self._thermostat.hvac_mode = HVACMode.HEAT
+ elif hvac_mode == HVACMode.COOL:
+ await self._thermostat_service.set_hvac_mode(
+ self._thermostat, WyzeHVACMode.COOL
+ )
+ self._thermostat.hvac_mode = HVACMode.COOL
+ elif hvac_mode == HVACMode.AUTO:
+ await self._thermostat_service.set_hvac_mode(
+ self._thermostat, WyzeHVACMode.AUTO
+ )
+ self._thermostat.hvac_mode = HVACMode.AUTO
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._server_out_of_sync = True
+ self.async_schedule_update_ha_state()
+
+ async def async_set_swing_mode(self, swing_mode: str) -> None:
+ raise NotImplementedError
+
+ @token_exception_handler
+ async def async_set_preset_mode(self, preset_mode: str) -> None:
+ try:
+ if preset_mode == PRESET_SLEEP:
+ await self._thermostat_service.set_preset(
+ self._thermostat, Preset.SLEEP
+ )
+ self._thermostat.preset = Preset.SLEEP
+ elif preset_mode == PRESET_AWAY:
+ await self._thermostat_service.set_preset(self._thermostat, Preset.AWAY)
+ self._thermostat.preset = Preset.AWAY
+ elif preset_mode == PRESET_HOME:
+ await self._thermostat_service.set_preset(self._thermostat, Preset.HOME)
+ self._thermostat.preset = Preset.HOME
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._server_out_of_sync = True
+ self.async_schedule_update_ha_state()
+
+ async def async_turn_aux_heat_on(self) -> None:
+ raise NotImplementedError
+
+ async def async_turn_aux_heat_off(self) -> None:
+ raise NotImplementedError
+
+ @property
+ def supported_features(self) -> int:
+ return (
+ ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
+ | ClimateEntityFeature.FAN_MODE
+ | ClimateEntityFeature.PRESET_MODE
+ )
+
+ @property
+ def device_info(self) -> dict:
+ return {
+ "identifiers": {(DOMAIN, self._thermostat.mac)},
+ "name": self._thermostat.nickname,
+ "connections": {
+ (
+ dr.CONNECTION_NETWORK_MAC,
+ self._thermostat.mac,
+ )
+ },
+ "manufacturer": "WyzeLabs",
+ "model": self._thermostat.product_model,
+ }
+
+ @property
+ def should_poll(self) -> bool:
+ return False
+
+ @property
+ def name(self) -> str:
+ """Return the display name of this lock."""
+ return self._thermostat.nickname
+
+ @property
+ def unique_id(self) -> str:
+ return self._thermostat.mac
+
+ @property
+ def available(self) -> bool:
+ """Return the connection status of this light"""
+ return self._thermostat.available
+
+ @token_exception_handler
+ async def async_update(self) -> None:
+ """
+ This function updates the state of the Thermostat
+
+ :return: None
+ """
+
+ if not self._server_out_of_sync:
+ self._thermostat = await self._thermostat_service.update(self._thermostat)
+ else:
+ self._server_out_of_sync = False
+
+ @callback
+ def async_update_callback(self, thermostat: Thermostat):
+ """Update the thermostat's state."""
+ self._thermostat = thermostat
+ self.async_schedule_update_ha_state()
+
+ async def async_added_to_hass(self) -> None:
+ """Subscribe to update events."""
+ self._thermostat.callback_function = self.async_update_callback
+ self._thermostat_service.register_updater(self._thermostat, 30)
+ await self._thermostat_service.start_update_manager()
+ return await super().async_added_to_hass()
+
+ async def async_will_remove_from_hass(self) -> None:
+ self._thermostat_service.unregister_updater(self._thermostat)
diff --git a/custom_components/wyzeapi/config_flow.py b/custom_components/wyzeapi/config_flow.py
new file mode 100644
index 0000000..13a6bef
--- /dev/null
+++ b/custom_components/wyzeapi/config_flow.py
@@ -0,0 +1,182 @@
+"""Config flow for Wyze Home Assistant Integration integration."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Optional
+
+import voluptuous as vol
+from homeassistant import config_entries
+from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_ACCESS_TOKEN
+from homeassistant.core import callback
+from homeassistant.exceptions import HomeAssistantError
+from wyzeapy import Wyzeapy, exceptions
+
+from .const import (
+ DOMAIN,
+ ACCESS_TOKEN,
+ REFRESH_TOKEN,
+ REFRESH_TIME,
+ BULB_LOCAL_CONTROL,
+ DEFAULT_LOCAL_CONTROL,
+ KEY_ID,
+ API_KEY,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_USERNAME): str,
+ vol.Required(CONF_PASSWORD): str,
+ vol.Required(KEY_ID): str,
+ vol.Required(API_KEY): str,
+ }
+)
+STEP_2FA_DATA_SCHEMA = vol.Schema({CONF_ACCESS_TOKEN: str})
+
+
+class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Wyze Home Assistant Integration."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
+ client: Wyzeapy = None
+ user_params = {}
+
+ def __init__(self):
+ """Initialize."""
+ self.email = None
+ self.password = None
+ self.key_id = None
+ self.api_key = None
+
+ async def get_client(self):
+ if not self.client:
+ self.client = await Wyzeapy.create()
+
+ async def async_step_user(
+ self, user_input: Optional[dict[str, any]] = None
+ ) -> dict[str, Any]:
+ """Handle the initial step."""
+ await self.get_client()
+
+ if user_input is None:
+ return self.async_show_form(
+ step_id="user", data_schema=STEP_USER_DATA_SCHEMA
+ )
+
+ errors = {}
+
+ # noinspection PyBroadException
+ try:
+ await self.client.login(
+ user_input[CONF_USERNAME],
+ user_input[CONF_PASSWORD],
+ user_input[KEY_ID],
+ user_input[API_KEY],
+ )
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except exceptions.AccessTokenError:
+ errors["base"] = "invalid_auth"
+ except exceptions.TwoFactorAuthenticationEnabled:
+ self.user_params[CONF_USERNAME] = user_input[CONF_USERNAME]
+ self.user_params[CONF_PASSWORD] = user_input[CONF_PASSWORD]
+ self.user_params[KEY_ID] = user_input[KEY_ID]
+ self.user_params[API_KEY] = user_input[API_KEY]
+ return await self.async_step_2fa()
+ else:
+ if self.hass.config_entries.async_entries(DOMAIN):
+ for entry in self.hass.config_entries.async_entries(DOMAIN):
+ self.hass.config_entries.async_update_entry(entry, data=user_input)
+ await self.hass.config_entries.async_reload(entry.entry_id)
+ return self.async_abort(reason="reauth_successful")
+ else:
+ return self.async_create_entry(title="", data=user_input)
+
+ return self.async_show_form(
+ step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+ )
+
+ async def async_step_2fa(
+ self, user_input: Optional[dict[str, Any]] = None
+ ) -> dict[str, Any]:
+ if user_input is None:
+ return self.async_show_form(step_id="2fa", data_schema=STEP_2FA_DATA_SCHEMA)
+
+ errors = {}
+
+ try:
+ token = await self.client.login_with_2fa(
+ user_input[CONF_ACCESS_TOKEN],
+ )
+ except exceptions.LoginError:
+ errors["base"] = "invalid_auth"
+ else:
+ self.user_params[ACCESS_TOKEN] = token.access_token
+ self.user_params[REFRESH_TOKEN] = token.refresh_token
+ self.user_params[REFRESH_TIME] = token.refresh_time
+ if self.hass.config_entries.async_entries(DOMAIN):
+ for entry in self.hass.config_entries.async_entries(DOMAIN):
+ self.hass.config_entries.async_update_entry(
+ entry, data=self.user_params
+ )
+ await self.hass.config_entries.async_reload(entry.entry_id)
+ return self.async_abort(reason="reauth_successful")
+ else:
+ return self.async_create_entry(title="", data=self.user_params)
+
+ return self.async_show_form(
+ step_id="2fa", data_schema=STEP_2FA_DATA_SCHEMA, errors=errors
+ )
+
+ async def async_step_import(self, import_config):
+ """Import a config entry from configuration.yaml."""
+ return await self.async_step_user(import_config)
+
+ async def async_step_reauth(self, user_input=None):
+ """Perform reauth upon an API authentication error."""
+ if user_input is None:
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=vol.Schema({}),
+ )
+ return await self.async_step_user()
+
+ @staticmethod
+ @callback
+ def async_get_options_flow(
+ config_entry: config_entries.ConfigEntry,
+ ) -> OptionsFlowHandler:
+ """Create the Wyze options flow."""
+ return OptionsFlowHandler()
+
+
+class OptionsFlowHandler(config_entries.OptionsFlow):
+ """Handle an option flow for Wyze."""
+
+ async def async_step_init(self, user_input=None):
+ """Handle options flow."""
+ if user_input is not None:
+ return self.async_create_entry(title="", data=user_input)
+
+ data_schema = vol.Schema(
+ {
+ vol.Optional(
+ BULB_LOCAL_CONTROL,
+ default=self.config_entry.options.get(
+ BULB_LOCAL_CONTROL, DEFAULT_LOCAL_CONTROL
+ ),
+ ): bool
+ }
+ )
+ return self.async_show_form(step_id="init", data_schema=data_schema)
+
+
+class CannotConnect(HomeAssistantError):
+ """Error to indicate we cannot connect."""
+
+
+class InvalidAuth(HomeAssistantError):
+ """Error to indicate there is invalid auth."""
diff --git a/custom_components/wyzeapi/const.py b/custom_components/wyzeapi/const.py
new file mode 100644
index 0000000..84bd7a7
--- /dev/null
+++ b/custom_components/wyzeapi/const.py
@@ -0,0 +1,28 @@
+"""Constants for the Wyze Home Assistant Integration integration."""
+
+DOMAIN = "wyzeapi"
+CONF_CLIENT = "wyzeapi_client"
+
+ACCESS_TOKEN = "access_token"
+REFRESH_TOKEN = "refresh_token"
+REFRESH_TIME = "refresh_time"
+KEY_ID = "key_id"
+API_KEY = "api_key"
+
+WYZE_NOTIFICATION_TOGGLE = f"{DOMAIN}.wyze.notification.toggle"
+
+LOCK_UPDATED = f"{DOMAIN}.lock_updated"
+CAMERA_UPDATED = f"{DOMAIN}.camera_updated"
+LIGHT_UPDATED = f"{DOMAIN}.light_updated"
+COVER_UPDATED = f"{DOMAIN}.cover_updated"
+RESET_BUTTON_PRESSED = f"{DOMAIN}.reset_button_pressed"
+# EVENT NAMES
+WYZE_CAMERA_EVENT = "wyze_camera_event"
+
+BULB_LOCAL_CONTROL = "bulb_local_control"
+DEFAULT_LOCAL_CONTROL = True
+
+# Yunding (YD) is the provider for Wyze Lock Bolt
+YDBLE_LOCK_STATE_UUID = "00002220-0000-6b63-6f6c-2e6b636f6f6c"
+YDBLE_UART_RX_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"
+YDBLE_UART_TX_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"
diff --git a/custom_components/wyzeapi/coordinator.py b/custom_components/wyzeapi/coordinator.py
new file mode 100644
index 0000000..4829b1b
--- /dev/null
+++ b/custom_components/wyzeapi/coordinator.py
@@ -0,0 +1,196 @@
+import asyncio
+import binascii
+import logging
+from datetime import datetime, timedelta
+from typing import Dict
+
+from bleak import BleakClient
+from bleak_retry_connector import establish_connection
+
+from homeassistant.components import bluetooth
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+from wyzeapy.services.lock_service import LockService, Lock
+
+from .const import YDBLE_LOCK_STATE_UUID, YDBLE_UART_RX_UUID, YDBLE_UART_TX_UUID
+from .token_manager import token_exception_handler
+from .ydble_utils import (
+ decrypt_ecb,
+ pack_l1,
+ pack_l2_dict,
+ pack_l2_lock_unlock,
+ parse_l1,
+ parse_l2_dict,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class WyzeLockBoltCoordinator(DataUpdateCoordinator):
+ """Manages fetching data from BLE periodically."""
+
+ def __init__(
+ self, hass: HomeAssistant, lock_service: LockService, lock: Lock
+ ) -> None:
+ """Initialize the coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name="Wyze Lock State Updater",
+ update_interval=timedelta(seconds=300),
+ )
+ self._lock_service = lock_service
+ self._lock = lock
+ # The `mac` in the original response should be UUID.
+ # The actual MAC address should be retrieved from another API.
+ self._uuid = lock.mac
+ self._mac = None
+ self._bleak_client = None
+ self._current_command = None
+
+ @token_exception_handler
+ async def update_lock_info(self):
+ self._lock = await self._lock_service.update(self._lock)
+ mac = self._lock.raw_dict["hardware_info"]["mac"]
+ # The mac is stored reverse ordered and no colon, e.g. mac="ab8967452301"
+ self._mac = ":".join(mac[i - 2 : i] for i in range(12, 0, -2)).upper()
+
+ async def _async_update_data(self):
+ """Fetch the latest data from BLE device."""
+ # Skip if running a command
+ if self._current_command:
+ return self.data
+
+ client = await self._get_ble_client()
+ if client is None:
+ raise UpdateFailed(
+ f"Could not find BLE device {self._lock.nickname} with address {self._mac}. Device may not be in range."
+ )
+
+ try:
+ value = await client.read_gatt_char(YDBLE_LOCK_STATE_UUID)
+ return self._parse_state(value)
+ finally:
+ await self._disconnect()
+
+ async def lock_unlock(self, command="lock"):
+ if self._current_command:
+ self.async_update_listeners()
+ raise Exception(f"Waiting for {self._current_command} command to complete")
+ self._current_command = command
+ self.async_update_listeners()
+ client = await self._get_ble_client()
+ if client is None:
+ raise Exception(
+ f"Could not find BLE device {self._lock.nickname} with address {self._mac}. Device may not be in range."
+ )
+
+ # disconnect in 10 seconds in case of error
+ asyncio.create_task(self._disconnect(delay=10))
+
+ context = {"command": command, "stage": 0}
+
+ async def _handle_uart_rx_context(sender, data):
+ await self._handle_uart_rx(sender, data, client, context)
+
+ await client.start_notify(YDBLE_UART_RX_UUID, _handle_uart_rx_context)
+ await client.start_notify(YDBLE_LOCK_STATE_UUID, self._handle_state)
+ await self._request_challenge(client)
+
+ async def _request_challenge(self, client: BleakClient):
+ l2_content = pack_l2_dict(0x91, 0, {10: b"\x27"})
+ req = pack_l1(0, 1, l2_content)
+ await client.write_gatt_char(YDBLE_UART_TX_UUID, req, response=False)
+
+ async def _send_lock_unlock(self, client: BleakClient, challenge, command):
+ l2_content = pack_l2_lock_unlock(
+ self._lock.ble_id, self._lock.ble_token, challenge, command
+ )
+ req = pack_l1(0, 2, l2_content)
+ await client.write_gatt_char(YDBLE_UART_TX_UUID, req, response=False)
+
+ async def _send_ack(self, client: BleakClient, seq_no: int):
+ req = pack_l1(0x08, seq_no, b"")
+ await client.write_gatt_char(YDBLE_UART_TX_UUID, req, response=False)
+
+ async def _handle_state(self, sender, data: bytearray):
+ self.data = self._parse_state(data)
+ self._current_command = None
+ self.async_update_listeners()
+
+ def _parse_state(self, state_data):
+ data = decrypt_ecb(self._uuid[-16:].lower(), state_data)
+ result = {
+ "state": data[0],
+ "timestamp": datetime.fromtimestamp(int.from_bytes(data[1:5])),
+ }
+ return result
+
+ async def _handle_uart_rx(
+ self, sender, data: bytearray, client: BleakClient, context: Dict
+ ):
+ # Process for unfinished data
+ if "l1_unfinished" in context:
+ data = context["l1_unfinished"] + data
+ del context["l1_unfinished"]
+ l2_data, l1_flags, seq_no, remain = parse_l1(data)
+ if remain:
+ context["l1_unfinished"] = data
+ return
+
+ # Process messages
+ if context["stage"] == 0:
+ # Ack for request chanllenge
+ if seq_no == 1 and l1_flags == 0x48:
+ context["stage"] = 1
+ return
+ if context["stage"] == 1:
+ if l1_flags == 0x40:
+ # Process L2 dict
+ cmd, l2_flags, l2_dict = parse_l2_dict(l2_data)
+ if cmd == 0x86 and 0xD2 in l2_dict:
+ # Got generated chanllenge
+ challenge = l2_dict[0xD2]
+ await self._send_ack(client, seq_no=seq_no)
+ await self._send_lock_unlock(client, challenge, context["command"])
+ context["stage"] = 2
+ return
+ if context["stage"] == 2:
+ # Ack for send_lock_unlock
+ if seq_no == 2 and l1_flags == 0x48:
+ context["stage"] = 3
+ return
+ if context["stage"] == 3:
+ if l1_flags == 0x40:
+ cmd, l2_flags, l2_dict = parse_l2_dict(l2_data)
+ if cmd == 0x04:
+ await self._send_ack(client, seq_no=seq_no)
+ return
+ _LOGGER.warning(
+ f"Unexpected message: stage={context['stage']}"
+ f" flags={l1_flags:01x}, seq_no={seq_no:02x},"
+ f" l2_data={binascii.hexlify(l2_data)}"
+ )
+
+ async def _get_ble_client(self) -> BleakClient | None:
+ if not self._bleak_client or not self._bleak_client.is_connected:
+ if not self._mac:
+ raise PlatformNotReady("Not initialized")
+ ble_device = bluetooth.async_ble_device_from_address(
+ self.hass, self._mac, connectable=True
+ )
+ if ble_device is None:
+ return None
+
+ self._bleak_client = await establish_connection(
+ BleakClient, ble_device, ble_device.address
+ )
+ return self._bleak_client
+
+ async def _disconnect(self, delay=0):
+ await asyncio.sleep(delay)
+ if self._bleak_client and self._bleak_client.is_connected:
+ await self._bleak_client.disconnect()
+ self._current_command = None
+ self.async_update_listeners()
diff --git a/custom_components/wyzeapi/cover.py b/custom_components/wyzeapi/cover.py
new file mode 100644
index 0000000..eea8e7e
--- /dev/null
+++ b/custom_components/wyzeapi/cover.py
@@ -0,0 +1,152 @@
+"""Platform for the cover integration."""
+
+from abc import ABC
+import logging
+from typing import Any, Callable, List
+
+from wyzeapy import Wyzeapy, CameraService
+from wyzeapy.services.camera_service import Camera
+from wyzeapy.exceptions import AccessTokenError, ParameterError, UnknownApiError
+from wyzeapy.types import DeviceTypes
+
+import homeassistant.components.cover
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import ATTR_ATTRIBUTION
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers import device_registry as dr
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature
+
+
+from .const import CAMERA_UPDATED, CONF_CLIENT, DOMAIN
+from .token_manager import token_exception_handler
+
+_LOGGER = logging.getLogger(__name__)
+ATTRIBUTION = "Data provided by Wyze"
+
+
+@token_exception_handler
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: Callable[[List[Any], bool], None],
+) -> None:
+ """
+ This function sets up the config_entry
+
+ :param hass: Home Assistant instance
+ :param config_entry: The current config_entry
+ :param async_add_entities: This function adds entities to the config_entry
+ :return:
+ """
+
+ _LOGGER.debug("""Creating new WyzeApi cover component""")
+ client: Wyzeapy = hass.data[DOMAIN][config_entry.entry_id][CONF_CLIENT]
+ camera_service = await client.camera_service
+ cameras: List[Camera] = await camera_service.get_cameras()
+ garages = []
+ for camera in cameras:
+ if camera.device_params["dongle_product_model"] == "HL_CGDC":
+ garages.append(WyzeGarageDoor(camera_service, camera))
+
+ async_add_entities(garages, True)
+
+
+class WyzeGarageDoor(homeassistant.components.cover.CoverEntity, ABC):
+ """Representation of a Wyze Garage Door."""
+
+ _attr_device_class = CoverDeviceClass.GARAGE
+ _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
+ _attr_has_entity_name = True
+
+ def __init__(self, camera_service: CameraService, camera: Camera):
+ """Initialize a Wyze garage door."""
+ self._camera = camera
+ if self._camera.type not in [DeviceTypes.CAMERA]:
+ raise HomeAssistantError(f"Invalid device type: {self._camera.type}")
+
+ self._camera_service = camera_service
+ self._available = self._camera.available
+
+ @property
+ def device_info(self):
+ """Return device information about this entity."""
+ return {
+ "identifiers": {(DOMAIN, self._camera.mac)},
+ "name": f"{self._camera.nickname}",
+ "connections": {(dr.CONNECTION_NETWORK_MAC, self._camera.mac)},
+ }
+
+ @property
+ def extra_state_attributes(self):
+ """Return device attributes of the entity."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ "device model": f"{self._camera.product_model}.{self._camera.device_params['dongle_product_model']}",
+ }
+
+ @property
+ def should_poll(self) -> bool:
+ return False
+
+ @token_exception_handler
+ async def async_open_cover(self, **kwargs):
+ """Open the cover."""
+ try:
+ await self._camera_service.garage_door_open(self._camera)
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except Exception as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._camera.garage = True
+ self.async_write_ha_state()
+
+ @token_exception_handler
+ async def async_close_cover(self, **kwargs):
+ """Close the cover."""
+ try:
+ await self._camera_service.garage_door_close(self._camera)
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except Exception as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._camera.garage = False
+ self.async_write_ha_state()
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ return not self._camera.garage
+
+ @property
+ def available(self):
+ """Return the connection status of this cover."""
+ return self._camera.available
+
+ @property
+ def unique_id(self):
+ """Define a unique id for this entity."""
+ return f"{self._camera.mac}_GarageDoor"
+
+ @property
+ def name(self):
+ """Return the name of the garage door."""
+ return "Garage Door"
+
+ async def async_added_to_hass(self) -> None:
+ self.async_on_remove(
+ async_dispatcher_connect(
+ self.hass,
+ f"{CAMERA_UPDATED}-{self._camera.mac}",
+ self.handle_camera_update,
+ )
+ )
+
+ @callback
+ def handle_camera_update(self, camera: Camera) -> None:
+ """Update the cover whenever there is an update"""
+ self._camera = camera
+ self.async_write_ha_state()
diff --git a/custom_components/wyzeapi/light.py b/custom_components/wyzeapi/light.py
new file mode 100644
index 0000000..caa3258
--- /dev/null
+++ b/custom_components/wyzeapi/light.py
@@ -0,0 +1,493 @@
+"""Platform for light integration."""
+
+from collections.abc import Callable
+from datetime import timedelta
+import logging
+from typing import Any
+
+from aiohttp.client_exceptions import ClientConnectionError
+from wyzeapy import BulbService, CameraService, Wyzeapy
+from wyzeapy.exceptions import AccessTokenError, ParameterError, UnknownApiError
+from wyzeapy.services.bulb_service import Bulb
+from wyzeapy.services.camera_service import Camera
+from wyzeapy.types import DeviceTypes, PropertyIDs
+from wyzeapy.utils import create_pid_pair
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS,
+ ATTR_COLOR_TEMP_KELVIN,
+ ATTR_EFFECT,
+ ATTR_HS_COLOR,
+ ColorMode,
+ LightEntity,
+ LightEntityFeature,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect,
+ async_dispatcher_send,
+)
+import homeassistant.util.color as color_util
+
+from .const import (
+ BULB_LOCAL_CONTROL,
+ CAMERA_UPDATED,
+ CONF_CLIENT,
+ DOMAIN,
+ LIGHT_UPDATED,
+)
+from .token_manager import token_exception_handler
+
+_LOGGER = logging.getLogger(__name__)
+ATTRIBUTION = "Data provided by Wyze"
+SCAN_INTERVAL = timedelta(seconds=30)
+EFFECT_MODE = "effects mode"
+EFFECT_SUN_MATCH = "sun match"
+EFFECT_SHADOW = "shadow"
+EFFECT_LEAP = "leap"
+EFFECT_FLICKER = "flicker"
+
+
+@token_exception_handler
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: Callable[[list[Any], bool], None],
+) -> None:
+ """Set up the entities in the config entry."""
+
+ _LOGGER.debug("""Creating new WyzeApi light component""")
+ client: Wyzeapy = hass.data[DOMAIN][config_entry.entry_id][CONF_CLIENT]
+ camera_service = await client.camera_service
+
+ bulb_service = await client.bulb_service
+
+ lights = [
+ WyzeLight(bulb_service, light, config_entry)
+ for light in await bulb_service.get_bulbs()
+ ]
+
+ for camera in await camera_service.get_cameras():
+ if camera.product_model == "HL_BC":
+ # Wyze Bulb Cam has integrated light
+ lights.append(WyzeCamerafloodlight(camera, camera_service, "bulbcam"))
+ elif (
+ camera.product_model == "WYZE_CAKP2JFUS"
+ and camera.device_params["dongle_product_model"] == "HL_CFL"
+ or camera.product_model in ("LD_CFP", "HL_CFL2") # Floodlight v2
+ ):
+ lights.append(WyzeCamerafloodlight(camera, camera_service, "floodlight"))
+
+ elif (
+ camera.product_model in ("WYZE_CAKP2JFUS", "HL_CAM4")
+ ) and camera.device_params[
+ "dongle_product_model"
+ ] == "HL_CAM3SS": # Cam v3 with lamp socket accessory
+ lights.append(WyzeCamerafloodlight(camera, camera_service, "lampsocket"))
+
+ elif (
+ camera.product_model == "AN_RSCW"
+ ): # Battery cam pro (integrated spotlight)
+ lights.append(WyzeCamerafloodlight(camera, camera_service, "spotlight"))
+
+ async_add_entities(lights, True)
+
+
+class WyzeLight(LightEntity):
+ """Representation of a Wyze Bulb."""
+
+ _just_updated = False
+ _attr_should_poll = False
+
+ def __init__(self, bulb_service: BulbService, bulb: Bulb, config_entry) -> None:
+ """Initialize a Wyze Bulb."""
+ self._bulb = bulb
+ self._device_type = DeviceTypes(self._bulb.product_type)
+ self._config_entry = config_entry
+ self._local_control = config_entry.options.get(BULB_LOCAL_CONTROL)
+ if self._device_type not in [
+ DeviceTypes.LIGHT,
+ DeviceTypes.MESH_LIGHT,
+ DeviceTypes.LIGHTSTRIP,
+ ]:
+ raise AttributeError("Device type not supported")
+
+ self._bulb_service = bulb_service
+ self._attr_min_color_temp_kelvin = (
+ 1800
+ if self._device_type in [DeviceTypes.MESH_LIGHT, DeviceTypes.LIGHTSTRIP]
+ else 2700
+ )
+ self._attr_max_color_temp_kelvin = 6500
+ self._attr_name = self._bulb.nickname
+ self._attr_unique_id = self._bulb.mac
+ self._attr_supported_features = LightEntityFeature.EFFECT
+
+ @property
+ def device_info(self):
+ """Return the device info."""
+ return {
+ "identifiers": {(DOMAIN, self._bulb.mac)},
+ "name": self._bulb.nickname,
+ "connections": {
+ (
+ dr.CONNECTION_NETWORK_MAC,
+ self._bulb.mac,
+ )
+ },
+ "manufacturer": "WyzeLabs",
+ "model": self._bulb.product_model,
+ }
+
+ @token_exception_handler
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn on the light."""
+ options = []
+ self._local_control = self._config_entry.options.get(BULB_LOCAL_CONTROL)
+
+ if kwargs.get(ATTR_BRIGHTNESS) is not None:
+ brightness = round((kwargs.get(ATTR_BRIGHTNESS) / 255) * 100)
+
+ options.append(create_pid_pair(PropertyIDs.BRIGHTNESS, str(brightness)))
+
+ _LOGGER.debug("Setting brightness to %s", brightness)
+ _LOGGER.debug("Options: %s", options)
+
+ self._bulb.brightness = brightness
+
+ if (
+ self._bulb.sun_match
+ ): # Turn off sun match if we're changing anything other than brightness
+ if any([kwargs.get(ATTR_COLOR_TEMP_KELVIN, kwargs.get(ATTR_HS_COLOR))]):
+ options.append(create_pid_pair(PropertyIDs.SUN_MATCH, str(0)))
+ self._bulb.sun_match = False
+ _LOGGER.debug("Turning off sun match")
+
+ if kwargs.get(ATTR_COLOR_TEMP_KELVIN) is not None:
+ _LOGGER.debug("Setting color temp")
+ color_temp = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
+
+ options.append(create_pid_pair(PropertyIDs.COLOR_TEMP, str(color_temp)))
+
+ if self._device_type in [DeviceTypes.MESH_LIGHT, DeviceTypes.LIGHTSTRIP]:
+ options.append(
+ create_pid_pair(PropertyIDs.COLOR_MODE, str(2))
+ ) # Put bulb in White Mode
+ self._bulb.color_mode = "2"
+
+ self._bulb.color_temp = color_temp
+ self._bulb.color = color_util.color_rgb_to_hex(
+ *color_util.color_temperature_to_rgb(color_temp)
+ )
+
+ if kwargs.get(ATTR_HS_COLOR) is not None and (
+ self._device_type is DeviceTypes.MESH_LIGHT
+ or self._device_type is DeviceTypes.LIGHTSTRIP
+ ):
+ _LOGGER.debug("Setting color")
+ color = color_util.color_rgb_to_hex(
+ *color_util.color_hs_to_RGB(*kwargs.get(ATTR_HS_COLOR))
+ )
+
+ options.extend(
+ [
+ create_pid_pair(PropertyIDs.COLOR, str(color)),
+ create_pid_pair(
+ PropertyIDs.COLOR_MODE, str(1)
+ ), # Put bulb in Color Mode
+ ]
+ )
+
+ self._bulb.color = color
+ self._bulb.color_mode = "1"
+
+ if kwargs.get(ATTR_EFFECT) is not None:
+ if kwargs.get(ATTR_EFFECT) == EFFECT_SUN_MATCH:
+ _LOGGER.debug("Setting Sun Match")
+ options.append(create_pid_pair(PropertyIDs.SUN_MATCH, str(1)))
+ self._bulb.sun_match = True
+ else:
+ if (
+ self._bulb.type is DeviceTypes.MESH_LIGHT
+ ): # Handle mesh light effects
+ self._local_control = False
+ options.append(create_pid_pair(PropertyIDs.COLOR_MODE, str(3)))
+ self._bulb.color_mode = "3"
+ if kwargs.get(ATTR_EFFECT) == EFFECT_SHADOW:
+ _LOGGER.debug("Setting Shadow Effect")
+ options.append(
+ create_pid_pair(PropertyIDs.LIGHTSTRIP_EFFECTS, str(1))
+ )
+ self._bulb.effects = "1"
+ elif kwargs.get(ATTR_EFFECT) == EFFECT_LEAP:
+ _LOGGER.debug("Setting Leap Effect")
+ options.append(
+ create_pid_pair(PropertyIDs.LIGHTSTRIP_EFFECTS, str(2))
+ )
+ self._bulb.effects = "2"
+ elif kwargs.get(ATTR_EFFECT) == EFFECT_FLICKER:
+ _LOGGER.debug("Setting Flicker Effect")
+ options.append(
+ create_pid_pair(PropertyIDs.LIGHTSTRIP_EFFECTS, str(3))
+ )
+ self._bulb.effects = "3"
+
+ _LOGGER.debug("Turning on light")
+ try:
+ await self._bulb_service.turn_on(self._bulb, self._local_control, options)
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._bulb.on = True
+ self._just_updated = True
+ self.async_schedule_update_ha_state()
+
+ @token_exception_handler
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn off the light."""
+ self._local_control = self._config_entry.options.get(BULB_LOCAL_CONTROL)
+ try:
+ await self._bulb_service.turn_off(self._bulb, self._local_control)
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._bulb.on = False
+ self._just_updated = True
+ self.async_schedule_update_ha_state()
+
+ @property
+ def supported_color_modes(self):
+ """Return the supported color modes."""
+ if self._bulb.type in [DeviceTypes.MESH_LIGHT, DeviceTypes.LIGHTSTRIP]:
+ return {ColorMode.COLOR_TEMP, ColorMode.HS}
+ return {ColorMode.COLOR_TEMP}
+
+ @property
+ def color_mode(self):
+ """Return the current color mode."""
+ if self._bulb.type is DeviceTypes.LIGHT:
+ return ColorMode.COLOR_TEMP
+ return ColorMode.COLOR_TEMP if self._bulb.color_mode == "2" else ColorMode.HS
+
+ @property
+ def available(self):
+ """Return the connection status of this light."""
+ return self._bulb.available
+
+ @property
+ def hs_color(self):
+ """Return the HS color."""
+ return color_util.color_RGB_to_hs(
+ *color_util.rgb_hex_to_rgb_list(self._bulb.color)
+ )
+
+ @property
+ def extra_state_attributes(self):
+ """Return device attributes of the entity."""
+ dev_info = {}
+
+ # noinspection DuplicatedCode
+ if self._bulb.device_params.get("ip"):
+ dev_info["IP"] = str(self._bulb.device_params.get("ip"))
+ if self._bulb.device_params.get("rssi"):
+ dev_info["RSSI"] = str(self._bulb.device_params.get("rssi"))
+ if self._bulb.device_params.get("ssid"):
+ dev_info["SSID"] = str(self._bulb.device_params.get("ssid"))
+ dev_info["Sun Match"] = self._bulb.sun_match
+ dev_info["local_control"] = (
+ self._local_control and not self._bulb.cloud_fallback
+ )
+
+ if self._device_type is DeviceTypes.LIGHTSTRIP and self._bulb.color_mode == "3":
+ if self._bulb.effects == "1":
+ dev_info["effect_mode"] = "Shadow"
+ elif self._bulb.effects == "2":
+ dev_info["effect_mode"] = "Leap"
+ elif self._bulb.effects == "3":
+ dev_info["effect_mode"] = "Flicker"
+
+ if (
+ self._device_type is DeviceTypes.MESH_LIGHT
+ or self._device_type is DeviceTypes.LIGHTSTRIP
+ ):
+ if self._bulb.color_mode == "1":
+ dev_info["mode"] = "Color"
+ elif self._bulb.color_mode == "2":
+ dev_info["mode"] = "White"
+ elif self._bulb.color_mode == "3":
+ dev_info["mode"] = "Effect"
+
+ return dev_info
+
+ @property
+ def brightness(self):
+ """Return the brightness of the light."""
+ return round(self._bulb.brightness * 2.55, 1)
+
+ @property
+ def color_temp_kelvin(self):
+ """Return the color temp in Kelvin."""
+ return self._bulb.color_temp
+
+ @property
+ def effect_list(self):
+ """Return the list of effects."""
+ if self._device_type is DeviceTypes.LIGHTSTRIP:
+ return [EFFECT_SHADOW, EFFECT_LEAP, EFFECT_FLICKER, EFFECT_SUN_MATCH]
+ return [EFFECT_SUN_MATCH]
+
+ @property
+ def is_on(self):
+ """Return true if light is on."""
+ return self._bulb.on
+
+ @token_exception_handler
+ async def async_update(self):
+ """Update the lock to be up to date with the Wyze Servers."""
+ if not self._just_updated:
+ self._bulb = await self._bulb_service.update(self._bulb)
+ else:
+ self._just_updated = False
+
+ @callback
+ def async_update_callback(self, bulb: Bulb):
+ """Update the bulb's state."""
+ self._bulb = bulb
+ self._local_control = self._config_entry.options.get(BULB_LOCAL_CONTROL)
+ async_dispatcher_send(self.hass, f"{LIGHT_UPDATED}-{self._bulb.mac}", bulb)
+ self.async_schedule_update_ha_state()
+
+ async def async_added_to_hass(self) -> None:
+ """Subscribe to update events."""
+ self._bulb.callback_function = self.async_update_callback
+ self._bulb_service.register_updater(self._bulb, 30)
+ await self._bulb_service.start_update_manager()
+ return await super().async_added_to_hass()
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Unregister the updater."""
+ self._bulb_service.unregister_updater(self._bulb)
+
+
+class WyzeCamerafloodlight(LightEntity):
+ """Representation of a Wyze Camera floodlight."""
+
+ _available: bool
+ _just_updated = False
+ _attr_should_poll = False
+
+ def __init__(
+ self, camera: Camera, camera_service: CameraService, light_type: str
+ ) -> None:
+ """Initialize the camera floodlight."""
+ self._device = camera
+ self._service = camera_service
+ self._light_type = light_type
+ self._attr_unique_id = f"{self._device.mac}-{self._light_type}"
+ self._is_on = self._device.floodlight
+
+ @token_exception_handler
+ async def async_turn_on(self, **kwargs) -> None:
+ """Turn the floodlight on."""
+ try:
+ await self._service.floodlight_on(self._device)
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._is_on = True
+ self._just_updated = True
+ self.async_schedule_update_ha_state()
+
+ @token_exception_handler
+ async def async_turn_off(self, **kwargs):
+ """Turn the floodlight off."""
+ try:
+ await self._service.floodlight_off(self._device)
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._is_on = False
+ self._just_updated = True
+ self.async_schedule_update_ha_state()
+
+ @property
+ def is_on(self):
+ """Return true if floodlight is on."""
+ return self._device.floodlight
+
+ @property
+ def name(self) -> str:
+ """Return the device name."""
+ light_type_names = {
+ "lampsocket": "Lamp Socket",
+ "floodlight": "Floodlight",
+ "spotlight": "Spotlight",
+ "bulbcam": "Light",
+ }
+ return (
+ f"{self._device.nickname} {light_type_names.get(self._light_type, 'Light')}"
+ )
+
+ @property
+ def device_info(self):
+ """Return the device info."""
+ return {
+ "identifiers": {(DOMAIN, self._device.mac)},
+ "name": self._device.nickname,
+ "connections": {
+ (
+ dr.CONNECTION_NETWORK_MAC,
+ self._device.mac,
+ )
+ },
+ "manufacturer": "WyzeLabs",
+ "model": self._device.product_model,
+ }
+
+ @callback
+ def handle_camera_update(self, camera: Camera) -> None:
+ """Update the camera object whenever there is an update."""
+ self._device = camera
+ self.async_write_ha_state()
+
+ async def async_added_to_hass(self) -> None:
+ """Add listener on startup."""
+ self.async_on_remove(
+ async_dispatcher_connect(
+ self.hass,
+ f"{CAMERA_UPDATED}-{self._device.mac}",
+ self.handle_camera_update,
+ )
+ )
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend."""
+ icons = {
+ "lampsocket": "mdi:lightbulb",
+ "floodlight": "mdi:track-light",
+ "spotlight": "mdi:spotlight",
+ "bulbcam": "mdi:lightbulb",
+ }
+ return icons.get(self._light_type, "mdi:lightbulb")
+
+ @property
+ def color_mode(self):
+ """Return the color mode."""
+ return ColorMode.ONOFF
+
+ @property
+ def supported_color_modes(self):
+ """Return the supported color mode."""
+ return ColorMode.ONOFF
diff --git a/custom_components/wyzeapi/lock.py b/custom_components/wyzeapi/lock.py
new file mode 100644
index 0000000..afe4b98
--- /dev/null
+++ b/custom_components/wyzeapi/lock.py
@@ -0,0 +1,270 @@
+#!/usr/bin/python3
+
+"""Platform for light integration."""
+
+from abc import ABC
+from datetime import timedelta
+import logging
+from typing import Any, Callable, List
+from aiohttp.client_exceptions import ClientConnectionError
+
+from wyzeapy import LockService, Wyzeapy
+from wyzeapy.services.lock_service import Lock
+from wyzeapy.types import DeviceTypes
+from wyzeapy.exceptions import AccessTokenError, ParameterError, UnknownApiError
+
+import homeassistant.components.lock
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import ATTR_ATTRIBUTION
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+from homeassistant.helpers import device_registry as dr
+from homeassistant.exceptions import HomeAssistantError
+
+from .const import CONF_CLIENT, DOMAIN, LOCK_UPDATED
+from .token_manager import token_exception_handler
+
+_LOGGER = logging.getLogger(__name__)
+ATTRIBUTION = "Data provided by Wyze"
+SCAN_INTERVAL = timedelta(seconds=10)
+MAX_OUT_OF_SYNC_COUNT = 5
+
+
+@token_exception_handler
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: Callable[[List[Any], bool], None],
+) -> None:
+ """
+ This function sets up the config_entry
+
+ :param hass: Home Assistant instance
+ :param config_entry: The current config_entry
+ :param async_add_entities: This function adds entities to the config_entry
+ :return:
+ """
+
+ _LOGGER.debug("""Creating new WyzeApi lock component""")
+ client: Wyzeapy = hass.data[DOMAIN][config_entry.entry_id][CONF_CLIENT]
+ lock_service = await client.lock_service
+
+ all_locks = await lock_service.get_locks()
+
+ locks = [
+ WyzeLock(lock_service, lock)
+ for lock in all_locks
+ if lock.product_model != "YD_BT1"
+ ]
+ lock_bolts = []
+ coordinators = hass.data[DOMAIN][config_entry.entry_id].get("coordinators", {})
+ for lock in all_locks:
+ if lock.product_model == "YD_BT1":
+ coordinator = coordinators.get(lock.mac)
+ if coordinator is None:
+ _LOGGER.warning(
+ "No coordinator found for Lock Bolt %s (%s), skipping",
+ lock.nickname,
+ lock.mac,
+ )
+ continue
+ lock_bolts.append(WyzeLockBolt(coordinator))
+
+ async_add_entities(locks + lock_bolts, True)
+
+
+class WyzeLock(homeassistant.components.lock.LockEntity, ABC):
+ """Representation of a Wyze Lock."""
+
+ def __init__(self, lock_service: LockService, lock: Lock):
+ """Initialize a Wyze lock."""
+ self._lock = lock
+ if self._lock.type not in [DeviceTypes.LOCK]:
+ raise AttributeError("Device type not supported")
+
+ self._lock_service = lock_service
+
+ self._out_of_sync_count = 0
+
+ @property
+ def device_info(self):
+ return {
+ "identifiers": {(DOMAIN, self._lock.mac)},
+ "name": self._lock.nickname,
+ "connections": {
+ (
+ dr.CONNECTION_NETWORK_MAC,
+ self._lock.mac,
+ )
+ },
+ "manufacturer": "WyzeLabs",
+ "model": self._lock.product_model,
+ }
+
+ def lock(self, **kwargs):
+ raise NotImplementedError
+
+ def unlock(self, **kwargs):
+ raise NotImplementedError
+
+ @property
+ def should_poll(self) -> bool:
+ return False
+
+ @token_exception_handler
+ async def async_lock(self, **kwargs):
+ _LOGGER.debug("Turning on lock")
+ try:
+ await self._lock_service.lock(self._lock)
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._lock.unlocked = False
+ self.async_schedule_update_ha_state()
+
+ @token_exception_handler
+ async def async_unlock(self, **kwargs):
+ try:
+ await self._lock_service.unlock(self._lock)
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._lock.unlocked = True
+ self.async_schedule_update_ha_state()
+
+ @property
+ def is_locked(self):
+ return not self._lock.unlocked
+
+ @property
+ def name(self):
+ """Return the display name of this lock."""
+ return self._lock.nickname
+
+ @property
+ def unique_id(self):
+ return self._lock.mac
+
+ @property
+ def available(self):
+ """Return the connection status of this lock"""
+ return self._lock.available
+
+ @property
+ def extra_state_attributes(self):
+ """Return device attributes of the entity."""
+ dev_info = {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ "door_open": self._lock.door_open,
+ }
+
+ # Add the lock battery value if it exists
+ if self._lock.raw_dict.get("power"):
+ dev_info["lock_battery"] = str(self._lock.raw_dict.get("power"))
+
+ # Add the keypad's battery value if it exists
+ if self._lock.raw_dict.get("keypad", {}).get("power"):
+ dev_info["keypad_battery"] = str(
+ self._lock.raw_dict.get("keypad", {}).get("power")
+ )
+
+ return dev_info
+
+ @property
+ def supported_features(self):
+ return None
+
+ @token_exception_handler
+ async def async_update(self):
+ """
+ This function updates the entity
+ """
+ lock = await self._lock_service.update(self._lock)
+ if (
+ lock.unlocked == self._lock.unlocked
+ or self._out_of_sync_count >= MAX_OUT_OF_SYNC_COUNT
+ ):
+ self._lock = lock
+ self._out_of_sync_count = 0
+ else:
+ self._out_of_sync_count += 1
+
+ @callback
+ def async_update_callback(self, lock: Lock):
+ """Update the switch's state."""
+ self._lock = lock
+ async_dispatcher_send(
+ self.hass,
+ f"{LOCK_UPDATED}-{self._lock.mac}",
+ lock,
+ )
+ self.async_schedule_update_ha_state()
+
+ async def async_added_to_hass(self) -> None:
+ """Subscribe to update events."""
+ self._lock.callback_function = self.async_update_callback
+ self._lock_service.register_updater(self._lock, 10)
+ await self._lock_service.start_update_manager()
+ return await super().async_added_to_hass()
+
+ async def async_will_remove_from_hass(self) -> None:
+ self._lock_service.unregister_updater(self._lock)
+
+
+class WyzeLockBolt(CoordinatorEntity, homeassistant.components.lock.LockEntity):
+ def __init__(self, coordinator):
+ super().__init__(coordinator)
+ self._lock = coordinator._lock
+
+ @property
+ def name(self):
+ """Return the display name of this lock."""
+ return self._lock.nickname
+
+ @property
+ def unique_id(self):
+ return self._lock.mac
+
+ @property
+ def device_info(self):
+ return {
+ "identifiers": {(DOMAIN, self._lock.mac)},
+ "name": self._lock.nickname,
+ "connections": {
+ (
+ dr.CONNECTION_NETWORK_MAC,
+ self.coordinator._mac,
+ ),
+ ("uuid", self.coordinator._uuid),
+ ("serial_number", self._lock.raw_dict["hardware_info"]["sn"]),
+ },
+ "manufacturer": "WyzeLabs",
+ "model": self._lock.product_model,
+ }
+
+ @property
+ def is_locked(self):
+ return self.coordinator.data["state"] == 1
+
+ async def async_lock(self, **kwargs):
+ return await self.coordinator.lock_unlock(command="lock")
+
+ async def async_unlock(self, **kwargs):
+ return await self.coordinator.lock_unlock(command="unlock")
+
+ @property
+ def is_locking(self, **kwargs):
+ return self.coordinator._current_command == "lock"
+
+ @property
+ def is_unlocking(self, **kwargs):
+ return self.coordinator._current_command == "unlock"
+
+ @property
+ def state_attributes(self):
+ return {"last_operated": self.coordinator.data["timestamp"]}
diff --git a/custom_components/wyzeapi/manifest.json b/custom_components/wyzeapi/manifest.json
new file mode 100644
index 0000000..4ab3d47
--- /dev/null
+++ b/custom_components/wyzeapi/manifest.json
@@ -0,0 +1,18 @@
+{
+ "domain": "wyzeapi",
+ "name": "Wyze",
+ "codeowners": [
+ "@SecKatie"
+ ],
+ "config_flow": true,
+ "dependencies": ["bluetooth_adapters"],
+ "documentation": "https://github.com/SecKatie/ha-wyzeapi#readme",
+ "iot_class": "cloud_polling",
+ "issue_tracker": "https://github.com/SecKatie/ha-wyzeapi/issues",
+ "loggers": ["custom_components.wyzeapi"],
+ "requirements": [
+ "wyzeapy>=0.5.33,<0.6",
+ "websockets"
+ ],
+ "version": "0.1.37"
+}
diff --git a/custom_components/wyzeapi/number.py b/custom_components/wyzeapi/number.py
new file mode 100644
index 0000000..edb8bcb
--- /dev/null
+++ b/custom_components/wyzeapi/number.py
@@ -0,0 +1,150 @@
+"""Platform for number integration."""
+
+import logging
+from typing import Any, Callable, List
+
+from homeassistant.components.number import RestoreNumber
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity import DeviceInfo
+from homeassistant.helpers import device_registry as dr
+from wyzeapy import Wyzeapy
+from wyzeapy.services.irrigation_service import IrrigationService, Irrigation, Zone
+
+from .const import DOMAIN, CONF_CLIENT
+from .token_manager import token_exception_handler
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@token_exception_handler
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: Callable[[List[Any], bool], None],
+) -> None:
+ """Set up the WyzeApi number platform."""
+ _LOGGER.debug("Creating new WyzeApi number component")
+ client: Wyzeapy = hass.data[DOMAIN][config_entry.entry_id][CONF_CLIENT]
+ irrigation_service = await client.irrigation_service
+
+ # Get all irrigation devices
+ irrigation_devices = await irrigation_service.get_irrigations()
+
+ # Create a number entity for each zone in each irrigation device
+ entities = []
+ for device in irrigation_devices:
+ # Update the device to get its zones
+ device = await irrigation_service.update(device)
+ for zone in device.zones:
+ if zone.enabled:
+ entities.append(
+ WyzeIrrigationQuickrunDuration(irrigation_service, device, zone)
+ )
+
+ async_add_entities(entities, True)
+
+
+class WyzeIrrigationQuickrunDuration(RestoreNumber):
+ """Representation of a Wyze Irrigation Zone Quickrun Duration."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self, irrigation_service: IrrigationService, irrigation: Irrigation, zone: Zone
+ ) -> None:
+ """Initialize the irrigation zone quickrun duration."""
+ self._irrigation_service = irrigation_service
+ self._device = irrigation
+ self._zone = zone
+
+ @property
+ def name(self) -> str:
+ """Return the name of the zone quickrun duration."""
+ return f"{self._zone.name}"
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID for the zone quickrun duration."""
+ return f"{self._device.mac}-zone-{self._zone.zone_number}-quickrun-duration"
+
+ @property
+ def device_info(self) -> DeviceInfo:
+ """Return device information about this entity."""
+ return DeviceInfo(
+ identifiers={(DOMAIN, self._device.mac)},
+ name=self._device.nickname,
+ manufacturer="WyzeLabs",
+ model=self._device.product_model,
+ serial_number=self._device.sn,
+ connections={(dr.CONNECTION_NETWORK_MAC, self._device.mac)},
+ )
+
+ @property
+ def native_value(self) -> float:
+ """Return the current value in minutes."""
+ return float(self._zone.quickrun_duration) / 60.0
+
+ @property
+ def native_min_value(self) -> float:
+ """Return the minimum value in minutes."""
+ return 1.0 # 1 minute
+
+ @property
+ def native_max_value(self) -> float:
+ """Return the maximum value in minutes."""
+ return 180.0 # 3 hours in minutes
+
+ @property
+ def native_step(self) -> float:
+ """Return the step value in minutes."""
+ return 1.0 # 1 minute steps
+
+ @property
+ def mode(self) -> str:
+ """Return the mode of the number entity."""
+ return "box"
+
+ @property
+ def native_unit_of_measurement(self) -> str:
+ """Return the unit of measurement."""
+ return "min"
+
+ @property
+ def icon(self) -> str:
+ """Return the icon for the quickrun duration number."""
+ return "mdi:timer"
+
+ async def async_set_native_value(self, value: float) -> None:
+ """Set the value in minutes."""
+ # Convert minutes to seconds for the API
+ seconds = int(value * 60)
+ await self._irrigation_service.set_zone_quickrun_duration(
+ self._device, self._zone.zone_number, seconds
+ )
+ self._zone.quickrun_duration = seconds
+ self.async_write_ha_state()
+
+ async def _async_load_value(self) -> None:
+ """Load the value from Home Assistant state or update from irrigation service."""
+ # Try to get the last number data from Home Assistant
+ state = await self.async_get_last_number_data()
+ if state and state.native_value is not None:
+ try:
+ # Convert minutes to seconds for storage
+ self._zone.quickrun_duration = int(state.native_value * 60)
+ return
+ except (ValueError, TypeError):
+ pass
+
+ # If no valid state exists, update from irrigation service
+ self._device = await self._irrigation_service.update(self._device)
+ for zone in self._device.zones:
+ if zone.zone_number == self._zone.zone_number:
+ self._zone = zone
+ break
+
+ async def async_added_to_hass(self) -> None:
+ """Subscribe to updates."""
+ await self._async_load_value()
+ return await super().async_added_to_hass()
diff --git a/custom_components/wyzeapi/sensor.py b/custom_components/wyzeapi/sensor.py
new file mode 100644
index 0000000..bb954e9
--- /dev/null
+++ b/custom_components/wyzeapi/sensor.py
@@ -0,0 +1,626 @@
+"""Platform for sensor integration."""
+
+from collections.abc import Callable
+import datetime
+import json
+import logging
+from typing import Any
+
+from wyzeapy import Wyzeapy
+from wyzeapy.services.camera_service import Camera
+from wyzeapy.services.irrigation_service import Irrigation, IrrigationService
+from wyzeapy.services.lock_service import Lock
+from wyzeapy.services.switch_service import Switch, SwitchUsageService
+
+from homeassistant.components.sensor import (
+ RestoreSensor,
+ SensorDeviceClass,
+ SensorEntity,
+ SensorStateClass,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+ ATTR_ATTRIBUTION,
+ PERCENTAGE,
+ EntityCategory,
+ UnitOfEnergy,
+)
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import DeviceInfo
+import homeassistant.helpers.entity_registry as er
+from homeassistant.helpers.event import (
+ async_track_state_change_event,
+ async_track_time_change,
+)
+
+from .const import (
+ CAMERA_UPDATED,
+ CONF_CLIENT,
+ DOMAIN,
+ LOCK_UPDATED,
+ RESET_BUTTON_PRESSED,
+)
+from .token_manager import token_exception_handler
+
+_LOGGER = logging.getLogger(__name__)
+ATTRIBUTION = "Data provided by Wyze"
+CAMERAS_WITH_BATTERIES = ["WVOD1", "HL_WCO2", "AN_RSCW", "GW_BE1"]
+OUTDOOR_PLUGS = ["WLPPO"]
+
+
+@token_exception_handler
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: Callable[[list[Any], bool], None],
+) -> None:
+ """This function sets up the config_entry.
+
+ :param hass: Home Assistant instance
+ :param config_entry: The current config_entry
+ :param async_add_entities: This function adds entities to the config_entry
+ :return:
+ """
+ _LOGGER.debug("""Creating new WyzeApi sensor component""")
+ client: Wyzeapy = hass.data[DOMAIN][config_entry.entry_id][CONF_CLIENT]
+
+ # Get the list of locks so that we can create lock and keypad battery sensors
+ lock_service = await client.lock_service
+ camera_service = await client.camera_service
+ switch_usage_service = await client.switch_usage_service
+ irrigation_service = await client.irrigation_service
+
+ locks = await lock_service.get_locks()
+ sensors = []
+ for lock in locks:
+ sensors.append(WyzeLockBatterySensor(lock, WyzeLockBatterySensor.LOCK_BATTERY))
+ sensors.append(
+ WyzeLockBatterySensor(lock, WyzeLockBatterySensor.KEYPAD_BATTERY)
+ )
+
+ cameras = await camera_service.get_cameras()
+ sensors.extend(
+ [
+ WyzeCameraBatterySensor(camera)
+ for camera in cameras
+ if camera.product_model in CAMERAS_WITH_BATTERIES
+ ]
+ )
+
+ plugs = await switch_usage_service.get_switches()
+ for plug in plugs:
+ if plug.product_model in OUTDOOR_PLUGS:
+ sensors.append(WyzePlugEnergySensor(plug, switch_usage_service))
+ sensors.append(WyzePlugDailyEnergySensor(plug))
+
+ # Get all irrigation devices
+ irrigation_devices = await irrigation_service.get_irrigations()
+
+ # Create sensor entities for each irrigation device
+ for device in irrigation_devices:
+ # Update the device to get its properties
+ device = await irrigation_service.update(device)
+ sensors.extend(
+ [
+ WyzeIrrigationRSSI(irrigation_service, device),
+ WyzeIrrigationIP(irrigation_service, device),
+ WyzeIrrigationSSID(irrigation_service, device),
+ ]
+ )
+
+ async_add_entities(sensors, True)
+
+
+class WyzeLockBatterySensor(SensorEntity):
+ """Representation of a Wyze Lock or Lock Keypad Battery."""
+
+ @property
+ def enabled(self):
+ """Return if the sensor is enabled."""
+ return self._enabled
+
+ LOCK_BATTERY = "lock_battery"
+ KEYPAD_BATTERY = "keypad_battery"
+
+ _attr_device_class = SensorDeviceClass.BATTERY
+ _attr_native_unit_of_measurement = PERCENTAGE
+ _attr_should_poll = False
+
+ def __init__(self, lock, battery_type) -> None:
+ """Initialize the sensor."""
+ self._enabled = None
+ self._lock = lock
+ self._battery_type = battery_type
+ # make the battery unavailable by default, this will be toggled after the first update from the battery entity that
+ # has battery data.
+ self._available = False
+
+ @callback
+ def handle_lock_update(self, lock: Lock) -> None:
+ """Helper function to Enable lock when Keypad has a battery.
+
+ Make it avaliable when either the lock battery or keypad battery exists.
+ """
+ self._lock = lock
+ if self._lock.raw_dict.get("power") and self._battery_type == self.LOCK_BATTERY:
+ self._available = True
+ if (
+ self._lock.raw_dict.get("keypad", {}).get("power")
+ and self._battery_type == self.KEYPAD_BATTERY
+ ):
+ if self.enabled is False:
+ self.enabled = True
+ self._available = True
+ self.async_write_ha_state()
+
+ async def async_added_to_hass(self) -> None:
+ """Add listener on startup."""
+ self.async_on_remove(
+ async_dispatcher_connect(
+ self.hass,
+ f"{LOCK_UPDATED}-{self._lock.mac}",
+ self.handle_lock_update,
+ )
+ )
+
+ @property
+ def name(self) -> str:
+ """Name of the Sensor."""
+ battery_type = self._battery_type.replace("_", " ").title()
+ return f"{self._lock.nickname} {battery_type}"
+
+ @property
+ def unique_id(self):
+ """Unique ID of the sensor."""
+ return f"{self._lock.nickname}.{self._battery_type}"
+
+ @property
+ def available(self) -> bool:
+ """Return if the sensor is available."""
+ return self._available
+
+ @property
+ def entity_registry_enabled_default(self) -> bool:
+ """Return if the entity should be enabled."""
+ if self._battery_type == self.KEYPAD_BATTERY:
+ # The keypad battery may not be available if the lock has no keypad
+ return False
+ # The battery voltage will always be available for the lock
+ return True
+
+ @property
+ def device_info(self):
+ """Return the device info."""
+ return {
+ "identifiers": {(DOMAIN, self._lock.mac)},
+ "connections": {
+ (
+ dr.CONNECTION_NETWORK_MAC,
+ self._lock.mac,
+ )
+ },
+ "name": f"{self._lock.nickname}.{self._battery_type}",
+ }
+
+ @property
+ def extra_state_attributes(self):
+ """Return device attributes of the entity."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ "device model": f"{self._lock.product_model}.{self._battery_type}",
+ }
+
+ @property
+ def native_value(self):
+ """Return the state of the device."""
+ if self._battery_type == self.LOCK_BATTERY:
+ return str(self._lock.raw_dict.get("power"))
+ if self._battery_type == self.KEYPAD_BATTERY:
+ return str(self._lock.raw_dict.get("keypad", {}).get("power"))
+ return 0
+
+ @enabled.setter
+ def enabled(self, value):
+ self._enabled = value
+
+
+class WyzeCameraBatterySensor(SensorEntity):
+ """Representation of a Wyze Camera Battery."""
+
+ _attr_device_class = SensorDeviceClass.BATTERY
+ _attr_native_unit_of_measurement = PERCENTAGE
+ _attr_should_poll = False
+
+ def __init__(self, camera) -> None:
+ """Initialize the sensor."""
+ self._camera = camera
+
+ @callback
+ def handle_camera_update(self, camera: Camera) -> None:
+ """Handle camera updates."""
+ self._camera = camera
+ self.async_write_ha_state()
+
+ async def async_added_to_hass(self) -> None:
+ """Add listener on startup."""
+ self.async_on_remove(
+ async_dispatcher_connect(
+ self.hass,
+ f"{CAMERA_UPDATED}-{self._camera.mac}",
+ self.handle_camera_update,
+ )
+ )
+
+ @property
+ def name(self) -> str:
+ """Return the entity name."""
+ return f"{self._camera.nickname} Battery"
+
+ @property
+ def unique_id(self):
+ """Unique ID of the sensor."""
+ return f"{self._camera.nickname}.battery"
+
+ @property
+ def device_info(self):
+ """Return the device info."""
+ return {
+ "identifiers": {(DOMAIN, self._camera.mac)},
+ "connections": {
+ (
+ dr.CONNECTION_NETWORK_MAC,
+ self._camera.mac,
+ )
+ },
+ "name": self._camera.nickname,
+ "model": self._camera.product_model,
+ "manufacturer": "WyzeLabs",
+ }
+
+ @property
+ def extra_state_attributes(self):
+ """Return device attributes of the entity."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ "device model": f"{self._camera.product_model}.battery",
+ }
+
+ @property
+ def native_value(self):
+ """Return the value of the sensor."""
+ return self._camera.device_params.get("electricity")
+
+
+class WyzePlugEnergySensor(RestoreSensor):
+ """Respresents an Outdoor Plug Total Energy Sensor."""
+
+ _attr_device_class = SensorDeviceClass.ENERGY
+ _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
+ _attr_state_class = SensorStateClass.TOTAL_INCREASING
+ _attr_suggested_display_precision = 3
+ _attr_should_poll = False
+ _attr_name = "Total Energy Usage"
+ _previous_hour = None
+ _previous_value = None
+ _past_hours_previous_value = None
+ _current_value = 0
+ _past_hours_value = 0
+ _hourly_energy_usage_added = 0
+
+ def __init__(
+ self, switch: Switch, switch_usage_service: SwitchUsageService
+ ) -> None:
+ """Initialize an energy sensor."""
+ self._switch = switch
+ self._switch_usage_service = switch_usage_service
+ self._switch.usage_history = None # type: ignore[attr-defined]
+
+ @property
+ def unique_id(self):
+ """Get the unique ID of the sensor."""
+ return f"{self._switch.nickname}.energy-{self._switch.mac}"
+
+ @property
+ def device_info(self):
+ """Return the device info."""
+ return {
+ "identifiers": {(DOMAIN, self._switch.mac)},
+ "name": self._switch.nickname,
+ }
+
+ def update_energy(self):
+ """Update the energy sensor."""
+ _now = int(datetime.datetime.now(datetime.UTC).hour)
+ self._hourly_energy_usage_added = 0
+
+ if (
+ self._switch.usage_history and len(self._switch.usage_history) > 0
+ ): # Confirm there is data
+ _raw_data = self._switch.usage_history
+ _LOGGER.debug(_raw_data)
+ _current_day_list = json.loads(_raw_data[0]["data"])
+ if _now == 0: # Handle rolling to the next UTC day
+ self._past_hours_value = _current_day_list[23] / 1000
+ if len(_raw_data) > 1: # New Day's value
+ _next_day_list = json.loads(_raw_data[1]["data"])
+ self._current_value = _next_day_list[_now] / 1000
+ else:
+ self._current_value = 0
+ else:
+ self._past_hours_value = _current_day_list[_now - 1] / 1000
+ self._current_value = _current_day_list[_now] / 1000
+
+ # Set inital values to current values on startup.
+ # Has to be done after we check for current or next UTC day
+ if self._previous_hour is None:
+ self._previous_hour = _now
+ if self._past_hours_previous_value is None:
+ self._past_hours_previous_value = self._past_hours_value
+ if self._previous_value is None:
+ self._previous_value = self._current_value
+
+ if _now != self._previous_hour: # New Hour
+ if self._past_hours_value > self._previous_value:
+ self._hourly_energy_usage_added = (
+ self._past_hours_value - self._previous_value
+ )
+ self._hourly_energy_usage_added += self._current_value
+ self._previous_value = self._current_value
+ self._previous_hour = _now
+ self._past_hours_previous_value = self._past_hours_value
+
+ else: # Current Hour
+ if self._current_value > self._previous_value:
+ self._hourly_energy_usage_added += round(
+ self._current_value - self._previous_value, 3
+ )
+ self._previous_value = self._current_value
+
+ if self._past_hours_value > self._past_hours_previous_value:
+ self._hourly_energy_usage_added += round(
+ self._past_hours_value - self._past_hours_previous_value, 3
+ )
+ self._past_hours_previous_value = self._past_hours_value
+
+ _LOGGER.debug(
+ "Total Value Added to device %s is %s",
+ self._switch.mac,
+ self._hourly_energy_usage_added,
+ )
+
+ return self._hourly_energy_usage_added
+
+ @callback
+ def async_update_callback(self, switch: Switch):
+ """Update the sensor's state."""
+ self._switch = switch
+ self.update_energy()
+ self._attr_native_value += self._hourly_energy_usage_added
+ self.async_write_ha_state()
+
+ @callback
+ def reset_energy_use(self, switch: Switch):
+ """Reset the Energy Usage."""
+ _LOGGER.debug("Resetting Usage of %s to 0", self._switch.nickname)
+ self._switch = switch
+ self._attr_native_value = 0
+ self.async_write_ha_state()
+
+ async def async_added_to_hass(self) -> None:
+ """Register Updater for the sensor and get previous data."""
+ state = await self.async_get_last_sensor_data()
+ if state:
+ self._attr_native_value = state.native_value
+ else:
+ self._attr_native_value = 0
+ self._switch.callback_function = self.async_update_callback
+ self._switch_usage_service.register_updater(
+ self._switch, 120
+ ) # Every 2 minutes seems to work fine, probably could be longer
+ await self._switch_usage_service.start_update_manager()
+
+ self.async_on_remove(
+ async_dispatcher_connect(
+ self.hass,
+ f"{RESET_BUTTON_PRESSED}-{self._switch.mac}",
+ self.reset_energy_use,
+ )
+ )
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Remove updater."""
+ self._switch_usage_service.unregister_updater(self._switch)
+
+
+class WyzePlugDailyEnergySensor(RestoreSensor):
+ """Respresents an Outdoor Plug Daily Energy Sensor."""
+
+ _attr_device_class = SensorDeviceClass.ENERGY
+ _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
+ _attr_state_class = SensorStateClass.TOTAL_INCREASING
+ _attr_suggested_display_precision = 3
+ _attr_name = "Daily Energy Usage"
+
+ def __init__(self, switch: Switch) -> None:
+ """Initialize a daily energy sensor."""
+ self._switch = switch
+
+ @property
+ def unique_id(self):
+ """Get the unique ID of the sensor."""
+ return f"{self._switch.nickname}.daily_energy-{self._switch.mac}"
+
+ @property
+ def should_poll(self) -> bool:
+ """No polling needed."""
+ return False
+
+ @property
+ def device_info(self):
+ """Return the device info."""
+ return {
+ "identifiers": {(DOMAIN, self._switch.mac)},
+ "name": self._switch.nickname,
+ }
+
+ @callback
+ def _update_daily_sensor(self, event):
+ """Update the sensor when the total sensor updates."""
+ event_data = event.data
+ new_state = event_data["new_state"]
+ old_state = event_data["old_state"]
+
+ if not old_state or not new_state:
+ return
+
+ updated_energy = float(new_state.state) - float(old_state.state)
+ self._attr_native_value += updated_energy
+ self.async_write_ha_state()
+
+ async def _async_reset_at_midnight(self, now: datetime) -> None:
+ """Reset the daily sensor."""
+ self._attr_native_value = 0
+ _LOGGER.debug("Resetting daily energy sensor %s to 0", self._switch.mac)
+ self.async_write_ha_state()
+
+ async def async_added_to_hass(self) -> None:
+ """Get previous data and add listeners."""
+
+ state = await self.async_get_last_sensor_data()
+ if state:
+ self._attr_native_value = state.native_value
+ else:
+ self._attr_native_value = 0
+
+ registry = er.async_get(self.hass)
+ entity_id_total_sensor = registry.async_get_entity_id(
+ "sensor", DOMAIN, f"{self._switch.nickname}.energy-{self._switch.mac}"
+ )
+
+ self.async_on_remove(
+ async_track_state_change_event(
+ self.hass, [entity_id_total_sensor], self._update_daily_sensor
+ )
+ )
+
+ self.async_on_remove(
+ async_track_time_change(
+ self.hass, self._async_reset_at_midnight, hour=0, minute=0, second=0
+ )
+ )
+
+
+class WyzeIrrigationBaseSensor(SensorEntity):
+ """Base class for Wyze Irrigation sensors."""
+
+ _attr_has_entity_name = True
+ _attr_should_poll = False
+
+ def __init__(
+ self, irrigation_service: IrrigationService, irrigation: Irrigation
+ ) -> None:
+ """Initialize the irrigation base sensor."""
+ self._irrigation_service = irrigation_service
+ self._device = irrigation
+
+ @property
+ def device_info(self) -> DeviceInfo:
+ """Return device information about this entity."""
+ return DeviceInfo(
+ identifiers={(DOMAIN, self._device.mac)},
+ name=self._device.nickname,
+ manufacturer="WyzeLabs",
+ model=self._device.product_model,
+ serial_number=self._device.sn,
+ connections={(dr.CONNECTION_NETWORK_MAC, self._device.mac)},
+ )
+
+ @callback
+ def async_update_callback(self, irrigation: Irrigation) -> None:
+ """Update the irrigation's state."""
+ self._device = self._irrigation_service.update_device_props(irrigation)
+ self.async_schedule_update_ha_state()
+
+ async def async_added_to_hass(self) -> None:
+ """Subscribe to updates."""
+ self._device.callback_function = self.async_update_callback
+ self._irrigation_service.register_updater(self._device, 30)
+ await self._irrigation_service.start_update_manager()
+ return await super().async_added_to_hass()
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Clean up when removed."""
+ self._irrigation_service.unregister_updater(self._device)
+
+
+class WyzeIrrigationRSSI(WyzeIrrigationBaseSensor):
+ """Representation of a Wyze Irrigation RSSI sensor."""
+
+ _attr_entity_category = EntityCategory.DIAGNOSTIC
+ _attr_entity_registry_enabled_default = False
+
+ @property
+ def name(self) -> str:
+ """Return the name of the sensor."""
+ return "RSSI"
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID for the sensor."""
+ return f"{self._device.mac}-rssi"
+
+ @property
+ def native_value(self) -> int:
+ """Return the RSSI value."""
+ return self._device.RSSI
+
+ @property
+ def native_unit_of_measurement(self) -> str:
+ """Return the unit of measurement."""
+ return "dBm"
+
+
+class WyzeIrrigationIP(WyzeIrrigationBaseSensor):
+ """Representation of a Wyze Irrigation IP sensor."""
+
+ _attr_entity_category = EntityCategory.DIAGNOSTIC
+ _attr_entity_registry_enabled_default = False
+
+ @property
+ def name(self) -> str:
+ """Return the name of the sensor."""
+ return "IP Address"
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID for the sensor."""
+ return f"{self._device.mac}-ip"
+
+ @property
+ def native_value(self) -> str:
+ """Return the IP address."""
+ return self._device.IP
+
+
+class WyzeIrrigationSSID(WyzeIrrigationBaseSensor):
+ """Representation of a Wyze Irrigation SSID sensor."""
+
+ _attr_entity_category = EntityCategory.DIAGNOSTIC
+ _attr_entity_registry_enabled_default = False
+
+ @property
+ def name(self) -> str:
+ """Return the name of the sensor."""
+ return "SSID"
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID for the sensor."""
+ return f"{self._device.mac}-ssid"
+
+ @property
+ def native_value(self) -> str:
+ """Return the SSID."""
+ return self._device.ssid
diff --git a/custom_components/wyzeapi/siren.py b/custom_components/wyzeapi/siren.py
new file mode 100644
index 0000000..0bb98f2
--- /dev/null
+++ b/custom_components/wyzeapi/siren.py
@@ -0,0 +1,142 @@
+#!/usr/bin/python3
+
+"""Platform for siren integration."""
+
+import logging
+from typing import Any, Callable
+from aiohttp.client_exceptions import ClientConnectionError
+
+from wyzeapy import CameraService, Wyzeapy
+from wyzeapy.services.camera_service import Camera
+from wyzeapy.exceptions import AccessTokenError, ParameterError, UnknownApiError
+
+from homeassistant.components.siren import (
+ SirenEntity,
+ SirenEntityFeature,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from .const import CAMERA_UPDATED, CONF_CLIENT, DOMAIN
+from .token_manager import token_exception_handler
+
+_LOGGER = logging.getLogger(__name__)
+ATTRIBUTION = "Data provided by Wyze"
+
+
+@token_exception_handler
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: Callable[[list[Any], bool], None],
+) -> None:
+ """
+ This function sets up the config entry
+
+ :param hass: The Home Assistant Instance
+ :param config_entry: The current config entry
+ :param async_add_entities: This function adds entities to the config entry
+ :return:
+ """
+
+ _LOGGER.debug("""Creating new WyzeApi siren component""")
+ client: Wyzeapy = hass.data[DOMAIN][config_entry.entry_id][CONF_CLIENT]
+ camera_service = await client.camera_service
+ sirens = []
+ for camera in await camera_service.get_cameras():
+ # The campan v1, v2 camera, and video doorbell pro don't have sirens
+ if camera.product_model not in ["WYZECP1_JEF", "WYZEC1-JZ", "GW_BE1"]:
+ sirens.append(WyzeCameraSiren(camera, camera_service))
+
+ async_add_entities(sirens, True)
+
+
+class WyzeCameraSiren(SirenEntity):
+ """Representation of a Wyze Camera Siren."""
+
+ _available: bool
+ _just_updated = False
+
+ def __init__(self, camera: Camera, camera_service: CameraService) -> None:
+ self._device = camera
+ self._service = camera_service
+
+ self._attr_supported_features = (
+ SirenEntityFeature.TURN_OFF | SirenEntityFeature.TURN_ON
+ )
+
+ @token_exception_handler
+ async def async_turn_on(self, **kwargs) -> None:
+ """Turn the siren on."""
+ try:
+ await self._service.siren_on(self._device)
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._device.siren = True
+ self._just_updated = True
+ self.async_schedule_update_ha_state()
+
+ @token_exception_handler
+ async def async_turn_off(self, **kwargs):
+ """Turn the siren off."""
+ try:
+ await self._service.siren_off(self._device)
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._device.siren = False
+ self._just_updated = True
+ self.async_schedule_update_ha_state()
+
+ @property
+ def should_poll(self) -> bool:
+ return False
+
+ @property
+ def is_on(self):
+ """Return true if siren is on."""
+ return self._device.siren
+
+ @property
+ def available(self):
+ """Return the connection status of this switch"""
+ return self._device.available
+
+ @property
+ def name(self) -> str:
+ return f"{self._device.nickname} Siren"
+
+ @property
+ def unique_id(self):
+ return f"{self._device.mac}-siren"
+
+ @property
+ def device_info(self):
+ return {
+ "identifiers": {(DOMAIN, self._device.mac)},
+ "name": self._device.nickname,
+ "manufacturer": "WyzeLabs",
+ "model": self._device.product_model,
+ }
+
+ @callback
+ def handle_camera_update(self, camera: Camera) -> None:
+ """Update the camera object whenever there is an update"""
+ self._device = camera
+ self.async_write_ha_state()
+
+ async def async_added_to_hass(self) -> None:
+ self.async_on_remove(
+ async_dispatcher_connect(
+ self.hass,
+ f"{CAMERA_UPDATED}-{self._device.mac}",
+ self.handle_camera_update,
+ )
+ )
diff --git a/custom_components/wyzeapi/strings.json b/custom_components/wyzeapi/strings.json
new file mode 100644
index 0000000..d3db476
--- /dev/null
+++ b/custom_components/wyzeapi/strings.json
@@ -0,0 +1,77 @@
+{
+ "title": "[%key:common::config_flow::data::title%]",
+ "config": {
+ "step": {
+ "user": {
+ "title": "[%key:common::config_flow::data::title%]",
+ "data": {
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]",
+ "keyid": "[%key:common::config_flow::data::key_id%]",
+ "apikey": "[%key:common::config_flow::data::api_key%]"
+ },
+ "data_description": {
+ "username": "Wyze or 3rd party OAuth email address",
+ "password": "Wyze or 3rd party OAuth password"
+ }
+ },
+ "2fa": {
+ "title": "[%key:common::config_flow::data::title%]",
+ "data": {
+ "verification_code": "[%key:common::config_flow::data::verification_code%]"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "bulb_local_control": "Use Local Control for Color Bulbs and Light Strips"
+ }
+ },
+ "user": {
+ "title": "[%key:common::config_flow::data::title%]",
+ "data": {
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]",
+ "keyid": "[%key:common::config_flow::data::key_id%]",
+ "apikey": "[%key:common::config_flow::data::api_key%]"
+ }
+ },
+ "2fa": {
+ "title": "[%key:common::config_flow::data::title%]",
+ "data": {
+ "verification_code": "[%key:common::config_flow::data::verification_code%]"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ }
+ },
+ "issues": {
+ "entity_changed": {
+ "title": "Wyze Entity Error",
+ "description": "{automation} may need attention. {entity} may have changed or been removed. This is due to a change in outdoor plug devices. Once fixed you can ignore this issue."
+ },
+ "device_changed": {
+ "title": "Wyze Device Error",
+ "description": "{automation} may need attention. A device has changed or been removed. This is due to a change in outdoor plug devices. Once fixed you can ignore this issue."
+ }
+ }
+}
diff --git a/custom_components/wyzeapi/switch.py b/custom_components/wyzeapi/switch.py
new file mode 100644
index 0000000..38ebe6b
--- /dev/null
+++ b/custom_components/wyzeapi/switch.py
@@ -0,0 +1,730 @@
+#!/usr/bin/python3
+
+"""Platform for switch integration."""
+
+from collections.abc import Callable
+from datetime import timedelta
+import logging
+from typing import Any
+
+from aiohttp.client_exceptions import ClientConnectionError
+from wyzeapy import BulbService, CameraService, SwitchService, Wyzeapy
+from wyzeapy.exceptions import AccessTokenError, ParameterError, UnknownApiError
+from wyzeapy.services.bulb_service import Bulb
+from wyzeapy.services.camera_service import Camera
+from wyzeapy.services.switch_service import Switch
+from wyzeapy.types import Device, DeviceTypes, Event
+
+from homeassistant.components.automation import (
+ automations_with_device,
+ automations_with_entity,
+)
+from homeassistant.components.script import scripts_with_device, scripts_with_entity
+from homeassistant.components.switch import SwitchEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import (
+ device_registry as dr,
+ entity_registry as er,
+ issue_registry as ir,
+)
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect,
+ async_dispatcher_send,
+)
+from homeassistant.helpers.issue_registry import IssueSeverity
+
+from .const import (
+ CAMERA_UPDATED,
+ CONF_CLIENT,
+ DOMAIN,
+ LIGHT_UPDATED,
+ WYZE_CAMERA_EVENT,
+ WYZE_NOTIFICATION_TOGGLE,
+)
+from .token_manager import token_exception_handler
+
+_LOGGER = logging.getLogger(__name__)
+ATTRIBUTION = "Data provided by Wyze"
+SCAN_INTERVAL = timedelta(seconds=30)
+OUTDOOR_PLUGS = "WLPPO"
+OUTDOOR_PLUG_INDIVUAL_OUTLETS = "WLPPO-SUB"
+MOTION_SWITCH_UNSUPPORTED = [
+ "GW_BE1",
+ "GW_GC1",
+ "GW_GC2",
+] # Video doorbell pro, OG, OG 3x Telephoto
+POWER_SWITCH_UNSUPPORTED = ["GW_BE1"] # Video doorbell pro (device has no off function)
+NOTIFICATION_SWITCH_UNSUPPORTED = {
+ "GW_GC1",
+ "GW_GC2",
+} # OG and OG 3x Telephoto models currently unsupported due to InvalidSignature2 error
+
+
+# noinspection DuplicatedCode
+@token_exception_handler
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: Callable[[list[Any], bool], None],
+) -> None:
+ """This function sets up the config entry.
+
+ :param hass: The Home Assistant Instance
+ :param config_entry: The current config entry
+ :param async_add_entities: This function adds entities to the config entry
+ :return:
+ """
+
+ _LOGGER.debug("""Creating new WyzeApi light component""")
+ client: Wyzeapy = hass.data[DOMAIN][config_entry.entry_id][CONF_CLIENT]
+ switch_service = await client.switch_service
+ wall_switch_service = await client.wall_switch_service
+ camera_service = await client.camera_service
+ bulb_service = await client.bulb_service
+
+ switches: list[SwitchEntity] = []
+ has_outdoor_plug: bool = False
+ devices_to_migrate: list[str] = []
+ devices = []
+ device_registry = dr.async_get(hass)
+
+ base_switches = await switch_service.get_switches()
+ # The outdoor plug has a dummy switch that doesn't control anything
+ # on the device. So we add non-outdoor plug switches and then
+ # the switches for each individual outlet on the outdoor plug.
+ switches.extend(
+ WyzeSwitch(switch_service, switch)
+ for switch in base_switches
+ if switch.product_model not in [OUTDOOR_PLUGS, OUTDOOR_PLUG_INDIVUAL_OUTLETS]
+ )
+
+ for switch in base_switches:
+ if switch.product_model in [OUTDOOR_PLUG_INDIVUAL_OUTLETS]:
+ has_outdoor_plug = True
+ switches.append(WyzeSwitch(switch_service, switch))
+
+ switches.extend(
+ WyzeSwitch(wall_switch_service, switch)
+ for switch in await wall_switch_service.get_switches()
+ )
+
+ camera_switches = await camera_service.get_cameras()
+ for switch in camera_switches:
+ # Notification toggle switch
+ if switch.product_model not in NOTIFICATION_SWITCH_UNSUPPORTED:
+ switches.append(WyzeCameraNotificationSwitch(camera_service, switch))
+
+ # IoT Power switch
+ if switch.product_model not in POWER_SWITCH_UNSUPPORTED:
+ switches.append(WyzeSwitch(camera_service, switch))
+
+ # Motion toggle switch
+ if switch.product_model not in MOTION_SWITCH_UNSUPPORTED:
+ switches.append(WyzeCameraMotionSwitch(camera_service, switch))
+
+ switches.append(WyzeNotifications(client))
+
+ bulb_switches = await bulb_service.get_bulbs()
+ switches.extend(
+ WzyeLightstripSwitch(bulb_service, bulb)
+ for bulb in bulb_switches
+ if bulb.type is DeviceTypes.LIGHTSTRIP
+ )
+
+ # Catch old outdoor plug devices and entities and remove.
+ # This can be removed at a later date.
+ if has_outdoor_plug:
+ devices = dr.async_entries_for_config_entry(
+ device_registry, config_entry.entry_id
+ )
+ for device in devices:
+ for identifier in device.identifiers:
+ mac = identifier[1]
+ if (
+ "-" in mac and device.model == OUTDOOR_PLUG_INDIVUAL_OUTLETS
+ ): # The old devices have a '-' in the mac
+ devices_to_migrate.append(device.id)
+
+ # Also catch the old dummy switch to remove below.
+ # Should only happen once while the old devices are still around.
+ if devices_to_migrate:
+ devices_to_migrate.extend(
+ device.id for device in devices if device.model == OUTDOOR_PLUGS
+ )
+ await async_migrate_switch_data(
+ hass, config_entry, devices_to_migrate, device_registry
+ )
+
+ async_add_entities(switches, True)
+
+
+async def async_migrate_switch_data(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ device_list,
+ device_registry,
+):
+ """Remove redundant switch devices and entities and flag for repair.
+
+ This can be removed in the future.
+ """
+
+ entity_registry = er.async_get(hass)
+ entities = er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)
+
+ for entity in entities:
+ if entity.device_id in device_list and entity.domain == "switch":
+ entity_automations = automations_with_entity(hass, entity.entity_id)
+ entity_automations.extend(scripts_with_entity(hass, entity.entity_id))
+ if entity_automations:
+ for issue in entity_automations:
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ issue_id=f"{issue}-entity-issue",
+ is_fixable=False,
+ is_persistent=True,
+ severity=IssueSeverity.ERROR,
+ learn_more_url="https://github.com/SecKatie/ha-wyzeapi/issues/789",
+ translation_key="entity_changed",
+ translation_placeholders={
+ "entity": entity.entity_id,
+ "automation": issue,
+ },
+ )
+ entity_registry.async_remove(entity.id)
+
+ for device in device_list:
+ device_automations = automations_with_device(hass, device)
+ device_automations.extend(scripts_with_device(hass, device))
+ if device_automations:
+ for issue in device_automations:
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ issue_id=f"{issue}-device-issue",
+ is_fixable=False,
+ is_persistent=True,
+ severity=IssueSeverity.ERROR,
+ learn_more_url="https://github.com/SecKatie/ha-wyzeapi/issues/789",
+ translation_key="device_changed",
+ translation_placeholders={
+ "automation": issue,
+ },
+ )
+ device_registry.async_remove_device(device)
+
+
+class WyzeNotifications(SwitchEntity):
+ """Class for notification switch."""
+
+ _attr_should_poll = False
+ _attr_name = "Wyze Notifications"
+
+ def __init__(self, client: Wyzeapy) -> None:
+ """Initialize the switch."""
+ self._client = client
+ self._is_on = False
+ self._uid = WYZE_NOTIFICATION_TOGGLE
+ self._just_updated = False
+
+ @property
+ def is_on(self) -> bool:
+ """Return if the switch is on."""
+ return self._is_on
+
+ @property
+ def device_info(self):
+ """Return the device info."""
+ return {
+ "identifiers": {(DOMAIN, self._uid)},
+ "name": "Wyze Notifications",
+ "manufacturer": "WyzeLabs",
+ "model": "WyzeNotificationToggle",
+ }
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn on the switch."""
+ try:
+ await self._client.enable_notifications()
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._is_on = True
+ self._just_updated = True
+ self.async_schedule_update_ha_state()
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn off the switch."""
+ try:
+ await self._client.disable_notifications()
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._is_on = False
+ self._just_updated = True
+ self.async_schedule_update_ha_state()
+
+ @property
+ def available(self):
+ """Return the connection status of this switch."""
+ return True
+
+ @property
+ def unique_id(self):
+ """Return the unique ID."""
+ return self._uid
+
+ async def async_update(self):
+ """Update the switch."""
+ if not self._just_updated:
+ self._is_on = await self._client.notifications_are_on
+ else:
+ self._just_updated = False
+
+
+class WyzeSwitch(SwitchEntity):
+ """Representation of a Wyze Switch."""
+
+ _on: bool
+ _available: bool
+ _just_updated = False
+ _old_event_ts: int = 0 # preload with 0 so that we know when it's been updated
+ _attr_should_poll = False
+
+ def __init__(self, service: CameraService | SwitchService, device: Device) -> None:
+ """Initialize a Wyze Bulb."""
+ self._device = device
+ self._service = service
+
+ if type(self._device) is Camera:
+ self._device = Camera(self._device.raw_dict)
+ elif type(self._device) is Switch:
+ self._device = Switch(self._device.raw_dict)
+
+ @property
+ def device_info(self):
+ """Return the device info.
+
+ Outdoor plug needs its own setup based on how the MAC's are
+ displayed and to keep the plugs organized by device.
+ """
+ if self._device.product_model == OUTDOOR_PLUG_INDIVUAL_OUTLETS:
+ mac = self._device.mac.split("-")[0]
+ return {
+ "identifiers": {(DOMAIN, mac)},
+ "connections": {
+ (
+ dr.CONNECTION_NETWORK_MAC,
+ mac,
+ )
+ },
+ "name": f"Outdoor Plug {mac}",
+ "manufacturer": "WyzeLabs",
+ "model": self._device.product_model,
+ }
+ return {
+ "identifiers": {(DOMAIN, self._device.mac)},
+ "connections": {
+ (
+ dr.CONNECTION_NETWORK_MAC,
+ self._device.mac,
+ )
+ },
+ "name": self._device.nickname,
+ "manufacturer": "WyzeLabs",
+ "model": self._device.product_model,
+ }
+
+ @token_exception_handler
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn on the switch."""
+ try:
+ await self._service.turn_on(self._device)
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._device.on = True
+ self._just_updated = True
+ self.async_schedule_update_ha_state()
+
+ @token_exception_handler
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn off the switch."""
+ try:
+ await self._service.turn_off(self._device)
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._device.on = False
+ self._just_updated = True
+ self.async_schedule_update_ha_state()
+
+ @property
+ def name(self):
+ """Return the display name of this switch."""
+ if type(self._device) is Camera:
+ return f"{self._device.nickname} Power"
+ return self._device.nickname
+
+ @property
+ def available(self):
+ """Return the connection status of this switch."""
+ return self._device.available
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self._device.on
+
+ @property
+ def unique_id(self):
+ """Return the unique ID."""
+ return f"{self._device.mac}-switch"
+
+ @property
+ def extra_state_attributes(self):
+ """Return device attributes of the entity."""
+ dev_info = {}
+
+ if self._device.device_params.get("electricity"):
+ dev_info["Battery"] = str(
+ self._device.device_params.get("electricity") + "%"
+ )
+ # noinspection DuplicatedCode
+ if self._device.device_params.get("ip"):
+ dev_info["IP"] = str(self._device.device_params.get("ip"))
+ if self._device.device_params.get("rssi"):
+ dev_info["RSSI"] = str(self._device.device_params.get("rssi"))
+ if self._device.device_params.get("ssid"):
+ dev_info["SSID"] = str(self._device.device_params.get("ssid"))
+
+ return dev_info
+
+ @token_exception_handler
+ async def async_update(self):
+ """Update the entity."""
+ if not self._just_updated:
+ self._device = await self._service.update(self._device)
+ else:
+ self._just_updated = False
+
+ @callback
+ def async_update_callback(self, switch: Switch):
+ """Update the switch's state."""
+ self._device = switch
+ async_dispatcher_send(
+ self.hass,
+ f"{CAMERA_UPDATED}-{switch.mac}",
+ switch,
+ )
+ self.async_schedule_update_ha_state()
+ # if the switch is from a camera, lets check for new events
+ if isinstance(switch, Camera):
+ if (
+ self._old_event_ts > 0
+ and self._old_event_ts != switch.last_event_ts
+ and switch.last_event is not None
+ ):
+ event: Event = switch.last_event
+ # The screenshot/video urls are not always in the same positions in the lists, so we have to loop
+ # through them
+ _screenshot_url = None
+ _video_url = None
+ _ai_tag_list = []
+ for resource in event.file_list:
+ _ai_tag_list = _ai_tag_list + resource["ai_tag_list"]
+ if resource["type"] == 1:
+ _screenshot_url = resource["url"]
+ elif resource["type"] == 2:
+ _video_url = resource["url"]
+ _LOGGER.debug("Camera: %s has a new event", switch.nickname)
+ self.hass.bus.fire(
+ WYZE_CAMERA_EVENT,
+ {
+ "device_name": switch.nickname,
+ "device_mac": switch.mac,
+ "ai_tag_list": _ai_tag_list,
+ "tag_list": event.tag_list,
+ "event_screenshot": _screenshot_url,
+ "event_video": _video_url,
+ },
+ )
+ self._old_event_ts = switch.last_event_ts
+
+ async def async_added_to_hass(self) -> None:
+ """Subscribe to update events."""
+ self._device.callback_function = self.async_update_callback
+ self._service.register_updater(self._device, 30)
+ await self._service.start_update_manager()
+ return await super().async_added_to_hass()
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Unregister updated on removal."""
+ self._service.unregister_updater(self._device)
+
+
+class WyzeCameraNotificationSwitch(SwitchEntity):
+ """Representation of a Wyze Camera Notification Switch."""
+
+ _available: bool
+
+ def __init__(self, service: CameraService, device: Camera) -> None:
+ """Initialize a Wyze Notification Switch."""
+ self._service = service
+ self._device = device
+
+ @property
+ def device_info(self):
+ """Return the device info."""
+ return {
+ "identifiers": {(DOMAIN, self._device.mac)},
+ "name": self._device.nickname,
+ "manufacturer": "WyzeLabs",
+ "model": self._device.product_model,
+ }
+
+ @property
+ def should_poll(self) -> bool:
+ """No polling needed."""
+ return False
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn on the switch."""
+ try:
+ await self._service.turn_on_notifications(self._device)
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._device.notify = True
+ self.async_write_ha_state()
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn off the switch."""
+ try:
+ await self._service.turn_off_notifications(self._device)
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._device.notify = False
+ self.async_write_ha_state()
+
+ @property
+ def name(self):
+ """Return the display name of this switch."""
+ return f"{self._device.nickname} Notifications"
+
+ @property
+ def available(self):
+ """Return the connection status of this switch."""
+ return self._device.available
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self._device.notify
+
+ @property
+ def unique_id(self):
+ """Add a unique ID to the switch."""
+ return f"{self._device.mac}-notification_switch"
+
+ @callback
+ def handle_camera_update(self, camera: Camera) -> None:
+ """Update the switch whenever there is an update."""
+ self._device = camera
+ self.async_write_ha_state()
+
+ async def async_added_to_hass(self) -> None:
+ """Listen for camera updates."""
+ self.async_on_remove(
+ async_dispatcher_connect(
+ self.hass,
+ f"{CAMERA_UPDATED}-{self._device.mac}",
+ self.handle_camera_update,
+ )
+ )
+
+
+class WyzeCameraMotionSwitch(SwitchEntity):
+ """Representation of a Wyze Camera Motion Detection Switch."""
+
+ _available: bool
+
+ def __init__(self, service: CameraService, device: Camera) -> None:
+ """Initialize a Wyze Notification Switch."""
+ self._service = service
+ self._device = device
+
+ @property
+ def device_info(self):
+ """Return the device info."""
+ return {
+ "identifiers": {(DOMAIN, self._device.mac)},
+ "name": self._device.nickname,
+ "manufacturer": "WyzeLabs",
+ "model": self._device.product_model,
+ }
+
+ @property
+ def should_poll(self) -> bool:
+ """No polling needed."""
+ return False
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn on the switch."""
+ try:
+ await self._service.turn_on_motion_detection(self._device)
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._device.motion = True
+ self.async_write_ha_state()
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn off the switch."""
+ try:
+ await self._service.turn_off_motion_detection(self._device)
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._device.motion = False
+ self.async_write_ha_state()
+
+ @property
+ def name(self):
+ """Return the display name of this switch."""
+ return f"{self._device.nickname} Motion Detection"
+
+ @property
+ def available(self):
+ """Return the connection status of this switch."""
+ return self._device.available
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self._device.motion
+
+ @property
+ def unique_id(self):
+ """Add a unique ID to the switch."""
+ return f"{self._device.mac}-motion_switch"
+
+ @callback
+ def handle_camera_update(self, camera: Camera) -> None:
+ """Update the switch whenever there is an update."""
+ self._device = camera
+ self.async_write_ha_state()
+
+ async def async_added_to_hass(self) -> None:
+ """Listen for camera updates."""
+ self.async_on_remove(
+ async_dispatcher_connect(
+ self.hass,
+ f"{CAMERA_UPDATED}-{self._device.mac}",
+ self.handle_camera_update,
+ )
+ )
+
+
+class WzyeLightstripSwitch(SwitchEntity):
+ """Music Mode Switch for Wyze Light Strip."""
+
+ def __init__(self, service: BulbService, device: Device) -> None:
+ """Initialize a Wyze Music Mode Switch."""
+ self._service = service
+ self._device = Bulb(device.raw_dict)
+
+ @property
+ def device_info(self):
+ """Return the device info."""
+ return {
+ "identifiers": {(DOMAIN, self._device.mac)},
+ "name": self._device.nickname,
+ "manufacturer": "WyzeLabs",
+ "model": self._device.product_model,
+ }
+
+ @property
+ def should_poll(self) -> bool:
+ """No polling needed."""
+ return False
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn on the switch."""
+ try:
+ await self._service.music_mode_on(self._device)
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._device.music_mode = True
+ self.async_schedule_update_ha_state()
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn off the switch."""
+ try:
+ await self._service.music_mode_off(self._device)
+ except (AccessTokenError, ParameterError, UnknownApiError) as err:
+ raise HomeAssistantError(f"Wyze returned an error: {err.args}") from err
+ except ClientConnectionError as err:
+ raise HomeAssistantError(err) from err
+ else:
+ self._device.music_mode = False
+ self.async_schedule_update_ha_state()
+
+ @property
+ def name(self):
+ """Return the display name of this switch."""
+ return f"{self._device.nickname} Music Mode for Effects"
+
+ @property
+ def available(self):
+ """Return the connection status of this switch."""
+ return self._device.available
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self._device.music_mode
+
+ @property
+ def unique_id(self):
+ """Add a unique ID to the switch."""
+ return f"{self._device.mac}-music_mode"
+
+ @callback
+ def handle_light_update(self, bulb: Bulb) -> None:
+ """Update the switch whenever there is an update."""
+ self._device = bulb
+ self.async_write_ha_state()
+
+ async def async_added_to_hass(self) -> None:
+ """Listen for light updates."""
+ self.async_on_remove(
+ async_dispatcher_connect(
+ self.hass,
+ f"{LIGHT_UPDATED}-{self._device.mac}",
+ self.handle_light_update,
+ )
+ )
diff --git a/custom_components/wyzeapi/token_manager.py b/custom_components/wyzeapi/token_manager.py
new file mode 100644
index 0000000..3f0005c
--- /dev/null
+++ b/custom_components/wyzeapi/token_manager.py
@@ -0,0 +1,52 @@
+import logging
+from inspect import iscoroutinefunction
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_USERNAME, CONF_PASSWORD
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from wyzeapy.exceptions import AccessTokenError, LoginError
+from wyzeapy.wyze_auth_lib import Token
+
+from .const import DOMAIN, ACCESS_TOKEN, REFRESH_TOKEN, REFRESH_TIME
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class TokenManager:
+ hass: HomeAssistant = None
+ config_entry: ConfigEntry = None
+
+ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry):
+ TokenManager.hass = hass
+ TokenManager.config_entry = config_entry
+
+ @staticmethod
+ async def token_callback(token: Token = None):
+ _LOGGER.debug("TokenManager: Received new token, updating config entry.")
+ if TokenManager.hass.config_entries.async_entries(DOMAIN):
+ for entry in TokenManager.hass.config_entries.async_entries(DOMAIN):
+ TokenManager.hass.config_entries.async_update_entry(
+ entry,
+ data={
+ CONF_USERNAME: entry.data.get(CONF_USERNAME),
+ CONF_PASSWORD: entry.data.get(CONF_PASSWORD),
+ ACCESS_TOKEN: token.access_token,
+ REFRESH_TOKEN: token.refresh_token,
+ REFRESH_TIME: str(token.refresh_time),
+ },
+ )
+
+
+def token_exception_handler(func):
+ async def inner_function(*args, **kwargs):
+ try:
+ if iscoroutinefunction(func):
+ await func(*args, **kwargs)
+ else:
+ func(*args, **kwargs)
+ except (AccessTokenError, LoginError) as err:
+ _LOGGER.error("TokenManager detected a login issue please re-login.")
+ raise ConfigEntryAuthFailed("Unable to login, please re-login.") from err
+
+ return inner_function
diff --git a/custom_components/wyzeapi/translations/en.json b/custom_components/wyzeapi/translations/en.json
new file mode 100644
index 0000000..548dcb1
--- /dev/null
+++ b/custom_components/wyzeapi/translations/en.json
@@ -0,0 +1,78 @@
+{
+ "title": "Wyze Home Assistant Integration",
+ "config": {
+ "abort": {
+ "already_configured": "Device is already configured",
+ "reauth_successful": "Reauthentication Successful"
+ },
+ "error": {
+ "cannot_connect": "Failed to connect",
+ "invalid_auth": "Invalid authentication",
+ "unknown": "Unexpected error"
+ },
+ "step": {
+ "user": {
+ "title": "Enter Wyze Login Credentials",
+ "data": {
+ "username": "Email",
+ "password": "Password",
+ "key_id": "Keyid",
+ "api_key": "Apikey"
+ },
+ "data_description": {
+ "username": "Wyze or 3rd party OAuth email address",
+ "password": "Wyze or 3rd party OAuth password"
+ }
+ },
+ "2fa": {
+ "title": "Enter Wyze 2FA Verification Code",
+ "data": {
+ "verification_code": "2FA Verification Code"
+ }
+ }
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "bulb_local_control": "Use Local Control for Color Bulbs and Light Strips"
+ }
+ },
+ "user": {
+ "title": "Enter Wyze Login Credentials",
+ "data": {
+ "username": "Email",
+ "password": "Password",
+ "key_id": "Keyid",
+ "api_key": "Apikey"
+ }
+ },
+ "2fa": {
+ "title": "Enter Wyze 2FA Verification Code",
+ "data": {
+ "verification_code": "2FA Verification Code"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "Failed to connect",
+ "invalid_auth": "Invalid authentication",
+ "unknown": "Unexpected error"
+ },
+ "abort": {
+ "already_configured": "Device is already configured",
+ "reauth_successful": "Reauthentication Successful"
+ }
+ },
+ "issues": {
+ "entity_changed": {
+ "title": "Wyze Entity Error",
+ "description": "{automation} may need attention. {entity} may have changed or been removed. This is due to a change in outdoor plug devices. Once fixed you can ignore this issue."
+ },
+ "device_changed": {
+ "title": "Wyze Device Error",
+ "description": "{automation} may need attention. A device has changed or been removed. This is due to a change in outdoor plug devices. Once fixed you can ignore this issue."
+ }
+ }
+}
diff --git a/custom_components/wyzeapi/translations/pt-BR.json b/custom_components/wyzeapi/translations/pt-BR.json
new file mode 100644
index 0000000..0ba61f6
--- /dev/null
+++ b/custom_components/wyzeapi/translations/pt-BR.json
@@ -0,0 +1,54 @@
+{
+ "title": "Integração Wyze para Home Assistant",
+ "config": {
+ "abort": {
+ "already_configured": "O dispositivo já está configurado",
+ "reauth_successful": "Reautenticação bem-sucedida"
+ },
+ "error": {
+ "cannot_connect": "Falhou ao conectar",
+ "invalid_auth": "Autenticação inválida",
+ "unknown": "Erro inesperado"
+ },
+ "step": {
+ "user": {
+ "title": "Coloque as credenciais de login do Wyze",
+ "data": {
+ "password": "Senha",
+ "username": "Nome de usuário"
+ }
+ },
+ "2fa": {
+ "title": "Insira o código de verificação Wyze 2FA",
+ "data": {
+ "verification_code": "Código de verificação 2FA"
+ }
+ }
+ }
+ },
+ "options": {
+ "abort": {
+ "already_configured": "O dispositivo já está configurado"
+ },
+ "error": {
+ "cannot_connect": "Falhou ao conectar",
+ "invalid_auth": "Autenticação inválida",
+ "unknown": "Erro inesperado"
+ },
+ "step": {
+ "user": {
+ "title": "Coloque as credenciais de login do Wyze",
+ "data": {
+ "password": "Senha",
+ "username": "Nome de usuário"
+ }
+ },
+ "2fa": {
+ "title": "Insira o código de verificação Wyze 2FA",
+ "data": {
+ "verification_code": "Código de verificação 2FA"
+ }
+ }
+ }
+ }
+}
diff --git a/custom_components/wyzeapi/translations/pt_br.json b/custom_components/wyzeapi/translations/pt_br.json
new file mode 100644
index 0000000..133c984
--- /dev/null
+++ b/custom_components/wyzeapi/translations/pt_br.json
@@ -0,0 +1,54 @@
+{
+ "title": "Integração Wyze para Home Assistant",
+ "config": {
+ "abort": {
+ "already_configured": "O dispositivo já está configurado",
+ "reauth_successful": "Reautenticação bem-sucedida"
+ },
+ "error": {
+ "cannot_connect": "Falhou ao conectar",
+ "invalid_auth": "Autenticação inválida",
+ "unknown": "Erro inesperado"
+ },
+ "step": {
+ "user": {
+ "title": "Coloque as credenciais de login do Wyze",
+ "data": {
+ "password": "Senha",
+ "username": "Nome de usuário"
+ }
+ },
+ "2fa": {
+ "title": "Insira o código de verificação Wyze 2FA",
+ "data": {
+ "verification_code": "Código de verificação 2FA"
+ }
+ }
+ }
+ },
+ "options": {
+ "abort": {
+ "already_configured": "O dispositivo já está configurado"
+ },
+ "error": {
+ "cannot_connect": "Falhou ao conectar",
+ "invalid_auth": "Autenticação inválida",
+ "unknown": "Erro inesperado"
+ },
+ "step": {
+ "user": {
+ "title": "Coloque as credenciais de login do Wyze",
+ "data": {
+ "password": "Senha",
+ "username": "Nome de usuário"
+ }
+ },
+ "2fa": {
+ "title": "Insira o código de verificação Wyze 2FA",
+ "data": {
+ "verification_code": "Código de verificação 2FA"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/custom_components/wyzeapi/translations/ru.json b/custom_components/wyzeapi/translations/ru.json
new file mode 100644
index 0000000..4c40c1b
--- /dev/null
+++ b/custom_components/wyzeapi/translations/ru.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Устройство уже сконфигурировано"
+ },
+ "error": {
+ "cannot_connect": "Не могу подключиться",
+ "invalid_auth": "Ошибка автризации",
+ "unknown": "Неизвестная ошибка"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Хост",
+ "password": "Пароль",
+ "username": "Логин"
+ }
+ }
+ }
+ },
+ "title": "Интеграция Wyze Home Assistant"
+}
\ No newline at end of file
diff --git a/custom_components/wyzeapi/ydble_utils.py b/custom_components/wyzeapi/ydble_utils.py
new file mode 100644
index 0000000..b9b793e
--- /dev/null
+++ b/custom_components/wyzeapi/ydble_utils.py
@@ -0,0 +1,116 @@
+import binascii
+from typing import Dict
+
+from Crypto.Cipher import AES
+
+
+def decrypt_ecb(key: str, data: bytes) -> bytes:
+ key_bytes = key.encode()
+ cipher = AES.new(key_bytes, AES.MODE_ECB)
+ decrypted_data = cipher.decrypt(data)
+ return decrypted_data
+
+
+def encrypt_ecb(key: str, data: bytes) -> bytes:
+ key_bytes = key.encode()
+ cipher = AES.new(key_bytes, AES.MODE_ECB)
+ encrypted_data = cipher.encrypt(data)
+ return encrypted_data
+
+
+def pack_l1(flags: int, seq_no: int, data: bytes):
+ data_crc = crc(data)
+ result = b"\xab"
+ result += flags.to_bytes(1)
+ result += len(data).to_bytes(2)
+ result += data_crc.to_bytes(2)
+ result += seq_no.to_bytes(2)
+ result += data
+ return result
+
+
+def parse_l1(data: bytes):
+ if data[0] != 0xAB:
+ raise ValueError("Unexpected data")
+ flags = data[1]
+ length = int.from_bytes(data[2:4])
+ data_crc = int.from_bytes(data[4:6])
+ seq_no = int.from_bytes(data[6:8])
+ l2_content = data[8:]
+ if len(l2_content) > length:
+ l2_content = l2_content[:length]
+ if len(l2_content) == length and crc(l2_content) != data_crc:
+ raise ValueError(f"CRC Checksum failed! {data_crc} != {crc(l2_content)}")
+ return l2_content, flags, seq_no, length - len(l2_content)
+
+
+def pack_l2_dict(cmd: int, flags: int, content: Dict[int, bytes]):
+ result = cmd.to_bytes(1)
+ result += flags.to_bytes(1)
+ for k, v in content.items():
+ result += k.to_bytes(1)
+ result += len(v).to_bytes(2)
+ result += v
+ return result
+
+
+def parse_l2_dict(data: bytes):
+ result_dict: Dict[int, bytes] = {}
+ cmd = data[0]
+ flags = data[1]
+ cur = 2
+ while cur < len(data):
+ key = data[cur]
+ length = int.from_bytes(data[cur + 1 : cur + 3])
+ value = data[cur + 3 : cur + 3 + length]
+ result_dict[key] = value
+ cur += 3 + length
+ return cmd, flags, result_dict
+
+
+def pack_l2_lock_unlock(ble_id: int, ble_token: str, challenge: bytes, command):
+ if command == "unlock":
+ magic_bytes = binascii.unhexlify("01000000000000000000006C6F6F636B")
+ elif command == "lock":
+ magic_bytes = binascii.unhexlify("02000000000000000000006C6F6F636B")
+ else:
+ raise ValueError(f"Only accept `lock` or `unlock`, but got `{command}`")
+ encrypted_challenge = encrypt_ecb(ble_token[16:], challenge)
+ encrypted_challenge = b"".join(
+ (x ^ y).to_bytes(1) for x, y in zip(encrypted_challenge, magic_bytes)
+ )
+ result = (0x0400050002).to_bytes(5)
+ result += ble_id.to_bytes(2)
+ result += (0x040010).to_bytes(3)
+ result += encrypted_challenge
+ result += (0xAD000100F4000101F7000101).to_bytes(12)
+ return result
+
+
+def crc(data):
+ magic = (
+ "0000c0c1c1810140c30103c00280c241c60106c00780c7410500c5c1c4810440"
+ "cc010cc00d80cd410f00cfc1ce810e400a00cac1cb810b40c90109c00880c841"
+ "d80118c01980d9411b00dbc1da811a401e00dec1df811f40dd011dc01c80dc41"
+ "1400d4c1d5811540d70117c01680d641d20112c01380d3411100d1c1d0811040"
+ "f00130c03180f1413300f3c1f28132403600f6c1f7813740f50135c03480f441"
+ "3c00fcc1fd813d40ff013fc03e80fe41fa013ac03b80fb413900f9c1f8813840"
+ "2800e8c1e9812940eb012bc02a80ea41ee012ec02f80ef412d00edc1ec812c40"
+ "e40124c02580e5412700e7c1e68126402200e2c1e3812340e10121c02080e041"
+ "a00160c06180a1416300a3c1a28162406600a6c1a7816740a50165c06480a441"
+ "6c00acc1ad816d40af016fc06e80ae41aa016ac06b80ab416900a9c1a8816840"
+ "7800b8c1b9817940bb017bc07a80ba41be017ec07f80bf417d00bdc1bc817c40"
+ "b40174c07580b5417700b7c1b68176407200b2c1b3817340b10171c07080b041"
+ "500090c191815140930153c052809241960156c057809741550095c194815440"
+ "9c015cc05d809d415f009fc19e815e405a009ac19b815b40990159c058809841"
+ "880148c0498089414b008bc18a814a404e008ec18f814f408d014dc04c808c41"
+ "440084c185814540870147c046808641820142c043808341410081c180814040"
+ )
+ magic = [
+ int.from_bytes(binascii.unhexlify(magic[x : x + 4]))
+ for x in range(0, len(magic), 4)
+ ]
+ result = 0
+ for b in data:
+ result = magic[(result ^ (b & 255)) & 255] ^ (result >> 8)
+ return 65535 & result