pax_global_header00006660000000000000000000000064151144130030014502gustar00rootroot0000000000000052 comment=66fa8cd546a6cf05e9f5b5796ae6d81f564a6698 sharkiqlibs-sharkiq-66fa8cd/000077500000000000000000000000001511441300300161615ustar00rootroot00000000000000sharkiqlibs-sharkiq-66fa8cd/.gitattributes000066400000000000000000000002021511441300300210460ustar00rootroot00000000000000# Source files # ============ *.py text diff=python eol=lf # Config and data files *.json text eol=lf *.yaml text eol=lf sharkiqlibs-sharkiq-66fa8cd/.github/000077500000000000000000000000001511441300300175215ustar00rootroot00000000000000sharkiqlibs-sharkiq-66fa8cd/.github/workflows/000077500000000000000000000000001511441300300215565ustar00rootroot00000000000000sharkiqlibs-sharkiq-66fa8cd/.github/workflows/ci.yml000066400000000000000000000035011511441300300226730ustar00rootroot00000000000000--- 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@v5.0.0 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v6.0.0 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install -e ".[test]" 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.5.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./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@v5.0.0 - name: Set up Python 3.13 uses: actions/setup-python@v6.0.0 with: python-version: 3.13 - name: Install dependencies run: | python -m pip install --upgrade pip pdoc3 pip install -e . 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 sharkiqlibs-sharkiq-66fa8cd/.github/workflows/publish.yml000066400000000000000000000020001511441300300237370ustar00rootroot00000000000000name: Upload Python Package on: release: types: [published] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5.0.0 - name: Set up Python uses: actions/setup-python@v6.0.0 with: python-version: '3.x' - name: Install build dependencies run: | python -m pip install --upgrade pip pip install build - name: Build package run: python -m build - name: Upload build artifacts uses: actions/upload-artifact@v4 with: name: python-package-distributions path: dist/ publish: runs-on: ubuntu-latest needs: build environment: name: pypi url: https://pypi.org/p/sharkiq permissions: id-token: write # Required for OIDC steps: - name: Download build artifacts uses: actions/download-artifact@v5 with: name: python-package-distributions path: dist/ - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1sharkiqlibs-sharkiq-66fa8cd/.gitignore000066400000000000000000000036251511441300300201570ustar00rootroot00000000000000# 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.py examples/test.curlsharkiqlibs-sharkiq-66fa8cd/LICENSE000066400000000000000000000021631511441300300171700ustar00rootroot00000000000000MIT License Copyright (c) 2022 sharkiqlibs 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. sharkiqlibs-sharkiq-66fa8cd/README.md000066400000000000000000000026401511441300300174420ustar00rootroot00000000000000[![codecov](https://codecov.io/gh/sharkiqlibs/sharkiq/graph/badge.svg?token=DO96BWVXA7)](https://codecov.io/gh/sharkiqlibs/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/sharkiqlibs/sharkiq)](https://github.com/sharkiqlibs/sharkiq) [![Documentation](https://img.shields.io/badge/Documentation-2c3e50)](https://sharkiqlibs.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://sharkiqlibs.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/)sharkiqlibs-sharkiq-66fa8cd/examples/000077500000000000000000000000001511441300300177775ustar00rootroot00000000000000sharkiqlibs-sharkiq-66fa8cd/examples/async.py000066400000000000000000000010221511441300300214610ustar00rootroot00000000000000import 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)) sharkiqlibs-sharkiq-66fa8cd/examples/simple.py000066400000000000000000000005211511441300300216400ustar00rootroot00000000000000from 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() sharkiqlibs-sharkiq-66fa8cd/pyproject.toml000066400000000000000000000023561511441300300211030ustar00rootroot00000000000000[build-system] requires = ["setuptools>=61.0", "setuptools-scm>=9.2.0", "wheel"] build-backend = "setuptools.build_meta" [project] name = "sharkiq" dynamic = ["version"] description = "Python API for Shark IQ robots" readme = "README.md" requires-python = ">=3.9" license = "MIT" authors = [ {name = "sharkiqlibs Maintainers"} ] classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", ] dependencies = [ "aiohttp>=3.8.1", "auth0-python>=4.10.0", "requests>=2.27.1", ] [project.urls] Homepage = "https://github.com/sharkiqlibs/sharkiq" Repository = "https://github.com/sharkiqlibs/sharkiq" Issues = "https://github.com/sharkiqlibs/sharkiq/issues" [project.optional-dependencies] test = [ "pytest==8.4.2", "pytest-asyncio==1.2.0", "pytest-cov==7.0.0", ] dev = [ "pytest==8.4.2", "pytest-asyncio==1.2.0", "pytest-cov==7.0.0", "build==1.3.0", ] [tool.setuptools] packages = ["sharkiq"] [tool.setuptools_scm] version_scheme = "python-simplified-semver" local_scheme = "no-local-version"sharkiqlibs-sharkiq-66fa8cd/pytest.ini000066400000000000000000000000341511441300300202070ustar00rootroot00000000000000[pytest] asyncio_mode=strictsharkiqlibs-sharkiq-66fa8cd/sharkiq/000077500000000000000000000000001511441300300176235ustar00rootroot00000000000000sharkiqlibs-sharkiq-66fa8cd/sharkiq/__init__.py000066400000000000000000000013021511441300300217300ustar00rootroot00000000000000"""Unofficial SDK for Shark IQ robot vacuums, designed primarily to support an integration for Home Assistant.""" from .ayla_api import AylaApi, get_ayla_api, Auth0Client from .exc import ( SharkIqError, SharkIqAuthExpiringError, SharkIqNotAuthedError, SharkIqAuthError, SharkIqReadOnlyPropertyError, ) from .sharkiq import OperatingModes, PowerModes, Properties, SharkIqVacuum try: from importlib.metadata import version, PackageNotFoundError except ImportError: # Python < 3.8 from importlib_metadata import version, PackageNotFoundError try: __version__ = version("sharkiq") except PackageNotFoundError: # Package is not installed __version__ = "unknown" sharkiqlibs-sharkiq-66fa8cd/sharkiq/auth0.py000066400000000000000000000076621511441300300212310ustar00rootroot00000000000000""" Auth0 API router for authentication to the Shark API """ import aiohttp import urllib from .const import ( AUTH0_URL, EU_AUTH0_URL, AUTH0_CLIENT_ID, AUTH0_REDIRECT_URI, AUTH0_SCOPES ) from .exc import SharkIqAuthError class Auth0Client: @staticmethod async def do_auth0_login( session: aiohttp.ClientSession, europe: bool, username: str, password: str ) -> dict: """Perform Auth0 login like the SharkClean app and return tokens.""" AUTH_DOMAIN = EU_AUTH0_URL if europe else AUTH0_URL CLIENT_ID = AUTH0_CLIENT_ID REDIRECT_URI = (AUTH0_REDIRECT_URI) SCOPE = AUTH0_SCOPES HEADERS = { "User-Agent": ( "Mozilla/5.0 (Linux; Android 10; K) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/139.0.0.0 Mobile Safari/537.36" ), "Content-Type": "application/x-www-form-urlencoded", "Origin": AUTH_DOMAIN, "Referer": AUTH_DOMAIN + "/", } # ------------------- # Step 1: /authorize # ------------------- authorize_url = ( f"{AUTH_DOMAIN}/authorize?" + urllib.parse.urlencode( { "os": "android", "response_type": "code", "client_id": CLIENT_ID, "redirect_uri": REDIRECT_URI, "scope": SCOPE, } ) ) async with session.get( authorize_url, headers=HEADERS, allow_redirects=True ) as resp: parsed = urllib.parse.urlparse(str(resp.url)) state = urllib.parse.parse_qs(parsed.query).get("state", [None])[0] if not state: raise SharkIqAuthError("No state returned from /authorize") # ------------------- # Step 2: /u/login # ------------------- login_url = f"{AUTH_DOMAIN}/u/login?state={state}" form_data = { "state": state, "username": username, "password": password, "action": "default", } async with session.post( login_url, headers=HEADERS, data=form_data, allow_redirects=False ) as resp: redirect_url = resp.headers.get("Location") code = None if redirect_url and redirect_url.startswith("/authorize/resume"): resume_url = AUTH_DOMAIN + redirect_url async with session.get( resume_url, headers=HEADERS, allow_redirects=False ) as resp: final_url = resp.headers.get("Location") if final_url: parsed = urllib.parse.urlparse(final_url) code = urllib.parse.parse_qs(parsed.query).get("code", [None])[0] else: parsed = urllib.parse.urlparse(redirect_url or "") code = urllib.parse.parse_qs(parsed.query).get("code", [None])[0] # NEW: handle deep link redirect if not code and redirect_url and redirect_url.startswith(REDIRECT_URI): parsed = urllib.parse.urlparse(redirect_url) code = urllib.parse.parse_qs(parsed.query).get("code", [None])[0] if not code: raise SharkIqAuthError(f"Auth0 login failed: {redirect_url}") # ------------------- # Step 3: /oauth/token # ------------------- token_url = f"{AUTH_DOMAIN}/oauth/token" payload = { "grant_type": "authorization_code", "client_id": CLIENT_ID, "code": code, "redirect_uri": REDIRECT_URI, } async with session.post( token_url, headers={"Content-Type": "application/json"}, json=payload ) as resp: token_data = await resp.json() if "access_token" not in token_data: raise SharkIqAuthError("Auth0 did not return an access token") return token_data sharkiqlibs-sharkiq-66fa8cd/sharkiq/ayla_api.py000066400000000000000000000457271511441300300217730ustar00rootroot00000000000000""" 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 from auth0.authentication import GetToken from auth0.asyncify import asyncify from datetime import datetime, timedelta from typing import Dict, List, Optional from .auth0 import Auth0Client from .const import ( DEVICE_URL, LOGIN_URL, AUTH0_HOST, SHARK_APP_ID, SHARK_APP_SECRET, AUTH0_URL, AUTH0_TOKEN_URL, AUTH0_CLIENT_ID, AUTH0_SCOPES, BROWSER_USERAGENT, EU_DEVICE_URL, EU_AUTH0_HOST, EU_LOGIN_URL, EU_SHARK_APP_ID, EU_SHARK_APP_SECRET, EU_AUTH0_URL, EU_AUTH0_TOKEN_URL, EU_AUTH0_CLIENT_ID ) from .exc import SharkIqAuthError, SharkIqAuthExpiringError, SharkIqNotAuthedError from .fallback_auth import FallbackAuth from .sharkiq import SharkIqVacuum _session = None def get_ayla_api( username: str, password: str, websession: Optional[aiohttp.ClientSession] = None, europe: bool = False, verify_ssl: bool = True, ): """ 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, verify_ssl=verify_ssl, ) else: return AylaApi( username, password, SHARK_APP_ID, AUTH0_CLIENT_ID, SHARK_APP_SECRET, websession=websession, verify_ssl=verify_ssl, ) 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, verify_ssl: bool = True): """ 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 # Allow disabling SSL verification if the Ayla host presents a mismatched cert in some environments. self.verify_ssl = verify_ssl 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 } @property def _auth0_login_headers(self) -> Dict[str, Dict]: """ Headers for the Auth0 login flow. Returns: A dict containing the headers to send for the Auth0 login flow. """ return { "Accept": "application/json, text/plain, */*", "Accept-Encoding": "gzip, deflate, br, zstd", "Accept-Language": "en-US,en;q=0.9", "Content-Type": "application/json", "dnt": "1", "Host": EU_AUTH0_HOST if self.europe else AUTH0_HOST, "Origin": EU_AUTH0_URL if self.europe else AUTH0_URL, "Priority": "u=1, i", "Referrer": EU_AUTH0_URL if self.europe else AUTH0_URL + "/", "Sec-Ch-Ua": '"Chrome";v="137", "Chromium";v="137", "Not A;Brand";v="24"', "Sec-Ch-Ua-Mobile": "?0", "Sec-Ch-Ua-Platform": '"macOS"', "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "same-origin", "Sec-Gpc": "1", "User-Agent": BROWSER_USERAGENT } @property def _ayla_login_headers(self) -> Dict[str, Dict]: """ Headers for the Ayla login flow. Returns: A dict containing the headers to send for the Ayla login flow. """ return { "Content-Type": "application/json", "User-Agent": BROWSER_USERAGENT } 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"] + ". Auth request flagged for verification.") elif status_code == 401: raise SharkIqAuthError(login_result["error_description"] + ". Confirm credentials are correct.") elif status_code == 400 or status_code == 403: raise SharkIqAuthError(login_result["error_description"]) self._auth0_id_token = login_result["id_token"] async def async_set_cookie(self): """ Query Auth0 to set session cookies [required for Auth0 support] """ initial_url = self.gen_fallback_url() ayla_client = await self.ensure_session() async with ayla_client.get(initial_url, allow_redirects=False, headers=self._auth0_login_headers, ssl=self.verify_ssl) as auth0_resp: ayla_client.cookie_jar.update_cookies(auth0_resp.cookies) async def _password_grant_sign_in(self, ayla_client: aiohttp.ClientSession): """ Auth0 password grant -> Ayla token_sign_in using aiohttp. """ token_url = EU_AUTH0_TOKEN_URL if self.europe else AUTH0_TOKEN_URL payload = { "grant_type": "password", "client_id": EU_AUTH0_CLIENT_ID if self.europe else AUTH0_CLIENT_ID, "username": self._email, "password": self._password, "scope": AUTH0_SCOPES, } async with ayla_client.post( token_url, json=payload, headers={"Content-Type": "application/json"}, ssl=self.verify_ssl, timeout=15, ) as resp: if resp.status >= 400: raise SharkIqAuthError(f"Auth0 password grant failed: {resp.status} {await resp.text()}") auth0_json = await resp.json() if "id_token" not in auth0_json: raise SharkIqAuthError("Auth0 response missing id_token") self._set_id_token(resp.status, auth0_json) async def _legacy_cookie_sign_in(self, ayla_client: aiohttp.ClientSession, force_auth0_sdk: bool = False): """ Legacy Auth0 browser-style flow to obtain id_token. """ try: if force_auth0_sdk or self.europe: AsyncGetToken = asyncify(GetToken) get_token = AsyncGetToken(EU_AUTH0_HOST if self.europe else AUTH0_HOST, EU_AUTH0_CLIENT_ID if self.europe else AUTH0_CLIENT_ID) auth_result = await get_token.login_async( username=self._email, password=self._password, grant_type='password', scope=AUTH0_SCOPES ) self._auth0_id_token = auth_result["id_token"] else: auth_result = await Auth0Client.do_auth0_login( ayla_client, self.europe, self._email, self._password ) self._auth0_id_token = auth_result["id_token"] except Exception as err: if not force_auth0_sdk: # Retry with Auth0 SDK path as a last resort return await self._legacy_cookie_sign_in(ayla_client, force_auth0_sdk=True) raise err async def async_sign_in(self): """ Authenticate to Ayla API asynchronously. Attempts password grant first, then automatically falls back to the legacy cookie-based Auth0 flow. """ ayla_client = await self.ensure_session() try: await self._password_grant_sign_in(ayla_client) except Exception: # Password grant failed; try legacy flow (will raise if it also fails) await self._legacy_cookie_sign_in(ayla_client) # Step 2: Ayla token_sign_in exchange login_data = self._login_data login_url = f"{EU_LOGIN_URL if self.europe else LOGIN_URL}/api/v1/token_sign_in" async with ayla_client.post( login_url, json=login_data, headers=self._ayla_login_headers, ssl=self.verify_ssl, timeout=15, ) as r2: try: login_json = await r2.json() except Exception: login_json = {"errors": await r2.text()} self._set_credentials(r2.status, login_json) return self._access_token async def async_refresh_auth(self): """ Refresh the authentication synchronously. """ refresh_data = {"user": {"refresh_token": self._refresh_token}} ayla_client = await self.ensure_session() async with ayla_client.post(f"{EU_LOGIN_URL if self.europe else LOGIN_URL:s}/users/refresh_token.json", json=refresh_data, headers=self._ayla_login_headers) 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 async def async_sign_out(self): """ Sign out and invalidate the access token. """ ayla_client = await self.ensure_session() async with ayla_client.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() def gen_fallback_url(self): """ Generate a URL for the fallback authentication flow. Returns: The URL for the fallback authentication flow. """ return FallbackAuth.GenerateFallbackAuthURL(self.europe) @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, verify=self.verify_ssl, **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. """ ayla_client = await self.ensure_session() headers = self._get_headers(kwargs) result = ayla_client.request(http_method, url, headers=headers, ssl=self.verify_ssl, **kwargs) return result 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 async def async_close_session(self): """ Close the shared aiohttp ClientSession. This should be called when you are finished with the AylaApi object. """ shared_session = self.ensure_session() if shared_session is not None: shared_session.close() sharkiqlibs-sharkiq-66fa8cd/sharkiq/const.py000066400000000000000000000022611511441300300213240ustar00rootroot00000000000000"""Various constants""" AUTH0_URL = "https://login.sharkninja.com" AUTH0_HOST = "login.sharkninja.com" AUTH0_CLIENT_ID = "wsguxrqm77mq4LtrTrwg8ZJUxmSrexGi" AUTH0_SCOPES = "openid profile email offline_access" AUTH0_REDIRECT_URI = "com.sharkninja.shark://login.sharkninja.com/ios/com.sharkninja.shark/callback" AUTH0_TOKEN_URL = "https://login.sharkninja.com/oauth/token" DEVICE_URL = "https://ads-sharkue1.aylanetworks.com" LOGIN_URL = "https://user-sharkue1.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_HOST = "logineu.sharkninja.com" EU_AUTH0_CLIENT_ID = "rKDx9O18dBrY3eoJMTkRiBZHDvd9Mx1I" EU_AUTH0_TOKEN_URL = "https://logineu.sharkninja.com/oauth/token" 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" sharkiqlibs-sharkiq-66fa8cd/sharkiq/exc.py000066400000000000000000000017751511441300300207660ustar00rootroot00000000000000"""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 sharkiqlibs-sharkiq-66fa8cd/sharkiq/fallback_auth.py000066400000000000000000000050241511441300300227560ustar00rootroot00000000000000import math import random import hashlib import codecs import base64 import urllib.parse from .const import ( AUTH0_URL, AUTH0_CLIENT_ID, AUTH0_SCOPES, AUTH0_REDIRECT_URI, EU_AUTH0_URL ) class FallbackAuth: def GenerateFallbackAuthURL(europe: bool): """ Generate an authorization URL for Auth0 that mimics the Shark app's behavior. Args: europe: If True, use the EU Auth0 URL and app ID/secret. Returns: The authorization URL. """ state = FallbackAuth.generateRandomString(43) verification = FallbackAuth.generateRandomString(43) challenge = FallbackAuth.generateChallengeB64Hash(verification) base_url = EU_AUTH0_URL if europe == True else AUTH0_URL url = (base_url + "/authorize?os=ios&response_type=code&mobile_shark_app_version=rn1.01" + '&client_id=' + FallbackAuth.urlEncode(AUTH0_CLIENT_ID) + '&state=' + FallbackAuth.urlEncode(state) + '&scope=' + FallbackAuth.urlEncode(AUTH0_SCOPES) + '&redirect_uri=' + FallbackAuth.urlEncode(AUTH0_REDIRECT_URI) + '&code_challenge=' + FallbackAuth.urlEncode(challenge) + '&screen_hint=signin' + '&code_challenge_method=S256' + '&ui_locales=en') return url def generateRandomString(length): """ Generate a random string of alphanumeric characters. Args: length: The length of the string to generate. Returns: A random string of alphanumeric characters of the specified length. """ characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' result = '' for _ in range(length): randomIndex = math.floor(random.random() * len(characters)) result += characters[randomIndex] return result def generateChallengeB64Hash(verification_code): """ Generate a challenge hash for the PKCE flow. Args: verification_code: The verification code to use in the hash. Returns: str: The challenge hash, base64 encoded with URL safe characters. """ verification_encoded = codecs.encode(verification_code, 'utf-8') verification_sha256 = hashlib.sha256(verification_encoded) challenge_b64 = base64.b64encode(verification_sha256.digest()).decode() challenge_b64_clean = challenge_b64.replace("+", "-").replace("/", "_").replace("=", "").replace("$", "") return challenge_b64_clean def urlEncode(s): """ URL encode a string. Args: s (str): The string to URL encode. Returns: str: The URL encoded string. """ return urllib.parse.quote_plus(s)sharkiqlibs-sharkiq-66fa8cd/sharkiq/sharkiq.py000066400000000000000000000554161511441300300216520ustar00rootroot00000000000000"""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) if ":" in room_list: room_arr = 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': room_arr[0], 'rooms': room_arr[1:], } else: return { # No room support - retain response format 'identifier': 'none', 'rooms': [], } 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)) sharkiqlibs-sharkiq-66fa8cd/tests/000077500000000000000000000000001511441300300173235ustar00rootroot00000000000000sharkiqlibs-sharkiq-66fa8cd/tests/__init__.py000066400000000000000000000000001511441300300214220ustar00rootroot00000000000000sharkiqlibs-sharkiq-66fa8cd/tests/conftest.py000066400000000000000000000023031511441300300215200ustar00rootroot00000000000000import 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 sharkiqlibs-sharkiq-66fa8cd/tests/test_ayla_api.py000066400000000000000000000226151511441300300225210ustar00rootroot00000000000000import 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. Auth request flagged for verification." 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 credentials are 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}" }sharkiqlibs-sharkiq-66fa8cd/tests/test_sharkiq.py000066400000000000000000000000001511441300300223640ustar00rootroot00000000000000