From c519b7ba07dba920366ed3fa4e45be511f48c8d6 Mon Sep 17 00:00:00 2001 From: Branden Cash <203336+ammmze@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:51:33 -0700 Subject: [PATCH] Populate hourly statistics in srp_energy (#167371) Co-authored-by: Joost Lekkerkerker Signed-off-by: Branden Cash <203336+ammmze@users.noreply.github.com> --- CODEOWNERS | 4 +- homeassistant/components/srp_energy/const.py | 2 +- .../components/srp_energy/coordinator.py | 354 ++++++++- .../components/srp_energy/manifest.json | 3 +- tests/components/srp_energy/__init__.py | 208 ++++-- tests/components/srp_energy/conftest.py | 43 +- .../snapshots/test_coordinator.ambr | 677 ++++++++++++++++++ .../components/srp_energy/test_config_flow.py | 14 +- .../components/srp_energy/test_coordinator.py | 227 ++++++ tests/components/srp_energy/test_init.py | 9 +- tests/components/srp_energy/test_sensor.py | 13 +- 11 files changed, 1455 insertions(+), 99 deletions(-) create mode 100644 tests/components/srp_energy/snapshots/test_coordinator.ambr create mode 100644 tests/components/srp_energy/test_coordinator.py diff --git a/CODEOWNERS b/CODEOWNERS index 671077618b4f..fa46c07f3409 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1712,8 +1712,8 @@ CLAUDE.md @home-assistant/core /tests/components/sql/ @gjohansson-ST @dougiteixeira /homeassistant/components/squeezebox/ @rajlaud @pssc @peteS-UK /tests/components/squeezebox/ @rajlaud @pssc @peteS-UK -/homeassistant/components/srp_energy/ @briglx -/tests/components/srp_energy/ @briglx +/homeassistant/components/srp_energy/ @briglx @ammmze +/tests/components/srp_energy/ @briglx @ammmze /homeassistant/components/starline/ @anonym-tsk /tests/components/starline/ @anonym-tsk /homeassistant/components/statistics/ @ThomDietrich @gjohansson-ST diff --git a/homeassistant/components/srp_energy/const.py b/homeassistant/components/srp_energy/const.py index 00b3b958740a..e1e9827aff3d 100644 --- a/homeassistant/components/srp_energy/const.py +++ b/homeassistant/components/srp_energy/const.py @@ -11,7 +11,7 @@ DEFAULT_NAME = "Home" CONF_IS_TOU = "is_tou" PHOENIX_TIME_ZONE = "America/Phoenix" -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1440) +MIN_TIME_BETWEEN_UPDATES = timedelta(hours=4) DEVICE_CONFIG_URL = "https://www.srpnet.com/" DEVICE_MANUFACTURER = "srpnet.com" diff --git a/homeassistant/components/srp_energy/coordinator.py b/homeassistant/components/srp_energy/coordinator.py index 06c3062fcc6b..c58dbd2834fc 100644 --- a/homeassistant/components/srp_energy/coordinator.py +++ b/homeassistant/components/srp_energy/coordinator.py @@ -1,15 +1,29 @@ """DataUpdateCoordinator for the srp_energy integration.""" import asyncio -from datetime import timedelta -from typing import override +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any, override from srpenergy.client import SrpEnergyClient +from homeassistant.components.recorder import get_instance +from homeassistant.components.recorder.models import ( + StatisticData, + StatisticMeanType, + StatisticMetaData, +) +from homeassistant.components.recorder.statistics import ( + async_add_external_statistics, + get_last_statistics, + statistics_during_period, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ID, UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt as dt_util +from homeassistant.util import dt as dt_util, slugify +from homeassistant.util.unit_conversion import EnergyConverter from .const import ( CONF_IS_TOU, @@ -24,6 +38,49 @@ PHOENIX_ZONE_INFO = dt_util.get_time_zone(PHOENIX_TIME_ZONE) type SRPEnergyConfigEntry = ConfigEntry[SRPEnergyDataUpdateCoordinator] +# SRP finalizes yesterday's hourly data by approximately this hour (Phoenix time). +# Polling after this point until the next midnight is unnecessary. +DATA_COMPLETE_HOUR = 6 + +type HourlyUsageTuple = tuple[ + str, str, str, float, float +] # (date, time, iso_timestamp, kwh, cost) + + +@dataclass(frozen=True, kw_only=True) +class Usage: + """Hourly energy usage data.""" + + start_time: datetime + end_time: datetime + kwh: float = 0.0 + cost: float = 0.0 + + @staticmethod + def from_tuple(usage: HourlyUsageTuple) -> Usage | None: + """Initialize Usage from a raw API tuple, or None if unparsable.""" + if not usage or len(usage) != 5: + return None + parsed = dt_util.parse_datetime(usage[2]) + if parsed is None: + return None + try: + kwh = float(usage[3]) + cost = float(usage[4]) + except TypeError, ValueError: + return None + start_time = ( + parsed.replace(tzinfo=PHOENIX_ZONE_INFO) + if parsed.tzinfo is None + else parsed.astimezone(PHOENIX_ZONE_INFO) + ) + return Usage( + start_time=start_time, + end_time=start_time + timedelta(hours=1), + kwh=kwh, + cost=cost, + ) + class SRPEnergyDataUpdateCoordinator(DataUpdateCoordinator[float]): """A srp_energy Data Update Coordinator.""" @@ -39,6 +96,7 @@ class SRPEnergyDataUpdateCoordinator(DataUpdateCoordinator[float]): """Initialize the srp_energy data coordinator.""" self._client = client self._is_time_of_use = config_entry.data[CONF_IS_TOU] + self._data_complete_until: datetime | None = None super().__init__( hass, LOGGER, @@ -55,19 +113,26 @@ class SRPEnergyDataUpdateCoordinator(DataUpdateCoordinator[float]): so entities can quickly look up their data. """ LOGGER.debug("async_update_data enter") - # Fetch srp_energy data - end_date = dt_util.now(PHOENIX_ZONE_INFO) - start_date = end_date - timedelta(days=1) - try: - async with asyncio.timeout(TIMEOUT): - hourly_usage = await self.hass.async_add_executor_job( - self._client.usage, - start_date, - end_date, - self._is_time_of_use, - ) - except (ValueError, TypeError) as err: - raise UpdateFailed(f"Error communicating with API: {err}") from err + now = dt_util.now(PHOENIX_ZONE_INFO) + + # SRP finalizes yesterday's data by DATA_COMPLETE_HOUR. Once we've + # confirmed completeness, skip the API call until the next midnight + # when a new day's data becomes available. + if self._data_complete_until and now < self._data_complete_until: + LOGGER.debug( + "Data is complete until %s, skipping fetch", self._data_complete_until + ) + return self.data or 0.0 + + # Because SRP provides hourly usage/cost for the previous day we need to + # insert data into statistics ourselves. + await self._insert_statistics() + + # Fetch last 24 hours of srp_energy data, but most recent could be almost + # 24 hours ago, so we will use last 2 days and then take the last 24 hours + end_date = now + start_date = end_date - timedelta(days=2) + hourly_usage = (await self._async_read_data(start_date, end_date))[-24:] LOGGER.debug( "async_update_data: Received %s record(s) from %s to %s", @@ -76,13 +141,264 @@ class SRPEnergyDataUpdateCoordinator(DataUpdateCoordinator[float]): end_date, ) - previous_daily_usage = 0.0 - for _, _, _, kwh, _ in hourly_usage: - previous_daily_usage += float(kwh) + previous_daily_usage = sum(float(hour.kwh) for hour in hourly_usage) LOGGER.debug( "async_update_data: previous_daily_usage %s", previous_daily_usage, ) + # Yesterday's data is finalized once it's past DATA_COMPLETE_HOUR. + # Cache this so subsequent hourly ticks skip the API until next midnight. + if now.hour >= DATA_COMPLETE_HOUR: + next_midnight = (now + timedelta(days=1)).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + self._data_complete_until = next_midnight + LOGGER.debug( + "Yesterday's data is complete. Next fetch after %s", next_midnight + ) + return previous_daily_usage + + async def _insert_statistics(self) -> None: + """Insert SRP statistics.""" + id_prefix = slugify(f"{self.config_entry.data[CONF_ID]}").lower() + cost_statistic_id = f"{DOMAIN}:{id_prefix}_energy_cost" + consumption_statistic_id = f"{DOMAIN}:{id_prefix}_energy_consumption" + LOGGER.debug( + "Updating Statistics for %s, and %s", + cost_statistic_id, + consumption_statistic_id, + ) + name_prefix = f"SRP {self.config_entry.title}" + cost_metadata = StatisticMetaData( + mean_type=StatisticMeanType.NONE, + has_sum=True, + name=f"{name_prefix} electric cost", + source=DOMAIN, + statistic_id=cost_statistic_id, + unit_class=None, + unit_of_measurement=None, + ) + + consumption_metadata = StatisticMetaData( + mean_type=StatisticMeanType.NONE, + has_sum=True, + name=f"{name_prefix} electric consumption", + source=DOMAIN, + statistic_id=consumption_statistic_id, + unit_class=EnergyConverter.UNIT_CLASS, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ) + + # Get the last 2 statistics we have recorded for both consumption and + # cost. We will use the oldest one as the baseline for the sum and + # re-import the data after it. The last non-zero reported statistic + # from SRP has potential to get updated as they fill out more data. + last_stat, last_cost_stat = await asyncio.gather( + get_instance(self.hass).async_add_executor_job( + get_last_statistics, + self.hass, + 2, + consumption_statistic_id, + True, + {"sum"}, + ), + get_instance(self.hass).async_add_executor_job( + get_last_statistics, self.hass, 2, cost_statistic_id, True, {"sum"} + ), + ) + LOGGER.debug("Last statistics: %s", last_stat) + if not last_stat: + LOGGER.debug("Updating statistic for the first time") + start_date = (dt_util.now(PHOENIX_ZONE_INFO) - timedelta(days=30)).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + hourly_usage = await self._async_read_data(start_date=start_date) + cost_sum = 0.0 + consumption_sum = 0.0 + last_stats_time = None + else: + start_date = datetime.fromtimestamp( + last_stat[consumption_statistic_id][-1]["start"], PHOENIX_ZONE_INFO + ) + LOGGER.debug( + "Last statistics for %s at %s", consumption_statistic_id, start_date + ) + hourly_usage = await self._async_read_data(start_date=start_date) + if not hourly_usage: + LOGGER.debug( + "No recent usage/cost data after %s. Skipping update", start_date + ) + return + start = hourly_usage[0].start_time + LOGGER.debug("Getting statistics at: %s", start) + + def _find_baseline_stats() -> dict: + for end in (start + timedelta(seconds=1), None): + result = statistics_during_period( + self.hass, + start, + end, + {cost_statistic_id, consumption_statistic_id}, + "hour", + None, + {"sum"}, + ) + if result: + return result + if end: + LOGGER.debug( + "Not found. Trying to find the oldest statistic after %s", + start, + ) + return {} + + stats = await get_instance(self.hass).async_add_executor_job( + _find_baseline_stats + ) + + def _safe_get_first( + records: list[Any], key: str, default: float | None + ) -> float | None: + if records and records[0] and key in records[0]: + return float(records[0][key]) + return default + + if stats: + LOGGER.debug("Statistics: %s", stats.get(cost_statistic_id, [])) + cost_sum = ( + _safe_get_first(stats.get(cost_statistic_id, []), "sum", 0.0) or 0.0 + ) + consumption_sum = ( + _safe_get_first(stats.get(consumption_statistic_id, []), "sum", 0.0) + or 0.0 + ) + last_stats_time = _safe_get_first( + stats.get(consumption_statistic_id, []), "start", None + ) + else: + # hourly_usage starts after the baseline stat period — SRP no + # longer returns data that far back. Fall back to the most + # recent known sums so we can continue the running total. + LOGGER.debug( + "No statistics found at %s; using most recent known sums", start + ) + cost_sum = ( + _safe_get_first( + last_cost_stat.get(cost_statistic_id, []), "sum", 0.0 + ) + or 0.0 + ) + consumption_sum = ( + _safe_get_first( + last_stat.get(consumption_statistic_id, []), "sum", 0.0 + ) + or 0.0 + ) + last_stats_time = _safe_get_first( + last_stat.get(consumption_statistic_id, []), "start", None + ) + LOGGER.debug( + "Last statistics for %s: Consumption sum: %s, Cost sum: %s, Last stat time: %s", + consumption_statistic_id, + consumption_sum, + cost_sum, + last_stats_time, + ) + + cost_statistics = [] + consumption_statistics = [] + + # SRP pads incomplete/future hours with zeros at the tail. Strip those + # trailing zeros so we don't create gaps for genuinely-zero hours earlier + # in the dataset. + last_nonzero = -1 + for i, usage in enumerate(hourly_usage): + if usage.kwh != 0 or usage.cost != 0: + last_nonzero = i + hourly_usage = hourly_usage[: last_nonzero + 1] + + for usage in hourly_usage: + start = usage.start_time + LOGGER.debug( + "Processing usage data for %s. Last stat time: %s. Usage: %s, Cost: %s", + start.timestamp(), + last_stats_time, + usage.kwh, + usage.cost, + ) + + if last_stats_time is not None and start.timestamp() <= last_stats_time: + continue + + cost_state = max(0, usage.cost) + consumption_state = max(0, usage.kwh) + + cost_sum += cost_state + consumption_sum += consumption_state + + cost_statistics.append( + StatisticData(start=start, state=cost_state, sum=cost_sum) + ) + consumption_statistics.append( + StatisticData(start=start, state=consumption_state, sum=consumption_sum) + ) + + LOGGER.debug( + "Adding %s statistics for %s", + len(cost_statistics), + cost_statistic_id, + ) + async_add_external_statistics(self.hass, cost_metadata, cost_statistics) + LOGGER.debug( + "Adding %s statistics for %s", + len(consumption_statistics), + consumption_statistic_id, + ) + async_add_external_statistics( + self.hass, consumption_metadata, consumption_statistics + ) + + async def _async_read_data( + self, start_date: datetime | None = None, end_date: datetime | None = None + ) -> list[Usage]: + """Read data from srp_energy client. + + There are some limitations here. The current api *client* only knows how to fetch the hourly usage data. + The hourly data for SRP is only available for the last month (30 days?). + The SRP api does provide daily usage for the last 12 months and monthly usage for the last 3 years, + however the api client will need to be updated to support these additional timeframes. + """ + try: + async with asyncio.timeout(TIMEOUT): + end_date = end_date or dt_util.now(PHOENIX_ZONE_INFO) + start_date = start_date or (end_date - timedelta(days=31)).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + + def _fetch_and_parse() -> list[Usage]: + # Filter out results outside the requested range — the SRP API + # only accepts dates so the returned window may be wider. + return [ + u + for raw in self._client.usage( + start_date, end_date, self._is_time_of_use + ) + if (u := Usage.from_tuple(raw)) is not None + and u.start_time >= start_date + and u.end_time <= end_date + ] + + results = await self.hass.async_add_executor_job(_fetch_and_parse) + LOGGER.debug( + "async_read_data: Received %s record(s) from %s to %s", + len(results) if results else "None", + start_date, + end_date, + ) + return results + except (ValueError, TypeError) as err: + LOGGER.error("Error communicating with API: %s", err) + raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/srp_energy/manifest.json b/homeassistant/components/srp_energy/manifest.json index ccbe73a97fd6..d288ae67d8f6 100644 --- a/homeassistant/components/srp_energy/manifest.json +++ b/homeassistant/components/srp_energy/manifest.json @@ -1,8 +1,9 @@ { "domain": "srp_energy", "name": "SRP Energy", - "codeowners": ["@briglx"], + "codeowners": ["@briglx", "@ammmze"], "config_flow": true, + "dependencies": ["recorder"], "documentation": "https://www.home-assistant.io/integrations/srp_energy", "integration_type": "service", "iot_class": "cloud_polling", diff --git a/tests/components/srp_energy/__init__.py b/tests/components/srp_energy/__init__.py index 634d589195e3..0c81272b2cec 100644 --- a/tests/components/srp_energy/__init__.py +++ b/tests/components/srp_energy/__init__.py @@ -32,65 +32,151 @@ TEST_CONFIG_CABIN: Final[dict[str, str]] = { } MOCK_USAGE = [ - ("7/31/2022", "00:00 AM", "2022-07-31T00:00:00", "1.2", "0.19"), - ("7/31/2022", "01:00 AM", "2022-07-31T01:00:00", "1.3", "0.20"), - ("7/31/2022", "02:00 AM", "2022-07-31T02:00:00", "1.1", "0.17"), - ("7/31/2022", "03:00 AM", "2022-07-31T03:00:00", "1.2", "0.18"), - ("7/31/2022", "04:00 AM", "2022-07-31T04:00:00", "0.8", "0.13"), - ("7/31/2022", "05:00 AM", "2022-07-31T05:00:00", "0.9", "0.14"), - ("7/31/2022", "06:00 AM", "2022-07-31T06:00:00", "1.6", "0.24"), - ("7/31/2022", "07:00 AM", "2022-07-31T07:00:00", "3.7", "0.53"), - ("7/31/2022", "08:00 AM", "2022-07-31T08:00:00", "1.0", "0.16"), - ("7/31/2022", "09:00 AM", "2022-07-31T09:00:00", "0.7", "0.12"), - ("7/31/2022", "10:00 AM", "2022-07-31T10:00:00", "1.9", "0.28"), - ("7/31/2022", "11:00 AM", "2022-07-31T11:00:00", "4.3", "0.61"), - ("7/31/2022", "12:00 PM", "2022-07-31T12:00:00", "2.0", "0.29"), - ("7/31/2022", "01:00 PM", "2022-07-31T13:00:00", "3.9", "0.55"), - ("7/31/2022", "02:00 PM", "2022-07-31T14:00:00", "5.3", "0.75"), - ("7/31/2022", "03:00 PM", "2022-07-31T15:00:00", "5.0", "0.70"), - ("7/31/2022", "04:00 PM", "2022-07-31T16:00:00", "2.2", "0.31"), - ("7/31/2022", "05:00 PM", "2022-07-31T17:00:00", "2.6", "0.37"), - ("7/31/2022", "06:00 PM", "2022-07-31T18:00:00", "4.5", "0.64"), - ("7/31/2022", "07:00 PM", "2022-07-31T19:00:00", "2.5", "0.35"), - ("7/31/2022", "08:00 PM", "2022-07-31T20:00:00", "2.9", "0.42"), - ("7/31/2022", "09:00 PM", "2022-07-31T21:00:00", "2.2", "0.32"), - ("7/31/2022", "10:00 PM", "2022-07-31T22:00:00", "2.1", "0.30"), - ("7/31/2022", "11:00 PM", "2022-07-31T23:00:00", "2.0", "0.28"), - ("8/01/2022", "00:00 AM", "2022-08-01T00:00:00", "1.8", "0.26"), - ("8/01/2022", "01:00 AM", "2022-08-01T01:00:00", "1.7", "0.26"), - ("8/01/2022", "02:00 AM", "2022-08-01T02:00:00", "1.7", "0.26"), - ("8/01/2022", "03:00 AM", "2022-08-01T03:00:00", "0.8", "0.14"), - ("8/01/2022", "04:00 AM", "2022-08-01T04:00:00", "1.2", "0.19"), - ("8/01/2022", "05:00 AM", "2022-08-01T05:00:00", "1.6", "0.23"), - ("8/01/2022", "06:00 AM", "2022-08-01T06:00:00", "1.2", "0.18"), - ("8/01/2022", "07:00 AM", "2022-08-01T07:00:00", "3.1", "0.44"), - ("8/01/2022", "08:00 AM", "2022-08-01T08:00:00", "2.5", "0.35"), - ("8/01/2022", "09:00 AM", "2022-08-01T09:00:00", "3.3", "0.47"), - ("8/01/2022", "10:00 AM", "2022-08-01T10:00:00", "2.6", "0.37"), - ("8/01/2022", "11:00 AM", "2022-08-01T11:00:00", "0.8", "0.13"), - ("8/01/2022", "12:00 PM", "2022-08-01T12:00:00", "0.6", "0.11"), - ("8/01/2022", "01:00 PM", "2022-08-01T13:00:00", "6.4", "0.9"), - ("8/01/2022", "02:00 PM", "2022-08-01T14:00:00", "3.6", "0.52"), - ("8/01/2022", "03:00 PM", "2022-08-01T15:00:00", "5.5", "0.79"), - ("8/01/2022", "04:00 PM", "2022-08-01T16:00:00", "3", "0.43"), - ("8/01/2022", "05:00 PM", "2022-08-01T17:00:00", "5", "0.71"), - ("8/01/2022", "06:00 PM", "2022-08-01T18:00:00", "4.4", "0.63"), - ("8/01/2022", "07:00 PM", "2022-08-01T19:00:00", "3.8", "0.54"), - ("8/01/2022", "08:00 PM", "2022-08-01T20:00:00", "3.6", "0.51"), - ("8/01/2022", "09:00 PM", "2022-08-01T21:00:00", "2.9", "0.4"), - ("8/01/2022", "10:00 PM", "2022-08-01T22:00:00", "3.4", "0.49"), - ("8/01/2022", "11:00 PM", "2022-08-01T23:00:00", "2.9", "0.41"), - ("8/02/2022", "00:00 AM", "2022-08-02T00:00:00", "2", "0.3"), - ("8/02/2022", "01:00 AM", "2022-08-02T01:00:00", "2", "0.29"), - ("8/02/2022", "02:00 AM", "2022-08-02T02:00:00", "1.9", "0.28"), - ("8/02/2022", "03:00 AM", "2022-08-02T03:00:00", "1.8", "0.27"), - ("8/02/2022", "04:00 AM", "2022-08-02T04:00:00", "1.8", "0.26"), - ("8/02/2022", "05:00 AM", "2022-08-02T05:00:00", "1.6", "0.23"), - ("8/02/2022", "06:00 AM", "2022-08-02T06:00:00", "0.8", "0.14"), - ("8/02/2022", "07:00 AM", "2022-08-02T07:00:00", "4", "0.56"), - ("8/02/2022", "08:00 AM", "2022-08-02T08:00:00", "2.4", "0.34"), - ("8/02/2022", "09:00 AM", "2022-08-02T09:00:00", "4.1", "0.58"), - ("8/02/2022", "10:00 AM", "2022-08-02T10:00:00", "2.6", "0.37"), - ("8/02/2022", "11:00 AM", "2022-08-02T11:00:00", "0.5", "0.1"), - ("8/02/2022", "00:00 AM", "2022-08-02T12:00:00", "1", "0.16"), + ("7/31/2022", "00:00 AM", "2022-07-31T00:00:00", 1.2, 0.19), + ("7/31/2022", "01:00 AM", "2022-07-31T01:00:00", 1.3, 0.20), + ("7/31/2022", "02:00 AM", "2022-07-31T02:00:00", 1.1, 0.17), + ("7/31/2022", "03:00 AM", "2022-07-31T03:00:00", 1.2, 0.18), + ("7/31/2022", "04:00 AM", "2022-07-31T04:00:00", 0.8, 0.13), + ("7/31/2022", "05:00 AM", "2022-07-31T05:00:00", 0.9, 0.14), + ("7/31/2022", "06:00 AM", "2022-07-31T06:00:00", 1.6, 0.24), + ("7/31/2022", "07:00 AM", "2022-07-31T07:00:00", 3.7, 0.53), + ("7/31/2022", "08:00 AM", "2022-07-31T08:00:00", 1.0, 0.16), + ("7/31/2022", "09:00 AM", "2022-07-31T09:00:00", 0.7, 0.12), + ("7/31/2022", "10:00 AM", "2022-07-31T10:00:00", 1.9, 0.28), + ("7/31/2022", "11:00 AM", "2022-07-31T11:00:00", 4.3, 0.61), + ("7/31/2022", "12:00 PM", "2022-07-31T12:00:00", 2.0, 0.29), + ("7/31/2022", "01:00 PM", "2022-07-31T13:00:00", 3.9, 0.55), + ("7/31/2022", "02:00 PM", "2022-07-31T14:00:00", 5.3, 0.75), + ("7/31/2022", "03:00 PM", "2022-07-31T15:00:00", 5.0, 0.70), + ("7/31/2022", "04:00 PM", "2022-07-31T16:00:00", 2.2, 0.31), + ("7/31/2022", "05:00 PM", "2022-07-31T17:00:00", 2.6, 0.37), + ("7/31/2022", "06:00 PM", "2022-07-31T18:00:00", 4.5, 0.64), + ("7/31/2022", "07:00 PM", "2022-07-31T19:00:00", 2.5, 0.35), + ("7/31/2022", "08:00 PM", "2022-07-31T20:00:00", 2.9, 0.42), + ("7/31/2022", "09:00 PM", "2022-07-31T21:00:00", 2.2, 0.32), + ("7/31/2022", "10:00 PM", "2022-07-31T22:00:00", 2.1, 0.30), + ("7/31/2022", "11:00 PM", "2022-07-31T23:00:00", 2.0, 0.28), + ("8/01/2022", "00:00 AM", "2022-08-01T00:00:00", 1.8, 0.26), + ("8/01/2022", "01:00 AM", "2022-08-01T01:00:00", 1.7, 0.26), + ("8/01/2022", "02:00 AM", "2022-08-01T02:00:00", 1.7, 0.26), + ("8/01/2022", "03:00 AM", "2022-08-01T03:00:00", 0.8, 0.14), + ("8/01/2022", "04:00 AM", "2022-08-01T04:00:00", 1.2, 0.19), + ("8/01/2022", "05:00 AM", "2022-08-01T05:00:00", 1.6, 0.23), + ("8/01/2022", "06:00 AM", "2022-08-01T06:00:00", 1.2, 0.18), + ("8/01/2022", "07:00 AM", "2022-08-01T07:00:00", 3.1, 0.44), + ("8/01/2022", "08:00 AM", "2022-08-01T08:00:00", 2.5, 0.35), + ("8/01/2022", "09:00 AM", "2022-08-01T09:00:00", 3.3, 0.47), + ("8/01/2022", "10:00 AM", "2022-08-01T10:00:00", 2.6, 0.37), + ("8/01/2022", "11:00 AM", "2022-08-01T11:00:00", 0.8, 0.13), + ("8/01/2022", "12:00 PM", "2022-08-01T12:00:00", 0.6, 0.11), + ("8/01/2022", "01:00 PM", "2022-08-01T13:00:00", 6.4, 0.9), + ("8/01/2022", "02:00 PM", "2022-08-01T14:00:00", 3.6, 0.52), + ("8/01/2022", "03:00 PM", "2022-08-01T15:00:00", 5.5, 0.79), + ("8/01/2022", "04:00 PM", "2022-08-01T16:00:00", 3.0, 0.43), + ("8/01/2022", "05:00 PM", "2022-08-01T17:00:00", 5.0, 0.71), + ("8/01/2022", "06:00 PM", "2022-08-01T18:00:00", 4.4, 0.63), + ("8/01/2022", "07:00 PM", "2022-08-01T19:00:00", 3.8, 0.54), + ("8/01/2022", "08:00 PM", "2022-08-01T20:00:00", 3.6, 0.51), + ("8/01/2022", "09:00 PM", "2022-08-01T21:00:00", 2.9, 0.4), + ("8/01/2022", "10:00 PM", "2022-08-01T22:00:00", 3.4, 0.49), + ("8/01/2022", "11:00 PM", "2022-08-01T23:00:00", 2.9, 0.41), + ("8/02/2022", "00:00 AM", "2022-08-02T00:00:00", 2.0, 0.3), + ("8/02/2022", "01:00 AM", "2022-08-02T01:00:00", 2.0, 0.29), + ("8/02/2022", "02:00 AM", "2022-08-02T02:00:00", 1.9, 0.28), + ("8/02/2022", "03:00 AM", "2022-08-02T03:00:00", 1.8, 0.27), + ("8/02/2022", "04:00 AM", "2022-08-02T04:00:00", 1.8, 0.26), + ("8/02/2022", "05:00 AM", "2022-08-02T05:00:00", 1.6, 0.23), + ("8/02/2022", "06:00 AM", "2022-08-02T06:00:00", 0.8, 0.14), + ("8/02/2022", "07:00 AM", "2022-08-02T07:00:00", 4.0, 0.56), + ("8/02/2022", "08:00 AM", "2022-08-02T08:00:00", 2.4, 0.34), + ("8/02/2022", "09:00 AM", "2022-08-02T09:00:00", 4.1, 0.58), + ("8/02/2022", "10:00 AM", "2022-08-02T10:00:00", 2.6, 0.37), + ("8/02/2022", "11:00 AM", "2022-08-02T11:00:00", 0.5, 0.1), + ("8/02/2022", "12:00 PM", "2022-08-02T12:00:00", 1.0, 0.16), +] + +# Majority of the day the latest data we can see is yesterdays data +MOCK_USAGE_DAY_AUG_1_HOUR_5 = [ + ("7/31/2022", "00:00 AM", "2022-07-31T00:00:00", 1.2, 0.19), + ("7/31/2022", "01:00 AM", "2022-07-31T01:00:00", 1.3, 0.20), + ("7/31/2022", "02:00 AM", "2022-07-31T02:00:00", 1.1, 0.17), + ("7/31/2022", "03:00 AM", "2022-07-31T03:00:00", 1.2, 0.18), + ("7/31/2022", "04:00 AM", "2022-07-31T04:00:00", 0.8, 0.13), + ("7/31/2022", "05:00 AM", "2022-07-31T05:00:00", 0.9, 0.14), + ("7/31/2022", "06:00 AM", "2022-07-31T06:00:00", 1.6, 0.24), + ("7/31/2022", "07:00 AM", "2022-07-31T07:00:00", 3.7, 0.53), + ("7/31/2022", "08:00 AM", "2022-07-31T08:00:00", 1.0, 0.16), + ("7/31/2022", "09:00 AM", "2022-07-31T09:00:00", 0.7, 0.12), + ("7/31/2022", "10:00 AM", "2022-07-31T10:00:00", 1.9, 0.28), + ("7/31/2022", "11:00 AM", "2022-07-31T11:00:00", 4.3, 0.61), + ("7/31/2022", "12:00 PM", "2022-07-31T12:00:00", 2.0, 0.29), + ("7/31/2022", "01:00 PM", "2022-07-31T13:00:00", 3.9, 0.55), + ("7/31/2022", "02:00 PM", "2022-07-31T14:00:00", 5.3, 0.75), + ("7/31/2022", "03:00 PM", "2022-07-31T15:00:00", 5.0, 0.70), + ("7/31/2022", "04:00 PM", "2022-07-31T16:00:00", 2.2, 0.31), + ("7/31/2022", "05:00 PM", "2022-07-31T17:00:00", 2.6, 0.37), + ("7/31/2022", "06:00 PM", "2022-07-31T18:00:00", 4.5, 0.64), + ("7/31/2022", "07:00 PM", "2022-07-31T19:00:00", 2.5, 0.35), + ("7/31/2022", "08:00 PM", "2022-07-31T20:00:00", 2.9, 0.42), + ("7/31/2022", "09:00 PM", "2022-07-31T21:00:00", 2.2, 0.32), + ("7/31/2022", "10:00 PM", "2022-07-31T22:00:00", 2.1, 0.30), + ("7/31/2022", "11:00 PM", "2022-07-31T23:00:00", 2.0, 0.28), +] + +# At midnight the next day, we get yesterday's data BUT the last few hours +# incomplete. +MOCK_USAGE_DAY_AUG_2_HOUR_0 = [ + # everything from aug 1 + *MOCK_USAGE_DAY_AUG_1_HOUR_5, + ("8/01/2022", "00:00 AM", "2022-08-01T00:00:00", 1.8, 0.26), + ("8/01/2022", "01:00 AM", "2022-08-01T01:00:00", 1.7, 0.26), + ("8/01/2022", "02:00 AM", "2022-08-01T02:00:00", 1.7, 0.26), + ("8/01/2022", "03:00 AM", "2022-08-01T03:00:00", 0.8, 0.14), + ("8/01/2022", "04:00 AM", "2022-08-01T04:00:00", 1.2, 0.19), + ("8/01/2022", "05:00 AM", "2022-08-01T05:00:00", 1.6, 0.23), + ("8/01/2022", "06:00 AM", "2022-08-01T06:00:00", 1.2, 0.18), + ("8/01/2022", "07:00 AM", "2022-08-01T07:00:00", 3.1, 0.44), + ("8/01/2022", "08:00 AM", "2022-08-01T08:00:00", 2.5, 0.35), + ("8/01/2022", "09:00 AM", "2022-08-01T09:00:00", 3.3, 0.47), + ("8/01/2022", "10:00 AM", "2022-08-01T10:00:00", 2.6, 0.37), + ("8/01/2022", "11:00 AM", "2022-08-01T11:00:00", 0.8, 0.13), + ("8/01/2022", "12:00 PM", "2022-08-01T12:00:00", 0.6, 0.11), + ("8/01/2022", "01:00 PM", "2022-08-01T13:00:00", 6.4, 0.9), + ("8/01/2022", "02:00 PM", "2022-08-01T14:00:00", 3.6, 0.52), + ("8/01/2022", "03:00 PM", "2022-08-01T15:00:00", 5.5, 0.79), + ("8/01/2022", "04:00 PM", "2022-08-01T16:00:00", 3.0, 0.43), + ("8/01/2022", "05:00 PM", "2022-08-01T17:00:00", 5.0, 0.71), + ("8/01/2022", "06:00 PM", "2022-08-01T18:00:00", 4.4, 0.63), + ("8/01/2022", "07:00 PM", "2022-08-01T19:00:00", 1.9, 0.27), + ("8/01/2022", "08:00 PM", "2022-08-01T20:00:00", 0.0, 0.0), + ("8/01/2022", "09:00 PM", "2022-08-01T21:00:00", 0.0, 0.0), + ("8/01/2022", "10:00 PM", "2022-08-01T22:00:00", 0.0, 0.0), + ("8/01/2022", "11:00 PM", "2022-08-01T23:00:00", 0.0, 0.0), +] + +# Around an hour after midnight, we get more of yesterdays data BUT the last +# record typically only accounts for part of the last hour, so it will get +# updated again. +MOCK_USAGE_DAY_AUG_2_HOUR_1 = [ + # everything from hour 0 except the last 5 entries, replaced by the updated values below + *MOCK_USAGE_DAY_AUG_2_HOUR_0[:-5], + ("8/01/2022", "07:00 PM", "2022-08-01T19:00:00", 3.8, 0.54), + ("8/01/2022", "08:00 PM", "2022-08-01T20:00:00", 3.6, 0.51), + ("8/01/2022", "09:00 PM", "2022-08-01T21:00:00", 2.9, 0.4), + ("8/01/2022", "10:00 PM", "2022-08-01T22:00:00", 3.4, 0.49), + ("8/01/2022", "11:00 PM", "2022-08-01T23:00:00", 1.4, 0.2), +] + +# Finally around 5 AM, we get the finalized data +MOCK_USAGE_DAY_AUG_2_HOUR_5 = [ + # everything from hour 1 except the last 1 entry, replaced by the updated values below + *MOCK_USAGE_DAY_AUG_2_HOUR_1[:-1], + ("8/01/2022", "11:00 PM", "2022-08-01T23:00:00", 2.9, 0.41), +] + +MOCK_USAGE_AT_TIMES = [ + ("2022-08-01T05:00:00-07:00", MOCK_USAGE_DAY_AUG_1_HOUR_5), + ("2022-08-02T00:00:00-07:00", MOCK_USAGE_DAY_AUG_2_HOUR_0), + ("2022-08-02T01:00:00-07:00", MOCK_USAGE_DAY_AUG_2_HOUR_1), + ("2022-08-02T05:00:00-07:00", MOCK_USAGE_DAY_AUG_2_HOUR_5), ] diff --git a/tests/components/srp_energy/conftest.py b/tests/components/srp_energy/conftest.py index b4b9c0b7bd5f..5d63a23523ad 100644 --- a/tests/components/srp_energy/conftest.py +++ b/tests/components/srp_energy/conftest.py @@ -7,17 +7,21 @@ from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.components.recorder import Recorder from homeassistant.components.srp_energy.const import DOMAIN, PHOENIX_TIME_ZONE +from homeassistant.components.srp_energy.coordinator import HourlyUsageTuple from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from . import MOCK_USAGE, TEST_CONFIG_HOME +from . import MOCK_USAGE_AT_TIMES, TEST_CONFIG_HOME from tests.common import MockConfigEntry +PHOENIX_ZONE_INFO = dt_util.get_time_zone(PHOENIX_TIME_ZONE) -@pytest.fixture(name="setup_hass_config", autouse=True) + +@pytest.fixture(name="setup_hass_config") async def fixture_setup_hass_config(hass: HomeAssistant) -> None: """Set up things to be run when tests are started.""" hass.config.latitude = 33.27 @@ -34,17 +38,43 @@ def fixture_hass_tz_info(hass: HomeAssistant, setup_hass_config) -> dt.tzinfo | @pytest.fixture(name="test_date") def fixture_test_date(hass: HomeAssistant, hass_tz_info) -> dt.datetime | None: """Return test datetime for the hass timezone.""" - return dt.datetime(2022, 8, 2, 0, 0, 0, 0, tzinfo=hass_tz_info) + # Default to run in the middle of the day on aug 2 + return dt.datetime(2022, 8, 2, 12, 0, 0, 0, tzinfo=hass_tz_info) @pytest.fixture(name="mock_config_entry") -def fixture_mock_config_entry() -> MockConfigEntry: +def fixture_mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Return the default mocked config entry.""" return MockConfigEntry( domain=DOMAIN, data=TEST_CONFIG_HOME, unique_id=TEST_CONFIG_HOME[CONF_ID] ) +def _mock_usage( + start_date: dt.datetime, end_date: dt.datetime, is_tou: bool +) -> list[HourlyUsageTuple]: + now = dt_util.now(PHOENIX_ZONE_INFO).replace(minute=0, second=0, microsecond=0) + data = MOCK_USAGE_AT_TIMES[-1][1] + start_date = start_date.replace( + hour=0, minute=0, second=0, microsecond=0, tzinfo=PHOENIX_ZONE_INFO + ) + end_date = end_date.replace( + hour=23, minute=59, second=59, microsecond=999999, tzinfo=PHOENIX_ZONE_INFO + ) + for key, value in reversed(MOCK_USAGE_AT_TIMES): + if now >= dt_util.parse_datetime(key).replace(tzinfo=PHOENIX_ZONE_INFO): + data = value + break + + def is_in_range(ts: str) -> bool: + item_time = dt_util.parse_datetime(ts).replace(tzinfo=PHOENIX_ZONE_INFO) + after_start = item_time >= start_date + before_end = item_time <= end_date + return after_start and before_end + + return [item for item in data if is_in_range(item[2])] + + @pytest.fixture(name="mock_srp_energy") def fixture_mock_srp_energy() -> Generator[MagicMock]: """Return a mocked SrpEnergyClient client.""" @@ -53,7 +83,7 @@ def fixture_mock_srp_energy() -> Generator[MagicMock]: ) as srp_energy_mock: client = srp_energy_mock.return_value client.validate.return_value = True - client.usage.return_value = MOCK_USAGE + client.usage.side_effect = _mock_usage yield client @@ -65,12 +95,13 @@ def fixture_mock_srp_energy_config_flow() -> Generator[MagicMock]: ) as srp_energy_mock: client = srp_energy_mock.return_value client.validate.return_value = True - client.usage.return_value = MOCK_USAGE + client.usage.side_effect = _mock_usage yield client @pytest.fixture async def init_integration( + recorder_mock: Recorder, hass: HomeAssistant, freezer: FrozenDateTimeFactory, test_date: dt.datetime, diff --git a/tests/components/srp_energy/snapshots/test_coordinator.ambr b/tests/components/srp_energy/snapshots/test_coordinator.ambr new file mode 100644 index 000000000000..4aaca0f128e7 --- /dev/null +++ b/tests/components/srp_energy/snapshots/test_coordinator.ambr @@ -0,0 +1,677 @@ +# serializer version: 1 +# name: test_coordinator_first_run + defaultdict({ + 'srp_energy:123456789_energy_consumption': list([ + dict({ + 'end': 1659254400.0, + 'start': 1659250800.0, + 'state': 1.2, + 'sum': 1.2, + }), + dict({ + 'end': 1659258000.0, + 'start': 1659254400.0, + 'state': 1.3, + 'sum': 2.5, + }), + dict({ + 'end': 1659261600.0, + 'start': 1659258000.0, + 'state': 1.1, + 'sum': 3.6, + }), + dict({ + 'end': 1659265200.0, + 'start': 1659261600.0, + 'state': 1.2, + 'sum': 4.8, + }), + dict({ + 'end': 1659268800.0, + 'start': 1659265200.0, + 'state': 0.8, + 'sum': 5.6, + }), + dict({ + 'end': 1659272400.0, + 'start': 1659268800.0, + 'state': 0.9, + 'sum': 6.5, + }), + dict({ + 'end': 1659276000.0, + 'start': 1659272400.0, + 'state': 1.6, + 'sum': 8.1, + }), + dict({ + 'end': 1659279600.0, + 'start': 1659276000.0, + 'state': 3.7, + 'sum': 11.8, + }), + dict({ + 'end': 1659283200.0, + 'start': 1659279600.0, + 'state': 1.0, + 'sum': 12.8, + }), + dict({ + 'end': 1659286800.0, + 'start': 1659283200.0, + 'state': 0.7, + 'sum': 13.5, + }), + dict({ + 'end': 1659290400.0, + 'start': 1659286800.0, + 'state': 1.9, + 'sum': 15.4, + }), + dict({ + 'end': 1659294000.0, + 'start': 1659290400.0, + 'state': 4.3, + 'sum': 19.7, + }), + dict({ + 'end': 1659297600.0, + 'start': 1659294000.0, + 'state': 2.0, + 'sum': 21.7, + }), + dict({ + 'end': 1659301200.0, + 'start': 1659297600.0, + 'state': 3.9, + 'sum': 25.599999999999998, + }), + dict({ + 'end': 1659304800.0, + 'start': 1659301200.0, + 'state': 5.3, + 'sum': 30.9, + }), + dict({ + 'end': 1659308400.0, + 'start': 1659304800.0, + 'state': 5.0, + 'sum': 35.9, + }), + dict({ + 'end': 1659312000.0, + 'start': 1659308400.0, + 'state': 2.2, + 'sum': 38.1, + }), + dict({ + 'end': 1659315600.0, + 'start': 1659312000.0, + 'state': 2.6, + 'sum': 40.7, + }), + dict({ + 'end': 1659319200.0, + 'start': 1659315600.0, + 'state': 4.5, + 'sum': 45.2, + }), + dict({ + 'end': 1659322800.0, + 'start': 1659319200.0, + 'state': 2.5, + 'sum': 47.7, + }), + dict({ + 'end': 1659326400.0, + 'start': 1659322800.0, + 'state': 2.9, + 'sum': 50.6, + }), + dict({ + 'end': 1659330000.0, + 'start': 1659326400.0, + 'state': 2.2, + 'sum': 52.800000000000004, + }), + dict({ + 'end': 1659333600.0, + 'start': 1659330000.0, + 'state': 2.1, + 'sum': 54.900000000000006, + }), + dict({ + 'end': 1659337200.0, + 'start': 1659333600.0, + 'state': 2.0, + 'sum': 56.900000000000006, + }), + dict({ + 'end': 1659340800.0, + 'start': 1659337200.0, + 'state': 1.8, + 'sum': 58.7, + }), + dict({ + 'end': 1659344400.0, + 'start': 1659340800.0, + 'state': 1.7, + 'sum': 60.400000000000006, + }), + dict({ + 'end': 1659348000.0, + 'start': 1659344400.0, + 'state': 1.7, + 'sum': 62.10000000000001, + }), + dict({ + 'end': 1659351600.0, + 'start': 1659348000.0, + 'state': 0.8, + 'sum': 62.900000000000006, + }), + dict({ + 'end': 1659355200.0, + 'start': 1659351600.0, + 'state': 1.2, + 'sum': 64.10000000000001, + }), + dict({ + 'end': 1659358800.0, + 'start': 1659355200.0, + 'state': 1.6, + 'sum': 65.7, + }), + dict({ + 'end': 1659362400.0, + 'start': 1659358800.0, + 'state': 1.2, + 'sum': 66.9, + }), + dict({ + 'end': 1659366000.0, + 'start': 1659362400.0, + 'state': 3.1, + 'sum': 70.0, + }), + dict({ + 'end': 1659369600.0, + 'start': 1659366000.0, + 'state': 2.5, + 'sum': 72.5, + }), + dict({ + 'end': 1659373200.0, + 'start': 1659369600.0, + 'state': 3.3, + 'sum': 75.8, + }), + dict({ + 'end': 1659376800.0, + 'start': 1659373200.0, + 'state': 2.6, + 'sum': 78.39999999999999, + }), + dict({ + 'end': 1659380400.0, + 'start': 1659376800.0, + 'state': 0.8, + 'sum': 79.19999999999999, + }), + dict({ + 'end': 1659384000.0, + 'start': 1659380400.0, + 'state': 0.6, + 'sum': 79.79999999999998, + }), + dict({ + 'end': 1659387600.0, + 'start': 1659384000.0, + 'state': 6.4, + 'sum': 86.19999999999999, + }), + dict({ + 'end': 1659391200.0, + 'start': 1659387600.0, + 'state': 3.6, + 'sum': 89.79999999999998, + }), + dict({ + 'end': 1659394800.0, + 'start': 1659391200.0, + 'state': 5.5, + 'sum': 95.29999999999998, + }), + dict({ + 'end': 1659398400.0, + 'start': 1659394800.0, + 'state': 3.0, + 'sum': 98.29999999999998, + }), + dict({ + 'end': 1659402000.0, + 'start': 1659398400.0, + 'state': 5.0, + 'sum': 103.29999999999998, + }), + dict({ + 'end': 1659405600.0, + 'start': 1659402000.0, + 'state': 4.4, + 'sum': 107.69999999999999, + }), + dict({ + 'end': 1659409200.0, + 'start': 1659405600.0, + 'state': 3.8, + 'sum': 111.49999999999999, + }), + dict({ + 'end': 1659412800.0, + 'start': 1659409200.0, + 'state': 3.6, + 'sum': 115.09999999999998, + }), + dict({ + 'end': 1659416400.0, + 'start': 1659412800.0, + 'state': 2.9, + 'sum': 117.99999999999999, + }), + dict({ + 'end': 1659420000.0, + 'start': 1659416400.0, + 'state': 3.4, + 'sum': 121.39999999999999, + }), + dict({ + 'end': 1659423600.0, + 'start': 1659420000.0, + 'state': 2.9, + 'sum': 124.3, + }), + ]), + 'srp_energy:123456789_energy_cost': list([ + dict({ + 'end': 1659254400.0, + 'start': 1659250800.0, + 'state': 0.19, + 'sum': 0.19, + }), + dict({ + 'end': 1659258000.0, + 'start': 1659254400.0, + 'state': 0.2, + 'sum': 0.39, + }), + dict({ + 'end': 1659261600.0, + 'start': 1659258000.0, + 'state': 0.17, + 'sum': 0.56, + }), + dict({ + 'end': 1659265200.0, + 'start': 1659261600.0, + 'state': 0.18, + 'sum': 0.74, + }), + dict({ + 'end': 1659268800.0, + 'start': 1659265200.0, + 'state': 0.13, + 'sum': 0.87, + }), + dict({ + 'end': 1659272400.0, + 'start': 1659268800.0, + 'state': 0.14, + 'sum': 1.01, + }), + dict({ + 'end': 1659276000.0, + 'start': 1659272400.0, + 'state': 0.24, + 'sum': 1.25, + }), + dict({ + 'end': 1659279600.0, + 'start': 1659276000.0, + 'state': 0.53, + 'sum': 1.78, + }), + dict({ + 'end': 1659283200.0, + 'start': 1659279600.0, + 'state': 0.16, + 'sum': 1.94, + }), + dict({ + 'end': 1659286800.0, + 'start': 1659283200.0, + 'state': 0.12, + 'sum': 2.06, + }), + dict({ + 'end': 1659290400.0, + 'start': 1659286800.0, + 'state': 0.28, + 'sum': 2.34, + }), + dict({ + 'end': 1659294000.0, + 'start': 1659290400.0, + 'state': 0.61, + 'sum': 2.9499999999999997, + }), + dict({ + 'end': 1659297600.0, + 'start': 1659294000.0, + 'state': 0.29, + 'sum': 3.2399999999999998, + }), + dict({ + 'end': 1659301200.0, + 'start': 1659297600.0, + 'state': 0.55, + 'sum': 3.79, + }), + dict({ + 'end': 1659304800.0, + 'start': 1659301200.0, + 'state': 0.75, + 'sum': 4.54, + }), + dict({ + 'end': 1659308400.0, + 'start': 1659304800.0, + 'state': 0.7, + 'sum': 5.24, + }), + dict({ + 'end': 1659312000.0, + 'start': 1659308400.0, + 'state': 0.31, + 'sum': 5.55, + }), + dict({ + 'end': 1659315600.0, + 'start': 1659312000.0, + 'state': 0.37, + 'sum': 5.92, + }), + dict({ + 'end': 1659319200.0, + 'start': 1659315600.0, + 'state': 0.64, + 'sum': 6.56, + }), + dict({ + 'end': 1659322800.0, + 'start': 1659319200.0, + 'state': 0.35, + 'sum': 6.909999999999999, + }), + dict({ + 'end': 1659326400.0, + 'start': 1659322800.0, + 'state': 0.42, + 'sum': 7.329999999999999, + }), + dict({ + 'end': 1659330000.0, + 'start': 1659326400.0, + 'state': 0.32, + 'sum': 7.6499999999999995, + }), + dict({ + 'end': 1659333600.0, + 'start': 1659330000.0, + 'state': 0.3, + 'sum': 7.949999999999999, + }), + dict({ + 'end': 1659337200.0, + 'start': 1659333600.0, + 'state': 0.28, + 'sum': 8.229999999999999, + }), + dict({ + 'end': 1659340800.0, + 'start': 1659337200.0, + 'state': 0.26, + 'sum': 8.489999999999998, + }), + dict({ + 'end': 1659344400.0, + 'start': 1659340800.0, + 'state': 0.26, + 'sum': 8.749999999999998, + }), + dict({ + 'end': 1659348000.0, + 'start': 1659344400.0, + 'state': 0.26, + 'sum': 9.009999999999998, + }), + dict({ + 'end': 1659351600.0, + 'start': 1659348000.0, + 'state': 0.14, + 'sum': 9.149999999999999, + }), + dict({ + 'end': 1659355200.0, + 'start': 1659351600.0, + 'state': 0.19, + 'sum': 9.339999999999998, + }), + dict({ + 'end': 1659358800.0, + 'start': 1659355200.0, + 'state': 0.23, + 'sum': 9.569999999999999, + }), + dict({ + 'end': 1659362400.0, + 'start': 1659358800.0, + 'state': 0.18, + 'sum': 9.749999999999998, + }), + dict({ + 'end': 1659366000.0, + 'start': 1659362400.0, + 'state': 0.44, + 'sum': 10.189999999999998, + }), + dict({ + 'end': 1659369600.0, + 'start': 1659366000.0, + 'state': 0.35, + 'sum': 10.539999999999997, + }), + dict({ + 'end': 1659373200.0, + 'start': 1659369600.0, + 'state': 0.47, + 'sum': 11.009999999999998, + }), + dict({ + 'end': 1659376800.0, + 'start': 1659373200.0, + 'state': 0.37, + 'sum': 11.379999999999997, + }), + dict({ + 'end': 1659380400.0, + 'start': 1659376800.0, + 'state': 0.13, + 'sum': 11.509999999999998, + }), + dict({ + 'end': 1659384000.0, + 'start': 1659380400.0, + 'state': 0.11, + 'sum': 11.619999999999997, + }), + dict({ + 'end': 1659387600.0, + 'start': 1659384000.0, + 'state': 0.9, + 'sum': 12.519999999999998, + }), + dict({ + 'end': 1659391200.0, + 'start': 1659387600.0, + 'state': 0.52, + 'sum': 13.039999999999997, + }), + dict({ + 'end': 1659394800.0, + 'start': 1659391200.0, + 'state': 0.79, + 'sum': 13.829999999999998, + }), + dict({ + 'end': 1659398400.0, + 'start': 1659394800.0, + 'state': 0.43, + 'sum': 14.259999999999998, + }), + dict({ + 'end': 1659402000.0, + 'start': 1659398400.0, + 'state': 0.71, + 'sum': 14.969999999999999, + }), + dict({ + 'end': 1659405600.0, + 'start': 1659402000.0, + 'state': 0.63, + 'sum': 15.6, + }), + dict({ + 'end': 1659409200.0, + 'start': 1659405600.0, + 'state': 0.54, + 'sum': 16.14, + }), + dict({ + 'end': 1659412800.0, + 'start': 1659409200.0, + 'state': 0.51, + 'sum': 16.650000000000002, + }), + dict({ + 'end': 1659416400.0, + 'start': 1659412800.0, + 'state': 0.4, + 'sum': 17.05, + }), + dict({ + 'end': 1659420000.0, + 'start': 1659416400.0, + 'state': 0.49, + 'sum': 17.54, + }), + dict({ + 'end': 1659423600.0, + 'start': 1659420000.0, + 'state': 0.41, + 'sum': 17.95, + }), + ]), + }) +# --- +# name: test_coordinator_subsequent_run + defaultdict({ + 'srp_energy:123456789_energy_consumption': list([ + dict({ + 'end': 1659315600.0, + 'start': 1659312000.0, + 'state': 2.6, + 'sum': 2.6, + }), + dict({ + 'end': 1659319200.0, + 'start': 1659315600.0, + 'state': 4.5, + 'sum': 7.1, + }), + dict({ + 'end': 1659322800.0, + 'start': 1659319200.0, + 'state': 2.5, + 'sum': 9.6, + }), + dict({ + 'end': 1659326400.0, + 'start': 1659322800.0, + 'state': 2.9, + 'sum': 12.5, + }), + dict({ + 'end': 1659330000.0, + 'start': 1659326400.0, + 'state': 2.2, + 'sum': 14.7, + }), + dict({ + 'end': 1659333600.0, + 'start': 1659330000.0, + 'state': 2.1, + 'sum': 16.8, + }), + dict({ + 'end': 1659337200.0, + 'start': 1659333600.0, + 'state': 2.0, + 'sum': 18.8, + }), + ]), + 'srp_energy:123456789_energy_cost': list([ + dict({ + 'end': 1659315600.0, + 'start': 1659312000.0, + 'state': 0.37, + 'sum': 0.37, + }), + dict({ + 'end': 1659319200.0, + 'start': 1659315600.0, + 'state': 0.64, + 'sum': 1.01, + }), + dict({ + 'end': 1659322800.0, + 'start': 1659319200.0, + 'state': 0.35, + 'sum': 1.3599999999999999, + }), + dict({ + 'end': 1659326400.0, + 'start': 1659322800.0, + 'state': 0.42, + 'sum': 1.7799999999999998, + }), + dict({ + 'end': 1659330000.0, + 'start': 1659326400.0, + 'state': 0.32, + 'sum': 2.0999999999999996, + }), + dict({ + 'end': 1659333600.0, + 'start': 1659330000.0, + 'state': 0.3, + 'sum': 2.3999999999999995, + }), + dict({ + 'end': 1659337200.0, + 'start': 1659333600.0, + 'state': 0.28, + 'sum': 2.6799999999999997, + }), + ]), + }) +# --- diff --git a/tests/components/srp_energy/test_config_flow.py b/tests/components/srp_energy/test_config_flow.py index 86d03921534f..13af9813c042 100644 --- a/tests/components/srp_energy/test_config_flow.py +++ b/tests/components/srp_energy/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch import pytest +from homeassistant.components.recorder import Recorder from homeassistant.components.srp_energy.const import CONF_IS_TOU, DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import ( @@ -33,7 +34,7 @@ from tests.common import MockConfigEntry @pytest.mark.usefixtures("mock_srp_energy_config_flow") async def test_show_form( - hass: HomeAssistant, capsys: pytest.CaptureFixture[str] + recorder_mock: Recorder, hass: HomeAssistant, capsys: pytest.CaptureFixture[str] ) -> None: """Test show configuration form.""" result = await hass.config_entries.flow.async_init( @@ -69,6 +70,7 @@ async def test_show_form( async def test_form_invalid_account( + recorder_mock: Recorder, hass: HomeAssistant, mock_srp_energy_config_flow: MagicMock, ) -> None: @@ -88,6 +90,7 @@ async def test_form_invalid_account( async def test_form_invalid_auth( + recorder_mock: Recorder, hass: HomeAssistant, mock_srp_energy_config_flow: MagicMock, ) -> None: @@ -107,6 +110,7 @@ async def test_form_invalid_auth( async def test_form_unknown_error( + recorder_mock: Recorder, hass: HomeAssistant, mock_srp_energy_config_flow: MagicMock, ) -> None: @@ -126,7 +130,7 @@ async def test_form_unknown_error( async def test_flow_entry_already_configured( - hass: HomeAssistant, init_integration: MockConfigEntry + recorder_mock: Recorder, hass: HomeAssistant, init_integration: MockConfigEntry ) -> None: """Test user input for config_entry that already exists.""" # Verify mock config setup from fixture @@ -150,7 +154,7 @@ async def test_flow_entry_already_configured( async def test_flow_multiple_configs( - hass: HomeAssistant, init_integration: MockConfigEntry + recorder_mock: Recorder, hass: HomeAssistant, init_integration: MockConfigEntry ) -> None: """Test multiple config entries.""" # Verify mock config setup from fixture @@ -183,7 +187,7 @@ async def test_flow_multiple_configs( async def test_reconfigure( - hass: HomeAssistant, init_integration: MockConfigEntry + recorder_mock: Recorder, hass: HomeAssistant, init_integration: MockConfigEntry ) -> None: """Test reconfiguring an existing entry.""" @@ -216,6 +220,7 @@ async def test_reconfigure( @pytest.mark.usefixtures("mock_setup_entry") async def test_reconfigure_error( + recorder_mock: Recorder, hass: HomeAssistant, init_integration: MockConfigEntry, mock_srp_energy_config_flow: MagicMock, @@ -262,6 +267,7 @@ async def test_reconfigure_error( @pytest.mark.usefixtures("mock_setup_entry") async def test_reconfigure_unknown_error( + recorder_mock: Recorder, hass: HomeAssistant, init_integration: MockConfigEntry, mock_srp_energy_config_flow: MagicMock, diff --git a/tests/components/srp_energy/test_coordinator.py b/tests/components/srp_energy/test_coordinator.py new file mode 100644 index 000000000000..0f47a2887a57 --- /dev/null +++ b/tests/components/srp_energy/test_coordinator.py @@ -0,0 +1,227 @@ +"""Tests for Srp Energy component coordinator.""" + +from datetime import datetime as dt +from unittest.mock import MagicMock + +from freezegun.api import freeze_time +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder.statistics import ( + StatisticsRow, + statistics_during_period, +) +from homeassistant.components.srp_energy import SRPEnergyDataUpdateCoordinator +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from .conftest import PHOENIX_ZONE_INFO + +from tests.common import MockConfigEntry +from tests.components.recorder.common import async_wait_recording_done + + +@freeze_time("2022-08-02T12:00:00-07:00") +async def test_coordinator_first_run( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_srp_energy: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the coordinator on its first run with no existing statistics. + + Should import multiple days of statistics. + """ + coordinator = SRPEnergyDataUpdateCoordinator( + hass, mock_config_entry, mock_srp_energy + ) + + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + # Check stats for electric account '123456789' + stats = await get_stats(hass) + assert stats == snapshot + + +async def test_coordinator_subsequent_run( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_srp_energy: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the coordinator correctly updates statistics on subsequent runs.""" + coordinator = SRPEnergyDataUpdateCoordinator( + hass, mock_config_entry, mock_srp_energy + ) + + mock_srp_energy.usage.side_effect = None + mock_srp_energy.usage.return_value = [ + ("7/31/2022", "05:00 PM", "2022-07-31T17:00:00", 2.6, 0.37), + ("7/31/2022", "06:00 PM", "2022-07-31T18:00:00", 4.5, 0.64), + ("7/31/2022", "07:00 PM", "2022-07-31T19:00:00", 2.2, 0.32), + ("7/31/2022", "08:00 PM", "2022-07-31T20:00:00", 0.0, 0.0), + ("7/31/2022", "09:00 PM", "2022-07-31T21:00:00", 0.0, 0.0), + ("7/31/2022", "10:00 PM", "2022-07-31T22:00:00", 0.0, 0.0), + ("7/31/2022", "11:00 PM", "2022-07-31T23:00:00", 0.0, 0.0), + ] + + # Run aug 2nd at midnight to import through aug 1 + # BUT tends to be missing last few hours + with freeze_time("2022-08-02T00:00:00-07:00"): + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + stats = await get_stats(hass) + last_consumption = stats["srp_energy:123456789_energy_consumption"][-1] + assert_stat(last_consumption, "2022-07-31T19:00:00-07:00", 2.2, 9.3) + last_cost = stats["srp_energy:123456789_energy_cost"][-1] + assert_stat(last_cost, "2022-07-31T19:00:00-07:00", 0.32, 1.33) + + mock_srp_energy.usage.return_value = [ + ("7/31/2022", "05:00 PM", "2022-07-31T17:00:00", 2.6, 0.37), + ("7/31/2022", "06:00 PM", "2022-07-31T18:00:00", 4.5, 0.64), + ("7/31/2022", "07:00 PM", "2022-07-31T19:00:00", 2.5, 0.35), # was 2.2 / 0.32 + ("7/31/2022", "08:00 PM", "2022-07-31T20:00:00", 2.9, 0.42), # was 0 / 0 + ("7/31/2022", "09:00 PM", "2022-07-31T21:00:00", 2.2, 0.32), # was 0 / 0 + ("7/31/2022", "10:00 PM", "2022-07-31T22:00:00", 2.1, 0.30), # was 0 / 0 + ("7/31/2022", "11:00 PM", "2022-07-31T23:00:00", 2.0, 0.28), # was 0 / 0 + ] + # Run aug 2nd at 5am to get the updated data for aug 1 + with freeze_time("2022-08-02T05:00:00-07:00"): + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + stats = await get_stats(hass) + last_consumption = stats["srp_energy:123456789_energy_consumption"][-1] + assert_stat(last_consumption, "2022-07-31T23:00:00-07:00", 2.0, 18.8) + last_cost = stats["srp_energy:123456789_energy_cost"][-1] + assert_stat(last_cost, "2022-07-31T23:00:00-07:00", 0.28, 2.68) + assert stats == snapshot + + +def assert_stat( + stat: StatisticsRow, expected_start: str, expected_state: float, expected_sum: float +) -> None: + """Helper function to assert a single statistics row.""" + assert ( + dt.fromtimestamp(stat["start"], PHOENIX_ZONE_INFO).isoformat() == expected_start + ) + assert round(stat["state"], 2) == expected_state + assert round(stat["sum"], 2) == expected_sum + + +async def test_coordinator_statistics_gap( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_srp_energy: MagicMock, +) -> None: + """Test coordinator handles a gap between stored stats and SRP's available data. + + When SRP's 30-day API window rolls past the period covered by stored statistics, + statistics_during_period finds nothing at hourly_usage[0].start_time. The else + branch should fall back to the last known running sums and continue the totals + from there rather than restarting from zero. + """ + coordinator = SRPEnergyDataUpdateCoordinator( + hass, mock_config_entry, mock_srp_energy + ) + + # First run: establish two stat entries so get_last_statistics returns 2 records. + mock_srp_energy.usage.side_effect = None + mock_srp_energy.usage.return_value = [ + ("7/31/2022", "05:00 PM", "2022-07-31T17:00:00", 2.0, 0.20), + ("7/31/2022", "06:00 PM", "2022-07-31T18:00:00", 3.0, 0.30), + ] + + with freeze_time("2022-08-01T12:00:00-07:00"): + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + stats = await get_stats(hass) + assert_stat( + stats["srp_energy:123456789_energy_consumption"][-1], + "2022-07-31T18:00:00-07:00", + 3.0, + 5.0, + ) + assert_stat( + stats["srp_energy:123456789_energy_cost"][-1], + "2022-07-31T18:00:00-07:00", + 0.30, + 0.50, + ) + + # Second run: SRP's API window has advanced so it only returns data starting + # AFTER the most recent stored stat (18:00). statistics_during_period will find + # nothing at 20:00 or later, triggering the else branch. + mock_srp_energy.usage.return_value = [ + ("7/31/2022", "08:00 PM", "2022-07-31T20:00:00", 4.0, 0.40), + ("7/31/2022", "09:00 PM", "2022-07-31T21:00:00", 5.0, 0.50), + ] + + with freeze_time("2022-09-01T12:00:00-07:00"): + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + stats = await get_stats(hass) + # Sums must continue from the last stored values (5.0 kWh / $0.50), not restart. + assert_stat( + stats["srp_energy:123456789_energy_consumption"][-2], + "2022-07-31T20:00:00-07:00", + 4.0, + 9.0, + ) + assert_stat( + stats["srp_energy:123456789_energy_consumption"][-1], + "2022-07-31T21:00:00-07:00", + 5.0, + 14.0, + ) + assert_stat( + stats["srp_energy:123456789_energy_cost"][-2], + "2022-07-31T20:00:00-07:00", + 0.40, + 0.90, + ) + assert_stat( + stats["srp_energy:123456789_energy_cost"][-1], + "2022-07-31T21:00:00-07:00", + 0.50, + 1.40, + ) + + +async def test_coordinator_subsequent_run_no_energy_data( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_srp_energy: MagicMock, +) -> None: + """Test the coordinator handles no recent usage/cost data.""" + coordinator = SRPEnergyDataUpdateCoordinator( + hass, mock_config_entry, mock_srp_energy + ) + await coordinator._async_update_data() + + await async_wait_recording_done(hass) + + +async def get_stats(hass: HomeAssistant) -> dict[str, list[StatisticsRow]]: + """Helper function to get the latest statistics.""" + return await hass.async_add_executor_job( + statistics_during_period, + hass, + dt_util.utc_from_timestamp(0), + None, + { + "srp_energy:123456789_energy_consumption", + "srp_energy:123456789_energy_cost", + }, + "hour", + None, + {"state", "sum"}, + ) diff --git a/tests/components/srp_energy/test_init.py b/tests/components/srp_energy/test_init.py index 2a02e44b9b09..224ae6701d6e 100644 --- a/tests/components/srp_energy/test_init.py +++ b/tests/components/srp_energy/test_init.py @@ -1,15 +1,20 @@ """Tests for Srp Energy component Init.""" +from homeassistant.components.recorder import Recorder from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -async def test_setup_entry(hass: HomeAssistant, init_integration) -> None: +async def test_setup_entry( + recorder_mock: Recorder, hass: HomeAssistant, init_integration +) -> None: """Test setup entry.""" assert init_integration.state is ConfigEntryState.LOADED -async def test_unload_entry(hass: HomeAssistant, init_integration) -> None: +async def test_unload_entry( + recorder_mock: Recorder, hass: HomeAssistant, init_integration +) -> None: """Test being able to unload an entry.""" assert init_integration.state is ConfigEntryState.LOADED diff --git a/tests/components/srp_energy/test_sensor.py b/tests/components/srp_energy/test_sensor.py index 025d9fe49ca7..689deec449c1 100644 --- a/tests/components/srp_energy/test_sensor.py +++ b/tests/components/srp_energy/test_sensor.py @@ -4,6 +4,7 @@ from unittest.mock import patch from requests.models import HTTPError +from homeassistant.components.recorder import Recorder from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -17,7 +18,9 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -async def test_loading_sensors(hass: HomeAssistant, init_integration) -> None: +async def test_loading_sensors( + recorder_mock: Recorder, hass: HomeAssistant, init_integration +) -> None: """Test the srp energy sensors.""" # Validate the Config Entry was initialized assert init_integration.state is ConfigEntryState.LOADED @@ -26,10 +29,12 @@ async def test_loading_sensors(hass: HomeAssistant, init_integration) -> None: assert len(hass.states.async_all()) == 1 -async def test_srp_entity(hass: HomeAssistant, init_integration) -> None: +async def test_srp_entity( + recorder_mock: Recorder, hass: HomeAssistant, init_integration +) -> None: """Test the SrpEntity.""" usage_state = hass.states.get("sensor.srp_energy_mock_title_energy_usage") - assert usage_state.state == "150.8" + assert usage_state.state == "67.4" # Validate attributions assert ( @@ -45,6 +50,7 @@ async def test_srp_entity(hass: HomeAssistant, init_integration) -> None: async def test_srp_entity_update_failed( + recorder_mock: Recorder, hass: HomeAssistant, mock_config_entry: MockConfigEntry, ) -> None: @@ -66,6 +72,7 @@ async def test_srp_entity_update_failed( async def test_srp_entity_timeout( + recorder_mock: Recorder, hass: HomeAssistant, mock_config_entry: MockConfigEntry, ) -> None: