From cea6064356d313646656a7ddae4ab4207cda8cad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=2E=20L=C3=B3pez?= Date: Thu, 8 Feb 2024 12:55:31 +0100 Subject: [PATCH 1/2] 3.x branch opened. Everything can change, API changes everywhere --- .gitignore | 2 +- Pipfile | 2 +- custom_components/delorian/manifest.json | 4 +- custom_components/delorian/sensor.py | 45 +-- ha-historical-sensor.sublime-project | 40 +++ .../recorderutil.py | 161 --------- homeassistant_historical_sensor/sensor.py | 205 ++++-------- homeassistant_historical_sensor/state.py | 36 +- .../timemachine.py | 310 ++++++++++++++++++ pyproject.toml | 2 +- 10 files changed, 473 insertions(+), 334 deletions(-) create mode 100644 ha-historical-sensor.sublime-project delete mode 100644 homeassistant_historical_sensor/recorderutil.py create mode 100644 homeassistant_historical_sensor/timemachine.py diff --git a/.gitignore b/.gitignore index 5068f44..c18ce16 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ *.egg-info/ *.pyc -*.sublime-project +*.sublime-workspace __pycache__ dist/ home-assistant-historical-sensor.sublime-workspace diff --git a/Pipfile b/Pipfile index c51e50d..20ab419 100644 --- a/Pipfile +++ b/Pipfile @@ -6,10 +6,10 @@ name = "pypi" [packages] [dev-packages] +# homeassistant-historical-sensor = {editable = true, path = "."} black = "*" build = "*" homeassistant = ">=2024.1.0" -homeassistant-historical-sensor = {editable = true, path = "."} ipdb = "*" ipython = "*" isort = "*" diff --git a/custom_components/delorian/manifest.json b/custom_components/delorian/manifest.json index 79a3aea..b7c3bc5 100644 --- a/custom_components/delorian/manifest.json +++ b/custom_components/delorian/manifest.json @@ -12,7 +12,7 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/ldotlopez/ha-historical-sensor/issues", "requirements": [ - "homeassistant-historical-sensor==2.0.0rc4" + "homeassistant-historical-sensor==3.0.0a1" ], - "version": "2.0.0rc4" + "version": "3.0.0a1" } diff --git a/custom_components/delorian/sensor.py b/custom_components/delorian/sensor.py index 7077c6d..32bffe5 100644 --- a/custom_components/delorian/sensor.py +++ b/custom_components/delorian/sensor.py @@ -23,9 +23,10 @@ # Important methods include comments about code itself and reasons behind them # -import itertools +import math import statistics from datetime import datetime, timedelta +from zoneinfo import ZoneInfo from homeassistant.components.recorder.models import StatisticData, StatisticMetaData from homeassistant.components.sensor import SensorDeviceClass, SensorEntity @@ -41,6 +42,7 @@ HistoricalState, PollUpdateMixin, ) +from homeassistant_historical_sensor.state import group_by_interval from .api import API from .const import DOMAIN, NAME @@ -92,18 +94,29 @@ async def async_update_historical(self): # This functions is equivaled to the `Sensor.async_update` from # HomeAssistant core # - # Important: You must provide datetime with tzinfo + # Important: ts is in UTC - hist_states = [ + upstream_data = self.api.fetch( + start=datetime.now() - timedelta(days=3), step=timedelta(minutes=15) + ) + + upstream_data_with_timestamps = [ + ( + dt.timestamp() if dt.tzinfo else dtutil.as_local(dt).timestamp(), + state, + ) + for (dt, state) in upstream_data + ] + + historical_states = [ HistoricalState( state=state, - dt=dtutil.as_local(dt), # Add tzinfo, required by HistoricalSensor - ) - for (dt, state) in self.api.fetch( - start=datetime.now() - timedelta(days=3), step=timedelta(minutes=15) + ts=ts, ) + for (ts, state) in upstream_data_with_timestamps ] - self._attr_historical_states = hist_states + + self._attr_historical_states = historical_states @property def statistic_id(self) -> str: @@ -131,24 +144,18 @@ async def async_calculate_statistic_data( accumulated = latest["sum"] if latest else 0 - def hour_block_for_hist_state(hist_state: HistoricalState) -> datetime: - # XX:00:00 states belongs to previous hour block - if hist_state.dt.minute == 0 and hist_state.dt.second == 0: - dt = hist_state.dt - timedelta(hours=1) - return dt.replace(minute=0, second=0, microsecond=0) - - else: - return hist_state.dt.replace(minute=0, second=0, microsecond=0) - ret = [] - for dt, collection_it in itertools.groupby( - hist_states, key=hour_block_for_hist_state + for block_ts, collection_it in group_by_interval( + hist_states, granurality=60 * 60 ): collection = list(collection_it) + mean = statistics.mean([x.state for x in collection]) partial_sum = sum([x.state for x in collection]) accumulated = accumulated + partial_sum + dt = datetime.fromtimestamp(block_ts).replace(tzinfo=ZoneInfo("UTC")) + ret.append( StatisticData( start=dt, diff --git a/ha-historical-sensor.sublime-project b/ha-historical-sensor.sublime-project new file mode 100644 index 0000000..a3de4d7 --- /dev/null +++ b/ha-historical-sensor.sublime-project @@ -0,0 +1,40 @@ +{ + "folders": + [ + { + "file_exclude_patterns": [ + "*.pyc", + "*.swp", + "Pipfile.lock" + ], + "folder_exclude_patterns": [ + "*.egg-info", + ".mypy_cache", + ".venv", + "__pycache__", + "dist", + ], + "follow_symlinks": true, + "path": ".", + } + ], + "settings": { + "python_interpreter": "${project_path}/.venv/bin/python", + + "sublack.black_command": "${project_path}/.venv/bin/black", + "sublack.black_on_save": true, + + "isort.sort_on_save": false, + + "SublimeLinter.linters.flake8.executable": "${project_path}/.venv/bin/flake8", + "SublimeLinter.linters.flake8.disable": false, + + "SublimeLinter.linters.mypy.executable": "${project_path}/.venv/bin/mypy", + "SublimeLinter.linters.mypy.disable": false, + // "SublimeLinter.linters.mypy.args": ["--ignore-missing-imports"], + + "SublimeLinter.linters.pycodestyle.executable": "${project_path}/.venv/bin/pycodestyle", + "SublimeLinter.linters.pycodestyle.disable": true, + } + +} diff --git a/homeassistant_historical_sensor/recorderutil.py b/homeassistant_historical_sensor/recorderutil.py deleted file mode 100644 index b13302f..0000000 --- a/homeassistant_historical_sensor/recorderutil.py +++ /dev/null @@ -1,161 +0,0 @@ -# Copyright (C) 2021-2023 Luis López -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, -# USA. - - -import logging -from contextlib import contextmanager -from typing import Literal - -from homeassistant.components import recorder -from homeassistant.components.recorder import Recorder, db_schema -from homeassistant.components.recorder.statistics import ( - StatisticsRow, - get_last_statistics, -) -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import Entity -from sqlalchemy import Select, not_, or_, select -from sqlalchemy.orm import Session - -_LOGGER = logging.getLogger(__name__) - - -@contextmanager -def hass_recorder_session(hass: HomeAssistant): - r = recorder.get_instance(hass) - with recorder.util.session_scope(session=r.get_session()) as session: - yield session - - -async def hass_get_entity_states_metadata_id( - hass: HomeAssistant, entity: Entity -) -> int | None: - rec = recorder.get_instance(hass) - return await rec.async_add_executor_job( - recorder_get_entity_states_metadata_id, rec, entity - ) - - -def recorder_get_entity_states_metadata_id(rec: Recorder, entity: Entity) -> int | None: - with rec.get_session() as sess: - return rec.states_meta_manager.get(entity.entity_id, sess, True) - - -async def get_last_statistics_wrapper( - hass: HomeAssistant, - statistic_id: str, - *, - convert_units: bool = True, - types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]] = { - "last_reset", - "max", - "mean", - "min", - "state", - "sum", - }, -) -> StatisticsRow | None: - res = await recorder.get_instance(hass).async_add_executor_job( - get_last_statistics, - hass, - 1, - statistic_id, - convert_units, - types, - ) - if not res: - return None - - return res[statistic_id][0] - - -def _entity_id_states_stmt(session: Session, entity: Entity) -> Select: - return ( - select(db_schema.States) - .join(db_schema.StatesMeta) - .where(db_schema.StatesMeta.entity_id == entity.entity_id) - ) - - -def get_entity_states_meta(session: Session, entity: Entity) -> db_schema.StatesMeta: - # Don't re-use _entity_id_states_stmt. - # It's posible to have a StatesMeta for the current entity but zero States in the - # database. - # In that case the _entity_id_states_stmt will return zero rows but it doesn't mean - # that we need to create a new StatesMeta - - res = session.execute( - select(db_schema.StatesMeta).where( - db_schema.StatesMeta.entity_id == entity.entity_id - ) - ).scalar() - - if res: - return res - - else: - ret = db_schema.StatesMeta(entity_id=entity.entity_id) - session.add(ret) - session.commit() - - return ret - - -def delete_entity_invalid_states(session: Session, entity: Entity) -> int: - stmt = _entity_id_states_stmt(session, entity).order_by( - db_schema.States.last_updated_ts.asc() - ) - - prev = None - to_delete = [] - - for state in session.execute(stmt).scalars(): - if state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: - to_delete.append(state) - else: - state.old_state_id = prev.state_id if prev else None # type: ignore[attr-defined] - session.add(state) - prev = state - - for state in to_delete: - session.delete(state) - - session.commit() - - return len(to_delete) - - -def get_entity_latest_state(session: Session, entity: Entity): - stmt = ( - _entity_id_states_stmt(session, entity) - .where( - not_( - or_( - db_schema.States.state == STATE_UNAVAILABLE, - db_schema.States.state == STATE_UNKNOWN, - ) - ) - ) - .order_by(db_schema.States.last_updated_ts.desc()) - ) - return session.execute(stmt).scalar() - - -def save_states(session: Session, states: list[db_schema.States]): - session.add_all(states) - session.commit() diff --git a/homeassistant_historical_sensor/sensor.py b/homeassistant_historical_sensor/sensor.py index f22d809..b37b0fa 100644 --- a/homeassistant_historical_sensor/sensor.py +++ b/homeassistant_historical_sensor/sensor.py @@ -14,7 +14,6 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, # USA. - import logging from abc import abstractmethod from datetime import datetime, timedelta @@ -33,19 +32,10 @@ ) from homeassistant.components.sensor import SensorEntity from homeassistant.helpers.event import async_call_later, async_track_time_interval -from homeassistant.util import dt as dtutil +from . import timemachine as tm from .consts import DELAY_ON_MISSING_STATES_METADATA from .patches import _build_attributes, _stringify_state -from .recorderutil import ( - delete_entity_invalid_states, - get_entity_latest_state, - get_entity_states_meta, - get_last_statistics_wrapper, - hass_get_entity_states_metadata_id, - hass_recorder_session, - save_states, -) from .state import HistoricalState _LOGGER = logging.getLogger(__name__) @@ -131,14 +121,17 @@ async def async_update_historical(self): """ raise NotImplementedError() - async def _schedule_on_missing_states_metadata(self, fn) -> bool: - metadata_id = await hass_get_entity_states_metadata_id(self.hass, self) - if metadata_id is not None: + async def _schedule_on_missing_states_meta(self, fn) -> bool: + states_metadata_id = await tm.hass_get_entity_states_metadata_id( + self.hass, self + ) + + if states_metadata_id is not None: return False - _LOGGER.debug( - f"{self.entity_id} not yet fully ready, StatesMeta object is not ready." - + f"Retry in {DELAY_ON_MISSING_STATES_METADATA} seconds" + _LOGGER.warning( + f"{self.entity_id}: not yet fully ready, states meta information is " + + f"unavailablele, retring in {DELAY_ON_MISSING_STATES_METADATA} seconds." ) async_call_later(self.hass, DELAY_ON_MISSING_STATES_METADATA, fn) @@ -150,52 +143,41 @@ async def async_write_ha_historical_states(self, _=None): This method writes `self.historical_states` into database """ - if await self._schedule_on_missing_states_metadata( + if await self._schedule_on_missing_states_meta( self.async_write_ha_historical_states ): return - _LOGGER.debug(f"{self.entity_id} states medata ready") - - hist_states = self.historical_states - if any([True for x in hist_states if x.dt.tzinfo is None]): - _LOGGER.error("historical_states MUST include tzinfo") - return - - hist_states = list(sorted(hist_states, key=lambda x: x.dt)) - _LOGGER.debug( - f"{self.entity_id}: " - + f"{len(hist_states)} historical states present in sensor" - ) - - if not hist_states: - return + _LOGGER.debug(f"{self.entity_id} states meta ready") # Write states - n = len(await self._async_write_states(hist_states)) + n = len(await self._async_write_states(self.historical_states)) _LOGGER.debug(f"{self.entity_id}: {n} states written into the database") - # Write statistics - n = len(await self._async_write_statistics(hist_states)) + # # Write statistics + n = len(await self._async_write_statistics(self.historical_states)) _LOGGER.debug(f"{self.entity_id}: {n} statistics points written into database") async def _async_write_states( self, hist_states: list[HistoricalState] - ) -> list[HistoricalState]: + ) -> list[db_schema.States]: return await recorder.get_instance(self.hass).async_add_executor_job( self._recorder_write_states, hist_states ) def _recorder_write_states( self, hist_states: list[HistoricalState] - ) -> list[HistoricalState]: - with hass_recorder_session(self.hass) as session: + ) -> list[db_schema.States]: + if not hist_states: + return [] + + with tm.hass_recorder_session(self.hass) as session: # # Delete invalid states # try: - n_states = delete_entity_invalid_states(session, self) + n_states = len(tm.delete_invalid_states(session, self)) _LOGGER.debug(f"{self.entity_id}: cleaned {n_states} invalid states") except sqlalchemy.exc.IntegrityError: @@ -206,61 +188,14 @@ def _recorder_write_states( + "This is not critical just unsightly for some graphs " ) - # - # Check latest state in the database - # - - try: - latest = get_entity_latest_state(session, self) - - except sqlalchemy.exc.DatabaseError: - _LOGGER.debug( - "Error: Current recorder schema is not supported. " - + "This error is fatal, please file a bug" - ) - return [] - - # - # Drop historical states older than lastest db state - # - - # About deleting intersecting states instead of drop incomming - # overlapping states: This approach has been tested several times and - # always ends up causing unexpected failures. Sometimes the database - # schema changes and sometimes, depending on the engine, integrity - # failures appear. It is better to discard the new overlapping states - # than to delete them from the database. - - if latest: - cutoff = dtutil.utc_from_timestamp(latest.last_updated_ts or 0) - _LOGGER.debug( - f"{self.entity_id}: " - + f"lastest state found at {cutoff} ({latest.state})" - ) - hist_states = [x for x in hist_states if x.dt > cutoff] - - else: - _LOGGER.debug(f"{self.entity_id}: no previous state found") - - # - # Check if there are any states left - # - if not hist_states: - _LOGGER.debug(f"{self.entity_id}: no new states") - return [] - - n_hist_states = len(hist_states) - _LOGGER.debug(f"{self.entity_id}: found {n_hist_states} new states") - # # Build recorder States # - state_meta = get_entity_states_meta(session, self) db_states: list[db_schema.States] = [] - for idx, hist_state in enumerate(hist_states): - attrs_as_dict = _build_attributes(self) - attrs_as_dict.update(hist_state.attributes) + base_attrs_dict = _build_attributes(self) + for hist_state in hist_states: + attrs_as_dict = base_attrs_dict | hist_state.attributes attrs_as_str = db_schema.JSON_DUMP(attrs_as_dict) attrs_as_bytes = ( @@ -275,26 +210,18 @@ def _recorder_write_states( hash=attrs_hash, shared_attrs=attrs_as_str ) - ts = dtutil.as_timestamp(hist_state.dt) state = db_schema.States( - # entity_id=self.entity_id, - states_meta_rel=state_meta, - last_changed_ts=ts, - last_updated_ts=ts, - old_state=db_states[idx - 1] if idx else latest, + last_changed_ts=hist_state.ts, + last_updated_ts=hist_state.ts, state=_stringify_state(self, hist_state.state), state_attributes=state_attributes, ) - # _LOGGER.debug( - # f"new state: " - # f"dt={dtutil.as_local(hist_state.dt)} value={hist_state.state}" - # ) db_states.append(state) - save_states(session, db_states) + ret = tm.save_states(session, self, db_states, overwrite_overlaping=True) - return hist_states + return ret async def _async_write_statistics( self, hist_states: list[HistoricalState] @@ -303,58 +230,58 @@ async def _async_write_statistics( _LOGGER.debug(f"{self.entity_id}: statistics are not enabled") return [] - statistics_meta = self.get_statistic_metadata() + if not hist_states: + return [] + + statistics_metadata = self.get_statistic_metadata() - latest = await get_last_statistics_wrapper( - self.hass, statistics_meta["statistic_id"] + hist_states = list(sorted(hist_states, key=lambda x: x.ts)) + + latest_stats_data = await tm.hass_get_last_statistic( + self.hass, statistics_metadata ) - # Don't do this, see notes above "About deleting intersecting states" - # - # def delete_statistics(): - # with recorder.session_scope( - # session=self.recorder.get_session() - # ) as session: - # start_cutoff = hist_states[0].when - timedelta(hours=1) - # end_cutoff = hist_states[-1].when - # qs = ( - # session.query(db_schema.Statistics) - # .join( - # db_schema.StatisticsMeta, - # db_schema.Statistics.metadata_id == db_schema.StatisticsMeta.id, - # isouter=True, - # ) - # .filter(db_schema.Statistics.start >= start_cutoff) - # .filter(db_schema.Statistics.start < end_cutoff) - # ) - # stats = [x.id for x in qs] # - # clear_statistics(self.recorder, stats) - # _LOGGER.debug(f"Cleared {len(stats)} statistics") + # Handle overlaping stats. # - # await self.recorder.async_add_executor_job(delete_statistics) - hist_states = self.historical_states - if latest is not None: - cutoff = dtutil.utc_from_timestamp(latest["start"]) + timedelta(hours=1) - hist_states = [x for x in hist_states if x.dt > cutoff] + overwrite = True + + if overwrite: + + def _delete_stats_since(ts: int): + with tm.hass_recorder_session(self.hass) as session: + return tm.delete_statistics_since( + session, statistics_metadata["statistic_id"], since=ts + ) + + deleted_statistics = await recorder.get_instance( + self.hass + ).async_add_executor_job(_delete_stats_since, hist_states[0].ts) + + _LOGGER.debug( + f"{statistics_metadata['statistic_id']}: " + + f"deleted {len(deleted_statistics)} statistics" + ) + + else: + if latest_stats_data is not None: + cutoff = latest_stats_data["start"] + 60 * 60 + hist_states = [x for x in hist_states if x.ts > cutoff] # # Calculate stats # statistics_data = await self.async_calculate_statistic_data( - hist_states, latest=latest + hist_states, latest=latest_stats_data ) - # for stat in statistics_data: - # tmp = dict(stat) - # start_dt = dtutil.as_local(tmp.pop("start")) - # _LOGGER.debug(f"new statistic: start={start_dt}, value={tmp!r}") - if valid_statistic_id(self.statistic_id): - async_add_external_statistics(self.hass, statistics_meta, statistics_data) + async_add_external_statistics( + self.hass, statistics_metadata, statistics_data + ) else: - async_import_statistics(self.hass, statistics_meta, statistics_data) + async_import_statistics(self.hass, statistics_metadata, statistics_data) return hist_states diff --git a/homeassistant_historical_sensor/state.py b/homeassistant_historical_sensor/state.py index b7fe601..d3d908c 100644 --- a/homeassistant_historical_sensor/state.py +++ b/homeassistant_historical_sensor/state.py @@ -16,26 +16,42 @@ # USA. +import functools +import itertools +from collections.abc import Iterator from dataclasses import asdict, dataclass, field -from datetime import datetime +from math import ceil from typing import Any -from homeassistant.util import dt as dtutil - @dataclass class HistoricalState: state: Any - dt: datetime + ts: float attributes: dict[str, Any] = field(default_factory=dict) def asdict(self): return asdict(self) - def as_value_and_timestamp(self): - if not self.dt.tzinfo: - raise ValueError(f"{self}.dt is missing tzinfo") - utc = dtutil.as_utc(self.dt) - ts = dtutil.utc_to_timestamp(utc) - return self.state, ts +def group_by_interval( + historical_states: list[HistoricalState], **blockize_kwargs +) -> Iterator[Any]: + fn = functools.partial(blockize, **blockize_kwargs) + yield from itertools.groupby(historical_states, key=lambda x: fn) + + +def blockize( + hist_state: HistoricalState, + *, + granurality: int = 60 * 60, + border_in_previous_block: int = True, +) -> int: + ts = ceil(hist_state.ts) + block = ts // granurality + leftover = ts % granurality + + if border_in_previous_block and leftover == 0: + block = block - 1 + + return block * granurality diff --git a/homeassistant_historical_sensor/timemachine.py b/homeassistant_historical_sensor/timemachine.py new file mode 100644 index 0000000..b70b1c7 --- /dev/null +++ b/homeassistant_historical_sensor/timemachine.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 + +import logging +from contextlib import contextmanager +from typing import Literal, cast + +from homeassistant.components import recorder +from homeassistant.components.recorder import Recorder, db_schema +from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.statistics import ( + StatisticsRow, + get_last_statistics, +) +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity +from sqlalchemy import Select, and_, not_, or_, select +from sqlalchemy.orm import Session + +_LOGGER = logging.getLogger(__name__) + + +@contextmanager +def hass_recorder_session(hass: HomeAssistant): + r = recorder.get_instance(hass) + with recorder_session(r) as session: + yield session + + +@contextmanager +def recorder_session(rec: Recorder): + with recorder.util.session_scope(session=rec.get_session()) as session: + yield session + + +async def hass_get_entity_states_metadata_id( + hass: HomeAssistant, entity: Entity +) -> int | None: + r = recorder.get_instance(hass) + return await r.async_add_executor_job( + recorder_get_entity_states_metadata_id, r, entity.entity_id + ) + + +def recorder_get_entity_states_metadata_id(rec: Recorder, entity_id: str) -> int | None: + with recorder_session(rec) as sess: + return rec.states_meta_manager.get(entity_id, sess, True) + + +def get_states_meta(session: Session, entity_id: str) -> db_schema.StatesMeta: + stmt = select(db_schema.StatesMeta).where( + db_schema.StatesMeta.entity_id == entity_id + ) + + return session.execute(stmt).scalar_one() + + +async def hass_get_last_statistic( + hass: HomeAssistant, + statistics_metadata: StatisticMetaData, + *, + convert_units: bool = True, + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]] = { + "last_reset", + "max", + "mean", + "min", + "state", + "sum", + }, +) -> StatisticsRow | None: + res = await recorder.get_instance(hass).async_add_executor_job( + get_last_statistics, + hass, + 1, + statistics_metadata["statistic_id"], + convert_units, + types, + ) + if not res: + return None + + return res[statistics_metadata["statistic_id"]][0] + + +def _build_entity_states_stmt(entity: Entity) -> Select: + return ( + select(db_schema.States) + .join(db_schema.StatesMeta) + .where(db_schema.StatesMeta.entity_id == entity.entity_id) + ) + + +def _rebuild_states_chain( + session: Session, entity: Entity, *, since: float = 0 +) -> None: + stmt = _build_entity_states_stmt(entity).order_by( + db_schema.States.last_updated_ts.asc() + ) + + if since: + prev = get_last_state(session, entity, before=since) + + else: + prev = None + + for state in session.execute(stmt).scalars(): + state.old_state_id = prev.state_id if prev else None + prev = state + + +def delete_invalid_states(session: Session, entity: Entity) -> list[db_schema.States]: + stmt = _build_entity_states_stmt(entity) + stmt = stmt.where(db_schema.States.state.in_([STATE_UNKNOWN, STATE_UNAVAILABLE])) + + deleted_states = list(session.execute(stmt).scalars()) + for state in deleted_states: + session.delete(state) + + _rebuild_states_chain(session, entity) + + return deleted_states + + +def delete_states_in_period( + session: Session, entity: Entity, *, start: float, end: float +) -> list[db_schema.States]: + """ + Delete all states between two points in time + """ + + # Link states just outside the period + + first_state_after_end = session.execute( + _build_entity_states_stmt(entity).where(db_schema.States.last_updated_ts > end) + ).scalar() + + last_state_before_start = session.execute( + _build_entity_states_stmt(entity).where( + db_schema.States.last_updated_ts < start + ) + ).scalar() + + if first_state_after_end: + first_state_after_end.old_state_id = ( + last_state_before_start.state_id if last_state_before_start else None + ) + + # Execute deletion backwards in time to delink safely the chain + + delete_stmt = _build_entity_states_stmt(entity) + delete_stmt = delete_stmt.where( + and_( + db_schema.States.last_updated_ts >= start, + db_schema.States.last_updated_ts <= end, + ) + ).order_by(db_schema.States.last_updated_ts.desc()) + + deleted_states = cast( + list[db_schema.States], list(session.execute(delete_stmt).scalars()) + ) + + for state in deleted_states: + session.delete(state) + + return deleted_states + + +def get_last_state( + session: Session, entity: Entity, *, before: float | None = None +) -> db_schema.States: + """ + Get last state from database + If `before` is passed the lastest state will be the last previous to the time + specified in `before` + """ + stmt = _build_entity_states_stmt(entity) + if before: + stmt = stmt.where(db_schema.States.last_updated_ts < before) + + stmt = stmt.where( + not_( + or_( + db_schema.States.state == STATE_UNAVAILABLE, + db_schema.States.state == STATE_UNKNOWN, + ) + ) + ).order_by(db_schema.States.last_updated_ts.desc()) + + state = cast(db_schema.States, session.execute(stmt).scalar()) + + return state + + +def save_states( + session: Session, + entity: Entity, + states: list[db_schema.States], + *, + overwrite_overlaping: bool = False, +) -> list[db_schema.States]: + # Initial checks: + # - at least one state + # - states meta information available + + if not states: + return [] + + states_meta = get_states_meta(session, entity.entity_id) + if not states_meta: + _LOGGER.error( + f"{entity.entity_id}: " + + "states meta information is NOT available (it should be!). This is a bug" + ) + return [] + + # Ensure ordered data + + states = list(sorted(states, key=lambda x: x.last_updated_ts)) + + # Add some data to states + + for x in states: + x.states_meta_rel = states_meta + x.metadata_id = states_meta.metadata_id + x.entity_id = states_meta.entity_id + + assert x.last_updated_ts is not None + + # Handle overlaping states + + if overwrite_overlaping: + deleted = delete_states_in_period( + session, + entity, + start=states[0].last_updated_ts, + end=states[-1].last_updated_ts, + ) + _LOGGER.debug( + f"{entity.entity_id}: deleted {len(deleted)} overlaping exisisting states" + ) + + else: + last_existing_state = get_last_state(session, entity) + assert last_existing_state.last_updated_ts is not None + + if last_existing_state: + n_prev = len(states) + states = [ + x + for x in states + if x.last_updated_ts > last_existing_state.last_updated_ts + ] + n_post = len(states) + + _LOGGER.debug( + f"{entity.entity_id}: discarded {n_prev-n_post} overlaping new states" + ) + + if not states: + return [] + + # Insert states and rebuild chain + + for state in states: + session.add(state) + + _rebuild_states_chain(session, entity, since=states[0].last_updated_ts) + + return states + + +def delete_statistics_since( + session: Session, + statistic_id: str, + *, + since: float, +) -> list[db_schema.Statistics]: + # SELECT * + # FROM statistics LEFT OUTER JOIN statistics_meta + # ON statistics.metadata_id = statistics_meta.id + # WHERE statistics_meta.statistic_id = 'sensor.delorian'; + + stmt = ( + select(db_schema.Statistics) + .join( + db_schema.StatisticsMeta, + db_schema.Statistics.metadata_id == db_schema.StatisticsMeta.id, + isouter=True, + ) + .filter(db_schema.StatisticsMeta.statistic_id == statistic_id) + .filter(db_schema.Statistics.start_ts >= since) + ) + + deleted_statistics = list(x for x in session.execute(stmt).scalars()) + for x in deleted_statistics: + session.delete(x) + + return deleted_statistics + + +def save_statistics_data( + session: Session, + statistics_metadata: StatisticMetaData, + statistics_data: list[StatisticData], + *, + overwrite_overlaping: bool = False, +): + raise NotImplementedError() + return [] diff --git a/pyproject.toml b/pyproject.toml index a3005c4..225374f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ [project] name = "homeassistant-historical-sensor" -version = "2.0.0rc6" +version = "3.0.0a1" dependencies = [ "importlib-metadata; python_version >= '3.11'", ] From 319be1d3d5eb3580cb54609e8ce4a522c693349e Mon Sep 17 00:00:00 2001 From: MDW Date: Sun, 5 Feb 2023 22:02:53 +0100 Subject: [PATCH 2/2] Add pre-commit setup, SVG icon, HACS configuration --- .github/workflows/pre-commit.yml | 52 ++++++++++ .github/workflows/validate-hacs.yml | 24 +++++ .pre-commit-config.yaml | 112 +++++++++++++++++++++- custom_components/delorian/manifest.json | 4 +- hacs.json | 8 ++ homeassistant_historical_sensor/sensor.py | 2 +- icon-64.png | Bin 2490 -> 1497 bytes icon.png | Bin 27426 -> 14221 bytes icon.svg | 1 + pyproject.toml | 36 +++++++ 10 files changed, 234 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/pre-commit.yml create mode 100644 .github/workflows/validate-hacs.yml create mode 100644 hacs.json create mode 100644 icon.svg diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..a6092f2 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,52 @@ +--- +name: pre-commit +on: + pull_request: + push: +jobs: + pre-commit: + runs-on: ubuntu-latest + env: + LOG_TO_CS: .github/logToCs.py + RAW_LOG: pre-commit.log + CS_XML: pre-commit.xml + steps: + - run: sudo apt-get update && sudo apt-get install cppcheck + if: false + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + if: false + with: + cache: pip + python-version: 3.12.1 + - run: python -m pip install pre-commit + - uses: actions/cache/restore@v4 + with: + path: ~/.cache/pre-commit/ + key: pre-commit-4|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') + }} + - name: Run pre-commit hooks + run: | + set -o pipefail + pre-commit gc + pre-commit run --show-diff-on-failure --color=always --all-files | tee ${RAW_LOG} + - name: Convert Raw Log to Annotations + uses: mdeweerd/logToCheckStyle@v2024.2.9 + if: ${{ failure() }} + with: + in: ${{ env.RAW_LOG }} + - uses: actions/cache/save@v4 + if: ${{ always() }} + with: + path: ~/.cache/pre-commit/ + key: pre-commit-4|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') + }} + - name: Provide log as artifact + uses: actions/upload-artifact@v4 + if: ${{ always() }} + with: + name: precommit-logs + path: | + ${{ env.RAW_LOG }} + ${{ env.CS_XML }} + retention-days: 2 diff --git a/.github/workflows/validate-hacs.yml b/.github/workflows/validate-hacs.yml new file mode 100644 index 0000000..a7540c1 --- /dev/null +++ b/.github/workflows/validate-hacs.yml @@ -0,0 +1,24 @@ +--- +name: Validate with hassfest + +on: + push: + pull_request: + schedule: + - cron: 0 0 * * * + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: home-assistant/actions/hassfest@master + hacs: + name: HACS Action + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: HACS Action + uses: hacs/action@main + with: + category: integration diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 06005ca..7d7b775 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,33 @@ +--- +files: ^(.*\.(py|json|md|sh|yaml|cfg|txt))$ +exclude: ^(\.[^/]*cache/.*|.*/_user.py|.github/logToCs.py)$ repos: + - repo: https://github.com/verhovsky/pyupgrade-docs + rev: v0.3.0 + hooks: + - id: pyupgrade-docs + + - repo: https://github.com/executablebooks/mdformat + # Do this before other tools "fixing" the line endings + rev: 0.7.17 + hooks: + - id: mdformat + name: Format Markdown + entry: mdformat # Executable to run, with fixed options + language: python + types: [markdown] + args: [--wrap, '75', --number] + additional_dependencies: + - mdformat-toc + - mdformat-beautysh + # -mdformat-shfmt + # -mdformat-tables + - mdformat-config + - mdformat-black + - mdformat-web + - mdformat-gfm + - setuptools + - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 hooks: @@ -12,6 +41,10 @@ repos: - id: check-toml # - id: check-yaml - id: debug-statements + - id: check-executables-have-shebangs + - id: check-shebang-scripts-are-executable + - id: fix-byte-order-marker + - id: check-case-conflict - repo: https://github.com/asottile/pyupgrade rev: v3.15.0 @@ -20,7 +53,7 @@ repos: args: ['--py311-plus'] - repo: https://github.com/pycqa/isort - rev: 5.13.0 + rev: 5.13.2 hooks: - id: isort args: ['--profile', 'black'] @@ -29,3 +62,80 @@ repos: rev: 23.12.1 hooks: - id: black + + - repo: https://github.com/adrienverge/yamllint.git + rev: v1.33.0 + hooks: + - id: yamllint + args: + - --no-warnings + - -d + - '{extends: relaxed, rules: {line-length: {max: 90}}}' + + - repo: https://github.com/asottile/pyupgrade + rev: v3.15.0 + hooks: + - id: pyupgrade + args: + - --py310-plus + + - repo: https://github.com/Lucas-C/pre-commit-hooks-bandit + rev: v1.0.6 + hooks: + - id: python-bandit-vulnerability-check + + - repo: https://github.com/fsouza/autoflake8 + rev: v0.4.1 + hooks: + - id: autoflake8 + args: + - -i + - -r + - --expand-star-imports + - custom_components + + - repo: https://github.com/PyCQA/flake8 + rev: 6.1.0 + hooks: + - id: flake8 + additional_dependencies: + # - pyproject-flake8>=0.0.1a5 + - flake8-bugbear>=22.7.1 + - flake8-comprehensions>=3.10.1 + - flake8-2020>=1.7.0 + - mccabe>=0.7.0 + - pycodestyle>=2.9.1 + - pyflakes>=2.5.0 + + - repo: https://github.com/PyCQA/isort + rev: 5.13.2 + hooks: + - id: isort + + - repo: https://github.com/codespell-project/codespell + rev: v2.2.6 + hooks: + - id: codespell + args: + # - --builtin=clear,rare,informal,usage,code,names,en-GB_to_en-US + - --builtin=clear,rare,informal,usage,code,names + - --ignore-words-list=hass,master + - --skip="./.*" + - --quiet-level=2 + - repo: https://github.com/pylint-dev/pylint + rev: v3.0.3 + hooks: + - id: pylint + additional_dependencies: + - homeassistant-stubs + - sqlalchemy + - pyyaml + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.7.1 + hooks: + - id: mypy + additional_dependencies: + - homeassistant-stubs + - sqlalchemy + - pyyaml diff --git a/custom_components/delorian/manifest.json b/custom_components/delorian/manifest.json index b7c3bc5..abc6c2a 100644 --- a/custom_components/delorian/manifest.json +++ b/custom_components/delorian/manifest.json @@ -1,12 +1,10 @@ { "domain": "delorian", "name": "Delorian - Testing integration for historical sensors", + "after_dependencies": ["recorder"], "codeowners": [ "@ldotlopez" ], - "dependencies": [ - "recorder" - ], "config_flow": true, "documentation": "https://github.com/ldotlopez/ha-historical-sensor", "iot_class": "cloud_polling", diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..72eeaa5 --- /dev/null +++ b/hacs.json @@ -0,0 +1,8 @@ +{ + "name": "Delorian - Testing integration for historical sensors", + "content_in_root": false, + "zip_release": false, + "render_readme": true, + "persistent_directory": "local", + "homeassistant": "2023.1" +} diff --git a/homeassistant_historical_sensor/sensor.py b/homeassistant_historical_sensor/sensor.py index b37b0fa..821d45e 100644 --- a/homeassistant_historical_sensor/sensor.py +++ b/homeassistant_historical_sensor/sensor.py @@ -92,7 +92,7 @@ def should_poll(self) -> bool: def state(self): # Better report unavailable than anything # - # Another aproach is to return data from historical entity, but causes + # Another approach is to return data from historical entity, but causes # wrong results. Keep here for reference. # # HistoricalSensors doesn't use poll but state is accessed only once when diff --git a/icon-64.png b/icon-64.png index c0ada3abe6b5c14474652ea6ee0789721f0791ae..097382c4d65123266777e82e963c66e0a5ea29c7 100644 GIT binary patch delta 1480 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 literal 27426 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

