diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index 776ebbeb1f6..c01dc771f3e 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -49,7 +49,7 @@ class HueBridge: # Jobs to be executed when API is reset. self.reset_jobs = [] self.sensor_manager = None - self.unsub_config_entry_listener = None + self._update_callbacks = {} @property def host(self): @@ -111,9 +111,8 @@ class HueBridge: 3 if self.api.config.modelid == "BSB001" else 10 ) - self.unsub_config_entry_listener = self.config_entry.add_update_listener( - _update_listener - ) + self.reset_jobs.append(self.config_entry.add_update_listener(_update_listener)) + self.reset_jobs.append(asyncio.create_task(self._subscribe_events()).cancel) self.authorized = True return True @@ -168,8 +167,7 @@ class HueBridge: while self.reset_jobs: self.reset_jobs.pop()() - if self.unsub_config_entry_listener is not None: - self.unsub_config_entry_listener() + self._update_callbacks = {} # If setup was successful, we set api variable, forwarded entry and # register service @@ -236,6 +234,36 @@ class HueBridge: self.authorized = False create_config_flow(self.hass, self.host) + async def _subscribe_events(self): + """Subscribe to Hue events.""" + try: + async for updated_object in self.api.listen_events(): + key = (updated_object.ITEM_TYPE, updated_object.id) + + if key in self._update_callbacks: + self._update_callbacks[key]() + + except GeneratorExit: + pass + + @core.callback + def listen_updates(self, item_type, item_id, update_callback): + """Listen to updates.""" + callbacks = self._update_callbacks + key = (item_type, item_id) + + if key in callbacks: + _LOGGER.warning("Overwriting update callback for %s", key) + + callbacks[key] = update_callback + + @core.callback + def unsub(): + if callbacks.get(key) == update_callback: + callbacks.pop(key) + + return unsub + async def authenticate_bridge(hass: core.HomeAssistant, bridge: aiohue.Bridge): """Create a bridge object and verify authentication.""" diff --git a/homeassistant/components/hue/hue_event.py b/homeassistant/components/hue/hue_event.py index 28c6ac3a594..f2edc129f10 100644 --- a/homeassistant/components/hue/hue_event.py +++ b/homeassistant/components/hue/hue_event.py @@ -39,7 +39,11 @@ class HueEvent(GenericHueDevice): self.async_update_callback ) ) - _LOGGER.debug("Hue event created: %s", self.event_id) + self.bridge.reset_jobs.append( + self.bridge.listen_updates( + self.sensor.ITEM_TYPE, self.sensor.id, self.async_update_callback + ) + ) @callback def async_update_callback(self): diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index e139f5a0c95..18c8444ce65 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -448,6 +448,15 @@ class HueLight(CoordinatorEntity, LightEntity): return info + async def async_added_to_hass(self) -> None: + """Handle entity being added to Home Assistant.""" + self.async_on_remove( + self.bridge.listen_updates( + self.light.ITEM_TYPE, self.light.id, self.async_write_ha_state + ) + ) + await super().async_added_to_hass() + async def async_turn_on(self, **kwargs): """Turn the specified or all lights on.""" command = {"on": True} diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 76d02d2b9fc..896c9c7a048 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -3,7 +3,7 @@ "name": "Philips Hue", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hue", - "requirements": ["aiohue==2.3.1"], + "requirements": ["aiohue==2.4.2"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/homeassistant/components/hue/sensor.py b/homeassistant/components/hue/sensor.py index 3cd3b002f98..988dfc31b35 100644 --- a/homeassistant/components/hue/sensor.py +++ b/homeassistant/components/hue/sensor.py @@ -37,9 +37,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class GenericHueGaugeSensorEntity(GenericZLLSensor, SensorEntity): """Parent class for all 'gauge' Hue device sensors.""" - async def _async_update_ha_state(self, *args, **kwargs): - await self.async_update_ha_state(self, *args, **kwargs) - class HueLightLevel(GenericHueGaugeSensorEntity): """The light level sensor entity for a Hue motion sensor device.""" diff --git a/homeassistant/components/hue/sensor_base.py b/homeassistant/components/hue/sensor_base.py index b8e2af138b2..bb527c63b2a 100644 --- a/homeassistant/components/hue/sensor_base.py +++ b/homeassistant/components/hue/sensor_base.py @@ -166,9 +166,6 @@ class GenericHueSensor(GenericHueDevice, entity.Entity): should_poll = False - async def _async_update_ha_state(self, *args, **kwargs): - raise NotImplementedError - @property def available(self): """Return if sensor is available.""" @@ -185,6 +182,7 @@ class GenericHueSensor(GenericHueDevice, entity.Entity): async def async_added_to_hass(self): """When entity is added to hass.""" + await super().async_added_to_hass() self.async_on_remove( self.bridge.sensor_manager.coordinator.async_add_listener( self.async_write_ha_state diff --git a/homeassistant/components/hue/sensor_device.py b/homeassistant/components/hue/sensor_device.py index 91719debeb5..8670f0853a3 100644 --- a/homeassistant/components/hue/sensor_device.py +++ b/homeassistant/components/hue/sensor_device.py @@ -1,8 +1,10 @@ """Support for the Philips Hue sensor devices.""" +from homeassistant.helpers import entity + from .const import DOMAIN as HUE_DOMAIN -class GenericHueDevice: +class GenericHueDevice(entity.Entity): """Representation of a Hue device.""" def __init__(self, sensor, name, bridge, primary_sensor=None): @@ -51,3 +53,12 @@ class GenericHueDevice: "sw_version": self.primary_sensor.swversion, "via_device": (HUE_DOMAIN, self.bridge.api.config.bridgeid), } + + async def async_added_to_hass(self) -> None: + """Handle entity being added to Home Assistant.""" + self.async_on_remove( + self.bridge.listen_updates( + self.sensor.ITEM_TYPE, self.sensor.id, self.async_write_ha_state + ) + ) + await super().async_added_to_hass() diff --git a/requirements_all.txt b/requirements_all.txt index f6a0cf43063..494dac6d1e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aiohomekit==0.2.61 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==2.3.1 +aiohue==2.4.2 # homeassistant.components.imap aioimaplib==0.7.15 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 04102316aaf..6164462ceca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -119,7 +119,7 @@ aiohomekit==0.2.61 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==2.3.1 +aiohue==2.4.2 # homeassistant.components.apache_kafka aiokafka==0.6.0 diff --git a/tests/components/hue/conftest.py b/tests/components/hue/conftest.py index f8bee35425c..ed31f00d9cc 100644 --- a/tests/components/hue/conftest.py +++ b/tests/components/hue/conftest.py @@ -1,5 +1,6 @@ """Test helpers for Hue.""" from collections import deque +import logging from unittest.mock import AsyncMock, Mock, patch from aiohue.groups import Groups @@ -30,46 +31,31 @@ def create_mock_bridge(hass): authorized=True, allow_unreachable=False, allow_groups=False, - api=Mock(), + api=create_mock_api(hass), 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 - # than expected. - bridge.mock_light_responses = deque() - bridge.mock_group_responses = deque() - bridge.mock_sensor_responses = deque() - - async def mock_request(method, path, **kwargs): - kwargs["method"] = method - kwargs["path"] = path - bridge.mock_requests.append(kwargs) - - if path == "lights": - return bridge.mock_light_responses.popleft() - if path == "groups": - return bridge.mock_group_responses.popleft() - if path == "sensors": - return bridge.mock_sensor_responses.popleft() - return None + bridge.mock_requests = bridge.api.mock_requests + bridge.mock_light_responses = bridge.api.mock_light_responses + bridge.mock_group_responses = bridge.api.mock_group_responses + bridge.mock_sensor_responses = bridge.api.mock_sensor_responses async def async_request_call(task): await task() bridge.async_request_call = async_request_call - bridge.api.config.apiversion = "9.9.9" - bridge.api.lights = Lights({}, mock_request) - bridge.api.groups = Groups({}, mock_request) - bridge.api.sensors = Sensors({}, mock_request) return bridge @pytest.fixture def mock_api(hass): """Mock the Hue api.""" + return create_mock_api(hass) + + +def create_mock_api(hass): + """Create a mock API.""" api = Mock(initialize=AsyncMock()) api.mock_requests = [] api.mock_light_responses = deque() @@ -92,11 +78,13 @@ def mock_api(hass): return api.mock_scene_responses.popleft() return None + logger = logging.getLogger(__name__) + api.config.apiversion = "9.9.9" - api.lights = Lights({}, mock_request) - api.groups = Groups({}, mock_request) - api.sensors = Sensors({}, mock_request) - api.scenes = Scenes({}, mock_request) + api.lights = Lights(logger, {}, mock_request) + api.groups = Groups(logger, {}, mock_request) + api.sensors = Sensors(logger, {}, mock_request) + api.scenes = Scenes(logger, {}, mock_request) return api diff --git a/tests/components/hue/test_bridge.py b/tests/components/hue/test_bridge.py index bc7573851ad..ee980c6bffe 100644 --- a/tests/components/hue/test_bridge.py +++ b/tests/components/hue/test_bridge.py @@ -1,4 +1,5 @@ """Test Hue bridge.""" +import asyncio from unittest.mock import AsyncMock, Mock, patch import pytest @@ -12,8 +13,19 @@ from homeassistant.components.hue.const import ( ) from homeassistant.exceptions import ConfigEntryNotReady +ORIG_SUBSCRIBE_EVENTS = bridge.HueBridge._subscribe_events -async def test_bridge_setup(hass): + +@pytest.fixture(autouse=True) +def mock_subscribe_events(): + """Mock subscribe events method.""" + with patch( + "homeassistant.components.hue.bridge.HueBridge._subscribe_events" + ) as mock: + yield mock + + +async def test_bridge_setup(hass, mock_subscribe_events): """Test a successful setup.""" entry = Mock() api = Mock(initialize=AsyncMock()) @@ -31,6 +43,8 @@ async def test_bridge_setup(hass): forward_entries = {c[1][1] for c in mock_forward.mock_calls} assert forward_entries == {"light", "binary_sensor", "sensor"} + assert len(mock_subscribe_events.mock_calls) == 1 + async def test_bridge_setup_invalid_username(hass): """Test we start config flow if username is no longer whitelisted.""" @@ -78,20 +92,23 @@ async def test_reset_if_entry_had_wrong_auth(hass): assert await hue_bridge.async_reset() -async def test_reset_unloads_entry_if_setup(hass): +async def test_reset_unloads_entry_if_setup(hass, mock_subscribe_events): """Test calling reset while the entry has been setup.""" entry = Mock() entry.data = {"host": "1.2.3.4", "username": "mock-username"} entry.options = {CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False} hue_bridge = bridge.HueBridge(hass, entry) - with patch.object(bridge, "authenticate_bridge", return_value=Mock()), patch( - "aiohue.Bridge", return_value=Mock() + with patch.object(bridge, "authenticate_bridge"), patch( + "aiohue.Bridge" ), patch.object(hass.config_entries, "async_forward_entry_setup") as mock_forward: assert await hue_bridge.async_setup() is True + await asyncio.sleep(0) + assert len(hass.services.async_services()) == 0 assert len(mock_forward.mock_calls) == 3 + assert len(mock_subscribe_events.mock_calls) == 1 with patch.object( hass.config_entries, "async_forward_entry_unload", return_value=True @@ -109,9 +126,7 @@ async def test_handle_unauthorized(hass): entry.options = {CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False} hue_bridge = bridge.HueBridge(hass, entry) - with patch.object(bridge, "authenticate_bridge", return_value=Mock()), patch( - "aiohue.Bridge", return_value=Mock() - ): + with patch.object(bridge, "authenticate_bridge"), patch("aiohue.Bridge"): assert await hue_bridge.async_setup() is True assert hue_bridge.authorized is True @@ -282,3 +297,78 @@ async def test_hue_activate_scene_scene_not_found(hass, mock_api): call.data = {"group_name": "Group 1", "scene_name": "Cozy dinner"} with patch("aiohue.Bridge", return_value=mock_api): assert await hue_bridge.hue_activate_scene(call.data) is False + + +async def test_event_updates(hass, caplog): + """Test calling reset while the entry has been setup.""" + events = asyncio.Queue() + + async def iterate_queue(): + while True: + event = await events.get() + if event is None: + return + yield event + + async def wait_empty_queue(): + count = 0 + while not events.empty() and count < 50: + await asyncio.sleep(0) + count += 1 + + hue_bridge = bridge.HueBridge(None, None) + hue_bridge.api = Mock(listen_events=iterate_queue) + subscription_task = asyncio.create_task(ORIG_SUBSCRIBE_EVENTS(hue_bridge)) + + calls = [] + + def obj_updated(): + calls.append(True) + + unsub = hue_bridge.listen_updates("lights", "2", obj_updated) + + events.put_nowait(Mock(ITEM_TYPE="lights", id="1")) + + await wait_empty_queue() + assert len(calls) == 0 + + events.put_nowait(Mock(ITEM_TYPE="lights", id="2")) + + await wait_empty_queue() + assert len(calls) == 1 + + unsub() + + events.put_nowait(Mock(ITEM_TYPE="lights", id="2")) + + await wait_empty_queue() + assert len(calls) == 1 + + # Test we can override update listener. + def obj_updated_false(): + calls.append(False) + + unsub = hue_bridge.listen_updates("lights", "2", obj_updated) + unsub_false = hue_bridge.listen_updates("lights", "2", obj_updated_false) + + assert "Overwriting update callback" in caplog.text + + events.put_nowait(Mock(ITEM_TYPE="lights", id="2")) + + await wait_empty_queue() + assert len(calls) == 2 + assert calls[-1] is False + + # Also call multiple times to make sure that works. + unsub() + unsub() + unsub_false() + unsub_false() + + events.put_nowait(Mock(ITEM_TYPE="lights", id="2")) + + await wait_empty_queue() + assert len(calls) == 2 + + events.put_nowait(None) + await subscription_task diff --git a/tests/components/hue/test_init_multiple_bridges.py b/tests/components/hue/test_init_multiple_bridges.py index cd36a5d4f77..1e3df824a38 100644 --- a/tests/components/hue/test_init_multiple_bridges.py +++ b/tests/components/hue/test_init_multiple_bridges.py @@ -1,16 +1,13 @@ """Test Hue init with multiple bridges.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import patch -from aiohue.groups import Groups -from aiohue.lights import Lights -from aiohue.scenes import Scenes -from aiohue.sensors import Sensors import pytest from homeassistant.components import hue -from homeassistant.components.hue import sensor_base as hue_sensor_base from homeassistant.setup import async_setup_component +from .conftest import create_mock_bridge + from tests.common import MockConfigEntry @@ -144,37 +141,3 @@ def mock_bridge1(hass): def mock_bridge2(hass): """Mock a Hue bridge.""" return create_mock_bridge(hass) - - -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, - async_setup=AsyncMock(return_value=True), - ) - bridge.sensor_manager = hue_sensor_base.SensorManager(bridge) - bridge.mock_requests = [] - - async def mock_request(method, path, **kwargs): - kwargs["method"] = method - kwargs["path"] = path - bridge.mock_requests.append(kwargs) - return {} - - async def async_request_call(task): - await task() - - bridge.async_request_call = async_request_call - bridge.api.config.apiversion = "9.9.9" - bridge.api.lights = Lights({}, mock_request) - bridge.api.groups = Groups({}, mock_request) - bridge.api.sensors = Sensors({}, mock_request) - bridge.api.scenes = Scenes({}, mock_request) - return bridge