pax_global_header00006660000000000000000000000064150323157430014515gustar00rootroot0000000000000052 comment=140f5660f1589ea3e01109d43c577538e270e0d4 sharkiq-1.1.1/000077500000000000000000000000001503231574300131575ustar00rootroot00000000000000sharkiq-1.1.1/.gitattributes000066400000000000000000000002021503231574300160440ustar00rootroot00000000000000# Source files # ============ *.py text diff=python eol=lf # Config and data files *.json text eol=lf *.yaml text eol=lf sharkiq-1.1.1/.github/000077500000000000000000000000001503231574300145175ustar00rootroot00000000000000sharkiq-1.1.1/.github/workflows/000077500000000000000000000000001503231574300165545ustar00rootroot00000000000000sharkiq-1.1.1/.github/workflows/ci.yml000066400000000000000000000036261503231574300177010ustar00rootroot00000000000000--- name: Continuous Integration on: push: branches: - main pull_request: branches: - main jobs: test: name: Code Coverage (Python ${{ matrix.python-version }} on ${{ matrix.os }}) runs-on: ${{ matrix.os }}-latest strategy: matrix: os: [ubuntu] python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] steps: - name: Checking out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v5.6.0 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip setuptools wheel pip install -r requirements.test.txt pip install -r requirements.txt pip list - name: Pytest with coverage reporting run: pytest --cov=sharkiq --cov-report=xml - name: Upload coverage to Codecov if: matrix.python-version == 3.13 && matrix.os == 'ubuntu' uses: codecov/codecov-action@v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} file: ./coverage.xml flags: unittests name: codecov-umbrella docs: name: Generate and Upload Documentation runs-on: ubuntu-latest needs: [test] steps: - name: Checking out code from GitHub uses: actions/checkout@v4.2.2 - name: Set up Python 3.13 uses: actions/setup-python@v5.6.0 with: python-version: 3.13 - name: Install dependencies run: | python -m pip install --upgrade pip pdoc3 pip install -r requirements.txt pip list - name: Generate documentation run: pdoc --html sharkiq - name: Deploy Documentation if: github.event_name == 'push' uses: JamesIves/github-pages-deploy-action@v4.7.3 with: branch: docs folder: html/sharkiq sharkiq-1.1.1/.github/workflows/publish.yml000066400000000000000000000015521503231574300207500ustar00rootroot00000000000000# This workflows will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: Upload Python Package on: release: types: [published] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4.2.2 - name: Set up Python uses: actions/setup-python@v5.6.0 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip pip install setuptools wheel twine - name: Build and publish env: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | python setup.py sdist bdist_wheel twine upload dist/*sharkiq-1.1.1/.gitignore000066400000000000000000000036021503231574300151500ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # IDE files .idea/ .vscode/ # Secrets secrets.py driver.py # Documentation html/ # Local env test file examples/test.pysharkiq-1.1.1/LICENSE000066400000000000000000000021701503231574300141640ustar00rootroot00000000000000MIT License Copyright (c) 2022 Jeff Rescignano, maintainers, & original authors of https://github.com/ajmarks/sharkiq/ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. sharkiq-1.1.1/README.md000066400000000000000000000026321503231574300144410ustar00rootroot00000000000000[![codecov](https://codecov.io/gh/JeffResc/sharkiq/branch/main/graph/badge.svg?token=DO96BWVXA7)](https://codecov.io/gh/JeffResc/sharkiq) [![PyPI](https://img.shields.io/pypi/v/sharkiq)](https://pypi.org/project/sharkiq/) [![PyPI - Downloads](https://img.shields.io/pypi/dm/sharkiq)](https://pypi.org/project/sharkiq/) [![GitHub](https://img.shields.io/github/license/JeffResc/sharkiq)](https://github.com/JeffResc/sharkiq) [![Documentation](https://img.shields.io/badge/Documentation-2c3e50)](https://jeffresc.github.io/sharkiq/) # sharkiq Unofficial SDK for Shark IQ robot vacuums, designed primarily to support an integration for [Home Assistant](https://www.home-assistant.io/). This library is heavily based off of [sharkiq](https://github.com/ajmarks/sharkiq) by [@ajmarks](https://github.com/ajmarks), with a few minor changes to allow it to work on newer versions of the Shark API. ## Installation ```bash pip install sharkiq ``` ## Usage Examples can be found in the [examples directory](examples/). ## Documentation You can view the latest documentation [here](https://jeffresc.github.io/sharkiq/). ## TODOs: * Add support for mapping * Once we have mapping, it may be possible to use the RSSI property combined with an increased update frequency to generate a wifi strength heatmap. Kind of orthogonal to the main purpose, but I really want to do this. ## License [MIT](https://choosealicense.com/licenses/mit/)sharkiq-1.1.1/examples/000077500000000000000000000000001503231574300147755ustar00rootroot00000000000000sharkiq-1.1.1/examples/async.py000066400000000000000000000010221503231574300164570ustar00rootroot00000000000000import asyncio from sharkiq import get_ayla_api, OperatingModes, SharkIqVacuum USERNAME = 'me@email.com' PASSWORD = '$7r0nkP@s$w0rD' async def main(ayla_api) -> SharkIqVacuum: await ayla_api.async_sign_in() shark_vacs = await ayla_api.async_get_devices() shark = shark_vacs[0] await shark.async_update() await shark.async_find_device() await shark.async_set_operating_mode(OperatingModes.START) return shark ayla_api = get_ayla_api(USERNAME, PASSWORD) shark = asyncio.run(main(ayla_api)) sharkiq-1.1.1/examples/simple.py000066400000000000000000000005211503231574300166360ustar00rootroot00000000000000from sharkiq import get_ayla_api, OperatingModes, Properties, PowerModes USERNAME = 'me@email.com' PASSWORD = '$7r0nkP@s$w0rD' ayla_api = get_ayla_api(USERNAME, PASSWORD) ayla_api.sign_in() shark_vacs = ayla_api.get_devices() shark = shark_vacs[0] shark.update() shark.set_operating_mode(OperatingModes.START) shark.return_to_base() sharkiq-1.1.1/pytest.ini000066400000000000000000000000341503231574300152050ustar00rootroot00000000000000[pytest] asyncio_mode=strictsharkiq-1.1.1/requirements.test.txt000066400000000000000000000000651503231574300174220ustar00rootroot00000000000000pytest-asyncio==1.0.0 pytest==8.4.1 pytest-cov==6.2.1sharkiq-1.1.1/requirements.txt000066400000000000000000000000401503231574300164350ustar00rootroot00000000000000aiohttp>=3.8.1 requests>=2.27.1 sharkiq-1.1.1/setup.py000066400000000000000000000023031503231574300146670ustar00rootroot00000000000000import pathlib from setuptools import setup try: import re2 as re except ImportError: import re packages = ["sharkiq"] # The directory containing this file HERE = pathlib.Path(__file__).parent # The text of the README file README = (HERE / "README.md").read_text() # Pull the version from __init__.py so we don't need to maintain it in multiple places init_txt = (HERE / packages[0] / "__init__.py").read_text("utf-8") try: version = re.findall(r"^__version__ = ['\"]([^'\"]+)['\"]\r?$", init_txt, re.M)[0] except IndexError: raise RuntimeError('Unable to determine version.') setup( name="sharkiq", version=version, description="Python API for Shark IQ robots", long_description=README, long_description_content_type="text/markdown", url="https://github.com/JeffResc/sharkiq-ng", author="Jeff Rescignano", author_email="jeff@jeffresc.dev", license="MIT", classifiers=[ "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.7", ], packages=packages, include_package_data=False, install_requires=list(val.strip() for val in open("requirements.txt")), ) sharkiq-1.1.1/sharkiq/000077500000000000000000000000001503231574300146215ustar00rootroot00000000000000sharkiq-1.1.1/sharkiq/__init__.py000066400000000000000000000006311503231574300167320ustar00rootroot00000000000000"""Unofficial SDK for Shark IQ robot vacuums, designed primarily to support an integration for Home Assistant.""" from .ayla_api import get_ayla_api, AylaApi from .exc import ( SharkIqError, SharkIqAuthExpiringError, SharkIqNotAuthedError, SharkIqAuthError, SharkIqReadOnlyPropertyError, ) from .sharkiq import OperatingModes, PowerModes, Properties, SharkIqVacuum __version__ = '1.1.1'sharkiq-1.1.1/sharkiq/ayla_api.py000066400000000000000000000356531503231574300167660ustar00rootroot00000000000000""" Simple implementation of the Ayla networks API Shark IQ robots use the Ayla networks IoT API to communicate with the device. Documentation can be found at: - https://developer.aylanetworks.com/apibrowser/ - https://docs.aylanetworks.com/cloud-services/api-browser/ """ import aiohttp import requests import json from datetime import datetime, timedelta from typing import Dict, List, Optional from .const import ( DEVICE_URL, LOGIN_URL, BROWSER_USERAGENT, SHARK_APP_ID, SHARK_APP_SECRET, SHARK_APP_USERAGENT, AUTH0_URL, AUTH0_CLIENT_ID, AUTH0_SCOPES, EU_DEVICE_URL, EU_LOGIN_URL, EU_SHARK_APP_ID, EU_SHARK_APP_SECRET, EU_AUTH0_URL, EU_AUTH0_CLIENT_ID ) from .exc import SharkIqAuthError, SharkIqAuthExpiringError, SharkIqNotAuthedError from .sharkiq import SharkIqVacuum _session = None def get_ayla_api(username: str, password: str, websession: Optional[aiohttp.ClientSession] = None, europe: bool = False): """ Get an AylaApi object. Args: username: The email address of the user. password: The password of the user. websession: A websession to use for the API. If None, a new session will be created. europe: If True, use the EU login URL and app ID/secret. Returns: An AylaApi object. """ if europe: return AylaApi(username, password, EU_SHARK_APP_ID, EU_AUTH0_CLIENT_ID, EU_SHARK_APP_SECRET, websession=websession, europe=europe) else: return AylaApi(username, password, SHARK_APP_ID, AUTH0_CLIENT_ID, SHARK_APP_SECRET, websession=websession) class AylaApi: """Simple Ayla Networks API wrapper.""" def __init__( self, email: str, password: str, app_id: str, auth0_client_id: str, app_secret: str, websession: Optional[aiohttp.ClientSession] = None, europe: bool = False): """ Initialize the AylaApi object. Args: email: The email address of the user. password: The password of the user. app_id: The app ID of the Ayla app. app_secret: The app secret of the Ayla app. websession: A websession to use for the API. If None, a new session will be created. europe: If True, use the EU login URL and app ID/secret. """ self._email = email self._password = password self._auth0_id_token = None # type: Optional[str] self._access_token = None # type: Optional[str] self._refresh_token = None # type: Optional[str] self._auth_expiration = None # type: Optional[datetime] self._is_authed = False # type: bool self._app_id = app_id self._auth0_client_id = auth0_client_id self._app_secret = app_secret self.websession = websession self.europe = europe async def ensure_session(self) -> aiohttp.ClientSession: """ Ensure that we have an aiohttp ClientSession. Returns: An aiohttp ClientSession. """ if self.websession is None: self.websession = aiohttp.ClientSession() return self.websession @property def _login_data(self) -> Dict[str, Dict]: """ Prettily formatted data for the login flow. Returns: A dict containing the login data. """ return { "app_id": self._app_id, "app_secret": self._app_secret, "token": self._auth0_id_token } @property def _auth0_login_data(self) -> Dict[str, Dict]: """ Prettily formatted data for the Auth0 login flow. Returns: A dict containing the login data. """ return { "grant_type": "password", "client_id": self._auth0_client_id, "username": self._email, "password": self._password, "scope": AUTH0_SCOPES } def _set_credentials(self, status_code: int, login_result: Dict): """ Update the internal credentials store. Args: status_code: The status code of the login response. login_result: The result of the login response. """ if status_code == 404: raise SharkIqAuthError(login_result["errors"] + " (Confirm app_id and app_secret are correct)") elif status_code == 401: raise SharkIqAuthError(login_result["errors"]) self._access_token = login_result["access_token"] self._refresh_token = login_result["refresh_token"] self._auth_expiration = datetime.now() + timedelta(seconds=login_result["expires_in"]) self._is_authed = (status_code < 400) def _set_id_token(self, status_code: int, login_result: Dict): """ Update the ID token. Args: status_code: The status code of the login response. login_result: The result of the login response. """ if status_code == 401 and login_result["error"] == "requires_verification": raise SharkIqAuthError(login_result["error_description"] + " (Try logging in with the SharkClean app, then try again)") elif status_code == 401: raise SharkIqAuthError(login_result["error_description"] + " (Confirm client_id is correct)") elif status_code == 400 or status_code == 403: raise SharkIqAuthError(login_result["error_description"]) self._auth0_id_token = login_result["id_token"] def sign_in(self): """ Authenticate to Ayla API synchronously. """ auth0_login_data = self._auth0_login_data auth0_headers = { "User-Agent": BROWSER_USERAGENT } api_headers = { "User-Agent": SHARK_APP_USERAGENT } auth0_resp = requests.post(f"{EU_AUTH0_URL if self.europe else AUTH0_URL:s}/oauth/token", json=auth0_login_data, headers=auth0_headers) self._set_id_token(auth0_resp.status_code, auth0_resp.json()) login_data = self._login_data resp = requests.post(f"{EU_LOGIN_URL if self.europe else LOGIN_URL:s}/api/v1/token_sign_in", json=login_data, headers=api_headers) self._set_credentials(resp.status_code, resp.json()) def refresh_auth(self): """ Refresh the authentication synchronously. """ refresh_data = {"user": {"refresh_token": self._refresh_token}} resp = requests.post(f"{EU_LOGIN_URL if self.europe else LOGIN_URL:s}/users/refresh_token.json", json=refresh_data) self._set_credentials(resp.status_code, resp.json()) async def async_sign_in(self): """ Authenticate to Ayla API asynchronously. """ session = await self.ensure_session() auth0_login_data = self._auth0_login_data auth0_headers = { "User-Agent": BROWSER_USERAGENT } api_headers = { "User-Agent": SHARK_APP_USERAGENT } auth0_url = f"{EU_AUTH0_URL if self.europe else AUTH0_URL}/oauth/token" async with session.post(auth0_url, json=auth0_login_data, headers=auth0_headers) as auth0_resp: auth0_resp_json = await auth0_resp.json() print("Auth status code " + str(auth0_resp.status)) print("Auth status code " + json.dumps(auth0_resp_json)) self._set_id_token(auth0_resp.status, auth0_resp_json) login_data = self._login_data login_url = f"{EU_LOGIN_URL if self.europe else LOGIN_URL}/api/v1/token_sign_in" async with session.post(login_url, json=login_data, headers=api_headers) as login_resp: login_resp_json = await login_resp.json() print("Login status code " + str(login_resp.status)) print("Login status code " + json.dumps(login_resp_json)) self._set_credentials(login_resp.status, login_resp_json) async def async_refresh_auth(self): """ Refresh the authentication synchronously. """ session = await self.ensure_session() refresh_data = {"user": {"refresh_token": self._refresh_token}} async with session.post(f"{EU_LOGIN_URL if self.europe else LOGIN_URL:s}/users/refresh_token.json", json=refresh_data) as resp: self._set_credentials(resp.status, await resp.json()) @property def sign_out_data(self) -> Dict: """ Payload for the sign_out call. Returns: A dict containing the sign out data. """ return {"user": {"access_token": self._access_token}} def _clear_auth(self): """ Clear authentication state. """ self._is_authed = False self._access_token = None self._refresh_token = None self._auth_expiration = None def sign_out(self): """ Sign out and invalidate the access token. """ requests.post(f"{EU_LOGIN_URL if self.europe else LOGIN_URL:s}/users/sign_out.json", json=self.sign_out_data) self._clear_auth() async def async_sign_out(self): """ Sign out and invalidate the access token. """ session = await self.ensure_session() async with session.post(f"{EU_LOGIN_URL if self.europe else LOGIN_URL:s}/users/sign_out.json", json=self.sign_out_data) as _: pass self._clear_auth() @property def auth_expiration(self) -> Optional[datetime]: """ Get the time at which the authentication expires. Returns: The time at which the authentication expires. """ if not self._is_authed: return None elif self._auth_expiration is None: # This should not happen, but let's be ready if it does... raise SharkIqNotAuthedError("Invalid state. Please reauthorize.") else: return self._auth_expiration @property def token_expired(self) -> bool: """ Return true if the token has already expired. Returns: True if the token has already expired. """ if self.auth_expiration is None: return True return datetime.now() > self.auth_expiration @property def token_expiring_soon(self) -> bool: """ Return true if the token will expire soon. Returns: True if the token will expire soon. """ if self.auth_expiration is None: return True return datetime.now() > self.auth_expiration - timedelta(seconds=600) # Prevent timeout immediately following def check_auth(self, raise_expiring_soon=True): """ Confirm authentication status. Args: raise_expiring_soon: Raise an exception if the token will expire soon. Raises: SharkIqAuthExpiringError: If the token will expire soon. SharkIqAuthError: If the token has already expired. """ if not self._access_token or not self._is_authed or self.token_expired: self._is_authed = False raise SharkIqNotAuthedError() elif raise_expiring_soon and self.token_expiring_soon: raise SharkIqAuthExpiringError() @property def auth_header(self) -> Dict[str, str]: """ Get the authorization header. Returns: The authorization header. """ self.check_auth() return {"Authorization": f"auth_token {self._access_token:s}"} def _get_headers(self, fn_kwargs) -> Dict[str, str]: """ Extract the headers element from fn_kwargs, removing it if it exists and updating with self.auth_header. Args: fn_kwargs: The kwargs passed to the function. Returns: The headers. """ try: headers = fn_kwargs['headers'] except KeyError: headers = {} else: del fn_kwargs['headers'] headers.update(self.auth_header) return headers def request(self, method: str, url: str, **kwargs) -> requests.Response: """ Make a request to the Ayla API. Args: method: The HTTP method to use. url: The URL to request. **kwargs: Additional keyword arguments to pass to requests. Returns: The response from the request. """ headers = self._get_headers(kwargs) return requests.request(method, url, headers=headers, **kwargs) async def async_request(self, http_method: str, url: str, **kwargs): """ Make a request to the Ayla API. Args: http_method: The HTTP method to use. url: The URL to request. **kwargs: Additional keyword arguments to pass to requests. Returns: The response from the request. """ session = await self.ensure_session() headers = self._get_headers(kwargs) return session.request(http_method, url, headers=headers, **kwargs) def list_devices(self) -> List[Dict]: """ List the devices on the account. Returns: A list of devices. """ resp = self.request("get", f"{EU_DEVICE_URL if self.europe else DEVICE_URL:s}/apiv1/devices.json") devices = resp.json() if resp.status_code == 401: raise SharkIqAuthError(devices["error"]["message"]) return [d["device"] for d in devices] async def async_list_devices(self) -> List[Dict]: """ List the devices on the account. Returns: A list of devices. """ async with await self.async_request("get", f"{EU_DEVICE_URL if self.europe else DEVICE_URL:s}/apiv1/devices.json") as resp: devices = await resp.json() if resp.status == 401: raise SharkIqAuthError(devices["error"]["message"]) return [d["device"] for d in devices] def get_devices(self, update: bool = True) -> List[SharkIqVacuum]: """ Get the devices on the account. Args: update: Update the device list if it is out of date. Returns: A list of devices. """ devices = [SharkIqVacuum(self, d, europe=self.europe) for d in self.list_devices()] if update: for device in devices: device.get_metadata() device.update() return devices async def async_get_devices(self, update: bool = True) -> List[SharkIqVacuum]: """ Get the devices on the account. Args: update: Update the device list if it is out of date. Returns: A list of devices. """ devices = [SharkIqVacuum(self, d, europe=self.europe) for d in await self.async_list_devices()] if update: for device in devices: await device.async_get_metadata() await device.async_update() return devices sharkiq-1.1.1/sharkiq/const.py000066400000000000000000000016151503231574300163240ustar00rootroot00000000000000"""Various constants""" AUTH0_URL = "https://login.sharkninja.com" AUTH0_CLIENT_ID = "wsguxrqm77mq4LtrTrwg8ZJUxmSrexGi" AUTH0_SCOPES = "openid profile email offline_access" DEVICE_URL = "https://ads-field-39a9391a.aylanetworks.com" LOGIN_URL = "https://user-field-39a9391a.aylanetworks.com" SHARK_APP_ID = "ios_shark_prod-3A-id" SHARK_APP_SECRET = "ios_shark_prod-74tFWGNg34LQCmR0m45SsThqrqs" SHARK_APP_USERAGENT = "SharkClean/29562 Darwin/24.3.0" BROWSER_USERAGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36" EU_AUTH0_URL = "https://logineu.sharkninja.com" EU_AUTH0_CLIENT_ID = "rKDx9O18dBrY3eoJMTkRiBZHDvd9Mx1I" EU_DEVICE_URL = "https://ads-eu.aylanetworks.com" EU_LOGIN_URL = "https://user-field-eu.aylanetworks.com" EU_SHARK_APP_ID = "android_shark_prod-lg-id" EU_SHARK_APP_SECRET = "android_shark_prod-xuf9mlHOo0p3Ty5bboFROSyRBlE" sharkiq-1.1.1/sharkiq/exc.py000066400000000000000000000017751503231574300157640ustar00rootroot00000000000000"""Exceptions.""" # Default messages AUTH_EXPIRED_MESSAGE = 'Ayla Networks API authentication expired. Re-authenticate and retry.' AUTH_FAILURE_MESSAGE = 'Error authenticating to Ayla Networks.' NOT_AUTHED_MESSAGE = 'Ayla Networks API not authenticated. Authenticate first and retry.' class SharkIqError(RuntimeError): """Parent class for all Shark IQ exceptions.""" class SharkIqAuthError(SharkIqError): """Exception authenticating.""" def __init__(self, msg=AUTH_FAILURE_MESSAGE, *args): super().__init__(msg, *args) class SharkIqAuthExpiringError(SharkIqError): """Authentication expired and needs to be refreshed.""" def __init__(self, msg=AUTH_EXPIRED_MESSAGE, *args): super().__init__(msg, *args) class SharkIqNotAuthedError(SharkIqError): """Shark not authorized""" def __init__(self, msg=NOT_AUTHED_MESSAGE, *args): super().__init__(msg, *args) class SharkIqReadOnlyPropertyError(SharkIqError): """Tried to set a read-only property""" pass sharkiq-1.1.1/sharkiq/sharkiq.py000066400000000000000000000550341503231574300166440ustar00rootroot00000000000000"""Shark IQ Wrapper.""" import base64 import enum import logging import requests from collections import abc, defaultdict from datetime import datetime from pprint import pformat from typing import Any, Dict, Iterable, List, Optional, Set, Union, TYPE_CHECKING from .const import DEVICE_URL, EU_DEVICE_URL from .exc import SharkIqReadOnlyPropertyError try: import ujson as json except ImportError: import json if TYPE_CHECKING: from .ayla_api import AylaApi TIMESTAMP_FMT = '%Y-%m-%dT%H:%M:%SZ' _LOGGER = logging.getLogger(__name__) PropertyName = Union[str, enum.Enum] PropertyValue = Union[str, int, enum.Enum] def _parse_datetime(date_string: str) -> datetime: """ Parse a datetime as returned by the Ayla Networks API. Args: date_string: A datetime string as returned by the Ayla Networks API. Returns: A datetime object. """ return datetime.strptime(date_string, TIMESTAMP_FMT) @enum.unique class PowerModes(enum.IntEnum): """ Vacuum power modes. Attributes: ECO: Eco mode. NORMAL: Normal mode. MAX: Max mode. """ ECO = 1 NORMAL = 0 MAX = 2 @enum.unique class OperatingModes(enum.IntEnum): """ Vacuum operation modes. Attributes: STOP: Stopped. PAUSE: Paused. START: Started. RETURN: Returning. EXPLORE: Explore and learn map. MOP: Mopping. VACCUM_AND_MOP: Both Vacuum and Mop. """ STOP = 0 PAUSE = 1 START = 2 RETURN = 3 EXPLORE = 4 MOP = 7 VACCUM_AND_MOP = 8 @enum.unique class Properties(enum.Enum): """ Useful properties. Attributes: AREAS_TO_CLEAN: Areas to clean. BATTERY_CAPACITY: Battery capacity. CHARGING_STATUS: Charging status. CLEAN_COMPLETE: Cleaning complete. CLEANING_STATISTICS: Cleaning statistics. DOCKED_STATUS: Docked status. ERROR_CODE: Error code. EVACUATING: Evacuating. FIND_DEVICE: Find device. LOW_LIGHT_MISSION: Low light mission. NAV_MODULE_FW_VERSION: Nav module firmware version. OPERATING_MODE: Operating mode. POWER_MODE: Power mode. RECHARGE_RESUME: Recharge resume. RECHARGING_TO_RESUME: Recharging to resume. ROBOT_FIRMWARE_VERSION: Robot firmware version. RSSI: RSSI. """ AREAS_TO_CLEAN = "Areas_To_Clean" BATTERY_CAPACITY = "Battery_Capacity" CHARGING_STATUS = "Charging_Status" CLEAN_COMPLETE = "CleanComplete" CLEANING_STATISTICS = "Cleaning_Statistics" DOCKED_STATUS = "DockedStatus" ERROR_CODE = "Error_Code" EVACUATING = "Evacuating" # Doesn't really work because update frequency on the dock (default 20s) is too slow FIND_DEVICE = "Find_Device" LOW_LIGHT_MISSION = "LowLightMission" NAV_MODULE_FW_VERSION = "Nav_Module_FW_Version" OPERATING_MODE = "Operating_Mode" POWER_MODE = "Power_Mode" RECHARGE_RESUME = "Recharge_Resume" RECHARGING_TO_RESUME = "Recharging_To_Resume" ROBOT_FIRMWARE_VERSION = "Robot_Firmware_Version" ROBOT_ROOM_LIST = "Robot_Room_List" RSSI = "RSSI" ERROR_MESSAGES = { 1: "Side wheel is stuck", 2: "Side brush is stuck", 3: "Suction motor failed", 4: "Brushroll stuck", 5: "Side wheel is stuck (2)", 6: "Bumper is stuck", 7: "Cliff sensor is blocked", 8: "Battery power is low", 9: "No Dustbin", 10: "Fall sensor is blocked", 11: "Front wheel is stuck", 13: "Switched off", 14: "Magnetic strip error", 16: "Top bumper is stuck", 18: "Wheel encoder error", 40: "Dustbin is blocked", } def _clean_property_name(raw_property_name: str) -> str: """ Clean up property names. Args: raw_property_name: The raw property name. Returns: The cleaned property name. """ if raw_property_name[:4].upper() in ['SET_', 'GET_']: return raw_property_name[4:] else: return raw_property_name class SharkIqVacuum: """Shark IQ vacuum entity.""" def __init__(self, ayla_api: "AylaApi", device_dct: Dict, europe: bool = False): """ Initialize a SharkIqVacuum object. Args: ayla_api: The AylaApi object. device_dct: The device dictionary. europe: True if the account is registered in Europe. """ self.ayla_api = ayla_api self._dsn = device_dct['dsn'] self._key = device_dct['key'] self._oem_model_number = device_dct['oem_model'] # type: str self._vac_model_number = None # type: Optional[str] self._vac_serial_number = None # type: Optional[str] self.properties_full = defaultdict(dict) # Using a defaultdict prevents errors before calling `update()` self.property_values = SharkPropertiesView(self) self._settable_properties = None # type: Optional[Set] self.europe = europe # Properties self._name = device_dct['product_name'] self._error = None @property def oem_model_number(self) -> str: """ The OEM model number. Returns: The OEM model number. """ return self._oem_model_number @property def vac_model_number(self) -> Optional[str]: """ The vacuum model number. Returns: The vacuum model number. """ return self._vac_model_number @property def vac_serial_number(self) -> Optional[str]: """ The vacuum serial number. Returns: The vacuum serial number. """ return self._vac_serial_number @property def name(self): """ The vacuum name. Returns: The vacuum name. """ return self._name @property def serial_number(self) -> str: """ The vacuum serial number. Returns: The vacuum serial number. """ return self._dsn @property def metadata_endpoint(self) -> str: """ Endpoint for device metadata. Returns: The endpoint for device metadata. """ return f'{EU_DEVICE_URL if self.europe else DEVICE_URL:s}/apiv1/dsns/{self._dsn:s}/data.json' def _update_metadata(self, metadata: List[Dict]): """ Update metadata. Args: metadata: The metadata. """ data = [d['datum'] for d in metadata if d.get('datum', {}).get('key', '') == 'sharkDeviceMobileData'] if data: datum = data[0] # I do not know why they don't just use multiple keys for this try: values = json.loads(datum.get('value')) except ValueError: values = {} self._vac_model_number = values.get('vacModelNumber') self._vac_serial_number = values.get('vacSerialNumber') def get_metadata(self): """Fetch device metadata. Not needed for basic operation.""" resp = self.ayla_api.request('get', self.metadata_endpoint) self._update_metadata(resp.json()) async def async_get_metadata(self): """Fetch device metadata. Not needed for basic operation.""" async with await self.ayla_api.async_request('get', self.metadata_endpoint) as resp: resp_data = await resp.json() self._update_metadata(resp_data) def set_property_endpoint(self, property_name) -> str: """ Get the API endpoint for a given property. Args: property_name: The property name. Returns: The API endpoint. """ return f'{EU_DEVICE_URL if self.europe else DEVICE_URL:s}/apiv1/dsns/{self._dsn:s}/properties/{property_name:s}/datapoints.json' def get_property_value(self, property_name: PropertyName) -> Any: """ Get the value of a property from the properties dictionary. Args: property_name: The property name. Returns: The property value. """ if isinstance(property_name, enum.Enum): property_name = property_name.value return self.property_values[property_name] def set_property_value(self, property_name: PropertyName, value: PropertyValue): """ Update a property. Args: property_name: The property name. value: The property value. """ if isinstance(property_name, enum.Enum): property_name = property_name.value if isinstance(value, enum.Enum): value = value.value if self.properties_full.get(property_name, {}).get('read_only'): raise SharkIqReadOnlyPropertyError(f'{property_name} is read only') end_point = self.set_property_endpoint(f'SET_{property_name}') data = {'datapoint': {'value': value}} resp = self.ayla_api.request('post', end_point, json=data) self.properties_full[property_name].update(resp.json()) async def async_set_property_value(self, property_name: PropertyName, value: PropertyValue): """ Update a property async. Args: property_name: The property name. value: The property value. """ if isinstance(property_name, enum.Enum): property_name = property_name.value if isinstance(value, enum.Enum): value = value.value end_point = self.set_property_endpoint(f'SET_{property_name}') data = {'datapoint': {'value': value}} async with await self.ayla_api.async_request('post', end_point, json=data) as resp: resp_data = await resp.json() self.properties_full[property_name].update(resp_data) @property def update_url(self) -> str: """ API endpoint to fetch updated device information. Returns: The API endpoint. """ return f'{EU_DEVICE_URL if self.europe else DEVICE_URL}/apiv1/dsns/{self.serial_number}/properties.json' def update(self, property_list: Optional[Iterable[str]] = None): """ Update the known device state. Args: property_list: The list of properties to update. """ full_update = property_list is None if full_update: params = None else: params = {'names[]': property_list} resp = self.ayla_api.request('get', self.update_url, params=params) properties = resp.json() self._do_update(full_update, properties) async def async_update(self, property_list: Optional[Iterable[str]] = None): """ Update the known device state async. Args: property_list: The list of properties to update. """ full_update = property_list is None if full_update: params = None else: params = {'names[]': property_list} async with await self.ayla_api.async_request('get', self.update_url, params=params) as resp: properties = await resp.json() self._do_update(full_update, properties) def _do_update(self, full_update: bool, properties: List[Dict]): """ Update the internal state from fetched properties. Args: full_update: Whether to update all properties. properties: The properties. """ property_names = {p['property']['name'] for p in properties} settable_properties = {_clean_property_name(p) for p in property_names if p[:3].upper() == 'SET'} readable_properties = { _clean_property_name(p['property']['name']): p['property'] for p in properties if p['property']['name'].upper() != 'SET' } if full_update or self._settable_properties is None: self._settable_properties = settable_properties else: self._settable_properties = self._settable_properties.union(settable_properties) # Update the property map so we can update by name instead of by fickle number if full_update: # Did a full update, so let's wipe everything self.properties_full = defaultdict(dict) self.properties_full.update(readable_properties) def set_operating_mode(self, mode: OperatingModes): """ Set the operating mode. This is just a convenience wrapper around `set_property_value`. Args: mode: The operating mode. """ self.set_property_value(Properties.OPERATING_MODE, mode) async def async_set_operating_mode(self, mode: OperatingModes): """ Set the operating mode. This is just a convenience wrapper around `set_property_value`. Args: mode: The operating mode. """ await self.async_set_property_value(Properties.OPERATING_MODE, mode) def find_device(self): """Make the device emit an annoying chirp so you can find it.""" self.set_property_value(Properties.FIND_DEVICE, 1) async def async_find_device(self): """Make the device emit an annoying chirp so you can find it.""" await self.async_set_property_value(Properties.FIND_DEVICE, 1) @property def error_code(self) -> Optional[int]: """ Error code. Returns: The error code. """ return self.get_property_value(Properties.ERROR_CODE) @property def error_text(self) -> Optional[str]: """ Error message. Returns: The error message. """ err = self.error_code if err: return ERROR_MESSAGES.get(err, f'Unknown error ({err})') return None @staticmethod def _get_most_recent_datum(data_list: List[Dict], date_field: str = 'updated_at') -> Dict: """ Get the most recent data point from a list of annoyingly nested values. Args: data_list: The list of data points. date_field: The field to use for the date. Returns: The most recent data point. """ datapoints = { _parse_datetime(d['datapoint'][date_field]): d['datapoint'] for d in data_list if 'datapoint' in d } if not datapoints: return {} latest_datum = datapoints[max(datapoints.keys())] return latest_datum def _get_file_property_endpoint(self, property_name: PropertyName) -> str: """ Check that property_name is a file property and return its lookup endpoint. Args: property_name: The property name. Returns: The endpoint. """ if isinstance(property_name, enum.Enum): property_name = property_name.value property_id = self.properties_full[property_name]['key'] if self.properties_full[property_name].get('base_type') != 'file': raise ValueError(f'{property_name} is not a file property') return f'{EU_DEVICE_URL if self.europe else DEVICE_URL:s}/apiv1/properties/{property_id:d}/datapoints.json' def get_file_property_url(self, property_name: PropertyName) -> Optional[str]: """ File properties are versioned and need a special lookup. Args: property_name: The property name. Returns: The URL. """ try: url = self._get_file_property_endpoint(property_name) except KeyError: return None resp = self.ayla_api.request('get', url) data_list = resp.json() latest_datum = self._get_most_recent_datum(data_list) return latest_datum.get('file') async def async_get_file_property_url(self, property_name: PropertyName) -> Optional[str]: """ File properties are versioned and need a special lookup. Args: property_name: The property name. Returns: The URL. """ try: url = self._get_file_property_endpoint(property_name) except KeyError: return None async with await self.ayla_api.async_request('get', url) as resp: data_list = await resp.json() latest_datum = self._get_most_recent_datum(data_list) return latest_datum.get('file') def get_file_property(self, property_name: PropertyName) -> bytes: """ Get the latest file for a file property and return as bytes. Args: property_name: The property name. Returns: The file as bytes. """ # These do not require authentication, so we won't use the ayla_api url = self.get_file_property_url(property_name) resp = requests.get(url) return resp.content async def async_get_file_property(self, property_name: PropertyName) -> bytes: """ Get the latest file for a file property and return as bytes. Args: property_name: The property name. Returns: The file as bytes. """ url = await self.async_get_file_property_url(property_name) session = self.ayla_api.websession async with session.get(url) as resp: return await resp.read() def _encode_room_list(self, rooms: List[str]): """ Base64 encode the list of rooms to clean. Args: rooms: The list of rooms. Returns: The base64 encoded list of rooms. """ if not rooms: # By default, clean all rooms return '*' room_list = self._get_device_room_list() _LOGGER.debug(f'Room list identifier is: {room_list["identifier"]}') # Header explained: # 0x80: Control character - some mode selection # 0x01: Start of Heading Character # 0x0B: Use Line Tabulation (entries separated by newlines) # 0xca: Control character - purpose unknown # 0x02: Start of text (indicates start of room list) header = '\x80\x01\x0b\xca\x02' # For each room in the list: # - Insert a byte representing the length of the room name string # - Add the room name # - Join with newlines (presumably because of the 0x0B in the header) rooms_enc = "\n".join([chr(len(room)) + room for room in rooms]) # The footer starts with control character 0x1A # Then add the length indicator for the room list identifier # Then add the room list identifier footer = '\x1a' + chr(len(room_list['identifier'])) + room_list['identifier'] # Now that we've computed the room list and footer and know their lengths, finish building the header # This character denotes the length of the remaining input header += chr(0 + 1 # Add one for a newline following the length specifier + len(rooms_enc) + len(footer) ) header += '\n' # This is the newline reference above # Finally, join and base64 encode the parts return base64.b64encode( # First encode the string as latin_1 to get the right endianness (header + rooms_enc + footer).encode('latin_1') # Then return as a utf8 string for ease of handling ).decode('utf8') def _get_device_room_list(self): """Gets the list of known rooms from the device, including the map identifier""" room_list = self.get_property_value(Properties.ROBOT_ROOM_LIST) split = room_list.split(':') return { # The room list is preceded by an identifier, which I believe identifies the list of rooms with the # onboard map in the robot 'identifier': split[0], 'rooms': split[1:], } def get_room_list(self) -> List[str]: """Gets the list of rooms known by the device""" return self._get_device_room_list()['rooms'] def clean_rooms(self, rooms: List[str]) -> None: """ Clean the given rooms. Args: rooms: The list of rooms to clean. """ payload = self._encode_room_list(rooms) _LOGGER.debug('Room list payload: ' + payload) self.set_property_value(Properties.AREAS_TO_CLEAN, payload) self.set_operating_mode(OperatingModes.START) async def async_clean_rooms(self, rooms: List[str]) -> None: """ Clean the given rooms. Args: rooms: The list of rooms to clean. """ payload = self._encode_room_list(rooms) _LOGGER.debug("Room list payload: " + payload) await self.async_set_property_value(Properties.AREAS_TO_CLEAN, payload) await self.async_set_operating_mode(OperatingModes.START) class SharkPropertiesView(abc.Mapping): """Convenience API for shark iq properties""" @staticmethod def _cast_value(value, value_type): """ Cast property value to the appropriate type. Args: value: The value to cast. value_type: The type to cast to. Returns: The cast value. """ if value is None: return None type_map = { 'boolean': bool, 'decimal': float, 'integer': int, 'string': str, } return type_map.get(value_type, lambda x: x)(value) def __init__(self, shark: SharkIqVacuum): """ Initialize the shark properties view. Args: shark: The shark iq vacuum. """ self._shark = shark def __getitem__(self, key): """ Get a property value. Args: key: The property name. Returns: The property value. """ value = self._shark.properties_full[key].get('value') value_type = self._shark.properties_full[key].get('base_type') try: return self._cast_value(value, value_type) except (TypeError, ValueError) as exc: # If we failed to convert the type, just return the raw value _LOGGER.warning('Error converting property type (value: %r, type: %r)', value, value_type, exc_info=exc) return value def __iter__(self): """Iterate over the properties.""" for k in self._shark.properties_full.keys(): yield k def __len__(self) -> int: """Return the number of properties.""" return self._shark.properties_full.__len__() def __str__(self) -> str: """Return a string representation of the properties.""" return pformat(dict(self)) sharkiq-1.1.1/tests/000077500000000000000000000000001503231574300143215ustar00rootroot00000000000000sharkiq-1.1.1/tests/__init__.py000066400000000000000000000000001503231574300164200ustar00rootroot00000000000000sharkiq-1.1.1/tests/conftest.py000066400000000000000000000023031503231574300165160ustar00rootroot00000000000000import pytest import os from sharkiq.ayla_api import get_ayla_api from datetime import datetime, timedelta @pytest.fixture def dummy_api(): """AylaApi object with invalid auth creds and attributes populated.""" username = "myusername@mysite.com" password = "mypassword" dummy_api = get_ayla_api(username=username, password=password) dummy_api._access_token = "token123" dummy_api._refresh_token = "token321" dummy_api._is_authed = True dummy_api._auth_expiration = datetime.now() + timedelta(seconds=700) return dummy_api @pytest.fixture def sample_api(): """AylaApi object using user-supplied auth creds via SHARKIQ_USERNAME and SHARKIQ_PASSWORD environement variables.""" username = os.getenv("SHARKIQ_USERNAME") password = os.getenv("SHARKIQ_PASSWORD") assert username is not None, "SHARKIQ_USERNAME environment variable unset" assert password is not None, "SHARKIQ_PASSWORD environment variable unset" return get_ayla_api(username=username, password=password) @pytest.fixture def sample_api_logged_in(sample_api): """Sample API object with user-supplied creds after performing auth flow.""" sample_api.sign_in() return sample_api sharkiq-1.1.1/tests/test_ayla_api.py000066400000000000000000000226331503231574300175170ustar00rootroot00000000000000import aiohttp import pytest from sharkiq.ayla_api import get_ayla_api, AylaApi from sharkiq.const import SHARK_APP_ID, SHARK_APP_SECRET, AUTH0_CLIENT_ID from sharkiq.exc import ( SharkIqAuthError, SharkIqAuthExpiringError, SharkIqNotAuthedError, AUTH_EXPIRED_MESSAGE, NOT_AUTHED_MESSAGE, ) from datetime import datetime, timedelta def test_get_ayla_api(): api = get_ayla_api("myusername@mysite.com", "mypassword") assert api._email == "myusername@mysite.com" assert api._password == "mypassword" assert api._access_token is None assert api._refresh_token is None assert api._auth_expiration is None assert api._is_authed == False assert api._app_id == SHARK_APP_ID assert api._app_secret == SHARK_APP_SECRET assert api.websession is None class TestAylaApi: def test_init__required_vals(self): api = AylaApi( "myusername@mysite.com", "mypassword", "app_id_123", "client_id_123", "appsecret_123" ) assert api._email == "myusername@mysite.com" assert api._password == "mypassword" assert api._access_token is None assert api._refresh_token is None assert api._auth_expiration is None assert api._is_authed == False assert api._app_id == "app_id_123" assert api._app_secret == "appsecret_123" assert api._auth0_client_id == "client_id_123" assert api.websession is None @pytest.mark.asyncio async def test_ensure_session(self, dummy_api): # Initially created with no websession assert dummy_api.websession is None session = await dummy_api.ensure_session() # Check that session was created and returned assert isinstance(session, aiohttp.ClientSession) assert dummy_api.websession is session def test_property__login_data(self, dummy_api): assert dummy_api._login_data["app_id"] == SHARK_APP_ID assert dummy_api._login_data["app_secret"] == SHARK_APP_SECRET def test_auth0__login_data(self, dummy_api): assert dummy_api._auth0_login_data == { "grant_type":"password", "client_id": AUTH0_CLIENT_ID, "username": "myusername@mysite.com", "password": "mypassword", "scope": "openid profile email offline_access" } def test_set_id_token__401_requires_verification_response(self, dummy_api): with pytest.raises(SharkIqAuthError) as e: dummy_api._set_id_token(401, {"error": "requires_verification", "error_description": "description"}) assert e.value.args[0] == "description (Try logging in with the SharkClean app, then try again)" def test_set_id_token__401_response(self, dummy_api): with pytest.raises(SharkIqAuthError) as e: dummy_api._set_id_token(401, {"error": "generic", "error_description": "generic"}) assert e.value.args[0] == "generic (Confirm client_id is correct)" def test_set_id_token__400_response(self, dummy_api): with pytest.raises(SharkIqAuthError) as e: dummy_api._set_id_token(400, {"error": "generic", "error_description": "generic"}) assert e.value.args[0] == "generic" def test_set_id_token__403_response(self, dummy_api): with pytest.raises(SharkIqAuthError) as e: dummy_api._set_id_token(403, {"error": "generic", "error_description": "generic"}) assert e.value.args[0] == "generic" def test_set_credentials__404_response(self, dummy_api): with pytest.raises(SharkIqAuthError) as e: dummy_api._set_credentials(404, {"errors": "Not found"}) assert ( e.value.args[0] == "Not found (Confirm app_id and app_secret are correct)" ) def test_set_credentials__401_response(self, dummy_api): with pytest.raises(SharkIqAuthError) as e: dummy_api._set_credentials(401, {"errors": "Unauthorized"}) assert e.value.args[0] == "Unauthorized" def test_set_credentials__valid_response(self, dummy_api): assert dummy_api._access_token is "token123" assert dummy_api._refresh_token is "token321" assert dummy_api._auth_expiration.timestamp() == pytest.approx( (datetime.now() + timedelta(seconds=700)).timestamp() ) assert dummy_api._is_authed == True t1 = datetime.now() + timedelta(seconds=3600) dummy_api._set_credentials( 200, { "access_token": "token123", "refresh_token": "token321", "expires_in": 3600, }, ) assert dummy_api._access_token == "token123" assert dummy_api._refresh_token == "token321" assert dummy_api._auth_expiration.timestamp() == pytest.approx(t1.timestamp()) assert dummy_api._is_authed == True def test_property__sign_out_data(self, dummy_api): assert dummy_api.sign_out_data == { "user": {"access_token": dummy_api._access_token} } def test_clear_auth(self, dummy_api): assert dummy_api._is_authed == True dummy_api._clear_auth() assert dummy_api._access_token is None assert dummy_api._refresh_token is None assert dummy_api._auth_expiration is None assert dummy_api._is_authed == False def test_property__auth_expiration__not_authed(self, dummy_api): dummy_api._is_authed = False dummy_api._auth_expiration = None assert dummy_api.auth_expiration is None def test_property__auth_expiration__no_expiration(self, dummy_api): # mock the invalid state dummy_api._is_authed = True dummy_api._auth_expiration = None # Check that the correct exception is raised when accessing property with pytest.raises(SharkIqNotAuthedError) as e: _ = dummy_api.auth_expiration assert e.value.args[0] == "Invalid state. Please reauthorize." def test_property__auth_expiration__not_authed(self, dummy_api): dummy_api._is_authed = True t = datetime.now() + timedelta(seconds=3600) dummy_api._auth_expiration = t assert dummy_api.auth_expiration == t def test_property__token_expired__false(self, dummy_api): dummy_api._is_authed = True dummy_api._auth_expiration = datetime.now() + timedelta(seconds=3600) assert dummy_api.token_expired == False def test_property__token_expired__true(self, dummy_api): dummy_api._is_authed = True dummy_api._auth_expiration = datetime.now() - timedelta(seconds=3600) assert dummy_api.token_expired == True def test_property__token_expired__not_authed(self, dummy_api): dummy_api._is_authed = False dummy_api._auth_expiration = datetime.now() + timedelta(seconds=3600) assert dummy_api.token_expired == True def test_property__token_expiring_soon__false(self, dummy_api): dummy_api._is_authed = True # "soon" is considered to be within 600 seconds from the current time dummy_api._auth_expiration = datetime.now() + timedelta(seconds=605) assert dummy_api.token_expiring_soon == False def test_property__token_expiring_soon__true(self, dummy_api): dummy_api._is_authed = True dummy_api._auth_expiration = datetime.now() + timedelta(seconds=595) assert dummy_api.token_expiring_soon == True def test_property__token_expiring_soon__not_authed(self, dummy_api): dummy_api._is_authed = False dummy_api._auth_expiration = datetime.now() + timedelta(seconds=3600) assert dummy_api.token_expiring_soon == True @pytest.mark.parametrize( "access_token,auth_state,auth_timedelta", [ ("token123", True, timedelta(seconds=-100)), # auth expiry passed (None, True, timedelta(seconds=700)), # invalid token ("token123", False, timedelta(seconds=-100)), # not authed ], ) def test_check_auth__not_authed( self, dummy_api, access_token, auth_state, auth_timedelta ): dummy_api._access_token = access_token dummy_api._is_authed = auth_state dummy_api._auth_expiration = datetime.now() + auth_timedelta with pytest.raises(SharkIqNotAuthedError) as e: dummy_api.check_auth() assert e.value.args[0] == NOT_AUTHED_MESSAGE assert dummy_api._is_authed == False def test_check_auth__expiring_soon_exception(self, dummy_api): dummy_api._auth_expiration = datetime.now() + timedelta(seconds=400) with pytest.raises(SharkIqAuthExpiringError) as e: dummy_api.check_auth(raise_expiring_soon=True) assert e.value.args[0] == AUTH_EXPIRED_MESSAGE # No exception raised when set to False dummy_api.check_auth(raise_expiring_soon=False) def test_check_auth__valid(self, dummy_api): assert dummy_api.check_auth() is None def test_auth_header(self, dummy_api): dummy_api._access_token = "myfaketoken" assert dummy_api.auth_header == { "Authorization": "auth_token myfaketoken" } def test_get_headers__no_kwargs(self, dummy_api): headers = dummy_api._get_headers({}) assert headers == dummy_api.auth_header def test_get_headers__kwargs_(self, dummy_api): headers = dummy_api._get_headers({"headers": {"X-Test": "val"}}) assert headers == { "X-Test": "val", "Authorization": f"auth_token {dummy_api._access_token}" }sharkiq-1.1.1/tests/test_sharkiq.py000066400000000000000000000000001503231574300173620ustar00rootroot00000000000000