|
2 | 2 |
|
3 | 3 | import asyncio |
4 | 4 | import logging |
5 | | -from datetime import timedelta |
| 5 | +from datetime import date, datetime, time, timedelta |
6 | 6 | from zoneinfo import ZoneInfo |
7 | 7 |
|
8 | 8 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed |
9 | 9 | from homeassistant.components.recorder.statistics import ( |
10 | 10 | async_add_external_statistics, |
11 | 11 | StatisticData, |
12 | 12 | StatisticMetaData, |
13 | | - get_last_statistics, |
| 13 | + statistics_during_period, |
14 | 14 | ) |
15 | 15 | from homeassistant.core import HomeAssistant |
16 | 16 | from homeassistant.components import recorder |
|
26 | 26 | SCAN_INTERVAL = timedelta(seconds=1200) |
27 | 27 | # Anything more than 15 days may cause Cloudflare to block all of our requests. |
28 | 28 | HOURLY_USAGE_BACKFILL_DAYS = 15 |
| 29 | +HOURLY_USAGE_START_HOUR = 4 |
29 | 30 |
|
30 | 31 | _LOGGER: logging.Logger = logging.getLogger(__package__) |
31 | 32 |
|
32 | 33 |
|
| 34 | +def _fpl_read_time(read_time: datetime) -> datetime: |
| 35 | + """Return an FPL reading time in FPL's timezone.""" |
| 36 | + if read_time.tzinfo is None: |
| 37 | + return read_time.replace(tzinfo=FPL_TIMEZONE) |
| 38 | + return read_time.astimezone(FPL_TIMEZONE) |
| 39 | + |
| 40 | + |
| 41 | +def _is_hourly_day_complete(hourly: list, target_date: date) -> bool: |
| 42 | + """Return whether hourly data contains the day's closing interval.""" |
| 43 | + expected_end = datetime.combine( |
| 44 | + target_date + timedelta(days=1), time.min, FPL_TIMEZONE |
| 45 | + ) |
| 46 | + return any( |
| 47 | + (read_time := hour.get("readTime")) is not None |
| 48 | + and _fpl_read_time(read_time) == expected_end |
| 49 | + for hour in hourly |
| 50 | + ) |
| 51 | + |
| 52 | + |
33 | 53 | class FplDataUpdateCoordinator(DataUpdateCoordinator): |
34 | 54 | """Class to manage fetching data from the API.""" |
35 | 55 |
|
36 | 56 | def __init__(self, hass: HomeAssistant, client: FplApi) -> None: |
37 | 57 | """Initialize.""" |
38 | 58 | self.api = client |
39 | 59 | self.platforms = [] |
| 60 | + self._hourly_backfill_pending = True |
| 61 | + self._finalized_hourly_dates: set[tuple[str, date]] = set() |
40 | 62 |
|
41 | 63 | super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) |
42 | 64 |
|
43 | | - async def _get_last_sum(self, stat_id: str): |
| 65 | + async def _get_sum_before(self, stat_id: str, start: datetime) -> float: |
44 | 66 | def _read(): |
45 | | - return get_last_statistics( |
46 | | - hass=self.hass, |
47 | | - number_of_stats=1, |
48 | | - statistic_id=stat_id, |
49 | | - convert_units=False, |
| 67 | + return statistics_during_period( |
| 68 | + self.hass, |
| 69 | + start - timedelta(hours=1), |
| 70 | + start, |
| 71 | + {stat_id}, |
| 72 | + "hour", |
| 73 | + None, |
50 | 74 | types={"sum"}, |
51 | 75 | ) |
52 | 76 |
|
53 | 77 | result = await recorder.get_instance(self.hass).async_add_executor_job(_read) |
54 | 78 |
|
55 | 79 | if rows := result.get(stat_id): |
56 | | - return float(rows[0]["sum"] or 0.0), dt_util.utc_from_timestamp( |
57 | | - rows[0]["start"] |
58 | | - ) |
59 | | - return 0.0, None |
| 80 | + return float(rows[-1]["sum"] or 0.0) |
| 81 | + return 0.0 |
60 | 82 |
|
61 | 83 | async def _publish_hourly_statistics(self, account: str, hourly: list) -> None: |
62 | 84 | stat_id_usage = f"{DOMAIN}:{account}_hourly_usage" |
63 | 85 | stat_id_cost = f"{DOMAIN}:{account}_hourly_cost" |
64 | 86 |
|
65 | | - usage_sum, last_usage_start = await self._get_last_sum(stat_id_usage) |
66 | | - cost_sum, last_cost_start = await self._get_last_sum(stat_id_cost) |
| 87 | + normalized = [] |
| 88 | + for hour in hourly: |
| 89 | + read_time = hour.get("readTime") |
| 90 | + if read_time is None: |
| 91 | + continue |
| 92 | + read_time_utc = _fpl_read_time(read_time).astimezone(dt_util.UTC) |
| 93 | + read_time_utc = read_time_utc.replace(minute=0, second=0, microsecond=0) |
| 94 | + normalized.append((read_time_utc - timedelta(hours=1), hour)) |
| 95 | + |
| 96 | + if not normalized: |
| 97 | + return |
| 98 | + |
| 99 | + normalized.sort(key=lambda item: item[0]) |
| 100 | + first_start = normalized[0][0] |
| 101 | + usage_sum = await self._get_sum_before(stat_id_usage, first_start) |
| 102 | + cost_sum = await self._get_sum_before(stat_id_cost, first_start) |
67 | 103 |
|
68 | 104 | cost_stats = [] |
69 | 105 | usage_stats = [] |
70 | | - for h in sorted(hourly, key=lambda x: x.get("readTime")): |
| 106 | + for start, h in normalized: |
71 | 107 | cost = h.get("billingCharged") |
72 | 108 | usage = h.get("kwhActual") |
73 | 109 |
|
74 | | - read_time = h.get("readTime") |
75 | | - if read_time is None: |
76 | | - continue |
77 | | - |
78 | | - # Ensure read_time is timezone-aware in FPL's timezone (Eastern) |
79 | | - if read_time.tzinfo is None: |
80 | | - read_time = read_time.replace(tzinfo=FPL_TIMEZONE) |
81 | | - |
82 | | - # Convert to UTC for Home Assistant statistics |
83 | | - read_time_utc = read_time.astimezone(dt_util.UTC) |
84 | | - read_time_utc = read_time_utc.replace(minute=0, second=0, microsecond=0) |
85 | | - start = read_time_utc - timedelta(hours=1) |
86 | | - |
87 | 110 | if cost is not None: |
88 | | - if not last_cost_start or start > last_cost_start: |
89 | | - cost_sum += cost |
90 | | - cost_stat = StatisticData( |
91 | | - start=start, |
92 | | - sum=cost_sum, |
93 | | - state=cost, |
94 | | - ) |
95 | | - cost_stats.append(cost_stat) |
| 111 | + cost_sum += cost |
| 112 | + cost_stats.append(StatisticData(start=start, sum=cost_sum, state=cost)) |
96 | 113 |
|
97 | 114 | if usage is not None: |
98 | | - if not last_usage_start or start > last_usage_start: |
99 | | - usage_sum += usage |
100 | | - usage_stat = StatisticData( |
101 | | - start=start, |
102 | | - sum=usage_sum, |
103 | | - state=usage, |
104 | | - ) |
105 | | - usage_stats.append(usage_stat) |
| 115 | + usage_sum += usage |
| 116 | + usage_stats.append( |
| 117 | + StatisticData(start=start, sum=usage_sum, state=usage) |
| 118 | + ) |
106 | 119 |
|
107 | 120 | if cost_stats: |
108 | 121 | metadata = StatisticMetaData( |
@@ -133,35 +146,42 @@ async def _publish_hourly_statistics(self, account: str, hourly: list) -> None: |
133 | 146 | async def _async_update_data(self): |
134 | 147 | try: |
135 | 148 | data = await self.api.async_get_data() |
| 149 | + now = dt_util.now().astimezone(FPL_TIMEZONE) |
| 150 | + if now.hour < HOURLY_USAGE_START_HOUR: |
| 151 | + return data |
| 152 | + |
| 153 | + yesterday = now.date() - timedelta(days=1) |
| 154 | + if self._hourly_backfill_pending: |
| 155 | + target_dates = [ |
| 156 | + now.date() - timedelta(days=offset) |
| 157 | + for offset in range(HOURLY_USAGE_BACKFILL_DAYS, 0, -1) |
| 158 | + ] |
| 159 | + else: |
| 160 | + target_dates = [yesterday] |
136 | 161 |
|
137 | | - # Backfill hourly cost for accounts |
138 | 162 | for account in data.get(CONF_ACCOUNTS, []): |
139 | 163 | premise = data.get(account, {}).get("premise") |
140 | | - # If there is already hourly usage statistics, then only backfill the yesterday. |
141 | | - _, last_sum_start = await self._get_last_sum( |
142 | | - f"{DOMAIN}:{account}_hourly_usage" |
143 | | - ) |
144 | | - if last_sum_start is not None: |
145 | | - date = dt_util.now() - timedelta(days=1) |
| 164 | + complete_dates = [] |
| 165 | + all_hourly = [] |
| 166 | + for target_date in target_dates: |
| 167 | + if (account, target_date) in self._finalized_hourly_dates: |
| 168 | + continue |
146 | 169 | hourly = await self.api.apiClient.get_hourly_usage( |
147 | | - account, premise, date |
| 170 | + account, premise, target_date |
148 | 171 | ) |
149 | | - await self._publish_hourly_statistics(account, hourly) |
150 | | - else: |
151 | | - # Only backfill the full amount of days if the account has no hourly usage statistics. |
152 | | - # We need to start backwards. For example today - 360 days. |
153 | | - date = dt_util.now() - timedelta(days=HOURLY_USAGE_BACKFILL_DAYS) |
154 | | - |
155 | | - all_hourly: list = [] |
156 | | - for _ in range(HOURLY_USAGE_BACKFILL_DAYS): |
157 | | - hourly = await self.api.apiClient.get_hourly_usage( |
158 | | - account, premise, date |
159 | | - ) |
| 172 | + if _is_hourly_day_complete(hourly, target_date): |
160 | 173 | all_hourly.extend(hourly) |
161 | | - date = date + timedelta(days=1) |
| 174 | + complete_dates.append(target_date) |
| 175 | + if len(target_dates) > 1: |
162 | 176 | await asyncio.sleep(1) |
163 | | - if all_hourly: |
164 | | - await self._publish_hourly_statistics(account, all_hourly) |
| 177 | + |
| 178 | + if all_hourly: |
| 179 | + await self._publish_hourly_statistics(account, all_hourly) |
| 180 | + self._finalized_hourly_dates.update( |
| 181 | + (account, target_date) for target_date in complete_dates |
| 182 | + ) |
| 183 | + |
| 184 | + self._hourly_backfill_pending = False |
165 | 185 |
|
166 | 186 | return data |
167 | 187 | except Exception as exception: |
|
0 commit comments