Skip to content

Commit 220a39d

Browse files
authored
fix: stabilize forecast usage/state handling and coordinator shutdown (#328)
- add fallback logic for forecasted usage sensor when future consumption data is missing - fix coordinator lifecycle cleanup and timezone handling in statistics fetching - cap stale statistics backfill window and improve forecast failure behavior - add typecheck script and missing translation label
1 parent 3810c43 commit 220a39d

File tree

5 files changed

+68
-13
lines changed

5 files changed

+68
-13
lines changed

AGENTS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,13 @@ When iec-api updates:
175175

176176
## Validation
177177

178+
**After every code change, run:**
179+
```bash
180+
ruff check .
181+
ruff format .
182+
./scripts/typecheck # Run mypy type checker
183+
```
184+
178185
**Before committing, run:**
179186
```bash
180187
./scripts/lint # Auto-format and fix linting issues

custom_components/iec/coordinator.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ def __init__(
9191
super().__init__(
9292
hass,
9393
_LOGGER,
94+
config_entry=config_entry,
9495
name="Iec",
9596
# Data is updated daily on IEC.
9697
# Refresh every 1h to be at most 5h behind.
@@ -127,10 +128,14 @@ def _dummy_listener() -> None:
127128
# Needed when the _async_update_data below returns {} for utilities that don't provide
128129
# forecast, which results to no sensors added, no registered listeners, and thus
129130
# _async_update_data not periodically getting called which is needed for _insert_statistics.
130-
self.async_add_listener(_dummy_listener)
131+
self._dummy_listener_unsub = self.async_add_listener(_dummy_listener)
131132

132133
async def async_unload(self):
133134
"""Unload the coordinator, cancel any pending tasks."""
135+
if self._dummy_listener_unsub is not None:
136+
self._dummy_listener_unsub()
137+
self._dummy_listener_unsub = None
138+
await self.async_shutdown()
134139
_LOGGER.info("Coordinator unloaded successfully.")
135140

136141
async def _get_devices_by_contract_id(self, contract_id) -> list[Device]:
@@ -876,13 +881,18 @@ async def _insert_statistics(self, contract_id: int, is_smart_meter: bool) -> No
876881
ReadingResolution.MONTHLY,
877882
)
878883

879-
if readings and readings.meter_start_date:
884+
if (
885+
readings
886+
and readings.meter_list
887+
and readings.meter_list[0].meter_start_date
888+
):
880889
# Fetching the last reading from either the installation date or a month ago
881890
month_ago_time = max(
882891
month_ago_time,
883892
localize_datetime(
884893
datetime.combine(
885-
readings.meter_start_date, datetime.min.time()
894+
readings.meter_list[0].meter_start_date,
895+
datetime.min.time(),
886896
)
887897
),
888898
)
@@ -907,7 +917,7 @@ async def _insert_statistics(self, contract_id: int, is_smart_meter: bool) -> No
907917
else:
908918
last_stat_time = last_stat[consumption_statistic_id][0]["start"]
909919
# API returns daily data, so need to increase the start date by 4 hrs to get the next day
910-
from_date = datetime.fromtimestamp(last_stat_time)
920+
from_date = localize_datetime(datetime.fromtimestamp(last_stat_time))
911921
_LOGGER.debug(
912922
f"[IEC Statistics] Last statistics are from {from_date.strftime('%Y-%m-%d %H:%M:%S')}"
913923
)
@@ -923,6 +933,16 @@ async def _insert_statistics(self, contract_id: int, is_smart_meter: bool) -> No
923933
hour=1, minute=0, second=0, microsecond=0
924934
)
925935

936+
min_from_date = (localized_today - timedelta(days=30)).replace(
937+
hour=1, minute=0, second=0, microsecond=0
938+
)
939+
if from_date < min_from_date:
940+
_LOGGER.debug(
941+
"[IEC Statistics] Last statistics are too old, limiting fetch window to %s",
942+
min_from_date.strftime("%Y-%m-%d %H:%M:%S"),
943+
)
944+
from_date = min_from_date
945+
926946
_LOGGER.debug(
927947
f"[IEC Statistics] Fetching consumption from {from_date.strftime('%Y-%m-%d %H:%M:%S')}"
928948
)
@@ -949,7 +969,7 @@ async def _insert_statistics(self, contract_id: int, is_smart_meter: bool) -> No
949969
continue
950970

951971
last_stat_hour = (
952-
datetime.fromtimestamp(last_stat_time)
972+
localize_datetime(datetime.fromtimestamp(last_stat_time))
953973
if last_stat_time
954974
else readings.meter_list[0].period_consumptions[0].interval
955975
)
@@ -1214,10 +1234,10 @@ def _calculate_estimated_bill(
12141234
)
12151235
else:
12161236
_LOGGER.warn(
1217-
f"Failed to calculate Future Consumption, Assuming last meter read \
1218-
({last_meter_read}) as full consumption"
1237+
f"Failed to calculate Future Consumption for meter {meter_id} "
1238+
f"(missing total_import), defaulting forecasted consumption to 0"
12191239
)
1220-
future_consumption = last_meter_read
1240+
future_consumption = 0
12211241

12221242
kva_price = power_size * kva_tariff / 365
12231243

custom_components/iec/sensor.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -264,14 +264,27 @@ def _get_reading_by_date(
264264
state_class=SensorStateClass.TOTAL_INCREASING,
265265
suggested_display_precision=3,
266266
value_fn=lambda data: (
267-
(
268-
data[FUTURE_CONSUMPTIONS_DICT_NAME][
267+
data[FUTURE_CONSUMPTIONS_DICT_NAME][
268+
data[ATTRIBUTES_DICT_NAME][METER_ID_ATTR_NAME]
269+
].total_import
270+
if (
271+
data[FUTURE_CONSUMPTIONS_DICT_NAME]
272+
and data[FUTURE_CONSUMPTIONS_DICT_NAME].get(
273+
data[ATTRIBUTES_DICT_NAME][METER_ID_ATTR_NAME]
274+
)
275+
and data[FUTURE_CONSUMPTIONS_DICT_NAME][
269276
data[ATTRIBUTES_DICT_NAME][METER_ID_ATTR_NAME]
270277
].total_import
271-
or 0
278+
is not None
279+
)
280+
else (
281+
data[INVOICE_DICT_NAME].meter_readings[0].reading
282+
if (
283+
data[INVOICE_DICT_NAME] != EMPTY_INVOICE
284+
and data[INVOICE_DICT_NAME].meter_readings
285+
)
286+
else None
272287
)
273-
if (data[FUTURE_CONSUMPTIONS_DICT_NAME])
274-
else None
275288
),
276289
),
277290
)

custom_components/iec/translations/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
"access_token_issued_at": {
1313
"name": "Access Token Issued At"
1414
},
15+
"elec_forecasted_usage": {
16+
"name": "Next electric bill forecasted usage {multi_contract}"
17+
},
1518
"elec_forecasted_cost": {
1619
"name": "Next electric bill forecasted cost {multi_contract}",
1720
"state_attributes": {

scripts/typecheck

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#!/usr/bin/env bash
2+
3+
set -e
4+
5+
cd "$(dirname "$0")/.."
6+
7+
python3 -m mypy \
8+
--cache-dir /tmp/mypy_cache_iec \
9+
--namespace-packages \
10+
--explicit-package-bases \
11+
--disable-error-code=import-untyped \
12+
custom_components/iec

0 commit comments

Comments
 (0)