C;u-3aC(}+>M$m4x58)UjfIj*<@3pAL5b9tVFKv zGdfSYH6U(zihy$KBhl?I@(2Un0LT4=o6?cv&^_5LmjA=B@go(1T|QG85KZXAVSIBB z4LAa-IO=iB=m}^3FUbGsP?uNsQts}-gG#97jKzn+HKN1 zW9&d*408{f?HloYFLbE+Ux`otS+nu<5&jNvXbAOBu6zdSb*>4lZuzXf`K*|j;x{gC zprE1V9z1Z2dlA>aw#cvfPj}yXBp7|Bu(1?G#8k&OunP&Jz~A3^%U_=>dp)cl(VY8C zAG!23rF52_WB`m%+$1whb%%Zua2WX$PYE2T{o-p6I6WVF|AMDALGWHz9w56=5mX#f zRRJ$eQjGTb7@Hmzwx;ry8Tbu~!MD5s(#JWi*IgR=s8VTU6gg%eZh7di(2anT5mQ6( ze9~U>UbEGEdBYLKe-pT5-8$de=z7{1=*7WR$3y{Xo#e7jKzo`=H!QIz92PLr9u-Z$ zZu`$$;r~C-UfzpNHUNdON}SMtM$x!SgWN4l-7N*oT`i#}7!EcLc4js{W)5CWc1{5< tZUJr%Mm9ERwM|NnAOpenclipartftkarm2011-01-31T02:11:50Originally uploaded by Danny Allen for OCAL 0.18 this icon is part of the flat themehttps://openclipart.org/detail/115447/ftkarm-by-anonymous diff --git a/pyproject.toml b/pyproject.toml index 225374f..bfa8304 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,14 @@ target-version = ['py311'] [tool.isort] profile = "black" +known_first_party = [ + "homeassistant", + "tests", +] +forced_separate = [ + "tests", +] + [tool.mypy] files = ['homeassistant_historical_sensor'] @@ -48,3 +56,31 @@ addopts = "--py311-plus" [tool.setuptools] packages = ["homeassistant_historical_sensor"] + +[tool.pylint.MAIN] +py-version = "3.11" +ignore = [ + "tests", +] +persistent = false + +[tool.pylint.BASIC] +class-const-naming-style = "any" +good-names = [ + "r", + "g", + "v", +] + +[tool.pylint."MESSAGES CONTROL"] +disable = [ + "line-too-long", + "too-few-public-methods", + "missing-class-docstring", + "missing-function-docstring", + "missing-module-docstring", + "too-many-locals", +] + +[tool.pylint.FORMAT] +expected-line-ending-format = "LF"