diff --git a/.coveragerc b/.coveragerc index 9c030123f72..deadd4e0b19 100644 --- a/.coveragerc +++ b/.coveragerc @@ -656,6 +656,7 @@ omit = homeassistant/components/mystrom/binary_sensor.py homeassistant/components/mystrom/light.py homeassistant/components/mystrom/switch.py + homeassistant/components/myq/__init__.py homeassistant/components/n26/* homeassistant/components/nad/media_player.py homeassistant/components/nanoleaf/light.py diff --git a/homeassistant/components/myq/__init__.py b/homeassistant/components/myq/__init__.py index fd3a46bbb5a..a299968712a 100644 --- a/homeassistant/components/myq/__init__.py +++ b/homeassistant/components/myq/__init__.py @@ -8,7 +8,7 @@ from pymyq.errors import InvalidCredentialsError, MyQError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -27,8 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): try: myq = await pymyq.login(conf[CONF_USERNAME], conf[CONF_PASSWORD], websession) except InvalidCredentialsError as err: - _LOGGER.error("There was an error while logging in: %s", err) - return False + raise ConfigEntryAuthFailed from err except MyQError as err: raise ConfigEntryNotReady from err @@ -37,6 +36,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): async def async_update_data(): try: return await myq.update_device_info() + except InvalidCredentialsError as err: + raise ConfigEntryAuthFailed from err except MyQError as err: raise UpdateFailed(str(err)) from err diff --git a/homeassistant/components/myq/binary_sensor.py b/homeassistant/components/myq/binary_sensor.py index e3832458b9b..b1b3680343d 100644 --- a/homeassistant/components/myq/binary_sensor.py +++ b/homeassistant/components/myq/binary_sensor.py @@ -26,7 +26,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for device in myq.gateways.values(): entities.append(MyQBinarySensorEntity(coordinator, device)) - async_add_entities(entities, True) + async_add_entities(entities) class MyQBinarySensorEntity(CoordinatorEntity, BinarySensorEntity): diff --git a/homeassistant/components/myq/config_flow.py b/homeassistant/components/myq/config_flow.py index fa3d6b502cc..78a751a18b1 100644 --- a/homeassistant/components/myq/config_flow.py +++ b/homeassistant/components/myq/config_flow.py @@ -5,7 +5,7 @@ import pymyq from pymyq.errors import InvalidCredentialsError, MyQError import voluptuous as vol -from homeassistant import config_entries, core, exceptions +from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers import aiohttp_client @@ -18,72 +18,81 @@ DATA_SCHEMA = vol.Schema( ) -async def validate_input(hass: core.HomeAssistant, data): - """Validate the user input allows us to connect. - - Data has the keys from DATA_SCHEMA with values provided by the user. - """ - - websession = aiohttp_client.async_get_clientsession(hass) - - try: - await pymyq.login(data[CONF_USERNAME], data[CONF_PASSWORD], websession) - except InvalidCredentialsError as err: - raise InvalidAuth from err - except MyQError as err: - raise CannotConnect from err - - return {"title": data[CONF_USERNAME]} - - class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for MyQ.""" VERSION = 1 + def __init__(self): + """Start a myq config flow.""" + self._reauth_unique_id = None + + async def _async_validate_input(self, username, password): + """Validate the user input allows us to connect.""" + websession = aiohttp_client.async_get_clientsession(self.hass) + try: + await pymyq.login(username, password, websession) + except InvalidCredentialsError: + return {CONF_PASSWORD: "invalid_auth"} + except MyQError: + return {"base": "cannot_connect"} + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return {"base": "unknown"} + + return None + async def async_step_user(self, user_input=None): """Handle the initial step.""" errors = {} if user_input is not None: - try: - info = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - - if "base" not in errors: + errors = await self._async_validate_input( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + if not errors: await self.async_set_unique_id(user_input[CONF_USERNAME]) self._abort_if_unique_id_configured() - return self.async_create_entry(title=info["title"], data=user_input) + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_homekit(self, discovery_info): - """Handle HomeKit discovery.""" - if self._async_current_entries(): - # We can see myq on the network to tell them to configure - # it, but since the device will not give up the account it is - # bound to and there can be multiple myq gateways on a single - # account, we avoid showing the device as discovered once - # they already have one configured as they can always - # add a new one via "+" - return self.async_abort(reason="already_configured") - properties = { - key.lower(): value for (key, value) in discovery_info["properties"].items() - } - await self.async_set_unique_id(properties["id"]) - return await self.async_step_user() + async def async_step_reauth(self, user_input=None): + """Handle reauth.""" + self._reauth_unique_id = self.context["unique_id"] + return await self.async_step_reauth_confirm() + async def async_step_reauth_confirm(self, user_input=None): + """Handle reauth input.""" + errors = {} + existing_entry = await self.async_set_unique_id(self._reauth_unique_id) + if user_input is not None: + errors = await self._async_validate_input( + existing_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + if not errors: + self.hass.config_entries.async_update_entry( + existing_entry, + data={ + **existing_entry.data, + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") -class CannotConnect(exceptions.HomeAssistantError): - """Error to indicate we cannot connect.""" - - -class InvalidAuth(exceptions.HomeAssistantError): - """Error to indicate there is invalid auth.""" + return self.async_show_form( + description_placeholders={ + CONF_USERNAME: existing_entry.data[CONF_USERNAME] + }, + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/myq/cover.py b/homeassistant/components/myq/cover.py index e26a969e724..3d587635f2d 100644 --- a/homeassistant/components/myq/cover.py +++ b/homeassistant/components/myq/cover.py @@ -32,7 +32,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): coordinator = data[MYQ_COORDINATOR] async_add_entities( - [MyQDevice(coordinator, device) for device in myq.covers.values()], True + [MyQDevice(coordinator, device) for device in myq.covers.values()] ) @@ -158,9 +158,3 @@ class MyQDevice(CoordinatorEntity, CoverEntity): if self._device.parent_device_id: device_info["via_device"] = (DOMAIN, self._device.parent_device_id) return device_info - - async def async_added_to_hass(self): - """Subscribe to updates.""" - self.async_on_remove( - self.coordinator.async_add_listener(self.async_write_ha_state) - ) diff --git a/homeassistant/components/myq/strings.json b/homeassistant/components/myq/strings.json index 19717907b0f..e8a0baa85ff 100644 --- a/homeassistant/components/myq/strings.json +++ b/homeassistant/components/myq/strings.json @@ -7,7 +7,14 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } - } + }, + "reauth_confirm": { + "description": "The password for {username} is no longer valid.", + "title": "Reauthenticate your MyQ Account", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -15,6 +22,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } } diff --git a/homeassistant/components/myq/translations/en.json b/homeassistant/components/myq/translations/en.json index 9dad2d10cad..5dc6d811c87 100644 --- a/homeassistant/components/myq/translations/en.json +++ b/homeassistant/components/myq/translations/en.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Service is already configured" + "already_configured": "Service is already configured", + "reauth_successful": "Re-authentication was successful" }, "error": { "cannot_connect": "Failed to connect", @@ -9,6 +10,13 @@ "unknown": "Unexpected error" }, "step": { + "reauth_confirm": { + "data": { + "password": "Password" + }, + "description": "The password for {username} is no longer valid.", + "title": "Reauthenticate your MyQ Account" + }, "user": { "data": { "password": "Password", diff --git a/tests/components/myq/test_config_flow.py b/tests/components/myq/test_config_flow.py index 3ae2da82f46..3b0d79f6f03 100644 --- a/tests/components/myq/test_config_flow.py +++ b/tests/components/myq/test_config_flow.py @@ -57,7 +57,7 @@ async def test_form_invalid_auth(hass): ) assert result2["type"] == "form" - assert result2["errors"] == {"base": "invalid_auth"} + assert result2["errors"] == {"password": "invalid_auth"} async def test_form_cannot_connect(hass): @@ -79,32 +79,87 @@ async def test_form_cannot_connect(hass): assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_homekit(hass): - """Test that we abort from homekit if myq is already setup.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - +async def test_form_unknown_exception(hass): + """Test we handle unknown exceptions.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_HOMEKIT}, - data={"properties": {"id": "AA:BB:CC:DD:EE:FF"}}, + DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" - assert result["errors"] == {} - flow = next( - flow - for flow in hass.config_entries.flow.async_progress() - if flow["flow_id"] == result["flow_id"] - ) - assert flow["context"]["unique_id"] == "AA:BB:CC:DD:EE:FF" + with patch( + "homeassistant.components.myq.config_flow.pymyq.login", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-username", "password": "test-password"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +async def test_reauth(hass): + """Test we can reauth.""" entry = MockConfigEntry( - domain=DOMAIN, data={CONF_USERNAME: "mock", CONF_PASSWORD: "mock"} + domain=DOMAIN, + data={ + CONF_USERNAME: "test@test.org", + CONF_PASSWORD: "secret", + }, + unique_id="test@test.org", ) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_HOMEKIT}, - data={"properties": {"id": "AA:BB:CC:DD:EE:FF"}}, + context={"source": config_entries.SOURCE_REAUTH, "unique_id": "test@test.org"}, ) - assert result["type"] == "abort" + + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.myq.config_flow.pymyq.login", + side_effect=InvalidCredentialsError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"password": "invalid_auth"} + + with patch( + "homeassistant.components.myq.config_flow.pymyq.login", + side_effect=MyQError, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_PASSWORD: "test-password", + }, + ) + + assert result3["type"] == "form" + assert result3["errors"] == {"base": "cannot_connect"} + + with patch( + "homeassistant.components.myq.config_flow.pymyq.login", + return_value=True, + ), patch( + "homeassistant.components.myq.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + { + CONF_PASSWORD: "test-password", + }, + ) + + assert mock_setup_entry.called + assert result4["type"] == "abort" + assert result4["reason"] == "reauth_successful"