diff --git a/homeassistant/components/meater/__init__.py b/homeassistant/components/meater/__init__.py index a722928a13f0..904d9e412c06 100644 --- a/homeassistant/components/meater/__init__.py +++ b/homeassistant/components/meater/__init__.py @@ -14,7 +14,7 @@ from meater.MeaterApi import MeaterProbe from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -40,8 +40,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (ServiceUnavailableError, TooManyRequestsError) as err: raise ConfigEntryNotReady from err except AuthenticationError as err: - _LOGGER.error("Unable to authenticate with the Meater API: %s", err) - return False + raise ConfigEntryAuthFailed( + f"Unable to authenticate with the Meater API: {err}" + ) from err async def async_update_data() -> dict[str, MeaterProbe]: """Fetch data from API endpoint.""" @@ -51,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async with async_timeout.timeout(10): devices: list[MeaterProbe] = await meater_api.get_all_devices() except AuthenticationError as err: - raise UpdateFailed("The API call wasn't authenticated") from err + raise ConfigEntryAuthFailed("The API call wasn't authenticated") from err except TooManyRequestsError as err: raise UpdateFailed( "Too many requests have been made to the API, rate limiting is in place" diff --git a/homeassistant/components/meater/config_flow.py b/homeassistant/components/meater/config_flow.py index 1b1a8a0eca46..07dbd4bd4a5e 100644 --- a/homeassistant/components/meater/config_flow.py +++ b/homeassistant/components/meater/config_flow.py @@ -1,14 +1,18 @@ """Config flow for Meater.""" +from __future__ import annotations + from meater import AuthenticationError, MeaterApi, ServiceUnavailableError import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import DOMAIN -FLOW_SCHEMA = vol.Schema( +REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) +USER_SCHEMA = vol.Schema( {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} ) @@ -16,12 +20,17 @@ FLOW_SCHEMA = vol.Schema( class MeaterConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Meater Config Flow.""" - async def async_step_user(self, user_input=None): + _data_schema = USER_SCHEMA + _username: str + + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Define the login user step.""" if user_input is None: return self.async_show_form( step_id="user", - data_schema=FLOW_SCHEMA, + data_schema=self._data_schema, ) username: str = user_input[CONF_USERNAME] @@ -31,13 +40,41 @@ class MeaterConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): username = user_input[CONF_USERNAME] password = user_input[CONF_PASSWORD] + return await self._try_connect_meater("user", None, username, password) + + async def async_step_reauth(self, data: dict[str, str]) -> FlowResult: + """Handle configuration by re-auth.""" + self._data_schema = REAUTH_SCHEMA + self._username = data[CONF_USERNAME] + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle re-auth completion.""" + placeholders = {"username": self._username} + if not user_input: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self._data_schema, + description_placeholders=placeholders, + ) + + password = user_input[CONF_PASSWORD] + return await self._try_connect_meater( + "reauth_confirm", placeholders, self._username, password + ) + + async def _try_connect_meater( + self, step_id, placeholders: dict[str, str] | None, username: str, password: str + ) -> FlowResult: session = aiohttp_client.async_get_clientsession(self.hass) api = MeaterApi(session) errors = {} try: - await api.authenticate(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) + await api.authenticate(username, password) except AuthenticationError: errors["base"] = "invalid_auth" except ServiceUnavailableError: @@ -45,13 +82,20 @@ class MeaterConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except Exception: # pylint: disable=broad-except errors["base"] = "unknown_auth_error" else: + data = {"username": username, "password": password} + existing_entry = await self.async_set_unique_id(username.lower()) + if existing_entry: + self.hass.config_entries.async_update_entry(existing_entry, data=data) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") return self.async_create_entry( title="Meater", - data={"username": username, "password": password}, + data=data, ) return self.async_show_form( - step_id="user", - data_schema=FLOW_SCHEMA, + step_id=step_id, + data_schema=self._data_schema, + description_placeholders=placeholders, errors=errors, ) diff --git a/homeassistant/components/meater/strings.json b/homeassistant/components/meater/strings.json index 772e6afd0808..635c71d324ce 100644 --- a/homeassistant/components/meater/strings.json +++ b/homeassistant/components/meater/strings.json @@ -6,6 +6,15 @@ "data": { "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "username": "Meater Cloud username, typically an email address." + } + }, + "reauth_confirm": { + "description": "Confirm the password for Meater Cloud account {username}.", + "data": { + "password": "[%key:common::config_flow::data::password%]" } } }, diff --git a/homeassistant/components/meater/translations/en.json b/homeassistant/components/meater/translations/en.json index 3ceb94bcef04..707c6dc6ed68 100644 --- a/homeassistant/components/meater/translations/en.json +++ b/homeassistant/components/meater/translations/en.json @@ -6,11 +6,20 @@ "unknown_auth_error": "Unexpected error" }, "step": { + "reauth_confirm": { + "data": { + "password": "Password" + }, + "description": "Confirm the password for Meater Cloud account {username}." + }, "user": { "data": { "password": "Password", "username": "Username" }, + "data_description": { + "username": "Meater Cloud username, typically an email address." + }, "description": "Set up your Meater Cloud account." } } diff --git a/tests/components/meater/test_config_flow.py b/tests/components/meater/test_config_flow.py index 597b72c354a4..11312111311d 100644 --- a/tests/components/meater/test_config_flow.py +++ b/tests/components/meater/test_config_flow.py @@ -4,9 +4,8 @@ from unittest.mock import AsyncMock, patch from meater import AuthenticationError, ServiceUnavailableError import pytest -from homeassistant import data_entry_flow +from homeassistant import config_entries, data_entry_flow from homeassistant.components.meater import DOMAIN -from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from tests.common import MockConfigEntry @@ -35,7 +34,7 @@ async def test_duplicate_error(hass): ) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -48,7 +47,7 @@ async def test_unknown_auth_error(hass, mock_meater): conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf ) assert result["errors"] == {"base": "unknown_auth_error"} @@ -59,7 +58,7 @@ async def test_invalid_credentials(hass, mock_meater): conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf ) assert result["errors"] == {"base": "invalid_auth"} @@ -72,7 +71,7 @@ async def test_service_unavailable(hass, mock_meater): conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf ) assert result["errors"] == {"base": "service_unavailable_error"} @@ -82,7 +81,7 @@ async def test_user_flow(hass, mock_meater): conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=None + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" @@ -106,3 +105,42 @@ async def test_user_flow(hass, mock_meater): CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123", } + + +async def test_reauth_flow(hass, mock_meater): + """Test that the reauth flow works.""" + data = { + CONF_USERNAME: "user@host.com", + CONF_PASSWORD: "password123", + } + mock_config = MockConfigEntry( + domain=DOMAIN, + unique_id="user@host.com", + data=data, + ) + mock_config.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_REAUTH}, + data=data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"password": "passwordabc"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_successful" + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.data == { + CONF_USERNAME: "user@host.com", + CONF_PASSWORD: "passwordabc", + }