1
0
mirror of https://github.com/home-assistant/core.git synced 2026-06-29 18:56:05 +01:00

Populate hourly statistics in srp_energy (#167371)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Signed-off-by: Branden Cash <203336+ammmze@users.noreply.github.com>
This commit is contained in:
Branden Cash
2026-06-22 14:51:33 -07:00
committed by GitHub
parent 158595464a
commit c519b7ba07
11 changed files with 1455 additions and 99 deletions
Generated
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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"
@@ -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
@@ -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",
+147 -61
View File
@@ -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),
]
+37 -6
View File
@@ -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,
@@ -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,
}),
]),
})
# ---
@@ -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,
@@ -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"},
)
+7 -2
View File
@@ -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
+10 -3
View File
@@ -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: