Add support for Hue push updates (#50591)

This commit is contained in:
Paulus Schoutsen 2021-05-14 13:39:57 -07:00 committed by GitHub
parent 7fd2f8090d
commit 646af533f0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 181 additions and 93 deletions

View file

@ -49,7 +49,7 @@ class HueBridge:
# Jobs to be executed when API is reset. # Jobs to be executed when API is reset.
self.reset_jobs = [] self.reset_jobs = []
self.sensor_manager = None self.sensor_manager = None
self.unsub_config_entry_listener = None self._update_callbacks = {}
@property @property
def host(self): def host(self):
@ -111,9 +111,8 @@ class HueBridge:
3 if self.api.config.modelid == "BSB001" else 10 3 if self.api.config.modelid == "BSB001" else 10
) )
self.unsub_config_entry_listener = self.config_entry.add_update_listener( self.reset_jobs.append(self.config_entry.add_update_listener(_update_listener))
_update_listener self.reset_jobs.append(asyncio.create_task(self._subscribe_events()).cancel)
)
self.authorized = True self.authorized = True
return True return True
@ -168,8 +167,7 @@ class HueBridge:
while self.reset_jobs: while self.reset_jobs:
self.reset_jobs.pop()() self.reset_jobs.pop()()
if self.unsub_config_entry_listener is not None: self._update_callbacks = {}
self.unsub_config_entry_listener()
# If setup was successful, we set api variable, forwarded entry and # If setup was successful, we set api variable, forwarded entry and
# register service # register service
@ -236,6 +234,36 @@ class HueBridge:
self.authorized = False self.authorized = False
create_config_flow(self.hass, self.host) 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): async def authenticate_bridge(hass: core.HomeAssistant, bridge: aiohue.Bridge):
"""Create a bridge object and verify authentication.""" """Create a bridge object and verify authentication."""

View file

@ -39,7 +39,11 @@ class HueEvent(GenericHueDevice):
self.async_update_callback 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 @callback
def async_update_callback(self): def async_update_callback(self):

View file

@ -448,6 +448,15 @@ class HueLight(CoordinatorEntity, LightEntity):
return info 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): async def async_turn_on(self, **kwargs):
"""Turn the specified or all lights on.""" """Turn the specified or all lights on."""
command = {"on": True} command = {"on": True}

View file

