From 730a206e1a1ada54b647af4ccb8ea22d5101c629 Mon Sep 17 00:00:00 2001 From: Elliott Balsley <3991046+llamafilm@users.noreply.github.com> Date: Mon, 11 Mar 2024 12:40:23 -0700 Subject: [PATCH] feat: Support modern vehicles using HTTP proxy (#853) See https://github.com/alandtse/tesla/blob/dev/README.md#tesla-fleet-api-proxy --- .devcontainer.json | 5 +- README.md | 8 + custom_components/tesla_custom/__init__.py | 20 +- custom_components/tesla_custom/config_flow.py | 83 +++++++- custom_components/tesla_custom/const.py | 3 + custom_components/tesla_custom/manifest.json | 2 +- custom_components/tesla_custom/strings.json | 14 +- .../tesla_custom/translations/en.json | 14 +- poetry.lock | 30 ++- pyproject.toml | 3 +- tests/common.py | 21 +- tests/const.py | 3 + tests/test_config_flow.py | 182 +++++++++++++++--- 13 files changed, 336 insertions(+), 52 deletions(-) diff --git a/.devcontainer.json b/.devcontainer.json index eeb8ae6d..eaa48ad9 100644 --- a/.devcontainer.json +++ b/.devcontainer.json @@ -9,15 +9,14 @@ "ms-python.python", "github.vscode-pull-request-github", "ryanluker.vscode-coverage-gutters", - "ms-python.vscode-pylance" + "ms-python.vscode-pylance", + "ms-python.pylint" ], "settings": { "files.eol": "\n", "editor.tabSize": 4, "python.pythonPath": "/usr/bin/python3", "python.analysis.autoSearchPaths": false, - "python.linting.pylintEnabled": true, - "python.linting.enabled": true, "python.formatting.provider": "black", "python.formatting.blackPath": "/usr/local/py-utils/bin/black", "editor.formatOnPaste": false, diff --git a/README.md b/README.md index 54bc499c..1375137c 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,14 @@ To use the component, you will need an application to generate a Tesla refresh t Note: This integration will wake up your vehicle(s) during installation. +## Tesla Fleet API Proxy + +Tesla has [deprecated](https://developer.tesla.com/docs/fleet-api) the Owner API for _most_ vehicles in favor of a new Fleet API with end-to-end encryption. You'll know you're affected if you see this error in the log: + +> [teslajsonpy.connection] 403: {"response":null,"error":"Tesla Vehicle Command Protocol required, please refer to the documentation here: https://developer.tesla.com/docs/fleet-api#2023-10-09-rest-api-vehicle-commands-endpoint-deprecation-warning","error_description":""} + +If your vehicle is affected by this, you'll need to install the [Tesla HTTP Proxy](https://github.com/llamafilm/tesla-http-proxy-addon) add-on and configure this component to use it. This requires a complex setup; see [here](https://github.com/llamafilm/tesla-http-proxy-addon/blob/main/tesla_http_proxy/DOCS.md) for details. After configuring the add-on, tick the box for "Fleet API Proxy" in this component, and the config flow will autofill your Client ID, Proxy URL, and SSL certificate. + ## Usage diff --git a/custom_components/tesla_custom/__init__.py b/custom_components/tesla_custom/__init__.py index bd0d19e7..17875f61 100644 --- a/custom_components/tesla_custom/__init__.py +++ b/custom_components/tesla_custom/__init__.py @@ -5,11 +5,13 @@ from functools import partial from http import HTTPStatus import logging +import ssl import async_timeout from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_ACCESS_TOKEN, + CONF_CLIENT_ID, CONF_DOMAIN, CONF_SCAN_INTERVAL, CONF_TOKEN, @@ -29,6 +31,8 @@ from .config_flow import CannotConnect, InvalidAuth, validate_input from .const import ( + CONF_API_PROXY_CERT, + CONF_API_PROXY_URL, CONF_ENABLE_TESLAMATE, CONF_EXPIRATION, CONF_INCLUDE_ENERGYSITES, @@ -131,11 +135,22 @@ def _update_entry(email, data=None, options=None): async def async_setup_entry(hass, config_entry): """Set up Tesla as config entry.""" - # pylint: disable=too-many-locals,too-many-statements + # pylint: disable=too-many-locals,too-many-statements,too-many-branches 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 + + if config[CONF_API_PROXY_CERT]: + try: + SSL_CONTEXT.load_verify_locations(config[CONF_API_PROXY_CERT]) + _LOGGER.debug(SSL_CONTEXT) + except (FileNotFoundError, ssl.SSLError): + _LOGGER.warning( + "Unable to load custom SSL certificate from %s", + config[CONF_API_PROXY_CERT], + ) + async_client = httpx.AsyncClient( headers={USER_AGENT: SERVER_SOFTWARE}, timeout=60, verify=SSL_CONTEXT ) @@ -165,6 +180,9 @@ async def async_setup_entry(hass, config_entry): polling_policy=config_entry.options.get( CONF_POLLING_POLICY, DEFAULT_POLLING_POLICY ), + api_proxy_cert=config.get(CONF_API_PROXY_CERT), + api_proxy_url=config.get(CONF_API_PROXY_URL), + client_id=config.get(CONF_CLIENT_ID), ) result = await controller.connect( include_vehicles=config.get(CONF_INCLUDE_VEHICLES), diff --git a/custom_components/tesla_custom/config_flow.py b/custom_components/tesla_custom/config_flow.py index 071a9b61..6d1427f6 100644 --- a/custom_components/tesla_custom/config_flow.py +++ b/custom_components/tesla_custom/config_flow.py @@ -2,10 +2,12 @@ from http import HTTPStatus import logging +import os from homeassistant import config_entries, core, exceptions from homeassistant.const import ( CONF_ACCESS_TOKEN, + CONF_CLIENT_ID, CONF_DOMAIN, CONF_SCAN_INTERVAL, CONF_TOKEN, @@ -24,6 +26,9 @@ ATTR_POLLING_POLICY_ALWAYS, ATTR_POLLING_POLICY_CONNECTED, ATTR_POLLING_POLICY_NORMAL, + CONF_API_PROXY_CERT, + CONF_API_PROXY_ENABLE, + CONF_API_PROXY_URL, CONF_ENABLE_TESLAMATE, CONF_EXPIRATION, CONF_INCLUDE_ENERGYSITES, @@ -51,6 +56,7 @@ def __init__(self) -> None: """Initialize the tesla flow.""" self.username = None self.reauth = False + self.use_proxy = False async def async_step_import(self, import_config): """Import a config entry from configuration.yaml.""" @@ -58,6 +64,27 @@ async def async_step_import(self, import_config): async def async_step_user(self, user_input=None): """Handle the start of the config flow.""" + data_schema = vol.Schema( + { + vol.Required(CONF_API_PROXY_ENABLE, default=False): bool, + } + ) + + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=data_schema, + ) + + # in case we import a config entry from configuration.yaml + if CONF_API_PROXY_CERT in user_input: + return await self.async_step_credentials(user_input) + + self.use_proxy = user_input[CONF_API_PROXY_ENABLE] + return await self.async_step_credentials() + + async def async_step_credentials(self, user_input=None): + """Handle the second step of the config flow.""" errors = {} if user_input is not None: @@ -87,8 +114,8 @@ async def async_step_user(self, user_input=None): ) return self.async_show_form( - step_id="user", - data_schema=self._async_schema(), + step_id="credentials", + data_schema=self._async_schema(api_proxy_enable=self.use_proxy), errors=errors, description_placeholders={}, ) @@ -106,9 +133,10 @@ def async_get_options_flow(config_entry): return OptionsFlowHandler(config_entry) @callback - def _async_schema(self): + def _async_schema(self, api_proxy_enable: bool): """Fetch schema with defaults.""" - return vol.Schema( + + schema = vol.Schema( { vol.Required(CONF_USERNAME, default=self.username): str, vol.Required(CONF_TOKEN): str, @@ -118,6 +146,47 @@ def _async_schema(self): } ) + if api_proxy_enable: + # autofill fields if HTTP Proxy is running as addon + if "SUPERVISOR_TOKEN" in os.environ: + api_proxy_cert = "/share/tesla/selfsigned.pem" + + # find out if addon is running from normal repo or local + req = httpx.get( + "http://supervisor/addons", + headers={ + "Authorization": f"Bearer {os.environ['SUPERVISOR_TOKEN']}" + }, + ) + for addon in req.json()["data"]["addons"]: + if addon["name"] == "Tesla HTTP Proxy": + addon_slug = addon["slug"] + break + if not addon_slug: + _LOGGER.warning("Unable to communicate with Tesla HTTP Proxy addon") + + # read Client ID from addon + req = httpx.get( + f"http://supervisor/addons/{addon_slug}/info", + headers={ + "Authorization": f"Bearer {os.environ['SUPERVISOR_TOKEN']}" + }, + ) + client_id = req.json()["data"]["options"]["client_id"] + api_proxy_url = "https://" + req.json()["data"]["hostname"] + + else: + api_proxy_url = client_id = api_proxy_cert = None + + schema = schema.extend( + { + vol.Required(CONF_API_PROXY_URL, default=api_proxy_url): str, + vol.Required(CONF_API_PROXY_CERT, default=api_proxy_cert): str, + vol.Required(CONF_CLIENT_ID, default=client_id): str, + } + ) + return schema + @callback def _async_entry_for_username(self, username): """Find an existing entry for a username.""" @@ -196,6 +265,9 @@ async def validate_input(hass: core.HomeAssistant, data) -> dict: expiration=data.get(CONF_EXPIRATION, 0), auth_domain=data.get(CONF_DOMAIN, AUTH_DOMAIN), polling_policy=data.get(CONF_POLLING_POLICY, DEFAULT_POLLING_POLICY), + api_proxy_cert=data.get(CONF_API_PROXY_CERT), + api_proxy_url=data.get(CONF_API_PROXY_URL), + client_id=data.get(CONF_CLIENT_ID), ) result = await controller.connect(test_login=True) config[CONF_TOKEN] = result["refresh_token"] @@ -205,6 +277,9 @@ async def validate_input(hass: core.HomeAssistant, data) -> dict: config[CONF_DOMAIN] = data.get(CONF_DOMAIN, AUTH_DOMAIN) config[CONF_INCLUDE_VEHICLES] = data[CONF_INCLUDE_VEHICLES] config[CONF_INCLUDE_ENERGYSITES] = data[CONF_INCLUDE_ENERGYSITES] + config[CONF_API_PROXY_URL] = data.get(CONF_API_PROXY_URL) + config[CONF_API_PROXY_CERT] = data.get(CONF_API_PROXY_CERT) + config[CONF_CLIENT_ID] = data.get(CONF_CLIENT_ID) except IncompleteCredentials as ex: _LOGGER.error("Authentication error: %s %s", ex.message, ex) diff --git a/custom_components/tesla_custom/const.py b/custom_components/tesla_custom/const.py index ac6aaff7..c874d818 100644 --- a/custom_components/tesla_custom/const.py +++ b/custom_components/tesla_custom/const.py @@ -7,6 +7,9 @@ CONF_POLLING_POLICY = "polling_policy" CONF_WAKE_ON_START = "enable_wake_on_start" CONF_ENABLE_TESLAMATE = "enable_teslamate" +CONF_API_PROXY_ENABLE = "api_proxy_enable" +CONF_API_PROXY_URL = "api_proxy_url" +CONF_API_PROXY_CERT = "api_proxy_cert" DOMAIN = "tesla_custom" ATTRIBUTION = "Data provided by Tesla" DATA_LISTENER = "listener" diff --git a/custom_components/tesla_custom/manifest.json b/custom_components/tesla_custom/manifest.json index ae471fc3..44fed085 100644 --- a/custom_components/tesla_custom/manifest.json +++ b/custom_components/tesla_custom/manifest.json @@ -24,6 +24,6 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/alandtse/tesla/issues", "loggers": ["teslajsonpy"], - "requirements": ["teslajsonpy==3.9.11"], + "requirements": ["teslajsonpy==3.10.1"], "version": "3.19.11" } diff --git a/custom_components/tesla_custom/strings.json b/custom_components/tesla_custom/strings.json index 9f326c06..5a35b1af 100644 --- a/custom_components/tesla_custom/strings.json +++ b/custom_components/tesla_custom/strings.json @@ -11,15 +11,25 @@ }, "step": { "user": { + "data": { + "api_proxy_enable": "Use Fleet API Proxy" + }, + "description": "Newer vehicles may require an API Proxy. If this applies to\r\n you, install the Tesla HTTP Proxy first.", + "title": "Tesla - Configuration" + }, + "credentials": { "data": { "mfa": "MFA Code (optional)", "password": "Password", "username": "Email", "token": "Refresh Token", "include_vehicles": "Include Vehicles", - "include_energysites": "Include Energy Sites" + "include_energysites": "Include Energy Sites", + "api_proxy_url": "Proxy URL", + "api_proxy_cert": "Proxy SSL certificate", + "client_id": "Tesla developer Client ID" }, - "description": "Use 'Auth App for Tesla' on iOS, 'Tesla Tokens' on Android\r\n or 'teslafi.com' to create a refresh token and enter it below.\r\n Vehicle(s) are forced awake for setup.", + "description": "Use 'Auth App for Tesla' on iOS or 'Tesla Tokens' on Android\r\n to create a refresh token and enter it below.\r\n Vehicle(s) are forced awake for setup.", "title": "Tesla - Configuration" } } diff --git a/custom_components/tesla_custom/translations/en.json b/custom_components/tesla_custom/translations/en.json index 9f326c06..5a35b1af 100644 --- a/custom_components/tesla_custom/translations/en.json +++ b/custom_components/tesla_custom/translations/en.json @@ -11,15 +11,25 @@ }, "step": { "user": { + "data": { + "api_proxy_enable": "Use Fleet API Proxy" + }, + "description": "Newer vehicles may require an API Proxy. If this applies to\r\n you, install the Tesla HTTP Proxy first.", + "title": "Tesla - Configuration" + }, + "credentials": { "data": { "mfa": "MFA Code (optional)", "password": "Password", "username": "Email", "token": "Refresh Token", "include_vehicles": "Include Vehicles", - "include_energysites": "Include Energy Sites" + "include_energysites": "Include Energy Sites", + "api_proxy_url": "Proxy URL", + "api_proxy_cert": "Proxy SSL certificate", + "client_id": "Tesla developer Client ID" }, - "description": "Use 'Auth App for Tesla' on iOS, 'Tesla Tokens' on Android\r\n or 'teslafi.com' to create a refresh token and enter it below.\r\n Vehicle(s) are forced awake for setup.", + "description": "Use 'Auth App for Tesla' on iOS or 'Tesla Tokens' on Android\r\n to create a refresh token and enter it below.\r\n Vehicle(s) are forced awake for setup.", "title": "Tesla - Configuration" } } diff --git a/poetry.lock b/poetry.lock index 3995684b..ed5181b9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. [[package]] name = "aiohttp" @@ -2362,6 +2362,24 @@ syrupy = "4.0.2" tomli = {version = "2.0.1", markers = "python_version < \"3.11\""} tqdm = "4.64.0" +[[package]] +name = "pytest-httpx" +version = "0.24.0" +description = "Send responses to httpx." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pytest_httpx-0.24.0-py3-none-any.whl", hash = "sha256:193cecb57a005eb15288f68986f328d4c8d06c0b7c4ef1ce512e024cbb1d5961"}, + {file = "pytest_httpx-0.24.0.tar.gz", hash = "sha256:259e6266cf3e04eb8fcc18dff262657ad96f6b8668dc2171fb353eaec5571889"}, +] + +[package.dependencies] +httpx = "==0.24.*" +pytest = ">=6.0,<8.0" + +[package.extras] +testing = ["pytest-asyncio (==0.21.*)", "pytest-cov (==4.*)"] + [[package]] name = "pytest-picked" version = "0.4.6" @@ -2804,7 +2822,7 @@ files = [ ] [package.dependencies] -greenlet = {version = "!=0.4.17", markers = "platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\""} +greenlet = {version = "!=0.4.17", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""} typing-extensions = ">=4.2.0" [package.extras] @@ -2889,13 +2907,13 @@ tests = ["pytest", "pytest-cov"] [[package]] name = "teslajsonpy" -version = "3.9.11" +version = "3.10.1" description = "A library to work with Tesla API." optional = false python-versions = ">=3.7,<4.0" files = [ - {file = "teslajsonpy-3.9.11-py3-none-any.whl", hash = "sha256:19a6a25a802a799adaf9ba698b4695e001af281d8c02ed306fcd77bc6b8bfe82"}, - {file = "teslajsonpy-3.9.11.tar.gz", hash = "sha256:6689e21b89747d12562ccbea537c9d810fde5f8abdfd66c2cc7e0e76949edccb"}, + {file = "teslajsonpy-3.10.1-py3-none-any.whl", hash = "sha256:555df778189639f4ff3bd49dc3b9ecd88e92f15d7326a758338c94389a9bee99"}, + {file = "teslajsonpy-3.10.1.tar.gz", hash = "sha256:037eccf250cd9ff635d0e7105df4cbfc32f620975868b93d28ebb8d7688a48a7"}, ] [package.dependencies] @@ -3259,4 +3277,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "aaf1462d015f0cfdfc170c7e4602b76acaddc4d3aebc874da583f9cca3c73414" +content-hash = "3fb0510d3463954a60821b89ea1d76896bc7269e956c3b7ba39157686a3c9b93" diff --git a/pyproject.toml b/pyproject.toml index b07d7043..76f9a823 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ license = "Apache-2.0" [tool.poetry.dependencies] python = "^3.10" -teslajsonpy = "3.9.11" +teslajsonpy = "3.10.1" [tool.poetry.group.dev.dependencies] @@ -21,6 +21,7 @@ pydocstyle = ">=6.0.0" prospector = { extras = ["with_all"], version = ">=1.3.1" } aiohttp_cors = ">=0.7.0" pytest-asyncio = ">=0.20.3" +pytest-httpx = ">=0.24.0" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/tests/common.py b/tests/common.py index 56bf94c3..f9ec569f 100644 --- a/tests/common.py +++ b/tests/common.py @@ -5,6 +5,7 @@ from homeassistant.const import ( CONF_ACCESS_TOKEN, + CONF_CLIENT_ID, CONF_DOMAIN, CONF_TOKEN, CONF_USERNAME, @@ -16,9 +17,22 @@ from teslajsonpy.const import AUTH_DOMAIN from teslajsonpy.energy import SolarPowerwallSite, SolarSite -from custom_components.tesla_custom.const import CONF_EXPIRATION, DOMAIN as TESLA_DOMIN +from custom_components.tesla_custom.const import ( + CONF_API_PROXY_CERT, + CONF_API_PROXY_URL, + CONF_EXPIRATION, + DOMAIN as TESLA_DOMIN, +) -from .const import TEST_ACCESS_TOKEN, TEST_TOKEN, TEST_USERNAME, TEST_VALID_EXPIRATION +from .const import ( + TEST_ACCESS_TOKEN, + TEST_API_PROXY_CERT, + TEST_API_PROXY_URL, + TEST_CLIENT_ID, + TEST_TOKEN, + TEST_USERNAME, + TEST_VALID_EXPIRATION, +) from .mock_data import car as car_mock_data, energysite as energysite_mock_data @@ -75,6 +89,9 @@ async def setup_platform(hass: HomeAssistant, platform: str) -> MockConfigEntry: CONF_TOKEN: TEST_TOKEN, CONF_EXPIRATION: TEST_VALID_EXPIRATION, CONF_DOMAIN: AUTH_DOMAIN, + CONF_CLIENT_ID: TEST_CLIENT_ID, + CONF_API_PROXY_CERT: TEST_API_PROXY_CERT, + CONF_API_PROXY_URL: TEST_API_PROXY_URL, }, options=None, ) diff --git a/tests/const.py b/tests/const.py index df60d7c6..bfdc6e04 100644 --- a/tests/const.py +++ b/tests/const.py @@ -5,3 +5,6 @@ TEST_PASSWORD = "test-password" TEST_ACCESS_TOKEN = "test-access-token" TEST_VALID_EXPIRATION = datetime.datetime.now().timestamp() * 2 +TEST_API_PROXY_CERT = "/path/to/certificate.pem" +TEST_API_PROXY_URL = "https://tesla-http-proxy" +TEST_CLIENT_ID = "test-client-id" diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index cce38452..69857ce6 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1,12 +1,13 @@ """Test the Tesla config flow.""" -import datetime from http import HTTPStatus +import os from unittest.mock import patch from homeassistant import config_entries, data_entry_flow, setup from homeassistant.const import ( CONF_ACCESS_TOKEN, + CONF_CLIENT_ID, CONF_DOMAIN, CONF_SCAN_INTERVAL, CONF_TOKEN, @@ -18,6 +19,9 @@ from custom_components.tesla_custom.const import ( ATTR_POLLING_POLICY_CONNECTED, + CONF_API_PROXY_CERT, + CONF_API_PROXY_ENABLE, + CONF_API_PROXY_URL, CONF_ENABLE_TESLAMATE, CONF_EXPIRATION, CONF_INCLUDE_ENERGYSITES, @@ -32,21 +36,103 @@ MIN_SCAN_INTERVAL, ) -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 +from .const import ( + TEST_ACCESS_TOKEN, + TEST_API_PROXY_CERT, + TEST_API_PROXY_URL, + TEST_CLIENT_ID, + TEST_TOKEN, + TEST_USERNAME, + TEST_VALID_EXPIRATION, +) async def test_form(hass): - """Test we get the form.""" + """Test we get the form if user chooses no proxy.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_PROXY_ENABLE: False} + ) + await hass.async_block_till_done() + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "credentials" + + with ( + patch( + "custom_components.tesla_custom.config_flow.TeslaAPI.connect", + return_value={ + "refresh_token": TEST_TOKEN, + CONF_ACCESS_TOKEN: TEST_ACCESS_TOKEN, + CONF_EXPIRATION: TEST_VALID_EXPIRATION, + }, + ), + patch( + "custom_components.tesla_custom.async_setup", return_value=True + ) as mock_setup, + patch( + "custom_components.tesla_custom.async_setup_entry", return_value=True + ) as mock_setup_entry, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_TOKEN: TEST_TOKEN, CONF_USERNAME: TEST_USERNAME} + ) + await hass.async_block_till_done() + + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result3["title"] == TEST_USERNAME + assert result3["data"] == { + CONF_USERNAME: TEST_USERNAME, + CONF_TOKEN: TEST_TOKEN, + CONF_ACCESS_TOKEN: TEST_ACCESS_TOKEN, + CONF_EXPIRATION: TEST_VALID_EXPIRATION, + CONF_DOMAIN: AUTH_DOMAIN, + CONF_INCLUDE_VEHICLES: True, + CONF_INCLUDE_ENERGYSITES: True, + "initial_setup": True, + CONF_API_PROXY_URL: None, + CONF_API_PROXY_CERT: None, + CONF_CLIENT_ID: None, + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_with_proxy(hass, httpx_mock): + """Test we get the form if user chooses to use proxy.""" + + os.environ["SUPERVISOR_TOKEN"] = "test-token" + httpx_mock.add_response( + url="http://supervisor/addons", + json={ + "data": { + "addons": [{"name": "Tesla HTTP Proxy", "slug": "tesla_http_proxy"}] + } + }, + ) + httpx_mock.add_response( + url="http://supervisor/addons/tesla_http_proxy/info", + json={"data": {"hostname": "http-proxy", "options": {"client_id": "test"}}}, + ) + await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == "form" - assert result["errors"] == {} + assert result["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_PROXY_ENABLE: True} + ) + await hass.async_block_till_done() + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "credentials" with ( patch( @@ -64,15 +150,22 @@ async def test_form(hass): "custom_components.tesla_custom.async_setup_entry", return_value=True ) as mock_setup_entry, ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_TOKEN: TEST_TOKEN, CONF_USERNAME: "test@email.com"} + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: TEST_TOKEN, + CONF_USERNAME: TEST_USERNAME, + CONF_CLIENT_ID: TEST_CLIENT_ID, + CONF_API_PROXY_CERT: TEST_API_PROXY_CERT, + CONF_API_PROXY_URL: TEST_API_PROXY_URL, + }, ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == "test@email.com" - assert result2["data"] == { - CONF_USERNAME: "test@email.com", + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result3["title"] == TEST_USERNAME + assert result3["data"] == { + CONF_USERNAME: TEST_USERNAME, CONF_TOKEN: TEST_TOKEN, CONF_ACCESS_TOKEN: TEST_ACCESS_TOKEN, CONF_EXPIRATION: TEST_VALID_EXPIRATION, @@ -80,6 +173,9 @@ async def test_form(hass): CONF_INCLUDE_VEHICLES: True, CONF_INCLUDE_ENERGYSITES: True, "initial_setup": True, + CONF_API_PROXY_URL: TEST_API_PROXY_URL, + CONF_API_PROXY_CERT: TEST_API_PROXY_CERT, + CONF_CLIENT_ID: TEST_CLIENT_ID, } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -90,18 +186,22 @@ async def test_form_invalid_auth(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_PROXY_ENABLE: False} + ) + await hass.async_block_till_done() with patch( "custom_components.tesla_custom.config_flow.TeslaAPI.connect", side_effect=TeslaException(401), ): - result2 = await hass.config_entries.flow.async_configure( + result3 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_USERNAME: TEST_USERNAME, CONF_TOKEN: TEST_TOKEN}, + {CONF_TOKEN: TEST_TOKEN, CONF_USERNAME: TEST_USERNAME}, ) - assert result2["type"] == "form" - assert result2["errors"] == {"base": "invalid_auth"} + assert result3["type"] == "form" + assert result3["errors"] == {"base": "invalid_auth"} async def test_form_invalid_auth_incomplete_credentials(hass): @@ -109,18 +209,22 @@ async def test_form_invalid_auth_incomplete_credentials(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_PROXY_ENABLE: False} + ) + await hass.async_block_till_done() with patch( "custom_components.tesla_custom.config_flow.TeslaAPI.connect", side_effect=IncompleteCredentials(401), ): - result2 = await hass.config_entries.flow.async_configure( + result3 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: TEST_USERNAME, CONF_TOKEN: TEST_TOKEN}, ) - assert result2["type"] == "form" - assert result2["errors"] == {"base": "invalid_auth"} + assert result3["type"] == "form" + assert result3["errors"] == {"base": "invalid_auth"} async def test_form_cannot_connect(hass): @@ -128,18 +232,22 @@ async def test_form_cannot_connect(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_PROXY_ENABLE: False} + ) + await hass.async_block_till_done() with patch( "custom_components.tesla_custom.config_flow.TeslaAPI.connect", side_effect=TeslaException(code=HTTPStatus.NOT_FOUND), ): - result2 = await hass.config_entries.flow.async_configure( + result3 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_TOKEN: TEST_TOKEN, CONF_USERNAME: TEST_USERNAME}, ) - assert result2["type"] == "form" - assert result2["errors"] == {"base": "cannot_connect"} + assert result3["type"] == "form" + assert result3["errors"] == {"base": "cannot_connect"} async def test_form_repeat_identifier(hass): @@ -155,6 +263,11 @@ async def test_form_repeat_identifier(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_PROXY_ENABLE: False} + ) + await hass.async_block_till_done() + with patch( "custom_components.tesla_custom.config_flow.TeslaAPI.connect", return_value={ @@ -163,13 +276,13 @@ async def test_form_repeat_identifier(hass): CONF_EXPIRATION: TEST_VALID_EXPIRATION, }, ): - result2 = await hass.config_entries.flow.async_configure( + result3 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: TEST_USERNAME, CONF_TOKEN: TEST_TOKEN}, ) - assert result2["type"] == "abort" - assert result2["reason"] == "already_configured" + assert result3["type"] == "abort" + assert result3["reason"] == "already_configured" async def test_form_reauth(hass): @@ -187,6 +300,11 @@ async def test_form_reauth(hass): context={"source": config_entries.SOURCE_REAUTH}, data={CONF_USERNAME: TEST_USERNAME}, ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_PROXY_ENABLE: False} + ) + await hass.async_block_till_done() + with patch( "custom_components.tesla_custom.config_flow.TeslaAPI.connect", return_value={ @@ -195,13 +313,13 @@ async def test_form_reauth(hass): CONF_EXPIRATION: TEST_VALID_EXPIRATION, }, ): - result2 = await hass.config_entries.flow.async_configure( + result3 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: TEST_USERNAME, CONF_TOKEN: "new-password"}, ) - assert result2["type"] == "abort" - assert result2["reason"] == "reauth_successful" + assert result3["type"] == "abort" + assert result3["reason"] == "reauth_successful" async def test_import(hass): @@ -223,6 +341,10 @@ async def test_import(hass): CONF_USERNAME: TEST_USERNAME, CONF_INCLUDE_VEHICLES: True, CONF_INCLUDE_ENERGYSITES: True, + CONF_API_PROXY_ENABLE: False, + CONF_API_PROXY_CERT: None, + CONF_API_PROXY_URL: None, + CONF_CLIENT_ID: None, }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY