494 lines
17 KiB
Python
494 lines
17 KiB
Python
"""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
|