"""Support for the Philips Hue lights.""" import asyncio from datetime import timedelta import logging import random from time import monotonic import aiohue import async_timeout from homeassistant.components import hue 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, Light, ) from homeassistant.util import color 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. """ pass 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.data["host"]] cur_lights = {} cur_groups = {} 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 # Hue updates all lights via a single API call. # # If we call a service to update 2 lights, we only want the API to be # called once. # # The throttle decorator will return right away if a call is currently # in progress. This means that if we are updating 2 lights, the first one # is in the update method, the second one will skip it and assume the # update went through and updates it's data, not good! # # The current mechanism will make sure that all lights will wait till # the update call is done before writing their data to the state machine. # # An alternative approach would be to disable automatic polling by Home # Assistant and take control ourselves. This works great for polling as now # we trigger from 1 time update an update to all entities. However it gets # tricky from inside async_turn_on and async_turn_off. # # If automatic polling is enabled, Home Assistant will call the entity # update method after it is done calling all the services. This means that # when we update, we know all commands have been processed. If we trigger # the update from inside async_turn_on, the update will not capture the # changes to the second entity until the next polling update because the # throttle decorator will prevent the call. progress = None light_progress = set() group_progress = set() async def request_update(is_group, object_id): """Request an update. We will only make 1 request to the server for updating at a time. If a request is in progress, we will join the request that is in progress. This approach is possible because should_poll=True. That means that Home Assistant will ask lights for updates during a polling cycle or after it has called a service. We keep track of the lights that are waiting for the request to finish. When new data comes in, we'll trigger an update for all non-waiting lights. This covers the case where a service is called to enable 2 lights but in the meanwhile some other light has changed too. """ nonlocal progress progress_set = group_progress if is_group else light_progress progress_set.add(object_id) if progress is not None: return await progress progress = asyncio.ensure_future(update_bridge()) result = await progress progress = None light_progress.clear() group_progress.clear() return result async def update_bridge(): """Update the values of the bridge. Will update lights and, if enabled, groups from the bridge. """ tasks = [] tasks.append( async_update_items( hass, config_entry, bridge, async_add_entities, request_update, False, cur_lights, light_progress, ) ) if allow_groups: tasks.append( async_update_items( hass, config_entry, bridge, async_add_entities, request_update, True, cur_groups, group_progress, ) ) await asyncio.wait(tasks) await update_bridge() async def async_update_items( hass, config_entry, bridge, async_add_entities, request_bridge_update, is_group, current, progress_waiting, ): """Update either groups or lights from the bridge.""" if not bridge.authorized: return if is_group: api_type = "group" api = bridge.api.groups else: api_type = "light" api = bridge.api.lights try: start = monotonic() with async_timeout.timeout(4): await bridge.async_request_call(api.update()) except aiohue.Unauthorized: await bridge.handle_unauthorized_error() return except (asyncio.TimeoutError, aiohue.AiohueException) as err: _LOGGER.debug("Failed to fetch %s: %s", api_type, err) if not bridge.available: return _LOGGER.error("Unable to reach bridge %s (%s)", bridge.host, err) bridge.available = False for item_id, item in current.items(): if item_id not in progress_waiting: item.async_schedule_update_ha_state() return finally: _LOGGER.debug( "Finished %s request in %.3f seconds", api_type, monotonic() - start ) if not bridge.available: _LOGGER.info("Reconnected to bridge %s", bridge.host) bridge.available = True new_items = [] for item_id in api: if item_id not in current: current[item_id] = HueLight( api[item_id], request_bridge_update, bridge, is_group ) new_items.append(current[item_id]) elif item_id not in progress_waiting: current[item_id].async_schedule_update_ha_state() await remove_devices(hass, config_entry, api, current) if new_items: async_add_entities(new_items) class HueLight(Light): """Representation of a Hue light.""" def __init__(self, light, request_bridge_update, bridge, is_group=False): """Initialize the light.""" self.light = light self.async_request_bridge_update = request_bridge_update self.bridge = bridge self.is_group = is_group if is_group: self.is_osram = False self.is_philips = False self.gamut_typ = GAMUT_TYPE_UNAVAILABLE self.gamut = None else: self.is_osram = light.manufacturername == "OSRAM" self.is_philips = light.manufacturername == "Philips" 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 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: return self.light.action.get("bri") return self.light.state.get("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 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.bridge.available and self.bridge.authorized and ( self.is_group or self.bridge.allow_unreachable or self.light.state["reachable"] ) ) @property def supported_features(self): """Flag supported features.""" return SUPPORT_HUE.get(self.light.type, SUPPORT_HUE_EXTENDED) @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_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"] = 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"] else: 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(self.light.set_action(**command)) else: await self.bridge.async_request_call(self.light.set_state(**command)) 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"] else: command["alert"] = "none" if self.is_group: await self.bridge.async_request_call(self.light.set_action(**command)) else: await self.bridge.async_request_call(self.light.set_state(**command)) async def async_update(self): """Synchronize state with bridge.""" await self.async_request_bridge_update(self.is_group, self.light.id) @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