Refactor Hue: phue -> aiohue (#13043)
* phue -> aiohue * Clean up * Fix config * Address comments * Typo * Fix rebase error * Mark light as unavailable when bridge is disconnected * Tests * Make Throttle work with double delay and async * Rework update logic * Don't resolve host to IP * Clarify comment * No longer do unnecessary updates * Add more doc * Another comment update * Wrap up tests * Lint * Fix tests * PyLint does not like mix 'n match async and coroutine * Lint * Update aiohue to 1.2 * Lint * Fix await MagicMock
This commit is contained in:
parent
d78e75db66
commit
5a9013cda5
20 changed files with 1289 additions and 1485 deletions
|
@ -8,31 +8,23 @@ import asyncio
|
|||
from datetime import timedelta
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import socket
|
||||
|
||||
import voluptuous as vol
|
||||
import async_timeout
|
||||
|
||||
import homeassistant.components.hue as hue
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_RGB_COLOR,
|
||||
ATTR_TRANSITION, ATTR_XY_COLOR, EFFECT_COLORLOOP, EFFECT_RANDOM,
|
||||
FLASH_LONG, FLASH_SHORT, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS,
|
||||
FLASH_LONG, FLASH_SHORT, SUPPORT_BRIGHTNESS,
|
||||
SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_RGB_COLOR,
|
||||
SUPPORT_TRANSITION, SUPPORT_XY_COLOR, Light)
|
||||
from homeassistant.const import CONF_FILENAME, CONF_HOST, DEVICE_DEFAULT_NAME
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import homeassistant.util as util
|
||||
from homeassistant.util import yaml
|
||||
import homeassistant.util.color as color_util
|
||||
|
||||
DEPENDENCIES = ['hue']
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
|
||||
MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100)
|
||||
|
||||
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)
|
||||
|
@ -48,244 +40,232 @@ SUPPORT_HUE = {
|
|||
'Color temperature light': SUPPORT_HUE_COLOR_TEMP
|
||||
}
|
||||
|
||||
ATTR_EMULATED_HUE_HIDDEN = 'emulated_hue_hidden'
|
||||
ATTR_IS_HUE_GROUP = 'is_hue_group'
|
||||
|
||||
# Legacy configuration, will be removed in 0.60
|
||||
CONF_ALLOW_UNREACHABLE = 'allow_unreachable'
|
||||
DEFAULT_ALLOW_UNREACHABLE = False
|
||||
CONF_ALLOW_IN_EMULATED_HUE = 'allow_in_emulated_hue'
|
||||
DEFAULT_ALLOW_IN_EMULATED_HUE = True
|
||||
CONF_ALLOW_HUE_GROUPS = 'allow_hue_groups'
|
||||
DEFAULT_ALLOW_HUE_GROUPS = True
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_HOST): cv.string,
|
||||
vol.Optional(CONF_ALLOW_UNREACHABLE): cv.boolean,
|
||||
vol.Optional(CONF_FILENAME): cv.string,
|
||||
vol.Optional(CONF_ALLOW_IN_EMULATED_HUE): cv.boolean,
|
||||
vol.Optional(CONF_ALLOW_HUE_GROUPS,
|
||||
default=DEFAULT_ALLOW_HUE_GROUPS): cv.boolean,
|
||||
})
|
||||
|
||||
MIGRATION_ID = 'light_hue_config_migration'
|
||||
MIGRATION_TITLE = 'Philips Hue Configuration Migration'
|
||||
MIGRATION_INSTRUCTIONS = """
|
||||
Configuration for the Philips Hue component has changed; action required.
|
||||
|
||||
You have configured at least one bridge:
|
||||
|
||||
hue:
|
||||
{config}
|
||||
|
||||
This configuration is deprecated, please check the
|
||||
[Hue component](https://home-assistant.io/components/hue/) page for more
|
||||
information.
|
||||
"""
|
||||
|
||||
SIGNAL_CALLBACK = 'hue_light_callback_{}_{}'
|
||||
# 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)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
async def async_setup_platform(hass, config, async_add_devices,
|
||||
discovery_info=None):
|
||||
"""Set up the Hue lights."""
|
||||
if discovery_info is None or 'bridge_id' not in discovery_info:
|
||||
if discovery_info is None:
|
||||
return
|
||||
|
||||
if config is not None and config:
|
||||
# Legacy configuration, will be removed in 0.60
|
||||
config_str = yaml.dump([config])
|
||||
# Indent so it renders in a fixed-width font
|
||||
config_str = re.sub('(?m)^', ' ', config_str)
|
||||
hass.components.persistent_notification.async_create(
|
||||
MIGRATION_INSTRUCTIONS.format(config=config_str),
|
||||
title=MIGRATION_TITLE,
|
||||
notification_id=MIGRATION_ID)
|
||||
bridge = hass.data[hue.DOMAIN][discovery_info['host']]
|
||||
cur_lights = {}
|
||||
cur_groups = {}
|
||||
|
||||
bridge_id = discovery_info['bridge_id']
|
||||
bridge = hass.data[hue.DOMAIN][bridge_id]
|
||||
unthrottled_update_lights(hass, bridge, add_devices)
|
||||
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, bridge, async_add_devices, request_update,
|
||||
False, cur_lights, light_progress
|
||||
))
|
||||
|
||||
if allow_groups:
|
||||
tasks.append(async_update_items(
|
||||
hass, bridge, async_add_devices, request_update,
|
||||
True, cur_groups, group_progress
|
||||
))
|
||||
|
||||
await asyncio.wait(tasks)
|
||||
|
||||
await update_bridge()
|
||||
|
||||
|
||||
@util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
|
||||
def update_lights(hass, bridge, add_devices):
|
||||
"""Update the Hue light objects with latest info from the bridge."""
|
||||
return unthrottled_update_lights(hass, bridge, add_devices)
|
||||
async def async_update_items(hass, bridge, async_add_devices,
|
||||
request_bridge_update, is_group, current,
|
||||
progress_waiting):
|
||||
"""Update either groups or lights from the bridge."""
|
||||
import aiohue
|
||||
|
||||
|
||||
def unthrottled_update_lights(hass, bridge, add_devices):
|
||||
"""Update the lights (Internal version of update_lights)."""
|
||||
import phue
|
||||
|
||||
if not bridge.configured:
|
||||
return
|
||||
if is_group:
|
||||
api = bridge.api.groups
|
||||
else:
|
||||
api = bridge.api.lights
|
||||
|
||||
try:
|
||||
api = bridge.get_api()
|
||||
except phue.PhueRequestTimeout:
|
||||
_LOGGER.warning("Timeout trying to reach the bridge")
|
||||
bridge.available = False
|
||||
return
|
||||
except ConnectionRefusedError:
|
||||
_LOGGER.error("The bridge refused the connection")
|
||||
bridge.available = False
|
||||
return
|
||||
except socket.error:
|
||||
# socket.error when we cannot reach Hue
|
||||
_LOGGER.exception("Cannot reach the bridge")
|
||||
with async_timeout.timeout(4):
|
||||
await api.update()
|
||||
except (asyncio.TimeoutError, aiohue.AiohueException):
|
||||
if not bridge.available:
|
||||
return
|
||||
|
||||
_LOGGER.error('Unable to reach bridge %s', bridge.host)
|
||||
bridge.available = False
|
||||
|
||||
for light_id, light in current.items():
|
||||
if light_id not in progress_waiting:
|
||||
light.async_schedule_update_ha_state()
|
||||
|
||||
return
|
||||
|
||||
bridge.available = True
|
||||
if not bridge.available:
|
||||
_LOGGER.info('Reconnected to bridge %s', bridge.host)
|
||||
bridge.available = True
|
||||
|
||||
new_lights = process_lights(
|
||||
hass, api, bridge,
|
||||
lambda **kw: update_lights(hass, bridge, add_devices, **kw))
|
||||
if bridge.allow_hue_groups:
|
||||
new_lightgroups = process_groups(
|
||||
hass, api, bridge,
|
||||
lambda **kw: update_lights(hass, bridge, add_devices, **kw))
|
||||
new_lights.extend(new_lightgroups)
|
||||
new_lights = []
|
||||
|
||||
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_lights.append(current[item_id])
|
||||
elif item_id not in progress_waiting:
|
||||
current[item_id].async_schedule_update_ha_state()
|
||||
|
||||
if new_lights:
|
||||
add_devices(new_lights)
|
||||
|
||||
|
||||
def process_lights(hass, api, bridge, update_lights_cb):
|
||||
"""Set up HueLight objects for all lights."""
|
||||
api_lights = api.get('lights')
|
||||
|
||||
if not isinstance(api_lights, dict):
|
||||
_LOGGER.error("Got unexpected result from Hue API")
|
||||
return []
|
||||
|
||||
new_lights = []
|
||||
|
||||
for light_id, info in api_lights.items():
|
||||
if light_id not in bridge.lights:
|
||||
bridge.lights[light_id] = HueLight(
|
||||
int(light_id), info, bridge,
|
||||
update_lights_cb,
|
||||
bridge.allow_unreachable,
|
||||
bridge.allow_in_emulated_hue)
|
||||
new_lights.append(bridge.lights[light_id])
|
||||
else:
|
||||
bridge.lights[light_id].info = info
|
||||
hass.helpers.dispatcher.dispatcher_send(
|
||||
SIGNAL_CALLBACK.format(
|
||||
bridge.bridge_id,
|
||||
bridge.lights[light_id].light_id))
|
||||
|
||||
return new_lights
|
||||
|
||||
|
||||
def process_groups(hass, api, bridge, update_lights_cb):
|
||||
"""Set up HueLight objects for all groups."""
|
||||
api_groups = api.get('groups')
|
||||
|
||||
if not isinstance(api_groups, dict):
|
||||
_LOGGER.error('Got unexpected result from Hue API')
|
||||
return []
|
||||
|
||||
new_lights = []
|
||||
|
||||
for lightgroup_id, info in api_groups.items():
|
||||
if 'state' not in info:
|
||||
_LOGGER.warning(
|
||||
"Group info does not contain state. Please update your hub")
|
||||
return []
|
||||
|
||||
if lightgroup_id not in bridge.lightgroups:
|
||||
bridge.lightgroups[lightgroup_id] = HueLight(
|
||||
int(lightgroup_id), info, bridge,
|
||||
update_lights_cb,
|
||||
bridge.allow_unreachable,
|
||||
bridge.allow_in_emulated_hue, True)
|
||||
new_lights.append(bridge.lightgroups[lightgroup_id])
|
||||
else:
|
||||
bridge.lightgroups[lightgroup_id].info = info
|
||||
hass.helpers.dispatcher.dispatcher_send(
|
||||
SIGNAL_CALLBACK.format(
|
||||
bridge.bridge_id,
|
||||
bridge.lightgroups[lightgroup_id].light_id))
|
||||
|
||||
return new_lights
|
||||
async_add_devices(new_lights)
|
||||
|
||||
|
||||
class HueLight(Light):
|
||||
"""Representation of a Hue light."""
|
||||
|
||||
def __init__(self, light_id, info, bridge, update_lights_cb,
|
||||
allow_unreachable, allow_in_emulated_hue, is_group=False):
|
||||
def __init__(self, light, request_bridge_update, bridge, is_group=False):
|
||||
"""Initialize the light."""
|
||||
self.light_id = light_id
|
||||
self.info = info
|
||||
self.light = light
|
||||
self.async_request_bridge_update = request_bridge_update
|
||||
self.bridge = bridge
|
||||
self.update_lights = update_lights_cb
|
||||
self.allow_unreachable = allow_unreachable
|
||||
self.is_group = is_group
|
||||
self.allow_in_emulated_hue = allow_in_emulated_hue
|
||||
|
||||
if is_group:
|
||||
self._command_func = self.bridge.set_group
|
||||
self.is_osram = False
|
||||
self.is_philips = False
|
||||
else:
|
||||
self._command_func = self.bridge.set_light
|
||||
self.is_osram = light.manufacturername == 'OSRAM'
|
||||
self.is_philips = light.manufacturername == 'Philips'
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return the ID of this Hue light."""
|
||||
return self.info.get('uniqueid')
|
||||
return self.light.uniqueid
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the Hue light."""
|
||||
return self.info.get('name', DEVICE_DEFAULT_NAME)
|
||||
return self.light.name
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
if self.is_group:
|
||||
return self.info['action'].get('bri')
|
||||
return self.info['state'].get('bri')
|
||||
return self.light.action.get('bri')
|
||||
return self.light.state.get('bri')
|
||||
|
||||
@property
|
||||
def xy_color(self):
|
||||
"""Return the XY color value."""
|
||||
if self.is_group:
|
||||
return self.info['action'].get('xy')
|
||||
return self.info['state'].get('xy')
|
||||
return self.light.action.get('xy')
|
||||
return self.light.state.get('xy')
|
||||
|
||||
@property
|
||||
def color_temp(self):
|
||||
"""Return the CT color value."""
|
||||
if self.is_group:
|
||||
return self.info['action'].get('ct')
|
||||
return self.info['state'].get('ct')
|
||||
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.info['state']['any_on']
|
||||
return self.info['state']['on']
|
||||
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.is_group or
|
||||
self.allow_unreachable or
|
||||
self.info['state']['reachable'])
|
||||
self.bridge.allow_unreachable or
|
||||
self.light.state['reachable'])
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Flag supported features."""
|
||||
return SUPPORT_HUE.get(self.info.get('type'), SUPPORT_HUE_EXTENDED)
|
||||
return SUPPORT_HUE.get(self.light.type, SUPPORT_HUE_EXTENDED)
|
||||
|
||||
@property
|
||||
def effect_list(self):
|
||||
"""Return the list of supported effects."""
|
||||
return [EFFECT_COLORLOOP, EFFECT_RANDOM]
|
||||
|
||||
def turn_on(self, **kwargs):
|
||||
async def async_turn_on(self, **kwargs):
|
||||
"""Turn the specified or all lights on."""
|
||||
command = {'on': True}
|
||||
|
||||
|
@ -293,7 +273,7 @@ class HueLight(Light):
|
|||
command['transitiontime'] = int(kwargs[ATTR_TRANSITION] * 10)
|
||||
|
||||
if ATTR_XY_COLOR in kwargs:
|
||||
if self.info.get('manufacturername') == 'OSRAM':
|
||||
if self.is_osram:
|
||||
color_hue, sat = color_util.color_xy_to_hs(
|
||||
*kwargs[ATTR_XY_COLOR])
|
||||
command['hue'] = color_hue / 360 * 65535
|
||||
|
@ -301,7 +281,7 @@ class HueLight(Light):
|
|||
else:
|
||||
command['xy'] = kwargs[ATTR_XY_COLOR]
|
||||
elif ATTR_RGB_COLOR in kwargs:
|
||||
if self.info.get('manufacturername') == 'OSRAM':
|
||||
if self.is_osram:
|
||||
hsv = color_util.color_RGB_to_hsv(
|
||||
*(int(val) for val in kwargs[ATTR_RGB_COLOR]))
|
||||
command['hue'] = hsv[0] / 360 * 65535
|
||||
|
@ -336,12 +316,15 @@ class HueLight(Light):
|
|||
elif effect == EFFECT_RANDOM:
|
||||
command['hue'] = random.randrange(0, 65535)
|
||||
command['sat'] = random.randrange(150, 254)
|
||||
elif self.info.get('manufacturername') == 'Philips':
|
||||
elif self.is_philips:
|
||||
command['effect'] = 'none'
|
||||
|
||||
self._command_func(self.light_id, command)
|
||||
if self.is_group:
|
||||
await self.light.set_action(**command)
|
||||
else:
|
||||
await self.light.set_state(**command)
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
async def async_turn_off(self, **kwargs):
|
||||
"""Turn the specified or all lights off."""
|
||||
command = {'on': False}
|
||||
|
||||
|
@ -359,27 +342,19 @@ class HueLight(Light):
|
|||
else:
|
||||
command['alert'] = 'none'
|
||||
|
||||
self._command_func(self.light_id, command)
|
||||
if self.is_group:
|
||||
await self.light.set_action(**command)
|
||||
else:
|
||||
await self.light.set_state(**command)
|
||||
|
||||
def update(self):
|
||||
async def async_update(self):
|
||||
"""Synchronize state with bridge."""
|
||||
self.update_lights(no_throttle=True)
|
||||
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 not self.allow_in_emulated_hue:
|
||||
attributes[ATTR_EMULATED_HUE_HIDDEN] = \
|
||||
not self.allow_in_emulated_hue
|
||||
if self.is_group:
|
||||
attributes[ATTR_IS_HUE_GROUP] = self.is_group
|
||||
return attributes
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_added_to_hass(self):
|
||||
"""Register update callback."""
|
||||
dev_id = self.bridge.bridge_id, self.light_id
|
||||
self.hass.helpers.dispatcher.async_dispatcher_connect(
|
||||
SIGNAL_CALLBACK.format(*dev_id),
|
||||
self.async_schedule_update_ha_state)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue