diff --git a/Pipfile b/Pipfile index 4f80f1b..376973a 100644 --- a/Pipfile +++ b/Pipfile @@ -6,11 +6,12 @@ name = "pypi" [packages] [dev-packages] -homeassistant = ">= 2023.6.1" ipython = "*" ipdb = "*" homeassistant-historical-sensor = {editable = true, path = "./../ha-historical-sensor"} sqlalchemy = "*" +pre-commit = "*" +homeassistant = ">= 2023.6.0" [requires] python_version = "3" diff --git a/config/configuration.yaml b/config/configuration.yaml index 7dbc083..7561fa1 100644 --- a/config/configuration.yaml +++ b/config/configuration.yaml @@ -7,4 +7,5 @@ logger: custom_components.ideenergy.datacoordinator: debug custom_components.ideenergy.sensor: debug custom_components.ideenergy.updates: debug - homeassistant_historical_sensor: debug \ No newline at end of file + custom_components.ideenergy.fixes: debug + homeassistant_historical_sensor: debug diff --git a/custom_components/ideenergy/__init__.py b/custom_components/ideenergy/__init__.py index c7433e0..6c8fb49 100644 --- a/custom_components/ideenergy/__init__.py +++ b/custom_components/ideenergy/__init__.py @@ -21,6 +21,7 @@ import math from datetime import timedelta +import ideenergy from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -28,8 +29,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import DeviceInfo -import ideenergy - from .barrier import TimeDeltaBarrier, TimeWindowBarrier # NoopBarrier, from .const import ( API_USER_SESSION_TIMEOUT, diff --git a/custom_components/ideenergy/config_flow.py b/custom_components/ideenergy/config_flow.py index bd5786a..68463fd 100644 --- a/custom_components/ideenergy/config_flow.py +++ b/custom_components/ideenergy/config_flow.py @@ -20,6 +20,7 @@ import os from typing import Any +import ideenergy import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME @@ -27,8 +28,6 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_create_clientsession -import ideenergy - from . import _LOGGER from .const import CONF_CONTRACT, CONFIG_ENTRY_VERSION, DOMAIN diff --git a/custom_components/ideenergy/datacoordinator.py b/custom_components/ideenergy/datacoordinator.py index b2a24df..359b29f 100644 --- a/custom_components/ideenergy/datacoordinator.py +++ b/custom_components/ideenergy/datacoordinator.py @@ -21,11 +21,10 @@ from datetime import datetime, timedelta, timezone from typing import Any +import ideenergy from homeassistant.core import dt_util from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -import ideenergy - from .barrier import Barrier, BarrierDeniedError from .const import ( DATA_ATTR_HISTORICAL_CONSUMPTION, diff --git a/custom_components/ideenergy/manifest.json b/custom_components/ideenergy/manifest.json index 5a74359..42bc926 100644 --- a/custom_components/ideenergy/manifest.json +++ b/custom_components/ideenergy/manifest.json @@ -1,16 +1,20 @@ { "domain": "ideenergy", "name": "i-DE Energy Monitor", - "codeowners": ["@ldotlopez"], + "codeowners": [ + "@ldotlopez" + ], "config_flow": true, - "dependencies": ["recorder"], + "dependencies": [ + "recorder" + ], "documentation": "https://github.com/ldotlopez/ha-ideenergy", "integration_type": "device", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/ldotlopez/ha-ideenergy/issues", "requirements": [ - "ideenergy==1.0.0", - "homeassistant-historical-sensor==1.0.1" - ], + "ideenergy==1.0.0", + "homeassistant-historical-sensor==2.0.0b0" + ], "version": "2.0.1" -} \ No newline at end of file +} diff --git a/custom_components/ideenergy/sensor.py b/custom_components/ideenergy/sensor.py index 464c189..57b6cf7 100644 --- a/custom_components/ideenergy/sensor.py +++ b/custom_components/ideenergy/sensor.py @@ -27,26 +27,19 @@ import itertools import logging - -# import statistics +from collections.abc import Callable from datetime import datetime, timedelta -from typing import Any, Callable +from typing import Any from homeassistant.components import recorder from homeassistant.components.recorder import statistics -from homeassistant.components.recorder.models import StatisticData, StatisticMetaData -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorStateClass, -) +from homeassistant.components.recorder.models import (StatisticData, + StatisticMetaData) +from homeassistant.components.sensor import (SensorDeviceClass, SensorEntity, + SensorStateClass) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - UnitOfEnergy, - UnitOfPower, -) +from homeassistant.const import (STATE_UNAVAILABLE, STATE_UNKNOWN, + UnitOfEnergy, UnitOfPower) from homeassistant.core import HomeAssistant, callback, dt_util from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -54,17 +47,14 @@ from homeassistant.util import dt as dtutil from homeassistant_historical_sensor import HistoricalSensor, HistoricalState -from . import fixes from .const import DOMAIN -from .datacoordinator import ( - DATA_ATTR_HISTORICAL_CONSUMPTION, - DATA_ATTR_HISTORICAL_GENERATION, - DATA_ATTR_HISTORICAL_POWER_DEMAND, - DATA_ATTR_MEASURE_ACCUMULATED, - DATA_ATTR_MEASURE_INSTANT, - DataSetType, -) +from .datacoordinator import (DATA_ATTR_HISTORICAL_CONSUMPTION, + DATA_ATTR_HISTORICAL_GENERATION, + DATA_ATTR_HISTORICAL_POWER_DEMAND, + DATA_ATTR_MEASURE_ACCUMULATED, + DATA_ATTR_MEASURE_INSTANT, DataSetType) from .entity import IDeEntity +from .fixes import async_fix_statistics PLATFORM = "sensor" @@ -97,21 +87,28 @@ def async_update_historical(self) -> None: class StatisticsMixin(HistoricalSensor): @property - def statatistic_id(self): + def statistic_id(self): return self.entity_id - def get_statatistic_metadata(self) -> StatisticMetaData: - meta = super().get_statatistic_metadata() | {"has_sum": True} + def get_statistic_metadata(self) -> StatisticMetaData: + meta = super().get_statistic_metadata() | {"has_sum": True} + return meta async def async_added_to_hass(self): await super().async_added_to_hass() - await fixes.async_fix_statistics(self.hass, self.get_statatistic_metadata()) + + # + # In 2.0 branch we f**ked statistiscs. + # Don't set state_class attributes for historical sensors! + # + # FIXME: Remove in future 3.0 series. + # + await async_fix_statistics(self.hass, self.get_statistic_metadata()) async def async_calculate_statistic_data( self, hist_states: list[HistoricalState], *, latest: dict | None ) -> list[StatisticData]: - # # Filter out invalid states # @@ -120,7 +117,7 @@ async def async_calculate_statistic_data( hist_states = [x for x in hist_states if x.state not in (0, None)] if len(hist_states) != n_original_hist_states: _LOGGER.warning( - f"{self.statatistic_id}: " + f"{self.statistic_id}: " + "found some weird values in historical statistics" ) @@ -137,13 +134,6 @@ def hour_block_for_hist_state(hist_state: HistoricalState) -> datetime: else: return hist_state.dt.replace(minute=0, second=0, microsecond=0) - # - # Somehow, somewhere, Home Assistant writes invalid statistics - # FIXME: integrate into homeassistant_historical_sensor and remove - # - - await fixes.async_fix_statistics(self.hass, self.get_statatistic_metadata()) - # # Ignore supplied 'lastest' and fetch again from recorder # FIXME: integrate into homeassistant_historical_sensor and remove @@ -153,22 +143,31 @@ def get_last_statistics(): ret = statistics.get_last_statistics( self.hass, 1, - self.statatistic_id, + self.statistic_id, convert_units=True, - types={"last_reset", "max", "mean", "min", "state", "sum"}, + types={"sum"}, ) - if ret is None: + + # ret can be none or {} + if not ret: return None try: - return ret[self.statatistic_id][0] - except (KeyError, IndexError): - _LOGGER.debug( - f"{self.statatistic_id}: [bug] found last statistics but doesn't " - + f"have matching key or values: {ret!r}" - ) + return ret[self.statistic_id][0] + + except KeyError: + # No stats found return None + except IndexError: + # What? + _LOGGER.error( + f"{self.statatistic_id}: " + + "[bug] found last statistics key but doesn't have any value! " + + f"({ret!r})" + ) + raise + latest = await recorder.get_instance(self.hass).async_add_executor_job( get_last_statistics ) @@ -183,7 +182,7 @@ def extract_last_sum(latest) -> float: total_accumulated = extract_last_sum(latest) except (KeyError, ValueError): _LOGGER.error( - f"{self.statatistic_id}: [bug] statistics broken (lastest={latest!r})" + f"{self.statistic_id}: [bug] statistics broken (lastest={latest!r})" ) return [] @@ -192,7 +191,7 @@ def extract_last_sum(latest) -> float: ) _LOGGER.debug( - f"{self.statatistic_id}: " + f"{self.statistic_id}: " + f"calculating statistics using {total_accumulated} as base accumulated " + f"(registed at {start_point_local_dt})" ) @@ -240,6 +239,10 @@ def __init__(self, *args, **kwargs): # possible, state class total_increasing or total with last_reset should only be # used when state class total without last_reset does not work for the sensor. # https://developers.home-assistant.io/docs/core/entity/sensor/#how-to-choose-state_class-and-last_reset + + # The sensor's value never resets, e.g. a lifetime total energy consumption or + # production: state_class total, last_reset not set or set to None + self._attr_state_class = SensorStateClass.TOTAL @property @@ -300,12 +303,24 @@ class HistoricalConsumption( def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self._attr_device_class = SensorDeviceClass.ENERGY - self._attr_state_class = SensorStateClass.MEASUREMENT self._attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR self._attr_entity_registry_enabled_default = False self._attr_state = None + # The sensor's state is reset with every state update, for example a sensor + # updating every minute with the energy consumption during the past minute: + # state class total, last_reset updated every state change. + # + # (*) last_reset is set in states by historical_states_from_historical_api_data + # (*) set only in internal statistics model + # + # DON'T set for HistoricalSensors, you will mess your statistics. + # Keep as reference. + # + # self._attr_state_class = SensorStateClass.TOTAL + @property def historical_states(self): ret = historical_states_from_historical_api_data( @@ -325,11 +340,23 @@ class HistoricalGeneration( def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._attr_device_class = SensorDeviceClass.ENERGY - self._attr_state_class = SensorStateClass.MEASUREMENT self._attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR self._attr_entity_registry_enabled_default = False self._attr_state = None + # The sensor's state is reset with every state update, for example a sensor + # updating every minute with the energy consumption during the past minute: + # state class total, last_reset updated every state change. + # + # (*) last_reset is set in states by historical_states_from_historical_api_data + # (*) set only in internal statistics model + # + # DON'T set for HistoricalSensors, you will mess your statistics. + # + # Keep as reference. + # + # self._attr_state_class = SensorStateClass.TOTAL + @property def historical_states(self): ret = historical_states_from_historical_api_data( @@ -347,7 +374,6 @@ class HistoricalPowerDemand(HistoricalSensorMixin, IDeEntity, SensorEntity): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._attr_device_class = SensorDeviceClass.POWER - self._attr_state_class = SensorStateClass.MEASUREMENT self._attr_native_unit_of_measurement = UnitOfPower.WATT self._attr_entity_registry_enabled_default = False self._attr_state = None diff --git a/custom_components/ideenergy/updates.py b/custom_components/ideenergy/updates.py index 423b5c0..671f4b6 100644 --- a/custom_components/ideenergy/updates.py +++ b/custom_components/ideenergy/updates.py @@ -18,13 +18,14 @@ import logging -from custom_components.ideenergy.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry, entity_registry from homeassistant.helpers.entity import DeviceInfo from homeassistant.util import slugify +from custom_components.ideenergy.const import DOMAIN + from .entity import IDeEntity from .entity import _build_entity_unique_id as _build_entity_unique_id_v3 from .sensor import AccumulatedConsumption, HistoricalConsumption @@ -150,7 +151,7 @@ def _update_entity_registry_v1( ("historical", HistoricalConsumption), ) - for (old_sensor_type, new_sensor_cls) in migrate: + for old_sensor_type, new_sensor_cls in migrate: entity_id = er.async_get_entity_id( "sensor", "ideenergy", diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..05b785f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,12 @@ +[build-system] +requires = ["setuptools>=40.8.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.black] +target-version = ['py310'] + +[tool.isort] +profile = "black" + +[tool.mypy] +files = ["custom_components/ideenergy"] \ No newline at end of file