From 055fb999380032428f4e2305859a25f2976b9fa1 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 31 Dec 2021 05:46:52 +0100 Subject: [PATCH] Hue allow per-device availability override (#63025) Co-authored-by: Paulus Schoutsen --- homeassistant/components/hue/config_flow.py | 66 +++++++++++++++---- homeassistant/components/hue/const.py | 1 + homeassistant/components/hue/strings.json | 3 +- .../components/hue/translations/en.json | 3 +- .../components/hue/translations/nl.json | 3 +- homeassistant/components/hue/v2/entity.py | 62 ++++++++++------- homeassistant/components/hue/v2/group.py | 2 +- tests/components/hue/test_config_flow.py | 26 +++++++- 8 files changed, 122 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 49fca2158d5..987afe17012 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -17,13 +17,15 @@ from homeassistant.components import ssdp, zeroconf from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import aiohttp_client +from homeassistant.helpers import aiohttp_client, device_registry +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( CONF_ALLOW_HUE_GROUPS, CONF_ALLOW_UNREACHABLE, CONF_API_VERSION, + CONF_IGNORE_AVAILABILITY, DEFAULT_ALLOW_HUE_GROUPS, DEFAULT_ALLOW_UNREACHABLE, DOMAIN, @@ -46,17 +48,11 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: config_entries.ConfigEntry, - ) -> HueOptionsFlowHandler: + ) -> HueV1OptionsFlowHandler | HueV2OptionsFlowHandler: """Get the options flow for this handler.""" - return HueOptionsFlowHandler(config_entry) - - @classmethod - @callback - def async_supports_options_flow( - cls, config_entry: config_entries.ConfigEntry - ) -> bool: - """Return options flow support for this handler.""" - return config_entry.data.get(CONF_API_VERSION, 1) == 1 + if config_entry.data.get(CONF_API_VERSION, 1) == 1: + return HueV1OptionsFlowHandler(config_entry) + return HueV2OptionsFlowHandler(config_entry) def __init__(self) -> None: """Initialize the Hue flow.""" @@ -288,8 +284,8 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_link() -class HueOptionsFlowHandler(config_entries.OptionsFlow): - """Handle Hue options.""" +class HueV1OptionsFlowHandler(config_entries.OptionsFlow): + """Handle Hue options for V1 implementation.""" def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize Hue options flow.""" @@ -319,3 +315,47 @@ class HueOptionsFlowHandler(config_entries.OptionsFlow): } ), ) + + +class HueV2OptionsFlowHandler(config_entries.OptionsFlow): + """Handle Hue options for V2 implementation.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize Hue options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input: ConfigType | None = None) -> FlowResult: + """Manage Hue options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + # create a list of Hue device ID's that the user can select + # to ignore availability status + dev_reg = device_registry.async_get(self.hass) + entries = device_registry.async_entries_for_config_entry( + dev_reg, self.config_entry.entry_id + ) + dev_ids = { + identifier[1]: entry.name + for entry in entries + for identifier in entry.identifiers + if identifier[0] == DOMAIN + } + # filter any non existing device id's from the list + cur_ids = [ + item + for item in self.config_entry.options.get(CONF_IGNORE_AVAILABILITY, []) + if item in dev_ids + ] + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_IGNORE_AVAILABILITY, + default=cur_ids, + ): cv.multi_select(dev_ids), + } + ), + ) diff --git a/homeassistant/components/hue/const.py b/homeassistant/components/hue/const.py index eef453fb83d..798148b92c0 100644 --- a/homeassistant/components/hue/const.py +++ b/homeassistant/components/hue/const.py @@ -3,6 +3,7 @@ DOMAIN = "hue" CONF_API_VERSION = "api_version" +CONF_IGNORE_AVAILABILITY = "ignore_availability" CONF_SUBTYPE = "subtype" diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 458e21419ab..266f26016c4 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -70,7 +70,8 @@ "data": { "allow_hue_groups": "Allow Hue groups", "allow_hue_scenes": "Allow Hue scenes", - "allow_unreachable": "Allow unreachable bulbs to report their state correctly" + "allow_unreachable": "Allow unreachable bulbs to report their state correctly", + "ignore_availability": "Ignore connectivity status for the given devices" } } } diff --git a/homeassistant/components/hue/translations/en.json b/homeassistant/components/hue/translations/en.json index f0b8e560729..7757aca9373 100644 --- a/homeassistant/components/hue/translations/en.json +++ b/homeassistant/components/hue/translations/en.json @@ -69,7 +69,8 @@ "data": { "allow_hue_groups": "Allow Hue groups", "allow_hue_scenes": "Allow Hue scenes", - "allow_unreachable": "Allow unreachable bulbs to report their state correctly" + "allow_unreachable": "Allow unreachable bulbs to report their state correctly", + "ignore_availability": "Ignore connectivity status for the given devices" } } } diff --git a/homeassistant/components/hue/translations/nl.json b/homeassistant/components/hue/translations/nl.json index 12eeaf71af0..7997a03c4aa 100644 --- a/homeassistant/components/hue/translations/nl.json +++ b/homeassistant/components/hue/translations/nl.json @@ -69,7 +69,8 @@ "data": { "allow_hue_groups": "Sta Hue-groepen toe", "allow_hue_scenes": "Sta Hue sc\u00e8nes toe", - "allow_unreachable": "Onbereikbare lampen toestaan hun status correct te melden" + "allow_unreachable": "Onbereikbare lampen toestaan hun status correct te melden", + "ignore_availability": "Negeer beschikbaarheid status voor deze apparaten" } } } diff --git a/homeassistant/components/hue/v2/entity.py b/homeassistant/components/hue/v2/entity.py index 8253d4ffbef..70987fff2be 100644 --- a/homeassistant/components/hue/v2/entity.py +++ b/homeassistant/components/hue/v2/entity.py @@ -12,7 +12,7 @@ from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry from ..bridge import HueBridge -from ..const import DOMAIN +from ..const import CONF_IGNORE_AVAILABILITY, DOMAIN RESOURCE_TYPE_NAMES = { # a simple mapping of hue resource type to Hass name @@ -71,7 +71,7 @@ class HueBaseEntity(Entity): async def async_added_to_hass(self) -> None: """Call when entity is added.""" - self._check_availability_workaround() + self._check_availability() # Add value_changed callbacks. self.async_on_remove( self.controller.subscribe( @@ -80,7 +80,7 @@ class HueBaseEntity(Entity): (EventType.RESOURCE_UPDATED, EventType.RESOURCE_DELETED), ) ) - # also subscribe to device update event to catch devicer changes (e.g. name) + # also subscribe to device update event to catch device changes (e.g. name) if self.device is None: return self.async_on_remove( @@ -92,25 +92,27 @@ class HueBaseEntity(Entity): ) # subscribe to zigbee_connectivity to catch availability changes if zigbee := self.bridge.api.devices.get_zigbee_connectivity(self.device.id): - self.bridge.api.sensors.zigbee_connectivity.subscribe( - self._handle_event, - zigbee.id, - EventType.RESOURCE_UPDATED, + self.async_on_remove( + self.bridge.api.sensors.zigbee_connectivity.subscribe( + self._handle_event, + zigbee.id, + EventType.RESOURCE_UPDATED, + ) ) @property def available(self) -> bool: """Return entity availability.""" + # entities without a device attached should be always available if self.device is None: - # entities without a device attached should be always available return True + # the zigbee connectivity sensor itself should be always available if self.resource.type == ResourceTypes.ZIGBEE_CONNECTIVITY: - # the zigbee connectivity sensor itself should be always available return True if self._ignore_availability: return True + # all device-attached entities get availability from the zigbee connectivity if zigbee := self.bridge.api.devices.get_zigbee_connectivity(self.device.id): - # all device-attached entities get availability from the zigbee connectivity return zigbee.status == ConnectivityServiceStatus.CONNECTED return True @@ -130,30 +132,41 @@ class HueBaseEntity(Entity): ent_reg.async_remove(self.entity_id) else: self.logger.debug("Received status update for %s", self.entity_id) - self._check_availability_workaround() + self._check_availability() self.on_update() self.async_write_ha_state() @callback - def _check_availability_workaround(self): + def _check_availability(self): """Check availability of the device.""" - if self.resource.type != ResourceTypes.LIGHT: - return + # return if we already processed this entity if self._ignore_availability is not None: - # already processed return + # only do the availability check for entities connected to a device + if self.device is None: + return + # ignore availability if user added device to ignore list + if self.device.id in self.bridge.config_entry.options.get( + CONF_IGNORE_AVAILABILITY, [] + ): + self._ignore_availability = True + self.logger.info( + "Device %s is configured to ignore availability status. ", + self.name, + ) + return + # certified products (normally) report their state correctly + # no need for workaround/reporting if self.device.product_data.certified: - # certified products report their state correctly self._ignore_availability = False return # some (3th party) Hue lights report their connection status incorrectly # causing the zigbee availability to report as disconnected while in fact - # it can be controlled. Although this is in fact something the device manufacturer - # should fix, we work around it here. If the light is reported unavailable + # it can be controlled. If the light is reported unavailable # by the zigbee connectivity but the state changes its considered as a # malfunctioning device and we report it. - # while the user should actually fix this issue instead of ignoring it, we - # ignore the availability for this light from this point. + # While the user should actually fix this issue, we allow to + # ignore the availability for this light/device from the config options. cur_state = self.resource.on.on if self._last_state is None: self._last_state = cur_state @@ -166,9 +179,10 @@ class HueBaseEntity(Entity): # the device state changed from on->off or off->on # while it was reported as not connected! self.logger.warning( - "Light %s changed state while reported as disconnected. " - "This might be an indicator that routing is not working for this device. " - "Home Assistant will ignore availability for this light from now on. " + "Device %s changed state while reported as disconnected. " + "This might be an indicator that routing is not working for this device " + "or the device is having connectivity issues. " + "You can disable availability reporting for this device in the Hue options. " "Device details: %s - %s (%s) fw: %s", self.name, self.device.product_data.manufacturer_name, @@ -178,6 +192,4 @@ class HueBaseEntity(Entity): ) # do we want to store this in some persistent storage? self._ignore_availability = True - else: - self._ignore_availability = False self._last_state = cur_state diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index 775c2dd1d76..b8fdb0b0b1d 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -102,7 +102,7 @@ class GroupedHueLight(HueBaseEntity, LightEntity): # Entities for Hue groups are disabled by default # unless they were enabled in old version (legacy option) - self._attr_entity_registry_enabled_default = bridge.config_entry.data.get( + self._attr_entity_registry_enabled_default = bridge.config_entry.options.get( CONF_ALLOW_HUE_GROUPS, False ) diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index 6ce8ff3e1c4..0aa032ddb0d 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -11,6 +11,7 @@ from homeassistant import config_entries from homeassistant.components import ssdp, zeroconf from homeassistant.components.hue import config_flow, const from homeassistant.components.hue.errors import CannotConnect +from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @@ -701,12 +702,33 @@ async def test_options_flow_v2(hass): """Test options config flow for a V2 bridge.""" entry = MockConfigEntry( domain="hue", - unique_id="v2bridge", + unique_id="aabbccddeeff", data={"host": "0.0.0.0", "api_version": 2}, ) entry.add_to_hass(hass) - assert config_flow.HueFlowHandler.async_supports_options_flow(entry) is False + dev_reg = dr.async_get(hass) + mock_dev_id = "aabbccddee" + dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={(const.DOMAIN, mock_dev_id)} + ) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "init" + schema = result["data_schema"].schema + assert _get_schema_default(schema, const.CONF_IGNORE_AVAILABILITY) == [] + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={const.CONF_IGNORE_AVAILABILITY: [mock_dev_id]}, + ) + + assert result["type"] == "create_entry" + assert result["data"] == { + const.CONF_IGNORE_AVAILABILITY: [mock_dev_id], + } async def test_bridge_zeroconf(hass, aioclient_mock):