Improve performance of google assistant supported checks (#99454)
* Improve performance of google assistant supported checks * tweak * tweak * split function * tweak
This commit is contained in:
parent
63273a307a
commit
ff2e0c570b
4 changed files with 144 additions and 65 deletions
|
@ -5,6 +5,7 @@ from abc import ABC, abstractmethod
|
||||||
from asyncio import gather
|
from asyncio import gather
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from functools import lru_cache
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
import logging
|
import logging
|
||||||
import pprint
|
import pprint
|
||||||
|
@ -490,9 +491,34 @@ def get_google_type(domain, device_class):
|
||||||
return typ if typ is not None else DOMAIN_TO_GOOGLE_TYPES[domain]
|
return typ if typ is not None else DOMAIN_TO_GOOGLE_TYPES[domain]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=4096)
|
||||||
|
def supported_traits_for_state(state: State) -> list[type[trait._Trait]]:
|
||||||
|
"""Return all supported traits for state."""
|
||||||
|
domain = state.domain
|
||||||
|
attributes = state.attributes
|
||||||
|
features = attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||||
|
|
||||||
|
if not isinstance(features, int):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Entity %s contains invalid supported_features value %s",
|
||||||
|
state.entity_id,
|
||||||
|
features,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
|
||||||
|
return [
|
||||||
|
Trait
|
||||||
|
for Trait in trait.TRAITS
|
||||||
|
if Trait.supported(domain, features, device_class, attributes)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class GoogleEntity:
|
class GoogleEntity:
|
||||||
"""Adaptation of Entity expressed in Google's terms."""
|
"""Adaptation of Entity expressed in Google's terms."""
|
||||||
|
|
||||||
|
__slots__ = ("hass", "config", "state", "_traits")
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, hass: HomeAssistant, config: AbstractConfig, state: State
|
self, hass: HomeAssistant, config: AbstractConfig, state: State
|
||||||
) -> None:
|
) -> None:
|
||||||
|
@ -502,6 +528,10 @@ class GoogleEntity:
|
||||||
self.state = state
|
self.state = state
|
||||||
self._traits: list[trait._Trait] | None = None
|
self._traits: list[trait._Trait] | None = None
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""Return the representation."""
|
||||||
|
return f"<GoogleEntity {self.state.entity_id}: {self.state.name}>"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def entity_id(self):
|
def entity_id(self):
|
||||||
"""Return entity ID."""
|
"""Return entity ID."""
|
||||||
|
@ -512,26 +542,10 @@ class GoogleEntity:
|
||||||
"""Return traits for entity."""
|
"""Return traits for entity."""
|
||||||
if self._traits is not None:
|
if self._traits is not None:
|
||||||
return self._traits
|
return self._traits
|
||||||
|
|
||||||
state = self.state
|
state = self.state
|
||||||
domain = state.domain
|
|
||||||
attributes = state.attributes
|
|
||||||
features = attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
|
||||||
|
|
||||||
if not isinstance(features, int):
|
|
||||||
_LOGGER.warning(
|
|
||||||
"Entity %s contains invalid supported_features value %s",
|
|
||||||
self.entity_id,
|
|
||||||
features,
|
|
||||||
)
|
|
||||||
return []
|
|
||||||
|
|
||||||
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
|
|
||||||
|
|
||||||
self._traits = [
|
self._traits = [
|
||||||
Trait(self.hass, state, self.config)
|
Trait(self.hass, state, self.config)
|
||||||
for Trait in trait.TRAITS
|
for Trait in supported_traits_for_state(state)
|
||||||
if Trait.supported(domain, features, device_class, attributes)
|
|
||||||
]
|
]
|
||||||
return self._traits
|
return self._traits
|
||||||
|
|
||||||
|
@ -554,18 +568,8 @@ class GoogleEntity:
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def is_supported(self) -> bool:
|
def is_supported(self) -> bool:
|
||||||
"""Return if the entity is supported by Google."""
|
"""Return if entity is supported."""
|
||||||
features: int | None = self.state.attributes.get(ATTR_SUPPORTED_FEATURES)
|
return bool(self.traits())
|
||||||
|
|
||||||
result = self.config.is_supported_cache.get(self.entity_id)
|
|
||||||
|
|
||||||
if result is None or result[0] != features:
|
|
||||||
result = self.config.is_supported_cache[self.entity_id] = (
|
|
||||||
features,
|
|
||||||
bool(self.traits()),
|
|
||||||
)
|
|
||||||
|
|
||||||
return result[1]
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def might_2fa(self) -> bool:
|
def might_2fa(self) -> bool:
|
||||||
|
@ -725,19 +729,64 @@ def deep_update(target, source):
|
||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_get_google_entity_if_supported_cached(
|
||||||
|
hass: HomeAssistant, config: AbstractConfig, state: State
|
||||||
|
) -> GoogleEntity | None:
|
||||||
|
"""Return a GoogleEntity if entity is supported checking the cache first.
|
||||||
|
|
||||||
|
This function will check the cache, and call async_get_google_entity_if_supported
|
||||||
|
if the entity is not in the cache, which will update the cache.
|
||||||
|
"""
|
||||||
|
entity_id = state.entity_id
|
||||||
|
is_supported_cache = config.is_supported_cache
|
||||||
|
features: int | None = state.attributes.get(ATTR_SUPPORTED_FEATURES)
|
||||||
|
if result := is_supported_cache.get(entity_id):
|
||||||
|
cached_features, supported = result
|
||||||
|
if cached_features == features:
|
||||||
|
return GoogleEntity(hass, config, state) if supported else None
|
||||||
|
# Cache miss, check if entity is supported
|
||||||
|
return async_get_google_entity_if_supported(hass, config, state)
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_get_google_entity_if_supported(
|
||||||
|
hass: HomeAssistant, config: AbstractConfig, state: State
|
||||||
|
) -> GoogleEntity | None:
|
||||||
|
"""Return a GoogleEntity if entity is supported.
|
||||||
|
|
||||||
|
This function will update the cache, but it does not check the cache first.
|
||||||
|
"""
|
||||||
|
features: int | None = state.attributes.get(ATTR_SUPPORTED_FEATURES)
|
||||||
|
entity = GoogleEntity(hass, config, state)
|
||||||
|
is_supported = bool(entity.traits())
|
||||||
|
config.is_supported_cache[state.entity_id] = (features, is_supported)
|
||||||
|
return entity if is_supported else None
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_get_entities(
|
def async_get_entities(
|
||||||
hass: HomeAssistant, config: AbstractConfig
|
hass: HomeAssistant, config: AbstractConfig
|
||||||
) -> list[GoogleEntity]:
|
) -> list[GoogleEntity]:
|
||||||
"""Return all entities that are supported by Google."""
|
"""Return all entities that are supported by Google."""
|
||||||
entities = []
|
entities: list[GoogleEntity] = []
|
||||||
|
is_supported_cache = config.is_supported_cache
|
||||||
for state in hass.states.async_all():
|
for state in hass.states.async_all():
|
||||||
if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
entity_id = state.entity_id
|
||||||
|
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||||
continue
|
continue
|
||||||
|
# Check check inlined for performance to avoid
|
||||||
entity = GoogleEntity(hass, config, state)
|
# function calls for every entity since we enumerate
|
||||||
|
# the entire state machine here
|
||||||
if entity.is_supported():
|
features: int | None = state.attributes.get(ATTR_SUPPORTED_FEATURES)
|
||||||
|
if result := is_supported_cache.get(entity_id):
|
||||||
|
cached_features, supported = result
|
||||||
|
if cached_features == features:
|
||||||
|
if supported:
|
||||||
|
entities.append(GoogleEntity(hass, config, state))
|
||||||
|
continue
|
||||||
|
# Cached features don't match, fall through to check
|
||||||
|
# if the entity is supported and update the cache.
|
||||||
|
if entity := async_get_google_entity_if_supported(hass, config, state):
|
||||||
entities.append(entity)
|
entities.append(entity)
|
||||||
|
|
||||||
return entities
|
return entities
|
||||||
|
|
|
@ -6,13 +6,17 @@ import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.const import MATCH_ALL
|
from homeassistant.const import MATCH_ALL
|
||||||
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
|
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, State, callback
|
||||||
from homeassistant.helpers.event import async_call_later, async_track_state_change
|
from homeassistant.helpers.event import async_call_later, async_track_state_change
|
||||||
from homeassistant.helpers.significant_change import create_checker
|
from homeassistant.helpers.significant_change import create_checker
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .error import SmartHomeError
|
from .error import SmartHomeError
|
||||||
from .helpers import AbstractConfig, GoogleEntity, async_get_entities
|
from .helpers import (
|
||||||
|
AbstractConfig,
|
||||||
|
async_get_entities,
|
||||||
|
async_get_google_entity_if_supported_cached,
|
||||||
|
)
|
||||||
|
|
||||||
# Time to wait until the homegraph updates
|
# Time to wait until the homegraph updates
|
||||||
# https://github.com/actions-on-google/smart-home-nodejs/issues/196#issuecomment-439156639
|
# https://github.com/actions-on-google/smart-home-nodejs/issues/196#issuecomment-439156639
|
||||||
|
@ -54,8 +58,10 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig
|
||||||
|
|
||||||
report_states_job = HassJob(report_states)
|
report_states_job = HassJob(report_states)
|
||||||
|
|
||||||
async def async_entity_state_listener(changed_entity, old_state, new_state):
|
async def async_entity_state_listener(
|
||||||
nonlocal unsub_pending
|
changed_entity: str, old_state: State | None, new_state: State | None
|
||||||
|
) -> None:
|
||||||
|
nonlocal unsub_pending, checker
|
||||||
|
|
||||||
if not hass.is_running:
|
if not hass.is_running:
|
||||||
return
|
return
|
||||||
|
@ -66,9 +72,11 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig
|
||||||
if not google_config.should_expose(new_state):
|
if not google_config.should_expose(new_state):
|
||||||
return
|
return
|
||||||
|
|
||||||
entity = GoogleEntity(hass, google_config, new_state)
|
if not (
|
||||||
|
entity := async_get_google_entity_if_supported_cached(
|
||||||
if not entity.is_supported():
|
hass, google_config, new_state
|
||||||
|
)
|
||||||
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -77,6 +85,7 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig
|
||||||
_LOGGER.debug("Not reporting state for %s: %s", changed_entity, err.code)
|
_LOGGER.debug("Not reporting state for %s: %s", changed_entity, err.code)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
assert checker is not None
|
||||||
if not checker.async_is_significant_change(new_state, extra_arg=entity_data):
|
if not checker.async_is_significant_change(new_state, extra_arg=entity_data):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
@ -447,32 +447,53 @@ async def test_config_local_sdk_warn_version(
|
||||||
) in caplog.text
|
) in caplog.text
|
||||||
|
|
||||||
|
|
||||||
def test_is_supported_cached() -> None:
|
def test_async_get_entities_cached(hass: HomeAssistant) -> None:
|
||||||
"""Test is_supported is cached."""
|
"""Test async_get_entities is cached."""
|
||||||
config = MockConfig()
|
config = MockConfig()
|
||||||
|
|
||||||
def entity(features: int):
|
hass.states.async_set("light.ceiling_lights", "off")
|
||||||
return helpers.GoogleEntity(
|
hass.states.async_set("light.bed_light", "off")
|
||||||
None,
|
hass.states.async_set("not_supported.not_supported", "off")
|
||||||
config,
|
|
||||||
State("test.entity_id", "on", {"supported_features": features}),
|
google_entities = helpers.async_get_entities(hass, config)
|
||||||
)
|
assert len(google_entities) == 2
|
||||||
|
assert config.is_supported_cache == {
|
||||||
|
"light.bed_light": (None, True),
|
||||||
|
"light.ceiling_lights": (None, True),
|
||||||
|
"not_supported.not_supported": (None, False),
|
||||||
|
}
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.google_assistant.helpers.GoogleEntity.traits",
|
"homeassistant.components.google_assistant.helpers.GoogleEntity.traits",
|
||||||
return_value=[1],
|
return_value=RuntimeError("Should not be called"),
|
||||||
) as mock_traits:
|
):
|
||||||
assert entity(1).is_supported() is True
|
google_entities = helpers.async_get_entities(hass, config)
|
||||||
assert len(mock_traits.mock_calls) == 1
|
|
||||||
|
|
||||||
# Supported feature changes, so we calculate again
|
assert len(google_entities) == 2
|
||||||
assert entity(2).is_supported() is True
|
assert config.is_supported_cache == {
|
||||||
assert len(mock_traits.mock_calls) == 2
|
"light.bed_light": (None, True),
|
||||||
|
"light.ceiling_lights": (None, True),
|
||||||
|
"not_supported.not_supported": (None, False),
|
||||||
|
}
|
||||||
|
|
||||||
mock_traits.reset_mock()
|
hass.states.async_set("light.new", "on")
|
||||||
|
google_entities = helpers.async_get_entities(hass, config)
|
||||||
|
|
||||||
# Supported feature is same, so we do not calculate again
|
assert len(google_entities) == 3
|
||||||
mock_traits.side_effect = ValueError
|
assert config.is_supported_cache == {
|
||||||
|
"light.bed_light": (None, True),
|
||||||
|
"light.new": (None, True),
|
||||||
|
"light.ceiling_lights": (None, True),
|
||||||
|
"not_supported.not_supported": (None, False),
|
||||||
|
}
|
||||||
|
|
||||||
assert entity(2).is_supported() is True
|
hass.states.async_set("light.new", "on", {"supported_features": 1})
|
||||||
assert len(mock_traits.mock_calls) == 0
|
google_entities = helpers.async_get_entities(hass, config)
|
||||||
|
|
||||||
|
assert len(google_entities) == 3
|
||||||
|
assert config.is_supported_cache == {
|
||||||
|
"light.bed_light": (None, True),
|
||||||
|
"light.new": (1, True),
|
||||||
|
"light.ceiling_lights": (None, True),
|
||||||
|
"not_supported.not_supported": (None, False),
|
||||||
|
}
|
||||||
|
|
|
@ -69,7 +69,7 @@ async def test_report_state(
|
||||||
|
|
||||||
# Test that if serialize returns same value, we don't send
|
# Test that if serialize returns same value, we don't send
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.google_assistant.report_state.GoogleEntity.query_serialize",
|
"homeassistant.components.google_assistant.helpers.GoogleEntity.query_serialize",
|
||||||
return_value={"same": "info"},
|
return_value={"same": "info"},
|
||||||
), patch.object(BASIC_CONFIG, "async_report_state_all", AsyncMock()) as mock_report:
|
), patch.object(BASIC_CONFIG, "async_report_state_all", AsyncMock()) as mock_report:
|
||||||
# New state, so reported
|
# New state, so reported
|
||||||
|
@ -104,7 +104,7 @@ async def test_report_state(
|
||||||
with patch.object(
|
with patch.object(
|
||||||
BASIC_CONFIG, "async_report_state_all", AsyncMock()
|
BASIC_CONFIG, "async_report_state_all", AsyncMock()
|
||||||
) as mock_report, patch(
|
) as mock_report, patch(
|
||||||
"homeassistant.components.google_assistant.report_state.GoogleEntity.query_serialize",
|
"homeassistant.components.google_assistant.helpers.GoogleEntity.query_serialize",
|
||||||
side_effect=error.SmartHomeError("mock-error", "mock-msg"),
|
side_effect=error.SmartHomeError("mock-error", "mock-msg"),
|
||||||
):
|
):
|
||||||
hass.states.async_set("light.kitchen", "off")
|
hass.states.async_set("light.kitchen", "off")
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue