Implement use_include_order in the history websocket api (#71839)

This commit is contained in:
J. Nick Koston 2022-05-14 15:37:35 -04:00 committed by GitHub
parent cd2898886b
commit 68632cb267
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 124 additions and 57 deletions

View File

@ -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))

View File

@ -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

View File

@ -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",
]