Added Wyze

This commit is contained in:
2026-06-12 09:59:05 -04:00
parent c156180e03
commit 8843bf77a5
61 changed files with 8331 additions and 1 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+1 -1
View File
@@ -1 +1 @@
{"pid": 72, "version": 1, "ha_version": "2026.6.2", "start_ts": 1781098592.353074} {"pid": 71, "version": 1, "ha_version": "2026.6.2", "start_ts": 1781272512.3211384}
+317
View File
@@ -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)
+106
View File
@@ -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},
)
+17
View File
@@ -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"
}
+99
View File
@@ -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)
+59
View File
@@ -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:
@@ -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"
}
}
}
}
}
@@ -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"
}
}
}
}
}
@@ -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"
}
}
}
}
}
@@ -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"
}
}
}
}
}
@@ -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": "ہاس سرور کے لیے دستی بیس یو آر ایل"
}
}
}
}
}
+260
View File
@@ -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()
+346
View File
@@ -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})`;
}
}
+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta content="width=device-width, initial-scale=1" name="viewport">
<title>WebRTC Camera</title>
<style>
body {
margin: 0;
padding: 0;
font-family: Arial, Helvetica, sans-serif;
}
html, body, webrtc-camera {
height: 100%;
width: 100%;
}
ha-card {
display: block;
}
</style>
</head>
<body>
<script type="module" src="/webrtc/webrtc-camera.js"></script>
<script type="module">
const config = {};
for (const [k, v] of new URLSearchParams(location.search)) {
if (v === 'true') config[k] = true;
else if (v === 'false') config[k] = false;
else config[k] = v;
}
const card = document.createElement('webrtc-camera');
card.setConfig(config);
card.hass = {
callWS: () => new Promise(resolve => {
resolve('');
}),
hassUrl: () => location.origin + '/api/webrtc/ws?embed=1'
};
document.body.appendChild(card);
</script>
</body>
</html>
+676
View File
@@ -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.<string,Function>}
*/
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.<string>} 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<RTCSessionDescriptionInit>}
*/
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);
}
}
@@ -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 = `
<style>
ha-card {
width: 100%;
height: 100%;
margin: auto;
overflow: hidden;
position: relative;
}
ha-icon {
color: white;
cursor: pointer;
}
.player {
background-color: black;
height: 100%;
position: relative; /* important for Safari */
}
.player:active {
cursor: move; /* important for zoom-controller */
}
.player .ptz-transform {
height: 100%;
}
.header {
position: absolute;
top: 6px;
left: 10px;
right: 10px;
color: white;
display: flex;
justify-content: space-between;
pointer-events: none;
}
.mode {
cursor: pointer;
opacity: 0.6;
pointer-events: auto;
}
</style>
<ha-card class="card">
<div class="player">
<div class="ptz-transform"></div>
</div>
<div class="header">
<div class="status"></div>
<div class="mode"></div>
</div>
</ha-card>
`;
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', `
<style>
.ptz {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 10px;
transition: opacity .3s ease-in-out;
opacity: ${parseFloat(this.config.ptz.opacity) || 0.4};
}
.ptz:hover {
opacity: 1 !important;
}
.ptz-move {
position: relative;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 50%;
width: 80px;
height: 80px;
display: ${hasMove ? 'block' : 'none'};
}
.ptz-zoom {
position: relative;
width: 80px;
height: 40px;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 4px;
display: ${hasZoom ? 'block' : 'none'};
}
.ptz-home {
position: relative;
width: 40px;
height: 40px;
background-color: rgba(0, 0, 0, 0.3);
border-radius: 4px;
align-self: center;
display: ${hasHome ? 'block' : 'none'};
}
.up {
position: absolute;
top: 5px;
left: 50%;
transform: translateX(-50%);
}
.down {
position: absolute;
bottom: 5px;
left: 50%;
transform: translateX(-50%);
}
.left {
position: absolute;
left: 5px;
top: 50%;
transform: translateY(-50%);
}
.right {
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
}
.zoom_out {
position: absolute;
left: 5px;
top: 50%;
transform: translateY(-50%);
}
.zoom_in {
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
}
.home {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
</style>
`);
card.insertAdjacentHTML('beforeend', `
<div class="ptz">
<div class="ptz-move">
<ha-icon class="right" icon="mdi:arrow-right"></ha-icon>
<ha-icon class="left" icon="mdi:arrow-left"></ha-icon>
<ha-icon class="up" icon="mdi:arrow-up"></ha-icon>
<ha-icon class="down" icon="mdi:arrow-down"></ha-icon>
</div>
<div class="ptz-zoom">
<ha-icon class="zoom_in" icon="mdi:plus"></ha-icon>
<ha-icon class="zoom_out" icon="mdi:minus"></ha-icon>
</div>
<div class="ptz-home">
<ha-icon class="home" icon="mdi:home"></ha-icon>
</div>
</div>
`);
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', `
<style>
.spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.controls {
position: absolute;
left: 5px;
right: 5px;
bottom: 5px;
display: flex;
}
.space {
width: 100%;
}
.volume {
display: none;
}
.stream {
padding-top: 2px;
margin-left: 2px;
font-weight: 400;
font-size: 20px;
color: white;
display: none;
cursor: pointer;
}
</style>
`);
card.insertAdjacentHTML('beforeend', `
<div class="ui">
<ha-circular-progress class="spinner"></ha-circular-progress>
<div class="controls">
<ha-icon class="fullscreen" icon="mdi:fullscreen"></ha-icon>
<ha-icon class="screenshot" icon="mdi:floppy"></ha-icon>
<ha-icon class="pictureinpicture" icon="mdi:picture-in-picture-bottom-right"></ha-icon>
<span class="stream">${this.streamName}</span>
<span class="space"></span>
<ha-icon class="play" icon="mdi:play"></ha-icon>
<ha-icon class="volume" icon="mdi:volume-high"></ha-icon>
</div>
</div>
`);
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', `
<style>
.shortcuts {
position: absolute;
top: 5px;
left: 5px;
}
</style>
`);
card.insertAdjacentHTML('beforeend', '<div class="shortcuts"></div>');
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) => `
<ha-icon data-index="${index}" icon="${value.icon}" title="${value.name}"></ha-icon>
`).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];
+215
View File
@@ -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()
@@ -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
+224
View File
@@ -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()
+317
View File
@@ -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,
)
+521
View File
@@ -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<kind>{})(.|\n)+?a=(?P<direction>{})(\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,
)
+391
View File
@@ -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)
+182
View File
@@ -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."""
+28
View File
@@ -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"
+196
View File
@@ -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()
+152
View File
@@ -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()
+493
View File
@@ -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
+270
View File
@@ -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"]}
+18
View File
@@ -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"
}
+150
View File
@@ -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()
+626
View File
@@ -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
+142
View File
@@ -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,
)
)
+77
View File
@@ -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."
}
}
}
+730
View File
@@ -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,
)
)
@@ -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
@@ -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."
}
}
}
@@ -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"
}
}
}
}
}
@@ -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"
}
}
}
}
}
@@ -0,0 +1,22 @@
{
"config": {
"abort": {
"already_configured": "Устройство уже сконфигурировано"
},
"error": {
"cannot_connect": "Не могу подключиться",
"invalid_auth": "Ошибка автризации",
"unknown": "Неизвестная ошибка"
},
"step": {
"user": {
"data": {
"host": "Хост",
"password": "Пароль",
"username": "Логин"
}
}
}
},
"title": "Интеграция Wyze Home Assistant"
}
+116
View File
@@ -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