diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py index fb277ee7a67..9c092c69fb5 100644 --- a/homeassistant/components/hue/__init__.py +++ b/homeassistant/components/hue/__init__.py @@ -1,4 +1,5 @@ """Support for the Philips Hue system.""" +import asyncio import logging from aiohue.util import normalize_bridge_id @@ -7,7 +8,13 @@ from homeassistant import config_entries, core from homeassistant.components import persistent_notification from homeassistant.helpers import device_registry as dr -from .bridge import HueBridge +from .bridge import ( + ATTR_GROUP_NAME, + ATTR_SCENE_NAME, + SCENE_SCHEMA, + SERVICE_HUE_SCENE, + HueBridge, +) from .const import ( CONF_ALLOW_HUE_GROUPS, CONF_ALLOW_UNREACHABLE, @@ -21,6 +28,39 @@ _LOGGER = logging.getLogger(__name__) async def async_setup(hass, config): """Set up the Hue platform.""" + + async def hue_activate_scene(call, skip_reload=True): + """Handle activation of Hue scene.""" + # Get parameters + group_name = call.data[ATTR_GROUP_NAME] + scene_name = call.data[ATTR_SCENE_NAME] + + # Call the set scene function on each bridge + tasks = [ + bridge.hue_activate_scene( + call, updated=skip_reload, hide_warnings=skip_reload + ) + for bridge in hass.data[DOMAIN].values() + if isinstance(bridge, HueBridge) + ] + results = await asyncio.gather(*tasks) + + # Did *any* bridge succeed? If not, refresh / retry + # Note that we'll get a "None" value for a successful call + if None not in results: + if skip_reload: + return await hue_activate_scene(call, skip_reload=False) + _LOGGER.warning( + "No bridge was able to activate " "scene %s in group %s", + scene_name, + group_name, + ) + + # Register a local handler for scene activation + hass.services.async_register( + DOMAIN, SERVICE_HUE_SCENE, hue_activate_scene, schema=SCENE_SCHEMA + ) + hass.data[DOMAIN] = {} return True @@ -131,4 +171,5 @@ async def async_setup_entry( async def async_unload_entry(hass, entry): """Unload a config entry.""" bridge = hass.data[DOMAIN].pop(entry.entry_id) + hass.services.async_remove(DOMAIN, SERVICE_HUE_SCENE) return await bridge.async_reset() diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index 3eb894552ab..880d9abcc35 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -19,7 +19,6 @@ from .const import ( CONF_ALLOW_UNREACHABLE, DEFAULT_ALLOW_HUE_GROUPS, DEFAULT_ALLOW_UNREACHABLE, - DOMAIN, LOGGER, ) from .errors import AuthenticationRequired, CannotConnect @@ -117,10 +116,6 @@ class HueBridge: hass.config_entries.async_forward_entry_setup(self.config_entry, "sensor") ) - hass.services.async_register( - DOMAIN, SERVICE_HUE_SCENE, self.hue_activate_scene, schema=SCENE_SCHEMA - ) - self.parallel_updates_semaphore = asyncio.Semaphore( 3 if self.api.config.modelid == "BSB001" else 10 ) @@ -179,8 +174,6 @@ class HueBridge: if self.api is None: return True - self.hass.services.async_remove(DOMAIN, SERVICE_HUE_SCENE) - while self.reset_jobs: self.reset_jobs.pop()() @@ -204,7 +197,7 @@ class HueBridge: # None and True are OK return False not in results - async def hue_activate_scene(self, call, updated=False): + async def hue_activate_scene(self, call, updated=False, hide_warnings=False): """Service to call directly into bridge to set scenes.""" group_name = call.data[ATTR_GROUP_NAME] scene_name = call.data[ATTR_SCENE_NAME] @@ -230,18 +223,20 @@ class HueBridge: if not updated and (group is None or scene is None): await self.async_request_call(self.api.groups.update) await self.async_request_call(self.api.scenes.update) - await self.hue_activate_scene(call, updated=True) - return + return await self.hue_activate_scene(call, updated=True) if group is None: - LOGGER.warning("Unable to find group %s", group_name) - return + if not hide_warnings: + LOGGER.warning( + "Unable to find group %s" " on bridge %s", group_name, self.host + ) + return False if scene is None: LOGGER.warning("Unable to find scene %s", scene_name) - return + return False - await self.async_request_call(partial(group.set_action, scene=scene.id)) + return await self.async_request_call(partial(group.set_action, scene=scene.id)) async def handle_unauthorized_error(self): """Create a new config flow when the authorization is no longer valid.""" diff --git a/tests/components/hue/conftest.py b/tests/components/hue/conftest.py index 52ae43d65b4..094378f47c9 100644 --- a/tests/components/hue/conftest.py +++ b/tests/components/hue/conftest.py @@ -3,6 +3,7 @@ from collections import deque from aiohue.groups import Groups from aiohue.lights import Lights +from aiohue.scenes import Scenes from aiohue.sensors import Sensors import pytest @@ -10,7 +11,7 @@ from homeassistant import config_entries from homeassistant.components import hue from homeassistant.components.hue import sensor_base as hue_sensor_base -from tests.async_mock import Mock, patch +from tests.async_mock import AsyncMock, Mock, patch @pytest.fixture(autouse=True) @@ -65,6 +66,39 @@ def create_mock_bridge(hass): return bridge +@pytest.fixture +def mock_api(hass): + """Mock the Hue api.""" + api = Mock(initialize=AsyncMock()) + api.mock_requests = [] + api.mock_light_responses = deque() + api.mock_group_responses = deque() + api.mock_sensor_responses = deque() + api.mock_scene_responses = deque() + + async def mock_request(method, path, **kwargs): + kwargs["method"] = method + kwargs["path"] = path + api.mock_requests.append(kwargs) + + if path == "lights": + return api.mock_light_responses.popleft() + if path == "groups": + return api.mock_group_responses.popleft() + if path == "sensors": + return api.mock_sensor_responses.popleft() + if path == "scenes": + return api.mock_scene_responses.popleft() + return None + + 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) + return api + + @pytest.fixture def mock_bridge(hass): """Mock a Hue bridge.""" diff --git a/tests/components/hue/test_bridge.py b/tests/components/hue/test_bridge.py index 19b642b0283..06dd459723d 100644 --- a/tests/components/hue/test_bridge.py +++ b/tests/components/hue/test_bridge.py @@ -1,6 +1,8 @@ """Test Hue bridge.""" import pytest +from homeassistant import config_entries +from homeassistant.components import hue from homeassistant.components.hue import bridge, errors from homeassistant.components.hue.const import ( CONF_ALLOW_HUE_GROUPS, @@ -88,7 +90,7 @@ async def test_reset_unloads_entry_if_setup(hass): ), patch.object(hass.config_entries, "async_forward_entry_setup") as mock_forward: assert await hue_bridge.async_setup() is True - assert len(hass.services.async_services()) == 1 + assert len(hass.services.async_services()) == 0 assert len(mock_forward.mock_calls) == 3 with patch.object( @@ -120,3 +122,131 @@ async def test_handle_unauthorized(hass): assert hue_bridge.authorized is False assert len(mock_create.mock_calls) == 1 assert mock_create.mock_calls[0][1][1] == "1.2.3.4" + + +GROUP_RESPONSE = { + "group_1": { + "name": "Group 1", + "lights": ["1", "2"], + "type": "LightGroup", + "action": { + "on": True, + "bri": 254, + "hue": 10000, + "sat": 254, + "effect": "none", + "xy": [0.5, 0.5], + "ct": 250, + "alert": "select", + "colormode": "ct", + }, + "state": {"any_on": True, "all_on": False}, + } +} +SCENE_RESPONSE = { + "scene_1": { + "name": "Cozy dinner", + "lights": ["1", "2"], + "owner": "ffffffffe0341b1b376a2389376a2389", + "recycle": True, + "locked": False, + "appdata": {"version": 1, "data": "myAppData"}, + "picture": "", + "lastupdated": "2015-12-03T10:09:22", + "version": 2, + } +} + + +async def test_hue_activate_scene(hass, mock_api): + """Test successful hue_activate_scene.""" + config_entry = config_entries.ConfigEntry( + 1, + hue.DOMAIN, + "Mock Title", + {"host": "mock-host", "username": "mock-username"}, + "test", + config_entries.CONN_CLASS_LOCAL_POLL, + system_options={}, + options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, + ) + hue_bridge = bridge.HueBridge(hass, config_entry) + + mock_api.mock_group_responses.append(GROUP_RESPONSE) + mock_api.mock_scene_responses.append(SCENE_RESPONSE) + + with patch("aiohue.Bridge", return_value=mock_api), patch.object( + hass.config_entries, "async_forward_entry_setup" + ): + assert await hue_bridge.async_setup() is True + + assert hue_bridge.api is mock_api + + call = Mock() + 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) is None + + assert len(mock_api.mock_requests) == 3 + assert mock_api.mock_requests[2]["json"]["scene"] == "scene_1" + assert mock_api.mock_requests[2]["path"] == "groups/group_1/action" + + +async def test_hue_activate_scene_group_not_found(hass, mock_api): + """Test failed hue_activate_scene due to missing group.""" + config_entry = config_entries.ConfigEntry( + 1, + hue.DOMAIN, + "Mock Title", + {"host": "mock-host", "username": "mock-username"}, + "test", + config_entries.CONN_CLASS_LOCAL_POLL, + system_options={}, + options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, + ) + hue_bridge = bridge.HueBridge(hass, config_entry) + + mock_api.mock_group_responses.append({}) + mock_api.mock_scene_responses.append(SCENE_RESPONSE) + + with patch("aiohue.Bridge", return_value=mock_api), patch.object( + hass.config_entries, "async_forward_entry_setup" + ): + assert await hue_bridge.async_setup() is True + + assert hue_bridge.api is mock_api + + call = Mock() + 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) is False + + +async def test_hue_activate_scene_scene_not_found(hass, mock_api): + """Test failed hue_activate_scene due to missing scene.""" + config_entry = config_entries.ConfigEntry( + 1, + hue.DOMAIN, + "Mock Title", + {"host": "mock-host", "username": "mock-username"}, + "test", + config_entries.CONN_CLASS_LOCAL_POLL, + system_options={}, + options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, + ) + hue_bridge = bridge.HueBridge(hass, config_entry) + + mock_api.mock_group_responses.append(GROUP_RESPONSE) + mock_api.mock_scene_responses.append({}) + + with patch("aiohue.Bridge", return_value=mock_api), patch.object( + hass.config_entries, "async_forward_entry_setup" + ): + assert await hue_bridge.async_setup() is True + + assert hue_bridge.api is mock_api + + call = Mock() + 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) is False diff --git a/tests/components/hue/test_init_multiple_bridges.py b/tests/components/hue/test_init_multiple_bridges.py new file mode 100644 index 00000000000..32be7677398 --- /dev/null +++ b/tests/components/hue/test_init_multiple_bridges.py @@ -0,0 +1,185 @@ +"""Test Hue init with multiple bridges.""" + +from aiohue.groups import Groups +from aiohue.lights import Lights +from aiohue.scenes import Scenes +from aiohue.sensors import Sensors +import pytest + +from homeassistant import config_entries +from homeassistant.components import hue +from homeassistant.components.hue import sensor_base as hue_sensor_base +from homeassistant.setup import async_setup_component + +from tests.async_mock import Mock, patch + + +async def setup_component(hass): + """Hue component.""" + with patch.object(hue, "async_setup_entry", return_value=True): + assert ( + await async_setup_component( + hass, + hue.DOMAIN, + {}, + ) + is True + ) + + +async def test_hue_activate_scene_both_responds( + hass, mock_bridge1, mock_bridge2, mock_config_entry1, mock_config_entry2 +): + """Test that makes both bridges successfully activate a scene.""" + + await setup_component(hass) + + await setup_bridge(hass, mock_bridge1, mock_config_entry1) + await setup_bridge(hass, mock_bridge2, mock_config_entry2) + + with patch.object( + mock_bridge1, "hue_activate_scene", return_value=None + ) as mock_hue_activate_scene1, patch.object( + mock_bridge2, "hue_activate_scene", return_value=None + ) as mock_hue_activate_scene2: + await hass.services.async_call( + "hue", + "hue_activate_scene", + {"group_name": "group_2", "scene_name": "my_scene"}, + blocking=True, + ) + + mock_hue_activate_scene1.assert_called_once() + mock_hue_activate_scene2.assert_called_once() + + +async def test_hue_activate_scene_one_responds( + hass, mock_bridge1, mock_bridge2, mock_config_entry1, mock_config_entry2 +): + """Test that makes only one bridge successfully activate a scene.""" + + await setup_component(hass) + + await setup_bridge(hass, mock_bridge1, mock_config_entry1) + await setup_bridge(hass, mock_bridge2, mock_config_entry2) + + with patch.object( + mock_bridge1, "hue_activate_scene", return_value=None + ) as mock_hue_activate_scene1, patch.object( + mock_bridge2, "hue_activate_scene", return_value=False + ) as mock_hue_activate_scene2: + await hass.services.async_call( + "hue", + "hue_activate_scene", + {"group_name": "group_2", "scene_name": "my_scene"}, + blocking=True, + ) + + mock_hue_activate_scene1.assert_called_once() + mock_hue_activate_scene2.assert_called_once() + + +async def test_hue_activate_scene_zero_responds( + hass, mock_bridge1, mock_bridge2, mock_config_entry1, mock_config_entry2 +): + """Test that makes no bridge successfully activate a scene.""" + + await setup_component(hass) + + await setup_bridge(hass, mock_bridge1, mock_config_entry1) + await setup_bridge(hass, mock_bridge2, mock_config_entry2) + + with patch.object( + mock_bridge1, "hue_activate_scene", return_value=False + ) as mock_hue_activate_scene1, patch.object( + mock_bridge2, "hue_activate_scene", return_value=False + ) as mock_hue_activate_scene2: + await hass.services.async_call( + "hue", + "hue_activate_scene", + {"group_name": "group_2", "scene_name": "my_scene"}, + blocking=True, + ) + + # both were retried + assert mock_hue_activate_scene1.call_count == 2 + assert mock_hue_activate_scene2.call_count == 2 + + +async def setup_bridge(hass, mock_bridge, config_entry): + """Load the Hue light platform with the provided bridge.""" + 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() + + +@pytest.fixture +def mock_config_entry1(hass): + """Mock a config entry.""" + return create_config_entry() + + +@pytest.fixture +def mock_config_entry2(hass): + """Mock a config entry.""" + return create_config_entry() + + +def create_config_entry(): + """Mock a config entry.""" + return config_entries.ConfigEntry( + 1, + hue.DOMAIN, + "Mock Title", + {"host": "mock-host"}, + "test", + config_entries.CONN_CLASS_LOCAL_POLL, + system_options={}, + ) + + +@pytest.fixture +def mock_bridge1(hass): + """Mock a Hue bridge.""" + return create_mock_bridge(hass) + + +@pytest.fixture +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, + ) + 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