diff --git a/README.md b/README.md index 84e3996..c1169fd 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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() @@ -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') @@ -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') @@ -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') diff --git a/wyze_sdk/api/client.py b/wyze_sdk/api/client.py index f7ab299..dd0e10f 100644 --- a/wyze_sdk/api/client.py +++ b/wyze_sdk/api/client.py @@ -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: @@ -125,7 +132,12 @@ 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 @@ -133,10 +145,19 @@ def login(self) -> WyzeResponse: :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}") diff --git a/wyze_sdk/service/base.py b/wyze_sdk/service/base.py index f1c35a4..ac39382 100644 --- a/wyze_sdk/service/base.py +++ b/wyze_sdk/service/base.py @@ -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}") @@ -90,6 +90,8 @@ def _do_request( response = session.send(request, **settings) + response.raise_for_status() + return WyzeResponse( client=self, http_verb=request.method, @@ -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: diff --git a/wyze_sdk/service/wyze_response.py b/wyze_sdk/service/wyze_response.py index d670c2c..1263d42 100644 --- a/wyze_sdk/service/wyze_response.py +++ b/wyze_sdk/service/wyze_response.py @@ -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."