* Remove unnecessary exception re-wraps * Preserve exception chains on re-raise We slap "from cause" to almost all possible cases here. In some cases it could conceivably be better to do "from None" if we really want to hide the cause. However those should be in the minority, and "from cause" should be an improvement over the corresponding raise without a "from" in all cases anyway. The only case where we raise from None here is in plex, where the exception for an original invalid SSL cert is not the root cause for failure to validate a newly fetched one. Follow local convention on exception variable names if there is a consistent one, otherwise `err` to match with majority of codebase. * Fix mistaken re-wrap in homematicip_cloud/hap.py Missed the difference between HmipConnectionError and HmipcConnectionError. * Do not hide original error on plex new cert validation error Original is not the cause for the new one, but showing old in the traceback is useful nevertheless.
478 lines
15 KiB
Python
478 lines
15 KiB
Python
"""Support for the Philips Hue lights."""
|
|
from datetime import timedelta
|
|
from functools import partial
|
|
import logging
|
|
import random
|
|
|
|
import aiohue
|
|
import async_timeout
|
|
|
|
from homeassistant.components.light import (
|
|
ATTR_BRIGHTNESS,
|
|
ATTR_COLOR_TEMP,
|
|
ATTR_EFFECT,
|
|
ATTR_FLASH,
|
|
ATTR_HS_COLOR,
|
|
ATTR_TRANSITION,
|
|
EFFECT_COLORLOOP,
|
|
EFFECT_RANDOM,
|
|
FLASH_LONG,
|
|
FLASH_SHORT,
|
|
SUPPORT_BRIGHTNESS,
|
|
SUPPORT_COLOR,
|
|
SUPPORT_COLOR_TEMP,
|
|
SUPPORT_EFFECT,
|
|
SUPPORT_FLASH,
|
|
SUPPORT_TRANSITION,
|
|
LightEntity,
|
|
)
|
|
from homeassistant.core import callback
|
|
from homeassistant.exceptions import PlatformNotReady
|
|
from homeassistant.helpers.debounce import Debouncer
|
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
|
from homeassistant.util import color
|
|
|
|
from .const import DOMAIN as HUE_DOMAIN, REQUEST_REFRESH_DELAY
|
|
from .helpers import remove_devices
|
|
|
|
SCAN_INTERVAL = timedelta(seconds=5)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
SUPPORT_HUE_ON_OFF = SUPPORT_FLASH | SUPPORT_TRANSITION
|
|
SUPPORT_HUE_DIMMABLE = SUPPORT_HUE_ON_OFF | SUPPORT_BRIGHTNESS
|
|
SUPPORT_HUE_COLOR_TEMP = SUPPORT_HUE_DIMMABLE | SUPPORT_COLOR_TEMP
|
|
SUPPORT_HUE_COLOR = SUPPORT_HUE_DIMMABLE | SUPPORT_EFFECT | SUPPORT_COLOR
|
|
SUPPORT_HUE_EXTENDED = SUPPORT_HUE_COLOR_TEMP | SUPPORT_HUE_COLOR
|
|
|
|
SUPPORT_HUE = {
|
|
"Extended color light": SUPPORT_HUE_EXTENDED,
|
|
"Color light": SUPPORT_HUE_COLOR,
|
|
"Dimmable light": SUPPORT_HUE_DIMMABLE,
|
|
"On/Off plug-in unit": SUPPORT_HUE_ON_OFF,
|
|
"Color temperature light": SUPPORT_HUE_COLOR_TEMP,
|
|
}
|
|
|
|
ATTR_IS_HUE_GROUP = "is_hue_group"
|
|
GAMUT_TYPE_UNAVAILABLE = "None"
|
|
# Minimum Hue Bridge API version to support groups
|
|
# 1.4.0 introduced extended group info
|
|
# 1.12 introduced the state object for groups
|
|
# 1.13 introduced "any_on" to group state objects
|
|
GROUP_MIN_API_VERSION = (1, 13, 0)
|
|
|
|
|
|
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
|
"""Old way of setting up Hue lights.
|
|
|
|
Can only be called when a user accidentally mentions hue platform in their
|
|
config. But even in that case it would have been ignored.
|
|
"""
|
|
|
|
|
|
def create_light(item_class, coordinator, bridge, is_group, api, item_id):
|
|
"""Create the light."""
|
|
if is_group:
|
|
supported_features = 0
|
|
for light_id in api[item_id].lights:
|
|
if light_id not in bridge.api.lights:
|
|
continue
|
|
light = bridge.api.lights[light_id]
|
|
supported_features |= SUPPORT_HUE.get(light.type, SUPPORT_HUE_EXTENDED)
|
|
supported_features = supported_features or SUPPORT_HUE_EXTENDED
|
|
else:
|
|
supported_features = SUPPORT_HUE.get(api[item_id].type, SUPPORT_HUE_EXTENDED)
|
|
return item_class(coordinator, bridge, is_group, api[item_id], supported_features)
|
|
|
|
|
|
async def async_setup_entry(hass, config_entry, async_add_entities):
|
|
"""Set up the Hue lights from a config entry."""
|
|
bridge = hass.data[HUE_DOMAIN][config_entry.entry_id]
|
|
|
|
light_coordinator = DataUpdateCoordinator(
|
|
hass,
|
|
_LOGGER,
|
|
name="light",
|
|
update_method=partial(async_safe_fetch, bridge, bridge.api.lights.update),
|
|
update_interval=SCAN_INTERVAL,
|
|
request_refresh_debouncer=Debouncer(
|
|
bridge.hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True
|
|
),
|
|
)
|
|
|
|
# First do a refresh to see if we can reach the hub.
|
|
# Otherwise we will declare not ready.
|
|
await light_coordinator.async_refresh()
|
|
|
|
if not light_coordinator.last_update_success:
|
|
raise PlatformNotReady
|
|
|
|
update_lights = partial(
|
|
async_update_items,
|
|
bridge,
|
|
bridge.api.lights,
|
|
{},
|
|
async_add_entities,
|
|
partial(create_light, HueLight, light_coordinator, bridge, False),
|
|
)
|
|
|
|
# We add a listener after fetching the data, so manually trigger listener
|
|
bridge.reset_jobs.append(light_coordinator.async_add_listener(update_lights))
|
|
update_lights()
|
|
|
|
api_version = tuple(int(v) for v in bridge.api.config.apiversion.split("."))
|
|
|
|
allow_groups = bridge.allow_groups
|
|
if allow_groups and api_version < GROUP_MIN_API_VERSION:
|
|
_LOGGER.warning("Please update your Hue bridge to support groups")
|
|
allow_groups = False
|
|
|
|
if not allow_groups:
|
|
return
|
|
|
|
group_coordinator = DataUpdateCoordinator(
|
|
hass,
|
|
_LOGGER,
|
|
name="group",
|
|
update_method=partial(async_safe_fetch, bridge, bridge.api.groups.update),
|
|
update_interval=SCAN_INTERVAL,
|
|
request_refresh_debouncer=Debouncer(
|
|
bridge.hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=True
|
|
),
|
|
)
|
|
|
|
update_groups = partial(
|
|
async_update_items,
|
|
bridge,
|
|
bridge.api.groups,
|
|
{},
|
|
async_add_entities,
|
|
partial(create_light, HueLight, group_coordinator, bridge, True),
|
|
)
|
|
|
|
bridge.reset_jobs.append(group_coordinator.async_add_listener(update_groups))
|
|
await group_coordinator.async_refresh()
|
|
|
|
|
|
async def async_safe_fetch(bridge, fetch_method):
|
|
"""Safely fetch data."""
|
|
try:
|
|
with async_timeout.timeout(4):
|
|
return await bridge.async_request_call(fetch_method)
|
|
except aiohue.Unauthorized as err:
|
|
await bridge.handle_unauthorized_error()
|
|
raise UpdateFailed("Unauthorized") from err
|
|
except (aiohue.AiohueException,) as err:
|
|
raise UpdateFailed(f"Hue error: {err}") from err
|
|
|
|
|
|
@callback
|
|
def async_update_items(bridge, api, current, async_add_entities, create_item):
|
|
"""Update items."""
|
|
new_items = []
|
|
|
|
for item_id in api:
|
|
if item_id in current:
|
|
continue
|
|
|
|
current[item_id] = create_item(api, item_id)
|
|
new_items.append(current[item_id])
|
|
|
|
bridge.hass.async_create_task(remove_devices(bridge, api, current))
|
|
|
|
if new_items:
|
|
async_add_entities(new_items)
|
|
|
|
|
|
def hue_brightness_to_hass(value):
|
|
"""Convert hue brightness 1..254 to hass format 0..255."""
|
|
return min(255, round((value / 254) * 255))
|
|
|
|
|
|
def hass_to_hue_brightness(value):
|
|
"""Convert hass brightness 0..255 to hue 1..254 scale."""
|
|
return max(1, round((value / 255) * 254))
|
|
|
|
|
|
class HueLight(LightEntity):
|
|
"""Representation of a Hue light."""
|
|
|
|
def __init__(self, coordinator, bridge, is_group, light, supported_features):
|
|
"""Initialize the light."""
|
|
self.light = light
|
|
self.coordinator = coordinator
|
|
self.bridge = bridge
|
|
self.is_group = is_group
|
|
self._supported_features = supported_features
|
|
|
|
if is_group:
|
|
self.is_osram = False
|
|
self.is_philips = False
|
|
self.is_innr = False
|
|
self.gamut_typ = GAMUT_TYPE_UNAVAILABLE
|
|
self.gamut = None
|
|
else:
|
|
self.is_osram = light.manufacturername == "OSRAM"
|
|
self.is_philips = light.manufacturername == "Philips"
|
|
self.is_innr = light.manufacturername == "innr"
|
|
self.gamut_typ = self.light.colorgamuttype
|
|
self.gamut = self.light.colorgamut
|
|
_LOGGER.debug("Color gamut of %s: %s", self.name, str(self.gamut))
|
|
if self.light.swupdatestate == "readytoinstall":
|
|
err = (
|
|
"Please check for software updates of the %s "
|
|
"bulb in the Philips Hue App."
|
|
)
|
|
_LOGGER.warning(err, self.name)
|
|
if self.gamut:
|
|
if not color.check_valid_gamut(self.gamut):
|
|
err = "Color gamut of %s: %s, not valid, setting gamut to None."
|
|
_LOGGER.warning(err, self.name, str(self.gamut))
|
|
self.gamut_typ = GAMUT_TYPE_UNAVAILABLE
|
|
self.gamut = None
|
|
|
|
@property
|
|
def unique_id(self):
|
|
"""Return the unique ID of this Hue light."""
|
|
return self.light.uniqueid
|
|
|
|
@property
|
|
def should_poll(self):
|
|
"""No polling required."""
|
|
return False
|
|
|
|
@property
|
|
def device_id(self):
|
|
"""Return the ID of this Hue light."""
|
|
return self.unique_id
|
|
|
|
@property
|
|
def name(self):
|
|
"""Return the name of the Hue light."""
|
|
return self.light.name
|
|
|
|
@property
|
|
def brightness(self):
|
|
"""Return the brightness of this light between 0..255."""
|
|
if self.is_group:
|
|
bri = self.light.action.get("bri")
|
|
else:
|
|
bri = self.light.state.get("bri")
|
|
|
|
if bri is None:
|
|
return bri
|
|
|
|
return hue_brightness_to_hass(bri)
|
|
|
|
@property
|
|
def _color_mode(self):
|
|
"""Return the hue color mode."""
|
|
if self.is_group:
|
|
return self.light.action.get("colormode")
|
|
return self.light.state.get("colormode")
|
|
|
|
@property
|
|
def hs_color(self):
|
|
"""Return the hs color value."""
|
|
mode = self._color_mode
|
|
source = self.light.action if self.is_group else self.light.state
|
|
|
|
if mode in ("xy", "hs") and "xy" in source:
|
|
return color.color_xy_to_hs(*source["xy"], self.gamut)
|
|
|
|
return None
|
|
|
|
@property
|
|
def color_temp(self):
|
|
"""Return the CT color value."""
|
|
# Don't return color temperature unless in color temperature mode
|
|
if self._color_mode != "ct":
|
|
return None
|
|
|
|
if self.is_group:
|
|
return self.light.action.get("ct")
|
|
return self.light.state.get("ct")
|
|
|
|
@property
|
|
def min_mireds(self):
|
|
"""Return the coldest color_temp that this light supports."""
|
|
if self.is_group:
|
|
return super().min_mireds
|
|
|
|
min_mireds = self.light.controlcapabilities.get("ct", {}).get("min")
|
|
|
|
# We filter out '0' too, which can be incorrectly reported by 3rd party buls
|
|
if not min_mireds:
|
|
return super().min_mireds
|
|
|
|
return min_mireds
|
|
|
|
@property
|
|
def max_mireds(self):
|
|
"""Return the warmest color_temp that this light supports."""
|
|
if self.is_group:
|
|
return super().max_mireds
|
|
|
|
max_mireds = self.light.controlcapabilities.get("ct", {}).get("max")
|
|
|
|
if not max_mireds:
|
|
return super().max_mireds
|
|
|
|
return max_mireds
|
|
|
|
@property
|
|
def is_on(self):
|
|
"""Return true if device is on."""
|
|
if self.is_group:
|
|
return self.light.state["any_on"]
|
|
return self.light.state["on"]
|
|
|
|
@property
|
|
def available(self):
|
|
"""Return if light is available."""
|
|
return self.coordinator.last_update_success and (
|
|
self.is_group
|
|
or self.bridge.allow_unreachable
|
|
or self.light.state["reachable"]
|
|
)
|
|
|
|
@property
|
|
def supported_features(self):
|
|
"""Flag supported features."""
|
|
return self._supported_features
|
|
|
|
@property
|
|
def effect(self):
|
|
"""Return the current effect."""
|
|
return self.light.state.get("effect", None)
|
|
|
|
@property
|
|
def effect_list(self):
|
|
"""Return the list of supported effects."""
|
|
if self.is_osram:
|
|
return [EFFECT_RANDOM]
|
|
return [EFFECT_COLORLOOP, EFFECT_RANDOM]
|
|
|
|
@property
|
|
def device_info(self):
|
|
"""Return the device info."""
|
|
if self.light.type in ("LightGroup", "Room", "Luminaire", "LightSource"):
|
|
return None
|
|
|
|
return {
|
|
"identifiers": {(HUE_DOMAIN, self.device_id)},
|
|
"name": self.name,
|
|
"manufacturer": self.light.manufacturername,
|
|
# productname added in Hue Bridge API 1.24
|
|
# (published 03/05/2018)
|
|
"model": self.light.productname or self.light.modelid,
|
|
# Not yet exposed as properties in aiohue
|
|
"sw_version": self.light.raw["swversion"],
|
|
"via_device": (HUE_DOMAIN, self.bridge.api.config.bridgeid),
|
|
}
|
|
|
|
async def async_added_to_hass(self):
|
|
"""When entity is added to hass."""
|
|
self.async_on_remove(
|
|
self.coordinator.async_add_listener(self.async_write_ha_state)
|
|
)
|
|
|
|
async def async_turn_on(self, **kwargs):
|
|
"""Turn the specified or all lights on."""
|
|
command = {"on": True}
|
|
|
|
if ATTR_TRANSITION in kwargs:
|
|
command["transitiontime"] = int(kwargs[ATTR_TRANSITION] * 10)
|
|
|
|
if ATTR_HS_COLOR in kwargs:
|
|
if self.is_osram:
|
|
command["hue"] = int(kwargs[ATTR_HS_COLOR][0] / 360 * 65535)
|
|
command["sat"] = int(kwargs[ATTR_HS_COLOR][1] / 100 * 255)
|
|
else:
|
|
# Philips hue bulb models respond differently to hue/sat
|
|
# requests, so we convert to XY first to ensure a consistent
|
|
# color.
|
|
xy_color = color.color_hs_to_xy(*kwargs[ATTR_HS_COLOR], self.gamut)
|
|
command["xy"] = xy_color
|
|
elif ATTR_COLOR_TEMP in kwargs:
|
|
temp = kwargs[ATTR_COLOR_TEMP]
|
|
command["ct"] = max(self.min_mireds, min(temp, self.max_mireds))
|
|
|
|
if ATTR_BRIGHTNESS in kwargs:
|
|
command["bri"] = hass_to_hue_brightness(kwargs[ATTR_BRIGHTNESS])
|
|
|
|
flash = kwargs.get(ATTR_FLASH)
|
|
|
|
if flash == FLASH_LONG:
|
|
command["alert"] = "lselect"
|
|
del command["on"]
|
|
elif flash == FLASH_SHORT:
|
|
command["alert"] = "select"
|
|
del command["on"]
|
|
elif not self.is_innr:
|
|
command["alert"] = "none"
|
|
|
|
if ATTR_EFFECT in kwargs:
|
|
effect = kwargs[ATTR_EFFECT]
|
|
if effect == EFFECT_COLORLOOP:
|
|
command["effect"] = "colorloop"
|
|
elif effect == EFFECT_RANDOM:
|
|
command["hue"] = random.randrange(0, 65535)
|
|
command["sat"] = random.randrange(150, 254)
|
|
else:
|
|
command["effect"] = "none"
|
|
|
|
if self.is_group:
|
|
await self.bridge.async_request_call(
|
|
partial(self.light.set_action, **command)
|
|
)
|
|
else:
|
|
await self.bridge.async_request_call(
|
|
partial(self.light.set_state, **command)
|
|
)
|
|
|
|
await self.coordinator.async_request_refresh()
|
|
|
|
async def async_turn_off(self, **kwargs):
|
|
"""Turn the specified or all lights off."""
|
|
command = {"on": False}
|
|
|
|
if ATTR_TRANSITION in kwargs:
|
|
command["transitiontime"] = int(kwargs[ATTR_TRANSITION] * 10)
|
|
|
|
flash = kwargs.get(ATTR_FLASH)
|
|
|
|
if flash == FLASH_LONG:
|
|
command["alert"] = "lselect"
|
|
del command["on"]
|
|
elif flash == FLASH_SHORT:
|
|
command["alert"] = "select"
|
|
del command["on"]
|
|
elif not self.is_innr:
|
|
command["alert"] = "none"
|
|
|
|
if self.is_group:
|
|
await self.bridge.async_request_call(
|
|
partial(self.light.set_action, **command)
|
|
)
|
|
else:
|
|
await self.bridge.async_request_call(
|
|
partial(self.light.set_state, **command)
|
|
)
|
|
|
|
await self.coordinator.async_request_refresh()
|
|
|
|
async def async_update(self):
|
|
"""Update the entity.
|
|
|
|
Only used by the generic entity update service.
|
|
"""
|
|
await self.coordinator.async_request_refresh()
|
|
|
|
@property
|
|
def device_state_attributes(self):
|
|
"""Return the device state attributes."""
|
|
attributes = {}
|
|
if self.is_group:
|
|
attributes[ATTR_IS_HUE_GROUP] = self.is_group
|
|
return attributes
|