Skip to content

Commit

Permalink
Added logic and instructions to allow access token reuse (#116)
Browse files Browse the repository at this point in the history
* Added logic and instructions to allow access token reuse

* Added TOTP instructions to README
  • Loading branch information
shauntarves authored Dec 9, 2022
1 parent 19a60cd commit bf9e568
Show file tree
Hide file tree
Showing 4 changed files with 108 additions and 13 deletions.
74 changes: 70 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,72 @@ $ pip install wyze-sdk

Wyze does not provide a Web API that gives you the ability to build applications that interact with Wyze devices. This Development Kit is a reverse-engineered, module-based wrapper that makes interaction with that API possible. We have a few basic examples here with some of the more common uses but you are encouraged to [explore the full range of methods](https://wyze-sdk.readthedocs.io/en/latest/wyze_sdk.api.devices.html) available to you.

#### Authenticating

When performing user "authentication" with an email and password in the Wyze app, the credentials are exchanged for an access token and a refrsh token. These are long strings of the form `lvtx.XXXX`. When using this library, be aware that there are two method for handling authentiation:

##### Obtaining the Token and Storing it for Later Use (Preferred)

It is preferred that users first create an empty `Client` object and use the `login()` method to perform the token exchange.

```python
import os
from wyze_sdk import Client

response = Client().login(email=os.environ['WYZE_EMAIL'], password=os.environ['WYZE_PASSWORD'])
print(f"access token: {response['access_token']}")
print(f"refresh token: {response['refresh_token']}")
```

The returned values can be stored on disk or as environment variables for use in subsequent calls.

```python
import os
from wyze_sdk import Client

client = Client(token=os.environ['WYZE_ACCESS_TOKEN'])
...
```

##### (Deprecated) Automatically Authenticate Every New Client

This method has been deprecated due to issues with authentication rate limiting. While it is still a perfectly usable approach for testing or performing infrequent client actions, it **is not recommended** if you are scripting with this client library.

```python
import os
from wyze_sdk import Client
from wyze_sdk.errors import WyzeApiError

client = Client(email=os.environ['WYZE_EMAIL'], password=os.environ['WYZE_PASSWORD'])
...
```

##### Multi-Factor Authentication (2FA) Support

If your Wyze account has multi-factor authentication (2FA) enabled, you may be prompted for your 2FA code when authenticating via either supported method described above. If you wish to automate the MFA interaction, both the `Client` constructor and the `login()` method accept `totp_key` as input. If the TOTP key is provided, the MFA prompt should not appear.

```python
import os
from wyze_sdk import Client

response = Client().login(
email=os.environ['WYZE_EMAIL'],
password=os.environ['WYZE_PASSWORD'],
totp_key=os.environ['WYZE_TOTP_KEY']
)

OR

client = Client(
email=os.environ['WYZE_EMAIL'],
password=os.environ['WYZE_PASSWORD'],
totp_key=os.environ['WYZE_TOTP_KEY']
)
...
```

**Note: This does not work with SMS or email-based MFA.**

#### Listing devices in your Wyze account

One of the most common use-cases is querying device state from Wyze. If you want to access devices you own, or devices shared to you, this method will do both.
Expand All @@ -65,7 +131,7 @@ import os
from wyze_sdk import Client
from wyze_sdk.errors import WyzeApiError

client = Client(email=os.environ['WYZE_EMAIL'], password=os.environ['WYZE_PASSWORD'])
client = Client(token=os.environ['WYZE_ACCESS_TOKEN'])

try:
response = client.devices_list()
Expand All @@ -89,7 +155,7 @@ from datetime import timedelta
from wyze_sdk import Client
from wyze_sdk.errors import WyzeApiError

client = Client(email=os.environ['WYZE_EMAIL'], password=os.environ['WYZE_PASSWORD'])
client = Client(token=os.environ['WYZE_ACCESS_TOKEN'])

try:
plug = client.plugs.info(device_mac='ABCDEF1234567890')
Expand Down Expand Up @@ -117,7 +183,7 @@ import os
from wyze_sdk import Client
from wyze_sdk.errors import WyzeApiError

client = Client(email=os.environ['WYZE_EMAIL'], password=os.environ['WYZE_PASSWORD'])
client = Client(token=os.environ['WYZE_ACCESS_TOKEN'])

try:
bulb = client.bulbs.info(device_mac='ABCDEF1234567890')
Expand Down Expand Up @@ -153,7 +219,7 @@ import wyze_sdk
from wyze_sdk import Client
from wyze_sdk.errors import WyzeApiError

client = Client(email=os.environ['WYZE_EMAIL'], password=os.environ['WYZE_PASSWORD'])
client = Client(token=os.environ['WYZE_ACCESS_TOKEN'])

try:
lock = client.locks.info(device_mac='YD.LO1.abcdefg0123456789abcdefg0123456789')
Expand Down
33 changes: 27 additions & 6 deletions wyze_sdk/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,24 +39,31 @@ class Client(object):

def __init__(
self,
token: Optional[str] = None,
refresh_token: Optional[str] = None,
email: Optional[str] = None,
password: Optional[str] = None,
totp_key: Optional[str] = None,
base_url: Optional[str] = None,
timeout: int = 30,
):
#: A string used for API-based requests
self._token = None if token is None else token.strip()
#: A string that can be used to rotate the authentication token
self._refresh_token = None if refresh_token is None else refresh_token.strip()
#: A string specifying the account email address.
self._email = email
self._email = None if email is None else email.strip()
#: An unencrypted string specifying the account password.
self._password = password
self._password = None if password is None else password.strip()
#: An unencrypted string specifying the TOTP Key for automatic TOTP 2FA verification code generation.
self._totp_key = totp_key
self._totp_key = None if totp_key is None else totp_key.strip()
#: An optional string representing the API base URL. **This should not be used except for when running tests.**
self._base_url = base_url
#: The maximum number of seconds the client will wait to connect and receive a response from Wyze. Defaults to 30
self.timeout = timeout

self.login()
if self._token is None and self._email is not None:
self.login()

@property
def vacuums(self) -> VacuumsClient:
Expand Down Expand Up @@ -125,18 +132,32 @@ def _update_session(self, *, access_token: str, refresh_token: str, user_id: Opt
self._user_id = user_id
self._logger.debug("wyze user : %s", self._user_id)

def login(self) -> WyzeResponse:
def login(
self,
email: str = None,
password: str = None,
totp_key: Optional[str] = None,
) -> WyzeResponse:
"""
Exchanges email and password for an ``access_token`` and a ``refresh_token``, which
are stored in this client. The tokens will be used for all subsequent requests
made by this ``Client`` unless ``refresh_token()`` is called.
:rtype: WyzeResponse
:raises WyzeClientConfigurationError: If ``access_point`` is already set or both ``email`` and ``password`` are not set.
:raises WyzeClientConfigurationError: If ``access_token`` is already set or both ``email`` and ``password`` are not set.
"""
if self._token is not None:
raise WyzeClientConfigurationError("already logged in")

# if an email/password is provided, use them. Otherwise, use the ones
# provided when constructing the client.
if email is not None:
self._email = email.strip()
if password is not None:
self._password = password.strip()
if totp_key is not None:
self._totp_key = totp_key.strip()
if self._email is None or self._password is None:
raise WyzeClientConfigurationError("must provide email and password")
self._logger.debug(f"access token not provided, attempting to login as {self._email}")
Expand Down
10 changes: 9 additions & 1 deletion wyze_sdk/service/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def _do_request(
self,
session: requests.Session,
request: requests.Request) -> WyzeResponse:
with suppress(requests.exceptions.HTTPError, requests.exceptions.RequestException, ValueError):
try:
self._logger.info(f"requesting {request.method} to {request.url}")
self._logger.debug(f"headers: {request.headers}")
self._logger.debug(f"body: {request.body}")
Expand All @@ -90,6 +90,8 @@ def _do_request(

response = session.send(request, **settings)

response.raise_for_status()

return WyzeResponse(
client=self,
http_verb=request.method,
Expand All @@ -99,6 +101,12 @@ def _do_request(
headers=response.headers,
status_code=response.status_code,
).validate()
except requests.exceptions.HTTPError as err:
# this is a placeholder for future retry logic - for now, raise the err
raise err
except Exception as e:
self._logger.debug(f"Failed to send a request to server: {e}")
raise e

def do_post(self, url: str, headers: dict, payload: dict, params: Optional[dict] = None) -> WyzeResponse:
with requests.Session() as client:
Expand Down
4 changes: 2 additions & 2 deletions wyze_sdk/service/wyze_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,9 @@ def validate(self):
message = "The username or password is incorrect. Please check your credentials and try again."
elif response_code in [1001, 1004]:
message = "Parameters passed to Wyze Service do not fit the endpoint"
elif response_code == [1003, 2001]:
elif response_code in [1003]:
message = "Unknown request error" # FIXME what do I mean?
elif msg == 'AccessTokenError':
elif response_code in [2001] or msg == 'AccessTokenError':
message = "The access token has expired. Please refresh the token and try again."
elif msg == 'UserIsLocked':
message = "The user account is locked. Please resolve this issue and try again."
Expand Down

0 comments on commit bf9e568

Please sign in to comment.