From 74639e3063769b42411e2c0eff48a773bbde3db6 Mon Sep 17 00:00:00 2001 From: shbatm Date: Mon, 14 Sep 2026 06:22:38 -0500 Subject: [PATCH] Include disabled entities in search/related when requested (#178024) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/search/__init__.py | 18 +++- tests/components/search/conftest.py | 107 ++++++++++++++++++++ tests/components/search/test_init.py | 93 +++++++++++++++++ 3 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 tests/components/search/conftest.py diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index 0d8c6bfde765..9bd4cb8ca17b 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -63,6 +63,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: probatio.Required("type"): "search/related", probatio.Required("item_type"): probatio.Coerce(ItemType), probatio.Required("item_id"): str, + probatio.Optional("include_disabled_entities", default=False): bool, } ) @callback @@ -72,7 +73,11 @@ def websocket_search_related( msg: dict[str, Any], ) -> None: """Handle search.""" - searcher = Searcher(hass, get_entity_sources(hass)) + searcher = Searcher( + hass, + get_entity_sources(hass), + include_disabled_entities=msg["include_disabled_entities"], + ) connection.send_result( msg["id"], searcher.async_search(msg["item_type"], msg["item_id"]) ) @@ -87,6 +92,8 @@ class Searcher: self, hass: HomeAssistant, entity_sources: dict[str, EntityInfo], + *, + include_disabled_entities: bool = False, ) -> None: """Search results.""" self.hass = hass @@ -94,6 +101,7 @@ class Searcher: self._device_registry = dr.async_get(hass) self._entity_registry = er.async_get(hass) self._entity_sources = entity_sources + self._include_disabled_entities = include_disabled_entities self.results: defaultdict[ItemType, set[str]] = defaultdict(set) @callback @@ -154,7 +162,9 @@ class Searcher: # Entities of this device for entity_entry in er.async_entries_for_device( - self._entity_registry, device.id + self._entity_registry, + device.id, + include_disabled_entities=self._include_disabled_entities, ): # Skip the entity if it's in a different area if entity_entry.area_id is not None: @@ -326,7 +336,9 @@ class Searcher: # Entities of this device for entity_entry in er.async_entries_for_device( - self._entity_registry, device_id + self._entity_registry, + device_id, + include_disabled_entities=self._include_disabled_entities, ): self._add(ItemType.ENTITY, entity_entry.entity_id) # Add all entity information as well diff --git a/tests/components/search/conftest.py b/tests/components/search/conftest.py new file mode 100644 index 000000000000..5e599da33686 --- /dev/null +++ b/tests/components/search/conftest.py @@ -0,0 +1,107 @@ +"""Test fixtures for the search integration.""" + +import pytest + +from homeassistant.components.search import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, + floor_registry as fr, +) +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="search_item_ids") +async def search_item_ids_fixture( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + floor_registry: fr.FloorRegistry, +) -> dict[str, str]: + """Set up a device and a child device, each owning enabled and disabled entities. + + The child device inherits the area from its parent, so it is reached by an area + or floor search without having an area of its own. + + Returns the item id to search by, per key. + """ + assert await async_setup_component(hass, DOMAIN, {}) + + floor = floor_registry.async_create("First floor") + area = area_registry.async_create("Kitchen", floor_id=floor.floor_id) + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={("test", "1")} + ) + device_registry.async_update_device(device.id, area_id=area.id) + + child_device = device_registry.async_get_or_create_child( + config_entry_id=config_entry.entry_id, + identifiers={("test", "1-child")}, + parent_device_id=device.id, + name="Child", + ) + + entity_registry.async_get_or_create( + "light", + "test", + "enabled", + suggested_object_id="enabled", + config_entry=config_entry, + device_id=device.id, + ) + entity_registry.async_get_or_create( + "light", + "test", + "disabled", + suggested_object_id="disabled", + config_entry=config_entry, + device_id=device.id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + # A disabled entity that overrides its area instead of inheriting it from the + # device is reached through the area index, which has no disabled filter. + disabled_area_override_entity = entity_registry.async_get_or_create( + "light", + "test", + "disabled_area_override", + suggested_object_id="disabled_area_override", + config_entry=config_entry, + device_id=device.id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + entity_registry.async_update_entity( + disabled_area_override_entity.entity_id, area_id=area.id + ) + + entity_registry.async_get_or_create( + "light", + "test", + "child_enabled", + suggested_object_id="child_enabled", + config_entry=config_entry, + device_id=child_device.id, + ) + entity_registry.async_get_or_create( + "light", + "test", + "child_disabled", + suggested_object_id="child_disabled", + config_entry=config_entry, + device_id=child_device.id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + + return { + "floor": floor.floor_id, + "area": area.id, + "device": device.id, + "child_device": child_device.id, + } diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index 883f9a766c60..51f42cdd3934 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -1,6 +1,7 @@ """Tests for Search integration.""" import attr +import pytest from pytest_unordered import unordered from homeassistant.components.search import DOMAIN, ItemType, Searcher @@ -1371,3 +1372,95 @@ async def test_search_child_devices( ItemType.CONFIG_ENTRY: {config_entry.entry_id}, ItemType.INTEGRATION: {"test"}, } + + +ALL_ENTITIES = { + "light.enabled", + "light.disabled", + "light.disabled_area_override", + "light.child_enabled", + "light.child_disabled", +} +PARENT_DEVICE_DEFAULT = {"light.enabled", "light.child_enabled"} +AREA_DEFAULT = {"light.enabled", "light.disabled_area_override", "light.child_enabled"} + + +@pytest.mark.parametrize( + ("item_type", "item_key", "expected_default", "expected_included"), + [ + pytest.param( + ItemType.DEVICE, "device", PARENT_DEVICE_DEFAULT, ALL_ENTITIES, id="device" + ), + pytest.param( + ItemType.DEVICE, + "child_device", + {"light.child_enabled"}, + {"light.child_enabled", "light.child_disabled"}, + id="child_device", + ), + pytest.param(ItemType.AREA, "area", AREA_DEFAULT, ALL_ENTITIES, id="area"), + pytest.param(ItemType.FLOOR, "floor", AREA_DEFAULT, ALL_ENTITIES, id="floor"), + ], +) +async def test_search_include_disabled_entities( + hass: HomeAssistant, + search_item_ids: dict[str, str], + item_type: ItemType, + item_key: str, + expected_default: set[str], + expected_included: set[str], +) -> None: + """Test device-inherited disabled entities are only returned when requested. + + A child device is searched both directly and through the area and floor it + inherits from its parent. Searching the child does not return the parent's + entities, because the parent is only resolved up. + """ + item_id = search_item_ids[item_key] + + searcher = Searcher(hass, {}) + assert ( + searcher.async_search(item_type, item_id)[ItemType.ENTITY] == expected_default + ) + + searcher = Searcher(hass, {}, include_disabled_entities=True) + assert ( + searcher.async_search(item_type, item_id)[ItemType.ENTITY] == expected_included + ) + + +@pytest.mark.parametrize( + ("extra_msg", "expected"), + [ + pytest.param({}, PARENT_DEVICE_DEFAULT, id="key_omitted"), + pytest.param( + {"include_disabled_entities": False}, + PARENT_DEVICE_DEFAULT, + id="explicit_false", + ), + pytest.param( + {"include_disabled_entities": True}, ALL_ENTITIES, id="explicit_true" + ), + ], +) +async def test_search_related_include_disabled_entities_websocket( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + search_item_ids: dict[str, str], + extra_msg: dict[str, bool], + expected: set[str], +) -> None: + """Test the websocket command accepts the new option, and defaults it to False.""" + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "search/related", + "item_type": "device", + "item_id": search_item_ids["device"], + } + | extra_msg + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"][ItemType.ENTITY] == unordered(list(expected))