From 220beefb89fb4b62e80412137398f13ea011fc41 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Apr 2022 01:34:44 -1000 Subject: [PATCH] Prevent HomeKit from offering hidden entities (#69042) --- .../components/homekit/config_flow.py | 47 ++++++----- tests/components/homekit/test_config_flow.py | 78 +++++++++++++++++++ 2 files changed, 107 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index a147c8dcb5d..79193cd3dac 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -466,7 +466,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow): entity_filter = self.hk_options.get(CONF_FILTER, {}) entities = entity_filter.get(CONF_INCLUDE_ENTITIES, []) - all_supported_entities = _async_get_matching_entities(self.hass, domains) + all_supported_entities = _async_get_matching_entities( + self.hass, domains, include_entity_category=True + ) # In accessory mode we can only have one default_value = next( iter( @@ -505,7 +507,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow): entity_filter = self.hk_options.get(CONF_FILTER, {}) entities = entity_filter.get(CONF_INCLUDE_ENTITIES, []) - all_supported_entities = _async_get_matching_entities(self.hass, domains) + all_supported_entities = _async_get_matching_entities( + self.hass, domains, include_entity_category=True + ) if not entities: entities = entity_filter.get(CONF_EXCLUDE_ENTITIES, []) # Strip out entities that no longer exist to prevent error in the UI @@ -559,21 +563,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): all_supported_entities = _async_get_matching_entities(self.hass, domains) if not entities: entities = entity_filter.get(CONF_EXCLUDE_ENTITIES, []) - ent_reg = entity_registry.async_get(self.hass) - excluded_entities = set() - for entity_id in all_supported_entities: - if ent_reg_ent := ent_reg.async_get(entity_id): - if ( - ent_reg_ent.entity_category is not None - or ent_reg_ent.hidden_by is not None - ): - excluded_entities.add(entity_id) - # Remove entity category entities since we will exclude them anyways - all_supported_entities = { - k: v - for k, v in all_supported_entities.items() - if k not in excluded_entities - } + # Strip out entities that no longer exist to prevent error in the UI default_value = [ entity_id for entity_id in entities if entity_id in all_supported_entities @@ -652,16 +642,37 @@ async def _async_get_supported_devices(hass: HomeAssistant) -> dict[str, str]: return dict(sorted(unsorted.items(), key=lambda item: item[1])) +def _exclude_by_entity_registry( + ent_reg: entity_registry.EntityRegistry, + entity_id: str, + include_entity_category: bool, +) -> bool: + """Filter out hidden entities and ones with entity category (unless specified).""" + return bool( + (entry := ent_reg.async_get(entity_id)) + and ( + entry.hidden_by is not None + or (not include_entity_category or entry.entity_category is not None) + ) + ) + + def _async_get_matching_entities( - hass: HomeAssistant, domains: list[str] | None = None + hass: HomeAssistant, + domains: list[str] | None = None, + include_entity_category: bool = False, ) -> dict[str, str]: """Fetch all entities or entities in the given domains.""" + ent_reg = entity_registry.async_get(hass) return { state.entity_id: f"{state.attributes.get(ATTR_FRIENDLY_NAME, state.entity_id)} ({state.entity_id})" for state in sorted( hass.states.async_all(domains and set(domains)), key=lambda item: item.entity_id, ) + if not _exclude_by_entity_registry( + ent_reg, state.entity_id, include_entity_category + ) } diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index 301040e4f88..fc3ef3e2710 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -1481,3 +1481,81 @@ async def test_options_flow_exclude_mode_skips_hidden_entities( "include_entities": [], }, } + + +@patch(f"{PATH_HOMEKIT}.async_port_is_available", return_value=True) +async def test_options_flow_include_mode_skips_hidden_entities( + port_mock, hass, mock_get_source_ip, hk_driver, mock_async_zeroconf, entity_reg +): + """Ensure include mode does not offer hidden entities.""" + config_entry = _mock_config_entry_with_options_populated() + await async_init_entry(hass, config_entry) + + hass.states.async_set("media_player.tv", "off") + hass.states.async_set("media_player.sonos", "off") + hass.states.async_set("switch.other", "off") + + sonos_hidden_switch: RegistryEntry = entity_reg.async_get_or_create( + "switch", + "sonos", + "config", + device_id="1234", + hidden_by=RegistryEntryHider.INTEGRATION, + ) + hass.states.async_set(sonos_hidden_switch.entity_id, "off") + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init( + config_entry.entry_id, context={"show_advanced_options": False} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["data_schema"]({}) == { + "domains": [ + "fan", + "humidifier", + "vacuum", + "media_player", + "climate", + "alarm_control_panel", + ], + "mode": "bridge", + "include_exclude_mode": "exclude", + } + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "domains": ["media_player", "switch"], + "mode": "bridge", + "include_exclude_mode": "include", + }, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "include" + assert _get_schema_default(result2["data_schema"].schema, "entities") == [] + + # sonos_hidden_switch.entity_id is a hidden entity + # so it should not be selectable since it will always be excluded + with pytest.raises(voluptuous.error.MultipleInvalid): + await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={"entities": [sonos_hidden_switch.entity_id]}, + ) + + result4 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={"entities": ["media_player.tv", "switch.other"]}, + ) + assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + "mode": "bridge", + "filter": { + "exclude_domains": [], + "exclude_entities": [], + "include_domains": [], + "include_entities": ["media_player.tv", "switch.other"], + }, + }