@ -3,7 +3,7 @@
"name": "Philips Hue", "name": "Philips Hue",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/hue", "documentation": "https://www.home-assistant.io/integrations/hue",
"requirements": ["aiohue==2.3.1"], "requirements": ["aiohue==2.4.2"],
"ssdp": [ "ssdp": [
{ {
"manufacturer": "Royal Philips Electronics", "manufacturer": "Royal Philips Electronics",

View file

@ -37,9 +37,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
class GenericHueGaugeSensorEntity(GenericZLLSensor, SensorEntity): class GenericHueGaugeSensorEntity(GenericZLLSensor, SensorEntity):
"""Parent class for all 'gauge' Hue device sensors.""" """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): class HueLightLevel(GenericHueGaugeSensorEntity):
"""The light level sensor entity for a Hue motion sensor device.""" """The light level sensor entity for a Hue motion sensor device."""

View file

@ -166,9 +166,6 @@ class GenericHueSensor(GenericHueDevice, entity.Entity):
should_poll = False should_poll = False
async def _async_update_ha_state(self, *args, **kwargs):
raise NotImplementedError
@property @property
def available(self): def available(self):
"""Return if sensor is available.""" """Return if sensor is available."""
@ -185,6 +182,7 @@ class GenericHueSensor(GenericHueDevice, entity.Entity):
async def async_added_to_hass(self): async def async_added_to_hass(self):
"""When entity is added to hass.""" """When entity is added to hass."""
await super().async_added_to_hass()
self.async_on_remove( self.async_on_remove(
self.bridge.sensor_manager.coordinator.async_add_listener( self.bridge.sensor_manager.coordinator.async_add_listener(
self.async_write_ha_state self.async_write_ha_state

View file

@ -1,8 +1,10 @@
"""Support for the Philips Hue sensor devices.""" """Support for the Philips Hue sensor devices."""
from homeassistant.helpers import entity
from .const import DOMAIN as HUE_DOMAIN from .const import DOMAIN as HUE_DOMAIN
class GenericHueDevice: class GenericHueDevice(entity.Entity):
"""Representation of a Hue device.""" """Representation of a Hue device."""
def __init__(self, sensor, name, bridge, primary_sensor=None): def __init__(self, sensor, name, bridge, primary_sensor=None):
@ -51,3 +53,12 @@ class GenericHueDevice:
"sw_version": self.primary_sensor.swversion, "sw_version": self.primary_sensor.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) -> 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()

View file

@ -182,7 +182,7 @@ aiohomekit==0.2.61
aiohttp_cors==0.7.0 aiohttp_cors==0.7.0
# homeassistant.components.hue # homeassistant.components.hue
aiohue==2.3.1 aiohue==2.4.2
# homeassistant.components.imap # homeassistant.components.imap
aioimaplib==0.7.15 aioimaplib==0.7.15

View file

@ -119,7 +119,7 @@ aiohomekit==0.2.61
aiohttp_cors==0.7.0 aiohttp_cors==0.7.0
# homeassistant.components.hue # homeassistant.components.hue
aiohue==2.3.1 aiohue==2.4.2
# homeassistant.components.apache_kafka # homeassistant.components.apache_kafka
aiokafka==0.6.0 aiokafka==0.6.0

View file

@ -1,5 +1,6 @@
"""Test helpers for Hue.""" """Test helpers for Hue."""
from collections import deque from collections import deque
import logging
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
from aiohue.groups import Groups from aiohue.groups import Groups
@ -30,46 +31,31 @@ def create_mock_bridge(hass):
authorized=True, authorized=True,
allow_unreachable=False, allow_unreachable=False,
allow_groups=False, allow_groups=False,
api=Mock(), api=create_mock_api(hass),
reset_jobs=[], reset_jobs=[],
spec=hue.HueBridge, spec=hue.HueBridge,
) )
bridge.sensor_manager = hue_sensor_base.SensorManager(bridge) bridge.sensor_manager = hue_sensor_base.SensorManager(bridge)
bridge.mock_requests = [] bridge.mock_requests = bridge.api.mock_requests
# We're using a deque so we can schedule multiple responses bridge.mock_light_responses = bridge.api.mock_light_responses
# and also means that `popleft()` will blow up if we get more updates bridge.mock_group_responses = bridge.api.mock_group_responses
# than expected. bridge.mock_sensor_responses = bridge.api.mock_sensor_responses
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
async def async_request_call(task): async def async_request_call(task):
await task() await task()
bridge.async_request_call = async_request_call 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 return bridge
@pytest.fixture @pytest.fixture
def mock_api(hass): def mock_api(hass):
"""Mock the Hue api.""" """Mock the Hue api."""
return create_mock_api(hass)
def create_mock_api(hass):
"""Create a mock API."""
api = Mock(initialize=AsyncMock()) api = Mock(initialize=AsyncMock())
api.mock_requests = [] api.mock_requests = []
api.mock_light_responses = deque() api.mock_light_responses = deque()
@ -92,11 +78,13 @@ def mock_api(hass):
return api.mock_scene_responses.popleft() return api.mock_scene_responses.popleft()
return None return None
logger = logging.getLogger(__name__)
api.config.apiversion = "9.9.9" api.config.apiversion = "9.9.9"
api.lights = Lights({}, mock_request) api.lights = Lights(logger, {}, mock_request)
api.groups = Groups({}, mock_request) api.groups = Groups(logger, {}, mock_request)
api.sensors = Sensors({}, mock_request) api.sensors = Sensors(logger, {}, mock_request)
api.scenes = Scenes({}, mock_request) api.scenes = Scenes(logger, {}, mock_request)
return api return api

View file

@ -1,4 +1,5 @@
"""Test Hue bridge.""" """Test Hue bridge."""
import asyncio
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
import pytest import pytest
@ -12,8 +13,19 @@ from homeassistant.components.hue.const import (
) )
from homeassistant.exceptions import ConfigEntryNotReady 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.""" """Test a successful setup."""
entry = Mock() entry = Mock()
api = Mock(initialize=AsyncMock()) 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} forward_entries = {c[1][1] for c in mock_forward.mock_calls}
assert forward_entries == {"light", "binary_sensor", "sensor"} assert forward_entries == {"light", "binary_sensor", "sensor"}
assert len(mock_subscribe_events.mock_calls) == 1
async def test_bridge_setup_invalid_username(hass): async def test_bridge_setup_invalid_username(hass):
"""Test we start config flow if username is no longer whitelisted.""" """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() 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.""" """Test calling reset while the entry has been setup."""
entry = Mock() entry = Mock()
entry.data = {"host": "1.2.3.4", "username": "mock-username"} entry.data = {"host": "1.2.3.4", "username": "mock-username"}
entry.options = {CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False} entry.options = {CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False}
hue_bridge = bridge.HueBridge(hass, entry) hue_bridge = bridge.HueBridge(hass, entry)
with patch.object(bridge, "authenticate_bridge", return_value=Mock()), patch( with patch.object(bridge, "authenticate_bridge"), patch(
"aiohue.Bridge", return_value=Mock() "aiohue.Bridge"
), patch.object(hass.config_entries, "async_forward_entry_setup") as mock_forward: ), patch.object(hass.config_entries, "async_forward_entry_setup") as mock_forward:
assert await hue_bridge.async_setup() is True assert await hue_bridge.async_setup() is True
await asyncio.sleep(0)
assert len(hass.services.async_services()) == 0 assert len(hass.services.async_services()) == 0
assert len(mock_forward.mock_calls) == 3 assert len(mock_forward.mock_calls) == 3
assert len(mock_subscribe_events.mock_calls) == 1
with patch.object( with patch.object(
hass.config_entries, "async_forward_entry_unload", return_value=True 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} entry.options = {CONF_ALLOW_HUE_GROUPS: False, CONF_ALLOW_UNREACHABLE: False}
hue_bridge = bridge.HueBridge(hass, entry) hue_bridge = bridge.HueBridge(hass, entry)
with patch.object(bridge, "authenticate_bridge", return_value=Mock()), patch( with patch.object(bridge, "authenticate_bridge"), patch("aiohue.Bridge"):
"aiohue.Bridge", return_value=Mock()
):
assert await hue_bridge.async_setup() is True assert await hue_bridge.async_setup() is True
assert hue_bridge.authorized 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"} call.data = {"group_name": "Group 1", "scene_name": "Cozy dinner"}
with patch("aiohue.Bridge", return_value=mock_api): with patch("aiohue.Bridge", return_value=mock_api):
assert await hue_bridge.hue_activate_scene(call.data) is False 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

View file

@ -1,16 +1,13 @@
"""Test Hue init with multiple bridges.""" """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 import pytest
from homeassistant.components import hue from homeassistant.components import hue
from homeassistant.components.hue import sensor_base as hue_sensor_base
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from .conftest import create_mock_bridge
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -144,37 +141,3 @@ def mock_bridge1(hass):
def mock_bridge2(hass): def mock_bridge2(hass):
"""Mock a Hue bridge.""" """Mock a Hue bridge."""
return create_mock_bridge(hass) 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