diff --git a/.vscode/settings.json b/.vscode/settings.json index 8a64568..c3b86e2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,7 +10,6 @@ "python.linting.pylintArgs": [ "--extension-pkg-whitelist=pydantic", ], - "python.formatting.provider": "black", "editor.formatOnPaste": false, "editor.formatOnSave": true, "editor.formatOnType": true, @@ -31,4 +30,7 @@ "terminal.integrated.defaultProfile.linux": "zsh", "terminal.integrated.defaultProfile.osx": "zsh", "terminal.integrated.defaultProfile.windows": "PowerShell", + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter" + }, } \ No newline at end of file diff --git a/estimenergy/devices/base_device.py b/estimenergy/devices/base_device.py index 11e22e1..c35702c 100644 --- a/estimenergy/devices/base_device.py +++ b/estimenergy/devices/base_device.py @@ -1,10 +1,11 @@ """Abstract class for all devices.""" from abc import ABC, abstractmethod import datetime +from typing import Optional, Type from estimenergy.const import Metric from estimenergy.models.config.config import Config -from estimenergy.models.device_config import DeviceConfig, DeviceConfigRead +from estimenergy.models.device_config import DeviceConfig from estimenergy.services.data_service import DataService from estimenergy.services.influx_service import InfluxService from estimenergy.services.prometheus_service import PrometheusService @@ -33,66 +34,52 @@ def __init__(self, device_config: DeviceConfig, config: Config): influx_service = InfluxService(self.device_config, config) self.data_services.append(influx_service) - @property @abstractmethod - def provided_metrics(self) -> list[Metric]: - """Return a list of metrics provided by this device.""" + async def start(self): + """Start the device.""" @abstractmethod async def stop(self): """Stop the device.""" - @abstractmethod - async def start(self): - """Start the device.""" - - async def increment( + async def write( self, metric: Metric, value: float, value_dt: datetime.datetime, ): - """Increment a metric in the database.""" - - if metric not in self.provided_metrics: - raise ValueError(f"Metric {metric} not provided by this device.") + """Write a metric to the database.""" for data_service in self.data_services: - await data_service.increment(metric, value, value_dt) + await data_service.write(metric, value, value_dt) - async def decrement( + await self.update(value_dt) + + async def update( self, - metric: Metric, - value: float, value_dt: datetime.datetime, ): - """Decrement a metric in the database.""" - - if metric not in self.provided_metrics: - raise ValueError(f"Metric {metric} not provided by this device.") + """Calculate metrics based on other metrics.""" for data_service in self.data_services: - await data_service.decrement(metric, value, value_dt) + await data_service.update(value_dt) - async def write( + async def last( self, metric: Metric, - value: float, value_dt: datetime.datetime, - ): - """Write a metric to the database.""" + ) -> Optional[float]: + """Get the last value of a metric.""" - if metric not in self.provided_metrics: - raise ValueError(f"Metric {metric} not provided by this device.") + sql_service: SqlService = next( + ( + data_service + for data_service in self.data_services + if isinstance(data_service, SqlService) + ), + ) - for data_service in self.data_services: - await data_service.write(metric, value, value_dt) + if sql_service: + return await sql_service.last(metric, value_dt) - async def update( - self, - value_dt: datetime.datetime, - ): - """Calculate metrics based on other metrics.""" - - for data_service in self.data_services: - await data_service.update(value_dt) + return None diff --git a/estimenergy/devices/glow_device.py b/estimenergy/devices/glow_device.py index 641b5b7..3da50c2 100644 --- a/estimenergy/devices/glow_device.py +++ b/estimenergy/devices/glow_device.py @@ -6,7 +6,6 @@ from aioesphomeapi import ( APIClient, APIConnectionError, - DeviceInfo, InvalidEncryptionKeyAPIError, ReconnectLogic, RequiresEncryptionAPIError, @@ -25,14 +24,31 @@ from estimenergy.models.device_config import DeviceConfig +class EnergySplit: + energy_start: float + energy_last: float + time_start: datetime.datetime + time_last: datetime.datetime + + def __init__(self, energy_start: float, time_start: datetime.datetime): + self.energy_start = energy_start + self.energy_last = energy_start + self.time_start = time_start + self.time_last = time_start + + def update(self, energy: float, time: datetime.datetime): + self.energy_last = energy + self.time_last = time + + class GlowDevice(BaseDevice): """Home Assistant Glow device.""" zeroconf: Zeroconf api: APIClient - reconnect_logic: Optional[ReconnectLogic] = None - last_kwh: Optional[float] = None - last_time: Optional[datetime.datetime] = None + reconnect_logic: Optional[ReconnectLogic] + energy_splits: list[EnergySplit] + current_split: Optional[EnergySplit] def __init__(self, device_config: DeviceConfig, config: Config): """Initialize the Glow device.""" @@ -46,17 +62,13 @@ def __init__(self, device_config: DeviceConfig, config: Config): self.device_config.password, zeroconf_instance=self.zeroconf, ) - - @property - def provided_metrics(self) -> list[Metric]: - return [ - Metric(MetricType.ENERGY, MetricPeriod.DAY, False, False), - Metric(MetricType.ACCURACY, MetricPeriod.DAY, False, False), - Metric(MetricType.POWER, MetricPeriod.TOTAL, False, False), - ] + self.reconnect_logic = None + self.energy_splits = [] + self.current_split = None async def start(self): """Start the device.""" + with Session(db_engine) as session: self.device_config.is_active = True session.add(self.device_config) @@ -79,6 +91,8 @@ async def on_connect(): session.commit() session.refresh(self.device_config) + await self.__initialize_data_splits() + await self.api.subscribe_states(self.__state_changed) async def on_connect_error(error: Exception): @@ -142,6 +156,29 @@ async def can_connect(self) -> bool: finally: await self.api.disconnect(force=True) + async def __initialize_data_splits(self): + current_timezone = datetime.datetime.now().astimezone().tzinfo + current_dt = datetime.datetime.now(tz=current_timezone) + + time_start: datetime.datetime = datetime.datetime.now().replace( + hour=0, minute=0, second=0, microsecond=0 + ) + energy_start: float = 0 + energy_split: EnergySplit = EnergySplit(energy_start, time_start) + + last_energy: float = await self.last( + Metric(MetricType.ENERGY, MetricPeriod.DAY, False, False), current_dt + ) + last_accuracy: float = await self.last( + Metric(MetricType.ACCURACY, MetricPeriod.DAY, False, False), current_dt + ) + last_time = time_start + datetime.timedelta( + seconds=last_accuracy * 60 * 60 * 24 + ) + energy_split.update(last_energy, last_time) + + self.energy_splits.append(energy_split) + def __state_changed(self, state: SensorState): loop = asyncio.get_event_loop() @@ -150,47 +187,63 @@ def __state_changed(self, state: SensorState): return if state.key == 2690257735: - loop.create_task(self.__on_total_kwh_changed(state.state)) + loop.create_task(self.__on_total_energy_changed(state.state)) - async def __on_total_kwh_changed(self, value: float): + async def __on_total_energy_changed(self, value: float): current_timezone = datetime.datetime.now().astimezone().tzinfo + current_dt = datetime.datetime.now(tz=current_timezone) - if self.last_kwh is None or self.last_time is None: - self.last_kwh = value - self.last_time = datetime.datetime.now(tz=current_timezone) + if self.current_split is None: + self.current_split = EnergySplit(value, current_dt) return - if value < self.last_kwh: - self.last_kwh = value - logger.warning("Detected a reset of the total kWh counter.") + if value < self.current_split.energy_last: + self.energy_splits.append(self.current_split) + self.current_split = EnergySplit(value, current_dt) return - value_dt = datetime.datetime.now(tz=current_timezone) - - kwh_increase = value - self.last_kwh - time_increase_us = (value_dt - self.last_time).microseconds - us_per_day = 1000 * 1000 * 60 * 60 * 24 - accuracy_increase = time_increase_us / us_per_day + if self.current_split.time_last.date() != current_dt.date(): + self.energy_splits = [] + self.current_split = EnergySplit(value, current_dt) + return - logger.debug( - f"Detected {kwh_increase} kWh increase in {time_increase_us} us for device {self.device_config.name}." + self.current_split.update(value, current_dt) + + energy_daily_total: float = ( + sum( + [ + energy_data_split.energy_last - energy_data_split.energy_start + for energy_data_split in self.energy_splits + ] + ) + + self.current_split.energy_last + - self.current_split.energy_start ) + seconds_daily_total: float = ( + sum( + [ + ( + energy_data_split.time_last - energy_data_split.time_start + ).total_seconds() + for energy_data_split in self.energy_splits + ] + ) + ) + ( + self.current_split.time_last - self.current_split.time_start + ).total_seconds() + accuracy_daily_total: float = seconds_daily_total / (60 * 60 * 24) - await self.increment( + await self.write( Metric(MetricType.ENERGY, MetricPeriod.DAY, False, False), - kwh_increase, - value_dt, + energy_daily_total, + current_dt, ) - await self.increment( + await self.write( Metric(MetricType.ACCURACY, MetricPeriod.DAY, False, False), - accuracy_increase, - value_dt, + accuracy_daily_total, + current_dt, ) - await self.update(value_dt) - - self.last_kwh = value - async def __on_power_changed(self, value: float): current_timezone = datetime.datetime.now().astimezone().tzinfo value_dt = datetime.datetime.now(tz=current_timezone) diff --git a/estimenergy/models/total.py b/estimenergy/models/total.py index 652e8a5..fcebc47 100644 --- a/estimenergy/models/total.py +++ b/estimenergy/models/total.py @@ -11,3 +11,4 @@ class Total(SQLModel, table=True): device_name: str = Field(default=None, index=True) energy: float = Field(default=0) cost: float = Field(default=0) + power: float = Field(default=0) diff --git a/estimenergy/services/data_service.py b/estimenergy/services/data_service.py index 59067ce..84d72d3 100644 --- a/estimenergy/services/data_service.py +++ b/estimenergy/services/data_service.py @@ -17,76 +17,8 @@ def __init__(self, device_config: DeviceConfig, config: Config): self.device_config = device_config self.config = config - async def last( - self, - metric: Metric, - value_dt: datetime.datetime, - ) -> float: - """Return the last value for a metric.""" - - if metric not in self.supported_metrics: - return 0 - - return await self._last(metric, value_dt) - - async def write( - self, - metric: Metric, - value: float, - value_dt: datetime.datetime, - ): - """Write a metric to the database.""" - - if metric not in self.supported_metrics: - return - - await self._write(metric, value, value_dt) - - async def increment( - self, - metric: Metric, - value: float, - value_dt: datetime.datetime, - ): - """Increment a metric in the database.""" - - if metric not in self.supported_metrics: - return - - last_value = await self.last(metric, value_dt) - - await self.write(metric, last_value + value, value_dt) - - async def decrement( - self, - metric: Metric, - value: float, - value_dt: datetime.datetime, - ): - """Decrement a metric in the database.""" - - if metric not in self.supported_metrics: - return - - last_value = await self.last(metric, value_dt) - - await self.write(metric, last_value - value, value_dt) - - @property - @abstractmethod - def supported_metrics(self) -> list[Metric]: - """Return a list of metrics supported by this service.""" - - @abstractmethod - async def _last( - self, - metric: Metric, - value_dt: datetime.datetime, - ) -> float: - """Get the last value for a metric.""" - @abstractmethod - async def _write( + async def write( self, metric: Metric, value: float, diff --git a/estimenergy/services/influx_service.py b/estimenergy/services/influx_service.py index 4cbd262..6175905 100644 --- a/estimenergy/services/influx_service.py +++ b/estimenergy/services/influx_service.py @@ -13,65 +13,7 @@ class InfluxService(DataService): """InfluxDB data service.""" - @property - def supported_metrics(self) -> list[Metric]: - """Return a list of metrics supported by this service.""" - return [ - Metric( - MetricType.ENERGY, - MetricPeriod.TOTAL, - False, - False, - ), - Metric( - MetricType.COST, - MetricPeriod.TOTAL, - False, - False, - ), - Metric( - MetricType.POWER, - MetricPeriod.TOTAL, - False, - False, - ), - ] - - async def _last( - self, - metric: Metric, - value_dt: datetime.datetime, - ) -> float: - """Get the last value for a metric type.""" - - if self.config.influx_config is None: - return 0 - - if metric.metric_period != MetricPeriod.TOTAL: - return 0 - - if metric.metric_type not in [MetricType.ENERGY, MetricType.COST]: - return 0 - - timestamp = value_dt.strftime("%Y-%m-%dT%H:%M:%SZ") - - result: list[FluxTable] = influx_client.query_api().query( - f'from(bucket:"{self.config.influx_config.bucket}") |> range(start: 0) |> filter(fn: (r) => r._measurement == "energy" and r._field == "{metric.metric_type.value[0]}") |> filter(fn: (r) => r._time >= {timestamp}) |> sort(columns: ["_time"], desc: false) |> limit(n: 1)' - ) - - if len(result) == 0: - return 0 - - last_data: FluxTable = result[0] - records: list[FluxRecord] = last_data.records - - if len(records) == 0: - return 0 - - last_record: FluxRecord = records[0] - return last_record.get_value() - - async def _write( + async def write( self, metric: Metric, value: float, diff --git a/estimenergy/services/prediction_service.py b/estimenergy/services/prediction_service.py index 67a13b2..dc11051 100644 --- a/estimenergy/services/prediction_service.py +++ b/estimenergy/services/prediction_service.py @@ -100,6 +100,7 @@ async def predict_energy( if accurate_day_count == 0: return 0 + mean_energy_per_day = energy / accurate_day_count predicted_energy = mean_energy_per_day * day_count diff --git a/estimenergy/services/prometheus_service.py b/estimenergy/services/prometheus_service.py index c90c6cf..6072bb3 100644 --- a/estimenergy/services/prometheus_service.py +++ b/estimenergy/services/prometheus_service.py @@ -3,6 +3,7 @@ from prometheus_client import Gauge, Metric as PrometheusMetric from estimenergy.const import METRICS, Metric +from estimenergy.log import logger from estimenergy.models.config.config import Config from estimenergy.models.device_config import DeviceConfig from estimenergy.prometheus import metric_registry @@ -30,21 +31,7 @@ def __init__( except ValueError: pass - @property - def supported_metrics(self): - return [] - - async def _last( - self, - metric: Metric, - value_dt: datetime.datetime, - ) -> float: - _ = metric - _ = value_dt - - return 0 - - async def _write( + async def write( self, metric: Metric, value: float, @@ -61,4 +48,7 @@ async def update( for metric in METRICS: last_value = await self.sql_service.last(metric, value_dt) - self.gauges[metric].set(last_value) + try: + self.gauges[metric].set(last_value) + except KeyError: + logger.error(f"Could not find gauge for metric {metric}") diff --git a/estimenergy/services/sql_service.py b/estimenergy/services/sql_service.py index b5fe590..5687ce3 100644 --- a/estimenergy/services/sql_service.py +++ b/estimenergy/services/sql_service.py @@ -1,12 +1,11 @@ """Service for writing and reading data from the SQL database.""" import datetime -import traceback from sqlalchemy import extract from sqlmodel import Session, select from sqlmodel.sql.expression import SelectOfScalar -from estimenergy.const import METRICS, Metric, MetricPeriod, MetricType +from estimenergy.const import Metric, MetricPeriod from estimenergy.db import db_engine from estimenergy.models.config.config import Config from estimenergy.models.day import Day @@ -28,28 +27,7 @@ def __init__(self, device_config: DeviceConfig, config: Config): self.prediction_service = PredictionService(device_config, config) - @property - def supported_metrics(self) -> list[Metric]: - return [ - metric - for metric in METRICS - if not metric.is_raw and metric.metric_type != MetricType.POWER - ] - - async def _last( - self, - metric: Metric, - value_dt: datetime.datetime, - ) -> float: - """Get a metric value.""" - - date = value_dt.date() - row = self.get_or_create_row(metric.metric_period, date) - value = getattr(row, metric.key, 0) - - return value - - async def _write( + async def write( self, metric: Metric, value: float, @@ -77,6 +55,19 @@ async def update( await self.update_year(date) await self.update_total(date) + async def last( + self, + metric: Metric, + value_dt: datetime.datetime, + ) -> float: + """Get a metric value.""" + + date = value_dt.date() + row = self.get_or_create_row(metric.metric_period, date) + value = getattr(row, metric.key, 0) + + return value + async def update_day( self, date: datetime.date, @@ -96,6 +87,7 @@ async def update_day( day.energy, day.date, ) + day.cost_difference = ( await self.prediction_service.calculate_cost_difference( metric_period, diff --git a/pyproject.toml b/pyproject.toml index de9ec3b..fb6ad17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ python = "^3.9" uvicorn = {extras = ["standard"], version = "~0.20.0"} fastapi = "~0.92.0" fastapi-crudrouter = "~0.8.6" -aioesphomeapi = "~13.4.0" +aioesphomeapi = "13.7.2" tortoise_orm = "~0.19.3" requests = "~2.28.2" prometheus_client = "~0.16.0"