├── tests ├── __init__.py ├── bandit.yaml └── test_init.py ├── custom_components ├── __init__.py └── tesla_gateway │ ├── const.py │ ├── strings.json │ ├── translations │ └── en.json │ ├── manifest.json │ ├── services.yaml │ ├── config_flow.py │ ├── __init__.py │ └── __init__.py.0eb19d943460c39f631c0777ff8e7ae8.tmp ├── .vscode └── settings.json ├── requirements.test.txt ├── hacs.json ├── .github └── workflows │ ├── hassfest.yaml │ └── validate.yaml ├── README.md ├── setup.cfg ├── .pre-commit-config.yaml └── .gitignore /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.formatting.provider": "black" 3 | } -------------------------------------------------------------------------------- /requirements.test.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-cov==2.9.0 3 | pytest-homeassistant-custom-component 4 | -------------------------------------------------------------------------------- /custom_components/tesla_gateway/const.py: -------------------------------------------------------------------------------- 1 | DOMAIN = "tesla_gateway" 2 | CONF_REFRESH_TOKEN = "refresh_token" 3 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Tesla Gateway", 3 | "render_readme": true, 4 | "iot_class": "Cloud Polling" 5 | } 6 | -------------------------------------------------------------------------------- /tests/bandit.yaml: -------------------------------------------------------------------------------- 1 | # https://bandit.readthedocs.io/en/latest/config.html 2 | 3 | tests: 4 | - B108 5 | - B306 6 | - B307 7 | - B313 8 | - B314 9 | - B315 10 | - B316 11 | - B317 12 | - B318 13 | - B319 14 | - B320 15 | - B325 16 | - B602 17 | - B604 18 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v2" 14 | - uses: home-assistant/actions/hassfest@master 15 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | """Test component setup.""" 2 | from homeassistant.setup import async_setup_component 3 | 4 | from custom_components.tesla_gateway.const import DOMAIN 5 | 6 | 7 | async def test_async_setup(hass): 8 | """Test the component gets setup.""" 9 | assert await async_setup_component(hass, DOMAIN, {}) is True 10 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | validate: 9 | runs-on: "ubuntu-latest" 10 | steps: 11 | - uses: "actions/checkout@v2" 12 | - name: HACS validation 13 | uses: "hacs/action@main" 14 | with: 15 | category: "integration" 16 | -------------------------------------------------------------------------------- /custom_components/tesla_gateway/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": {}, 4 | "step": { 5 | "user": { 6 | "data": { "username": "Tesla Username", "password": "Tesla Password" }, 7 | "description": "Enter your Tesla Gatway details.", 8 | "title": "Authentication" 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /custom_components/tesla_gateway/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": {}, 4 | "step": { 5 | "user": { 6 | "data": { "username": "Tesla Username", "password": "Tesla Password" }, 7 | "description": "Enter your Tesla Gatway details.", 8 | "title": "Authentication" 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /custom_components/tesla_gateway/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "codeowners": ["@carboncoop"], 3 | "config_flow": true, 4 | "dependencies": [], 5 | "documentation": "https://github.com/carboncoop/tesla-gateway-ha-component", 6 | "issue_tracker": "https://github.com/carboncoop/tesla-gateway-ha-component/issues", 7 | "domain": "tesla_gateway", 8 | "name": "Tesla Gateway", 9 | "version": "0.1.0", 10 | "requirements": ["teslapy==1.1.0"], 11 | "iot_class": "cloud_polling" 12 | } 13 | -------------------------------------------------------------------------------- /custom_components/tesla_gateway/services.yaml: -------------------------------------------------------------------------------- 1 | set_operation: 2 | description: > 3 | Changes operation mode 4 | fields: 5 | real_mode: 6 | description: Mode to set to the Tesla gateway. 7 | example: "self_consumption, backup, autonomous" 8 | backup_reserve_percent: 9 | description: Percentage of battery reserve 10 | example: 10 11 | set_reserve: 12 | description: > 13 | Changes battery reserve percent in self_consumption mode 14 | fields: 15 | backup_reserve_percent: 16 | description: Percentage of battery reserve 17 | example: 70 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tesla Powerwall Gateway for Home Assistant 2 | 3 | Uses the Tesla API to control a Powerwall. 4 | 5 | ## Installation 6 | 7 | Install the repository through HACS by adding a custom repository or by manually copying the `custom_components/tesla_gateway` folder into your `custom_components` folder. 8 | 9 | ## Configuration 10 | 11 | The component is now configured through the user interface. 12 | 13 | To setup the integration, got to Configuration -> Integrations, and search for Tesla Gateway 14 | Add your Tesla username and password. 15 | 16 | ## Services 17 | 18 | The integration provides two services - `set_operation` and `set_reserve`. 19 | You can call these from the Developer -> Services page, or include them in automations. 20 | 21 | ### set_operation 22 | 23 | Sets the operation mode of the PowerWall. Possible values include `self_consumption`, `backup` or `autonomous`. 24 | Service data looks like this: 25 | 26 | ``` 27 | real_mode: 'self_consumption' 28 | backup_reserve_percent: 20 29 | ``` 30 | 31 | ### set_reserve 32 | 33 | Changes battery reserve percent. 34 | 35 | ``` 36 | backup_reserve_percent: 10 37 | ``` 38 | -------------------------------------------------------------------------------- /custom_components/tesla_gateway/config_flow.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | 3 | from homeassistant import config_entries 4 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME 5 | import homeassistant.helpers.config_validation as cv 6 | import voluptuous as vol 7 | 8 | from .const import DOMAIN 9 | 10 | AUTH_SCHEMA = vol.Schema( 11 | { 12 | vol.Required(CONF_USERNAME): cv.string, 13 | vol.Optional(CONF_PASSWORD): cv.string, 14 | } 15 | ) 16 | 17 | 18 | class TeslaGatewayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 19 | """Tesla Gateway config flow.""" 20 | 21 | data: Optional[Dict[str, Any]] 22 | 23 | async def async_step_user(self, user_input: Optional[Dict[str, Any]] = None): 24 | """Invoked when a user initiates a flow via the user interface.""" 25 | errors: Dict[str, str] = {} 26 | if user_input is not None: 27 | if not errors: 28 | self.data = user_input 29 | return self.async_create_entry(title="Tesla Gateway", data=self.data) 30 | 31 | return self.async_show_form( 32 | step_id="user", data_schema=AUTH_SCHEMA, errors=errors 33 | ) 34 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | source = 3 | custom_components 4 | 5 | [coverage:report] 6 | exclude_lines = 7 | pragma: no cover 8 | raise NotImplemented() 9 | if __name__ == '__main__': 10 | main() 11 | show_missing = true 12 | 13 | [tool:pytest] 14 | testpaths = tests 15 | norecursedirs = .git 16 | addopts = 17 | --strict 18 | --cov=custom_components 19 | 20 | [flake8] 21 | # https://github.com/ambv/black#line-length 22 | max-line-length = 88 23 | # E501: line too long 24 | # W503: Line break occurred before a binary operator 25 | # E203: Whitespace before ':' 26 | # D202 No blank lines allowed after function docstring 27 | # W504 line break after binary operator 28 | ignore = 29 | E501, 30 | W503, 31 | E203, 32 | D202, 33 | W504 34 | 35 | [isort] 36 | # https://github.com/timothycrosley/isort 37 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 38 | # splits long import on multiple lines indented by 4 spaces 39 | multi_line_output = 3 40 | include_trailing_comma=True 41 | force_grid_wrap=0 42 | use_parentheses=True 43 | line_length=88 44 | indent = " " 45 | # by default isort don't check module indexes 46 | not_skip = __init__.py 47 | # will group `import x` and `from x import` of the same module. 48 | force_sort_within_sections = true 49 | sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 50 | default_section = THIRDPARTY 51 | known_first_party = custom_components,tests 52 | forced_separate = tests 53 | combine_as_imports = true 54 | 55 | [mypy] 56 | python_version = 3.7 57 | ignore_errors = true 58 | follow_imports = silent 59 | ignore_missing_imports = true 60 | warn_incomplete_stub = true 61 | warn_redundant_casts = true 62 | warn_unused_configs = true 63 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/asottile/pyupgrade 3 | rev: v2.3.0 4 | hooks: 5 | - id: pyupgrade 6 | args: [--py37-plus] 7 | - repo: https://github.com/psf/black 8 | rev: 19.10b0 9 | hooks: 10 | - id: black 11 | args: 12 | - --safe 13 | - --quiet 14 | files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$ 15 | - repo: https://github.com/codespell-project/codespell 16 | rev: v1.16.0 17 | hooks: 18 | - id: codespell 19 | args: 20 | - --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing 21 | - --skip="./.*,*.csv,*.json" 22 | - --quiet-level=2 23 | exclude_types: [csv, json] 24 | - repo: https://gitlab.com/pycqa/flake8 25 | rev: 3.8.1 26 | hooks: 27 | - id: flake8 28 | additional_dependencies: 29 | - flake8-docstrings==1.5.0 30 | - pydocstyle==5.0.2 31 | files: ^(homeassistant|script|tests)/.+\.py$ 32 | - repo: https://github.com/PyCQA/bandit 33 | rev: 1.6.2 34 | hooks: 35 | - id: bandit 36 | args: 37 | - --quiet 38 | - --format=custom 39 | - --configfile=tests/bandit.yaml 40 | files: ^(homeassistant|script|tests)/.+\.py$ 41 | - repo: https://github.com/pre-commit/mirrors-isort 42 | rev: v4.3.21 43 | hooks: 44 | - id: isort 45 | - repo: https://github.com/pre-commit/pre-commit-hooks 46 | rev: v2.4.0 47 | hooks: 48 | - id: check-executables-have-shebangs 49 | stages: [manual] 50 | - id: check-json 51 | - repo: https://github.com/pre-commit/mirrors-mypy 52 | rev: v0.770 53 | hooks: 54 | - id: mypy 55 | args: 56 | - --pretty 57 | - --show-error-codes 58 | - --show-error-context 59 | -------------------------------------------------------------------------------- /custom_components/tesla_gateway/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Monitors and controls the Tesla gateway. 3 | """ 4 | import logging 5 | 6 | import asyncio 7 | import voluptuous as vol 8 | import teslapy 9 | 10 | from homeassistant.const import ( 11 | CONF_USERNAME, 12 | CONF_PASSWORD 13 | ) 14 | import homeassistant.helpers.config_validation as cv 15 | 16 | DOMAIN = 'tesla_gateway' 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | CONFIG_SCHEMA = vol.Schema({ 21 | DOMAIN: vol.Schema({ 22 | vol.Required(CONF_USERNAME): cv.string, 23 | vol.Required(CONF_PASSWORD): cv.string 24 | }), 25 | }, extra=vol.ALLOW_EXTRA) 26 | 27 | @asyncio.coroutine 28 | def async_setup(hass, config): 29 | 30 | domain_config = config[DOMAIN] 31 | conf_user = domain_config[CONF_USERNAME] 32 | conf_password = domain_config[CONF_PASSWORD] 33 | 34 | tesla = teslapy.Tesla(domain_config[CONF_USERNAME], domain_config[CONF_PASSWORD]) 35 | 36 | def get_battery(): 37 | batteries = tesla.battery_list() 38 | if len(batteries) > 0: 39 | return batteries[0] 40 | else: 41 | return None 42 | 43 | @asyncio.coroutine 44 | async def set_operation(service): 45 | 46 | battery = await hass.async_add_executor_job(get_battery) 47 | if not battery: 48 | _LOGGER.warning('Battery object is None') 49 | return None 50 | 51 | await hass.async_add_executor_job(battery.set_operation, service.data['real_mode']) 52 | if 'backup_reserve_percent' in service.data: 53 | await hass.async_add_executor_job(battery.set_backup_reserve_percent, service.data['backup_reserve_percent']) 54 | 55 | hass.services.async_register(DOMAIN, 'set_operation', set_operation) 56 | 57 | @asyncio.coroutine 58 | async def set_reserve(service): 59 | 60 | battery = await hass.async_add_executor_job(get_battery) 61 | if not battery: 62 | _LOGGER.warning('Battery object is None') 63 | return None 64 | 65 | if 'backup_reserve_percent' in service.data: 66 | await hass.async_add_executor_job(battery.set_backup_reserve_percent, service.data['backup_reserve_percent']) 67 | 68 | hass.services.async_register(DOMAIN, 'set_reserve', set_reserve) 69 | 70 | return True 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/python,homeassistant,vscode 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,homeassistant,vscode 4 | 5 | ### HomeAssistant ### 6 | # Files with personal details 7 | *.crt 8 | *.csr 9 | *.key 10 | .google.token 11 | .uuid 12 | icloud/ 13 | google_calendars.yaml 14 | harmony_media_room.conf 15 | home-assistant.db 16 | home-assistant_v2.db 17 | home-assistant_v2.db-* 18 | html5_push_registrations.conf 19 | ip_bans.yaml 20 | known_devices.yaml 21 | phue.conf 22 | plex.conf 23 | pyozw.sqlite 24 | secrets.yaml 25 | tradfri.conf 26 | 27 | # Temporary files 28 | *.db-journal 29 | *.pid 30 | tts 31 | 32 | # automatically downloaded dependencies 33 | deps 34 | lib 35 | www 36 | 37 | # Log files 38 | home-assistant.log 39 | ozw_log.txt 40 | 41 | 42 | ### Python ### 43 | # Byte-compiled / optimized / DLL files 44 | __pycache__/ 45 | *.py[cod] 46 | *$py.class 47 | 48 | # C extensions 49 | *.so 50 | 51 | # Distribution / packaging 52 | .Python 53 | build/ 54 | develop-eggs/ 55 | dist/ 56 | downloads/ 57 | eggs/ 58 | .eggs/ 59 | lib/ 60 | lib64/ 61 | parts/ 62 | sdist/ 63 | var/ 64 | wheels/ 65 | pip-wheel-metadata/ 66 | share/python-wheels/ 67 | *.egg-info/ 68 | .installed.cfg 69 | *.egg 70 | MANIFEST 71 | 72 | # PyInstaller 73 | # Usually these files are written by a python script from a template 74 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 75 | *.manifest 76 | *.spec 77 | 78 | # Installer logs 79 | pip-log.txt 80 | pip-delete-this-directory.txt 81 | 82 | # Unit test / coverage reports 83 | htmlcov/ 84 | .tox/ 85 | .nox/ 86 | .coverage 87 | .coverage.* 88 | .cache 89 | nosetests.xml 90 | coverage.xml 91 | *.cover 92 | *.py,cover 93 | .hypothesis/ 94 | .pytest_cache/ 95 | pytestdebug.log 96 | 97 | # Translations 98 | *.mo 99 | *.pot 100 | 101 | # Django stuff: 102 | *.log 103 | local_settings.py 104 | db.sqlite3 105 | db.sqlite3-journal 106 | 107 | # Flask stuff: 108 | instance/ 109 | .webassets-cache 110 | 111 | # Scrapy stuff: 112 | .scrapy 113 | 114 | # Sphinx documentation 115 | docs/_build/ 116 | doc/_build/ 117 | 118 | # PyBuilder 119 | target/ 120 | 121 | # Jupyter Notebook 122 | .ipynb_checkpoints 123 | 124 | # IPython 125 | profile_default/ 126 | ipython_config.py 127 | 128 | # pyenv 129 | .python-version 130 | 131 | # pipenv 132 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 133 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 134 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 135 | # install all needed dependencies. 136 | #Pipfile.lock 137 | 138 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 139 | __pypackages__/ 140 | 141 | # Celery stuff 142 | celerybeat-schedule 143 | celerybeat.pid 144 | 145 | # SageMath parsed files 146 | *.sage.py 147 | 148 | # Environments 149 | .env 150 | .venv 151 | env/ 152 | venv/ 153 | ENV/ 154 | env.bak/ 155 | venv.bak/ 156 | pythonenv* 157 | 158 | # Spyder project settings 159 | .spyderproject 160 | .spyproject 161 | 162 | # Rope project settings 163 | .ropeproject 164 | 165 | # mkdocs documentation 166 | /site 167 | 168 | # mypy 169 | .mypy_cache/ 170 | .dmypy.json 171 | dmypy.json 172 | 173 | # Pyre type checker 174 | .pyre/ 175 | 176 | # pytype static type analyzer 177 | .pytype/ 178 | 179 | # profiling data 180 | .prof 181 | 182 | ### vscode ### 183 | .vscode/* 184 | !.vscode/settings.json 185 | !.vscode/tasks.json 186 | !.vscode/launch.json 187 | !.vscode/extensions.json 188 | *.code-workspace 189 | 190 | # End of https://www.toptal.com/developers/gitignore/api/python,homeassistant,vscode 191 | 192 | -------------------------------------------------------------------------------- /custom_components/tesla_gateway/__init__.py.0eb19d943460c39f631c0777ff8e7ae8.tmp: -------------------------------------------------------------------------------- 1 | """ 2 | Monitors and controls the Tesla gateway. 3 | """ 4 | import logging 5 | 6 | import aiohttp 7 | import asyncio 8 | import async_timeout 9 | import base64 10 | import hashlib 11 | import json 12 | import os 13 | import re 14 | import time 15 | from urllib.parse import parse_qs 16 | import voluptuous as vol 17 | from homeassistant import config_entries, core 18 | from homeassistant.const import ( 19 | CONF_USERNAME, 20 | CONF_PASSWORD, 21 | CONF_ACCESS_TOKEN, 22 | ) 23 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 24 | import homeassistant.helpers.config_validation as cv 25 | 26 | from .const import DOMAIN, CONF_REFRESH_TOKEN 27 | 28 | _LOGGER = logging.getLogger(__name__) 29 | 30 | DEFAULT_TIMEOUT = 100 31 | 32 | CONFIG_SCHEMA = vol.Schema( 33 | { 34 | DOMAIN: vol.Schema( 35 | { 36 | vol.Required(CONF_USERNAME): cv.string, 37 | vol.Required(CONF_PASSWORD): cv.string, 38 | vol.Optional(CONF_ACCESS_TOKEN, default=""): cv.string, 39 | vol.Optional(CONF_REFRESH_TOKEN, default=""): cv.string, 40 | } 41 | ), 42 | }, 43 | extra=vol.ALLOW_EXTRA, 44 | ) 45 | 46 | tesla_base_url = "https://owner-api.teslamotors.com" 47 | tesla_auth_url = "https://auth.tesla.com" 48 | 49 | step_max_attempts = 7 50 | step_attempt_sleep = 3 51 | TESLA_CLIENT_ID = "81527cff06843c8634fdc09e8ac0abefb46ac849f38fe1e431c2ef2106796384" 52 | 53 | 54 | @asyncio.coroutine 55 | def async_setup(hass: core.HomeAssistant, config: dict) -> bool: 56 | websession = async_get_clientsession(hass, verify_ssl=False) 57 | hass.data.setdefault(DOMAIN, {}) 58 | domain_config = config[DOMAIN] 59 | setup_common(domain_config=domain_config) 60 | return True 61 | 62 | 63 | @asyncio.coroutine 64 | def async_setup_entry( 65 | hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry 66 | ) -> bool: 67 | websession = async_get_clientsession(hass, verify_ssl=False) 68 | 69 | hass.data.setdefault(DOMAIN, {}) 70 | hass.data[DOMAIN][config_entry.entry_id] = config_entry.data 71 | domain_config = dict(config_entry.data) 72 | # conf_user = domain_config[CONF_USERNAME] 73 | # conf_password = domain_config[CONF_PASSWORD] 74 | setup_common(domain_config=domain_config) 75 | return True 76 | 77 | 78 | @asyncio.coroutine 79 | def setup_common(domain_config: dict): 80 | @asyncio.coroutine 81 | def SSO_login(): 82 | 83 | # Code extracted from https://github.com/enode-engineering/tesla-oauth2/blob/2414d74a50f38ab7b3ad5424de4e867ac2709dcf/tesla.py 84 | # Login process explained at https://tesla-api.timdorr.com/api-basics/authentication 85 | 86 | authorize_url = tesla_auth_url + "/oauth2/v3/authorize" 87 | callback_url = tesla_auth_url + "/void/callback" 88 | 89 | headers = { 90 | "User-Agent": "curl", 91 | "x-tesla-user-agent": "TeslaApp/3.10.9-433/adff2e065/android/10", 92 | "X-Requested-With": "com.teslamotors.tesla", 93 | } 94 | 95 | verifier_bytes = os.urandom(86) 96 | code_verifier = base64.urlsafe_b64encode(verifier_bytes).rstrip(b"=") 97 | code_challenge = ( 98 | base64.urlsafe_b64encode(hashlib.sha256(code_verifier).digest()) 99 | .rstrip(b"=") 100 | .decode("utf-8") 101 | ) 102 | state = base64.urlsafe_b64encode(os.urandom(16)).rstrip(b"=").decode("utf-8") 103 | 104 | params = ( 105 | ("client_id", "ownerapi"), 106 | ("code_challenge", code_challenge), 107 | ("code_challenge_method", "S256"), 108 | ("redirect_uri", callback_url), 109 | ("response_type", "code"), 110 | ("scope", "openid email offline_access"), 111 | ("state", state), 112 | ) 113 | 114 | try: 115 | # Step 1: Obtain the login page 116 | _LOGGER.debug("Step 1: GET %s\nparams %s", authorize_url, params) 117 | for attempt in range(step_max_attempts): 118 | with async_timeout.timeout(DEFAULT_TIMEOUT, loop=hass.loop): 119 | response = yield from websession.get( 120 | authorize_url, 121 | headers=headers, 122 | params=params, 123 | raise_for_status=False, 124 | ) 125 | 126 | returned_text = yield from response.text() 127 | if response.status == 200 and "" in returned_text: 128 | crsf_regex_result = re.search( 129 | r'name="_csrf".+value="([^"]+)"', returned_text 130 | ) 131 | if crsf_regex_result: 132 | _LOGGER.debug("Step 1: Success on attempt %d", attempt) 133 | break 134 | 135 | _LOGGER.warning( 136 | "Step 1: Error %d on attempt %d, call %s:\n%s", 137 | response.status, 138 | attempt, 139 | response.url, 140 | returned_text, 141 | ) 142 | time.sleep(step_attempt_sleep) 143 | else: 144 | raise ValueError( 145 | "Step 1: failed after %d attempts, last response %s:\n%s", 146 | step_max_attempts, 147 | response.status, 148 | returned_text, 149 | ) 150 | 151 | # Step 2: Obtain an authorization code 152 | csrf = crsf_regex_result.group(1) 153 | transaction_id = re.search( 154 | r'name="transaction_id".+value="([^"]+)"', returned_text 155 | ).group(1) 156 | 157 | body = { 158 | "_csrf": csrf, 159 | "_phase": "authenticate", 160 | "_process": "1", 161 | "transaction_id": transaction_id, 162 | "cancel": "", 163 | "identity": domain_config[CONF_USERNAME], 164 | "credential": domain_config[CONF_PASSWORD], 165 | } 166 | 167 | _LOGGER.debug( 168 | "Step 2: POST %s\nparams: %s\nbody: %s", authorize_url, params, body 169 | ) 170 | for attempt in range(step_max_attempts): 171 | with async_timeout.timeout(DEFAULT_TIMEOUT, loop=hass.loop): 172 | response = yield from websession.post( 173 | authorize_url, 174 | headers=headers, 175 | params=params, 176 | data=body, 177 | raise_for_status=False, 178 | allow_redirects=False, 179 | ) 180 | 181 | returned_text = yield from response.text() 182 | 183 | if ( 184 | "We could not sign you in" in returned_text 185 | and response.status == 401 186 | ): 187 | raise ValueError( 188 | "Step 2: Invalid credentials. Error %d on call %s:\n%s", 189 | response.status, 190 | response.url, 191 | returned_text, 192 | ) 193 | 194 | if response.status == 302 or "<title>" in returned_text: 195 | _LOGGER.debug("Step 2: Success on attempt %d", attempt) 196 | break 197 | 198 | _LOGGER.warning( 199 | "Step 2: Error %d on call %s:\n%s", 200 | response.status, 201 | response.url, 202 | returned_text, 203 | ) 204 | time.sleep(step_attempt_sleep) 205 | else: 206 | raise ValueError( 207 | "Step 2: failed after %d attempts, last response %s:\n%s", 208 | step_max_attempts, 209 | response.status, 210 | returned_text, 211 | ) 212 | 213 | is_mfa = ( 214 | True 215 | if response.status == 200 and "/mfa/verify" in returned_text 216 | else False 217 | ) 218 | if is_mfa: 219 | raise ValueError( 220 | "Multi-factor authentication enabled for the account and not supported" 221 | ) 222 | 223 | # Step 3: Exchange authorization code for bearer token 224 | code = parse_qs(response.headers["location"])[callback_url + "?code"] 225 | 226 | token_url = tesla_auth_url + "/oauth2/v3/token" 227 | body = { 228 | "grant_type": "authorization_code", 229 | "client_id": "ownerapi", 230 | "code_verifier": code_verifier.decode("utf-8"), 231 | "code": code, 232 | "redirect_uri": callback_url, 233 | } 234 | 235 | _LOGGER.debug("Step 3: POST %s", token_url) 236 | with async_timeout.timeout(DEFAULT_TIMEOUT, loop=hass.loop): 237 | response = yield from websession.post( 238 | token_url, headers=headers, data=body, raise_for_status=False 239 | ) 240 | 241 | returned_json = yield from response.json() 242 | access_token = returned_json["access_token"] 243 | domain_config[CONF_ACCESS_TOKEN] = access_token 244 | domain_config[CONF_REFRESH_TOKEN] = returned_json["refresh_token"] 245 | return access_token 246 | 247 | except asyncio.TimeoutError: 248 | _LOGGER.warning("Timeout call %s.", response.url) 249 | 250 | except aiohttp.ClientError: 251 | _LOGGER.error("Client error %s.", response.url) 252 | 253 | return None 254 | 255 | @asyncio.coroutine 256 | def SSO_refresh_token(): 257 | token_oauth2_url = tesla_auth_url + "/oauth2/v3/token" 258 | headers = { 259 | "User-Agent": "curl", 260 | "x-tesla-user-agent": "TeslaApp/3.10.9-433/adff2e065/android/10", 261 | "X-Requested-With": "com.teslamotors.tesla", 262 | } 263 | body = { 264 | "grant_type": "refresh_token", 265 | "refresh_token": domain_config[CONF_REFRESH_TOKEN], 266 | "client_id": "ownerapi", 267 | "scope": "openid email offline_access", 268 | } 269 | with async_timeout.timeout(DEFAULT_TIMEOUT, loop=hass.loop): 270 | response = yield from websession.post( 271 | token_oauth2_url, headers=headers, data=body, raise_for_status=False 272 | ) 273 | returned_json = yield from response.json() 274 | access_token = returned_json["access_token"] 275 | domain_config[CONF_ACCESS_TOKEN] = access_token 276 | domain_config[CONF_REFRESH_TOKEN] = returned_json["refresh_token"] 277 | return access_token 278 | 279 | @asyncio.coroutine 280 | def OWNER_get_token(access_token): 281 | try: 282 | token_oauth_url = tesla_base_url + "/oauth/token" 283 | headers = { 284 | "User-Agent": "curl", 285 | "authorization": "bearer " + access_token, 286 | } 287 | body = { 288 | "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", 289 | "client_id": TESLA_CLIENT_ID, 290 | } 291 | with async_timeout.timeout(DEFAULT_TIMEOUT, loop=hass.loop): 292 | response = yield from websession.post( 293 | token_oauth_url, headers=headers, data=body, raise_for_status=False 294 | ) 295 | returned_json = yield from response.json() 296 | owner_access_token = returned_json["access_token"] 297 | return owner_access_token 298 | 299 | except asyncio.TimeoutError: 300 | _LOGGER.warning("Timeout call %s.", response.url) 301 | 302 | except aiohttp.ClientError: 303 | _LOGGER.error("Client error %s.", response.url) 304 | 305 | return None 306 | 307 | @asyncio.coroutine 308 | def OWNER_revoke(owner_token): 309 | revoke_url = tesla_base_url + "/oauth/revoke" 310 | headers = {"Content-type": "application/json"} 311 | body = {"token": owner_token} 312 | 313 | try: 314 | with async_timeout.timeout(DEFAULT_TIMEOUT, loop=hass.loop): 315 | response = yield from websession.post( 316 | revoke_url, headers=headers, json=body, raise_for_status=False 317 | ) 318 | 319 | if response.status != 200: 320 | returned_text = yield from response.text() 321 | _LOGGER.warning( 322 | "Error %d on call %s:\n%s", 323 | response.status, 324 | response.url, 325 | returned_text, 326 | ) 327 | else: 328 | _LOGGER.debug("revoke completed") 329 | return True 330 | 331 | except asyncio.TimeoutError: 332 | _LOGGER.warning("Timeout call %s.", response.url) 333 | 334 | except aiohttp.ClientError: 335 | _LOGGER.error("Client error %s.", response.url) 336 | 337 | return False 338 | 339 | @asyncio.coroutine 340 | def get_energy_site_id(owner_token): 341 | list_url = tesla_base_url + "/api/1/products" 342 | headers = {"Authorization": "Bearer " + owner_token} 343 | body = {} 344 | 345 | try: 346 | with async_timeout.timeout(DEFAULT_TIMEOUT, loop=hass.loop): 347 | response = yield from websession.get( 348 | list_url, headers=headers, json=body, raise_for_status=False 349 | ) 350 | 351 | if response.status != 200: 352 | returned_text = yield from response.text() 353 | _LOGGER.warning( 354 | "Error %d on call %s:\n%s", 355 | response.status, 356 | response.url, 357 | returned_text, 358 | ) 359 | else: 360 | returned_json = yield from response.json() 361 | for r in returned_json["response"]: 362 | if "energy_site_id" in r: 363 | return r["energy_site_id"] 364 | return None 365 | 366 | except asyncio.TimeoutError: 367 | _LOGGER.warning("Timeout call %s.", response.url) 368 | 369 | except aiohttp.ClientError: 370 | _LOGGER.error("Client error %s.", response.url) 371 | 372 | return None 373 | 374 | @asyncio.coroutine 375 | def set_operation(owner_token, energy_site_id, service_data): 376 | operation_url = tesla_base_url + "/api/1/energy_sites/{}/operation".format( 377 | energy_site_id 378 | ) 379 | headers = { 380 | "Content-type": "application/json", 381 | "Authorization": "Bearer " + owner_token, 382 | } 383 | body = { 384 | "default_real_mode": service_data["real_mode"], 385 | "backup_reserve_percent": int(service_data["backup_reserve_percent"]), 386 | } 387 | try: 388 | 389 | with async_timeout.timeout(DEFAULT_TIMEOUT, loop=hass.loop): 390 | response = yield from websession.post( 391 | operation_url, json=body, headers=headers, raise_for_status=False 392 | ) 393 | 394 | if response.status != 200: 395 | returned_text = yield from response.text() 396 | _LOGGER.warning( 397 | "Error %d on call %s:\n%s", 398 | response.status, 399 | response.url, 400 | returned_text, 401 | ) 402 | else: 403 | returned_json = yield from response.json() 404 | _LOGGER.debug( 405 | "set operation successful, request: %s response: %s", 406 | body, 407 | returned_json, 408 | ) 409 | 410 | except asyncio.TimeoutError: 411 | _LOGGER.warning("Timeout call %s.", response.url) 412 | 413 | except aiohttp.ClientError: 414 | _LOGGER.error("Client error %s.", response.url) 415 | 416 | @asyncio.coroutine 417 | def get_owner_api_token(): 418 | access_token = domain_config[CONF_ACCESS_TOKEN] 419 | if not access_token: 420 | access_token = yield from SSO_login() 421 | else: 422 | access_token = yield from SSO_refresh_token() 423 | if not access_token: 424 | return None 425 | owner_token = yield from OWNER_get_token(access_token) 426 | return owner_token 427 | 428 | @asyncio.coroutine 429 | def async_set_operation(service): 430 | owner_token = yield from get_owner_api_token() 431 | if owner_token: 432 | energy_site_id = yield from get_energy_site_id(owner_token) 433 | if energy_site_id: 434 | yield from set_operation(owner_token, energy_site_id, service.data) 435 | yield from OWNER_revoke(owner_token) 436 | 437 | hass.services.async_register(DOMAIN, "set_operation", async_set_operation) 438 | 439 | @asyncio.coroutine 440 | def set_reserve(owner_token, energy_site_id, service_data): 441 | operation_url = tesla_base_url + "/api/1/energy_sites/{}/backup".format( 442 | energy_site_id 443 | ) 444 | headers = { 445 | "Content-type": "application/json", 446 | "Authorization": "Bearer " + owner_token, 447 | } 448 | body = {"backup_reserve_percent": int(service_data["reserve_percent"])} 449 | _LOGGER.debug(body) 450 | 451 | try: 452 | with async_timeout.timeout(DEFAULT_TIMEOUT, loop=hass.loop): 453 | response = yield from websession.post( 454 | operation_url, json=body, headers=headers, raise_for_status=False 455 | ) 456 | 457 | if response.status != 200: 458 | returned_text = yield from response.text() 459 | _LOGGER.warning( 460 | "Error %d on call %s:\n%s", 461 | response.status, 462 | response.url, 463 | returned_text, 464 | ) 465 | else: 466 | returned_json = yield from response.json() 467 | _LOGGER.debug("set reserve successful, response: %s", returned_json) 468 | 469 | except asyncio.TimeoutError: 470 | _LOGGER.warning("Timeout call %s.", response.url) 471 | 472 | except aiohttp.ClientError: 473 | _LOGGER.error("Client error %s.", response.url) 474 | 475 | @asyncio.coroutine 476 | def async_set_reserve(service): 477 | owner_token = yield from get_owner_api_token() 478 | if owner_token: 479 | energy_site_id = yield from get_energy_site_id(owner_token) 480 | if energy_site_id: 481 | yield from set_reserve(owner_token, energy_site_id, service.data) 482 | yield from OWNER_revoke(owner_token) 483 | 484 | hass.services.async_register(DOMAIN, "set_reserve", async_set_reserve) 485 | 486 | return True 487 | --------------------------------------------------------------------------------