Skip to content

Commit 6adae5e

Browse files
NirBYGuyKh
andauthored
fix: handle asyncio.CancelledError during tariff fetch to avoid setup… (#280)
* fix: handle asyncio.CancelledError during tariff fetch to avoid setup failure (iec-api aiohttp)\n\n- Catch CancelledError in _get_kwh_tariff and _get_kva_tariff\n- Fallback to 0.0 and warn instead of failing setup\n\nRefs: #278 * fix(config): map asyncio.CancelledError to cannot_connect in config flow; add broad handling around MFA and login\n\nfeat(coordinator): add fallback kWh/kVA tariffs from calculators API and handle CancelledError for customer/contracts fetch; return {} on cancelled refresh instead of failing\n\nAlso logs clearer messages; aligns with HA UX to avoid 'Unknown error occurred'.\n\nRefs: #278 --------- Co-authored-by: Guy Khmelnitsky <3136012+GuyKh@users.noreply.github.com>
1 parent c8b61fa commit 6adae5e

File tree

2 files changed

+208
-40
lines changed

2 files changed

+208
-40
lines changed

custom_components/iec/config_flow.py

Lines changed: 85 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import logging
6+
import asyncio
67
from collections.abc import Mapping
78
from typing import Any
89

@@ -39,28 +40,34 @@ async def _validate_login(
3940
hass: HomeAssistant, login_data: dict[str, Any], api: IecClient
4041
) -> dict[str, str]:
4142
"""Validate login data and return any errors."""
42-
assert login_data is not None
43-
assert api is not None
44-
assert login_data.get(CONF_USER_ID) is not None
45-
assert (
46-
login_data.get(CONF_TOTP_SECRET) or login_data.get(CONF_API_TOKEN) is not None
47-
)
43+
if not login_data or not api:
44+
return {"base": "cannot_connect"}
45+
if not login_data.get(CONF_USER_ID):
46+
return {"base": "invalid_auth"}
47+
if not (login_data.get(CONF_TOTP_SECRET) or login_data.get(CONF_API_TOKEN)):
48+
return {"base": "invalid_auth"}
4849

4950
if login_data.get(CONF_TOTP_SECRET):
5051
try:
5152
await api.verify_otp(login_data.get(CONF_TOTP_SECRET))
53+
except asyncio.CancelledError:
54+
return {"base": "cannot_connect"}
5255
except IECError:
5356
return {"base": "invalid_auth"}
5457

5558
elif login_data.get(CONF_API_TOKEN):
5659
try:
5760
await api.load_jwt_token(JWT.from_dict(login_data.get(CONF_API_TOKEN)))
61+
except asyncio.CancelledError:
62+
return {"base": "cannot_connect"}
5863
except IECError:
5964
return {"base": "invalid_auth"}
6065

6166
errors: dict[str, str] = {}
6267
try:
6368
await api.check_token()
69+
except asyncio.CancelledError:
70+
errors["base"] = "cannot_connect"
6471
except IECError:
6572
errors["base"] = "invalid_auth"
6673

@@ -112,47 +119,78 @@ async def async_step_mfa(
112119
self, user_input: dict[str, Any] | None = None
113120
) -> FlowResult:
114121
"""Handle MFA step."""
115-
assert self.data is not None
116-
assert self.data.get(CONF_USER_ID) is not None
122+
if not self.data or not self.data.get(CONF_USER_ID):
123+
return self.async_show_form(
124+
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors={"base": "invalid_auth"}
125+
)
117126

118127
client: IecClient = self.client
119128

120129
errors: dict[str, str] = {}
121130
if user_input is not None and user_input.get(CONF_TOTP_SECRET) is not None:
122-
data = {**self.data, **user_input}
123-
errors = await _validate_login(self.hass, data, client)
124-
if not errors:
125-
data[CONF_API_TOKEN] = client.get_token().to_dict()
126-
127-
if data.get(CONF_TOTP_SECRET):
128-
data.pop(CONF_TOTP_SECRET)
129-
130-
customer = await client.get_customer()
131-
data[CONF_BP_NUMBER] = customer.bp_number
132-
133-
contracts = await client.get_contracts(customer.bp_number)
134-
contract_ids = [
135-
int(contract.contract_id)
136-
for contract in contracts
137-
if contract.status == 1
138-
]
139-
if len(contract_ids) == 0:
140-
errors["base"] = "no_active_contracts"
141-
elif len(contract_ids) == 1:
142-
data[CONF_SELECTED_CONTRACTS] = [contract_ids[0]]
143-
return self._async_create_iec_entry(data)
144-
else:
145-
data[CONF_AVAILABLE_CONTRACTS] = contract_ids
146-
self.data = data
147-
return await self.async_step_select_contracts()
131+
try:
132+
data = {**self.data, **user_input}
133+
errors = await _validate_login(self.hass, data, client)
134+
if not errors:
135+
data[CONF_API_TOKEN] = client.get_token().to_dict()
136+
137+
if data.get(CONF_TOTP_SECRET):
138+
data.pop(CONF_TOTP_SECRET)
139+
140+
try:
141+
customer = await client.get_customer()
142+
data[CONF_BP_NUMBER] = customer.bp_number
143+
144+
contracts = await client.get_contracts(customer.bp_number)
145+
contract_ids = [
146+
int(contract.contract_id)
147+
for contract in contracts
148+
if contract.status == 1
149+
]
150+
except asyncio.CancelledError:
151+
errors["base"] = "cannot_connect"
152+
except IECError:
153+
errors["base"] = "cannot_connect"
154+
except Exception as err: # noqa: BLE001
155+
_LOGGER.exception("Unexpected error during contracts fetch: %s", err)
156+
errors["base"] = "cannot_connect"
157+
158+
if not errors:
159+
if len(contract_ids) == 0:
160+
errors["base"] = "no_active_contracts"
161+
elif len(contract_ids) == 1:
162+
data[CONF_SELECTED_CONTRACTS] = [contract_ids[0]]
163+
return self._async_create_iec_entry(data)
164+
else:
165+
data[CONF_AVAILABLE_CONTRACTS] = contract_ids
166+
self.data = data
167+
return await self.async_step_select_contracts()
168+
except asyncio.CancelledError:
169+
errors["base"] = "cannot_connect"
170+
except IECError:
171+
errors["base"] = "cannot_connect"
172+
except Exception as err: # noqa: BLE001
173+
_LOGGER.exception("Unexpected error during MFA step: %s", err)
174+
errors["base"] = "cannot_connect"
148175

149176
if errors:
150177
schema = {vol.Required(CONF_USER_ID, default=self.data[CONF_USER_ID]): str}
151178
else:
152179
schema = {}
153180

154181
schema[vol.Required(CONF_TOTP_SECRET)] = str
155-
otp_type = await client.login_with_id()
182+
try:
183+
otp_type = await client.login_with_id()
184+
except asyncio.CancelledError:
185+
errors["base"] = errors.get("base") or "cannot_connect"
186+
otp_type = "OTP"
187+
except IECError:
188+
errors["base"] = errors.get("base") or "cannot_connect"
189+
otp_type = "OTP"
190+
except Exception as err: # noqa: BLE001
191+
_LOGGER.exception("Unexpected error during login_with_id: %s", err)
192+
errors["base"] = errors.get("base") or "cannot_connect"
193+
otp_type = "OTP"
156194

157195
return self.async_show_form(
158196
step_id="mfa",
@@ -243,7 +281,18 @@ async def async_step_reauth_confirm(
243281
)
244282
client = self.client
245283

246-
otp_type = await client.login_with_id()
284+
try:
285+
otp_type = await client.login_with_id()
286+
except asyncio.CancelledError:
287+
errors["base"] = errors.get("base") or "cannot_connect"
288+
otp_type = "OTP"
289+
except IECError:
290+
errors["base"] = errors.get("base") or "cannot_connect"
291+
otp_type = "OTP"
292+
except Exception as err: # noqa: BLE001
293+
_LOGGER.exception("Unexpected error during reauth login_with_id: %s", err)
294+
errors["base"] = errors.get("base") or "cannot_connect"
295+
otp_type = "OTP"
247296

248297
schema = {
249298
vol.Required(CONF_USER_ID): self.reauth_entry.data[CONF_USER_ID],

custom_components/iec/coordinator.py

Lines changed: 123 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Coordinator to handle IEC connections."""
22

33
import calendar
4+
import asyncio
45
import itertools
56
import jwt
67
import logging
@@ -189,20 +190,104 @@ async def _get_kwh_tariff(self) -> float:
189190
if not self._kwh_tariff:
190191
try:
191192
self._kwh_tariff = await self.api.get_kwh_tariff()
193+
except asyncio.CancelledError:
194+
_LOGGER.warning(
195+
"Fetching kWh tariff was cancelled; using 0.0 and continuing"
196+
)
197+
self._kwh_tariff = 0.0
192198
except IECError as e:
193199
_LOGGER.exception("Failed fetching kWh Tariff", e)
200+
except Exception as e:
201+
_LOGGER.exception("Unexpected error fetching kWh Tariff", e)
202+
203+
# Fallback: try IEC calculators API when main call failed or returned 0.0
204+
if not self._kwh_tariff or self._kwh_tariff == 0.0:
205+
kwh_fallback, _ = await self._fetch_tariffs_from_calculators()
206+
if kwh_fallback and kwh_fallback > 0:
207+
_LOGGER.debug("Using fallback kWh tariff from calculators API: %s", kwh_fallback)
208+
self._kwh_tariff = kwh_fallback
194209
return self._kwh_tariff or 0.0
195210

196211
async def _get_kva_tariff(self) -> float:
197212
if not self._kva_tariff:
198213
try:
199214
self._kva_tariff = await self.api.get_kva_tariff()
215+
except asyncio.CancelledError:
216+
_LOGGER.warning(
217+
"Fetching kVA tariff was cancelled; using 0.0 and continuing"
218+
)
219+
self._kva_tariff = 0.0
200220
except IECError as e:
201221
_LOGGER.exception("Failed fetching KVA Tariff from IEC API", e)
202222
except Exception as e:
203-
_LOGGER.exception("Failed fetching KVA Tariff", e)
223+
_LOGGER.exception("Unexpected error fetching KVA Tariff", e)
224+
225+
# Fallback: try IEC calculators API when main call failed or returned 0.0
226+
if not self._kva_tariff or self._kva_tariff == 0.0:
227+
_, kva_fallback = await self._fetch_tariffs_from_calculators()
228+
if kva_fallback and kva_fallback > 0:
229+
_LOGGER.debug("Using fallback kVA tariff from calculators API: %s", kva_fallback)
230+
self._kva_tariff = kva_fallback
204231
return self._kva_tariff or 0.0
205232

233+
async def _fetch_tariffs_from_calculators(self) -> tuple[float | None, float | None]:
234+
"""Fetch tariffs from IEC calculators endpoints as a fallback.
235+
236+
Returns: tuple of (kwh_home_rate, kva_rate), each may be None if not found.
237+
"""
238+
session = aiohttp_client.async_get_clientsession(self.hass, family=socket.AF_INET)
239+
kwh_tariff: float | None = None
240+
kva_tariff: float | None = None
241+
242+
# Primary fallback: calculators/period (contains both homeRate and kvaRate)
243+
try:
244+
async with session.get(
245+
"https://iecapi.iec.co.il/api/content/he-IL/calculators/period",
246+
timeout=30,
247+
) as resp:
248+
if resp.status == 200:
249+
data = await resp.json(content_type=None)
250+
rates = data.get("period_Calculator_Rates") or {}
251+
kwh_val = rates.get("homeRate")
252+
kva_val = rates.get("kvaRate")
253+
if isinstance(kwh_val, (int, float)):
254+
kwh_tariff = float(kwh_val)
255+
if isinstance(kva_val, (int, float)):
256+
kva_tariff = float(kva_val)
257+
_LOGGER.debug(
258+
"Fetched fallback tariffs from calculators/period: homeRate=%s, kvaRate=%s",
259+
kwh_tariff,
260+
kva_tariff,
261+
)
262+
except asyncio.CancelledError:
263+
_LOGGER.debug("Fallback calculators/period fetch was cancelled")
264+
except Exception as err: # noqa: BLE001
265+
_LOGGER.debug("Failed fetching fallback tariffs from calculators/period: %s", err)
266+
267+
# Secondary fallback: calculators/gadget (has homeRate only)
268+
if kwh_tariff is None:
269+
try:
270+
async with session.get(
271+
"https://iecapi.iec.co.il/api/content/he-IL/calculators/gadget",
272+
timeout=30,
273+
) as resp:
274+
if resp.status == 200:
275+
data = await resp.json(content_type=None)
276+
rates = data.get("gadget_Calculator_Rates") or {}
277+
kwh_val = rates.get("homeRate")
278+
if isinstance(kwh_val, (int, float)):
279+
kwh_tariff = float(kwh_val)
280+
_LOGGER.debug(
281+
"Fetched fallback kWh tariff from calculators/gadget: homeRate=%s",
282+
kwh_tariff,
283+
)
284+
except asyncio.CancelledError:
285+
_LOGGER.debug("Fallback calculators/gadget fetch was cancelled")
286+
except Exception as err: # noqa: BLE001
287+
_LOGGER.debug("Failed fetching fallback kWh tariff from calculators/gadget: %s", err)
288+
289+
return kwh_tariff, kva_tariff
290+
206291
async def _get_delivery_tariff(self, phase) -> float:
207292
delivery_tariff = self._delivery_tariff_by_phase.get(phase)
208293
if not delivery_tariff:
@@ -373,10 +458,32 @@ async def _update_data(
373458
self,
374459
) -> dict[str, dict[str, Any]]:
375460
if not self._bp_number:
376-
customer = await self.api.get_customer()
377-
self._bp_number = customer.bp_number
461+
try:
462+
customer = await self.api.get_customer()
463+
self._bp_number = customer.bp_number
464+
except asyncio.CancelledError:
465+
_LOGGER.warning(
466+
"Fetching customer was cancelled; using empty BP number and skipping contracts"
467+
)
468+
self._bp_number = None
469+
except IECError as e:
470+
_LOGGER.exception("Failed fetching customer", e)
471+
self._bp_number = None
378472

379-
all_contracts: list[Contract] = await self.api.get_contracts(self._bp_number)
473+
try:
474+
all_contracts: list[Contract] = (
475+
await self.api.get_contracts(self._bp_number)
476+
if self._bp_number
477+
else []
478+
)
479+
except asyncio.CancelledError:
480+
_LOGGER.warning(
481+
"Fetching contracts was cancelled; continuing with empty contracts"
482+
)
483+
all_contracts = []
484+
except IECError as e:
485+
_LOGGER.exception("Failed fetching contracts", e)
486+
all_contracts = []
380487
if not self._contract_ids:
381488
self._contract_ids = [
382489
int(contract.contract_id)
@@ -428,6 +535,11 @@ async def _update_data(
428535
billing_invoices = await self.api.get_billing_invoices(
429536
self._bp_number, contract_id
430537
)
538+
except asyncio.CancelledError:
539+
_LOGGER.warning(
540+
"Fetching invoices was cancelled; continuing without invoices"
541+
)
542+
billing_invoices = None
431543
except IECError as e:
432544
_LOGGER.exception("Failed fetching invoices", e)
433545
billing_invoices = None
@@ -669,6 +781,13 @@ async def _async_update_data(
669781

670782
try:
671783
return await self._update_data()
784+
except asyncio.CancelledError as err:
785+
_LOGGER.warning(
786+
"Data update was cancelled (network timeout/cancelled); will retry later: %s",
787+
err,
788+
)
789+
# Return empty data so setup doesn't fail; periodic refresh will try again
790+
return {}
672791
except Exception as err:
673792
_LOGGER.error("Failed updating data. Exception: %s", err)
674793
_LOGGER.error(traceback.format_exc())

0 commit comments

Comments
 (0)