Add support for Hue push updates (#50591)

pull/50625/head
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.
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."""

View File

@ -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):

View File

@ -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}

View File

@ -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",

View File

@ -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."""

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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