Include disabled entities in search/related when requested (#178024)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
shbatm
2026-09-14 14:22:38 +03:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent ae3fae9879
commit 74639e3063
3 changed files with 215 additions and 3 deletions
+15 -3
View File
@@ -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
+107
View File
@@ -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,
}
+93
View File
@@ -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))