1
mirror of https://github.com/home-assistant/core synced 2024-09-03 08:14:07 +02:00

Update Hue data fetching (#31338)

* Refactor Hue Lights to use DataCoordinator

* Redo how Hue updates data

* Address comments

* Inherit from Entity and remove pylint disable

* Add tests for debounce
This commit is contained in:
Paulus Schoutsen 2020-01-31 14:47:40 -08:00
parent 1aa322f2f0
commit 283cc5c8c3
15 changed files with 549 additions and 355 deletions

View File

@ -122,7 +122,7 @@ async def async_setup_entry(
if not await bridge.async_setup():
return False
hass.data[DOMAIN][host] = bridge
hass.data[DOMAIN][entry.entry_id] = bridge
config = bridge.api.config
# For backwards compat
@ -151,5 +151,5 @@ async def async_setup_entry(
async def async_unload_entry(hass, entry):
"""Unload a config entry."""
bridge = hass.data[DOMAIN].pop(entry.data["host"])
bridge = hass.data[DOMAIN].pop(entry.entry_id)
return await bridge.async_reset()

View File

@ -6,27 +6,18 @@ from homeassistant.components.binary_sensor import (
DEVICE_CLASS_MOTION,
BinarySensorDevice,
)
from homeassistant.components.hue.sensor_base import (
GenericZLLSensor,
SensorManager,
async_setup_entry as shared_async_setup_entry,
)
from .const import DOMAIN as HUE_DOMAIN
from .sensor_base import SENSOR_CONFIG_MAP, GenericZLLSensor
PRESENCE_NAME_FORMAT = "{} motion"
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Defer binary sensor setup to the shared sensor module."""
SensorManager.sensor_config_map.update(
{
TYPE_ZLL_PRESENCE: {
"binary": True,
"name_format": PRESENCE_NAME_FORMAT,
"class": HuePresence,
}
}
)
await shared_async_setup_entry(hass, config_entry, async_add_entities, binary=True)
await hass.data[HUE_DOMAIN][
config_entry.entry_id
].sensor_manager.async_register_component(True, async_add_entities)
class HuePresence(GenericZLLSensor, BinarySensorDevice):
@ -34,9 +25,6 @@ class HuePresence(GenericZLLSensor, BinarySensorDevice):
device_class = DEVICE_CLASS_MOTION
async def _async_update_ha_state(self, *args, **kwargs):
await self.async_update_ha_state(self, *args, **kwargs)
@property
def is_on(self):
"""Return true if the binary sensor is on."""
@ -51,3 +39,14 @@ class HuePresence(GenericZLLSensor, BinarySensorDevice):
if "sensitivitymax" in self.sensor.config:
attributes["sensitivity_max"] = self.sensor.config["sensitivitymax"]
return attributes
SENSOR_CONFIG_MAP.update(
{
TYPE_ZLL_PRESENCE: {
"binary": True,
"name_format": PRESENCE_NAME_FORMAT,
"class": HuePresence,
}
}
)

View File

@ -13,6 +13,7 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv
from .const import DOMAIN, LOGGER
from .errors import AuthenticationRequired, CannotConnect
from .helpers import create_config_flow
from .sensor_base import SensorManager
SERVICE_HUE_SCENE = "hue_activate_scene"
ATTR_GROUP_NAME = "group_name"
@ -35,6 +36,9 @@ class HueBridge:
self.authorized = False
self.api = None
self.parallel_updates_semaphore = None
# Jobs to be executed when API is reset.
self.reset_jobs = []
self.sensor_manager = None
@property
def host(self):
@ -72,6 +76,7 @@ class HueBridge:
return False
self.api = bridge
self.sensor_manager = SensorManager(self)
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(self.config_entry, "light")
@ -118,6 +123,9 @@ class HueBridge:
self.hass.services.async_remove(DOMAIN, SERVICE_HUE_SCENE)
while self.reset_jobs:
self.reset_jobs.pop()()
# If setup was successful, we set api variable, forwarded entry and
# register service
results = await asyncio.gather(
@ -131,6 +139,7 @@ class HueBridge:
self.config_entry, "sensor"
),
)
# None and True are OK
return False not in results

View File

@ -4,3 +4,7 @@ import logging
LOGGER = logging.getLogger(__package__)
DOMAIN = "hue"
API_NUPNP = "https://www.meethue.com/api/nupnp"
# How long to wait to actually do the refresh after requesting it.
# We wait some time so if we control multiple lights, we batch requests.
REQUEST_REFRESH_DELAY = 0.3

View File

@ -6,7 +6,7 @@ from homeassistant.helpers.entity_registry import async_get_registry as get_ent_
from .const import DOMAIN
async def remove_devices(hass, config_entry, api_ids, current):
async def remove_devices(bridge, api_ids, current):
"""Get items that are removed from api."""
removed_items = []
@ -18,16 +18,16 @@ async def remove_devices(hass, config_entry, api_ids, current):
entity = current[item_id]
removed_items.append(item_id)
await entity.async_remove()
ent_registry = await get_ent_reg(hass)
ent_registry = await get_ent_reg(bridge.hass)
if entity.entity_id in ent_registry.entities:
ent_registry.async_remove(entity.entity_id)
dev_registry = await get_dev_reg(hass)
dev_registry = await get_dev_reg(bridge.hass)
device = dev_registry.async_get_device(
identifiers={(DOMAIN, entity.device_id)}, connections=set()
)
if device is not None:
dev_registry.async_update_device(
device.id, remove_config_entry_id=config_entry.entry_id
device.id, remove_config_entry_id=bridge.config_entry.entry_id
)
for item_id in removed_items:

View File

@ -1,14 +1,13 @@
"""Support for the Philips Hue lights."""
import asyncio
from datetime import timedelta
from functools import partial
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,
@ -28,8 +27,13 @@ from homeassistant.components.light import (
SUPPORT_TRANSITION,
Light,
)
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)
@ -70,9 +74,40 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
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 = {}
bridge = hass.data[HUE_DOMAIN][config_entry.entry_id]
light_coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
"light",
partial(async_safe_fetch, bridge, bridge.api.lights.update),
SCAN_INTERVAL,
Debouncer(bridge.hass, _LOGGER, REQUEST_REFRESH_DELAY, 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 light_coordinator.failed_last_update:
raise PlatformNotReady
update_lights = partial(
async_update_items,
bridge,
bridge.api.lights,
{},
async_add_entities,
partial(HueLight, light_coordinator, bridge, False),
)
# We add a listener after fetching the data, so manually trigger listener
light_coordinator.async_add_listener(update_lights)
update_lights()
bridge.reset_jobs.append(
lambda: light_coordinator.async_remove_listener(update_lights)
)
api_version = tuple(int(v) for v in bridge.api.config.apiversion.split("."))
@ -81,168 +116,60 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
_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:
if not allow_groups:
return
if is_group:
api_type = "group"
api = bridge.api.groups
else:
api_type = "light"
api = bridge.api.lights
group_coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
"group",
partial(async_safe_fetch, bridge, bridge.api.groups.update),
SCAN_INTERVAL,
Debouncer(bridge.hass, _LOGGER, REQUEST_REFRESH_DELAY, True),
)
update_groups = partial(
async_update_items,
bridge,
bridge.api.groups,
{},
async_add_entities,
partial(HueLight, group_coordinator, bridge, True),
)
group_coordinator.async_add_listener(update_groups)
await group_coordinator.async_refresh()
bridge.reset_jobs.append(
lambda: group_coordinator.async_remove_listener(update_groups)
)
async def async_safe_fetch(bridge, fetch_method):
"""Safely fetch data."""
try:
start = monotonic()
with async_timeout.timeout(4):
await bridge.async_request_call(api.update())
return await bridge.async_request_call(fetch_method())
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)
raise UpdateFailed
except (asyncio.TimeoutError, aiohue.AiohueException):
raise UpdateFailed
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
@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 not in current:
current[item_id] = HueLight(
api[item_id], request_bridge_update, bridge, is_group
)
if item_id in current:
continue
new_items.append(current[item_id])
elif item_id not in progress_waiting:
current[item_id].async_schedule_update_ha_state()
current[item_id] = create_item(api[item_id])
new_items.append(current[item_id])
await remove_devices(hass, config_entry, api, current)
bridge.hass.async_create_task(remove_devices(bridge, api, current))
if new_items:
async_add_entities(new_items)
@ -251,10 +178,10 @@ async def async_update_items(
class HueLight(Light):
"""Representation of a Hue light."""
def __init__(self, light, request_bridge_update, bridge, is_group=False):
def __init__(self, coordinator, bridge, is_group, light):
"""Initialize the light."""
self.light = light
self.async_request_bridge_update = request_bridge_update
self.coordinator = coordinator
self.bridge = bridge
self.is_group = is_group
@ -289,6 +216,11 @@ class HueLight(Light):
"""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."""
@ -345,14 +277,10 @@ class HueLight(Light):
@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"]
)
return not self.coordinator.failed_last_update and (
self.is_group
or self.bridge.allow_unreachable
or self.light.state["reachable"]
)
@property
@ -379,7 +307,7 @@ class HueLight(Light):
return None
return {
"identifiers": {(hue.DOMAIN, self.device_id)},
"identifiers": {(HUE_DOMAIN, self.device_id)},
"name": self.name,
"manufacturer": self.light.manufacturername,
# productname added in Hue Bridge API 1.24
@ -387,9 +315,17 @@ class HueLight(Light):
"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),
"via_device": (HUE_DOMAIN, self.bridge.api.config.bridgeid),
}
async def async_added_to_hass(self):
"""When entity is added to hass."""
self.coordinator.async_add_listener(self.async_write_ha_state)
async def async_will_remove_from_hass(self):
"""When entity will be removed from hass."""
self.coordinator.async_remove_listener(self.async_write_ha_state)
async def async_turn_on(self, **kwargs):
"""Turn the specified or all lights on."""
command = {"on": True}
@ -440,6 +376,8 @@ class HueLight(Light):
else:
await self.bridge.async_request_call(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}
@ -463,9 +401,14 @@ class HueLight(Light):
else:
await self.bridge.async_request_call(self.light.set_state(**command))
await self.coordinator.async_request_refresh()
async def async_update(self):
"""Synchronize state with bridge."""
await self.async_request_bridge_update(self.is_group, self.light.id)
"""Update the entity.
Only used by the generic entity update service.
"""
await self.coordinator.async_request_refresh()
@property
def device_state_attributes(self):

View File

@ -1,11 +1,6 @@
"""Hue sensor entities."""
from aiohue.sensors import TYPE_ZLL_LIGHTLEVEL, TYPE_ZLL_TEMPERATURE
from homeassistant.components.hue.sensor_base import (
GenericZLLSensor,
SensorManager,
async_setup_entry as shared_async_setup_entry,
)
from homeassistant.const import (
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_TEMPERATURE,
@ -13,27 +8,18 @@ from homeassistant.const import (
)
from homeassistant.helpers.entity import Entity
from .const import DOMAIN as HUE_DOMAIN
from .sensor_base import SENSOR_CONFIG_MAP, GenericZLLSensor
LIGHT_LEVEL_NAME_FORMAT = "{} light level"
TEMPERATURE_NAME_FORMAT = "{} temperature"
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Defer sensor setup to the shared sensor module."""
SensorManager.sensor_config_map.update(
{
TYPE_ZLL_LIGHTLEVEL: {
"binary": False,
"name_format": LIGHT_LEVEL_NAME_FORMAT,
"class": HueLightLevel,
},
TYPE_ZLL_TEMPERATURE: {
"binary": False,
"name_format": TEMPERATURE_NAME_FORMAT,
"class": HueTemperature,
},
}
)
await shared_async_setup_entry(hass, config_entry, async_add_entities, binary=False)
await hass.data[HUE_DOMAIN][
config_entry.entry_id
].sensor_manager.async_register_component(False, async_add_entities)
class GenericHueGaugeSensorEntity(GenericZLLSensor, Entity):
@ -91,3 +77,19 @@ class HueTemperature(GenericHueGaugeSensorEntity):
return None
return self.sensor.temperature / 100
SENSOR_CONFIG_MAP.update(
{
TYPE_ZLL_LIGHTLEVEL: {
"binary": False,
"name_format": LIGHT_LEVEL_NAME_FORMAT,
"class": HueLightLevel,
},
TYPE_ZLL_TEMPERATURE: {
"binary": False,
"name_format": TEMPERATURE_NAME_FORMAT,
"class": HueTemperature,
},
}
)

View File

@ -2,22 +2,19 @@
import asyncio
from datetime import timedelta
import logging
from time import monotonic
from aiohue import AiohueException, Unauthorized
from aiohue.sensors import TYPE_ZLL_PRESENCE
import async_timeout
from homeassistant.components import hue
from homeassistant.exceptions import NoEntitySpecifiedError
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.util.dt import utcnow
from homeassistant.core import callback
from homeassistant.helpers import debounce, entity
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN as HUE_DOMAIN, REQUEST_REFRESH_DELAY
from .helpers import remove_devices
CURRENT_SENSORS_FORMAT = "{}_current_sensors"
SENSOR_MANAGER_FORMAT = "{}_sensor_manager"
SENSOR_CONFIG_MAP = {}
_LOGGER = logging.getLogger(__name__)
@ -29,22 +26,6 @@ def _device_id(aiohue_sensor):
return device_id
async def async_setup_entry(hass, config_entry, async_add_entities, binary=False):
"""Set up the Hue sensors from a config entry."""
sensor_key = CURRENT_SENSORS_FORMAT.format(config_entry.data["host"])
bridge = hass.data[hue.DOMAIN][config_entry.data["host"]]
hass.data[hue.DOMAIN].setdefault(sensor_key, {})
sm_key = SENSOR_MANAGER_FORMAT.format(config_entry.data["host"])
manager = hass.data[hue.DOMAIN].get(sm_key)
if manager is None:
manager = SensorManager(hass, bridge, config_entry)
hass.data[hue.DOMAIN][sm_key] = manager
manager.register_component(binary, async_add_entities)
await manager.start()
class SensorManager:
"""Class that handles registering and updating Hue sensor entities.
@ -52,84 +33,60 @@ class SensorManager:
"""
SCAN_INTERVAL = timedelta(seconds=5)
sensor_config_map = {}
def __init__(self, hass, bridge, config_entry):
def __init__(self, bridge):
"""Initialize the sensor manager."""
self.hass = hass
self.bridge = bridge
self.config_entry = config_entry
self._component_add_entities = {}
self._started = False
self.current = {}
self.coordinator = DataUpdateCoordinator(
bridge.hass,
_LOGGER,
"sensor",
self.async_update_data,
self.SCAN_INTERVAL,
debounce.Debouncer(bridge.hass, _LOGGER, REQUEST_REFRESH_DELAY, True),
)
def register_component(self, binary, async_add_entities):
async def async_update_data(self):
"""Update sensor data."""
try:
with async_timeout.timeout(4):
return await self.bridge.async_request_call(
self.bridge.api.sensors.update()
)
except Unauthorized:
await self.bridge.handle_unauthorized_error()
raise UpdateFailed
except (asyncio.TimeoutError, AiohueException):
raise UpdateFailed
async def async_register_component(self, binary, async_add_entities):
"""Register async_add_entities methods for components."""
self._component_add_entities[binary] = async_add_entities
async def start(self):
"""Start updating sensors from the bridge on a schedule."""
# but only if it's not already started, and when we've got both
# async_add_entities methods
if self._started or len(self._component_add_entities) < 2:
if len(self._component_add_entities) < 2:
return
self._started = True
_LOGGER.info(
"Starting sensor polling loop with %s second interval",
self.SCAN_INTERVAL.total_seconds(),
# We have all components available, start the updating.
self.coordinator.async_add_listener(self.async_update_items)
self.bridge.reset_jobs.append(
lambda: self.coordinator.async_remove_listener(self.async_update_items)
)
await self.coordinator.async_refresh()
async def async_update_bridge(now):
"""Will update sensors from the bridge."""
# don't update when we are not authorized
if not self.bridge.authorized:
return
await self.async_update_items()
async_track_point_in_utc_time(
self.hass, async_update_bridge, utcnow() + self.SCAN_INTERVAL
)
await async_update_bridge(None)
async def async_update_items(self):
@callback
def async_update_items(self):
"""Update sensors from the bridge."""
api = self.bridge.api.sensors
try:
start = monotonic()
with async_timeout.timeout(4):
await self.bridge.async_request_call(api.update())
except Unauthorized:
await self.bridge.handle_unauthorized_error()
if len(self._component_add_entities) < 2:
return
except (asyncio.TimeoutError, AiohueException) as err:
_LOGGER.debug("Failed to fetch sensor: %s", err)
if not self.bridge.available:
return
_LOGGER.error("Unable to reach bridge %s (%s)", self.bridge.host, err)
self.bridge.available = False
return
finally:
_LOGGER.debug(
"Finished sensor request in %.3f seconds", monotonic() - start
)
if not self.bridge.available:
_LOGGER.info("Reconnected to bridge %s", self.bridge.host)
self.bridge.available = True
new_sensors = []
new_binary_sensors = []
primary_sensor_devices = {}
sensor_key = CURRENT_SENSORS_FORMAT.format(self.config_entry.data["host"])
current = self.hass.data[hue.DOMAIN][sensor_key]
current = self.current
# Physical Hue motion sensors present as three sensors in the API: a
# presence sensor, a temperature sensor, and a light level sensor. Of
@ -155,11 +112,10 @@ class SensorManager:
for item_id in api:
existing = current.get(api[item_id].uniqueid)
if existing is not None:
self.hass.async_create_task(existing.async_maybe_update_ha_state())
continue
primary_sensor = None
sensor_config = self.sensor_config_map.get(api[item_id].type)
sensor_config = SENSOR_CONFIG_MAP.get(api[item_id].type)
if sensor_config is None:
continue
@ -177,22 +133,19 @@ class SensorManager:
else:
new_sensors.append(current[api[item_id].uniqueid])
await remove_devices(
self.hass,
self.config_entry,
[value.uniqueid for value in api.values()],
current,
self.bridge.hass.async_create_task(
remove_devices(
self.bridge, [value.uniqueid for value in api.values()], current,
)
)
async_add_sensor_entities = self._component_add_entities.get(False)
async_add_binary_entities = self._component_add_entities.get(True)
if new_sensors and async_add_sensor_entities:
async_add_sensor_entities(new_sensors)
if new_binary_sensors and async_add_binary_entities:
async_add_binary_entities(new_binary_sensors)
if new_sensors:
self._component_add_entities[False](new_sensors)
if new_binary_sensors:
self._component_add_entities[True](new_binary_sensors)
class GenericHueSensor:
class GenericHueSensor(entity.Entity):
"""Representation of a Hue sensor."""
should_poll = False
@ -230,10 +183,8 @@ class GenericHueSensor:
@property
def available(self):
"""Return if sensor is available."""
return (
self.bridge.available
and self.bridge.authorized
and (self.bridge.allow_unreachable or self.sensor.config["reachable"])
return not self.bridge.sensor_manager.coordinator.failed_last_update and (
self.bridge.allow_unreachable or self.sensor.config["reachable"]
)
@property
@ -241,15 +192,24 @@ class GenericHueSensor:
"""Return detail of available software updates for this device."""
return self.primary_sensor.raw.get("swupdate", {}).get("state")
async def async_maybe_update_ha_state(self):
"""Try to update Home Assistant with current state of entity.
async def async_added_to_hass(self):
"""When entity is added to hass."""
self.bridge.sensor_manager.coordinator.async_add_listener(
self.async_write_ha_state
)
But if it's not been added to hass yet, then don't throw an error.
async def async_will_remove_from_hass(self):
"""When entity will be removed from hass."""
self.bridge.sensor_manager.coordinator.async_remove_listener(
self.async_write_ha_state
)
async def async_update(self):
"""Update the entity.
Only used by the generic entity update service.
"""
try:
await self._async_update_ha_state()
except (RuntimeError, NoEntitySpecifiedError):
_LOGGER.debug("Hue sensor update requested before it has been added.")
await self.bridge.sensor_manager.coordinator.coordinator.async_request_refresh()
@property
def device_info(self):
@ -258,12 +218,12 @@ class GenericHueSensor:
Links individual entities together in the hass device registry.
"""
return {
"identifiers": {(hue.DOMAIN, self.device_id)},
"identifiers": {(HUE_DOMAIN, self.device_id)},
"name": self.primary_sensor.name,
"manufacturer": self.primary_sensor.manufacturername,
"model": (self.primary_sensor.productname or self.primary_sensor.modelid),
"sw_version": self.primary_sensor.swversion,
"via_device": (hue.DOMAIN, self.bridge.api.config.bridgeid),
"via_device": (HUE_DOMAIN, self.bridge.api.config.bridgeid),
}

View File

@ -0,0 +1,77 @@
"""Debounce helper."""
import asyncio
from logging import Logger
from typing import Any, Awaitable, Callable, Optional
from homeassistant.core import HomeAssistant, callback
class Debouncer:
"""Class to rate limit calls to a specific command."""
def __init__(
self,
hass: HomeAssistant,
logger: Logger,
cooldown: float,
immediate: bool,
function: Optional[Callable[..., Awaitable[Any]]] = None,
):
"""Initialize debounce.
immediate: indicate if the function needs to be called right away and
wait 0.3s until executing next invocation.
function: optional and can be instantiated later.
"""
self.hass = hass
self.logger = logger
self.function = function
self.cooldown = cooldown
self.immediate = immediate
self._timer_task: Optional[asyncio.TimerHandle] = None
self._execute_at_end_of_timer: bool = False
async def async_call(self) -> None:
"""Call the function."""
assert self.function is not None
if self._timer_task:
if not self._execute_at_end_of_timer:
self._execute_at_end_of_timer = True
return
if self.immediate:
await self.hass.async_add_job(self.function) # type: ignore
else:
self._execute_at_end_of_timer = True
self._timer_task = self.hass.loop.call_later(
self.cooldown,
lambda: self.hass.async_create_task(self._handle_timer_finish()),
)
async def _handle_timer_finish(self) -> None:
"""Handle a finished timer."""
assert self.function is not None
self._timer_task = None
if not self._execute_at_end_of_timer:
return
self._execute_at_end_of_timer = False
try:
await self.hass.async_add_job(self.function) # type: ignore
except Exception: # pylint: disable=broad-except
self.logger.exception("Unexpected exception from %s", self.function)
@callback
def async_cancel(self) -> None:
"""Cancel any scheduled call."""
if self._timer_task:
self._timer_task.cancel()
self._timer_task = None
self._execute_at_end_of_timer = False

View File

@ -225,7 +225,7 @@ track_point_in_time = threaded_listener_factory(async_track_point_in_time)
@callback
@bind_hass
def async_track_point_in_utc_time(
hass: HomeAssistant, action: Callable[..., None], point_in_time: datetime
hass: HomeAssistant, action: Callable[..., Any], point_in_time: datetime
) -> CALLBACK_TYPE:
"""Add a listener that fires once after a specific point in UTC time."""
# Ensure point_in_time is UTC

View File

@ -0,0 +1,135 @@
"""Helpers to help coordinate updates."""
import asyncio
from datetime import datetime, timedelta
import logging
from time import monotonic
from typing import Any, Awaitable, Callable, List, Optional
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.util.dt import utcnow
from .debounce import Debouncer
class UpdateFailed(Exception):
"""Raised when an update has failed."""
class DataUpdateCoordinator:
"""Class to manage fetching data from single endpoint."""
def __init__(
self,
hass: HomeAssistant,
logger: logging.Logger,
name: str,
update_method: Callable[[], Awaitable],
update_interval: timedelta,
request_refresh_debouncer: Debouncer,
):
"""Initialize global data updater."""
self.hass = hass
self.logger = logger
self.name = name
self.update_method = update_method
self.update_interval = update_interval
self.data: Optional[Any] = None
self._listeners: List[CALLBACK_TYPE] = []
self._unsub_refresh: Optional[CALLBACK_TYPE] = None
self._request_refresh_task: Optional[asyncio.TimerHandle] = None
self.failed_last_update = False
self._debounced_refresh = request_refresh_debouncer
request_refresh_debouncer.function = self._async_do_refresh
@callback
def async_add_listener(self, update_callback: CALLBACK_TYPE) -> None:
"""Listen for data updates."""
schedule_refresh = not self._listeners
self._listeners.append(update_callback)
# This is the first listener, set up interval.
if schedule_refresh:
self._schedule_refresh()
@callback
def async_remove_listener(self, update_callback: CALLBACK_TYPE) -> None:
"""Remove data update."""
self._listeners.remove(update_callback)
if not self._listeners and self._unsub_refresh:
self._unsub_refresh()
self._unsub_refresh = None
async def async_refresh(self) -> None:
"""Refresh the data."""
if self._unsub_refresh:
self._unsub_refresh()
self._unsub_refresh = None
await self._async_do_refresh()
@callback
def _schedule_refresh(self) -> None:
"""Schedule a refresh."""
if self._unsub_refresh:
self._unsub_refresh()
self._unsub_refresh = None
self._unsub_refresh = async_track_point_in_utc_time(
self.hass, self._handle_refresh_interval, utcnow() + self.update_interval
)
async def _handle_refresh_interval(self, _now: datetime) -> None:
"""Handle a refresh interval occurrence."""
self._unsub_refresh = None
await self._async_do_refresh()
async def async_request_refresh(self) -> None:
"""Request a refresh.
Refresh will wait a bit to see if it can batch them.
"""
await self._debounced_refresh.async_call()
async def _async_do_refresh(self) -> None:
"""Time to update."""
if self._unsub_refresh:
self._unsub_refresh()
self._unsub_refresh = None
self._debounced_refresh.async_cancel()
try:
start = monotonic()
self.data = await self.update_method()
except UpdateFailed as err:
if not self.failed_last_update:
self.logger.error("Error fetching %s data: %s", self.name, err)
self.failed_last_update = True
except Exception as err: # pylint: disable=broad-except
self.failed_last_update = True
self.logger.exception(
"Unexpected error fetching %s data: %s", self.name, err
)
else:
if self.failed_last_update:
self.failed_last_update = False
self.logger.info("Fetching %s data recovered")
finally:
self.logger.debug(
"Finished fetching %s data in %.3f seconds",
self.name,
monotonic() - start,
)
self._schedule_refresh()
for update_callback in self._listeners:
update_callback()

View File

@ -0,0 +1,11 @@
"""Test helpers for Hue."""
from unittest.mock import patch
import pytest
@pytest.fixture(autouse=True)
def no_request_delay():
"""Make the request refresh delay 0 for instant tests."""
with patch("homeassistant.components.hue.light.REQUEST_REFRESH_DELAY", 0):
yield

View File

@ -179,11 +179,13 @@ LIGHT_GAMUT_TYPE = "A"
def mock_bridge(hass):
"""Mock a Hue bridge."""
bridge = Mock(
hass=hass,
available=True,
authorized=True,
allow_unreachable=False,
allow_groups=False,
api=Mock(),
reset_jobs=[],
spec=hue.HueBridge,
)
bridge.mock_requests = []
@ -218,7 +220,6 @@ def mock_bridge(hass):
async def setup_bridge(hass, mock_bridge):
"""Load the Hue light platform with the provided bridge."""
hass.config.components.add(hue.DOMAIN)
hass.data[hue.DOMAIN] = {"mock-host": mock_bridge}
config_entry = config_entries.ConfigEntry(
1,
hue.DOMAIN,
@ -228,6 +229,8 @@ async def setup_bridge(hass, mock_bridge):
config_entries.CONN_CLASS_LOCAL_POLL,
system_options={},
)
mock_bridge.config_entry = config_entry
hass.data[hue.DOMAIN] = {config_entry.entry_id: mock_bridge}
await hass.config_entries.async_forward_entry_setup(config_entry, "light")
# To flush out the service call to update the group
await hass.async_block_till_done()
@ -363,8 +366,8 @@ async def test_new_group_discovered(hass, mock_bridge):
await hass.services.async_call(
"light", "turn_on", {"entity_id": "light.group_1"}, blocking=True
)
# 2x group update, 2x light update, 1 turn on request
assert len(mock_bridge.mock_requests) == 5
# 2x group update, 1x light update, 1 turn on request
assert len(mock_bridge.mock_requests) == 4
assert len(hass.states.async_all()) == 3
new_group = hass.states.get("light.group_3")
@ -443,8 +446,8 @@ async def test_group_removed(hass, mock_bridge):
"light", "turn_on", {"entity_id": "light.group_1"}, blocking=True
)
# 2x group update, 2x light update, 1 turn on request
assert len(mock_bridge.mock_requests) == 5
# 2x group update, 1x light update, 1 turn on request
assert len(mock_bridge.mock_requests) == 4
assert len(hass.states.async_all()) == 1
group = hass.states.get("light.group_1")
@ -524,8 +527,8 @@ async def test_other_group_update(hass, mock_bridge):
await hass.services.async_call(
"light", "turn_on", {"entity_id": "light.group_1"}, blocking=True
)
# 2x group update, 2x light update, 1 turn on request
assert len(mock_bridge.mock_requests) == 5
# 2x group update, 1x light update, 1 turn on request
assert len(mock_bridge.mock_requests) == 4
assert len(hass.states.async_all()) == 2
group_2 = hass.states.get("light.group_2")
@ -599,7 +602,6 @@ async def test_update_timeout(hass, mock_bridge):
await setup_bridge(hass, mock_bridge)
assert len(mock_bridge.mock_requests) == 0
assert len(hass.states.async_all()) == 0
assert mock_bridge.available is False
async def test_update_unauthorized(hass, mock_bridge):
@ -701,7 +703,7 @@ def test_available():
colorgamuttype=LIGHT_GAMUT_TYPE,
colorgamut=LIGHT_GAMUT,
),
request_bridge_update=None,
coordinator=Mock(failed_last_update=False),
bridge=Mock(allow_unreachable=False),
is_group=False,
)
@ -715,7 +717,7 @@ def test_available():
colorgamuttype=LIGHT_GAMUT_TYPE,
colorgamut=LIGHT_GAMUT,
),
request_bridge_update=None,
coordinator=Mock(failed_last_update=False),
bridge=Mock(allow_unreachable=True),
is_group=False,
)
@ -729,7 +731,7 @@ def test_available():
colorgamuttype=LIGHT_GAMUT_TYPE,
colorgamut=LIGHT_GAMUT,
),
request_bridge_update=None,
coordinator=Mock(failed_last_update=False),
bridge=Mock(allow_unreachable=False),
is_group=True,
)
@ -746,7 +748,7 @@ def test_hs_color():
colorgamuttype=LIGHT_GAMUT_TYPE,
colorgamut=LIGHT_GAMUT,
),
request_bridge_update=None,
coordinator=Mock(failed_last_update=False),
bridge=Mock(),
is_group=False,
)
@ -760,7 +762,7 @@ def test_hs_color():
colorgamuttype=LIGHT_GAMUT_TYPE,
colorgamut=LIGHT_GAMUT,
),
request_bridge_update=None,
coordinator=Mock(failed_last_update=False),
bridge=Mock(),
is_group=False,
)
@ -774,7 +776,7 @@ def test_hs_color():
colorgamuttype=LIGHT_GAMUT_TYPE,
colorgamut=LIGHT_GAMUT,
),
request_bridge_update=None,
coordinator=Mock(failed_last_update=False),
bridge=Mock(),
is_group=False,
)

