Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

2021-02-13 #141

Merged
merged 12 commits into from
Feb 13, 2021
2 changes: 1 addition & 1 deletion .github/workflows/python_package.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.8, 3.9]
python-version: [3.7, 3.8]

steps:
- uses: actions/checkout@v2
Expand Down
4 changes: 2 additions & 2 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ pytest-cov = "*"
tox = "*"
tox-pipenv = "*"
twine = "*"
python-semantic-release = "*"
black = "*"

[packages]
aiohttp = "*"
backoff = "*"
bs4 = "*"
beautifulsoup4 = "*"
wrapt = "*"
authcaptureproxy = "0.4.0"

[pipenv]
allow_prereleases = true
941 changes: 557 additions & 384 deletions Pipfile.lock

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
# teslajsonpy
[![Version status](https://img.shields.io/pypi/status/teslajsonpy)](https://pypi.org/project/teslajsonpy)
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Python version compatibility](https://img.shields.io/pypi/pyversions/teslajsonpy)](https://pypi.org/project/teslajsonpy)
[![Version on Github](https://img.shields.io/github/v/release/zabuldon/teslajsonpy?include_prereleases&label=GitHub)](https://github.com/zabuldon/teslajsonpy/releases)
[![Version on PyPi](https://img.shields.io/pypi/v/teslajsonpy)](https://pypi.org/project/teslajsonpy)
![PyPI - Downloads](https://img.shields.io/pypi/dd/teslajsonpy)
![PyPI - Downloads](https://img.shields.io/pypi/dw/teslajsonpy)
![PyPI - Downloads](https://img.shields.io/pypi/dm/teslajsonpy)

Async python module for Tesla API primarily for enabling Home-Assistant.

Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ indent = " "
not_skip = __init__.py
# will group `import x` and `from x import` of the same module.
force_sort_within_sections = true
sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
default_section = THIRDPARTY
known_first_party = teslajsonpy,tests
forced_separate = tests
Expand Down
4 changes: 2 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@
URL = "https://github.com/zabuldon/teslajsonpy"
EMAIL = "[email protected]"
AUTHOR = "Sergey Isachenko"
REQUIRES_PYTHON = ">=3.6"
REQUIRES_PYTHON = ">=3.6.1"
LICENSE = "Apache-2.0"
VERSION = None

# What packages are required for this module to be executed?
REQUIRED = ["aiohttp", "backoff", "bs4", "wrapt"]
REQUIRED = ["aiohttp", "authcaptureproxy", "backoff", "beautifulsoup4", "wrapt"]

# What packages are optional?
EXTRAS = {
Expand Down
70 changes: 52 additions & 18 deletions teslajsonpy/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def __init__(
password: Text = None,
access_token: Text = None,
refresh_token: Text = None,
authorization_token=None,
authorization_token: Text = None,
expiration: int = 0,
) -> None:
"""Initialize connection object."""
Expand Down Expand Up @@ -80,6 +80,10 @@ async def get(self, command):
async def post(self, command, method="post", data=None):
"""Post data to API."""
now = calendar.timegm(datetime.datetime.now().timetuple())
_LOGGER.debug(
"Token expiration in %s",
str(datetime.timedelta(seconds=self.expiration - now)),
)
if now > self.expiration:
self.token_refreshed = False
auth = {}
Expand All @@ -91,11 +95,13 @@ async def post(self, command, method="post", data=None):
and not self.sso_oauth.get("refresh_token")
)
):
_LOGGER.debug("Getting sso auth code using credentials")
if self.email and self.password:
_LOGGER.debug("Getting sso auth code using credentials")
self.code = await self.get_authorization_code(
self.email, self.password
)
else:
_LOGGER.debug("Using existing authorization code")
auth = await self.get_sso_auth_token(self.code)
elif self.sso_oauth.get("refresh_token") and now > self.sso_oauth.get(
"expires_in", 0
Expand All @@ -104,23 +110,39 @@ async def post(self, command, method="post", data=None):
auth = await self.refresh_access_token(
refresh_token=self.sso_oauth.get("refresh_token")
)
if auth:
if auth and all(
[
auth.get(item)
for item in ["access_token", "refresh_token", "expires_in"]
]
):
self.sso_oauth = {
"access_token": auth["access_token"],
"refresh_token": auth["refresh_token"],
"expires_in": auth["expires_in"] + now,
}
_LOGGER.debug("Saving new auth info %s", self.sso_oauth)
_LOGGER.debug("Saved new auth info %s", self.sso_oauth)
else:
_LOGGER.debug("Unable to refresh sso oauth token")
if auth:
_LOGGER.debug("Auth returned %s", auth)
self.code = None
self.sso_oauth = {}
raise IncompleteCredentials("Need oauth credentials")
auth = await self.get_bearer_token(
access_token=self.sso_oauth.get("access_token")
)
self.__sethead(
access_token=auth["access_token"], expires_in=auth["expires_in"]
)
_LOGGER.debug("Received bearer token %s", auth)
if auth.get("created_at"):
# use server time if available
self.__sethead(
access_token=auth["access_token"],
expiration=auth["expires_in"] + auth["created_at"],
)
else:
self.__sethead(
access_token=auth["access_token"], expires_in=auth["expires_in"]
)
self.refresh_token = auth["refresh_token"]
self.token_refreshed = True
_LOGGER.debug("Successfully refreshed oauth")
Expand Down Expand Up @@ -347,7 +369,29 @@ async def get_authorization_code(self, email, password) -> Text:
if not (email and password):
_LOGGER.debug("No email or password for login; unable to login.")
return
url = self.get_authorization_code_link(new=True)
resp = await self.websession.get(url)
html = await resp.text()
soup: BeautifulSoup = BeautifulSoup(html, "html.parser")
data = get_inputs(soup)
data["identity"] = self.email
data["credential"] = self.password
resp = await self.websession.post(url, data=data)
_process_resp(resp)
code_url = URL(resp.history[-1].url)
return code_url.query.get("code")

def get_authorization_code_link(self, new=False) -> yarl.URL:
"""Get authorization code url for the oauth3 login method."""
# https://tesla-api.timdorr.com/api-basics/authentication#step-2-obtain-an-authorization-code
if new:
self.code_verifier: Text = secrets.token_urlsafe(64)
self.code_challenge = str(
base64.urlsafe_b64encode(
hashlib.sha256(self.code_verifier.encode()).hexdigest().encode()
),
"utf-8",
)
state = secrets.token_urlsafe(64)
query = {
"client_id": "ownerapi",
Expand All @@ -360,17 +404,7 @@ async def get_authorization_code(self, email, password) -> Text:
}
url = yarl.URL("https://auth.tesla.com/oauth2/v3/authorize")
url = url.update_query(query)
_LOGGER.debug("Getting sso auth token from %s", url)
resp = await self.websession.get(url)
html = await resp.text()
soup: BeautifulSoup = BeautifulSoup(html, "html.parser")
data = get_inputs(soup)
data["identity"] = self.email
data["credential"] = self.password
resp = await self.websession.post(url, data=data)
_process_resp(resp)
code_url = URL(resp.history[-1].url)
return code_url.query.get("code")
return url

async def get_sso_auth_token(self, code):
"""Get sso auth token."""
Expand Down
40 changes: 31 additions & 9 deletions teslajsonpy/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@
import asyncio
import logging
import time
from typing import Callable, Optional, Text, Tuple
from typing import Callable, Dict, Optional, Text

from aiohttp import ClientConnectorError
import backoff
import wrapt
from yarl import URL

from teslajsonpy.connection import Connection
from teslajsonpy.const import (
Expand Down Expand Up @@ -222,7 +223,12 @@ def __init__(

"""
self.__connection = Connection(
websession, email, password, access_token, refresh_token, expiration
websession=websession,
email=email,
password=password,
access_token=access_token,
refresh_token=refresh_token,
expiration=expiration,
)
self.__components = []
self._update_interval: int = update_interval
Expand Down Expand Up @@ -252,7 +258,7 @@ def __init__(

async def connect(
self, test_login=False, wake_if_asleep=False, filtered_vins=None
) -> Tuple[Text, Text]:
) -> Dict[Text, Text]:
"""Connect controller to Tesla.

Args
Expand All @@ -261,7 +267,7 @@ async def connect(
filtered_vins (list, optional): If not empty, filters the cars by the provided VINs.

Returns
Tuple[Text, Text]: Returns the refresh_token and access_token
Dict[Text, Text]: Returns the refresh_token, access_token, and expires_in time

"""

Expand Down Expand Up @@ -306,7 +312,11 @@ async def connect(
await asyncio.gather(*tasks)
except (TeslaException, RetryLimitError):
pass
return (self.__connection.refresh_token, self.__connection.access_token)
return {
"refresh_token": self.__connection.refresh_token,
"access_token": self.__connection.access_token,
"expiration": self.__connection.expiration,
}

def is_token_refreshed(self) -> bool:
"""Return whether token has been changed and not retrieved.
Expand All @@ -317,17 +327,21 @@ def is_token_refreshed(self) -> bool:
"""
return self.__connection.token_refreshed

def get_tokens(self) -> Tuple[Text, Text]:
"""Return refresh and access tokens.
def get_tokens(self) -> Dict[Text, Text]:
"""Return oauth data including refresh and access tokens, and expires time.

This will set the the self.__connection token_refreshed to False.

Returns
Tuple[Text, Text]: Returns a tuple of refresh and access tokens
Dict[Text, Text]: Returns the refresh_token, access_token, and expires time

"""
self.__connection.token_refreshed = False
return (self.__connection.refresh_token, self.__connection.access_token)
return {
"refresh_token": self.__connection.refresh_token,
"access_token": self.__connection.access_token,
"expiration": self.__connection.expiration,
}

def get_expiration(self) -> int:
"""Return expiration for oauth.
Expand All @@ -338,6 +352,14 @@ def get_expiration(self) -> int:
"""
return self.__connection.expiration

def get_oauth_url(self) -> URL:
"""Return oauth url."""
return self.__connection.get_authorization_code_link(new=True)

def set_authorization_code(self, code: Text) -> None:
"""Set authorization code in Connection."""
self.__connection.code = code

def register_websocket_callback(self, callback) -> int:
"""Register callback for websocket messages.

Expand Down
Loading