Files
Home-Assistant/custom_components/wyzeapi/light.py
T
2026-07-08 10:43:39 -04:00

493 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
r, g, b = color_util.color_temperature_to_rgb(color_temp)
self._bulb.color = color_util.color_rgb_to_hex(int(r), int(g), int(b))
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