Skip to content

Commit 137bfd4

Browse files
Merge pull request #87 from dotKrad/fix/85-finalize-hourly-usage
2 parents 5f6ac7d + 4b6ad36 commit 137bfd4

4 files changed

Lines changed: 270 additions & 67 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11

2+
__pycache__/
3+
24
custom_components/fpl/test.py
35

46
custom_components/fpl/__pycache__/

custom_components/fpl/FplMainRegionApiClient.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,7 @@ async def get_account_details(self, account_number: str) -> dict:
614614

615615
return data
616616

617-
except Exception as e:
617+
except Exception:
618618
_LOGGER.error(
619619
"Failed to get account details for %s", account_number, exc_info=True
620620
)

custom_components/fpl/fplDataUpdateCoordinator.py

Lines changed: 86 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22

33
import asyncio
44
import logging
5-
from datetime import timedelta
5+
from datetime import date, datetime, time, timedelta
66
from zoneinfo import ZoneInfo
77

88
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
99
from homeassistant.components.recorder.statistics import (
1010
async_add_external_statistics,
1111
StatisticData,
1212
StatisticMetaData,
13-
get_last_statistics,
13+
statistics_during_period,
1414
)
1515
from homeassistant.core import HomeAssistant
1616
from homeassistant.components import recorder
@@ -26,83 +26,96 @@
2626
SCAN_INTERVAL = timedelta(seconds=1200)
2727
# Anything more than 15 days may cause Cloudflare to block all of our requests.
2828
HOURLY_USAGE_BACKFILL_DAYS = 15
29+
HOURLY_USAGE_START_HOUR = 4
2930

3031
_LOGGER: logging.Logger = logging.getLogger(__package__)
3132

3233

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+
3353
class FplDataUpdateCoordinator(DataUpdateCoordinator):
3454
"""Class to manage fetching data from the API."""
3555

3656
def __init__(self, hass: HomeAssistant, client: FplApi) -> None:
3757
"""Initialize."""
3858
self.api = client
3959
self.platforms = []
60+
self._hourly_backfill_pending = True
61+
self._finalized_hourly_dates: set[tuple[str, date]] = set()
4062

4163
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL)
4264

43-
async def _get_last_sum(self, stat_id: str):
65+
async def _get_sum_before(self, stat_id: str, start: datetime) -> float:
4466
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,
5074
types={"sum"},
5175
)
5276

5377
result = await recorder.get_instance(self.hass).async_add_executor_job(_read)
5478

5579
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
6082

6183
async def _publish_hourly_statistics(self, account: str, hourly: list) -> None:
6284
stat_id_usage = f"{DOMAIN}:{account}_hourly_usage"
6385
stat_id_cost = f"{DOMAIN}:{account}_hourly_cost"
6486

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)
67103

68104
cost_stats = []
69105
usage_stats = []
70-
for h in sorted(hourly, key=lambda x: x.get("readTime")):
106+
for start, h in normalized:
71107
cost = h.get("billingCharged")
72108
usage = h.get("kwhActual")
73109

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-
87110
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))
96113

97114
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+
)
106119

107120
if cost_stats:
108121
metadata = StatisticMetaData(
@@ -133,35 +146,42 @@ async def _publish_hourly_statistics(self, account: str, hourly: list) -> None:
133146
async def _async_update_data(self):
134147
try:
135148
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]
136161

137-
# Backfill hourly cost for accounts
138162
for account in data.get(CONF_ACCOUNTS, []):
139163
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
146169
hourly = await self.api.apiClient.get_hourly_usage(
147-
account, premise, date
170+
account, premise, target_date
148171
)
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):
160173
all_hourly.extend(hourly)
161-
date = date + timedelta(days=1)
174+
complete_dates.append(target_date)
175+
if len(target_dates) > 1:
162176
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
165185

166186
return data
167187
except Exception as exception:

0 commit comments

Comments
 (0)