Add support for Hue push updates (#50591)
parent
7fd2f8090d
commit
646af533f0
|
@ -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."""
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue