Hue allow per-device availability override (#63025)

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
Marcel van der Veldt 2021-12-31 05:46:52 +01:00 committed by GitHub
parent ebe9853e6f
commit 055fb99938
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 122 additions and 44 deletions

View File

@ -17,13 +17,15 @@ from homeassistant.components import ssdp, zeroconf
from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.const import CONF_API_KEY, CONF_HOST
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult 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 homeassistant.helpers.typing import ConfigType
from .const import ( from .const import (
CONF_ALLOW_HUE_GROUPS, CONF_ALLOW_HUE_GROUPS,
CONF_ALLOW_UNREACHABLE, CONF_ALLOW_UNREACHABLE,
CONF_API_VERSION, CONF_API_VERSION,
CONF_IGNORE_AVAILABILITY,
DEFAULT_ALLOW_HUE_GROUPS, DEFAULT_ALLOW_HUE_GROUPS,
DEFAULT_ALLOW_UNREACHABLE, DEFAULT_ALLOW_UNREACHABLE,
DOMAIN, DOMAIN,
@ -46,17 +48,11 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
@callback @callback
def async_get_options_flow( def async_get_options_flow(
config_entry: config_entries.ConfigEntry, config_entry: config_entries.ConfigEntry,
) -> HueOptionsFlowHandler: ) -> HueV1OptionsFlowHandler | HueV2OptionsFlowHandler:
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return HueOptionsFlowHandler(config_entry) if config_entry.data.get(CONF_API_VERSION, 1) == 1:
return HueV1OptionsFlowHandler(config_entry)
@classmethod return HueV2OptionsFlowHandler(config_entry)
@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
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize the Hue flow.""" """Initialize the Hue flow."""
@ -288,8 +284,8 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_link() return await self.async_step_link()
class HueOptionsFlowHandler(config_entries.OptionsFlow): class HueV1OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle Hue options.""" """Handle Hue options for V1 implementation."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None: def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize Hue options flow.""" """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),
}
),
)

View File

@ -3,6 +3,7 @@
DOMAIN = "hue" DOMAIN = "hue"
CONF_API_VERSION = "api_version" CONF_API_VERSION = "api_version"
CONF_IGNORE_AVAILABILITY = "ignore_availability"
CONF_SUBTYPE = "subtype" CONF_SUBTYPE = "subtype"

View File

@ -70,7 +70,8 @@
"data": { "data": {
"allow_hue_groups": "Allow Hue groups", "allow_hue_groups": "Allow Hue groups",
"allow_hue_scenes": "Allow Hue scenes", "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"
} }
} }
} }

View File

@ -69,7 +69,8 @@
"data": { "data": {
"allow_hue_groups": "Allow Hue groups", "allow_hue_groups": "Allow Hue groups",
"allow_hue_scenes": "Allow Hue scenes", "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"
} }
} }
} }

View File

@ -69,7 +69,8 @@
"data": { "data": {
"allow_hue_groups": "Sta Hue-groepen toe", "allow_hue_groups": "Sta Hue-groepen toe",
"allow_hue_scenes": "Sta Hue sc\u00e8nes 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"
} }
} }
} }

View File

@ -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 homeassistant.helpers.entity_registry import async_get as async_get_entity_registry
from ..bridge import HueBridge from ..bridge import HueBridge
from ..const import DOMAIN from ..const import CONF_IGNORE_AVAILABILITY, DOMAIN
RESOURCE_TYPE_NAMES = { RESOURCE_TYPE_NAMES = {
# a simple mapping of hue resource type to Hass name # 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: async def async_added_to_hass(self) -> None:
"""Call when entity is added.""" """Call when entity is added."""
self._check_availability_workaround() self._check_availability()
# Add value_changed callbacks. # Add value_changed callbacks.
self.async_on_remove( self.async_on_remove(
self.controller.subscribe( self.controller.subscribe(
@ -80,7 +80,7 @@ class HueBaseEntity(Entity):
(EventType.RESOURCE_UPDATED, EventType.RESOURCE_DELETED), (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: if self.device is None:
return return
self.async_on_remove( self.async_on_remove(
@ -92,25 +92,27 @@ class HueBaseEntity(Entity):
) )
# subscribe to zigbee_connectivity to catch availability changes # subscribe to zigbee_connectivity to catch availability changes
if zigbee := self.bridge.api.devices.get_zigbee_connectivity(self.device.id): if zigbee := self.bridge.api.devices.get_zigbee_connectivity(self.device.id):
self.bridge.api.sensors.zigbee_connectivity.subscribe( self.async_on_remove(
self._handle_event, self.bridge.api.sensors.zigbee_connectivity.subscribe(
zigbee.id, self._handle_event,
EventType.RESOURCE_UPDATED, zigbee.id,
EventType.RESOURCE_UPDATED,
)
) )
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return entity availability.""" """Return entity availability."""
# entities without a device attached should be always available
if self.device is None: if self.device is None:
# entities without a device attached should be always available
return True return True
# the zigbee connectivity sensor itself should be always available
if self.resource.type == ResourceTypes.ZIGBEE_CONNECTIVITY: if self.resource.type == ResourceTypes.ZIGBEE_CONNECTIVITY:
# the zigbee connectivity sensor itself should be always available
return True return True
if self._ignore_availability: if self._ignore_availability:
return True return True
# all device-attached entities get availability from the zigbee connectivity
if zigbee := self.bridge.api.devices.get_zigbee_connectivity(self.device.id): 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 zigbee.status == ConnectivityServiceStatus.CONNECTED
return True return True
@ -130,30 +132,41 @@ class HueBaseEntity(Entity):
ent_reg.async_remove(self.entity_id) ent_reg.async_remove(self.entity_id)
else: else:
self.logger.debug("Received status update for %s", self.entity_id) self.logger.debug("Received status update for %s", self.entity_id)
self._check_availability_workaround() self._check_availability()
self.on_update() self.on_update()
self.async_write_ha_state() self.async_write_ha_state()
@callback @callback
def _check_availability_workaround(self): def _check_availability(self):
"""Check availability of the device.""" """Check availability of the device."""
if self.resource.type != ResourceTypes.LIGHT: # return if we already processed this entity
return
if self._ignore_availability is not None: if self._ignore_availability is not None:
# already processed
return 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: if self.device.product_data.certified:
# certified products report their state correctly
self._ignore_availability = False self._ignore_availability = False
return return
# some (3th party) Hue lights report their connection status incorrectly # some (3th party) Hue lights report their connection status incorrectly
# causing the zigbee availability to report as disconnected while in fact # causing the zigbee availability to report as disconnected while in fact
# it can be controlled. Although this is in fact something the device manufacturer # it can be controlled. If the light is reported unavailable
# should fix, we work around it here. If the light is reported unavailable
# by the zigbee connectivity but the state changes its considered as a # by the zigbee connectivity but the state changes its considered as a
# malfunctioning device and we report it. # malfunctioning device and we report it.
# while the user should actually fix this issue instead of ignoring it, we # While the user should actually fix this issue, we allow to
# ignore the availability for this light from this point. # ignore the availability for this light/device from the config options.
cur_state = self.resource.on.on cur_state = self.resource.on.on
if self._last_state is None: if self._last_state is None:
self._last_state = cur_state self._last_state = cur_state
@ -166,9 +179,10 @@ class HueBaseEntity(Entity):
# the device state changed from on->off or off->on # the device state changed from on->off or off->on
# while it was reported as not connected! # while it was reported as not connected!
self.logger.warning( self.logger.warning(
"Light %s changed state while reported as disconnected. " "Device %s changed state while reported as disconnected. "
"This might be an indicator that routing is not working for this device. " "This might be an indicator that routing is not working for this device "
"Home Assistant will ignore availability for this light from now on. " "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", "Device details: %s - %s (%s) fw: %s",
self.name, self.name,
self.device.product_data.manufacturer_name, self.device.product_data.manufacturer_name,
@ -178,6 +192,4 @@ class HueBaseEntity(Entity):
) )
# do we want to store this in some persistent storage? # do we want to store this in some persistent storage?
self._ignore_availability = True self._ignore_availability = True
else:
self._ignore_availability = False
self._last_state = cur_state self._last_state = cur_state

View File

@ -102,7 +102,7 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
# Entities for Hue groups are disabled by default # Entities for Hue groups are disabled by default
# unless they were enabled in old version (legacy option) # 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 CONF_ALLOW_HUE_GROUPS, False
) )

View File

@ -11,6 +11,7 @@ from homeassistant import config_entries
from homeassistant.components import ssdp, zeroconf from homeassistant.components import ssdp, zeroconf
from homeassistant.components.hue import config_flow, const from homeassistant.components.hue import config_flow, const
from homeassistant.components.hue.errors import CannotConnect from homeassistant.components.hue.errors import CannotConnect
from homeassistant.helpers import device_registry as dr
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -701,12 +702,33 @@ async def test_options_flow_v2(hass):
"""Test options config flow for a V2 bridge.""" """Test options config flow for a V2 bridge."""
entry = MockConfigEntry( entry = MockConfigEntry(
domain="hue", domain="hue",
unique_id="v2bridge", unique_id="aabbccddeeff",
data={"host": "0.0.0.0", "api_version": 2}, data={"host": "0.0.0.0", "api_version": 2},
) )
entry.add_to_hass(hass) 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): async def test_bridge_zeroconf(hass, aioclient_mock):