diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 70933f3470ed..6059c0e46db9 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -10,26 +10,23 @@ from dataclasses import dataclass import logging from typing import Final +from config_entries import ConfigEntry import evohomeasync as ec1 import evohomeasync2 as ec2 import voluptuous as vol -from homeassistant.const import ( - CONF_PASSWORD, - CONF_SCAN_INTERVAL, - CONF_USERNAME, - Platform, -) +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType from .const import ( CONF_LOCATION_IDX, DOMAIN, - EVOHOME_DATA, + PLATFORMS, SCAN_INTERVAL_DEFAULT, SCAN_INTERVAL_MINIMUM, ) @@ -65,13 +62,34 @@ class EvoData: tcs: ec2.ControlSystem +type EvohomeConfigEntry = ConfigEntry[EvoData] + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Evohome integration.""" + if DOMAIN in config: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config[DOMAIN], + ) + if result["type"] is FlowResultType.CREATE_ENTRY: + # issue success + pass + else: + pass + # issue failure + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: EvohomeConfigEntry) -> bool: + """Set up Evohome from a config entry.""" token_manager = TokenManager( hass, - config[DOMAIN][CONF_USERNAME], - config[DOMAIN][CONF_PASSWORD], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], async_get_clientsession(hass), ) coordinator = EvoDataUpdateCoordinator( @@ -79,37 +97,25 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER, ec2.EvohomeClient(token_manager), name=f"{DOMAIN}_coordinator", - update_interval=config[DOMAIN][CONF_SCAN_INTERVAL], - location_idx=config[DOMAIN][CONF_LOCATION_IDX], + update_interval=SCAN_INTERVAL_DEFAULT, + location_idx=entry.data[CONF_LOCATION_IDX], client_v1=ec1.EvohomeClient(token_manager), ) - await coordinator.async_register_shutdown() - await coordinator.async_first_refresh() - - if not coordinator.last_update_success: - _LOGGER.error(f"Failed to fetch initial data: {coordinator.last_exception}") # noqa: G004 - return False - - assert coordinator.tcs is not None # mypy - - hass.data[EVOHOME_DATA] = EvoData( + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = EvoData( coordinator=coordinator, loc_idx=coordinator.loc_idx, tcs=coordinator.tcs, ) - hass.async_create_task( - async_load_platform(hass, Platform.CLIMATE, DOMAIN, {}, config) - ) - hass.async_create_task( - async_load_platform(hass, Platform.BUTTON, DOMAIN, {}, config) - ) - if coordinator.tcs.hotwater: - hass.async_create_task( - async_load_platform(hass, Platform.WATER_HEATER, DOMAIN, {}, config) - ) - setup_service_functions(hass, coordinator) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: EvohomeConfigEntry) -> bool: + """Unload Evohome config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/evohome/button.py b/homeassistant/components/evohome/button.py index 9e27cb74de73..00d81a36ac7a 100644 --- a/homeassistant/components/evohome/button.py +++ b/homeassistant/components/evohome/button.py @@ -7,28 +7,23 @@ import evohomeasync2 as evo from homeassistant.components.button import ButtonEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import EVOHOME_DATA +from . import EvohomeConfigEntry from .coordinator import EvoDataUpdateCoordinator from .entity import is_valid_zone, unique_zone_id -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - _: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: EvohomeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the button platform for Evohome.""" + """Set up Evohome button platform.""" - if discovery_info is None: - return - - coordinator = hass.data[EVOHOME_DATA].coordinator - tcs = hass.data[EVOHOME_DATA].tcs + coordinator = entry.runtime_data.coordinator + tcs = entry.runtime_data.tcs entities: list[EvoResetButtonBase] = [EvoResetSystemButton(coordinator, tcs)] diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 534e64ae5a7a..20a6bca63579 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -31,11 +31,11 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemper from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DOMAIN, EVOHOME_DATA, RESET_BREAKS_IN_HA_VERSION, EvoService +from . import EvohomeConfigEntry +from .const import DOMAIN, RESET_BREAKS_IN_HA_VERSION, EvoService from .coordinator import EvoDataUpdateCoordinator from .entity import EvoChild, EvoEntity, is_valid_zone, unique_zone_id from .helpers import async_create_deprecation_issue_once @@ -63,27 +63,15 @@ EVO_PRESET_TO_HA = { HA_PRESET_TO_EVO = {v: k for k, v in EVO_PRESET_TO_HA.items()} -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - _: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: EvohomeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the climate platform for Evohome.""" + """Set up Evohome climate platform.""" - if discovery_info is None: - return - - coordinator = hass.data[EVOHOME_DATA].coordinator - tcs = hass.data[EVOHOME_DATA].tcs - - _LOGGER.debug( - "Found the Location/Controller (%s), id=%s, name=%s (location_idx=%s)", - tcs.model, - tcs.id, - tcs.location.name, - coordinator.loc_idx, - ) + coordinator = entry.runtime_data.coordinator + tcs = entry.runtime_data.tcs entities: list[EvoController | EvoZone] = [EvoController(coordinator, tcs)] diff --git a/homeassistant/components/evohome/config_flow.py b/homeassistant/components/evohome/config_flow.py new file mode 100644 index 000000000000..86c247e2b57b --- /dev/null +++ b/homeassistant/components/evohome/config_flow.py @@ -0,0 +1,89 @@ +"""Adds config flow for Evohome integration.""" + +from typing import Any, override + +import evohomeasync2 as ec2 +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import CONF_LOCATION_IDX, DOMAIN +from .storage import TokenManager + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): TextSelector( + TextSelectorConfig(type=TextSelectorType.EMAIL, autocomplete="username") + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, autocomplete="current-password" + ) + ), + vol.Optional(CONF_LOCATION_IDX, default=0): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + } +) + + +async def validate_api( + hass: HomeAssistant, username: str, password: str +) -> dict[str, Any]: + """Validate the API key.""" + errors: dict[str, str] = {} + token_manager = TokenManager( + hass, + username, + password, + async_get_clientsession(hass), + ) + client_v2 = ec2.EvohomeClient(token_manager) + try: + await client_v2.update(dont_update_status=True) # only config for now + except ec2.EvohomeError: + errors["base"] = "cannot_connect" + + return errors + + +class EvohomeConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Evohome integration.""" + + VERSION = 1 + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + + errors: dict[str, str] = {} + + if user_input: + self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]}) + errors = await validate_api( + self.hass, user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + if not errors: + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema(DATA_SCHEMA, user_input), + errors=errors, + ) diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py index 3023b9081455..433d1d36b95c 100644 --- a/homeassistant/components/evohome/const.py +++ b/homeassistant/components/evohome/const.py @@ -4,11 +4,18 @@ from datetime import timedelta from enum import StrEnum, unique from typing import TYPE_CHECKING, Final +from homeassistant.const import Platform from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: from . import EvoData +PLATFORMS = [ + Platform.BUTTON, + Platform.CLIMATE, + Platform.WATER_HEATER, +] + DOMAIN: Final = "evohome" EVOHOME_DATA: HassKey[EvoData] = HassKey(DOMAIN) diff --git a/homeassistant/components/evohome/coordinator.py b/homeassistant/components/evohome/coordinator.py index 157ae189be5e..0e0672273151 100644 --- a/homeassistant/components/evohome/coordinator.py +++ b/homeassistant/components/evohome/coordinator.py @@ -65,22 +65,6 @@ class EvoDataUpdateCoordinator(DataUpdateCoordinator): self._first_refresh_done = False # get schedules only after first refresh - # our version of async_config_entry_first_refresh()... - async def async_first_refresh(self) -> None: - """Refresh data for the first time when integration is setup. - - This integration does not have config flow, so it is inappropriate to - invoke `async_config_entry_first_refresh()`. - """ - - # can't replicate `if not await self.__wrap_async_setup():` (is mangled), so... - if not await self._DataUpdateCoordinator__wrap_async_setup(): # type: ignore[attr-defined] - return - - await self._async_refresh( - log_failures=False, raise_on_auth_failed=True, raise_on_entry_error=True - ) - @override async def _async_setup(self) -> None: """Set up the coordinator. diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 56e7c7647597..ae3cbc85b343 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -2,7 +2,9 @@ "domain": "evohome", "name": "Honeywell Total Connect Comfort (Europe)", "codeowners": ["@zxdavb"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/evohome", + "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["evohomeasync", "evohomeasync2"], "quality_scale": "legacy", diff --git a/homeassistant/components/evohome/strings.json b/homeassistant/components/evohome/strings.json index 047e961c977a..b9c4fa7da5b1 100644 --- a/homeassistant/components/evohome/strings.json +++ b/homeassistant/components/evohome/strings.json @@ -1,4 +1,26 @@ { + "config": { + "abort": { + "already_configured": "Evohome account already configured" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "user": { + "data": { + "location_idx": "Location", + "password": "Password", + "username": "Username" + }, + "data_description": { + "location_idx": "Which location to use if your login has access to more than one location.", + "password": "Your Evohome account password.", + "username": "Your Evohome account username." + } + } + } + }, "exceptions": { "controller_only_service": { "message": "Only Evohome controllers support the `{service}` action" diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index 68565487bcc1..e6947bf7cad9 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -24,11 +24,10 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .const import EVOHOME_DATA +from . import EvohomeConfigEntry from .coordinator import EvoDataUpdateCoordinator from .entity import EvoChild @@ -40,28 +39,18 @@ HA_STATE_TO_EVO = {STATE_AUTO: "", STATE_ON: EvoDhwState.ON, STATE_OFF: EvoDhwSt EVO_STATE_TO_HA = {v: k for k, v in HA_STATE_TO_EVO.items() if k != ""} -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - _: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: EvohomeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the water heater platform for Evohome.""" + """Set up Evohome water heater platform.""" - if discovery_info is None: - return - - coordinator = hass.data[EVOHOME_DATA].coordinator - tcs = hass.data[EVOHOME_DATA].tcs + coordinator = entry.runtime_data.coordinator + tcs = entry.runtime_data.tcs assert tcs.hotwater is not None # mypy check - _LOGGER.debug( - "Adding: DhwController (%s), id=%s", - tcs.hotwater.type, - tcs.hotwater.id, - ) - entity = EvoDHW(coordinator, tcs.hotwater) async_add_entities([entity]) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e17d12b77ef1..a0b9ae7b2546 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -223,6 +223,7 @@ FLOWS = { "eufylife_ble", "eurotronic_cometblue", "evil_genius_labs", + "evohome", "ezviz", "faa_delays", "fastdotcom", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 0fb73355adb8..9e2c1241b2f0 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2993,7 +2993,7 @@ }, "evohome": { "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling", "name": "Honeywell Total Connect Comfort (Europe)" },