diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index 6a654744397..5015ec669aa 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -5,12 +5,12 @@ import aiohue import async_timeout import voluptuous as vol -from homeassistant import config_entries from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_validation as cv from .const import DOMAIN, LOGGER from .errors import AuthenticationRequired, CannotConnect +from .helpers import create_config_flow SERVICE_HUE_SCENE = "hue_activate_scene" ATTR_GROUP_NAME = "group_name" @@ -30,6 +30,7 @@ class HueBridge: self.allow_unreachable = allow_unreachable self.allow_groups = allow_groups self.available = True + self.authorized = False self.api = None @property @@ -49,13 +50,7 @@ class HueBridge: # We are going to fail the config entry setup and initiate a new # linking procedure. When linking succeeds, it will remove the # old config entry. - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={"host": host}, - ) - ) + create_config_flow(hass, host) return False except CannotConnect: @@ -82,6 +77,7 @@ class HueBridge: DOMAIN, SERVICE_HUE_SCENE, self.hue_activate_scene, schema=SCENE_SCHEMA ) + self.authorized = True return True async def async_reset(self): @@ -155,6 +151,17 @@ class HueBridge: await group.set_action(scene=scene.id) + async def handle_unauthorized_error(self): + """Create a new config flow when the authorization is no longer valid.""" + if not self.authorized: + # we already created a new config flow, no need to do it again + return + LOGGER.error( + "Unable to authorize to bridge %s, setup the linking again.", self.host + ) + self.authorized = False + create_config_flow(self.hass, self.host) + async def get_bridge(hass, host, username=None): """Create a bridge object and verify authentication.""" diff --git a/homeassistant/components/hue/helpers.py b/homeassistant/components/hue/helpers.py index 971509ab647..af0f996b537 100644 --- a/homeassistant/components/hue/helpers.py +++ b/homeassistant/components/hue/helpers.py @@ -1,6 +1,7 @@ """Helper functions for Philips Hue.""" from homeassistant.helpers.device_registry import async_get_registry as get_dev_reg from homeassistant.helpers.entity_registry import async_get_registry as get_ent_reg +from homeassistant import config_entries from .const import DOMAIN @@ -31,3 +32,14 @@ async def remove_devices(hass, config_entry, api_ids, current): for item_id in removed_items: del current[item_id] + + +def create_config_flow(hass, host): + """Start a config flow.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={"host": host}, + ) + ) diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index 041eb76c1d3..d58e4608b65 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -189,6 +189,9 @@ async def async_update_items( progress_waiting, ): """Update either groups or lights from the bridge.""" + if not bridge.authorized: + return + if is_group: api_type = "group" api = bridge.api.groups @@ -200,6 +203,9 @@ async def async_update_items( start = monotonic() with async_timeout.timeout(4): await api.update() + except aiohue.Unauthorized: + await bridge.handle_unauthorized_error() + return except (asyncio.TimeoutError, aiohue.AiohueException) as err: _LOGGER.debug("Failed to fetch %s: %s", api_type, err) @@ -337,10 +343,14 @@ class HueLight(Light): @property def available(self): """Return if light is available.""" - return self.bridge.available and ( - self.is_group - or self.bridge.allow_unreachable - or self.light.state["reachable"] + return ( + self.bridge.available + and self.bridge.authorized + and ( + self.is_group + or self.bridge.allow_unreachable + or self.light.state["reachable"] + ) ) @property diff --git a/homeassistant/components/hue/sensor_base.py b/homeassistant/components/hue/sensor_base.py index 7236dfbd886..62bd98df3a2 100644 --- a/homeassistant/components/hue/sensor_base.py +++ b/homeassistant/components/hue/sensor_base.py @@ -4,7 +4,7 @@ from datetime import timedelta import logging from time import monotonic -from aiohue import AiohueException +from aiohue import AiohueException, Unauthorized from aiohue.sensors import TYPE_ZLL_PRESENCE import async_timeout @@ -80,6 +80,11 @@ class SensorManager: async def async_update_bridge(now): """Will update sensors from the bridge.""" + + # don't update when we are not authorized + if not self.bridge.authorized: + return + await self.async_update_items() async_track_point_in_utc_time( @@ -96,6 +101,9 @@ class SensorManager: start = monotonic() with async_timeout.timeout(4): await api.update() + except Unauthorized: + await self.bridge.handle_unauthorized_error() + return except (asyncio.TimeoutError, AiohueException) as err: _LOGGER.debug("Failed to fetch sensor: %s", err) @@ -220,8 +228,10 @@ class GenericHueSensor: @property def available(self): """Return if sensor is available.""" - return self.bridge.available and ( - self.bridge.allow_unreachable or self.sensor.config["reachable"] + return ( + self.bridge.available + and self.bridge.authorized + and (self.bridge.allow_unreachable or self.sensor.config["reachable"]) ) @property diff --git a/tests/components/hue/test_bridge.py b/tests/components/hue/test_bridge.py index a426d35abf7..7265b468714 100644 --- a/tests/components/hue/test_bridge.py +++ b/tests/components/hue/test_bridge.py @@ -91,3 +91,25 @@ async def test_reset_unloads_entry_if_setup(): assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 3 assert len(hass.services.async_remove.mock_calls) == 1 + + +async def test_handle_unauthorized(): + """Test handling an unauthorized error on update.""" + hass = Mock() + entry = Mock() + entry.data = {"host": "1.2.3.4", "username": "mock-username"} + hue_bridge = bridge.HueBridge(hass, entry, False, False) + + with patch.object(bridge, "get_bridge", return_value=mock_coro(Mock())): + assert await hue_bridge.async_setup() is True + + assert hue_bridge.authorized is True + + await hue_bridge.handle_unauthorized_error() + + assert hue_bridge.authorized is False + assert len(hass.async_create_task.mock_calls) == 4 + assert len(hass.config_entries.flow.async_init.mock_calls) == 1 + assert hass.config_entries.flow.async_init.mock_calls[0][2]["data"] == { + "host": "1.2.3.4" + } diff --git a/tests/components/hue/test_light.py b/tests/components/hue/test_light.py index 582cc185bc8..88c527a50ca 100644 --- a/tests/components/hue/test_light.py +++ b/tests/components/hue/test_light.py @@ -180,6 +180,7 @@ def mock_bridge(hass): """Mock a Hue bridge.""" bridge = Mock( available=True, + authorized=True, allow_unreachable=False, allow_groups=False, api=Mock(), @@ -598,13 +599,13 @@ async def test_update_timeout(hass, mock_bridge): async def test_update_unauthorized(hass, mock_bridge): - """Test bridge marked as not available if unauthorized during update.""" + """Test bridge marked as not authorized if unauthorized during update.""" mock_bridge.api.lights.update = Mock(side_effect=aiohue.Unauthorized) mock_bridge.api.groups.update = Mock(side_effect=aiohue.Unauthorized) await setup_bridge(hass, mock_bridge) assert len(mock_bridge.mock_requests) == 0 assert len(hass.states.async_all()) == 0 - assert mock_bridge.available is False + assert len(mock_bridge.handle_unauthorized_error.mock_calls) == 1 async def test_light_turn_on_service(hass, mock_bridge): diff --git a/tests/components/hue/test_sensor_base.py b/tests/components/hue/test_sensor_base.py index 72ac816483a..ba259dccf71 100644 --- a/tests/components/hue/test_sensor_base.py +++ b/tests/components/hue/test_sensor_base.py @@ -256,6 +256,7 @@ def create_mock_bridge(): """Create a mock Hue bridge.""" bridge = Mock( available=True, + authorized=True, allow_unreachable=False, allow_groups=False, api=Mock(), @@ -425,6 +426,36 @@ async def test_new_sensor_discovered(hass, mock_bridge): assert temperature.state == "17.75" +async def test_sensor_removed(hass, mock_bridge): + """Test if 2nd update has removed sensor.""" + mock_bridge.mock_sensor_responses.append(SENSOR_RESPONSE) + + await setup_bridge(hass, mock_bridge) + assert len(mock_bridge.mock_requests) == 1 + assert len(hass.states.async_all()) == 6 + + mock_bridge.mock_sensor_responses.clear() + keys = ("1", "2", "3") + mock_bridge.mock_sensor_responses.append({k: SENSOR_RESPONSE[k] for k in keys}) + + # Force updates to run again + sm_key = hue_sensor_base.SENSOR_MANAGER_FORMAT.format("mock-host") + sm = hass.data[hue.DOMAIN][sm_key] + await sm.async_update_items() + + # To flush out the service call to update the group + await hass.async_block_till_done() + + assert len(mock_bridge.mock_requests) == 2 + assert len(hass.states.async_all()) == 3 + + sensor = hass.states.get("binary_sensor.living_room_sensor_motion") + assert sensor is not None + + removed_sensor = hass.states.get("binary_sensor.kitchen_sensor_motion") + assert removed_sensor is None + + async def test_update_timeout(hass, mock_bridge): """Test bridge marked as not available if timeout error during update.""" mock_bridge.api.sensors.update = Mock(side_effect=asyncio.TimeoutError) @@ -435,9 +466,9 @@ async def test_update_timeout(hass, mock_bridge): async def test_update_unauthorized(hass, mock_bridge): - """Test bridge marked as not available if unauthorized during update.""" + """Test bridge marked as not authorized if unauthorized during update.""" mock_bridge.api.sensors.update = Mock(side_effect=aiohue.Unauthorized) await setup_bridge(hass, mock_bridge) assert len(mock_bridge.mock_requests) == 0 assert len(hass.states.async_all()) == 0 - assert mock_bridge.available is False + assert len(mock_bridge.handle_unauthorized_error.mock_calls) == 1