From 3546ff2da2c52ff10e41c4bd3985f54c6c9a62bd Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Sat, 1 May 2021 17:04:37 -0700 Subject: [PATCH] Bump Tesla dependency teslajsonpy to 0.18.3 (#49939) Co-authored-by: J. Nick Koston --- homeassistant/components/tesla/__init__.py | 36 ++++++-- homeassistant/components/tesla/config_flow.py | 29 ++++--- homeassistant/components/tesla/const.py | 1 + homeassistant/components/tesla/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tesla/test_config_flow.py | 84 ++++++++++++++----- 7 files changed, 118 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/tesla/__init__.py b/homeassistant/components/tesla/__init__.py index 80cefaa9c56..2b0373dba33 100644 --- a/homeassistant/components/tesla/__init__.py +++ b/homeassistant/components/tesla/__init__.py @@ -1,9 +1,11 @@ """Support for Tesla cars.""" +import asyncio from collections import defaultdict from datetime import timedelta import logging import async_timeout +import httpx from teslajsonpy import Controller as TeslaAPI from teslajsonpy.exceptions import IncompleteCredentials, TeslaException import voluptuous as vol @@ -17,11 +19,13 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_USERNAME, + EVENT_HOMEASSISTANT_CLOSE, HTTP_UNAUTHORIZED, ) from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.httpx_client import SERVER_SOFTWARE, USER_AGENT from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -31,6 +35,7 @@ from homeassistant.util import slugify from .config_flow import CannotConnect, InvalidAuth, validate_input from .const import ( + CONF_EXPIRATION, CONF_WAKE_ON_START, DATA_LISTENER, DEFAULT_SCAN_INTERVAL, @@ -113,6 +118,7 @@ async def async_setup(hass, base_config): CONF_PASSWORD: password, CONF_ACCESS_TOKEN: info[CONF_ACCESS_TOKEN], CONF_TOKEN: info[CONF_TOKEN], + CONF_EXPIRATION: info[CONF_EXPIRATION], }, options={CONF_SCAN_INTERVAL: scan_interval}, ) @@ -134,7 +140,7 @@ async def async_setup_entry(hass, config_entry): hass.data.setdefault(DOMAIN, {}) config = config_entry.data # Because users can have multiple accounts, we always create a new session so they have separate cookies - websession = aiohttp_client.async_create_clientsession(hass) + async_client = httpx.AsyncClient(headers={USER_AGENT: SERVER_SOFTWARE}) email = config_entry.title if email in hass.data[DOMAIN] and CONF_SCAN_INTERVAL in hass.data[DOMAIN][email]: scan_interval = hass.data[DOMAIN][email][CONF_SCAN_INTERVAL] @@ -144,27 +150,45 @@ async def async_setup_entry(hass, config_entry): hass.data[DOMAIN].pop(email) try: controller = TeslaAPI( - websession, + async_client, email=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), refresh_token=config[CONF_TOKEN], access_token=config[CONF_ACCESS_TOKEN], + expiration=config.get(CONF_EXPIRATION, 0), update_interval=config_entry.options.get( CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL ), ) - (refresh_token, access_token) = await controller.connect( + result = await controller.connect( wake_if_asleep=config_entry.options.get( CONF_WAKE_ON_START, DEFAULT_WAKE_ON_START ) ) + refresh_token = result["refresh_token"] + access_token = result["access_token"] except IncompleteCredentials as ex: + await async_client.aclose() raise ConfigEntryAuthFailed from ex except TeslaException as ex: + await async_client.aclose() if ex.code == HTTP_UNAUTHORIZED: raise ConfigEntryAuthFailed from ex _LOGGER.error("Unable to communicate with Tesla API: %s", ex.message) return False + + async def _async_close_client(*_): + await async_client.aclose() + + @callback + def _async_create_close_task(): + asyncio.create_task(_async_close_client()) + + config_entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_close_client) + ) + config_entry.async_on_unload(_async_create_close_task) + _async_save_tokens(hass, config_entry, access_token, refresh_token) coordinator = TeslaDataUpdateCoordinator( hass, config_entry=config_entry, controller=controller @@ -240,7 +264,9 @@ class TeslaDataUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self): """Fetch data from API endpoint.""" if self.controller.is_token_refreshed(): - (refresh_token, access_token) = self.controller.get_tokens() + result = self.controller.get_tokens() + refresh_token = result["refresh_token"] + access_token = result["access_token"] _async_save_tokens( self.hass, self.config_entry, access_token, refresh_token ) diff --git a/homeassistant/components/tesla/config_flow.py b/homeassistant/components/tesla/config_flow.py index b6f31e9de98..706c91ae59b 100644 --- a/homeassistant/components/tesla/config_flow.py +++ b/homeassistant/components/tesla/config_flow.py @@ -1,7 +1,9 @@ """Tesla Config Flow.""" import logging +import httpx from teslajsonpy import Controller as TeslaAPI, TeslaException +from teslajsonpy.exceptions import IncompleteCredentials import voluptuous as vol from homeassistant import config_entries, core, exceptions @@ -14,9 +16,11 @@ from homeassistant.const import ( HTTP_UNAUTHORIZED, ) from homeassistant.core import callback -from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.httpx_client import SERVER_SOFTWARE, USER_AGENT from .const import ( + CONF_EXPIRATION, CONF_WAKE_ON_START, DEFAULT_SCAN_INTERVAL, DEFAULT_WAKE_ON_START, @@ -35,6 +39,7 @@ class TeslaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize the tesla flow.""" self.username = None + self.reauth = False async def async_step_import(self, import_config): """Import a config entry from configuration.yaml.""" @@ -46,10 +51,7 @@ class TeslaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: existing_entry = self._async_entry_for_username(user_input[CONF_USERNAME]) - if ( - existing_entry - and existing_entry.data[CONF_PASSWORD] == user_input[CONF_PASSWORD] - ): + if existing_entry and not self.reauth: return self.async_abort(reason="already_configured") try: @@ -81,6 +83,7 @@ class TeslaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_reauth(self, data): """Handle configuration by re-auth.""" self.username = data[CONF_USERNAME] + self.reauth = True return await self.async_step_user() @staticmethod @@ -146,26 +149,32 @@ async def validate_input(hass: core.HomeAssistant, data): """ config = {} - websession = aiohttp_client.async_create_clientsession(hass) + async_client = httpx.AsyncClient(headers={USER_AGENT: SERVER_SOFTWARE}) try: controller = TeslaAPI( - websession, + async_client, email=data[CONF_USERNAME], password=data[CONF_PASSWORD], update_interval=DEFAULT_SCAN_INTERVAL, ) - (config[CONF_TOKEN], config[CONF_ACCESS_TOKEN]) = await controller.connect( - test_login=True - ) + result = await controller.connect(test_login=True) + config[CONF_TOKEN] = result["refresh_token"] + config[CONF_ACCESS_TOKEN] = result["access_token"] + config[CONF_EXPIRATION] = result[CONF_EXPIRATION] config[CONF_USERNAME] = data[CONF_USERNAME] config[CONF_PASSWORD] = data[CONF_PASSWORD] + except IncompleteCredentials as ex: + _LOGGER.error("Authentication error: %s %s", ex.message, ex) + raise InvalidAuth() from ex except TeslaException as ex: if ex.code == HTTP_UNAUTHORIZED: _LOGGER.error("Invalid credentials: %s", ex) raise InvalidAuth() from ex _LOGGER.error("Unable to communicate with Tesla API: %s", ex) raise CannotConnect() from ex + finally: + await async_client.aclose() _LOGGER.debug("Credentials successfully connected to the Tesla API") return config diff --git a/homeassistant/components/tesla/const.py b/homeassistant/components/tesla/const.py index 94883e4a833..4155942c0ad 100644 --- a/homeassistant/components/tesla/const.py +++ b/homeassistant/components/tesla/const.py @@ -1,4 +1,5 @@ """Const file for Tesla cars.""" +CONF_EXPIRATION = "expiration" CONF_WAKE_ON_START = "enable_wake_on_start" DOMAIN = "tesla" DATA_LISTENER = "listener" diff --git a/homeassistant/components/tesla/manifest.json b/homeassistant/components/tesla/manifest.json index 6befca8a5f2..8604436d5a4 100644 --- a/homeassistant/components/tesla/manifest.json +++ b/homeassistant/components/tesla/manifest.json @@ -3,7 +3,7 @@ "name": "Tesla", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tesla", - "requirements": ["teslajsonpy==0.11.5"], + "requirements": ["teslajsonpy==0.18.3"], "codeowners": ["@zabuldon", "@alandtse"], "dhcp": [ { diff --git a/requirements_all.txt b/requirements_all.txt index ddde6b15107..9c61d470239 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2223,7 +2223,7 @@ temperusb==1.5.3 tesla-powerwall==0.3.5 # homeassistant.components.tesla -teslajsonpy==0.11.5 +teslajsonpy==0.18.3 # homeassistant.components.tensorflow # tf-models-official==2.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0282c879ad4..7f85f6014a4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1180,7 +1180,7 @@ tellduslive==0.10.11 tesla-powerwall==0.3.5 # homeassistant.components.tesla -teslajsonpy==0.11.5 +teslajsonpy==0.18.3 # homeassistant.components.toon toonapi==0.2.0 diff --git a/tests/components/tesla/test_config_flow.py b/tests/components/tesla/test_config_flow.py index b35ab0039d0..4a45aac5124 100644 --- a/tests/components/tesla/test_config_flow.py +++ b/tests/components/tesla/test_config_flow.py @@ -1,10 +1,12 @@ """Test the Tesla config flow.""" +import datetime from unittest.mock import patch -from teslajsonpy import TeslaException +from teslajsonpy.exceptions import IncompleteCredentials, TeslaException from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components.tesla.const import ( + CONF_EXPIRATION, CONF_WAKE_ON_START, DEFAULT_SCAN_INTERVAL, DEFAULT_WAKE_ON_START, @@ -22,6 +24,12 @@ from homeassistant.const import ( from tests.common import MockConfigEntry +TEST_USERNAME = "test-username" +TEST_TOKEN = "test-token" +TEST_PASSWORD = "test-password" +TEST_ACCESS_TOKEN = "test-access-token" +TEST_VALID_EXPIRATION = datetime.datetime.now().timestamp() * 2 + async def test_form(hass): """Test we get the form.""" @@ -34,7 +42,11 @@ async def test_form(hass): with patch( "homeassistant.components.tesla.config_flow.TeslaAPI.connect", - return_value=("test-refresh-token", "test-access-token"), + return_value={ + "refresh_token": TEST_TOKEN, + CONF_ACCESS_TOKEN: TEST_ACCESS_TOKEN, + CONF_EXPIRATION: TEST_VALID_EXPIRATION, + }, ), patch( "homeassistant.components.tesla.async_setup", return_value=True ) as mock_setup, patch( @@ -50,8 +62,9 @@ async def test_form(hass): assert result2["data"] == { CONF_USERNAME: "test@email.com", CONF_PASSWORD: "test", - CONF_TOKEN: "test-refresh-token", - CONF_ACCESS_TOKEN: "test-access-token", + CONF_TOKEN: TEST_TOKEN, + CONF_ACCESS_TOKEN: TEST_ACCESS_TOKEN, + CONF_EXPIRATION: TEST_VALID_EXPIRATION, } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -69,7 +82,26 @@ async def test_form_invalid_auth(hass): ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}, + {CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_invalid_auth_incomplete_credentials(hass): + """Test we handle invalid auth with incomplete credentials.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.tesla.config_flow.TeslaAPI.connect", + side_effect=IncompleteCredentials(401), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD}, ) assert result2["type"] == "form" @@ -88,7 +120,7 @@ async def test_form_cannot_connect(hass): ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_PASSWORD: "test-password", CONF_USERNAME: "test-username"}, + {CONF_PASSWORD: TEST_PASSWORD, CONF_USERNAME: TEST_USERNAME}, ) assert result2["type"] == "form" @@ -99,8 +131,8 @@ async def test_form_repeat_identifier(hass): """Test we handle repeat identifiers.""" entry = MockConfigEntry( domain=DOMAIN, - title="test-username", - data={"username": "test-username", "password": "test-password"}, + title=TEST_USERNAME, + data={"username": TEST_USERNAME, "password": TEST_PASSWORD}, options=None, ) entry.add_to_hass(hass) @@ -110,11 +142,15 @@ async def test_form_repeat_identifier(hass): ) with patch( "homeassistant.components.tesla.config_flow.TeslaAPI.connect", - return_value=("test-refresh-token", "test-access-token"), + return_value={ + "refresh_token": TEST_TOKEN, + CONF_ACCESS_TOKEN: TEST_ACCESS_TOKEN, + CONF_EXPIRATION: TEST_VALID_EXPIRATION, + }, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}, + {CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD}, ) assert result2["type"] == "abort" @@ -125,8 +161,8 @@ async def test_form_reauth(hass): """Test we handle reauth.""" entry = MockConfigEntry( domain=DOMAIN, - title="test-username", - data={"username": "test-username", "password": "same"}, + title=TEST_USERNAME, + data={"username": TEST_USERNAME, "password": "same"}, options=None, ) entry.add_to_hass(hass) @@ -134,15 +170,19 @@ async def test_form_reauth(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, - data={"username": "test-username"}, + data={"username": TEST_USERNAME}, ) with patch( "homeassistant.components.tesla.config_flow.TeslaAPI.connect", - return_value=("test-refresh-token", "test-access-token"), + return_value={ + "refresh_token": TEST_TOKEN, + CONF_ACCESS_TOKEN: TEST_ACCESS_TOKEN, + CONF_EXPIRATION: TEST_VALID_EXPIRATION, + }, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_USERNAME: "test-username", CONF_PASSWORD: "new-password"}, + {CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: "new-password"}, ) assert result2["type"] == "abort" @@ -154,17 +194,21 @@ async def test_import(hass): with patch( "homeassistant.components.tesla.config_flow.TeslaAPI.connect", - return_value=("test-refresh-token", "test-access-token"), + return_value={ + "refresh_token": TEST_TOKEN, + CONF_ACCESS_TOKEN: TEST_ACCESS_TOKEN, + CONF_EXPIRATION: TEST_VALID_EXPIRATION, + }, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_PASSWORD: "test-password", CONF_USERNAME: "test-username"}, + data={CONF_PASSWORD: TEST_PASSWORD, CONF_USERNAME: TEST_USERNAME}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "test-username" - assert result["data"][CONF_ACCESS_TOKEN] == "test-access-token" - assert result["data"][CONF_TOKEN] == "test-refresh-token" + assert result["title"] == TEST_USERNAME + assert result["data"][CONF_ACCESS_TOKEN] == TEST_ACCESS_TOKEN + assert result["data"][CONF_TOKEN] == TEST_TOKEN assert result["description_placeholders"] is None