pax_global_header00006660000000000000000000000064142671513640014523gustar00rootroot0000000000000052 comment=bbda45bb0e921db66b527da6af39f31be0a71175 JeffResc-sharkiq-bbda45b/000077500000000000000000000000001426715136400154175ustar00rootroot00000000000000JeffResc-sharkiq-bbda45b/.gitattributes000066400000000000000000000002021426715136400203040ustar00rootroot00000000000000# Source files # ============ *.py text diff=python eol=lf # Config and data files *.json text eol=lf *.yaml text eol=lf JeffResc-sharkiq-bbda45b/.github/000077500000000000000000000000001426715136400167575ustar00rootroot00000000000000JeffResc-sharkiq-bbda45b/.github/dependabot.yml000066400000000000000000000004051426715136400216060ustar00rootroot00000000000000# Documentation link: # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: - package-ecosystem: "pip" directory: "/" schedule: interval: "weekly" JeffResc-sharkiq-bbda45b/.github/workflows/000077500000000000000000000000001426715136400210145ustar00rootroot00000000000000JeffResc-sharkiq-bbda45b/.github/workflows/code_coverage.yml000066400000000000000000000022601426715136400243240ustar00rootroot00000000000000--- name: Code Coverage on: push: branches: - main pull_request: branches: - main jobs: test: name: Python ${{ matrix.python-version }} on ${{ matrix.os }} runs-on: ${{ matrix.os }}-latest strategy: matrix: os: [ubuntu] python-version: ['3.7', '3.8', '3.9', '3.10'] steps: - name: Checking out code from GitHub uses: actions/checkout@v2.4.0 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v2.2.2 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.9 && matrix.os == 'ubuntu' uses: codecov/codecov-action@v1.5.2 with: token: ${{ secrets.CODECOV_TOKEN }} file: ./coverage.xml flags: unittests name: codecov-umbrella JeffResc-sharkiq-bbda45b/.github/workflows/publish.yml000066400000000000000000000015521426715136400232100ustar00rootroot00000000000000# 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@v2.4.0 - name: Set up Python uses: actions/setup-python@v3.1.1 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/*JeffResc-sharkiq-bbda45b/.gitignore000066400000000000000000000035031426715136400174100ustar00rootroot00000000000000# 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.pyJeffResc-sharkiq-bbda45b/LICENSE000066400000000000000000000021701426715136400164240ustar00rootroot00000000000000MIT 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. JeffResc-sharkiq-bbda45b/README.md000066400000000000000000000157061426715136400167070ustar00rootroot00000000000000[![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) ![PyPI - Downloads](https://img.shields.io/pypi/dm/sharkiq) ![GitHub](https://img.shields.io/github/license/JeffResc/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 ### Simple Operation ```python from 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() ``` ### Async operation ```python import 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)) ``` ## Documentation ### `get_ayla_api(username, password, websession=None)` Returns and `AylaApi` object to interact with the Ayla Networks Device API conrolling the Shark IQ robot, with the `app_id` and `app_secret` parameters set for the Shark IQ robot. ### `class AylaAPI(username, password, app_id, app_secret, websession)` Class for interacting with the Ayla Networks Device API underlying the Shark IQ controls. * `username: str` * `password: str` * `app_id: str` * `app_secret: str` * `websession: Optional[aiohttp.ClientSession] = None` Optional `aiohttp.ClientSession` to use for async calls. If one is not provided, a new `aiohttp.ClientSession` will be created at the first async call. #### Methods * `get_devices()`/`async_get_devices()` Get a list of `SharkIqVacuum`s for every device found in `list_devices()` * `list_devices()`/`async_list_devices()` Get a list of known device description `dict`s * `refesh_auth()`/`async_refesh_auth()` Refresh the authentication token * `request(method, url, headers = None, auto_refresh = True, **kwargs)`/`async_request(...)` Submit an HTTP request to the Ayla networks API with the auth header * `method: str` An HTTP method, usually `'get'` or `'post'` * `url: str` * `headers: Optional[Dict] = None` Optional `dict` of HTTP headers besides the auth header, which is included automatically * `auto_refresh: bool = True` If `True`, automatically call `refesh_auth()`/`async_refesh_auth()` if the auth token is near expiration * `**kwargs` Passed on to `requests.request` or `aiohttp.ClientSession.request` * `sign_in()`/`async_sign_in()` Authenticate * `sign_out()`/`async_sign_out()` Sign out ### `class SharkIqVacuum(ayla_api, device_dct)` Primary API for interacting with Shark IQ vacuums * `ayla_api: AylaApi` An `AylaApi` with an authenticated connection * `device_dct: Dict` A `dict` describing the device, usually obtained from `AylaApi.list_devices()` #### Methods * `find_device()`/`async_find_device()` Cause the device to emit an annoying chirp * `get_file_property_url(property_name)`/`async_get_file_property_url(property_name)` Get the URL for the latest version of a `'file'`-type property * `property_name: Union[str, PropertyName]` Either a `str` or `PropertyNames` value for the desired property * `get_file_property(property_name)`/`async_get_file_property(property_name)` Get the contents of the latest version of a `'file'`-type property * `property_name: Union[str, PropertyName]` Either a `str` or `PropertyNames` value for the desired property * `get_metadata()`/`async_get_metadata()` Update some device metadata (`vac_model_number` and `vac_serial_number`) * `set_operating_mode(mode)`/`async_set_operating_mode(mode)` Set the operating mode. This is just a thin wrapper around `set_property_value`/`async_set_property_value` provided for convenience. * mode: OperatingModes Mode to set, e.g., `OperatingModes.START` to start the vacuum * `get_property_value(property_name)/async_get_property_value(property_name)` Returns the value of `property_name`, cast to the appropriate type * `property_name: Union[str, PropertyName]` Either a `str` or `PropertyNames` value for the desired property * `set_property_value(property_name, property_value)/async_set_property_value(property_name, property_value)` Set the value of `property_name` * `property_name: Union[str, PropertyName]` Either a `str` or `PropertyName` value for the desired property * `property_value: Any` New value. Type checking is currently left to the remote API. * `update()`/`async_update(property_list=None)` Fetch the updated robot state from the remote api * `property_list: Optional[Interable[str]]` An optional iterable of property names. If specified, only those properties will be updated. * `get_room_list()` Get a list of known room `str`s * `clean_rooms(List[str])` Start cleaning a subset of rooms #### Properties * `ayla_api` The underlying `AylaApi` object * `error_code` Error code, if any. *NB: these can be very stale as they are not immediately reset in the API when the error is cleared*. * `error_text` English description of the `error_code`. The same caveat applies. * `name` The device name as it appears in the SharkClean app * `oem_model_number` A "rough" model number that does not distinguish, for example, between robots with and without a self-emptying base * `properties_full` A dictionary representing all known device properties and their metadata (type `Dict[str, Dict]`) * `property_values` A convenience accessor into `properties_full` mapping property names to values * `serial_number` The unique device serial number used with the Ayla Networks API (NB: this name may change) * `vac_model_number` The precise model number * `vac_serial_number` The serial number printed on the device ### Enums * `OperatingModes` Operation modes to control the vacuum (`START`, `STOP`, `PAUSE`, `RETURN`) * `PowerModes` Vacuum power mode (`ECO`, `NORMAL`, `MAX`) * `Properties` Properties to use with `get_property_value`/`set_property_value` ### 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/)JeffResc-sharkiq-bbda45b/pytest.ini000066400000000000000000000000341426715136400174450ustar00rootroot00000000000000[pytest] asyncio_mode=strictJeffResc-sharkiq-bbda45b/requirements.test.txt000066400000000000000000000000661426715136400216630ustar00rootroot00000000000000pytest-asyncio==0.18.3 pytest==7.1.1 pytest-cov==3.0.0JeffResc-sharkiq-bbda45b/requirements.txt000066400000000000000000000000401426715136400206750ustar00rootroot00000000000000aiohttp>=3.8.1 requests>=2.27.1 JeffResc-sharkiq-bbda45b/setup.py000066400000000000000000000023031426715136400171270ustar00rootroot00000000000000import 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")), ) JeffResc-sharkiq-bbda45b/sharkiq/000077500000000000000000000000001426715136400170615ustar00rootroot00000000000000JeffResc-sharkiq-bbda45b/sharkiq/__init__.py000066400000000000000000000005241426715136400211730ustar00rootroot00000000000000"""Python API for Shark IQ vacuum robots""" 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.0.2' JeffResc-sharkiq-bbda45b/sharkiq/ayla_api.py000066400000000000000000000216171426715136400212210ustar00rootroot00000000000000""" 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 datetime import datetime, timedelta from typing import Dict, List, Optional from .const import ( DEVICE_URL, LOGIN_URL, SHARK_APP_ID, SHARK_APP_SECRET, EU_DEVICE_URL, EU_LOGIN_URL, EU_SHARK_APP_ID, EU_SHARK_APP_SECRET ) 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""" if europe: return AylaApi(username, password, EU_SHARK_APP_ID, EU_SHARK_APP_SECRET, websession=websession, europe=europe) else: return AylaApi(username, password, SHARK_APP_ID, SHARK_APP_SECRET, websession=websession) class AylaApi: """Simple Ayla Networks API wrapper""" def __init__( self, email: str, password: str, app_id: str, app_secret: str, websession: Optional[aiohttp.ClientSession] = None, europe: bool = False): self._email = email self._password = password 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._app_secret = app_secret self.websession = websession self.europe = europe async def ensure_session(self) -> aiohttp.ClientSession: """Ensure that we have 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""" return { "user": { "email": self._email, "password": self._password, "application": {"app_id": self._app_id, "app_secret": self._app_secret}, } } def _set_credentials(self, status_code: int, login_result: Dict): """Update the internal credentials store.""" if status_code == 404: raise SharkIqAuthError(login_result["error"]["message"] + " (Confirm app_id and app_secret are correct)") elif status_code == 401: raise SharkIqAuthError(login_result["error"]["message"]) 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 = True # TODO: Any non 200 status code should cause this to be false def sign_in(self): """Authenticate to Ayla API synchronously.""" login_data = self._login_data resp = requests.post(f"{EU_LOGIN_URL if self.europe else LOGIN_URL:s}/users/sign_in.json", json=login_data) 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 synchronously.""" session = await self.ensure_session() login_data = self._login_data async with session.post(f"{EU_LOGIN_URL if self.europe else LOGIN_URL:s}/users/sign_in.json", json=login_data) as resp: self._set_credentials(resp.status, await 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""" 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]: """When does the auth expire""" 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""" 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""" 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""" 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]: 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. """ 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: headers = self._get_headers(kwargs) return requests.request(method, url, headers=headers, **kwargs) async def async_request(self, http_method: str, url: str, **kwargs): 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]: 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]: 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]: 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]: 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 JeffResc-sharkiq-bbda45b/sharkiq/const.py000066400000000000000000000007411426715136400205630ustar00rootroot00000000000000"""Various constants""" DEVICE_URL = "https://ads-field-39a9391a.aylanetworks.com" LOGIN_URL = "https://user-field-39a9391a.aylanetworks.com" SHARK_APP_ID = "Shark-Android-field-id" SHARK_APP_SECRET = "Shark-Android-field-Wv43MbdXRM297HUHotqe6lU1n-w" EU_DEVICE_URL = "https://ads-eu.aylanetworks.com" EU_LOGIN_URL = "https://user-field-eu.aylanetworks.com" EU_SHARK_APP_ID = "Shark-Android-EUField-Fw-id" EU_SHARK_APP_SECRET = "Shark-Android-EUField-s-zTykblGJujGcSSTaJaeE4PESI" JeffResc-sharkiq-bbda45b/sharkiq/exc.py000066400000000000000000000017711426715136400202200ustar00rootroot00000000000000"""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 JeffResc-sharkiq-bbda45b/sharkiq/sharkiq.py000066400000000000000000000421471426715136400211050ustar00rootroot00000000000000"""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""" return datetime.strptime(date_string, TIMESTAMP_FMT) @enum.unique class PowerModes(enum.IntEnum): """Vacuum power modes""" ECO = 1 NORMAL = 0 MAX = 2 @enum.unique class OperatingModes(enum.IntEnum): """Vacuum operation modes""" STOP = 0 PAUSE = 1 START = 2 RETURN = 3 @enum.unique class Properties(enum.Enum): """Useful properties""" 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", } def _clean_property_name(raw_property_name: str) -> str: """Clean up property names""" 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): 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: return self._oem_model_number @property def vac_model_number(self) -> Optional[str]: return self._vac_model_number @property def vac_serial_number(self) -> Optional[str]: return self._vac_serial_number @property def name(self): return self._name @property def serial_number(self) -> str: return self._dsn @property def metadata_endpoint(self) -> str: """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]): 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""" 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""" 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""" 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""" 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""" 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""" 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""" 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""" 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`""" 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`""" 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""" return self.get_property_value(Properties.ERROR_CODE) @property def error_text(self) -> Optional[str]: """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""" 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""" 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""" 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""" 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""" # 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""" 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""" 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: 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: 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.""" 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): self._shark = shark def __getitem__(self, key): 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): for k in self._shark.properties_full.keys(): yield k def __len__(self) -> int: return self._shark.properties_full.__len__() def __str__(self) -> str: return pformat(dict(self)) JeffResc-sharkiq-bbda45b/tests/000077500000000000000000000000001426715136400165615ustar00rootroot00000000000000JeffResc-sharkiq-bbda45b/tests/__init__.py000066400000000000000000000000001426715136400206600ustar00rootroot00000000000000JeffResc-sharkiq-bbda45b/tests/conftest.py000066400000000000000000000023031426715136400207560ustar00rootroot00000000000000import 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 JeffResc-sharkiq-bbda45b/tests/test_ayla_api.py000066400000000000000000000201371426715136400217540ustar00rootroot00000000000000import aiohttp import pytest from sharkiq.ayla_api import get_ayla_api, AylaApi from sharkiq.const import SHARK_APP_ID, SHARK_APP_SECRET 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", "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.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 == { "user": { "email": "myusername@mysite.com", "password": "mypassword", "application": { "app_id": SHARK_APP_ID, "app_secret": SHARK_APP_SECRET, }, } } def test_set_credentials__404_response(self, dummy_api): with pytest.raises(SharkIqAuthError) as e: dummy_api._set_credentials(404, {"error": {"message": "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, {"error": {"message": "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}" }JeffResc-sharkiq-bbda45b/tests/test_sharkiq.py000066400000000000000000000000001426715136400216220ustar00rootroot00000000000000