├── requirements.txt ├── pybotvac ├── version.py ├── __init__.py ├── vorwerk.py ├── exceptions.py ├── neato.py ├── session.py ├── account.py └── robot.py ├── setup.cfg ├── .github └── workflows │ └── main.yaml ├── setup.py ├── LICENSE ├── sample └── sample.py ├── .gitignore ├── README.md └── .pylintrc /requirements.txt: -------------------------------------------------------------------------------- 1 | urllib3 2 | requests 3 | requests_oauthlib 4 | voluptuous 5 | bandit>=1 6 | black 7 | codespell>=2 8 | flake8>=3 9 | isort>=5 10 | pylint>=2.6 11 | -------------------------------------------------------------------------------- /pybotvac/version.py: -------------------------------------------------------------------------------- 1 | import pkg_resources 2 | 3 | try: 4 | __version__ = pkg_resources.get_distribution("pybotvac").version 5 | except Exception: # pylint: disable=broad-except 6 | __version__ = "unknown" 7 | -------------------------------------------------------------------------------- /pybotvac/__init__.py: -------------------------------------------------------------------------------- 1 | from .account import Account 2 | from .neato import Neato 3 | from .robot import Robot 4 | from .session import OAuthSession, PasswordlessSession, PasswordSession 5 | from .version import __version__ 6 | from .vorwerk import Vorwerk 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [codespell] 2 | skip = ./.git,./.mypy_cache,./.vscode,./venv 3 | 4 | [flake8] 5 | exclude = .vscode,venv 6 | max-line-length = 88 7 | ignore = 8 | # Import error, pylint covers this 9 | F401, 10 | # Formatting errors that are covered by black 11 | D202, 12 | E203, 13 | E501, 14 | W503, 15 | W504, 16 | 17 | [isort] 18 | profile=black 19 | multi_line_output = 3 20 | src_paths=pybotvac, sample 21 | -------------------------------------------------------------------------------- /pybotvac/vorwerk.py: -------------------------------------------------------------------------------- 1 | from .neato import Vendor 2 | 3 | 4 | class Vorwerk(Vendor): 5 | name = "vorwerk" 6 | endpoint = "https://beehive.ksecosys.com/" 7 | passwordless_endpoint = "https://mykobold.eu.auth0.com/passwordless/start" 8 | token_endpoint = "https://mykobold.eu.auth0.com/oauth/token" # nosec 9 | scope = ["openid", "email", "profile", "read:current_user", "offline_access"] 10 | audience = "https://mykobold.eu.auth0.com/userinfo" 11 | source = "vorwerk_auth0" 12 | cert_path = True 13 | -------------------------------------------------------------------------------- /pybotvac/exceptions.py: -------------------------------------------------------------------------------- 1 | class NeatoException(Exception): 2 | """ 3 | General neato exception. 4 | """ 5 | 6 | 7 | class NeatoLoginException(NeatoException): 8 | """ 9 | To indicate there is a login issue. 10 | """ 11 | 12 | 13 | class NeatoRobotException(NeatoException): 14 | """ 15 | To be thrown anytime there is a robot error. 16 | """ 17 | 18 | 19 | class NeatoUnsupportedDevice(NeatoRobotException): 20 | """ 21 | To be thrown only for unsupported devices. 22 | """ 23 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Run Checks 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v2 11 | - name: Setup 12 | uses: actions/setup-python@v2 13 | with: 14 | python-version: "3.8" 15 | architecture: "x64" 16 | - run: pip install -r requirements.txt 17 | - run: codespell 18 | - run: black --check . 19 | - run: isort --check --diff . 20 | - run: pylint pybotvac 21 | - run: flake8 pybotvac 22 | - run: bandit -r pybotvac 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open("README.md", "r") as f: 4 | long_description = f.read() 5 | 6 | setup( 7 | name="pybotvac", 8 | version="0.0.28", 9 | description="Python package for controlling Neato pybotvac Connected vacuum robot", 10 | long_description=long_description, 11 | long_description_content_type="text/markdown", 12 | author="Stian Askeland", 13 | author_email="stianaske@gmail.com", 14 | url="https://github.com/stianaske/pybotvac", 15 | license="Licensed under the MIT license. See LICENSE file for details", 16 | packages=["pybotvac"], 17 | package_dir={"pybotvac": "pybotvac"}, 18 | package_data={"pybotvac": ["cert/*.crt"]}, 19 | install_requires=["requests", "requests_oauthlib", "voluptuous"], 20 | ) 21 | -------------------------------------------------------------------------------- /pybotvac/neato.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dataclasses import dataclass 3 | from typing import List, Union 4 | 5 | 6 | @dataclass(init=False, frozen=True) 7 | class Vendor: 8 | name: str 9 | endpoint: str 10 | auth_endpoint: str 11 | passwordless_endpoint: str 12 | token_endpoint: str 13 | scope: List[str] 14 | audience: str 15 | source: str 16 | cert_path: Union[str, bool] = False 17 | beehive_version: str = "application/vnd.neato.beehive.v1+json" 18 | nucleo_version: str = "application/vnd.neato.nucleo.v1" 19 | 20 | 21 | class Neato(Vendor): 22 | name = "neato" 23 | endpoint = "https://beehive.neatocloud.com/" 24 | auth_endpoint = "https://apps.neatorobotics.com/oauth2/authorize" 25 | token_endpoint = "https://beehive.neatocloud.com/oauth2/token" # nosec 26 | scope = ["public_profile", "control_robots", "maps"] 27 | cert_path = None 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Stian Askeland 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /sample/sample.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from pybotvac import ( 4 | Account, 5 | Neato, 6 | OAuthSession, 7 | PasswordlessSession, 8 | PasswordSession, 9 | Vorwerk, 10 | ) 11 | 12 | # Set email and password if you plan to use password authentication. 13 | # Set Client ID and Secret if you plan to use OAuth2. 14 | # If you plan to use email OTP, all you need to do is specify your email and a Client ID. 15 | email = "Your email" 16 | password = "Your password" 17 | client_id = "Your client it" 18 | client_secret = "Your client secret" 19 | redirect_uri = "Your redirect URI" 20 | 21 | # Set your vendor 22 | vendor = Neato() 23 | 24 | ########################## 25 | # Authenticate via Email and Password 26 | ########################## 27 | # session = PasswordSession(email=email, password=password, vendor=vendor) 28 | # account = Account(session) 29 | 30 | ########################## 31 | # Authenticate via OAuth2 32 | ########################## 33 | session = OAuthSession( 34 | client_id=client_id, 35 | client_secret=client_secret, 36 | redirect_uri=redirect_uri, 37 | vendor=vendor, 38 | ) 39 | authorization_url = session.get_authorization_url() 40 | print("Visit: " + authorization_url) 41 | authorization_response = input("Enter the full callback URL: ") 42 | token = session.fetch_token(authorization_response) 43 | account = Account(session) 44 | 45 | ########################## 46 | # Authenticate via One Time Password 47 | ########################## 48 | # session = PasswordlessSession(client_id=client_id, vendor=vendor) 49 | # session.send_email_otp(email) 50 | # code = input("Enter the code: ") 51 | # session.fetch_token_passwordless(email, code) 52 | # account = Account(session) 53 | 54 | print("Robots:") 55 | for robot in account.robots: 56 | print(robot) 57 | print() 58 | 59 | print("State:\n", robot.state) 60 | print() 61 | 62 | print("Schedule enabled:", robot.schedule_enabled) 63 | 64 | print("Disabling schedule") 65 | robot.schedule_enabled = False 66 | 67 | print("Schedule enabled:", robot.schedule_enabled) 68 | 69 | print("Enabling schedule") 70 | robot.schedule_enabled = True 71 | 72 | print("Schedule enabled:", robot.schedule_enabled) 73 | print() 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | # IDE 141 | .vscode 142 | .idea 143 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pybotvac 2 | 3 | This is an unofficial API for controlling Neato Botvac Connected vacuum robots. 4 | The code is based on https://github.com/kangguru/botvac and credit for reverse engineering the API goes to 5 | [Lars Brillert @kangguru](https://github.com/kangguru) 6 | 7 | ## Disclaimer 8 | This API is experimental. Use at your own risk. Feel free to contribute if things are not working. 9 | 10 | ## Installation 11 | Install using pip 12 | 13 | ```bash 14 | pip install pybotvac 15 | ``` 16 | 17 | Alternatively, clone the repository and run 18 | 19 | ```bash 20 | python setup.py install 21 | ``` 22 | 23 | ## Usage 24 | ### Robot 25 | If the serial and secret for your robot is known, simply run 26 | 27 | ```python 28 | >>> from pybotvac import Robot 29 | >>> robot = Robot('OPS01234-0123456789AB', '0123456789ABCDEF0123456789ABCDEF', 'my_robot_name') 30 | >>> print(robot) 31 | Name: sample_robot, Serial: OPS01234-0123456789AB, Secret: 0123456789ABCDEF0123456789ABCDEF 32 | ``` 33 | 34 | The format of the serial should be 'OPSxxxxx-xxxxxxxxxxxx', and the secret should be a string of hex characters 32 characters long. 35 | These can be found by using the Account class. 36 | 37 | To start cleaning 38 | 39 | ```python 40 | robot.start_cleaning() 41 | ``` 42 | 43 | If no exception occurred, your robot should now get to work. 44 | 45 | Currently the following methods are available in the Robot class: 46 | 47 | * get_robot_state() 48 | * start_cleaning() 49 | * start_spot_cleaning() 50 | * pause_cleaning() 51 | * stop_cleaning() 52 | * send_to_base() 53 | * enable_schedule() 54 | * disable_schedule() 55 | * get_schedule() 56 | 57 | For convenience, properties exist for state and schedule 58 | 59 | ```python 60 | # Get state 61 | state = robot.state 62 | 63 | # Check if schedule is enabled 64 | robot.schedule_enabled 65 | 66 | # Disable schedule 67 | robot.schedule_enabled = False 68 | ``` 69 | 70 | ### Account 71 | If the serial and secret are unknown, they can be retrieved using the Account class. 72 | You need a session instance to create an account. 73 | There are three different types of sessions available. 74 | It depends on your provider which session is suitable for you. 75 | 76 | * **PasswordSession** lets you authenticate via E-Mail and Password. Even though this works fine, it is not recommended. 77 | * **OAuthSession** lets you authenticate via OAuth2. You have to create an application [here](https://developers.neatorobotics.com/applications) in order to generate `client_id`, `client_secret` and `redirect_url`. 78 | * **PasswordlessSession** is known to work for users of the new MyKobold App. The only known `client_id` is `KY4YbVAvtgB7lp8vIbWQ7zLk3hssZlhR`. 79 | 80 | ```python 81 | from pybotvac import Account, Neato, OAuthSession, PasswordlessSession, PasswordSession, Vorwerk 82 | 83 | email = "Your email" 84 | password = "Your password" 85 | client_id = "Your client it" 86 | client_secret = "Your client secret" 87 | redirect_uri = "Your redirect URI" 88 | 89 | # Authenticate via Email and Password 90 | password_session = PasswordSession(email=email, password=password, vendor=Neato()) 91 | 92 | # Authenticate via OAuth2 93 | oauth_session = OAuthSession(client_id=client_id, client_secret=client_secret, redirect_uri=redirect_uri, vendor=Neato()) 94 | authorization_url = oauth_session.get_authorization_url() 95 | print("Visit: " + authorization_url) 96 | authorization_response = input("Enter the full callback URL: ") 97 | token = oauth_session.fetch_token(authorization_response) 98 | 99 | # Authenticate via One Time Password 100 | passwordless_session = PasswordlessSession(client_id=client_id, vendor=Vorwerk()) 101 | passwordless_session.send_email_otp(email) 102 | code = input("Enter the code: ") 103 | passwordless_session.fetch_token_passwordless(email, code) 104 | 105 | # Create an account with one of the generated sessions 106 | account = Account(password_session) 107 | 108 | # List all robots associated with account 109 | for robot in account.robots: 110 | print(robot) 111 | ``` 112 | 113 | Information about maps and download of maps can be done from the Account class: 114 | 115 | ```python 116 | >>> from pybotvac import Account 117 | >>> # List all maps associated with a specific robot 118 | >>> for map_info in Account(PasswordSession('sample@email.com', 'sample_password')).maps: 119 | ... print(map_info) 120 | ``` 121 | 122 | A cleaning map can be downloaded with the account class. Returns the raw image response. Example shows latest map. 123 | You need the url from the map output to do that: 124 | 125 | ```python 126 | >>> from pybotvac import Account 127 | >>> # List all maps associated with a specific robot 128 | >>> map = Account(PasswordSession('sample@email.com', 'sample_password')).maps 129 | >>> download_link = map['robot_serial']['maps'][0]['url'] 130 | >>> Account('sample@email.com', 'sample_password').get_map_image(download_link) 131 | ``` 132 | -------------------------------------------------------------------------------- /pybotvac/session.py: -------------------------------------------------------------------------------- 1 | """Sessionhandling for beehive endpoint.""" 2 | 3 | import binascii 4 | import json 5 | import os 6 | import os.path 7 | from typing import Callable, Dict, Optional 8 | 9 | import requests 10 | from oauthlib.oauth2 import TokenExpiredError 11 | from requests_oauthlib import OAuth2Session 12 | 13 | from .exceptions import NeatoException, NeatoLoginException, NeatoRobotException 14 | from .neato import Neato, Vendor 15 | from .vorwerk import Vorwerk 16 | 17 | try: 18 | from urllib.parse import urljoin 19 | except ImportError: 20 | from urlparse import urljoin 21 | 22 | 23 | class Session: 24 | def __init__(self, vendor: Vendor): 25 | """Initialize the session.""" 26 | self.vendor = vendor 27 | self.endpoint = vendor.endpoint 28 | self.headers = {"Accept": vendor.beehive_version} 29 | 30 | def get(self, path, **kwargs): 31 | """Send a GET request to the specified path.""" 32 | raise NotImplementedError 33 | 34 | def urljoin(self, path): 35 | return urljoin(self.endpoint, path) 36 | 37 | def generate_headers( 38 | self, custom_headers: Optional[Dict[str, str]] = None 39 | ) -> Dict[str, str]: 40 | """Merge self.headers with custom headers id necessary.""" 41 | if not custom_headers: 42 | return self.headers 43 | 44 | return {**self.headers, **custom_headers} 45 | 46 | 47 | class PasswordSession(Session): 48 | def __init__(self, email: str, password: str, vendor: Vendor = Neato()): 49 | super().__init__(vendor=vendor) 50 | self._login(email, password) 51 | 52 | def _login(self, email: str, password: str): 53 | """ 54 | Login to pybotvac account using provided email and password. 55 | 56 | :param email: email for pybotvac account 57 | :param password: Password for pybotvac account 58 | :return: 59 | """ 60 | 61 | try: 62 | response = requests.post( 63 | urljoin(self.endpoint, "sessions"), 64 | json={ 65 | "email": email, 66 | "password": password, 67 | "platform": "ios", 68 | "token": binascii.hexlify(os.urandom(64)).decode("utf8"), 69 | }, 70 | headers=self.headers, 71 | timeout=10, 72 | ) 73 | 74 | response.raise_for_status() 75 | access_token = response.json()["access_token"] 76 | 77 | # pylint: disable=consider-using-f-string 78 | self.headers["Authorization"] = "Token token=%s" % access_token 79 | except ( 80 | requests.exceptions.ConnectionError, 81 | requests.exceptions.HTTPError, 82 | requests.exceptions.Timeout, 83 | ) as ex: 84 | if ( 85 | isinstance(ex, requests.exceptions.HTTPError) 86 | and ex.response.status_code == 403 87 | ): 88 | raise NeatoLoginException( 89 | "Unable to login to neato, check account credentials." 90 | ) from ex 91 | raise NeatoRobotException("Unable to connect to Neato API.") from ex 92 | 93 | def get(self, path, **kwargs): 94 | url = self.urljoin(path) 95 | headers = self.generate_headers(kwargs.pop("headers", None)) 96 | try: 97 | response = requests.get(url, headers=headers, timeout=10, **kwargs) 98 | response.raise_for_status() 99 | except ( 100 | requests.exceptions.ConnectionError, 101 | requests.exceptions.HTTPError, 102 | requests.exceptions.Timeout, 103 | ) as ex: 104 | raise NeatoException("Unable to connect to neato the neato serves.") from ex 105 | return response 106 | 107 | 108 | class OAuthSession(Session): 109 | def __init__( 110 | self, 111 | token: Optional[Dict[str, str]] = None, 112 | client_id: str = None, 113 | client_secret: str = None, 114 | redirect_uri: str = None, 115 | token_updater: Optional[Callable[[str], None]] = None, 116 | vendor: Vendor = Neato(), 117 | ): 118 | super().__init__(vendor=vendor) 119 | 120 | self._client_id = client_id 121 | self._client_secret = client_secret 122 | self._redirect_uri = redirect_uri 123 | self._token_updater = token_updater 124 | 125 | extra = {"client_id": self._client_id, "client_secret": self._client_secret} 126 | 127 | self._oauth = OAuth2Session( 128 | auto_refresh_kwargs=extra, 129 | client_id=client_id, 130 | token=token, 131 | redirect_uri=redirect_uri, 132 | token_updater=token_updater, 133 | scope=vendor.scope, 134 | ) 135 | 136 | def refresh_tokens(self) -> dict: 137 | """Refresh and return new tokens.""" 138 | token = self._oauth.refresh_token(f"{self.endpoint}/auth/token") 139 | 140 | if self._token_updater is not None: 141 | self._token_updater(token) 142 | 143 | return token 144 | 145 | def get_authorization_url(self) -> str: 146 | """Get an authorization url via oauth2.""" 147 | # pylint: disable=unused-variable 148 | authorization_url, state = self._oauth.authorization_url( 149 | self.vendor.auth_endpoint 150 | ) 151 | return authorization_url 152 | 153 | def fetch_token(self, authorization_response: str) -> Dict[str, str]: 154 | """Fetch an access token via oauth2.""" 155 | token = self._oauth.fetch_token( 156 | self.vendor.token_endpoint, 157 | authorization_response=authorization_response, 158 | client_secret=self._client_secret, 159 | ) 160 | return token 161 | 162 | def get(self, path: str, **kwargs) -> requests.Response: 163 | """Make a get request. 164 | 165 | We don't use the built-in token refresh mechanism of OAuth2 session because 166 | we want to allow overriding the token refresh logic. 167 | """ 168 | url = self.urljoin(path) 169 | try: 170 | response = self._get(url, **kwargs) 171 | response.raise_for_status() 172 | except ( 173 | requests.exceptions.ConnectionError, 174 | requests.exceptions.HTTPError, 175 | requests.exceptions.Timeout, 176 | ) as ex: 177 | raise NeatoException("Unable to connect to neato the neato serves.") from ex 178 | return response 179 | 180 | def _get(self, path: str, **kwargs) -> requests.Response: 181 | """Get request without error handling. 182 | 183 | Refreshes the token if necessary. 184 | """ 185 | headers = self.generate_headers(kwargs.pop("headers", None)) 186 | try: 187 | return self._oauth.get(path, headers=headers, **kwargs) 188 | except TokenExpiredError: 189 | self._oauth.token = self.refresh_tokens() 190 | 191 | return self._oauth.get(path, headers=self.headers, **kwargs) 192 | 193 | 194 | class PasswordlessSession(Session): 195 | def __init__( 196 | self, 197 | token: Optional[Dict[str, str]] = None, 198 | client_id: str = None, 199 | token_updater: Optional[Callable[[str], None]] = None, 200 | vendor: Vendor = Vorwerk(), 201 | ): 202 | super().__init__(vendor=vendor) 203 | 204 | self._token = token 205 | self._client_id = client_id 206 | self._token_updater = token_updater 207 | 208 | def send_email_otp(self, email: str): 209 | """Request an authorization code via email.""" 210 | response = requests.post( 211 | self.vendor.passwordless_endpoint, 212 | data=json.dumps( 213 | { 214 | "client_id": self._client_id, 215 | "connection": "email", 216 | "email": email, 217 | "send": "code", 218 | } 219 | ), 220 | headers={"Content-Type": "application/json"}, 221 | timeout=10, 222 | ) 223 | response.raise_for_status() 224 | 225 | def fetch_token_passwordless(self, email: str, code: str): 226 | """Fetch an access token using the emailed code.""" 227 | response = requests.post( 228 | self.vendor.token_endpoint, 229 | data=json.dumps( 230 | { 231 | "prompt": "login", 232 | "grant_type": "http://auth0.com/oauth/grant-type/passwordless/otp", 233 | "scope": " ".join(self.vendor.scope), 234 | "locale": "en", 235 | "otp": code, 236 | "source": self.vendor.source, 237 | "platform": "ios", 238 | "audience": self.vendor.audience, 239 | "username": email, 240 | "client_id": self._client_id, 241 | "realm": "email", 242 | "country_code": "DE", 243 | } 244 | ), 245 | headers={"Content-Type": "application/json"}, 246 | timeout=10, 247 | ) 248 | response.raise_for_status() 249 | self._token = response.json() 250 | 251 | def get(self, path: str, **kwargs) -> requests.Response: 252 | """Make a get request.""" 253 | url = self.urljoin(path) 254 | headers = self.generate_headers(kwargs.pop("headers", None)) 255 | # pylint: disable=consider-using-f-string 256 | headers["Authorization"] = "Auth0Bearer {}".format(self._token.get("id_token")) 257 | 258 | try: 259 | response = requests.get(url, headers=headers, timeout=10) 260 | response.raise_for_status() 261 | except ( 262 | requests.exceptions.ConnectionError, 263 | requests.exceptions.HTTPError, 264 | requests.exceptions.Timeout, 265 | ) as ex: 266 | raise NeatoException("Unable to connect to neato servers.") from ex 267 | return response 268 | -------------------------------------------------------------------------------- /pybotvac/account.py: -------------------------------------------------------------------------------- 1 | """Account access and data handling for beehive endpoint.""" 2 | 3 | import logging 4 | import os 5 | import shutil 6 | 7 | import requests 8 | from voluptuous import ( 9 | ALLOW_EXTRA, 10 | All, 11 | Any, 12 | Extra, 13 | MultipleInvalid, 14 | Optional, 15 | Range, 16 | Required, 17 | Schema, 18 | Url, 19 | ) 20 | 21 | from .exceptions import NeatoRobotException, NeatoUnsupportedDevice 22 | from .robot import Robot 23 | from .session import Session 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | USER_SCHEMA = Schema( 28 | { 29 | Required("id"): str, 30 | "first_name": Any(str, None), 31 | "last_name": Any(str, None), 32 | "company": Any(str, None), 33 | "locale": Any(str, None), 34 | "phone_number": Any(str, None), 35 | "street_1": Any(str, None), 36 | "street_2": Any(str, None), 37 | "city": Any(str, None), 38 | "post_code": Any(str, None), 39 | "province": Any(str, None), 40 | "state_region": Any(str, None), 41 | "country_code": Any(str, None), 42 | "source": Any(str, None), 43 | "developer": Any(bool, None), 44 | "email": Any(str, None), 45 | "newsletter": Any(bool, None), 46 | "created_at": Any(str, None), 47 | "verified_at": Any(str, None), 48 | } 49 | ) 50 | 51 | ROBOT_SCHEMA = Schema( 52 | { 53 | Required("serial"): str, 54 | "prefix": Any(str, None), 55 | Required("name"): str, 56 | "model": Any(str, None), 57 | Required("secret_key"): str, 58 | "purchased_at": Any(str, None), 59 | "linked_at": Any(str, None), 60 | Required("traits"): list, 61 | # Everything below this line is not documented, but still present 62 | "firmware": Any(str, None), 63 | "timezone": Any(str, None), 64 | Required("nucleo_url"): Url, 65 | "mac_address": Any(str, None), 66 | "created_at": Any(str, None), 67 | }, 68 | extra=ALLOW_EXTRA, 69 | ) 70 | MAP_SCHEMA = Schema( 71 | { 72 | "version": Any(int, None), 73 | Required("id"): str, 74 | Required("url"): Url, 75 | "url_valid_for_seconds": Any(int, None), 76 | Optional("run_id"): str, # documented, but not present 77 | "status": Any(str, None), 78 | "launched_from": Any(str, None), 79 | "error": Any(str, None), 80 | "category": Any(int, None), 81 | "mode": Any(int, None), 82 | "modifier": Any(int, None), 83 | "start_at": Any(str, None), 84 | "end_at": Any(str, None), 85 | "end_orientation_relative_degrees": All(int, Range(min=0, max=360)), 86 | "run_charge_at_start": All(int, Range(min=0, max=100)), 87 | "run_charge_at_end": All(int, Range(min=0, max=100)), 88 | "suspended_cleaning_charging_count": Any(int, None), 89 | "time_in_suspended_cleaning": Any(int, None), 90 | "time_in_error": Any(int, None), 91 | "time_in_pause": Any(int, None), 92 | "cleaned_area": Any(float, None), 93 | "base_count": Any(int, None), 94 | "is_docked": Any(bool, None), 95 | "delocalized": Any(bool, None), 96 | # Everything below this line is not documented, but still present 97 | "generated_at": Any(str, None), 98 | "persistent_map_id": Any(int, str, None), 99 | "cleaned_with_persistent_map_id": Any(int, str, None), 100 | "valid_as_persistent_map": Any(bool, None), 101 | "navigation_mode": Any(int, str, None), 102 | }, 103 | extra=ALLOW_EXTRA, 104 | ) 105 | MAPS_SCHEMA = Schema( 106 | {"stats": {Extra: object}, Required("maps"): [MAP_SCHEMA]}, 107 | extra=ALLOW_EXTRA, 108 | ) 109 | PERSISTENT_MAP_SCHEMA = Schema( 110 | { 111 | Required("id"): Any(int, str), 112 | Required("name"): str, 113 | Required("url"): Url, 114 | "raw_floor_map_url": Any(Url, None), 115 | "url_valid_for_seconds": Any(int, None), 116 | }, 117 | extra=ALLOW_EXTRA, 118 | ) 119 | PERSISTENT_MAPS_SCHEMA = Schema(Required([PERSISTENT_MAP_SCHEMA])) 120 | 121 | 122 | class Account: 123 | """ 124 | Class with data and methods for interacting with a pybotvac cloud session. 125 | 126 | :param email: Email for pybotvac account 127 | :param password: Password for pybotvac account 128 | 129 | """ 130 | 131 | def __init__(self, session: Session): 132 | """Initialize the account data.""" 133 | self._robots = set() 134 | self.robot_serials = {} 135 | self._maps = {} 136 | self._persistent_maps = {} 137 | self._session = session 138 | self._userdata = {} 139 | 140 | @property 141 | def robots(self): 142 | """ 143 | Return set of robots for logged in account. 144 | 145 | :return: 146 | """ 147 | if not self._robots: 148 | self.refresh_robots() 149 | 150 | return self._robots 151 | 152 | @property 153 | def maps(self): 154 | """ 155 | Return set of map data for logged in account. 156 | 157 | :return: 158 | """ 159 | self.refresh_maps() 160 | 161 | return self._maps 162 | 163 | def refresh_maps(self): 164 | """ 165 | Get information about maps of the robots. 166 | 167 | :return: 168 | """ 169 | 170 | for robot in self.robots: 171 | url = f"users/me/robots/{robot.serial}/maps" 172 | resp2 = self._session.get(url) 173 | resp2_json = resp2.json() 174 | try: 175 | MAPS_SCHEMA(resp2_json) 176 | self._maps.update({robot.serial: resp2_json}) 177 | except MultipleInvalid as ex: 178 | _LOGGER.warning( 179 | "Invalid response from %s: %s. Got: %s", url, ex, resp2_json 180 | ) 181 | 182 | def refresh_robots(self): 183 | """ 184 | Get information about robots connected to account. 185 | 186 | :return: 187 | """ 188 | 189 | resp = self._session.get("users/me/robots") 190 | 191 | for robot in resp.json(): 192 | _LOGGER.debug("Create Robot: %s", robot) 193 | try: 194 | ROBOT_SCHEMA(robot) 195 | robot_object = Robot( 196 | name=robot["name"], 197 | vendor=self._session.vendor, 198 | serial=robot["serial"], 199 | secret=robot["secret_key"], 200 | traits=robot["traits"], 201 | endpoint=robot["nucleo_url"], 202 | ) 203 | self._robots.add(robot_object) 204 | except MultipleInvalid as ex: 205 | # Robot was not described accordingly by neato 206 | _LOGGER.warning( 207 | "Bad response from robots endpoint: %s. Got: %s", ex, robot 208 | ) 209 | continue 210 | except NeatoUnsupportedDevice: 211 | # Robot does not support home_cleaning service 212 | _LOGGER.warning("Your robot %s is unsupported.", robot["name"]) 213 | continue 214 | except NeatoRobotException: 215 | # The state of the robot could not be received 216 | _LOGGER.warning("Your robot %s is offline.", robot["name"]) 217 | continue 218 | 219 | self.refresh_persistent_maps() 220 | for robot in self._robots: 221 | robot.has_persistent_maps = ( 222 | len(self._persistent_maps.get(robot.serial, [])) > 0 223 | ) 224 | 225 | @staticmethod 226 | def get_map_image(url, dest_path=None, file_name=None): 227 | """ 228 | Return a requested map from a robot. 229 | 230 | :return: 231 | """ 232 | 233 | try: 234 | image = requests.get(url, stream=True, timeout=10) 235 | 236 | if dest_path: 237 | image_url = url.rsplit("/", 2)[1] + "-" + url.rsplit("/", 1)[1] 238 | if file_name: 239 | image_filename = file_name 240 | else: 241 | image_filename = image_url.split("?")[0] 242 | 243 | dest = os.path.join(dest_path, image_filename) 244 | image.raise_for_status() 245 | with open(dest, "wb") as data: 246 | image.raw.decode_content = True 247 | shutil.copyfileobj(image.raw, data) 248 | except ( 249 | requests.exceptions.ConnectionError, 250 | requests.exceptions.HTTPError, 251 | requests.exceptions.Timeout, 252 | ) as ex: 253 | raise NeatoRobotException("Unable to get robot map") from ex 254 | 255 | return image.raw 256 | 257 | @property 258 | def persistent_maps(self): 259 | """ 260 | Return set of persistent maps for logged in account. 261 | 262 | :return: 263 | """ 264 | self.refresh_persistent_maps() 265 | 266 | return self._persistent_maps 267 | 268 | def refresh_persistent_maps(self): 269 | """ 270 | Get information about persistent maps of the robots. 271 | 272 | :return: 273 | """ 274 | 275 | for robot in self._robots: 276 | url = f"users/me/robots/{robot.serial}/persistent_maps" 277 | resp2 = self._session.get(url) 278 | 279 | try: 280 | PERSISTENT_MAPS_SCHEMA(resp2.json()) 281 | self._persistent_maps.update({robot.serial: resp2.json()}) 282 | except MultipleInvalid as ex: 283 | _LOGGER.warning( 284 | "Invalid response from %s: %s. Got: %s", url, ex, resp2.json() 285 | ) 286 | 287 | @property 288 | def unique_id(self): 289 | """ 290 | Return the unique id of logged in account. 291 | 292 | :return: 293 | """ 294 | if not self._userdata: 295 | self.refresh_userdata() 296 | 297 | return self._userdata["id"] 298 | 299 | @property 300 | def email(self): 301 | """ 302 | Return email of logged in account. 303 | 304 | :return: 305 | """ 306 | if not self._userdata: 307 | self.refresh_userdata() 308 | 309 | return self._userdata["email"] 310 | 311 | def refresh_userdata(self): 312 | """ 313 | Get information about the user who is logged in. 314 | 315 | :return: 316 | """ 317 | url = "users/me" 318 | resp = self._session.get(url) 319 | resp_json = resp.json() 320 | try: 321 | USER_SCHEMA(resp_json) 322 | self._userdata = resp_json 323 | except MultipleInvalid as ex: 324 | _LOGGER.warning("Invalid response from %s: %s. Got: %s", url, ex, resp_json) 325 | -------------------------------------------------------------------------------- /pybotvac/robot.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | import logging 4 | from datetime import datetime, timezone 5 | from email.utils import format_datetime 6 | 7 | import requests 8 | from voluptuous import ( 9 | ALLOW_EXTRA, 10 | All, 11 | Any, 12 | Extra, 13 | MultipleInvalid, 14 | Range, 15 | Required, 16 | Schema, 17 | ) 18 | 19 | from .exceptions import NeatoRobotException, NeatoUnsupportedDevice 20 | from .neato import Neato # For default Vendor argument 21 | 22 | _LOGGER = logging.getLogger(__name__) 23 | 24 | SUPPORTED_SERVICES = ["basic-1", "minimal-2", "basic-2", "basic-3", "basic-4"] 25 | ALERTS_FLOORPLAN = [ 26 | "nav_floorplan_load_fail", 27 | "nav_floorplan_localization_fail", 28 | "nav_floorplan_not_created", 29 | ] 30 | 31 | RESULT_SCHEMA = Schema( 32 | Any( 33 | "ok", 34 | "invalid_json", 35 | "bad_request", 36 | "command_not_found", 37 | "command_rejected", 38 | "ko", 39 | # Everything below this line is not documented, but still present 40 | "not_on_charge_base", 41 | ) 42 | ) 43 | STANDARD_SCHEMA = Schema( 44 | { 45 | "version": int, 46 | "reqId": str, 47 | Required("result"): RESULT_SCHEMA, 48 | "data": {Extra: object}, 49 | }, 50 | extra=ALLOW_EXTRA, 51 | ) 52 | STATE_SCHEMA = Schema( 53 | { 54 | "version": int, 55 | "reqId": str, 56 | Required("result"): RESULT_SCHEMA, 57 | "data": {Extra: object}, 58 | Required("state"): int, 59 | "action": int, 60 | "error": Any(str, None), 61 | "alert": Any(str, None), 62 | "cleaning": { 63 | "category": int, 64 | "mode": int, 65 | "modifier": int, 66 | "navigationMode": int, 67 | "spotWidth": int, 68 | "spotHeight": int, 69 | }, 70 | "details": { 71 | "isCharging": bool, 72 | "isDocked": bool, 73 | "dockHasBeenSeen": bool, 74 | "charge": All(int, Range(min=0, max=100)), 75 | "isScheduleEnabled": bool, 76 | }, 77 | "availableCommands": { 78 | "start": bool, 79 | "stop": bool, 80 | "pause": bool, 81 | "resume": bool, 82 | "goToBase": bool, 83 | }, 84 | Required("availableServices"): { 85 | "findMe": str, 86 | "generalInfo": str, 87 | "houseCleaning": str, 88 | "localStats": str, 89 | "manualCleaning": str, 90 | "maps": str, 91 | "preferences": str, 92 | "schedule": str, 93 | "spotCleaning": str, 94 | # Undocumented services 95 | "IECTest": str, 96 | "logCopy": str, 97 | "softwareUpdate": str, 98 | "wifi": str, 99 | }, 100 | "meta": {"modelName": str, "firmware": str}, 101 | }, 102 | extra=ALLOW_EXTRA, 103 | ) 104 | 105 | 106 | class Robot: 107 | """Data and methods for interacting with a Neato Botvac Connected vacuum robot""" 108 | 109 | def __init__( 110 | self, 111 | serial, 112 | secret, 113 | traits, 114 | vendor=Neato, 115 | name="", 116 | endpoint="https://nucleo.neatocloud.com:4443", 117 | has_persistent_maps=False, 118 | ): 119 | """ 120 | Initialize robot 121 | 122 | :param serial: Robot serial 123 | :param secret: Robot secret 124 | :param name: Name of robot (optional) 125 | :param traits: Extras the robot supports 126 | """ 127 | self.name = name 128 | self._vendor = vendor 129 | self.serial = serial 130 | self.secret = secret 131 | self.traits = traits 132 | self.has_persistent_maps = has_persistent_maps 133 | 134 | # pylint: disable=consider-using-f-string 135 | self._url = "{endpoint}/vendors/{vendor_name}/robots/{serial}/messages".format( 136 | endpoint=endpoint, 137 | vendor_name=vendor.name, 138 | serial=self.serial, 139 | ) 140 | self._headers = {"Accept": vendor.nucleo_version} 141 | 142 | # Check if service_version is supported 143 | # We manually scan the state here to perform appropriate error handling 144 | state = self.get_robot_state().json() 145 | if ( 146 | "availableServices" not in state 147 | or "houseCleaning" not in state["availableServices"] 148 | or state["availableServices"]["houseCleaning"] not in SUPPORTED_SERVICES 149 | ): 150 | raise NeatoUnsupportedDevice( 151 | "Service houseCleaning is not supported by your robot" 152 | ) 153 | 154 | def __str__(self): 155 | # pylint: disable=consider-using-f-string 156 | return "Name: %s, Serial: %s, Secret: %s Traits: %s" % ( 157 | self.name, 158 | self.serial, 159 | self.secret, 160 | self.traits, 161 | ) 162 | 163 | def _message(self, json: dict, schema: Schema): 164 | """ 165 | Sends message to robot with data from parameter 'json' 166 | :param json: dict containing data to send 167 | :return: server response 168 | """ 169 | 170 | try: 171 | response = requests.post( 172 | self._url, 173 | json=json, 174 | verify=self._vendor.cert_path, 175 | auth=Auth(self.serial, self.secret), 176 | headers=self._headers, 177 | timeout=10, 178 | ) 179 | response.raise_for_status() 180 | schema(response.json()) 181 | except ( 182 | requests.exceptions.ConnectionError, 183 | requests.exceptions.HTTPError, 184 | ) as ex: 185 | raise NeatoRobotException("Unable to communicate with robot") from ex 186 | except MultipleInvalid as ex: 187 | _LOGGER.warning( 188 | "Invalid response from %s: %s. Got: %s", self._url, ex, response.json() 189 | ) 190 | 191 | return response 192 | 193 | def start_cleaning( 194 | self, mode=2, navigation_mode=1, category=None, boundary_id=None, map_id=None 195 | ): 196 | # mode & navigation_mode used if applicable to service version 197 | # mode: 1 eco, 2 turbo 198 | # navigation_mode: 1 normal, 2 extra care, 3 deep 199 | # category: 2 non-persistent map, 4 persistent map 200 | # boundary_id: the id of the zone to clean 201 | # map_id: the id of the map to clean 202 | 203 | # Default to using the persistent map if we support basic-3 or basic-4. 204 | if category is None: 205 | category = ( 206 | 4 207 | if self.service_version in ["basic-3", "basic-4"] 208 | and self.has_persistent_maps 209 | else 2 210 | ) 211 | 212 | if self.service_version == "basic-1": 213 | json = { 214 | "reqId": "1", 215 | "cmd": "startCleaning", 216 | "params": {"category": category, "mode": mode, "modifier": 1}, 217 | } 218 | elif self.service_version in ["basic-3", "basic-4"]: 219 | json = { 220 | "reqId": "1", 221 | "cmd": "startCleaning", 222 | "params": { 223 | "category": category, 224 | "mode": mode, 225 | "modifier": 1, 226 | "navigationMode": navigation_mode, 227 | }, 228 | } 229 | if boundary_id: 230 | json["params"]["boundaryId"] = boundary_id 231 | if map_id: 232 | json["params"]["mapId"] = map_id 233 | elif self.service_version == "minimal-2": 234 | json = { 235 | "reqId": "1", 236 | "cmd": "startCleaning", 237 | "params": {"category": category, "navigationMode": navigation_mode}, 238 | } 239 | else: # self.service_version == 'basic-2' 240 | json = { 241 | "reqId": "1", 242 | "cmd": "startCleaning", 243 | "params": { 244 | "category": category, 245 | "mode": mode, 246 | "modifier": 1, 247 | "navigationMode": navigation_mode, 248 | }, 249 | } 250 | 251 | response = self._message(json, STATE_SCHEMA) 252 | result = response.json().get("result", None) 253 | alert = response.json().get("alert", None) 254 | if result != "ok": 255 | _LOGGER.warning( 256 | "Result of robot.start_cleaning is not ok: %s, alert: %s", result, alert 257 | ) 258 | 259 | # Fall back to category 2 if we tried and failed with category 4 260 | if ( 261 | category == 4 262 | and alert in ALERTS_FLOORPLAN 263 | or result == "not_on_charge_base" 264 | ): 265 | json["params"]["category"] = 2 266 | response_fallback = self._message(json, STATE_SCHEMA) 267 | result = response_fallback.json().get("result", None) 268 | alert = response_fallback.json().get("alert", None) 269 | if result != "ok": 270 | _LOGGER.warning( 271 | "Result of robot.start_cleaning is not ok after fallback: %s, alert: %s", 272 | result, 273 | alert, 274 | ) 275 | return response_fallback 276 | 277 | return response 278 | 279 | def start_spot_cleaning(self, spot_width=400, spot_height=400, mode=2, modifier=2): 280 | # Spot cleaning if applicable to version 281 | # spot_width: spot width in cm 282 | # spot_height: spot height in cm 283 | 284 | if self.spot_cleaning_version == "basic-1": 285 | json = { 286 | "reqId": "1", 287 | "cmd": "startCleaning", 288 | "params": { 289 | "category": 3, 290 | "mode": mode, 291 | "modifier": modifier, 292 | "spotWidth": spot_width, 293 | "spotHeight": spot_height, 294 | }, 295 | } 296 | elif self.spot_cleaning_version == "basic-3": 297 | json = { 298 | "reqId": "1", 299 | "cmd": "startCleaning", 300 | "params": { 301 | "category": 3, 302 | "spotWidth": spot_width, 303 | "spotHeight": spot_height, 304 | }, 305 | } 306 | elif self.spot_cleaning_version == "minimal-2": 307 | json = { 308 | "reqId": "1", 309 | "cmd": "startCleaning", 310 | "params": {"category": 3, "modifier": modifier, "navigationMode": 1}, 311 | } 312 | else: # self.spot_cleaning_version == 'micro-2' 313 | json = { 314 | "reqId": "1", 315 | "cmd": "startCleaning", 316 | "params": {"category": 3, "navigationMode": 1}, 317 | } 318 | 319 | return self._message(json, STATE_SCHEMA) 320 | 321 | def pause_cleaning(self): 322 | return self._message({"reqId": "1", "cmd": "pauseCleaning"}, STATE_SCHEMA) 323 | 324 | def resume_cleaning(self): 325 | return self._message({"reqId": "1", "cmd": "resumeCleaning"}, STATE_SCHEMA) 326 | 327 | def stop_cleaning(self): 328 | return self._message({"reqId": "1", "cmd": "stopCleaning"}, STATE_SCHEMA) 329 | 330 | def send_to_base(self): 331 | return self._message({"reqId": "1", "cmd": "sendToBase"}, STATE_SCHEMA) 332 | 333 | def get_robot_state(self): 334 | return self._message({"reqId": "1", "cmd": "getRobotState"}, STATE_SCHEMA) 335 | 336 | def enable_schedule(self): 337 | return self._message({"reqId": "1", "cmd": "enableSchedule"}, STANDARD_SCHEMA) 338 | 339 | def disable_schedule(self): 340 | return self._message({"reqId": "1", "cmd": "disableSchedule"}, STANDARD_SCHEMA) 341 | 342 | def get_schedule(self): 343 | return self._message({"reqId": "1", "cmd": "getSchedule"}, STANDARD_SCHEMA) 344 | 345 | def locate(self): 346 | return self._message({"reqId": "1", "cmd": "findMe"}, STANDARD_SCHEMA) 347 | 348 | def get_general_info(self): 349 | return self._message({"reqId": "1", "cmd": "getGeneralInfo"}, STANDARD_SCHEMA) 350 | 351 | def get_local_stats(self): 352 | return self._message({"reqId": "1", "cmd": "getLocalStats"}, STANDARD_SCHEMA) 353 | 354 | def get_preferences(self): 355 | return self._message({"reqId": "1", "cmd": "getPreferences"}, STANDARD_SCHEMA) 356 | 357 | def get_map_boundaries(self, map_id=None): 358 | return self._message( 359 | {"reqId": "1", "cmd": "getMapBoundaries", "params": {"mapId": map_id}}, 360 | STANDARD_SCHEMA, 361 | ) 362 | 363 | def get_robot_info(self): 364 | return self._message({"reqId": "1", "cmd": "getRobotInfo"}, STANDARD_SCHEMA) 365 | 366 | def dismiss_current_alert(self): 367 | return self._message( 368 | {"reqId": "1", "cmd": "dismissCurrentAlert"}, STANDARD_SCHEMA 369 | ) 370 | 371 | @property 372 | def schedule_enabled(self): 373 | return self.get_robot_state().json()["details"]["isScheduleEnabled"] 374 | 375 | @schedule_enabled.setter 376 | def schedule_enabled(self, enable): 377 | if enable: 378 | self.enable_schedule() 379 | else: 380 | self.disable_schedule() 381 | 382 | @property 383 | def state(self): 384 | return self.get_robot_state().json() 385 | 386 | @property 387 | def available_services(self): 388 | return self.state["availableServices"] 389 | 390 | @property 391 | def service_version(self): 392 | return self.available_services["houseCleaning"] 393 | 394 | @property 395 | def spot_cleaning_version(self): 396 | return self.available_services["spotCleaning"] 397 | 398 | 399 | class Auth(requests.auth.AuthBase): 400 | """Create headers for request authentication""" 401 | 402 | def __init__(self, serial, secret): 403 | self.serial = serial 404 | self.secret = secret 405 | 406 | def __call__(self, request): 407 | # We have to format the date according to RFC 2616 408 | # https://tools.ietf.org/html/rfc2616#section-14.18 409 | 410 | now = datetime.now(timezone.utc) 411 | date = format_datetime(now, True) 412 | 413 | try: 414 | # Attempt to decode request.body (assume bytes received) 415 | msg = "\n".join([self.serial.lower(), date, request.body.decode("utf8")]) 416 | except AttributeError: 417 | # Decode failed, assume request.body is already type str 418 | msg = "\n".join([self.serial.lower(), date, request.body]) 419 | 420 | signing = hmac.new( 421 | key=self.secret.encode("utf8"), 422 | msg=msg.encode("utf8"), 423 | digestmod=hashlib.sha256, 424 | ) 425 | 426 | request.headers["Date"] = date 427 | request.headers["Authorization"] = "NEATOAPP " + signing.hexdigest() 428 | 429 | return request 430 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MAIN] 2 | 3 | # Analyse import fallback blocks. This can be used to support both Python 2 and 4 | # 3 compatible code, which means that the block might have code that exists 5 | # only in one or another interpreter, leading to false positives when analysed. 6 | analyse-fallback-blocks=no 7 | 8 | # Clear in-memory caches upon conclusion of linting. Useful if running pylint 9 | # in a server-like mode. 10 | clear-cache-post-run=no 11 | 12 | # Load and enable all available extensions. Use --list-extensions to see a list 13 | # all available extensions. 14 | #enable-all-extensions= 15 | 16 | # In error mode, messages with a category besides ERROR or FATAL are 17 | # suppressed, and no reports are done by default. Error mode is compatible with 18 | # disabling specific errors. 19 | #errors-only= 20 | 21 | # Always return a 0 (non-error) status code, even if lint errors are found. 22 | # This is primarily useful in continuous integration scripts. 23 | #exit-zero= 24 | 25 | # A comma-separated list of package or module names from where C extensions may 26 | # be loaded. Extensions are loading into the active Python interpreter and may 27 | # run arbitrary code. 28 | extension-pkg-allow-list= 29 | 30 | # A comma-separated list of package or module names from where C extensions may 31 | # be loaded. Extensions are loading into the active Python interpreter and may 32 | # run arbitrary code. (This is an alternative name to extension-pkg-allow-list 33 | # for backward compatibility.) 34 | extension-pkg-whitelist= 35 | 36 | # Return non-zero exit code if any of these messages/categories are detected, 37 | # even if score is above --fail-under value. Syntax same as enable. Messages 38 | # specified are enabled, while categories only check already-enabled messages. 39 | fail-on= 40 | 41 | # Specify a score threshold under which the program will exit with error. 42 | fail-under=10 43 | 44 | # Interpret the stdin as a python script, whose filename needs to be passed as 45 | # the module_or_package argument. 46 | #from-stdin= 47 | 48 | # Files or directories to be skipped. They should be base names, not paths. 49 | ignore=CVS 50 | 51 | # Add files or directories matching the regular expressions patterns to the 52 | # ignore-list. The regex matches against paths and can be in Posix or Windows 53 | # format. Because '\\' represents the directory delimiter on Windows systems, 54 | # it can't be used as an escape character. 55 | ignore-paths= 56 | 57 | # Files or directories matching the regular expression patterns are skipped. 58 | # The regex matches against base names, not paths. The default value ignores 59 | # Emacs file locks 60 | ignore-patterns=^\.# 61 | 62 | # List of module names for which member attributes should not be checked 63 | # (useful for modules/projects where namespaces are manipulated during runtime 64 | # and thus existing member attributes cannot be deduced by static analysis). It 65 | # supports qualified module names, as well as Unix pattern matching. 66 | ignored-modules= 67 | 68 | # Python code to execute, usually for sys.path manipulation such as 69 | # pygtk.require(). 70 | #init-hook= 71 | 72 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 73 | # number of processors available to use, and will cap the count on Windows to 74 | # avoid hangs. 75 | jobs=1 76 | 77 | # Control the amount of potential inferred values when inferring a single 78 | # object. This can help the performance when dealing with large functions or 79 | # complex, nested conditions. 80 | limit-inference-results=100 81 | 82 | # List of plugins (as comma separated values of python module names) to load, 83 | # usually to register additional checkers. 84 | load-plugins= 85 | 86 | # Pickle collected data for later comparisons. 87 | persistent=yes 88 | 89 | # Minimum Python version to use for version dependent checks. Will default to 90 | # the version used to run pylint. 91 | py-version=3.11 92 | 93 | # Discover python modules and packages in the file system subtree. 94 | recursive=no 95 | 96 | # Add paths to the list of the source roots. Supports globbing patterns. The 97 | # source root is an absolute path or a path relative to the current working 98 | # directory used to determine a package namespace for modules located under the 99 | # source root. 100 | source-roots= 101 | 102 | # When enabled, pylint would attempt to guess common misconfiguration and emit 103 | # user-friendly hints instead of false-positive error messages. 104 | suggestion-mode=yes 105 | 106 | # Allow loading of arbitrary C extensions. Extensions are imported into the 107 | # active Python interpreter and may run arbitrary code. 108 | unsafe-load-any-extension=no 109 | 110 | # In verbose mode, extra non-checker-related info will be displayed. 111 | #verbose= 112 | 113 | 114 | [BASIC] 115 | 116 | # Naming style matching correct argument names. 117 | argument-naming-style=snake_case 118 | 119 | # Regular expression matching correct argument names. Overrides argument- 120 | # naming-style. If left empty, argument names will be checked with the set 121 | # naming style. 122 | #argument-rgx= 123 | 124 | # Naming style matching correct attribute names. 125 | attr-naming-style=snake_case 126 | 127 | # Regular expression matching correct attribute names. Overrides attr-naming- 128 | # style. If left empty, attribute names will be checked with the set naming 129 | # style. 130 | #attr-rgx= 131 | 132 | # Bad variable names which should always be refused, separated by a comma. 133 | bad-names=foo, 134 | bar, 135 | baz, 136 | toto, 137 | tutu, 138 | tata 139 | 140 | # Bad variable names regexes, separated by a comma. If names match any regex, 141 | # they will always be refused 142 | bad-names-rgxs= 143 | 144 | # Naming style matching correct class attribute names. 145 | class-attribute-naming-style=any 146 | 147 | # Regular expression matching correct class attribute names. Overrides class- 148 | # attribute-naming-style. If left empty, class attribute names will be checked 149 | # with the set naming style. 150 | #class-attribute-rgx= 151 | 152 | # Naming style matching correct class constant names. 153 | class-const-naming-style=UPPER_CASE 154 | 155 | # Regular expression matching correct class constant names. Overrides class- 156 | # const-naming-style. If left empty, class constant names will be checked with 157 | # the set naming style. 158 | #class-const-rgx= 159 | 160 | # Naming style matching correct class names. 161 | class-naming-style=PascalCase 162 | 163 | # Regular expression matching correct class names. Overrides class-naming- 164 | # style. If left empty, class names will be checked with the set naming style. 165 | #class-rgx= 166 | 167 | # Naming style matching correct constant names. 168 | const-naming-style=UPPER_CASE 169 | 170 | # Regular expression matching correct constant names. Overrides const-naming- 171 | # style. If left empty, constant names will be checked with the set naming 172 | # style. 173 | #const-rgx= 174 | 175 | # Minimum line length for functions/classes that require docstrings, shorter 176 | # ones are exempt. 177 | docstring-min-length=-1 178 | 179 | # Naming style matching correct function names. 180 | function-naming-style=snake_case 181 | 182 | # Regular expression matching correct function names. Overrides function- 183 | # naming-style. If left empty, function names will be checked with the set 184 | # naming style. 185 | #function-rgx= 186 | 187 | # Good variable names which should always be accepted, separated by a comma. 188 | good-names=i, 189 | j, 190 | k, 191 | ex, 192 | Run, 193 | _ 194 | 195 | # Good variable names regexes, separated by a comma. If names match any regex, 196 | # they will always be accepted 197 | good-names-rgxs= 198 | 199 | # Include a hint for the correct naming format with invalid-name. 200 | include-naming-hint=no 201 | 202 | # Naming style matching correct inline iteration names. 203 | inlinevar-naming-style=any 204 | 205 | # Regular expression matching correct inline iteration names. Overrides 206 | # inlinevar-naming-style. If left empty, inline iteration names will be checked 207 | # with the set naming style. 208 | #inlinevar-rgx= 209 | 210 | # Naming style matching correct method names. 211 | method-naming-style=snake_case 212 | 213 | # Regular expression matching correct method names. Overrides method-naming- 214 | # style. If left empty, method names will be checked with the set naming style. 215 | #method-rgx= 216 | 217 | # Naming style matching correct module names. 218 | module-naming-style=snake_case 219 | 220 | # Regular expression matching correct module names. Overrides module-naming- 221 | # style. If left empty, module names will be checked with the set naming style. 222 | #module-rgx= 223 | 224 | # Colon-delimited sets of names that determine each other's naming style when 225 | # the name regexes allow several styles. 226 | name-group= 227 | 228 | # Regular expression which should only match function or class names that do 229 | # not require a docstring. 230 | no-docstring-rgx=^_ 231 | 232 | # List of decorators that produce properties, such as abc.abstractproperty. Add 233 | # to this list to register other decorators that produce valid properties. 234 | # These decorators are taken in consideration only for invalid-name. 235 | property-classes=abc.abstractproperty 236 | 237 | # Regular expression matching correct type alias names. If left empty, type 238 | # alias names will be checked with the set naming style. 239 | #typealias-rgx= 240 | 241 | # Regular expression matching correct type variable names. If left empty, type 242 | # variable names will be checked with the set naming style. 243 | #typevar-rgx= 244 | 245 | # Naming style matching correct variable names. 246 | variable-naming-style=snake_case 247 | 248 | # Regular expression matching correct variable names. Overrides variable- 249 | # naming-style. If left empty, variable names will be checked with the set 250 | # naming style. 251 | #variable-rgx= 252 | 253 | 254 | [CLASSES] 255 | 256 | # Warn about protected attribute access inside special methods 257 | check-protected-access-in-special-methods=no 258 | 259 | # List of method names used to declare (i.e. assign) instance attributes. 260 | defining-attr-methods=__init__, 261 | __new__, 262 | setUp, 263 | asyncSetUp, 264 | __post_init__ 265 | 266 | # List of member names, which should be excluded from the protected access 267 | # warning. 268 | exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit 269 | 270 | # List of valid names for the first argument in a class method. 271 | valid-classmethod-first-arg=cls 272 | 273 | # List of valid names for the first argument in a metaclass class method. 274 | valid-metaclass-classmethod-first-arg=mcs 275 | 276 | 277 | [DESIGN] 278 | 279 | # List of regular expressions of class ancestor names to ignore when counting 280 | # public methods (see R0903) 281 | exclude-too-few-public-methods= 282 | 283 | # List of qualified class names to ignore when counting class parents (see 284 | # R0901) 285 | ignored-parents= 286 | 287 | # Maximum number of arguments for function / method. 288 | max-args=5 289 | 290 | # Maximum number of attributes for a class (see R0902). 291 | max-attributes=7 292 | 293 | # Maximum number of boolean expressions in an if statement (see R0916). 294 | max-bool-expr=5 295 | 296 | # Maximum number of branch for function / method body. 297 | max-branches=12 298 | 299 | # Maximum number of locals for function / method body. 300 | max-locals=15 301 | 302 | # Maximum number of parents for a class (see R0901). 303 | max-parents=7 304 | 305 | # Maximum number of public methods for a class (see R0904). 306 | max-public-methods=20 307 | 308 | # Maximum number of return / yield for function / method body. 309 | max-returns=6 310 | 311 | # Maximum number of statements in function / method body. 312 | max-statements=50 313 | 314 | # Minimum number of public methods for a class (see R0903). 315 | min-public-methods=2 316 | 317 | 318 | [EXCEPTIONS] 319 | 320 | # Exceptions that will emit a warning when caught. 321 | overgeneral-exceptions=builtins.BaseException,builtins.Exception 322 | 323 | 324 | [FORMAT] 325 | 326 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 327 | expected-line-ending-format= 328 | 329 | # Regexp for a line that is allowed to be longer than the limit. 330 | ignore-long-lines=^\s*(# )??$ 331 | 332 | # Number of spaces of indent required inside a hanging or continued line. 333 | indent-after-paren=4 334 | 335 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 336 | # tab). 337 | indent-string=' ' 338 | 339 | # Maximum number of characters on a single line. 340 | max-line-length=100 341 | 342 | # Maximum number of lines in a module. 343 | max-module-lines=1000 344 | 345 | # Allow the body of a class to be on the same line as the declaration if body 346 | # contains single statement. 347 | single-line-class-stmt=no 348 | 349 | # Allow the body of an if to be on the same line as the test if there is no 350 | # else. 351 | single-line-if-stmt=no 352 | 353 | 354 | [IMPORTS] 355 | 356 | # List of modules that can be imported at any level, not just the top level 357 | # one. 358 | allow-any-import-level= 359 | 360 | # Allow explicit reexports by alias from a package __init__. 361 | allow-reexport-from-package=no 362 | 363 | # Allow wildcard imports from modules that define __all__. 364 | allow-wildcard-with-all=no 365 | 366 | # Deprecated modules which should not be used, separated by a comma. 367 | deprecated-modules= 368 | 369 | # Output a graph (.gv or any supported image format) of external dependencies 370 | # to the given file (report RP0402 must not be disabled). 371 | ext-import-graph= 372 | 373 | # Output a graph (.gv or any supported image format) of all (i.e. internal and 374 | # external) dependencies to the given file (report RP0402 must not be 375 | # disabled). 376 | import-graph= 377 | 378 | # Output a graph (.gv or any supported image format) of internal dependencies 379 | # to the given file (report RP0402 must not be disabled). 380 | int-import-graph= 381 | 382 | # Force import order to recognize a module as part of the standard 383 | # compatibility libraries. 384 | known-standard-library= 385 | 386 | # Force import order to recognize a module as part of a third party library. 387 | known-third-party=enchant 388 | 389 | # Couples of modules and preferred modules, separated by a comma. 390 | preferred-modules= 391 | 392 | 393 | [LOGGING] 394 | 395 | # The type of string formatting that logging methods do. `old` means using % 396 | # formatting, `new` is for `{}` formatting. 397 | logging-format-style=old 398 | 399 | # Logging modules to check that the string format arguments are in logging 400 | # function parameter format. 401 | logging-modules=logging 402 | 403 | 404 | [MESSAGES CONTROL] 405 | 406 | # Only show warnings with the listed confidence levels. Leave empty to show 407 | # all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, 408 | # UNDEFINED. 409 | confidence=HIGH, 410 | CONTROL_FLOW, 411 | INFERENCE, 412 | INFERENCE_FAILURE, 413 | UNDEFINED 414 | 415 | # Disable the message, report, category or checker with the given id(s). You 416 | # can either give multiple identifiers separated by comma (,) or put this 417 | # option multiple times (only on the command line, not in the configuration 418 | # file where it should appear only once). You can also use "--disable=all" to 419 | # disable everything first and then re-enable specific checks. For example, if 420 | # you want to run only the similarities checker, you can use "--disable=all 421 | # --enable=similarities". If you want to run only the classes checker, but have 422 | # no Warning level messages displayed, use "--disable=all --enable=classes 423 | # --disable=W". 424 | disable=raw-checker-failed, 425 | bad-inline-option, 426 | locally-disabled, 427 | file-ignored, 428 | suppressed-message, 429 | useless-suppression, 430 | deprecated-pragma, 431 | use-symbolic-message-instead, 432 | use-implicit-booleaness-not-comparison-to-string, 433 | use-implicit-booleaness-not-comparison-to-zero, 434 | too-few-public-methods, 435 | too-many-arguments, 436 | too-many-instance-attributes, 437 | too-many-public-methods, 438 | missing-class-docstring, 439 | missing-function-docstring, 440 | missing-module-docstring, 441 | duplicate-code 442 | 443 | # Enable the message, report, category or checker with the given id(s). You can 444 | # either give multiple identifier separated by comma (,) or put this option 445 | # multiple time (only on the command line, not in the configuration file where 446 | # it should appear only once). See also the "--disable" option for examples. 447 | enable= 448 | 449 | 450 | [METHOD_ARGS] 451 | 452 | # List of qualified names (i.e., library.method) which require a timeout 453 | # parameter e.g. 'requests.api.get,requests.api.post' 454 | timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request 455 | 456 | 457 | [MISCELLANEOUS] 458 | 459 | # List of note tags to take in consideration, separated by a comma. 460 | notes=FIXME, 461 | XXX, 462 | TODO 463 | 464 | # Regular expression of note tags to take in consideration. 465 | notes-rgx= 466 | 467 | 468 | [REFACTORING] 469 | 470 | # Maximum number of nested blocks for function / method body 471 | max-nested-blocks=5 472 | 473 | # Complete name of functions that never returns. When checking for 474 | # inconsistent-return-statements if a never returning function is called then 475 | # it will be considered as an explicit return statement and no message will be 476 | # printed. 477 | never-returning-functions=sys.exit,argparse.parse_error 478 | 479 | # Let 'consider-using-join' be raised when the separator to join on would be 480 | # non-empty (resulting in expected fixes of the type: ``"- " + " - 481 | # ".join(items)``) 482 | suggest-join-with-non-empty-separator=yes 483 | 484 | 485 | [REPORTS] 486 | 487 | # Python expression which should return a score less than or equal to 10. You 488 | # have access to the variables 'fatal', 'error', 'warning', 'refactor', 489 | # 'convention', and 'info' which contain the number of messages in each 490 | # category, as well as 'statement' which is the total number of statements 491 | # analyzed. This score is used by the global evaluation report (RP0004). 492 | evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) 493 | 494 | # Template used to display messages. This is a python new-style format string 495 | # used to format the message information. See doc for all details. 496 | msg-template= 497 | 498 | # Set the output format. Available formats are: text, parseable, colorized, 499 | # json2 (improved json format), json (old json format) and msvs (visual 500 | # studio). You can also give a reporter class, e.g. 501 | # mypackage.mymodule.MyReporterClass. 502 | #output-format= 503 | 504 | # Tells whether to display a full report or only the messages. 505 | reports=no 506 | 507 | # Activate the evaluation score. 508 | score=yes 509 | 510 | 511 | [SIMILARITIES] 512 | 513 | # Comments are removed from the similarity computation 514 | ignore-comments=yes 515 | 516 | # Docstrings are removed from the similarity computation 517 | ignore-docstrings=yes 518 | 519 | # Imports are removed from the similarity computation 520 | ignore-imports=yes 521 | 522 | # Signatures are removed from the similarity computation 523 | ignore-signatures=yes 524 | 525 | # Minimum lines number of a similarity. 526 | min-similarity-lines=4 527 | 528 | 529 | [SPELLING] 530 | 531 | # Limits count of emitted suggestions for spelling mistakes. 532 | max-spelling-suggestions=4 533 | 534 | # Spelling dictionary name. No available dictionaries : You need to install 535 | # both the python package and the system dependency for enchant to work. 536 | spelling-dict= 537 | 538 | # List of comma separated words that should be considered directives if they 539 | # appear at the beginning of a comment and should not be checked. 540 | spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: 541 | 542 | # List of comma separated words that should not be checked. 543 | spelling-ignore-words= 544 | 545 | # A path to a file that contains the private dictionary; one word per line. 546 | spelling-private-dict-file= 547 | 548 | # Tells whether to store unknown words to the private dictionary (see the 549 | # --spelling-private-dict-file option) instead of raising a message. 550 | spelling-store-unknown-words=no 551 | 552 | 553 | [STRING] 554 | 555 | # This flag controls whether inconsistent-quotes generates a warning when the 556 | # character used as a quote delimiter is used inconsistently within a module. 557 | check-quote-consistency=no 558 | 559 | # This flag controls whether the implicit-str-concat should generate a warning 560 | # on implicit string concatenation in sequences defined over several lines. 561 | check-str-concat-over-line-jumps=no 562 | 563 | 564 | [TYPECHECK] 565 | 566 | # List of decorators that produce context managers, such as 567 | # contextlib.contextmanager. Add to this list to register other decorators that 568 | # produce valid context managers. 569 | contextmanager-decorators=contextlib.contextmanager 570 | 571 | # List of members which are set dynamically and missed by pylint inference 572 | # system, and so shouldn't trigger E1101 when accessed. Python regular 573 | # expressions are accepted. 574 | generated-members= 575 | 576 | # Tells whether to warn about missing members when the owner of the attribute 577 | # is inferred to be None. 578 | ignore-none=yes 579 | 580 | # This flag controls whether pylint should warn about no-member and similar 581 | # checks whenever an opaque object is returned when inferring. The inference 582 | # can return multiple potential results while evaluating a Python object, but 583 | # some branches might not be evaluated, which results in partial inference. In 584 | # that case, it might be useful to still emit no-member and other checks for 585 | # the rest of the inferred objects. 586 | ignore-on-opaque-inference=yes 587 | 588 | # List of symbolic message names to ignore for Mixin members. 589 | ignored-checks-for-mixins=no-member, 590 | not-async-context-manager, 591 | not-context-manager, 592 | attribute-defined-outside-init 593 | 594 | # List of class names for which member attributes should not be checked (useful 595 | # for classes with dynamically set attributes). This supports the use of 596 | # qualified names. 597 | ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace 598 | 599 | # Show a hint with possible names when a member name was not found. The aspect 600 | # of finding the hint is based on edit distance. 601 | missing-member-hint=yes 602 | 603 | # The minimum edit distance a name should have in order to be considered a 604 | # similar match for a missing member name. 605 | missing-member-hint-distance=1 606 | 607 | # The total number of similar names that should be taken in consideration when 608 | # showing a hint for a missing member. 609 | missing-member-max-choices=1 610 | 611 | # Regex pattern to define which classes are considered mixins. 612 | mixin-class-rgx=.*[Mm]ixin 613 | 614 | # List of decorators that change the signature of a decorated function. 615 | signature-mutators= 616 | 617 | 618 | [VARIABLES] 619 | 620 | # List of additional names supposed to be defined in builtins. Remember that 621 | # you should avoid defining new builtins when possible. 622 | additional-builtins= 623 | 624 | # Tells whether unused global variables should be treated as a violation. 625 | allow-global-unused-variables=yes 626 | 627 | # List of names allowed to shadow builtins 628 | allowed-redefined-builtins= 629 | 630 | # List of strings which can identify a callback function by name. A callback 631 | # name must start or end with one of those strings. 632 | callbacks=cb_, 633 | _cb 634 | 635 | # A regular expression matching the name of dummy variables (i.e. expected to 636 | # not be used). 637 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 638 | 639 | # Argument names that match this expression will be ignored. 640 | ignored-argument-names=_.*|^ignored_|^unused_ 641 | 642 | # Tells whether we should check for unused import in __init__ files. 643 | init-import=no 644 | 645 | # List of qualified module names which can have objects that can redefine 646 | # builtins. 647 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 648 | --------------------------------------------------------------------------------