diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index 54b7310b7ad..b392b713741 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -4,10 +4,15 @@ from datetime import timedelta import logging import requests -from tesla_powerwall import MissingAttributeError, Powerwall, PowerwallUnreachableError +from tesla_powerwall import ( + AccessDeniedError, + MissingAttributeError, + Powerwall, + PowerwallUnreachableError, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry @@ -93,11 +98,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.data[DOMAIN].setdefault(entry_id, {}) http_session = requests.Session() + + password = entry.data.get(CONF_PASSWORD) power_wall = Powerwall(entry.data[CONF_IP_ADDRESS], http_session=http_session) try: - await hass.async_add_executor_job(power_wall.detect_and_pin_version) - await hass.async_add_executor_job(_fetch_powerwall_data, power_wall) - powerwall_data = await hass.async_add_executor_job(call_base_info, power_wall) + powerwall_data = await hass.async_add_executor_job( + _login_and_fetch_base_info, power_wall, password + ) except PowerwallUnreachableError as err: http_session.close() raise ConfigEntryNotReady from err @@ -105,6 +112,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): http_session.close() await _async_handle_api_changed_error(hass, err) return False + except AccessDeniedError as err: + _LOGGER.debug("Authentication failed", exc_info=err) + http_session.close() + _async_start_reauth(hass, entry) + return False await _migrate_old_unique_ids(hass, entry_id, powerwall_data) @@ -112,22 +124,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Fetch data from API endpoint.""" # Check if we had an error before _LOGGER.debug("Checking if update failed") - if not hass.data[DOMAIN][entry.entry_id][POWERWALL_API_CHANGED]: - _LOGGER.debug("Updating data") - try: - return await hass.async_add_executor_job( - _fetch_powerwall_data, power_wall - ) - except PowerwallUnreachableError as err: - raise UpdateFailed("Unable to fetch data from powerwall") from err - except MissingAttributeError as err: - await _async_handle_api_changed_error(hass, err) - hass.data[DOMAIN][entry.entry_id][POWERWALL_API_CHANGED] = True - # Returns the cached data. This data can also be None - return hass.data[DOMAIN][entry.entry_id][POWERWALL_COORDINATOR].data - else: + if hass.data[DOMAIN][entry.entry_id][POWERWALL_API_CHANGED]: return hass.data[DOMAIN][entry.entry_id][POWERWALL_COORDINATOR].data + _LOGGER.debug("Updating data") + try: + return await _async_update_powerwall_data(hass, entry, power_wall) + except AccessDeniedError: + if password is None: + raise + + # If the session expired, relogin, and try again + await hass.async_add_executor_job(power_wall.login, "", password) + return await _async_update_powerwall_data(hass, entry, power_wall) + coordinator = DataUpdateCoordinator( hass, _LOGGER, @@ -156,6 +166,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): return True +async def _async_update_powerwall_data( + hass: HomeAssistant, entry: ConfigEntry, power_wall: Powerwall +): + """Fetch updated powerwall data.""" + try: + return await hass.async_add_executor_job(_fetch_powerwall_data, power_wall) + except PowerwallUnreachableError as err: + raise UpdateFailed("Unable to fetch data from powerwall") from err + except MissingAttributeError as err: + await _async_handle_api_changed_error(hass, err) + hass.data[DOMAIN][entry.entry_id][POWERWALL_API_CHANGED] = True + # Returns the cached data. This data can also be None + return hass.data[DOMAIN][entry.entry_id][POWERWALL_COORDINATOR].data + + +def _async_start_reauth(hass: HomeAssistant, entry: ConfigEntry): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "reauth"}, + data=entry.data, + ) + ) + _LOGGER.error("Password is no longer valid. Please reauthenticate") + + +def _login_and_fetch_base_info(power_wall: Powerwall, password: str): + """Login to the powerwall and fetch the base info.""" + if password is not None: + power_wall.login("", password) + power_wall.detect_and_pin_version() + return call_base_info(power_wall) + + def call_base_info(power_wall): """Wrap powerwall properties to be a callable.""" serial_numbers = power_wall.get_serial_numbers() diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py index 37ee2730bb4..b649b160085 100644 --- a/homeassistant/components/powerwall/config_flow.py +++ b/homeassistant/components/powerwall/config_flow.py @@ -1,12 +1,17 @@ """Config flow for Tesla Powerwall integration.""" import logging -from tesla_powerwall import MissingAttributeError, Powerwall, PowerwallUnreachableError +from tesla_powerwall import ( + AccessDeniedError, + MissingAttributeError, + Powerwall, + PowerwallUnreachableError, +) import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.components.dhcp import IP_ADDRESS -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import callback from .const import DOMAIN # pylint:disable=unused-import @@ -14,6 +19,14 @@ from .const import DOMAIN # pylint:disable=unused-import _LOGGER = logging.getLogger(__name__) +def _login_and_fetch_site_info(power_wall: Powerwall, password: str): + """Login to the powerwall and fetch the base info.""" + if password is not None: + power_wall.login("", password) + power_wall.detect_and_pin_version() + return power_wall.get_site_info() + + async def validate_input(hass: core.HomeAssistant, data): """Validate the user input allows us to connect. @@ -21,12 +34,12 @@ async def validate_input(hass: core.HomeAssistant, data): """ power_wall = Powerwall(data[CONF_IP_ADDRESS]) + password = data[CONF_PASSWORD] try: - await hass.async_add_executor_job(power_wall.detect_and_pin_version) - site_info = await hass.async_add_executor_job(power_wall.get_site_info) - except PowerwallUnreachableError as err: - raise CannotConnect from err + site_info = await hass.async_add_executor_job( + _login_and_fetch_site_info, power_wall, password + ) except MissingAttributeError as err: # Only log the exception without the traceback _LOGGER.error(str(err)) @@ -62,27 +75,44 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: try: info = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" + except PowerwallUnreachableError: + errors[CONF_IP_ADDRESS] = "cannot_connect" except WrongVersion: errors["base"] = "wrong_version" + except AccessDeniedError: + errors[CONF_PASSWORD] = "invalid_auth" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" - if "base" not in errors: - await self.async_set_unique_id(user_input[CONF_IP_ADDRESS]) - self._abort_if_unique_id_configured() + if not errors: + existing_entry = await self.async_set_unique_id( + user_input[CONF_IP_ADDRESS] + ) + if existing_entry: + self.hass.config_entries.async_update_entry( + existing_entry, data=user_input + ) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") return self.async_create_entry(title=info["title"], data=user_input) return self.async_show_form( step_id="user", data_schema=vol.Schema( - {vol.Required(CONF_IP_ADDRESS, default=self.ip_address): str} + { + vol.Required(CONF_IP_ADDRESS, default=self.ip_address): str, + vol.Optional(CONF_PASSWORD): str, + } ), errors=errors, ) + async def async_step_reauth(self, data): + """Handle configuration by re-auth.""" + self.ip_address = data[CONF_IP_ADDRESS] + return await self.async_step_user() + @callback def _async_ip_address_already_configured(self, ip_address): """See if we already have an entry matching the ip_address.""" @@ -92,9 +122,5 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return False -class CannotConnect(exceptions.HomeAssistantError): - """Error to indicate we cannot connect.""" - - class WrongVersion(exceptions.HomeAssistantError): """Error to indicate the powerwall uses a software version we cannot interact with.""" diff --git a/homeassistant/components/powerwall/manifest.json b/homeassistant/components/powerwall/manifest.json index 6b7b147d3c5..40d0a6c50fe 100644 --- a/homeassistant/components/powerwall/manifest.json +++ b/homeassistant/components/powerwall/manifest.json @@ -3,7 +3,7 @@ "name": "Tesla Powerwall", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/powerwall", - "requirements": ["tesla-powerwall==0.3.3"], + "requirements": ["tesla-powerwall==0.3.5"], "codeowners": ["@bdraco", "@jrester"], "dhcp": [ {"hostname":"1118431-*","macaddress":"88DA1A*"}, diff --git a/homeassistant/components/powerwall/strings.json b/homeassistant/components/powerwall/strings.json index ac0d9568154..c576d931756 100644 --- a/homeassistant/components/powerwall/strings.json +++ b/homeassistant/components/powerwall/strings.json @@ -4,18 +4,22 @@ "step": { "user": { "title": "Connect to the powerwall", + "description": "The password is usually the last 5 characters of the serial number for Backup Gateway and can be found in the Telsa app; or the last 5 characters of the password found inside the door for Backup Gateway 2.", "data": { - "ip_address": "[%key:common::config_flow::data::ip%]" + "ip_address": "[%key:common::config_flow::data::ip%]", + "password": "[%key:common::config_flow::data::password%]" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "wrong_version": "Your powerwall uses a software version that is not supported. Please consider upgrading or reporting this issue so it can be resolved.", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/homeassistant/components/powerwall/translations/en.json b/homeassistant/components/powerwall/translations/en.json index 6eb0b77708d..4ebe1e9d5ef 100644 --- a/homeassistant/components/powerwall/translations/en.json +++ b/homeassistant/components/powerwall/translations/en.json @@ -1,10 +1,12 @@ { "config": { "abort": { - "already_configured": "Device is already configured" + "already_configured": "Device is already configured", + "reauth_successful": "Re-authentication was successful" }, "error": { "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", "unknown": "Unexpected error", "wrong_version": "Your powerwall uses a software version that is not supported. Please consider upgrading or reporting this issue so it can be resolved." }, @@ -12,10 +14,12 @@ "step": { "user": { "data": { - "ip_address": "IP Address" + "ip_address": "IP Address", + "password": "Password" }, + "description": "The password is usually the last 5 characters of the serial number for Backup Gateway and can be found in the Telsa app; or the last 5 characters of the password found inside the door for Backup Gateway 2.", "title": "Connect to the powerwall" } } } -} \ No newline at end of file +} diff --git a/requirements_all.txt b/requirements_all.txt index 8a3d5e2b5c8..ee86d62270a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2181,7 +2181,7 @@ temperusb==1.5.3 # tensorflow==2.3.0 # homeassistant.components.powerwall -tesla-powerwall==0.3.3 +tesla-powerwall==0.3.5 # homeassistant.components.tesla teslajsonpy==0.11.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d55d406cdb0..5d63ba30e1c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1102,7 +1102,7 @@ synologydsm-api==1.0.1 tellduslive==0.10.11 # homeassistant.components.powerwall -tesla-powerwall==0.3.3 +tesla-powerwall==0.3.5 # homeassistant.components.tesla teslajsonpy==0.11.5 diff --git a/tests/components/powerwall/test_config_flow.py b/tests/components/powerwall/test_config_flow.py index 0955c16c9ec..be071b45947 100644 --- a/tests/components/powerwall/test_config_flow.py +++ b/tests/components/powerwall/test_config_flow.py @@ -2,17 +2,23 @@ from unittest.mock import patch -from tesla_powerwall import MissingAttributeError, PowerwallUnreachableError +from tesla_powerwall import ( + AccessDeniedError, + MissingAttributeError, + PowerwallUnreachableError, +) from homeassistant import config_entries, setup from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS from homeassistant.components.powerwall.const import DOMAIN -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from .mocks import _mock_powerwall_side_effect, _mock_powerwall_site_name from tests.common import MockConfigEntry +VALID_CONFIG = {CONF_IP_ADDRESS: "1.2.3.4", CONF_PASSWORD: "00GGX"} + async def test_form_source_user(hass): """Test we get config flow setup form as a user.""" @@ -36,13 +42,13 @@ async def test_form_source_user(hass): ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_IP_ADDRESS: "1.2.3.4"}, + VALID_CONFIG, ) await hass.async_block_till_done() assert result2["type"] == "create_entry" assert result2["title"] == "My site" - assert result2["data"] == {CONF_IP_ADDRESS: "1.2.3.4"} + assert result2["data"] == VALID_CONFIG assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -61,11 +67,32 @@ async def test_form_cannot_connect(hass): ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_IP_ADDRESS: "1.2.3.4"}, + VALID_CONFIG, ) assert result2["type"] == "form" - assert result2["errors"] == {"base": "cannot_connect"} + assert result2["errors"] == {CONF_IP_ADDRESS: "cannot_connect"} + + +async def test_invalid_auth(hass): + """Test we handle invalid auth error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_powerwall = _mock_powerwall_side_effect(site_info=AccessDeniedError("any")) + + with patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {CONF_PASSWORD: "invalid_auth"} async def test_form_unknown_exeption(hass): @@ -81,8 +108,7 @@ async def test_form_unknown_exeption(hass): return_value=mock_powerwall, ): result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_IP_ADDRESS: "1.2.3.4"}, + result["flow_id"], VALID_CONFIG ) assert result2["type"] == "form" @@ -105,7 +131,7 @@ async def test_form_wrong_version(hass): ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_IP_ADDRESS: "1.2.3.4"}, + VALID_CONFIG, ) assert result3["type"] == "form" @@ -178,16 +204,54 @@ async def test_dhcp_discovery(hass): ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_IP_ADDRESS: "1.1.1.1", - }, + VALID_CONFIG, ) await hass.async_block_till_done() assert result2["type"] == "create_entry" assert result2["title"] == "Some site" - assert result2["data"] == { - CONF_IP_ADDRESS: "1.1.1.1", - } + assert result2["data"] == VALID_CONFIG + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_reauth(hass): + """Test reauthenticate.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data=VALID_CONFIG, + unique_id="1.2.3.4", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=entry.data + ) + assert result["type"] == "form" + assert result["errors"] == {} + + mock_powerwall = await _mock_powerwall_site_name(hass, "My site") + + with patch( + "homeassistant.components.powerwall.config_flow.Powerwall", + return_value=mock_powerwall, + ), patch( + "homeassistant.components.powerwall.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.powerwall.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: "1.2.3.4", + CONF_PASSWORD: "new-test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "reauth_successful" assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1