View File

@ -1,7 +1,6 @@
"""Philips Hue sensors platform tests."""
import asyncio
from collections import deque
import datetime
import logging
from unittest.mock import Mock
@ -252,16 +251,19 @@ SENSOR_RESPONSE = {
}
def create_mock_bridge():
def create_mock_bridge(hass):
"""Create a mock Hue bridge."""
bridge = Mock(
hass=hass,
available=True,
authorized=True,
allow_unreachable=False,
allow_groups=False,
api=Mock(),
reset_jobs=[],
spec=hue.HueBridge,
)
bridge.sensor_manager = hue_sensor_base.SensorManager(bridge)
bridge.mock_requests = []
# We're using a deque so we can schedule multiple responses
# and also means that `popleft()` will blow up if we get more updates
@ -289,13 +291,7 @@ def create_mock_bridge():
@pytest.fixture
def mock_bridge(hass):
"""Mock a Hue bridge."""
return create_mock_bridge()
@pytest.fixture
def increase_scan_interval(hass):
"""Increase the SCAN_INTERVAL to prevent unexpected scans during tests."""
hue_sensor_base.SensorManager.SCAN_INTERVAL = datetime.timedelta(days=365)
return create_mock_bridge(hass)
async def setup_bridge(hass, mock_bridge, hostname=None):
@ -303,7 +299,6 @@ async def setup_bridge(hass, mock_bridge, hostname=None):
if hostname is None:
hostname = "mock-host"
hass.config.components.add(hue.DOMAIN)
hass.data[hue.DOMAIN] = {hostname: mock_bridge}
config_entry = config_entries.ConfigEntry(
1,
hue.DOMAIN,
@ -313,6 +308,8 @@ async def setup_bridge(hass, mock_bridge, hostname=None):
config_entries.CONN_CLASS_LOCAL_POLL,
system_options={},
)
mock_bridge.config_entry = config_entry
hass.data[hue.DOMAIN] = {config_entry.entry_id: mock_bridge}
await hass.config_entries.async_forward_entry_setup(config_entry, "binary_sensor")
await hass.config_entries.async_forward_entry_setup(config_entry, "sensor")
# and make sure it completes before going further
@ -330,7 +327,7 @@ async def test_no_sensors(hass, mock_bridge):
async def test_sensors_with_multiple_bridges(hass, mock_bridge):
"""Test the update_items function with some sensors."""
mock_bridge_2 = create_mock_bridge()
mock_bridge_2 = create_mock_bridge(hass)
mock_bridge_2.mock_sensor_responses.append(
{
"1": PRESENCE_SENSOR_3_PRESENT,
@ -412,11 +409,7 @@ async def test_new_sensor_discovered(hass, mock_bridge):
mock_bridge.mock_sensor_responses.append(new_sensor_response)
# Force updates to run again
sm_key = hue_sensor_base.SENSOR_MANAGER_FORMAT.format("mock-host")
sm = hass.data[hue.DOMAIN][sm_key]
await sm.async_update_items()
# To flush out the service call to update the group
await mock_bridge.sensor_manager.coordinator.async_refresh()
await hass.async_block_till_done()
assert len(mock_bridge.mock_requests) == 2
@ -443,9 +436,7 @@ async def test_sensor_removed(hass, mock_bridge):
mock_bridge.mock_sensor_responses.append({k: SENSOR_RESPONSE[k] for k in keys})
# Force updates to run again
sm_key = hue_sensor_base.SENSOR_MANAGER_FORMAT.format("mock-host")
sm = hass.data[hue.DOMAIN][sm_key]
await sm.async_update_items()
await mock_bridge.sensor_manager.coordinator.async_refresh()
# To flush out the service call to update the group
await hass.async_block_till_done()
@ -466,7 +457,6 @@ async def test_update_timeout(hass, mock_bridge):
await setup_bridge(hass, mock_bridge)
assert len(mock_bridge.mock_requests) == 0
assert len(hass.states.async_all()) == 0
assert mock_bridge.available is False
async def test_update_unauthorized(hass, mock_bridge):

View File

@ -0,0 +1,62 @@
"""Tests for debounce."""
from asynctest import CoroutineMock
from homeassistant.helpers import debounce
async def test_immediate_works(hass):
"""Test immediate works."""
calls = []
debouncer = debounce.Debouncer(
hass, None, 0.01, True, CoroutineMock(side_effect=lambda: calls.append(None))
)
await debouncer.async_call()
assert len(calls) == 1
assert debouncer._timer_task is not None
assert debouncer._execute_at_end_of_timer is False
await debouncer.async_call()
assert len(calls) == 1
assert debouncer._timer_task is not None
assert debouncer._execute_at_end_of_timer is True
debouncer.async_cancel()
assert debouncer._timer_task is None
assert debouncer._execute_at_end_of_timer is False
await debouncer.async_call()
assert len(calls) == 2
await debouncer._handle_timer_finish()
assert len(calls) == 2
assert debouncer._timer_task is None
assert debouncer._execute_at_end_of_timer is False
async def test_not_immediate_works(hass):
"""Test immediate works."""
calls = []
debouncer = debounce.Debouncer(
hass, None, 0.01, False, CoroutineMock(side_effect=lambda: calls.append(None))
)
await debouncer.async_call()
assert len(calls) == 0
assert debouncer._timer_task is not None
assert debouncer._execute_at_end_of_timer is True
await debouncer.async_call()
assert len(calls) == 0
assert debouncer._timer_task is not None
assert debouncer._execute_at_end_of_timer is True
debouncer.async_cancel()
assert debouncer._timer_task is None
assert debouncer._execute_at_end_of_timer is False
await debouncer.async_call()
assert len(calls) == 0
await debouncer._handle_timer_finish()
assert len(calls) == 1
assert debouncer._timer_task is None
assert debouncer._execute_at_end_of_timer is False