Added Wyze
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
+1
-1
@@ -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}
|
||||||
@@ -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)
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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},
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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": "ہاس سرور کے لیے دستی بیس یو آر ایل"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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];
|
||||||
|
|
||||||
@@ -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()
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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."""
|
||||||
@@ -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"
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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"]}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user