From 68632cb2671c26a8d367c1dafb6ac2e6c2904b95 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 May 2022 15:37:35 -0400 Subject: [PATCH] Implement use_include_order in the history websocket api (#71839) --- homeassistant/components/history/__init__.py | 121 ++++++++++--------- homeassistant/components/logbook/queries.py | 2 +- tests/components/history/test_init.py | 58 +++++++++ 3 files changed, 124 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index e5b6c99eb2a..2ebe6405a7a 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -10,6 +10,8 @@ from typing import Any, Literal, cast from aiohttp import web from sqlalchemy import not_, or_ +from sqlalchemy.ext.baked import BakedQuery +from sqlalchemy.orm import Query import voluptuous as vol from homeassistant.components import frontend, websocket_api @@ -36,12 +38,12 @@ from homeassistant.helpers.entityfilter import ( from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util -# mypy: allow-untyped-defs, no-check-untyped-defs - _LOGGER = logging.getLogger(__name__) DOMAIN = "history" HISTORY_FILTERS = "history_filters" +HISTORY_USE_INCLUDE_ORDER = "history_use_include_order" + CONF_ORDER = "use_include_order" GLOB_TO_SQL_CHARS = { @@ -66,8 +68,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[HISTORY_FILTERS] = filters = sqlalchemy_filter_from_include_exclude_conf( conf ) - - use_include_order = conf.get(CONF_ORDER) + hass.data[HISTORY_USE_INCLUDE_ORDER] = use_include_order = conf.get(CONF_ORDER) hass.http.register_view(HistoryPeriodView(filters, use_include_order)) frontend.async_register_built_in_panel(hass, "history", "history", "hass:chart-box") @@ -176,30 +177,41 @@ def _ws_get_significant_states( hass: HomeAssistant, msg_id: int, start_time: dt, - end_time: dt | None = None, - entity_ids: list[str] | None = None, - filters: Any | None = None, - include_start_time_state: bool = True, - significant_changes_only: bool = True, - minimal_response: bool = False, - no_attributes: bool = False, + end_time: dt | None, + entity_ids: list[str] | None, + filters: Filters | None, + use_include_order: bool | None, + include_start_time_state: bool, + significant_changes_only: bool, + minimal_response: bool, + no_attributes: bool, ) -> str: """Fetch history significant_states and convert them to json in the executor.""" + states = history.get_significant_states( + hass, + start_time, + end_time, + entity_ids, + filters, + include_start_time_state, + significant_changes_only, + minimal_response, + no_attributes, + True, + ) + + if not use_include_order or not filters: + return JSON_DUMP(messages.result_message(msg_id, states)) + return JSON_DUMP( messages.result_message( msg_id, - history.get_significant_states( - hass, - start_time, - end_time, - entity_ids, - filters, - include_start_time_state, - significant_changes_only, - minimal_response, - no_attributes, - True, - ), + { + order_entity: states.pop(order_entity) + for order_entity in filters.included_entities + if order_entity in states + } + | states, ) ) @@ -267,6 +279,7 @@ async def ws_get_history_during_period( end_time, entity_ids, hass.data[HISTORY_FILTERS], + hass.data[HISTORY_USE_INCLUDE_ORDER], include_start_time_state, significant_changes_only, minimal_response, @@ -351,20 +364,20 @@ class HistoryPeriodView(HomeAssistantView): def _sorted_significant_states_json( self, - hass, - start_time, - end_time, - entity_ids, - include_start_time_state, - significant_changes_only, - minimal_response, - no_attributes, - ): + hass: HomeAssistant, + start_time: dt, + end_time: dt, + entity_ids: list[str] | None, + include_start_time_state: bool, + significant_changes_only: bool, + minimal_response: bool, + no_attributes: bool, + ) -> web.Response: """Fetch significant stats from the database as json.""" timer_start = time.perf_counter() with session_scope(hass=hass) as session: - result = history.get_significant_states_with_session( + states = history.get_significant_states_with_session( hass, session, start_time, @@ -377,25 +390,24 @@ class HistoryPeriodView(HomeAssistantView): no_attributes, ) - result = list(result.values()) if _LOGGER.isEnabledFor(logging.DEBUG): elapsed = time.perf_counter() - timer_start - _LOGGER.debug("Extracted %d states in %fs", sum(map(len, result)), elapsed) + _LOGGER.debug( + "Extracted %d states in %fs", sum(map(len, states.values())), elapsed + ) # Optionally reorder the result to respect the ordering given # by any entities explicitly included in the configuration. - if self.filters and self.use_include_order: - sorted_result = [] - for order_entity in self.filters.included_entities: - for state_list in result: - if state_list[0].entity_id == order_entity: - sorted_result.append(state_list) - result.remove(state_list) - break - sorted_result.extend(result) - result = sorted_result + if not self.filters or not self.use_include_order: + return self.json(list(states.values())) - return self.json(result) + sorted_result = [ + states.pop(order_entity) + for order_entity in self.filters.included_entities + if order_entity in states + ] + sorted_result.extend(list(states.values())) + return self.json(sorted_result) def sqlalchemy_filter_from_include_exclude_conf(conf: ConfigType) -> Filters | None: @@ -426,7 +438,7 @@ class Filters: self.included_domains: list[str] = [] self.included_entity_globs: list[str] = [] - def apply(self, query): + def apply(self, query: Query) -> Query: """Apply the entity filter.""" if not self.has_config: return query @@ -434,21 +446,18 @@ class Filters: return query.filter(self.entity_filter()) @property - def has_config(self): + def has_config(self) -> bool: """Determine if there is any filter configuration.""" - if ( + return bool( self.excluded_entities or self.excluded_domains or self.excluded_entity_globs or self.included_entities or self.included_domains or self.included_entity_globs - ): - return True + ) - return False - - def bake(self, baked_query): + def bake(self, baked_query: BakedQuery) -> None: """Update a baked query. Works the same as apply on a baked_query. @@ -458,7 +467,7 @@ class Filters: baked_query += lambda q: q.filter(self.entity_filter()) - def entity_filter(self): + def entity_filter(self) -> Any: """Generate the entity filter query.""" includes = [] if self.included_domains: @@ -502,7 +511,7 @@ class Filters: return or_(*includes) & not_(or_(*excludes)) -def _glob_to_like(glob_str): +def _glob_to_like(glob_str: str) -> Any: """Translate glob to sql.""" return history_models.States.entity_id.like(glob_str.translate(GLOB_TO_SQL_CHARS)) diff --git a/homeassistant/components/logbook/queries.py b/homeassistant/components/logbook/queries.py index ba1138c2c26..1cea6599327 100644 --- a/homeassistant/components/logbook/queries.py +++ b/homeassistant/components/logbook/queries.py @@ -83,7 +83,7 @@ def statement_for_request( # No entities: logbook sends everything for the timeframe # limited by the context_id and the yaml configured filter if not entity_ids: - entity_filter = filters.entity_filter() if filters else None # type: ignore[no-untyped-call] + entity_filter = filters.entity_filter() if filters else None return _all_stmt(start_day, end_day, event_types, entity_filter, context_id) # Multiple entities: logbook sends everything for the timeframe for the entities diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 1dc18e5cc73..bcbab1e21ca 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -11,6 +11,7 @@ from pytest import approx from homeassistant.components import history from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.recorder.models import process_timestamp +from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES import homeassistant.core as ha from homeassistant.helpers.json import JSONEncoder from homeassistant.setup import async_setup_component @@ -1476,3 +1477,60 @@ async def test_history_during_period_bad_end_time(hass, hass_ws_client, recorder response = await client.receive_json() assert not response["success"] assert response["error"]["code"] == "invalid_end_time" + + +async def test_history_during_period_with_use_include_order( + hass, hass_ws_client, recorder_mock +): + """Test history_during_period.""" + now = dt_util.utcnow() + sort_order = ["sensor.two", "sensor.four", "sensor.one"] + await async_setup_component( + hass, + "history", + { + history.DOMAIN: { + history.CONF_ORDER: True, + history.CONF_INCLUDE: { + CONF_ENTITIES: sort_order, + CONF_DOMAINS: ["sensor"], + }, + } + }, + ) + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.one", "on", attributes={"any": "attr"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.two", "off", attributes={"any": "attr"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.three", "off", attributes={"any": "changed"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.four", "off", attributes={"any": "again"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("switch.excluded", "off", attributes={"any": "again"}) + await async_wait_recording_done(hass) + + do_adhoc_statistics(hass, start=now) + await async_wait_recording_done(hass) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": True, + "minimal_response": True, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 1 + + assert list(response["result"]) == [ + *sort_order, + "sensor.three", + ]