Skip to content

Commit c058810

Browse files
Cache flattened service descriptions in websocket api (home-assistant#157510)
Co-authored-by: Erik Montnemery <[email protected]>
1 parent 0ccfd77 commit c058810

File tree

2 files changed

+109
-8
lines changed

2 files changed

+109
-8
lines changed

homeassistant/components/websocket_api/automation.py

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
from collections.abc import Mapping
56
from dataclasses import dataclass
67
import logging
78
from typing import Any, Self
@@ -24,9 +25,14 @@
2425
async_get_all_descriptions as async_get_all_trigger_descriptions,
2526
)
2627
from homeassistant.helpers.typing import ConfigType
28+
from homeassistant.util.hass_dict import HassKey
2729

2830
_LOGGER = logging.getLogger(__name__)
2931

32+
FLATTENED_SERVICE_DESCRIPTIONS_CACHE: HassKey[
33+
tuple[dict[str, dict[str, Any]], dict[str, dict[str, Any]]]
34+
] = HassKey("websocket_automation_flat_service_description_cache")
35+
3036

3137
@dataclass(slots=True, kw_only=True)
3238
class _EntityFilter:
@@ -136,7 +142,7 @@ def _async_get_automation_components_for_target(
136142
hass: HomeAssistant,
137143
target_selection: ConfigType,
138144
expand_group: bool,
139-
component_descriptions: dict[str, dict[str, Any] | None],
145+
component_descriptions: Mapping[str, Mapping[str, Any] | None],
140146
) -> set[str]:
141147
"""Get automation components (triggers/conditions/services) for a target.
142148
@@ -217,12 +223,29 @@ async def async_get_services_for_target(
217223
) -> set[str]:
218224
"""Get services for a target."""
219225
descriptions = await async_get_all_service_descriptions(hass)
220-
# Flatten dicts to be keyed by domain.name to match trigger/condition format
221-
descriptions_flatten = {
222-
f"{domain}.{service_name}": desc
223-
for domain, services in descriptions.items()
224-
for service_name, desc in services.items()
225-
}
226+
227+
def get_flattened_service_descriptions() -> dict[str, dict[str, Any]]:
228+
"""Get flattened service descriptions, with caching."""
229+
if FLATTENED_SERVICE_DESCRIPTIONS_CACHE in hass.data:
230+
cached_descriptions, cached_flattened_descriptions = hass.data[
231+
FLATTENED_SERVICE_DESCRIPTIONS_CACHE
232+
]
233+
# If the descriptions are the same, return the cached flattened version
234+
if cached_descriptions is descriptions:
235+
return cached_flattened_descriptions
236+
237+
# Flatten dicts to be keyed by domain.name to match trigger/condition format
238+
flattened_descriptions = {
239+
f"{domain}.{service_name}": desc
240+
for domain, services in descriptions.items()
241+
for service_name, desc in services.items()
242+
}
243+
hass.data[FLATTENED_SERVICE_DESCRIPTIONS_CACHE] = (
244+
descriptions,
245+
flattened_descriptions,
246+
)
247+
return flattened_descriptions
248+
226249
return _async_get_automation_components_for_target(
227-
hass, target_selector, expand_group, descriptions_flatten
250+
hass, target_selector, expand_group, get_flattened_service_descriptions()
228251
)

tests/components/websocket_api/test_commands.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4162,3 +4162,81 @@ async def assert_services(target: dict[str, list[str]], expected: list[str]) ->
41624162
"switch.turn_on",
41634163
],
41644164
)
4165+
4166+
4167+
@patch("annotatedyaml.loader.load_yaml")
4168+
@patch.object(Integration, "has_services", return_value=True)
4169+
async def test_get_services_for_target_caching(
4170+
mock_has_services: Mock,
4171+
mock_load_yaml: Mock,
4172+
hass: HomeAssistant,
4173+
websocket_client: MockHAClientWebSocket,
4174+
) -> None:
4175+
"""Test that flattened service descriptions are cached and reused."""
4176+
4177+
def get_common_service_descriptions(domain: str):
4178+
return f"""
4179+
turn_on:
4180+
target:
4181+
entity:
4182+
domain: {domain}
4183+
"""
4184+
4185+
def _load_yaml(fname, secrets=None):
4186+
domain = fname.split("/")[-2]
4187+
with io.StringIO(get_common_service_descriptions(domain)) as file:
4188+
return parse_yaml(file)
4189+
4190+
mock_load_yaml.side_effect = _load_yaml
4191+
await hass.async_block_till_done()
4192+
4193+
hass.services.async_register("light", "turn_on", lambda call: None)
4194+
hass.services.async_register("switch", "turn_on", lambda call: None)
4195+
await hass.async_block_till_done()
4196+
4197+
async def call_command():
4198+
await websocket_client.send_json_auto_id(
4199+
{
4200+
"type": "get_services_for_target",
4201+
"target": {"entity_id": ["light.test1"]},
4202+
}
4203+
)
4204+
msg = await websocket_client.receive_json()
4205+
assert msg["success"]
4206+
4207+
with patch(
4208+
"homeassistant.components.websocket_api.automation._async_get_automation_components_for_target",
4209+
return_value=set(),
4210+
) as mock_get_components:
4211+
# First call: should create and cache flat descriptions
4212+
await call_command()
4213+
4214+
assert mock_get_components.call_count == 1
4215+
first_flat_descriptions = mock_get_components.call_args_list[0][0][3]
4216+
assert first_flat_descriptions == {
4217+
"light.turn_on": {
4218+
"fields": {},
4219+
"target": {"entity": [{"domain": ["light"]}]},
4220+
},
4221+
"switch.turn_on": {
4222+
"fields": {},
4223+
"target": {"entity": [{"domain": ["switch"]}]},
4224+
},
4225+
}
4226+
4227+
# Second call: should reuse cached flat descriptions
4228+
await call_command()
4229+
assert mock_get_components.call_count == 2
4230+
second_flat_descriptions = mock_get_components.call_args_list[1][0][3]
4231+
assert first_flat_descriptions is second_flat_descriptions
4232+
4233+
# Register a new service to invalidate cache
4234+
hass.services.async_register("new_domain", "new_service", lambda call: None)
4235+
await hass.async_block_till_done()
4236+
4237+
# Third call: cache should be rebuilt
4238+
await call_command()
4239+
assert mock_get_components.call_count == 3
4240+
third_flat_descriptions = mock_get_components.call_args_list[2][0][3]
4241+
assert "new_domain.new_service" in third_flat_descriptions
4242+
assert third_flat_descriptions is not first_flat_descriptions

0 commit comments

Comments
 (0)