Multiple Hue activate scene (#41353)

This commit is contained in:
fnurgel 2020-10-11 21:01:49 +02:00 committed by GitHub
parent 1c6d0d138d
commit 366006e0aa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 402 additions and 17 deletions

View File

@ -1,4 +1,5 @@
"""Support for the Philips Hue system.""" """Support for the Philips Hue system."""
import asyncio
import logging import logging
from aiohue.util import normalize_bridge_id 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.components import persistent_notification
from homeassistant.helpers import device_registry as dr 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 ( from .const import (
CONF_ALLOW_HUE_GROUPS, CONF_ALLOW_HUE_GROUPS,
CONF_ALLOW_UNREACHABLE, CONF_ALLOW_UNREACHABLE,
@ -21,6 +28,39 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup(hass, config): async def async_setup(hass, config):
"""Set up the Hue platform.""" """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] = {} hass.data[DOMAIN] = {}
return True return True
@ -131,4 +171,5 @@ async def async_setup_entry(
async def async_unload_entry(hass, entry): async def async_unload_entry(hass, entry):
"""Unload a config entry.""" """Unload a config entry."""
bridge = hass.data[DOMAIN].pop(entry.entry_id) bridge = hass.data[DOMAIN].pop(entry.entry_id)
hass.services.async_remove(DOMAIN, SERVICE_HUE_SCENE)
return await bridge.async_reset() return await bridge.async_reset()

View File

@ -19,7 +19,6 @@ from .const import (
CONF_ALLOW_UNREACHABLE, CONF_ALLOW_UNREACHABLE,
DEFAULT_ALLOW_HUE_GROUPS, DEFAULT_ALLOW_HUE_GROUPS,
DEFAULT_ALLOW_UNREACHABLE, DEFAULT_ALLOW_UNREACHABLE,
DOMAIN,
LOGGER, LOGGER,
) )
from .errors import AuthenticationRequired, CannotConnect from .errors import AuthenticationRequired, CannotConnect
@ -117,10 +116,6 @@ class HueBridge:
hass.config_entries.async_forward_entry_setup(self.config_entry, "sensor") 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( self.parallel_updates_semaphore = asyncio.Semaphore(
3 if self.api.config.modelid == "BSB001" else 10 3 if self.api.config.modelid == "BSB001" else 10
) )
@ -179,8 +174,6 @@ class HueBridge:
if self.api is None: if self.api is None:
return True return True
self.hass.services.async_remove(DOMAIN, SERVICE_HUE_SCENE)
while self.reset_jobs: while self.reset_jobs:
self.reset_jobs.pop()() self.reset_jobs.pop()()
@ -204,7 +197,7 @@ class HueBridge:
# None and True are OK # None and True are OK
return False not in results 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.""" """Service to call directly into bridge to set scenes."""
group_name = call.data[ATTR_GROUP_NAME] group_name = call.data[ATTR_GROUP_NAME]
scene_name = call.data[ATTR_SCENE_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): 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.groups.update)
await self.async_request_call(self.api.scenes.update) await self.async_request_call(self.api.scenes.update)
await self.hue_activate_scene(call, updated=True) return await self.hue_activate_scene(call, updated=True)
return
if group is None: if group is None:
LOGGER.warning("Unable to find group %s", group_name) if not hide_warnings:
return LOGGER.warning(
"Unable to find group %s" " on bridge %s", group_name, self.host
)
return False
if scene is None: if scene is None:
LOGGER.warning("Unable to find scene %s", scene_name) 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): async def handle_unauthorized_error(self):
"""Create a new config flow when the authorization is no longer valid.""" """Create a new config flow when the authorization is no longer valid."""

View File

@ -3,6 +3,7 @@ from collections import deque
from aiohue.groups import Groups from aiohue.groups import Groups
from aiohue.lights import Lights from aiohue.lights import Lights
from aiohue.scenes import Scenes
from aiohue.sensors import Sensors from aiohue.sensors import Sensors
import pytest import pytest
@ -10,7 +11,7 @@ from homeassistant import config_entries
from homeassistant.components import hue from homeassistant.components import hue
from homeassistant.components.hue import sensor_base as hue_sensor_base 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) @pytest.fixture(autouse=True)
@ -65,6 +66,39 @@ def create_mock_bridge(hass):
return bridge 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 @pytest.fixture
def mock_bridge(hass): def mock_bridge(hass):
"""Mock a Hue bridge.""" """Mock a Hue bridge."""

View File

@ -1,6 +1,8 @@
"""Test Hue bridge.""" """Test Hue bridge."""
import pytest import pytest
from homeassistant import config_entries
from homeassistant.components import hue
from homeassistant.components.hue import bridge, errors from homeassistant.components.hue import bridge, errors
from homeassistant.components.hue.const import ( from homeassistant.components.hue.const import (
CONF_ALLOW_HUE_GROUPS, 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: ), 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
assert len(hass.services.async_services()) == 1 assert len(hass.services.async_services()) == 0
assert len(mock_forward.mock_calls) == 3 assert len(mock_forward.mock_calls) == 3
with patch.object( with patch.object(
@ -120,3 +122,131 @@ async def test_handle_unauthorized(hass):
assert hue_bridge.authorized is False assert hue_bridge.authorized is False
assert len(mock_create.mock_calls) == 1 assert len(mock_create.mock_calls) == 1
assert mock_create.mock_calls[0][1][1] == "1.2.3.4" 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

View File

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