mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 12:47:08 +00:00
Add history/history_during_period websocket endpoint (#71688)
This commit is contained in:
parent
81e8d2ab86
commit
e2cef55162
@ -1,12 +1,12 @@
|
||||
"""Provide pre-made queries on top of the recorder component."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Iterable, MutableMapping
|
||||
from datetime import datetime as dt, timedelta
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
import time
|
||||
from typing import cast
|
||||
from typing import Any, cast
|
||||
|
||||
from aiohttp import web
|
||||
from sqlalchemy import not_, or_
|
||||
@ -25,7 +25,7 @@ from homeassistant.components.recorder.statistics import (
|
||||
)
|
||||
from homeassistant.components.recorder.util import session_scope
|
||||
from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.deprecation import deprecated_class, deprecated_function
|
||||
from homeassistant.helpers.entityfilter import (
|
||||
@ -40,6 +40,7 @@ import homeassistant.util.dt as dt_util
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "history"
|
||||
HISTORY_FILTERS = "history_filters"
|
||||
CONF_ORDER = "use_include_order"
|
||||
|
||||
GLOB_TO_SQL_CHARS = {
|
||||
@ -83,7 +84,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the history hooks."""
|
||||
conf = config.get(DOMAIN, {})
|
||||
|
||||
filters = sqlalchemy_filter_from_include_exclude_conf(conf)
|
||||
hass.data[HISTORY_FILTERS] = filters = sqlalchemy_filter_from_include_exclude_conf(
|
||||
conf
|
||||
)
|
||||
|
||||
use_include_order = conf.get(CONF_ORDER)
|
||||
|
||||
@ -91,6 +94,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
frontend.async_register_built_in_panel(hass, "history", "history", "hass:chart-box")
|
||||
websocket_api.async_register_command(hass, ws_get_statistics_during_period)
|
||||
websocket_api.async_register_command(hass, ws_get_list_statistic_ids)
|
||||
websocket_api.async_register_command(hass, ws_get_history_during_period)
|
||||
|
||||
return True
|
||||
|
||||
@ -163,6 +167,79 @@ async def ws_get_list_statistic_ids(
|
||||
connection.send_result(msg["id"], statistic_ids)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "history/history_during_period",
|
||||
vol.Required("start_time"): str,
|
||||
vol.Optional("end_time"): str,
|
||||
vol.Optional("entity_ids"): [str],
|
||||
vol.Optional("include_start_time_state", default=True): bool,
|
||||
vol.Optional("significant_changes_only", default=True): bool,
|
||||
vol.Optional("minimal_response", default=False): bool,
|
||||
vol.Optional("no_attributes", default=False): bool,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_get_history_during_period(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||
) -> None:
|
||||
"""Handle history during period websocket command."""
|
||||
start_time_str = msg["start_time"]
|
||||
end_time_str = msg.get("end_time")
|
||||
|
||||
if start_time := dt_util.parse_datetime(start_time_str):
|
||||
start_time = dt_util.as_utc(start_time)
|
||||
else:
|
||||
connection.send_error(msg["id"], "invalid_start_time", "Invalid start_time")
|
||||
return
|
||||
|
||||
if end_time_str:
|
||||
if end_time := dt_util.parse_datetime(end_time_str):
|
||||
end_time = dt_util.as_utc(end_time)
|
||||
else:
|
||||
connection.send_error(msg["id"], "invalid_end_time", "Invalid end_time")
|
||||
return
|
||||
else:
|
||||
end_time = None
|
||||
|
||||
if start_time > dt_util.utcnow():
|
||||
connection.send_result(msg["id"], {})
|
||||
return
|
||||
|
||||
entity_ids = msg.get("entity_ids")
|
||||
include_start_time_state = msg["include_start_time_state"]
|
||||
|
||||
if (
|
||||
not include_start_time_state
|
||||
and entity_ids
|
||||
and not _entities_may_have_state_changes_after(hass, entity_ids, start_time)
|
||||
):
|
||||
connection.send_result(msg["id"], {})
|
||||
return
|
||||
|
||||
significant_changes_only = msg["significant_changes_only"]
|
||||
no_attributes = msg["no_attributes"]
|
||||
minimal_response = msg["minimal_response"]
|
||||
compressed_state_format = True
|
||||
|
||||
history_during_period: MutableMapping[
|
||||
str, list[State | dict[str, Any]]
|
||||
] = await get_instance(hass).async_add_executor_job(
|
||||
history.get_significant_states,
|
||||
hass,
|
||||
start_time,
|
||||
end_time,
|
||||
entity_ids,
|
||||
hass.data[HISTORY_FILTERS],
|
||||
include_start_time_state,
|
||||
significant_changes_only,
|
||||
minimal_response,
|
||||
no_attributes,
|
||||
compressed_state_format,
|
||||
)
|
||||
connection.send_result(msg["id"], history_during_period)
|
||||
|
||||
|
||||
class HistoryPeriodView(HomeAssistantView):
|
||||
"""Handle history period requests."""
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable, Iterator, MutableMapping
|
||||
from collections.abc import Callable, Iterable, Iterator, MutableMapping
|
||||
from datetime import datetime
|
||||
from itertools import groupby
|
||||
import logging
|
||||
@ -10,6 +10,7 @@ import time
|
||||
from typing import Any, cast
|
||||
|
||||
from sqlalchemy import Column, Text, and_, bindparam, func, or_
|
||||
from sqlalchemy.engine.row import Row
|
||||
from sqlalchemy.ext import baked
|
||||
from sqlalchemy.ext.baked import BakedQuery
|
||||
from sqlalchemy.orm.query import Query
|
||||
@ -17,6 +18,10 @@ from sqlalchemy.orm.session import Session
|
||||
from sqlalchemy.sql.expression import literal
|
||||
|
||||
from homeassistant.components import recorder
|
||||
from homeassistant.components.websocket_api.const import (
|
||||
COMPRESSED_STATE_LAST_CHANGED,
|
||||
COMPRESSED_STATE_STATE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State, split_entity_id
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
@ -25,8 +30,10 @@ from .models import (
|
||||
RecorderRuns,
|
||||
StateAttributes,
|
||||
States,
|
||||
process_datetime_to_timestamp,
|
||||
process_timestamp,
|
||||
process_timestamp_to_utc_isoformat,
|
||||
row_to_compressed_state,
|
||||
)
|
||||
from .util import execute, session_scope
|
||||
|
||||
@ -161,6 +168,7 @@ def get_significant_states(
|
||||
significant_changes_only: bool = True,
|
||||
minimal_response: bool = False,
|
||||
no_attributes: bool = False,
|
||||
compressed_state_format: bool = False,
|
||||
) -> MutableMapping[str, list[State | dict[str, Any]]]:
|
||||
"""Wrap get_significant_states_with_session with an sql session."""
|
||||
with session_scope(hass=hass) as session:
|
||||
@ -175,6 +183,7 @@ def get_significant_states(
|
||||
significant_changes_only,
|
||||
minimal_response,
|
||||
no_attributes,
|
||||
compressed_state_format,
|
||||
)
|
||||
|
||||
|
||||
@ -199,7 +208,7 @@ def _query_significant_states_with_session(
|
||||
filters: Any = None,
|
||||
significant_changes_only: bool = True,
|
||||
no_attributes: bool = False,
|
||||
) -> list[States]:
|
||||
) -> list[Row]:
|
||||
"""Query the database for significant state changes."""
|
||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
timer_start = time.perf_counter()
|
||||
@ -271,6 +280,7 @@ def get_significant_states_with_session(
|
||||
significant_changes_only: bool = True,
|
||||
minimal_response: bool = False,
|
||||
no_attributes: bool = False,
|
||||
compressed_state_format: bool = False,
|
||||
) -> MutableMapping[str, list[State | dict[str, Any]]]:
|
||||
"""
|
||||
Return states changes during UTC period start_time - end_time.
|
||||
@ -304,6 +314,7 @@ def get_significant_states_with_session(
|
||||
include_start_time_state,
|
||||
minimal_response,
|
||||
no_attributes,
|
||||
compressed_state_format,
|
||||
)
|
||||
|
||||
|
||||
@ -541,7 +552,7 @@ def _get_states_baked_query_for_all(
|
||||
return baked_query
|
||||
|
||||
|
||||
def _get_states_with_session(
|
||||
def _get_rows_with_session(
|
||||
hass: HomeAssistant,
|
||||
session: Session,
|
||||
utc_point_in_time: datetime,
|
||||
@ -549,7 +560,7 @@ def _get_states_with_session(
|
||||
run: RecorderRuns | None = None,
|
||||
filters: Any | None = None,
|
||||
no_attributes: bool = False,
|
||||
) -> list[State]:
|
||||
) -> list[Row]:
|
||||
"""Return the states at a specific point in time."""
|
||||
if entity_ids and len(entity_ids) == 1:
|
||||
return _get_single_entity_states_with_session(
|
||||
@ -570,17 +581,13 @@ def _get_states_with_session(
|
||||
else:
|
||||
baked_query = _get_states_baked_query_for_all(hass, filters, no_attributes)
|
||||
|
||||
attr_cache: dict[str, dict[str, Any]] = {}
|
||||
return [
|
||||
LazyState(row, attr_cache)
|
||||
for row in execute(
|
||||
baked_query(session).params(
|
||||
run_start=run.start,
|
||||
utc_point_in_time=utc_point_in_time,
|
||||
entity_ids=entity_ids,
|
||||
)
|
||||
return execute(
|
||||
baked_query(session).params(
|
||||
run_start=run.start,
|
||||
utc_point_in_time=utc_point_in_time,
|
||||
entity_ids=entity_ids,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _get_single_entity_states_with_session(
|
||||
@ -589,7 +596,7 @@ def _get_single_entity_states_with_session(
|
||||
utc_point_in_time: datetime,
|
||||
entity_id: str,
|
||||
no_attributes: bool = False,
|
||||
) -> list[State]:
|
||||
) -> list[Row]:
|
||||
# Use an entirely different (and extremely fast) query if we only
|
||||
# have a single entity id
|
||||
baked_query, join_attributes = bake_query_and_join_attributes(hass, no_attributes)
|
||||
@ -607,19 +614,20 @@ def _get_single_entity_states_with_session(
|
||||
utc_point_in_time=utc_point_in_time, entity_id=entity_id
|
||||
)
|
||||
|
||||
return [LazyState(row) for row in execute(query)]
|
||||
return execute(query)
|
||||
|
||||
|
||||
def _sorted_states_to_dict(
|
||||
hass: HomeAssistant,
|
||||
session: Session,
|
||||
states: Iterable[States],
|
||||
states: Iterable[Row],
|
||||
start_time: datetime,
|
||||
entity_ids: list[str] | None,
|
||||
filters: Any = None,
|
||||
include_start_time_state: bool = True,
|
||||
minimal_response: bool = False,
|
||||
no_attributes: bool = False,
|
||||
compressed_state_format: bool = False,
|
||||
) -> MutableMapping[str, list[State | dict[str, Any]]]:
|
||||
"""Convert SQL results into JSON friendly data structure.
|
||||
|
||||
@ -632,6 +640,19 @@ def _sorted_states_to_dict(
|
||||
each list of states, otherwise our graphs won't start on the Y
|
||||
axis correctly.
|
||||
"""
|
||||
if compressed_state_format:
|
||||
state_class = row_to_compressed_state
|
||||
_process_timestamp: Callable[
|
||||
[datetime], float | str
|
||||
] = process_datetime_to_timestamp
|
||||
attr_last_changed = COMPRESSED_STATE_LAST_CHANGED
|
||||
attr_state = COMPRESSED_STATE_STATE
|
||||
else:
|
||||
state_class = LazyState # type: ignore[assignment]
|
||||
_process_timestamp = process_timestamp_to_utc_isoformat
|
||||
attr_last_changed = LAST_CHANGED_KEY
|
||||
attr_state = STATE_KEY
|
||||
|
||||
result: dict[str, list[State | dict[str, Any]]] = defaultdict(list)
|
||||
# Set all entity IDs to empty lists in result set to maintain the order
|
||||
if entity_ids is not None:
|
||||
@ -640,27 +661,24 @@ def _sorted_states_to_dict(
|
||||
|
||||
# Get the states at the start time
|
||||
timer_start = time.perf_counter()
|
||||
initial_states: dict[str, Row] = {}
|
||||
if include_start_time_state:
|
||||
for state in _get_states_with_session(
|
||||
hass,
|
||||
session,
|
||||
start_time,
|
||||
entity_ids,
|
||||
filters=filters,
|
||||
no_attributes=no_attributes,
|
||||
):
|
||||
state.last_updated = start_time
|
||||
state.last_changed = start_time
|
||||
result[state.entity_id].append(state)
|
||||
initial_states = {
|
||||
row.entity_id: row
|
||||
for row in _get_rows_with_session(
|
||||
hass,
|
||||
session,
|
||||
start_time,
|
||||
entity_ids,
|
||||
filters=filters,
|
||||
no_attributes=no_attributes,
|
||||
)
|
||||
}
|
||||
|
||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
elapsed = time.perf_counter() - timer_start
|
||||
_LOGGER.debug("getting %d first datapoints took %fs", len(result), elapsed)
|
||||
|
||||
# Called in a tight loop so cache the function
|
||||
# here
|
||||
_process_timestamp_to_utc_isoformat = process_timestamp_to_utc_isoformat
|
||||
|
||||
if entity_ids and len(entity_ids) == 1:
|
||||
states_iter: Iterable[tuple[str | Column, Iterator[States]]] = (
|
||||
(entity_ids[0], iter(states)),
|
||||
@ -670,11 +688,15 @@ def _sorted_states_to_dict(
|
||||
|
||||
# Append all changes to it
|
||||
for ent_id, group in states_iter:
|
||||
ent_results = result[ent_id]
|
||||
attr_cache: dict[str, dict[str, Any]] = {}
|
||||
prev_state: Column | str
|
||||
ent_results = result[ent_id]
|
||||
if row := initial_states.pop(ent_id, None):
|
||||
prev_state = row.state
|
||||
ent_results.append(state_class(row, attr_cache, start_time))
|
||||
|
||||
if not minimal_response or split_entity_id(ent_id)[0] in NEED_ATTRIBUTE_DOMAINS:
|
||||
ent_results.extend(LazyState(db_state, attr_cache) for db_state in group)
|
||||
ent_results.extend(state_class(db_state, attr_cache) for db_state in group)
|
||||
continue
|
||||
|
||||
# With minimal response we only provide a native
|
||||
@ -684,34 +706,35 @@ def _sorted_states_to_dict(
|
||||
if not ent_results:
|
||||
if (first_state := next(group, None)) is None:
|
||||
continue
|
||||
ent_results.append(LazyState(first_state, attr_cache))
|
||||
prev_state = first_state.state
|
||||
ent_results.append(state_class(first_state, attr_cache))
|
||||
|
||||
assert isinstance(ent_results[-1], State)
|
||||
prev_state: Column | str = ent_results[-1].state
|
||||
initial_state_count = len(ent_results)
|
||||
|
||||
db_state = None
|
||||
for db_state in group:
|
||||
row = None
|
||||
for row in group:
|
||||
# With minimal response we do not care about attribute
|
||||
# changes so we can filter out duplicate states
|
||||
if (state := db_state.state) == prev_state:
|
||||
if (state := row.state) == prev_state:
|
||||
continue
|
||||
|
||||
ent_results.append(
|
||||
{
|
||||
STATE_KEY: state,
|
||||
LAST_CHANGED_KEY: _process_timestamp_to_utc_isoformat(
|
||||
db_state.last_changed
|
||||
),
|
||||
attr_state: state,
|
||||
attr_last_changed: _process_timestamp(row.last_changed),
|
||||
}
|
||||
)
|
||||
prev_state = state
|
||||
|
||||
if db_state and len(ent_results) != initial_state_count:
|
||||
if row and len(ent_results) != initial_state_count:
|
||||
# There was at least one state change
|
||||
# replace the last minimal state with
|
||||
# a full state
|
||||
ent_results[-1] = LazyState(db_state, attr_cache)
|
||||
ent_results[-1] = state_class(row, attr_cache)
|
||||
|
||||
# If there are no states beyond the initial state,
|
||||
# the state a was never popped from initial_states
|
||||
for ent_id, row in initial_states.items():
|
||||
result[ent_id].append(state_class(row, {}, start_time))
|
||||
|
||||
# Filter out the empty lists if some states had 0 results.
|
||||
return {key: val for key, val in result.items() if val}
|
||||
|
@ -28,6 +28,12 @@ from sqlalchemy.ext.declarative import declared_attr
|
||||
from sqlalchemy.orm import declarative_base, relationship
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from homeassistant.components.websocket_api.const import (
|
||||
COMPRESSED_STATE_ATTRIBUTES,
|
||||
COMPRESSED_STATE_LAST_CHANGED,
|
||||
COMPRESSED_STATE_LAST_UPDATED,
|
||||
COMPRESSED_STATE_STATE,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
MAX_LENGTH_EVENT_CONTEXT_ID,
|
||||
MAX_LENGTH_EVENT_EVENT_TYPE,
|
||||
@ -612,6 +618,13 @@ def process_timestamp_to_utc_isoformat(ts: datetime | None) -> str | None:
|
||||
return ts.astimezone(dt_util.UTC).isoformat()
|
||||
|
||||
|
||||
def process_datetime_to_timestamp(ts: datetime) -> float:
|
||||
"""Process a timestamp into a unix timestamp."""
|
||||
if ts.tzinfo == dt_util.UTC:
|
||||
return ts.timestamp()
|
||||
return ts.replace(tzinfo=dt_util.UTC).timestamp()
|
||||
|
||||
|
||||
class LazyState(State):
|
||||
"""A lazy version of core State."""
|
||||
|
||||
@ -621,45 +634,30 @@ class LazyState(State):
|
||||
"_last_changed",
|
||||
"_last_updated",
|
||||
"_context",
|
||||
"_attr_cache",
|
||||
"attr_cache",
|
||||
]
|
||||
|
||||
def __init__( # pylint: disable=super-init-not-called
|
||||
self, row: Row, attr_cache: dict[str, dict[str, Any]] | None = None
|
||||
self,
|
||||
row: Row,
|
||||
attr_cache: dict[str, dict[str, Any]],
|
||||
start_time: datetime | None = None,
|
||||
) -> None:
|
||||
"""Init the lazy state."""
|
||||
self._row = row
|
||||
self.entity_id: str = self._row.entity_id
|
||||
self.state = self._row.state or ""
|
||||
self._attributes: dict[str, Any] | None = None
|
||||
self._last_changed: datetime | None = None
|
||||
self._last_updated: datetime | None = None
|
||||
self._last_changed: datetime | None = start_time
|
||||
self._last_updated: datetime | None = start_time
|
||||
self._context: Context | None = None
|
||||
self._attr_cache = attr_cache
|
||||
self.attr_cache = attr_cache
|
||||
|
||||
@property # type: ignore[override]
|
||||
def attributes(self) -> dict[str, Any]: # type: ignore[override]
|
||||
"""State attributes."""
|
||||
if self._attributes is None:
|
||||
source = self._row.shared_attrs or self._row.attributes
|
||||
if self._attr_cache is not None and (
|
||||
attributes := self._attr_cache.get(source)
|
||||
):
|
||||
self._attributes = attributes
|
||||
return attributes
|
||||
if source == EMPTY_JSON_OBJECT or source is None:
|
||||
self._attributes = {}
|
||||
return self._attributes
|
||||
try:
|
||||
self._attributes = json.loads(source)
|
||||
except ValueError:
|
||||
# When json.loads fails
|
||||
_LOGGER.exception(
|
||||
"Error converting row to state attributes: %s", self._row
|
||||
)
|
||||
self._attributes = {}
|
||||
if self._attr_cache is not None:
|
||||
self._attr_cache[source] = self._attributes
|
||||
self._attributes = decode_attributes_from_row(self._row, self.attr_cache)
|
||||
return self._attributes
|
||||
|
||||
@attributes.setter
|
||||
@ -748,3 +746,48 @@ class LazyState(State):
|
||||
and self.state == other.state
|
||||
and self.attributes == other.attributes
|
||||
)
|
||||
|
||||
|
||||
def decode_attributes_from_row(
|
||||
row: Row, attr_cache: dict[str, dict[str, Any]]
|
||||
) -> dict[str, Any]:
|
||||
"""Decode attributes from a database row."""
|
||||
source: str = row.shared_attrs or row.attributes
|
||||
if (attributes := attr_cache.get(source)) is not None:
|
||||
return attributes
|
||||
if not source or source == EMPTY_JSON_OBJECT:
|
||||
return {}
|
||||
try:
|
||||
attr_cache[source] = attributes = json.loads(source)
|
||||
except ValueError:
|
||||
_LOGGER.exception("Error converting row to state attributes: %s", source)
|
||||
attr_cache[source] = attributes = {}
|
||||
return attributes
|
||||
|
||||
|
||||
def row_to_compressed_state(
|
||||
row: Row,
|
||||
attr_cache: dict[str, dict[str, Any]],
|
||||
start_time: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Convert a database row to a compressed state."""
|
||||
if start_time:
|
||||
last_changed = last_updated = start_time.timestamp()
|
||||
else:
|
||||
row_changed_changed: datetime = row.last_changed
|
||||
if (
|
||||
not (row_last_updated := row.last_updated)
|
||||
or row_last_updated == row_changed_changed
|
||||
):
|
||||
last_changed = last_updated = process_datetime_to_timestamp(
|
||||
row_changed_changed
|
||||
)
|
||||
else:
|
||||
last_changed = process_datetime_to_timestamp(row_changed_changed)
|
||||
last_updated = process_datetime_to_timestamp(row_last_updated)
|
||||
return {
|
||||
COMPRESSED_STATE_STATE: row.state,
|
||||
COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_row(row, attr_cache),
|
||||
COMPRESSED_STATE_LAST_CHANGED: last_changed,
|
||||
COMPRESSED_STATE_LAST_UPDATED: last_updated,
|
||||
}
|
||||
|
@ -56,3 +56,9 @@ DATA_CONNECTIONS: Final = f"{DOMAIN}.connections"
|
||||
JSON_DUMP: Final = partial(
|
||||
json.dumps, cls=JSONEncoder, allow_nan=False, separators=(",", ":")
|
||||
)
|
||||
|
||||
COMPRESSED_STATE_STATE = "s"
|
||||
COMPRESSED_STATE_ATTRIBUTES = "a"
|
||||
COMPRESSED_STATE_CONTEXT = "c"
|
||||
COMPRESSED_STATE_LAST_CHANGED = "lc"
|
||||
COMPRESSED_STATE_LAST_UPDATED = "lu"
|
||||
|
@ -16,6 +16,13 @@ from homeassistant.util.json import (
|
||||
from homeassistant.util.yaml.loader import JSON_TYPE
|
||||
|
||||
from . import const
|
||||
from .const import (
|
||||
COMPRESSED_STATE_ATTRIBUTES,
|
||||
COMPRESSED_STATE_CONTEXT,
|
||||
COMPRESSED_STATE_LAST_CHANGED,
|
||||
COMPRESSED_STATE_LAST_UPDATED,
|
||||
COMPRESSED_STATE_STATE,
|
||||
)
|
||||
|
||||
_LOGGER: Final = logging.getLogger(__name__)
|
||||
|
||||
@ -31,12 +38,6 @@ BASE_COMMAND_MESSAGE_SCHEMA: Final = vol.Schema({vol.Required("id"): cv.positive
|
||||
IDEN_TEMPLATE: Final = "__IDEN__"
|
||||
IDEN_JSON_TEMPLATE: Final = '"__IDEN__"'
|
||||
|
||||
COMPRESSED_STATE_STATE = "s"
|
||||
COMPRESSED_STATE_ATTRIBUTES = "a"
|
||||
COMPRESSED_STATE_CONTEXT = "c"
|
||||
COMPRESSED_STATE_LAST_CHANGED = "lc"
|
||||
COMPRESSED_STATE_LAST_UPDATED = "lu"
|
||||
|
||||
STATE_DIFF_ADDITIONS = "+"
|
||||
STATE_DIFF_REMOVALS = "-"
|
||||
|
||||
|
@ -1070,3 +1070,409 @@ async def test_list_statistic_ids(
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == []
|
||||
|
||||
|
||||
async def test_history_during_period(hass, hass_ws_client, recorder_mock):
|
||||
"""Test history_during_period."""
|
||||
now = dt_util.utcnow()
|
||||
|
||||
await async_setup_component(hass, "history", {})
|
||||
await async_setup_component(hass, "sensor", {})
|
||||
await async_recorder_block_till_done(hass)
|
||||
hass.states.async_set("sensor.test", "on", attributes={"any": "attr"})
|
||||
await async_recorder_block_till_done(hass)
|
||||
hass.states.async_set("sensor.test", "off", attributes={"any": "attr"})
|
||||
await async_recorder_block_till_done(hass)
|
||||
hass.states.async_set("sensor.test", "off", attributes={"any": "changed"})
|
||||
await async_recorder_block_till_done(hass)
|
||||
hass.states.async_set("sensor.test", "off", attributes={"any": "again"})
|
||||
await async_recorder_block_till_done(hass)
|
||||
hass.states.async_set("sensor.test", "on", attributes={"any": "attr"})
|
||||
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(),
|
||||
"end_time": now.isoformat(),
|
||||
"entity_ids": ["sensor.test"],
|
||||
"include_start_time_state": True,
|
||||
"significant_changes_only": False,
|
||||
"no_attributes": True,
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == {}
|
||||
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 2,
|
||||
"type": "history/history_during_period",
|
||||
"start_time": now.isoformat(),
|
||||
"entity_ids": ["sensor.test"],
|
||||
"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"] == 2
|
||||
|
||||
sensor_test_history = response["result"]["sensor.test"]
|
||||
assert len(sensor_test_history) == 3
|
||||
|
||||
assert sensor_test_history[0]["s"] == "on"
|
||||
assert sensor_test_history[0]["a"] == {}
|
||||
assert isinstance(sensor_test_history[0]["lu"], float)
|
||||
assert isinstance(sensor_test_history[0]["lc"], float)
|
||||
|
||||
assert "a" not in sensor_test_history[1]
|
||||
assert sensor_test_history[1]["s"] == "off"
|
||||
assert isinstance(sensor_test_history[1]["lc"], float)
|
||||
|
||||
assert sensor_test_history[2]["s"] == "on"
|
||||
assert sensor_test_history[2]["a"] == {}
|
||||
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 3,
|
||||
"type": "history/history_during_period",
|
||||
"start_time": now.isoformat(),
|
||||
"entity_ids": ["sensor.test"],
|
||||
"include_start_time_state": True,
|
||||
"significant_changes_only": False,
|
||||
"no_attributes": False,
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["id"] == 3
|
||||
sensor_test_history = response["result"]["sensor.test"]
|
||||
|
||||
assert len(sensor_test_history) == 5
|
||||
|
||||
assert sensor_test_history[0]["s"] == "on"
|
||||
assert sensor_test_history[0]["a"] == {"any": "attr"}
|
||||
assert isinstance(sensor_test_history[0]["lu"], float)
|
||||
assert isinstance(sensor_test_history[0]["lc"], float)
|
||||
|
||||
assert sensor_test_history[1]["s"] == "off"
|
||||
assert isinstance(sensor_test_history[1]["lc"], float)
|
||||
assert sensor_test_history[1]["a"] == {"any": "attr"}
|
||||
|
||||
assert sensor_test_history[4]["s"] == "on"
|
||||
assert sensor_test_history[4]["a"] == {"any": "attr"}
|
||||
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 4,
|
||||
"type": "history/history_during_period",
|
||||
"start_time": now.isoformat(),
|
||||
"entity_ids": ["sensor.test"],
|
||||
"include_start_time_state": True,
|
||||
"significant_changes_only": True,
|
||||
"no_attributes": False,
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["id"] == 4
|
||||
sensor_test_history = response["result"]["sensor.test"]
|
||||
|
||||
assert len(sensor_test_history) == 3
|
||||
|
||||
assert sensor_test_history[0]["s"] == "on"
|
||||
assert sensor_test_history[0]["a"] == {"any": "attr"}
|
||||
assert isinstance(sensor_test_history[0]["lu"], float)
|
||||
assert isinstance(sensor_test_history[0]["lc"], float)
|
||||
|
||||
assert sensor_test_history[1]["s"] == "off"
|
||||
assert isinstance(sensor_test_history[1]["lc"], float)
|
||||
assert sensor_test_history[1]["a"] == {"any": "attr"}
|
||||
|
||||
assert sensor_test_history[2]["s"] == "on"
|
||||
assert sensor_test_history[2]["a"] == {"any": "attr"}
|
||||
|
||||
|
||||
async def test_history_during_period_impossible_conditions(
|
||||
hass, hass_ws_client, recorder_mock
|
||||
):
|
||||
"""Test history_during_period returns when condition cannot be true."""
|
||||
now = dt_util.utcnow()
|
||||
|
||||
await async_setup_component(hass, "history", {})
|
||||
await async_setup_component(hass, "sensor", {})
|
||||
await async_recorder_block_till_done(hass)
|
||||
hass.states.async_set("sensor.test", "on", attributes={"any": "attr"})
|
||||
await async_recorder_block_till_done(hass)
|
||||
hass.states.async_set("sensor.test", "off", attributes={"any": "attr"})
|
||||
await async_recorder_block_till_done(hass)
|
||||
hass.states.async_set("sensor.test", "off", attributes={"any": "changed"})
|
||||
await async_recorder_block_till_done(hass)
|
||||
hass.states.async_set("sensor.test", "off", attributes={"any": "again"})
|
||||
await async_recorder_block_till_done(hass)
|
||||
hass.states.async_set("sensor.test", "on", attributes={"any": "attr"})
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
do_adhoc_statistics(hass, start=now)
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
after = dt_util.utcnow()
|
||||
|
||||
client = await hass_ws_client()
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 1,
|
||||
"type": "history/history_during_period",
|
||||
"start_time": after.isoformat(),
|
||||
"end_time": after.isoformat(),
|
||||
"entity_ids": ["sensor.test"],
|
||||
"include_start_time_state": False,
|
||||
"significant_changes_only": False,
|
||||
"no_attributes": True,
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["id"] == 1
|
||||
assert response["result"] == {}
|
||||
|
||||
future = dt_util.utcnow() + timedelta(hours=10)
|
||||
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 2,
|
||||
"type": "history/history_during_period",
|
||||
"start_time": future.isoformat(),
|
||||
"entity_ids": ["sensor.test"],
|
||||
"include_start_time_state": True,
|
||||
"significant_changes_only": True,
|
||||
"no_attributes": True,
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["id"] == 2
|
||||
assert response["result"] == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"time_zone", ["UTC", "Europe/Berlin", "America/Chicago", "US/Hawaii"]
|
||||
)
|
||||
async def test_history_during_period_significant_domain(
|
||||
time_zone, hass, hass_ws_client, recorder_mock
|
||||
):
|
||||
"""Test history_during_period with climate domain."""
|
||||
hass.config.set_time_zone(time_zone)
|
||||
now = dt_util.utcnow()
|
||||
|
||||
await async_setup_component(hass, "history", {})
|
||||
await async_setup_component(hass, "sensor", {})
|
||||
await async_recorder_block_till_done(hass)
|
||||
hass.states.async_set("climate.test", "on", attributes={"temperature": "1"})
|
||||
await async_recorder_block_till_done(hass)
|
||||
hass.states.async_set("climate.test", "off", attributes={"temperature": "2"})
|
||||
await async_recorder_block_till_done(hass)
|
||||
hass.states.async_set("climate.test", "off", attributes={"temperature": "3"})
|
||||
await async_recorder_block_till_done(hass)
|
||||
hass.states.async_set("climate.test", "off", attributes={"temperature": "4"})
|
||||
await async_recorder_block_till_done(hass)
|
||||
hass.states.async_set("climate.test", "on", attributes={"temperature": "5"})
|
||||
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(),
|
||||
"end_time": now.isoformat(),
|
||||
"entity_ids": ["climate.test"],
|
||||
"include_start_time_state": True,
|
||||
"significant_changes_only": False,
|
||||
"no_attributes": True,
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == {}
|
||||
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 2,
|
||||
"type": "history/history_during_period",
|
||||
"start_time": now.isoformat(),
|
||||
"entity_ids": ["climate.test"],
|
||||
"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"] == 2
|
||||
|
||||
sensor_test_history = response["result"]["climate.test"]
|
||||
assert len(sensor_test_history) == 5
|
||||
|
||||
assert sensor_test_history[0]["s"] == "on"
|
||||
assert sensor_test_history[0]["a"] == {}
|
||||
assert isinstance(sensor_test_history[0]["lu"], float)
|
||||
assert isinstance(sensor_test_history[0]["lc"], float)
|
||||
|
||||
assert "a" in sensor_test_history[1]
|
||||
assert sensor_test_history[1]["s"] == "off"
|
||||
assert isinstance(sensor_test_history[1]["lc"], float)
|
||||
|
||||
assert sensor_test_history[4]["s"] == "on"
|
||||
assert sensor_test_history[4]["a"] == {}
|
||||
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 3,
|
||||
"type": "history/history_during_period",
|
||||
"start_time": now.isoformat(),
|
||||
"entity_ids": ["climate.test"],
|
||||
"include_start_time_state": True,
|
||||
"significant_changes_only": False,
|
||||
"no_attributes": False,
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["id"] == 3
|
||||
sensor_test_history = response["result"]["climate.test"]
|
||||
|
||||
assert len(sensor_test_history) == 5
|
||||
|
||||
assert sensor_test_history[0]["s"] == "on"
|
||||
assert sensor_test_history[0]["a"] == {"temperature": "1"}
|
||||
assert isinstance(sensor_test_history[0]["lu"], float)
|
||||
assert isinstance(sensor_test_history[0]["lc"], float)
|
||||
|
||||
assert sensor_test_history[1]["s"] == "off"
|
||||
assert isinstance(sensor_test_history[1]["lc"], float)
|
||||
assert sensor_test_history[1]["a"] == {"temperature": "2"}
|
||||
|
||||
assert sensor_test_history[4]["s"] == "on"
|
||||
assert sensor_test_history[4]["a"] == {"temperature": "5"}
|
||||
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 4,
|
||||
"type": "history/history_during_period",
|
||||
"start_time": now.isoformat(),
|
||||
"entity_ids": ["climate.test"],
|
||||
"include_start_time_state": True,
|
||||
"significant_changes_only": True,
|
||||
"no_attributes": False,
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["id"] == 4
|
||||
sensor_test_history = response["result"]["climate.test"]
|
||||
|
||||
assert len(sensor_test_history) == 5
|
||||
|
||||
assert sensor_test_history[0]["s"] == "on"
|
||||
assert sensor_test_history[0]["a"] == {"temperature": "1"}
|
||||
assert isinstance(sensor_test_history[0]["lu"], float)
|
||||
assert isinstance(sensor_test_history[0]["lc"], float)
|
||||
|
||||
assert sensor_test_history[1]["s"] == "off"
|
||||
assert isinstance(sensor_test_history[1]["lc"], float)
|
||||
assert sensor_test_history[1]["a"] == {"temperature": "2"}
|
||||
|
||||
assert sensor_test_history[2]["s"] == "off"
|
||||
assert sensor_test_history[2]["a"] == {"temperature": "3"}
|
||||
|
||||
assert sensor_test_history[3]["s"] == "off"
|
||||
assert sensor_test_history[3]["a"] == {"temperature": "4"}
|
||||
|
||||
assert sensor_test_history[4]["s"] == "on"
|
||||
assert sensor_test_history[4]["a"] == {"temperature": "5"}
|
||||
|
||||
# Test we impute the state time state
|
||||
later = dt_util.utcnow()
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 5,
|
||||
"type": "history/history_during_period",
|
||||
"start_time": later.isoformat(),
|
||||
"entity_ids": ["climate.test"],
|
||||
"include_start_time_state": True,
|
||||
"significant_changes_only": True,
|
||||
"no_attributes": False,
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["id"] == 5
|
||||
sensor_test_history = response["result"]["climate.test"]
|
||||
|
||||
assert len(sensor_test_history) == 1
|
||||
|
||||
assert sensor_test_history[0]["s"] == "on"
|
||||
assert sensor_test_history[0]["a"] == {"temperature": "5"}
|
||||
assert sensor_test_history[0]["lu"] == later.timestamp()
|
||||
assert sensor_test_history[0]["lc"] == later.timestamp()
|
||||
|
||||
|
||||
async def test_history_during_period_bad_start_time(
|
||||
hass, hass_ws_client, recorder_mock
|
||||
):
|
||||
"""Test history_during_period bad state time."""
|
||||
await async_setup_component(
|
||||
hass,
|
||||
"history",
|
||||
{"history": {}},
|
||||
)
|
||||
|
||||
client = await hass_ws_client()
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 1,
|
||||
"type": "history/history_during_period",
|
||||
"start_time": "cats",
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert not response["success"]
|
||||
assert response["error"]["code"] == "invalid_start_time"
|
||||
|
||||
|
||||
async def test_history_during_period_bad_end_time(hass, hass_ws_client, recorder_mock):
|
||||
"""Test history_during_period bad end time."""
|
||||
now = dt_util.utcnow()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
"history",
|
||||
{"history": {}},
|
||||
)
|
||||
|
||||
client = await hass_ws_client()
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 1,
|
||||
"type": "history/history_during_period",
|
||||
"start_time": now.isoformat(),
|
||||
"end_time": "dogs",
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert not response["success"]
|
||||
assert response["error"]["code"] == "invalid_end_time"
|
||||
|
@ -14,6 +14,7 @@ from homeassistant.components import recorder
|
||||
from homeassistant.components.recorder import history
|
||||
from homeassistant.components.recorder.models import (
|
||||
Events,
|
||||
LazyState,
|
||||
RecorderRuns,
|
||||
StateAttributes,
|
||||
States,
|
||||
@ -40,9 +41,19 @@ async def _async_get_states(
|
||||
|
||||
def _get_states_with_session():
|
||||
with session_scope(hass=hass) as session:
|
||||
return history._get_states_with_session(
|
||||
hass, session, utc_point_in_time, entity_ids, run, None, no_attributes
|
||||
)
|
||||
attr_cache = {}
|
||||
return [
|
||||
LazyState(row, attr_cache)
|
||||
for row in history._get_rows_with_session(
|
||||
hass,
|
||||
session,
|
||||
utc_point_in_time,
|
||||
entity_ids,
|
||||
run,
|
||||
None,
|
||||
no_attributes,
|
||||
)
|
||||
]
|
||||
|
||||
return await recorder.get_instance(hass).async_add_executor_job(
|
||||
_get_states_with_session
|
||||
|
@ -247,7 +247,7 @@ async def test_lazy_state_handles_include_json(caplog):
|
||||
entity_id="sensor.invalid",
|
||||
shared_attrs="{INVALID_JSON}",
|
||||
)
|
||||
assert LazyState(row).attributes == {}
|
||||
assert LazyState(row, {}).attributes == {}
|
||||
assert "Error converting row to state attributes" in caplog.text
|
||||
|
||||
|
||||
@ -258,7 +258,7 @@ async def test_lazy_state_prefers_shared_attrs_over_attrs(caplog):
|
||||
shared_attrs='{"shared":true}',
|
||||
attributes='{"shared":false}',
|
||||
)
|
||||
assert LazyState(row).attributes == {"shared": True}
|
||||
assert LazyState(row, {}).attributes == {"shared": True}
|
||||
|
||||
|
||||
async def test_lazy_state_handles_different_last_updated_and_last_changed(caplog):
|
||||
@ -271,7 +271,7 @@ async def test_lazy_state_handles_different_last_updated_and_last_changed(caplog
|
||||
last_updated=now,
|
||||
last_changed=now - timedelta(seconds=60),
|
||||
)
|
||||
lstate = LazyState(row)
|
||||
lstate = LazyState(row, {})
|
||||
assert lstate.as_dict() == {
|
||||
"attributes": {"shared": True},
|
||||
"entity_id": "sensor.valid",
|
||||
@ -300,7 +300,7 @@ async def test_lazy_state_handles_same_last_updated_and_last_changed(caplog):
|
||||
last_updated=now,
|
||||
last_changed=now,
|
||||
)
|
||||
lstate = LazyState(row)
|
||||
lstate = LazyState(row, {})
|
||||
assert lstate.as_dict() == {
|
||||
"attributes": {"shared": True},
|
||||
"entity_id": "sensor.valid",
|
||||
|
Loading…
x
Reference in New Issue
Block a user