"""Config flow for HomeWizard.""" from __future__ import annotations from collections.abc import Mapping from typing import Any from homewizard_energy import ( HomeWizardEnergy, HomeWizardEnergyV1, HomeWizardEnergyV2, has_v2_api, ) from homewizard_energy.errors import ( DisabledError, RequestError, UnauthorizedError, UnsupportedError, ) from homewizard_energy.models import Device import voluptuous as vol from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import instance_id from homeassistant.helpers.selector import TextSelector from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_PRODUCT_NAME, CONF_PRODUCT_TYPE, CONF_SERIAL, DOMAIN, LOGGER class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for P1 meter.""" VERSION = 1 ip_address: str | None = None product_name: str | None = None product_type: str | None = None serial: str | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initiated by the user.""" errors: dict[str, str] | None = None if user_input is not None: try: device_info = await async_try_connect(user_input[CONF_IP_ADDRESS]) except RecoverableError as ex: LOGGER.error(ex) errors = {"base": ex.error_code} except UnauthorizedError: # Device responded, so IP is correct. But we have to authorize self.ip_address = user_input[CONF_IP_ADDRESS] return await self.async_step_authorize() else: await self.async_set_unique_id( f"{device_info.product_type}_{device_info.serial}" ) self._abort_if_unique_id_configured(updates=user_input) return self.async_create_entry( title=f"{device_info.product_name}", data=user_input, ) user_input = user_input or {} return self.async_show_form( step_id="user", data_schema=vol.Schema( { vol.Required( CONF_IP_ADDRESS, default=user_input.get(CONF_IP_ADDRESS) ): TextSelector(), } ), errors=errors, ) async def async_step_authorize( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Step where we attempt to get a token.""" assert self.ip_address # Tell device we want a token, user must now press the button within 30 seconds # The first attempt will always fail, but this opens the window to press the button token = await async_request_token(self.hass, self.ip_address) errors: dict[str, str] | None = None if token is None: if user_input is not None: errors = {"base": "authorization_failed"} return self.async_show_form(step_id="authorize", errors=errors) # Now we got a token, we can ask for some more info async with HomeWizardEnergyV2(self.ip_address, token=token) as api: device_info = await api.device() data = { CONF_IP_ADDRESS: self.ip_address, CONF_TOKEN: token, } await self.async_set_unique_id( f"{device_info.product_type}_{device_info.serial}" ) self._abort_if_unique_id_configured(updates=data) return self.async_create_entry( title=f"{device_info.product_name}", data=data, ) async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle zeroconf discovery.""" if ( CONF_PRODUCT_NAME not in discovery_info.properties or CONF_PRODUCT_TYPE not in discovery_info.properties or CONF_SERIAL not in discovery_info.properties ): return self.async_abort(reason="invalid_discovery_parameters") self.ip_address = discovery_info.host self.product_type = discovery_info.properties[CONF_PRODUCT_TYPE] self.product_name = discovery_info.properties[CONF_PRODUCT_NAME] self.serial = discovery_info.properties[CONF_SERIAL] await self.async_set_unique_id(f"{self.product_type}_{self.serial}") self._abort_if_unique_id_configured( updates={CONF_IP_ADDRESS: discovery_info.host} ) return await self.async_step_discovery_confirm() async def async_step_dhcp( self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle dhcp discovery to update existing entries. This flow is triggered only by DHCP discovery of known devices. """ try: device = await async_try_connect(discovery_info.ip) except RecoverableError as ex: LOGGER.error(ex) return self.async_abort(reason="unknown") except UnauthorizedError: return self.async_abort(reason="unsupported_api_version") await self.async_set_unique_id( f"{device.product_type}_{discovery_info.macaddress}" ) self._abort_if_unique_id_configured( updates={CONF_IP_ADDRESS: discovery_info.ip} ) # This situation should never happen, as Home Assistant will only # send updates for existing entries. In case it does, we'll just # abort the flow with an unknown error. return self.async_abort(reason="unknown") async def async_step_discovery_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm discovery.""" assert self.ip_address assert self.product_name assert self.product_type assert self.serial errors: dict[str, str] | None = None if user_input is not None or not onboarding.async_is_onboarded(self.hass): try: await async_try_connect(self.ip_address) except RecoverableError as ex: LOGGER.error(ex) errors = {"base": ex.error_code} except UnauthorizedError: return await self.async_step_authorize() else: return self.async_create_entry( title=self.product_name, data={CONF_IP_ADDRESS: self.ip_address}, ) self._set_confirm_only() # We won't be adding mac/serial to the title for devices # that users generally don't have multiple of. name = self.product_name if self.product_type not in ["HWE-P1", "HWE-WTR"]: name = f"{name} ({self.serial})" self.context["title_placeholders"] = {"name": name} return self.async_show_form( step_id="discovery_confirm", description_placeholders={ CONF_PRODUCT_TYPE: self.product_type, CONF_SERIAL: self.serial, CONF_IP_ADDRESS: self.ip_address, }, errors=errors, ) async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle re-auth if API was disabled.""" self.ip_address = entry_data[CONF_IP_ADDRESS] # If token exists, we assume we use the v2 API and that the token has been invalidated if entry_data.get(CONF_TOKEN): return await self.async_step_reauth_confirm_update_token() # Else we assume we use the v1 API and that the API has been disabled return await self.async_step_reauth_enable_api() async def async_step_reauth_enable_api( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm reauth dialog, where user is asked to re-enable the HomeWizard API.""" errors: dict[str, str] | None = None if user_input is not None: reauth_entry = self._get_reauth_entry() try: await async_try_connect(reauth_entry.data[CONF_IP_ADDRESS]) except RecoverableError as ex: LOGGER.error(ex) errors = {"base": ex.error_code} else: await self.hass.config_entries.async_reload(reauth_entry.entry_id) return self.async_abort(reason="reauth_enable_api_successful") return self.async_show_form(step_id="reauth_enable_api", errors=errors) async def async_step_reauth_confirm_update_token( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm reauth dialog.""" assert self.ip_address errors: dict[str, str] | None = None token = await async_request_token(self.hass, self.ip_address) if user_input is not None: if token is None: errors = {"base": "authorization_failed"} else: return self.async_update_reload_and_abort( self._get_reauth_entry(), data_updates={ CONF_TOKEN: token, }, ) return self.async_show_form( step_id="reauth_confirm_update_token", errors=errors ) async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle reconfiguration of the integration.""" errors: dict[str, str] = {} reconfigure_entry = self._get_reconfigure_entry() if user_input: try: device_info = await async_try_connect( user_input[CONF_IP_ADDRESS], token=reconfigure_entry.data.get(CONF_TOKEN), ) except RecoverableError as ex: LOGGER.error(ex) errors = {"base": ex.error_code} else: await self.async_set_unique_id( f"{device_info.product_type}_{device_info.serial}" ) self._abort_if_unique_id_mismatch(reason="wrong_device") return self.async_update_reload_and_abort( self._get_reconfigure_entry(), data_updates=user_input, ) return self.async_show_form( step_id="reconfigure", data_schema=vol.Schema( { vol.Required( CONF_IP_ADDRESS, default=reconfigure_entry.data.get(CONF_IP_ADDRESS), ): TextSelector(), } ), description_placeholders={ "title": reconfigure_entry.title, }, errors=errors, ) async def async_try_connect(ip_address: str, token: str | None = None) -> Device: """Try to connect. Make connection with device to test the connection and to get info for unique_id. """ energy_api: HomeWizardEnergy # Determine if device is v1 or v2 capable if await has_v2_api(ip_address): energy_api = HomeWizardEnergyV2(ip_address, token=token) else: energy_api = HomeWizardEnergyV1(ip_address) try: return await energy_api.device() except DisabledError as ex: raise RecoverableError( "API disabled, API must be enabled in the app", "api_not_enabled" ) from ex except UnsupportedError as ex: LOGGER.error("API version unsuppored") raise AbortFlow("unsupported_api_version") from ex except RequestError as ex: raise RecoverableError( "Device unreachable or unexpected response", "network_error" ) from ex except UnauthorizedError as ex: raise UnauthorizedError("Unauthorized") from ex except Exception as ex: LOGGER.exception("Unexpected exception") raise AbortFlow("unknown_error") from ex finally: await energy_api.close() async def async_request_token(hass: HomeAssistant, ip_address: str) -> str | None: """Try to request a token from the device. This method is used to request a token from the device, it will return None if the token request failed. """ api = HomeWizardEnergyV2(ip_address) # Get a part of the unique id to make the token unique # This is to prevent token conflicts when multiple HA instances are used uuid = await instance_id.async_get(hass) try: return await api.get_token(f"home-assistant#{uuid[:6]}") except DisabledError: return None finally: await api.close() class RecoverableError(HomeAssistantError): """Raised when a connection has been failed but can be retried.""" def __init__(self, message: str, error_code: str) -> None: """Init RecoverableError.""" super().__init__(message) self.error_code = error_code