diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1d7c715..db8dcf3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,7 +19,7 @@ jobs: - name: "Set up Python" uses: actions/setup-python@v6.1.0 with: - python-version: "3.11" + python-version: "3.13" cache: "pip" - name: "Install requirements" diff --git a/custom_components/iec/__init__.py b/custom_components/iec/__init__.py index 1011e84..56990b3 100644 --- a/custom_components/iec/__init__.py +++ b/custom_components/iec/__init__.py @@ -6,6 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from .const import DOMAIN from .coordinator import IecApiCoordinator @@ -23,8 +24,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = iec_coordinator try: await hass.data[DOMAIN][entry.entry_id].async_config_entry_first_refresh() + except ConfigEntryAuthFailed: + raise except Exception as err: - # Log the error but don't fail the setup _LOGGER.error("Failed to fetch initial data: %s", err) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/custom_components/iec/binary_sensor.py b/custom_components/iec/binary_sensor.py index 43723b2..527a4f2 100644 --- a/custom_components/iec/binary_sensor.py +++ b/custom_components/iec/binary_sensor.py @@ -69,7 +69,7 @@ async def async_setup_entry( list( filter( lambda key: key not in (STATICS_DICT_NAME, JWT_DICT_NAME), - list(coordinator.data.keys()), + list(coordinator.data.keys()) if coordinator.data else [], ) ) ) @@ -77,6 +77,13 @@ async def async_setup_entry( ) entities: list[BinarySensorEntity] = [] + + if not coordinator.data: + _LOGGER.warning( + "Coordinator data is not available yet, skipping binary sensor setup" + ) + return + for contract_key in coordinator.data: if contract_key in (STATICS_DICT_NAME, JWT_DICT_NAME): continue diff --git a/custom_components/iec/config_flow.py b/custom_components/iec/config_flow.py index 22df709..19894d8 100644 --- a/custom_components/iec/config_flow.py +++ b/custom_components/iec/config_flow.py @@ -80,8 +80,9 @@ class IecConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 def __init__(self) -> None: - """Initialize a new IECConfigFlow.""" + """Initialize a new IecConfigFlow.""" self.reauth_entry: config_entries.ConfigEntry | None = None + self.reconfigure_entry: config_entries.ConfigEntry | None = None self.data: dict[str, Any] | None = None self.client: IecClient | None = None @@ -309,3 +310,76 @@ async def async_step_reauth_confirm( data_schema=vol.Schema(schema), errors=errors, ) + + async def async_step_reconfigure( + self, entry_data: Mapping[str, Any] | None = None + ) -> FlowResult: + """Handle configuration by reconfigure.""" + self.reconfigure_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + self.data = dict(self.reconfigure_entry.data) + # Clear TOTP secret for re-authentication + self.data.pop(CONF_TOTP_SECRET, None) + return await self.async_step_reconfigure_mfa() + + async def async_step_reconfigure_mfa( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle MFA step for reconfigure.""" + assert self.reconfigure_entry + errors: dict[str, str] = {} + + client: IecClient = self.client + + if user_input and user_input.get(CONF_TOTP_SECRET) is not None: + assert client + # Create data for validation, merging existing data with TOTP_SECRET + validation_data = {**self.reconfigure_entry.data, **user_input} + errors = await _validate_login(self.hass, validation_data, client) + if not errors: + # Only update the token, preserve all other existing data + updated_data = dict(self.reconfigure_entry.data) + updated_data[CONF_API_TOKEN] = client.get_token().to_dict() + # Ensure TOTP_SECRET is not persisted + updated_data.pop(CONF_TOTP_SECRET, None) + + self.hass.config_entries.async_update_entry( + self.reconfigure_entry, data=updated_data + ) + await self.hass.config_entries.async_reload( + self.reconfigure_entry.entry_id + ) + return self.async_abort(reason="reconfigure_successful") + + if not client: + self.client = IecClient( + self.data[CONF_USER_ID], async_create_clientsession(self.hass) + ) + client = self.client + + try: + otp_type = await client.login_with_id() + except asyncio.CancelledError: + errors["base"] = errors.get("base") or "cannot_connect" + otp_type = "OTP" + except IECError: + errors["base"] = errors.get("base") or "cannot_connect" + otp_type = "OTP" + except Exception as err: # noqa: BLE001 + _LOGGER.exception( + "Unexpected error during reconfigure login_with_id: %s", err + ) + errors["base"] = errors.get("base") or "cannot_connect" + otp_type = "OTP" + + schema = { + vol.Required(CONF_TOTP_SECRET): str, + } + + return self.async_show_form( + step_id="reconfigure_mfa", + description_placeholders={"otp_type": otp_type}, + data_schema=vol.Schema(schema), + errors=errors, + ) diff --git a/custom_components/iec/coordinator.py b/custom_components/iec/coordinator.py index 3c81ba7..5333035 100644 --- a/custom_components/iec/coordinator.py +++ b/custom_components/iec/coordinator.py @@ -781,20 +781,67 @@ async def _async_update_data( ) self._first_load = False - try: - _LOGGER.debug("Checking if API token needs to be refreshed") - # First thing first, check the token and refresh if needed. - old_token = self.api.get_token() - await self.api.check_token() - new_token = self.api.get_token() + + def update_token_if_changed(new_token): + """Update config entry if token has changed.""" if old_token != new_token: _LOGGER.debug("Token refreshed") new_data = {**self._entry_data, CONF_API_TOKEN: new_token.to_dict()} self.hass.config_entries.async_update_entry( entry=self._config_entry, data=new_data ) + + try: + _LOGGER.debug("Checking if API token needs to be refreshed") + old_token = self.api.get_token() + await self.api.check_token() + new_token = self.api.get_token() + update_token_if_changed(new_token) + + _LOGGER.debug("Validating token with get_customer API call") + await self.api.get_customer() + except IECError as err: - raise ConfigEntryAuthFailed from err + if err.status == 400: + _LOGGER.error( + "Token validation failed with 400 Bad Request. Attempting refresh and retry." + ) + try: + await self.api.check_token() + new_token_after_retry = self.api.get_token() + update_token_if_changed(new_token_after_retry) + await self.api.get_customer() + except IECError as retry_err: + if retry_err.status == 400: + _LOGGER.error( + "Token refresh and retry failed with 400 Bad Request. Need to reconfigure integration." + ) + entry = self.hass.config_entries.async_get_entry( + self._config_entry.entry_id + ) + if entry: + try: + await self.hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": "reconfigure", + "entry_id": entry.entry_id, + }, + ) + except Exception as reconfig_err: # noqa: BLE001 + _LOGGER.error( + "Failed to start reconfigure flow: %s", reconfig_err + ) + raise ConfigEntryAuthFailed from retry_err + else: + raise ConfigEntryAuthFailed from retry_err + else: + _LOGGER.error( + "Authentication error during token validation (status %s): %s", + err.status, + err, + ) + raise ConfigEntryAuthFailed from err try: return await self._update_data() diff --git a/custom_components/iec/sensor.py b/custom_components/iec/sensor.py index 7e7942d..b00f45f 100644 --- a/custom_components/iec/sensor.py +++ b/custom_components/iec/sensor.py @@ -353,13 +353,18 @@ async def async_setup_entry( len( list( filter( - lambda key: key != STATICS_DICT_NAME, list(coordinator.data.keys()) + lambda key: key != STATICS_DICT_NAME, + list(coordinator.data.keys()) if coordinator.data else [], ) ) ) > 1 ) + if not coordinator.data: + _LOGGER.warning("Coordinator data is not available yet, skipping sensor setup") + return + for contract_key in coordinator.data: if contract_key == STATICS_DICT_NAME: for sensor_desc in STATIC_SENSORS: diff --git a/custom_components/iec/translations/en.json b/custom_components/iec/translations/en.json index cf3d515..d8908e6 100644 --- a/custom_components/iec/translations/en.json +++ b/custom_components/iec/translations/en.json @@ -87,7 +87,7 @@ "description": "Select which contract to use" }, "reauth_confirm": { - "title": "[%key:common::config_flow::title::reauth%]", + "title": "@common::config_flow::title::reauth", "description": "Enter your One Time Password (OTP) send to your {otp_type}", "data": { "user_id": "User ID", @@ -96,15 +96,15 @@ } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "cannot_connect": "@common::config_flow::error::cannot_connect", + "invalid_auth": "@common::config_flow::error::invalid_auth", "invalid_id": "Invalid Israeli ID", "no_contracts": "You should select at least one contract", "no_active_contracts": "No active contracts found" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "already_configured": "@common::config_flow::abort::already_configured_service", + "reauth_successful": "@common::config_flow::abort::reauth_successful" } } } diff --git a/custom_components/iec/translations/he.json b/custom_components/iec/translations/he.json index 1748ef0..5477aa1 100644 --- a/custom_components/iec/translations/he.json +++ b/custom_components/iec/translations/he.json @@ -90,7 +90,7 @@ "description": "בחרו באיזה חשבון חוזה להשתמש" }, "reauth_confirm": { - "title": "[%key:common::config_flow::title::reauth%]", + "title": "@common::config_flow::title::reauth", "description": "הכניסו את הקוד החד-פעמי שנשלח אליכם (OTP) שנשלח ל-{otp_type}", "data": { "user_id": "מזהה לקוח", @@ -99,15 +99,15 @@ } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "cannot_connect": "@common::config_flow::error::cannot_connect", + "invalid_auth": "@common::config_flow::error::invalid_auth", "invalid_id": "תעודת זהות לא תקנית", "no_contracts": "נא לבחור לפחות חוזה אחד", "no_active_contracts": "לא נמצאו חוזים פעילים" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "already_configured": "@common::config_flow::abort::already_configured_service", + "reauth_successful": "@common::config_flow::abort::reauth_successful" } } } diff --git a/hacs.json b/hacs.json index 19f0881..ef67e40 100644 --- a/hacs.json +++ b/hacs.json @@ -2,7 +2,7 @@ "name": "Israel Electric Corporation (IEC)", "filename": "iec.zip", "hide_default_branch": true, - "homeassistant": "2024.2.0", + "homeassistant": "2025.8.0", "render_readme": true, "zip_release": true } diff --git a/requirements.txt b/requirements.txt index 16681fc..6a7540f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ colorlog>=6.8.2 -homeassistant==2024.2.0 +homeassistant==2025.8.0 iec-api==0.4.12 pip>=21.0 ruff>=0.5.6