Sign Up
Log In
Log In
or
Sign Up
Places
All Projects
Status Monitor
Collapse sidebar
system:homeautomation:home-assistant
python-pybotvac
_service:obs_scm:pybotvac-0.0.25.obscpio
Overview
Repositories
Revisions
Requests
Users
Attributes
Meta
File _service:obs_scm:pybotvac-0.0.25.obscpio of Package python-pybotvac
07070100000000000041ED0000000000000000000000036618088E00000000000000000000000000000000000000000000001800000000pybotvac-0.0.25/.github07070100000001000041ED0000000000000000000000026618088E00000000000000000000000000000000000000000000002200000000pybotvac-0.0.25/.github/workflows07070100000002000081A40000000000000000000000016618088E000001FF000000000000000000000000000000000000002C00000000pybotvac-0.0.25/.github/workflows/main.yamlname: Run Checks on: [push, pull_request] jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 - name: Setup uses: actions/setup-python@v2 with: python-version: "3.8" architecture: "x64" - run: pip install -r requirements.txt - run: codespell - run: black --check . - run: isort --check --diff . - run: pylint pybotvac - run: flake8 pybotvac - run: bandit -r pybotvac 07070100000003000081A40000000000000000000000016618088E00000808000000000000000000000000000000000000001B00000000pybotvac-0.0.25/.gitignore# 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/ 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/ cover/ # 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 .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .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/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # IDE .vscode .idea 07070100000004000081A40000000000000000000000016618088E0000552D000000000000000000000000000000000000001A00000000pybotvac-0.0.25/.pylintrc[MAIN] # Analyse import fallback blocks. This can be used to support both Python 2 and # 3 compatible code, which means that the block might have code that exists # only in one or another interpreter, leading to false positives when analysed. analyse-fallback-blocks=no # Clear in-memory caches upon conclusion of linting. Useful if running pylint # in a server-like mode. clear-cache-post-run=no # Load and enable all available extensions. Use --list-extensions to see a list # all available extensions. #enable-all-extensions= # In error mode, messages with a category besides ERROR or FATAL are # suppressed, and no reports are done by default. Error mode is compatible with # disabling specific errors. #errors-only= # Always return a 0 (non-error) status code, even if lint errors are found. # This is primarily useful in continuous integration scripts. #exit-zero= # A comma-separated list of package or module names from where C extensions may # be loaded. Extensions are loading into the active Python interpreter and may # run arbitrary code. extension-pkg-allow-list= # A comma-separated list of package or module names from where C extensions may # be loaded. Extensions are loading into the active Python interpreter and may # run arbitrary code. (This is an alternative name to extension-pkg-allow-list # for backward compatibility.) extension-pkg-whitelist= # Return non-zero exit code if any of these messages/categories are detected, # even if score is above --fail-under value. Syntax same as enable. Messages # specified are enabled, while categories only check already-enabled messages. fail-on= # Specify a score threshold under which the program will exit with error. fail-under=10 # Interpret the stdin as a python script, whose filename needs to be passed as # the module_or_package argument. #from-stdin= # Files or directories to be skipped. They should be base names, not paths. ignore=CVS # Add files or directories matching the regular expressions patterns to the # ignore-list. The regex matches against paths and can be in Posix or Windows # format. Because '\\' represents the directory delimiter on Windows systems, # it can't be used as an escape character. ignore-paths= # Files or directories matching the regular expression patterns are skipped. # The regex matches against base names, not paths. The default value ignores # Emacs file locks ignore-patterns=^\.# # List of module names for which member attributes should not be checked # (useful for modules/projects where namespaces are manipulated during runtime # and thus existing member attributes cannot be deduced by static analysis). It # supports qualified module names, as well as Unix pattern matching. ignored-modules= # Python code to execute, usually for sys.path manipulation such as # pygtk.require(). #init-hook= # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the # number of processors available to use, and will cap the count on Windows to # avoid hangs. jobs=1 # Control the amount of potential inferred values when inferring a single # object. This can help the performance when dealing with large functions or # complex, nested conditions. limit-inference-results=100 # List of plugins (as comma separated values of python module names) to load, # usually to register additional checkers. load-plugins= # Pickle collected data for later comparisons. persistent=yes # Minimum Python version to use for version dependent checks. Will default to # the version used to run pylint. py-version=3.11 # Discover python modules and packages in the file system subtree. recursive=no # Add paths to the list of the source roots. Supports globbing patterns. The # source root is an absolute path or a path relative to the current working # directory used to determine a package namespace for modules located under the # source root. source-roots= # When enabled, pylint would attempt to guess common misconfiguration and emit # user-friendly hints instead of false-positive error messages. suggestion-mode=yes # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. unsafe-load-any-extension=no # In verbose mode, extra non-checker-related info will be displayed. #verbose= [BASIC] # Naming style matching correct argument names. argument-naming-style=snake_case # Regular expression matching correct argument names. Overrides argument- # naming-style. If left empty, argument names will be checked with the set # naming style. #argument-rgx= # Naming style matching correct attribute names. attr-naming-style=snake_case # Regular expression matching correct attribute names. Overrides attr-naming- # style. If left empty, attribute names will be checked with the set naming # style. #attr-rgx= # Bad variable names which should always be refused, separated by a comma. bad-names=foo, bar, baz, toto, tutu, tata # Bad variable names regexes, separated by a comma. If names match any regex, # they will always be refused bad-names-rgxs= # Naming style matching correct class attribute names. class-attribute-naming-style=any # Regular expression matching correct class attribute names. Overrides class- # attribute-naming-style. If left empty, class attribute names will be checked # with the set naming style. #class-attribute-rgx= # Naming style matching correct class constant names. class-const-naming-style=UPPER_CASE # Regular expression matching correct class constant names. Overrides class- # const-naming-style. If left empty, class constant names will be checked with # the set naming style. #class-const-rgx= # Naming style matching correct class names. class-naming-style=PascalCase # Regular expression matching correct class names. Overrides class-naming- # style. If left empty, class names will be checked with the set naming style. #class-rgx= # Naming style matching correct constant names. const-naming-style=UPPER_CASE # Regular expression matching correct constant names. Overrides const-naming- # style. If left empty, constant names will be checked with the set naming # style. #const-rgx= # Minimum line length for functions/classes that require docstrings, shorter # ones are exempt. docstring-min-length=-1 # Naming style matching correct function names. function-naming-style=snake_case # Regular expression matching correct function names. Overrides function- # naming-style. If left empty, function names will be checked with the set # naming style. #function-rgx= # Good variable names which should always be accepted, separated by a comma. good-names=i, j, k, ex, Run, _ # Good variable names regexes, separated by a comma. If names match any regex, # they will always be accepted good-names-rgxs= # Include a hint for the correct naming format with invalid-name. include-naming-hint=no # Naming style matching correct inline iteration names. inlinevar-naming-style=any # Regular expression matching correct inline iteration names. Overrides # inlinevar-naming-style. If left empty, inline iteration names will be checked # with the set naming style. #inlinevar-rgx= # Naming style matching correct method names. method-naming-style=snake_case # Regular expression matching correct method names. Overrides method-naming- # style. If left empty, method names will be checked with the set naming style. #method-rgx= # Naming style matching correct module names. module-naming-style=snake_case # Regular expression matching correct module names. Overrides module-naming- # style. If left empty, module names will be checked with the set naming style. #module-rgx= # Colon-delimited sets of names that determine each other's naming style when # the name regexes allow several styles. name-group= # Regular expression which should only match function or class names that do # not require a docstring. no-docstring-rgx=^_ # List of decorators that produce properties, such as abc.abstractproperty. Add # to this list to register other decorators that produce valid properties. # These decorators are taken in consideration only for invalid-name. property-classes=abc.abstractproperty # Regular expression matching correct type alias names. If left empty, type # alias names will be checked with the set naming style. #typealias-rgx= # Regular expression matching correct type variable names. If left empty, type # variable names will be checked with the set naming style. #typevar-rgx= # Naming style matching correct variable names. variable-naming-style=snake_case # Regular expression matching correct variable names. Overrides variable- # naming-style. If left empty, variable names will be checked with the set # naming style. #variable-rgx= [CLASSES] # Warn about protected attribute access inside special methods check-protected-access-in-special-methods=no # List of method names used to declare (i.e. assign) instance attributes. defining-attr-methods=__init__, __new__, setUp, asyncSetUp, __post_init__ # List of member names, which should be excluded from the protected access # warning. exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit # List of valid names for the first argument in a class method. valid-classmethod-first-arg=cls # List of valid names for the first argument in a metaclass class method. valid-metaclass-classmethod-first-arg=mcs [DESIGN] # List of regular expressions of class ancestor names to ignore when counting # public methods (see R0903) exclude-too-few-public-methods= # List of qualified class names to ignore when counting class parents (see # R0901) ignored-parents= # Maximum number of arguments for function / method. max-args=5 # Maximum number of attributes for a class (see R0902). max-attributes=7 # Maximum number of boolean expressions in an if statement (see R0916). max-bool-expr=5 # Maximum number of branch for function / method body. max-branches=12 # Maximum number of locals for function / method body. max-locals=15 # Maximum number of parents for a class (see R0901). max-parents=7 # Maximum number of public methods for a class (see R0904). max-public-methods=20 # Maximum number of return / yield for function / method body. max-returns=6 # Maximum number of statements in function / method body. max-statements=50 # Minimum number of public methods for a class (see R0903). min-public-methods=2 [EXCEPTIONS] # Exceptions that will emit a warning when caught. overgeneral-exceptions=builtins.BaseException,builtins.Exception [FORMAT] # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. expected-line-ending-format= # Regexp for a line that is allowed to be longer than the limit. ignore-long-lines=^\s*(# )?<?https?://\S+>?$ # Number of spaces of indent required inside a hanging or continued line. indent-after-paren=4 # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 # tab). indent-string=' ' # Maximum number of characters on a single line. max-line-length=100 # Maximum number of lines in a module. max-module-lines=1000 # Allow the body of a class to be on the same line as the declaration if body # contains single statement. single-line-class-stmt=no # Allow the body of an if to be on the same line as the test if there is no # else. single-line-if-stmt=no [IMPORTS] # List of modules that can be imported at any level, not just the top level # one. allow-any-import-level= # Allow explicit reexports by alias from a package __init__. allow-reexport-from-package=no # Allow wildcard imports from modules that define __all__. allow-wildcard-with-all=no # Deprecated modules which should not be used, separated by a comma. deprecated-modules= # Output a graph (.gv or any supported image format) of external dependencies # to the given file (report RP0402 must not be disabled). ext-import-graph= # Output a graph (.gv or any supported image format) of all (i.e. internal and # external) dependencies to the given file (report RP0402 must not be # disabled). import-graph= # Output a graph (.gv or any supported image format) of internal dependencies # to the given file (report RP0402 must not be disabled). int-import-graph= # Force import order to recognize a module as part of the standard # compatibility libraries. known-standard-library= # Force import order to recognize a module as part of a third party library. known-third-party=enchant # Couples of modules and preferred modules, separated by a comma. preferred-modules= [LOGGING] # The type of string formatting that logging methods do. `old` means using % # formatting, `new` is for `{}` formatting. logging-format-style=old # Logging modules to check that the string format arguments are in logging # function parameter format. logging-modules=logging [MESSAGES CONTROL] # Only show warnings with the listed confidence levels. Leave empty to show # all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, # UNDEFINED. confidence=HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, UNDEFINED # Disable the message, report, category or checker with the given id(s). You # can either give multiple identifiers separated by comma (,) or put this # option multiple times (only on the command line, not in the configuration # file where it should appear only once). You can also use "--disable=all" to # disable everything first and then re-enable specific checks. For example, if # you want to run only the similarities checker, you can use "--disable=all # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use "--disable=all --enable=classes # --disable=W". disable=raw-checker-failed, bad-inline-option, locally-disabled, file-ignored, suppressed-message, useless-suppression, deprecated-pragma, use-symbolic-message-instead, use-implicit-booleaness-not-comparison-to-string, use-implicit-booleaness-not-comparison-to-zero, too-few-public-methods, too-many-arguments, too-many-instance-attributes, too-many-public-methods, missing-class-docstring, missing-function-docstring, missing-module-docstring, duplicate-code # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option # multiple time (only on the command line, not in the configuration file where # it should appear only once). See also the "--disable" option for examples. enable= [METHOD_ARGS] # List of qualified names (i.e., library.method) which require a timeout # parameter e.g. 'requests.api.get,requests.api.post' 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 [MISCELLANEOUS] # List of note tags to take in consideration, separated by a comma. notes=FIXME, XXX, TODO # Regular expression of note tags to take in consideration. notes-rgx= [REFACTORING] # Maximum number of nested blocks for function / method body max-nested-blocks=5 # Complete name of functions that never returns. When checking for # inconsistent-return-statements if a never returning function is called then # it will be considered as an explicit return statement and no message will be # printed. never-returning-functions=sys.exit,argparse.parse_error # Let 'consider-using-join' be raised when the separator to join on would be # non-empty (resulting in expected fixes of the type: ``"- " + " - # ".join(items)``) suggest-join-with-non-empty-separator=yes [REPORTS] # Python expression which should return a score less than or equal to 10. You # have access to the variables 'fatal', 'error', 'warning', 'refactor', # 'convention', and 'info' which contain the number of messages in each # category, as well as 'statement' which is the total number of statements # analyzed. This score is used by the global evaluation report (RP0004). evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) # Template used to display messages. This is a python new-style format string # used to format the message information. See doc for all details. msg-template= # Set the output format. Available formats are: text, parseable, colorized, # json2 (improved json format), json (old json format) and msvs (visual # studio). You can also give a reporter class, e.g. # mypackage.mymodule.MyReporterClass. #output-format= # Tells whether to display a full report or only the messages. reports=no # Activate the evaluation score. score=yes [SIMILARITIES] # Comments are removed from the similarity computation ignore-comments=yes # Docstrings are removed from the similarity computation ignore-docstrings=yes # Imports are removed from the similarity computation ignore-imports=yes # Signatures are removed from the similarity computation ignore-signatures=yes # Minimum lines number of a similarity. min-similarity-lines=4 [SPELLING] # Limits count of emitted suggestions for spelling mistakes. max-spelling-suggestions=4 # Spelling dictionary name. No available dictionaries : You need to install # both the python package and the system dependency for enchant to work. spelling-dict= # List of comma separated words that should be considered directives if they # appear at the beginning of a comment and should not be checked. spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: # List of comma separated words that should not be checked. spelling-ignore-words= # A path to a file that contains the private dictionary; one word per line. spelling-private-dict-file= # Tells whether to store unknown words to the private dictionary (see the # --spelling-private-dict-file option) instead of raising a message. spelling-store-unknown-words=no [STRING] # This flag controls whether inconsistent-quotes generates a warning when the # character used as a quote delimiter is used inconsistently within a module. check-quote-consistency=no # This flag controls whether the implicit-str-concat should generate a warning # on implicit string concatenation in sequences defined over several lines. check-str-concat-over-line-jumps=no [TYPECHECK] # List of decorators that produce context managers, such as # contextlib.contextmanager. Add to this list to register other decorators that # produce valid context managers. contextmanager-decorators=contextlib.contextmanager # List of members which are set dynamically and missed by pylint inference # system, and so shouldn't trigger E1101 when accessed. Python regular # expressions are accepted. generated-members= # Tells whether to warn about missing members when the owner of the attribute # is inferred to be None. ignore-none=yes # This flag controls whether pylint should warn about no-member and similar # checks whenever an opaque object is returned when inferring. The inference # can return multiple potential results while evaluating a Python object, but # some branches might not be evaluated, which results in partial inference. In # that case, it might be useful to still emit no-member and other checks for # the rest of the inferred objects. ignore-on-opaque-inference=yes # List of symbolic message names to ignore for Mixin members. ignored-checks-for-mixins=no-member, not-async-context-manager, not-context-manager, attribute-defined-outside-init # List of class names for which member attributes should not be checked (useful # for classes with dynamically set attributes). This supports the use of # qualified names. ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace # Show a hint with possible names when a member name was not found. The aspect # of finding the hint is based on edit distance. missing-member-hint=yes # The minimum edit distance a name should have in order to be considered a # similar match for a missing member name. missing-member-hint-distance=1 # The total number of similar names that should be taken in consideration when # showing a hint for a missing member. missing-member-max-choices=1 # Regex pattern to define which classes are considered mixins. mixin-class-rgx=.*[Mm]ixin # List of decorators that change the signature of a decorated function. signature-mutators= [VARIABLES] # List of additional names supposed to be defined in builtins. Remember that # you should avoid defining new builtins when possible. additional-builtins= # Tells whether unused global variables should be treated as a violation. allow-global-unused-variables=yes # List of names allowed to shadow builtins allowed-redefined-builtins= # List of strings which can identify a callback function by name. A callback # name must start or end with one of those strings. callbacks=cb_, _cb # A regular expression matching the name of dummy variables (i.e. expected to # not be used). dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ # Argument names that match this expression will be ignored. ignored-argument-names=_.*|^ignored_|^unused_ # Tells whether we should check for unused import in __init__ files. init-import=no # List of qualified module names which can have objects that can redefine # builtins. redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 07070100000005000081A40000000000000000000000016618088E00000438000000000000000000000000000000000000001800000000pybotvac-0.0.25/LICENSEThe MIT License (MIT) Copyright (c) 2016 Stian Askeland 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.07070100000006000081A40000000000000000000000016618088E000010DC000000000000000000000000000000000000001A00000000pybotvac-0.0.25/README.md# pybotvac This is an unofficial API for controlling Neato Botvac Connected vacuum robots. The code is based on https://github.com/kangguru/botvac and credit for reverse engineering the API goes to [Lars Brillert @kangguru](https://github.com/kangguru) ## Disclaimer This API is experimental. Use at your own risk. Feel free to contribute if things are not working. ## Installation Install using pip ```bash pip install pybotvac ``` Alternatively, clone the repository and run ```bash python setup.py install ``` ## Usage ### Robot If the serial and secret for your robot is known, simply run ```python >>> from pybotvac import Robot >>> robot = Robot('OPS01234-0123456789AB', '0123456789ABCDEF0123456789ABCDEF', 'my_robot_name') >>> print(robot) Name: sample_robot, Serial: OPS01234-0123456789AB, Secret: 0123456789ABCDEF0123456789ABCDEF ``` The format of the serial should be 'OPSxxxxx-xxxxxxxxxxxx', and the secret should be a string of hex characters 32 characters long. These can be found by using the Account class. To start cleaning ```python robot.start_cleaning() ``` If no exception occurred, your robot should now get to work. Currently the following methods are available in the Robot class: * get_robot_state() * start_cleaning() * start_spot_cleaning() * pause_cleaning() * stop_cleaning() * send_to_base() * enable_schedule() * disable_schedule() * get_schedule() For convenience, properties exist for state and schedule ```python # Get state state = robot.state # Check if schedule is enabled robot.schedule_enabled # Disable schedule robot.schedule_enabled = False ``` ### Account If the serial and secret are unknown, they can be retrieved using the Account class. You need a session instance to create an account. There are three different types of sessions available. It depends on your provider which session is suitable for you. * **PasswordSession** lets you authenticate via E-Mail and Password. Even though this works fine, it is not recommended. * **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`. * **PasswordlessSession** is known to work for users of the new MyKobold App. The only known `client_id` is `KY4YbVAvtgB7lp8vIbWQ7zLk3hssZlhR`. ```python from pybotvac import Account, Neato, OAuthSession, PasswordlessSession, PasswordSession, Vorwerk email = "Your email" password = "Your password" client_id = "Your client it" client_secret = "Your client secret" redirect_uri = "Your redirect URI" # Authenticate via Email and Password password_session = PasswordSession(email=email, password=password, vendor=Neato()) # Authenticate via OAuth2 oauth_session = OAuthSession(client_id=client_id, client_secret=client_secret, redirect_uri=redirect_uri, vendor=Neato()) authorization_url = oauth_session.get_authorization_url() print("Visit: " + authorization_url) authorization_response = input("Enter the full callback URL: ") token = oauth_session.fetch_token(authorization_response) # Authenticate via One Time Password passwordless_session = PasswordlessSession(client_id=client_id, vendor=Vorwerk()) passwordless_session.send_email_otp(email) code = input("Enter the code: ") passwordless_session.fetch_token_passwordless(email, code) # Create an account with one of the generated sessions account = Account(password_session) # List all robots associated with account for robot in account.robots: print(robot) ``` Information about maps and download of maps can be done from the Account class: ```python >>> from pybotvac import Account >>> # List all maps associated with a specific robot >>> for map_info in Account(PasswordSession('sample@email.com', 'sample_password')).maps: ... print(map_info) ``` A cleaning map can be downloaded with the account class. Returns the raw image response. Example shows latest map. You need the url from the map output to do that: ```python >>> from pybotvac import Account >>> # List all maps associated with a specific robot >>> map = Account(PasswordSession('sample@email.com', 'sample_password')).maps >>> download_link = map['robot_serial']['maps'][0]['url'] >>> Account('sample@email.com', 'sample_password').get_map_image(download_link) ``` 07070100000007000041ED0000000000000000000000036618088E00000000000000000000000000000000000000000000001900000000pybotvac-0.0.25/pybotvac07070100000008000081A40000000000000000000000016618088E000000D5000000000000000000000000000000000000002500000000pybotvac-0.0.25/pybotvac/__init__.pyfrom .account import Account from .neato import Neato from .robot import Robot from .session import OAuthSession, PasswordlessSession, PasswordSession from .version import __version__ from .vorwerk import Vorwerk 07070100000009000081A40000000000000000000000016618088E000024FE000000000000000000000000000000000000002400000000pybotvac-0.0.25/pybotvac/account.py"""Account access and data handling for beehive endpoint.""" import logging import os import shutil import requests from voluptuous import ( ALLOW_EXTRA, All, Any, Extra, MultipleInvalid, Optional, Range, Required, Schema, Url, ) from .exceptions import NeatoRobotException, NeatoUnsupportedDevice from .robot import Robot from .session import Session _LOGGER = logging.getLogger(__name__) USER_SCHEMA = Schema( { Required("id"): str, "first_name": Any(str, None), "last_name": Any(str, None), "company": Any(str, None), "locale": Any(str, None), "phone_number": Any(str, None), "street_1": Any(str, None), "street_2": Any(str, None), "city": Any(str, None), "post_code": Any(str, None), "province": Any(str, None), "state_region": Any(str, None), "country_code": Any(str, None), "source": Any(str, None), "developer": Any(bool, None), "email": Any(str, None), "newsletter": Any(bool, None), "created_at": Any(str, None), "verified_at": Any(str, None), } ) ROBOT_SCHEMA = Schema( { Required("serial"): str, "prefix": Any(str, None), Required("name"): str, "model": Any(str, None), Required("secret_key"): str, "purchased_at": Any(str, None), "linked_at": Any(str, None), Required("traits"): list, # Everything below this line is not documented, but still present "firmware": Any(str, None), "timezone": Any(str, None), Required("nucleo_url"): Url, "mac_address": Any(str, None), "created_at": Any(str, None), }, extra=ALLOW_EXTRA, ) MAP_SCHEMA = Schema( { "version": Any(int, None), Required("id"): str, Required("url"): Url, "url_valid_for_seconds": Any(int, None), Optional("run_id"): str, # documented, but not present "status": Any(str, None), "launched_from": Any(str, None), "error": Any(str, None), "category": Any(int, None), "mode": Any(int, None), "modifier": Any(int, None), "start_at": Any(str, None), "end_at": Any(str, None), "end_orientation_relative_degrees": All(int, Range(min=0, max=360)), "run_charge_at_start": All(int, Range(min=0, max=100)), "run_charge_at_end": All(int, Range(min=0, max=100)), "suspended_cleaning_charging_count": Any(int, None), "time_in_suspended_cleaning": Any(int, None), "time_in_error": Any(int, None), "time_in_pause": Any(int, None), "cleaned_area": Any(float, None), "base_count": Any(int, None), "is_docked": Any(bool, None), "delocalized": Any(bool, None), # Everything below this line is not documented, but still present "generated_at": Any(str, None), "persistent_map_id": Any(int, str, None), "cleaned_with_persistent_map_id": Any(int, str, None), "valid_as_persistent_map": Any(bool, None), "navigation_mode": Any(int, str, None), }, extra=ALLOW_EXTRA, ) MAPS_SCHEMA = Schema( {"stats": {Extra: object}, Required("maps"): [MAP_SCHEMA]}, extra=ALLOW_EXTRA, ) PERSISTENT_MAP_SCHEMA = Schema( { Required("id"): Any(int, str), Required("name"): str, Required("url"): Url, "raw_floor_map_url": Any(Url, None), "url_valid_for_seconds": Any(int, None), }, extra=ALLOW_EXTRA, ) PERSISTENT_MAPS_SCHEMA = Schema(Required([PERSISTENT_MAP_SCHEMA])) class Account: """ Class with data and methods for interacting with a pybotvac cloud session. :param email: Email for pybotvac account :param password: Password for pybotvac account """ def __init__(self, session: Session): """Initialize the account data.""" self._robots = set() self.robot_serials = {} self._maps = {} self._persistent_maps = {} self._session = session self._userdata = {} @property def robots(self): """ Return set of robots for logged in account. :return: """ if not self._robots: self.refresh_robots() return self._robots @property def maps(self): """ Return set of map data for logged in account. :return: """ self.refresh_maps() return self._maps def refresh_maps(self): """ Get information about maps of the robots. :return: """ for robot in self.robots: url = f"users/me/robots/{robot.serial}/maps" resp2 = self._session.get(url) resp2_json = resp2.json() try: MAPS_SCHEMA(resp2_json) self._maps.update({robot.serial: resp2_json}) except MultipleInvalid as ex: _LOGGER.warning( "Invalid response from %s: %s. Got: %s", url, ex, resp2_json ) def refresh_robots(self): """ Get information about robots connected to account. :return: """ resp = self._session.get("users/me/robots") for robot in resp.json(): _LOGGER.debug("Create Robot: %s", robot) try: ROBOT_SCHEMA(robot) robot_object = Robot( name=robot["name"], vendor=self._session.vendor, serial=robot["serial"], secret=robot["secret_key"], traits=robot["traits"], endpoint=robot["nucleo_url"], ) self._robots.add(robot_object) except MultipleInvalid as ex: # Robot was not described accordingly by neato _LOGGER.warning( "Bad response from robots endpoint: %s. Got: %s", ex, robot ) continue except NeatoUnsupportedDevice: # Robot does not support home_cleaning service _LOGGER.warning("Your robot %s is unsupported.", robot["name"]) continue except NeatoRobotException: # The state of the robot could not be received _LOGGER.warning("Your robot %s is offline.", robot["name"]) continue self.refresh_persistent_maps() for robot in self._robots: robot.has_persistent_maps = ( len(self._persistent_maps.get(robot.serial, [])) > 0 ) @staticmethod def get_map_image(url, dest_path=None, file_name=None): """ Return a requested map from a robot. :return: """ try: image = requests.get(url, stream=True, timeout=10) if dest_path: image_url = url.rsplit("/", 2)[1] + "-" + url.rsplit("/", 1)[1] if file_name: image_filename = file_name else: image_filename = image_url.split("?")[0] dest = os.path.join(dest_path, image_filename) image.raise_for_status() with open(dest, "wb") as data: image.raw.decode_content = True shutil.copyfileobj(image.raw, data) except ( requests.exceptions.ConnectionError, requests.exceptions.HTTPError, requests.exceptions.Timeout, ) as ex: raise NeatoRobotException("Unable to get robot map") from ex return image.raw @property def persistent_maps(self): """ Return set of persistent maps for logged in account. :return: """ self.refresh_persistent_maps() return self._persistent_maps def refresh_persistent_maps(self): """ Get information about persistent maps of the robots. :return: """ for robot in self._robots: url = f"users/me/robots/{robot.serial}/persistent_maps" resp2 = self._session.get(url) try: PERSISTENT_MAPS_SCHEMA(resp2.json()) self._persistent_maps.update({robot.serial: resp2.json()}) except MultipleInvalid as ex: _LOGGER.warning( "Invalid response from %s: %s. Got: %s", url, ex, resp2.json() ) @property def unique_id(self): """ Return the unique id of logged in account. :return: """ if not self._userdata: self.refresh_userdata() return self._userdata["id"] @property def email(self): """ Return email of logged in account. :return: """ if not self._userdata: self.refresh_userdata() return self._userdata["email"] def refresh_userdata(self): """ Get information about the user who is logged in. :return: """ url = "users/me" resp = self._session.get(url) resp_json = resp.json() try: USER_SCHEMA(resp_json) self._userdata = resp_json except MultipleInvalid as ex: _LOGGER.warning("Invalid response from %s: %s. Got: %s", url, ex, resp_json) 0707010000000A000041ED0000000000000000000000026618088E00000000000000000000000000000000000000000000001E00000000pybotvac-0.0.25/pybotvac/cert0707010000000B000081A40000000000000000000000016618088E000008A4000000000000000000000000000000000000002F00000000pybotvac-0.0.25/pybotvac/cert/ksecosys.com.crt-----BEGIN CERTIFICATE----- MIIGMzCCBRugAwIBAgIQD5HqMsUA6BwpvxxeNCAkfzANBgkqhkiG9w0BAQsFADCB jzELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G A1UEBxMHU2FsZm9yZDEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTcwNQYDVQQD Ey5TZWN0aWdvIFJTQSBEb21haW4gVmFsaWRhdGlvbiBTZWN1cmUgU2VydmVyIENB MB4XDTIyMDcwNzAwMDAwMFoXDTIzMDcwNzIzNTk1OVowGTEXMBUGA1UEAwwOKi5r c2Vjb3N5cy5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFcyPy ey9IumJwoOrkjtE/W2r0IgfaNm+YF+UQTq2qMekUy2JatJJ4ddxkOj8aMjXV2IdB lHKbM+kdWd2PqdnaS4uk1AfEN6/SkGVoiwh3i7DctoijTieqYl99K18D8Rf8AsYh BhPmOzOrUiwKmJj+iv4EQ7NzQTysLIwVuAyHYsazq8DOMTSIuL4NR5ZcACxfRHo+ lNilnfhqmhpWLvVqenhvMF5SRDGDgXy9uGS3WyUVHrJ3MF4w/LSDtg4XMeMXr5TU d+4ehY7Q07WnyAZho6vmaqYASXi1mePyWg57lrCZB6z/zyedg0LB1f8mAPh4kAh1 +TqA5jCtQOcCuUuBAgMBAAGjggL+MIIC+jAfBgNVHSMEGDAWgBSNjF7EVK2K4Xfp m/mbBeG4AY1h4TAdBgNVHQ4EFgQU4iof2djiX0tvZQU+L4AatK5Fvw8wDgYDVR0P AQH/BAQDAgWgMAwGA1UdEwEB/wQCMAAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsG AQUFBwMCMEkGA1UdIARCMEAwNAYLKwYBBAGyMQECAgcwJTAjBggrBgEFBQcCARYX aHR0cHM6Ly9zZWN0aWdvLmNvbS9DUFMwCAYGZ4EMAQIBMIGEBggrBgEFBQcBAQR4 MHYwTwYIKwYBBQUHMAKGQ2h0dHA6Ly9jcnQuc2VjdGlnby5jb20vU2VjdGlnb1JT QURvbWFpblZhbGlkYXRpb25TZWN1cmVTZXJ2ZXJDQS5jcnQwIwYIKwYBBQUHMAGG F2h0dHA6Ly9vY3NwLnNlY3RpZ28uY29tMCcGA1UdEQQgMB6CDioua3NlY29zeXMu Y29tggxrc2Vjb3N5cy5jb20wggF+BgorBgEEAdZ5AgQCBIIBbgSCAWoBaAB3AK33 vvp8/xDIi509nB4+GGq0Zyldz7EMJMqFhjTr3IKKAAABgdgXP6sAAAQDAEgwRgIh AMbLwKIVtIgR28SdVFNhpUGewdwv+JZHV0UbFqo0AkTEAiEA17Hnq4RlzZIQzec9 n3rKF8IBw94a3NEukYRHrBbDO5IAdgB6MoxU2LcttiDqOOBSHumEFnAyE4VNO9Ir wTpXo1LrUgAAAYHYFz98AAAEAwBHMEUCIG3TvX2M8sEYefXiyiRVCC8t/A8FSDkX Epqx9lSaSUZhAiEA4ljPM689kolwCn1pqt8IU07SR9jk4DEQGX29isg28fQAdQDo PtDaPvUGNTLnVyi8iWvJA9PL0RFr7Otp4Xd9bQa9bgAAAYHYFz9LAAAEAwBGMEQC IDGAQem9lLeHG6uouU7mZk7IUpzsvC19kLUbgjyAYWIZAiBUkSxII/er5JjB/9KK v7a1Yortb5/8EDTVw5/7EpAmsDANBgkqhkiG9w0BAQsFAAOCAQEAnInCkRNqSiCB 561X4DPBXFwfTnguFOnbtb9u2O0REBAsmGbR1xDm442VVnEOmIn6EN4kr0EPA8jS CvBoE1tsGocRilZJaicI4P1tKMZrbKYRYoNGbLRpRMEWp4PwblPUhDmzJT0gHAn8 mF4oIyX+/2ocQq2YZKO7bR7WLqjDK7puZUCJvpDhhsCIlV+DkfWqO4CZFq74wskQ IzCJhRUrQMhpAOrACaFot80wmrHEx7unNV++B6m1gb8e+tXMXJ3kp7Z7lKKPc+xW TLAiRt4Ln6P5UzGVZmNtyyYWysW/+DlbYKGUetHUcDxp9Cx+nxb2mTfXRHPQZumO pr+HrJaOoQ== -----END CERTIFICATE----- 0707010000000C000081A40000000000000000000000016618088E000006D0000000000000000000000000000000000000003100000000pybotvac-0.0.25/pybotvac/cert/neatocloud.com.crt-----BEGIN CERTIFICATE----- MIIE3DCCA8SgAwIBAgIJALHphD11lrmHMA0GCSqGSIb3DQEBBQUAMIGkMQswCQYD VQQGEwJVUzELMAkGA1UECBMCQ0ExDzANBgNVBAcTBk5ld2FyazEbMBkGA1UEChMS TmVhdG8gUm9ib3RpY3MgSW5jMRcwFQYDVQQLEw5DbG91ZCBTZXJ2aWNlczEZMBcG A1UEAxQQKi5uZWF0b2Nsb3VkLmNvbTEmMCQGCSqGSIb3DQEJARYXY2xvdWRAbmVh dG9yb2JvdGljcy5jb20wHhcNMTUwNDIxMTA1OTA4WhcNNDUwNDEzMTA1OTA4WjCB pDELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMQ8wDQYDVQQHEwZOZXdhcmsxGzAZ BgNVBAoTEk5lYXRvIFJvYm90aWNzIEluYzEXMBUGA1UECxMOQ2xvdWQgU2Vydmlj ZXMxGTAXBgNVBAMUECoubmVhdG9jbG91ZC5jb20xJjAkBgkqhkiG9w0BCQEWF2Ns b3VkQG5lYXRvcm9ib3RpY3MuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB CgKCAQEAur0WFcJ2YvnL3dtXJFv3lfCQtELLHVcux88tH7HN/FTeUvCqdleDNv4S mXWgxVOdUUuhV885wppYyXNzDDrwCyjPmYj0m1EZ4FqTCcjFmk+xdEJsPsKPgRt5 QqaO0CA/T7dcIhT/PtQnJtcjn6E6vt2JLhsLz9OazadwjvdkejmfrOL643FGxsIP 8hu3+JINcfxnmff85zshe0yQH5yIYkmQGUPQz061T6mMzFrED/hx9zDpiB1mfkUm uG3rBVcZWtrdyMvqB9LB1vqKgcCRANVg5S0GKpySudFlHOZjekXwBsZ+E6tW53qx hvlgmlxX80aybYC5hQaNSQBaV9N4lwIDAQABo4IBDTCCAQkwHQYDVR0OBBYEFM3g l7v7HP6zQgF90eHIl9coH6jhMIHZBgNVHSMEgdEwgc6AFM3gl7v7HP6zQgF90eHI l9coH6jhoYGqpIGnMIGkMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExDzANBgNV BAcTBk5ld2FyazEbMBkGA1UEChMSTmVhdG8gUm9ib3RpY3MgSW5jMRcwFQYDVQQL Ew5DbG91ZCBTZXJ2aWNlczEZMBcGA1UEAxQQKi5uZWF0b2Nsb3VkLmNvbTEmMCQG CSqGSIb3DQEJARYXY2xvdWRAbmVhdG9yb2JvdGljcy5jb22CCQCx6YQ9dZa5hzAM BgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQB93p+MUmKH+MQI3pEVvPUW y+VDB5qt1spE5J0awVwUzhQ7QXkEqgFfOk0kzufvxdha9wz+05E1glQ8l5CzlATu kA7V5OsygYB+TgqjvhfFHkSI6TJ8OlKcAJuZ2yQE8s2+LVo92NLwpooZLA6BCahn fX+rzmo6b4ylhyX98Tm3upINNH3whV355PJFgk74fw9N7U6cFlBrqXXssKOse2D2 xY65IK7OQxSq5K5OPFLwN3h/eURo5kwl7jhpJhJbFL4I46OkpgqWHxQEqSxQnS0d AC62ApwWkm42i0/DGODms2tnGL/DaCiTkgEE+8EEF9kfvQDtMoUDNvIkl7Vvm914 -----END CERTIFICATE----- 0707010000000D000081A40000000000000000000000016618088E000001AB000000000000000000000000000000000000002700000000pybotvac-0.0.25/pybotvac/exceptions.pyclass NeatoException(Exception): """ General neato exception. """ class NeatoLoginException(NeatoException): """ To indicate there is a login issue. """ class NeatoRobotException(NeatoException): """ To be thrown anytime there is a robot error. """ class NeatoUnsupportedDevice(NeatoRobotException): """ To be thrown only for unsupported devices. """ 0707010000000E000081A40000000000000000000000016618088E00000347000000000000000000000000000000000000002200000000pybotvac-0.0.25/pybotvac/neato.pyimport os from dataclasses import dataclass from typing import List, Union @dataclass(init=False, frozen=True) class Vendor: name: str endpoint: str auth_endpoint: str passwordless_endpoint: str token_endpoint: str scope: List[str] audience: str source: str cert_path: Union[str, bool] = False beehive_version: str = "application/vnd.neato.beehive.v1+json" nucleo_version: str = "application/vnd.neato.nucleo.v1" class Neato(Vendor): name = "neato" endpoint = "https://beehive.neatocloud.com/" auth_endpoint = "https://apps.neatorobotics.com/oauth2/authorize" token_endpoint = "https://beehive.neatocloud.com/oauth2/token" # nosec scope = ["public_profile", "control_robots", "maps"] cert_path = os.path.join(os.path.dirname(__file__), "cert", "neatocloud.com.crt") 0707010000000F000081A40000000000000000000000016618088E00003607000000000000000000000000000000000000002200000000pybotvac-0.0.25/pybotvac/robot.pyimport hashlib import hmac import logging import re from datetime import datetime, timezone from email.utils import format_datetime import requests import urllib3 from voluptuous import ( ALLOW_EXTRA, All, Any, Extra, MultipleInvalid, Range, Required, Schema, ) from .exceptions import NeatoRobotException, NeatoUnsupportedDevice from .neato import Neato # For default Vendor argument # Disable warning due to SubjectAltNameWarning in certificate # pylint: disable=no-member urllib3.disable_warnings(urllib3.exceptions.SubjectAltNameWarning) _LOGGER = logging.getLogger(__name__) SUPPORTED_SERVICES = ["basic-1", "minimal-2", "basic-2", "basic-3", "basic-4"] ALERTS_FLOORPLAN = [ "nav_floorplan_load_fail", "nav_floorplan_localization_fail", "nav_floorplan_not_created", ] RESULT_SCHEMA = Schema( Any( "ok", "invalid_json", "bad_request", "command_not_found", "command_rejected", "ko", # Everything below this line is not documented, but still present "not_on_charge_base", ) ) STANDARD_SCHEMA = Schema( { "version": int, "reqId": str, Required("result"): RESULT_SCHEMA, "data": {Extra: object}, }, extra=ALLOW_EXTRA, ) STATE_SCHEMA = Schema( { "version": int, "reqId": str, Required("result"): RESULT_SCHEMA, "data": {Extra: object}, Required("state"): int, "action": int, "error": Any(str, None), "alert": Any(str, None), "cleaning": { "category": int, "mode": int, "modifier": int, "navigationMode": int, "spotWidth": int, "spotHeight": int, }, "details": { "isCharging": bool, "isDocked": bool, "dockHasBeenSeen": bool, "charge": All(int, Range(min=0, max=100)), "isScheduleEnabled": bool, }, "availableCommands": { "start": bool, "stop": bool, "pause": bool, "resume": bool, "goToBase": bool, }, Required("availableServices"): { "findMe": str, "generalInfo": str, "houseCleaning": str, "localStats": str, "manualCleaning": str, "maps": str, "preferences": str, "schedule": str, "spotCleaning": str, # Undocumented services "IECTest": str, "logCopy": str, "softwareUpdate": str, "wifi": str, }, "meta": {"modelName": str, "firmware": str}, }, extra=ALLOW_EXTRA, ) class Robot: """Data and methods for interacting with a Neato Botvac Connected vacuum robot""" def __init__( self, serial, secret, traits, vendor=Neato, name="", endpoint="https://nucleo.neatocloud.com:4443", has_persistent_maps=False, ): """ Initialize robot :param serial: Robot serial :param secret: Robot secret :param name: Name of robot (optional) :param traits: Extras the robot supports """ self.name = name self._vendor = vendor self.serial = serial self.secret = secret self.traits = traits self.has_persistent_maps = has_persistent_maps # pylint: disable=consider-using-f-string self._url = "{endpoint}/vendors/{vendor_name}/robots/{serial}/messages".format( endpoint=re.sub(r":\d+", "", endpoint), # Remove port number vendor_name=vendor.name, serial=self.serial, ) self._headers = {"Accept": vendor.nucleo_version} # Check if service_version is supported # We manually scan the state here to perform appropriate error handling state = self.get_robot_state().json() if ( "availableServices" not in state or "houseCleaning" not in state["availableServices"] or state["availableServices"]["houseCleaning"] not in SUPPORTED_SERVICES ): raise NeatoUnsupportedDevice( "Service houseCleaning is not supported by your robot" ) def __str__(self): # pylint: disable=consider-using-f-string return "Name: %s, Serial: %s, Secret: %s Traits: %s" % ( self.name, self.serial, self.secret, self.traits, ) def _message(self, json: dict, schema: Schema): """ Sends message to robot with data from parameter 'json' :param json: dict containing data to send :return: server response """ try: response = requests.post( self._url, json=json, verify=self._vendor.cert_path, auth=Auth(self.serial, self.secret), headers=self._headers, timeout=10, ) response.raise_for_status() schema(response.json()) except ( requests.exceptions.ConnectionError, requests.exceptions.HTTPError, ) as ex: raise NeatoRobotException("Unable to communicate with robot") from ex except MultipleInvalid as ex: _LOGGER.warning( "Invalid response from %s: %s. Got: %s", self._url, ex, response.json() ) return response def start_cleaning( self, mode=2, navigation_mode=1, category=None, boundary_id=None, map_id=None ): # mode & navigation_mode used if applicable to service version # mode: 1 eco, 2 turbo # navigation_mode: 1 normal, 2 extra care, 3 deep # category: 2 non-persistent map, 4 persistent map # boundary_id: the id of the zone to clean # map_id: the id of the map to clean # Default to using the persistent map if we support basic-3 or basic-4. if category is None: category = ( 4 if self.service_version in ["basic-3", "basic-4"] and self.has_persistent_maps else 2 ) if self.service_version == "basic-1": json = { "reqId": "1", "cmd": "startCleaning", "params": {"category": category, "mode": mode, "modifier": 1}, } elif self.service_version in ["basic-3", "basic-4"]: json = { "reqId": "1", "cmd": "startCleaning", "params": { "category": category, "mode": mode, "modifier": 1, "navigationMode": navigation_mode, }, } if boundary_id: json["params"]["boundaryId"] = boundary_id if map_id: json["params"]["mapId"] = map_id elif self.service_version == "minimal-2": json = { "reqId": "1", "cmd": "startCleaning", "params": {"category": category, "navigationMode": navigation_mode}, } else: # self.service_version == 'basic-2' json = { "reqId": "1", "cmd": "startCleaning", "params": { "category": category, "mode": mode, "modifier": 1, "navigationMode": navigation_mode, }, } response = self._message(json, STATE_SCHEMA) result = response.json().get("result", None) alert = response.json().get("alert", None) if result != "ok": _LOGGER.warning( "Result of robot.start_cleaning is not ok: %s, alert: %s", result, alert ) # Fall back to category 2 if we tried and failed with category 4 if ( category == 4 and alert in ALERTS_FLOORPLAN or result == "not_on_charge_base" ): json["params"]["category"] = 2 response_fallback = self._message(json, STATE_SCHEMA) result = response_fallback.json().get("result", None) alert = response_fallback.json().get("alert", None) if result != "ok": _LOGGER.warning( "Result of robot.start_cleaning is not ok after fallback: %s, alert: %s", result, alert, ) return response_fallback return response def start_spot_cleaning(self, spot_width=400, spot_height=400, mode=2, modifier=2): # Spot cleaning if applicable to version # spot_width: spot width in cm # spot_height: spot height in cm if self.spot_cleaning_version == "basic-1": json = { "reqId": "1", "cmd": "startCleaning", "params": { "category": 3, "mode": mode, "modifier": modifier, "spotWidth": spot_width, "spotHeight": spot_height, }, } elif self.spot_cleaning_version == "basic-3": json = { "reqId": "1", "cmd": "startCleaning", "params": { "category": 3, "spotWidth": spot_width, "spotHeight": spot_height, }, } elif self.spot_cleaning_version == "minimal-2": json = { "reqId": "1", "cmd": "startCleaning", "params": {"category": 3, "modifier": modifier, "navigationMode": 1}, } else: # self.spot_cleaning_version == 'micro-2' json = { "reqId": "1", "cmd": "startCleaning", "params": {"category": 3, "navigationMode": 1}, } return self._message(json, STATE_SCHEMA) def pause_cleaning(self): return self._message({"reqId": "1", "cmd": "pauseCleaning"}, STATE_SCHEMA) def resume_cleaning(self): return self._message({"reqId": "1", "cmd": "resumeCleaning"}, STATE_SCHEMA) def stop_cleaning(self): return self._message({"reqId": "1", "cmd": "stopCleaning"}, STATE_SCHEMA) def send_to_base(self): return self._message({"reqId": "1", "cmd": "sendToBase"}, STATE_SCHEMA) def get_robot_state(self): return self._message({"reqId": "1", "cmd": "getRobotState"}, STATE_SCHEMA) def enable_schedule(self): return self._message({"reqId": "1", "cmd": "enableSchedule"}, STANDARD_SCHEMA) def disable_schedule(self): return self._message({"reqId": "1", "cmd": "disableSchedule"}, STANDARD_SCHEMA) def get_schedule(self): return self._message({"reqId": "1", "cmd": "getSchedule"}, STANDARD_SCHEMA) def locate(self): return self._message({"reqId": "1", "cmd": "findMe"}, STANDARD_SCHEMA) def get_general_info(self): return self._message({"reqId": "1", "cmd": "getGeneralInfo"}, STANDARD_SCHEMA) def get_local_stats(self): return self._message({"reqId": "1", "cmd": "getLocalStats"}, STANDARD_SCHEMA) def get_preferences(self): return self._message({"reqId": "1", "cmd": "getPreferences"}, STANDARD_SCHEMA) def get_map_boundaries(self, map_id=None): return self._message( {"reqId": "1", "cmd": "getMapBoundaries", "params": {"mapId": map_id}}, STANDARD_SCHEMA, ) def get_robot_info(self): return self._message({"reqId": "1", "cmd": "getRobotInfo"}, STANDARD_SCHEMA) def dismiss_current_alert(self): return self._message( {"reqId": "1", "cmd": "dismissCurrentAlert"}, STANDARD_SCHEMA ) @property def schedule_enabled(self): return self.get_robot_state().json()["details"]["isScheduleEnabled"] @schedule_enabled.setter def schedule_enabled(self, enable): if enable: self.enable_schedule() else: self.disable_schedule() @property def state(self): return self.get_robot_state().json() @property def available_services(self): return self.state["availableServices"] @property def service_version(self): return self.available_services["houseCleaning"] @property def spot_cleaning_version(self): return self.available_services["spotCleaning"] class Auth(requests.auth.AuthBase): """Create headers for request authentication""" def __init__(self, serial, secret): self.serial = serial self.secret = secret def __call__(self, request): # We have to format the date according to RFC 2616 # https://tools.ietf.org/html/rfc2616#section-14.18 now = datetime.now(timezone.utc) date = format_datetime(now, True) try: # Attempt to decode request.body (assume bytes received) msg = "\n".join([self.serial.lower(), date, request.body.decode("utf8")]) except AttributeError: # Decode failed, assume request.body is already type str msg = "\n".join([self.serial.lower(), date, request.body]) signing = hmac.new( key=self.secret.encode("utf8"), msg=msg.encode("utf8"), digestmod=hashlib.sha256, ) request.headers["Date"] = date request.headers["Authorization"] = "NEATOAPP " + signing.hexdigest() return request 07070100000010000081A40000000000000000000000016618088E00002320000000000000000000000000000000000000002400000000pybotvac-0.0.25/pybotvac/session.py"""Sessionhandling for beehive endpoint.""" import binascii import json import os import os.path from typing import Callable, Dict, Optional import requests from oauthlib.oauth2 import TokenExpiredError from requests_oauthlib import OAuth2Session from .exceptions import NeatoException, NeatoLoginException, NeatoRobotException from .neato import Neato, Vendor from .vorwerk import Vorwerk try: from urllib.parse import urljoin except ImportError: from urlparse import urljoin class Session: def __init__(self, vendor: Vendor): """Initialize the session.""" self.vendor = vendor self.endpoint = vendor.endpoint self.headers = {"Accept": vendor.beehive_version} def get(self, path, **kwargs): """Send a GET request to the specified path.""" raise NotImplementedError def urljoin(self, path): return urljoin(self.endpoint, path) def generate_headers( self, custom_headers: Optional[Dict[str, str]] = None ) -> Dict[str, str]: """Merge self.headers with custom headers id necessary.""" if not custom_headers: return self.headers return {**self.headers, **custom_headers} class PasswordSession(Session): def __init__(self, email: str, password: str, vendor: Vendor = Neato()): super().__init__(vendor=vendor) self._login(email, password) def _login(self, email: str, password: str): """ Login to pybotvac account using provided email and password. :param email: email for pybotvac account :param password: Password for pybotvac account :return: """ try: response = requests.post( urljoin(self.endpoint, "sessions"), json={ "email": email, "password": password, "platform": "ios", "token": binascii.hexlify(os.urandom(64)).decode("utf8"), }, headers=self.headers, timeout=10, ) response.raise_for_status() access_token = response.json()["access_token"] # pylint: disable=consider-using-f-string self.headers["Authorization"] = "Token token=%s" % access_token except ( requests.exceptions.ConnectionError, requests.exceptions.HTTPError, requests.exceptions.Timeout, ) as ex: if ( isinstance(ex, requests.exceptions.HTTPError) and ex.response.status_code == 403 ): raise NeatoLoginException( "Unable to login to neato, check account credentials." ) from ex raise NeatoRobotException("Unable to connect to Neato API.") from ex def get(self, path, **kwargs): url = self.urljoin(path) headers = self.generate_headers(kwargs.pop("headers", None)) try: response = requests.get(url, headers=headers, timeout=10, **kwargs) response.raise_for_status() except ( requests.exceptions.ConnectionError, requests.exceptions.HTTPError, requests.exceptions.Timeout, ) as ex: raise NeatoException("Unable to connect to neato the neato serves.") from ex return response class OAuthSession(Session): def __init__( self, token: Optional[Dict[str, str]] = None, client_id: str = None, client_secret: str = None, redirect_uri: str = None, token_updater: Optional[Callable[[str], None]] = None, vendor: Vendor = Neato(), ): super().__init__(vendor=vendor) self._client_id = client_id self._client_secret = client_secret self._redirect_uri = redirect_uri self._token_updater = token_updater extra = {"client_id": self._client_id, "client_secret": self._client_secret} self._oauth = OAuth2Session( auto_refresh_kwargs=extra, client_id=client_id, token=token, redirect_uri=redirect_uri, token_updater=token_updater, scope=vendor.scope, ) def refresh_tokens(self) -> dict: """Refresh and return new tokens.""" token = self._oauth.refresh_token(f"{self.endpoint}/auth/token") if self._token_updater is not None: self._token_updater(token) return token def get_authorization_url(self) -> str: """Get an authorization url via oauth2.""" # pylint: disable=unused-variable authorization_url, state = self._oauth.authorization_url( self.vendor.auth_endpoint ) return authorization_url def fetch_token(self, authorization_response: str) -> Dict[str, str]: """Fetch an access token via oauth2.""" token = self._oauth.fetch_token( self.vendor.token_endpoint, authorization_response=authorization_response, client_secret=self._client_secret, ) return token def get(self, path: str, **kwargs) -> requests.Response: """Make a get request. We don't use the built-in token refresh mechanism of OAuth2 session because we want to allow overriding the token refresh logic. """ url = self.urljoin(path) try: response = self._get(url, **kwargs) response.raise_for_status() except ( requests.exceptions.ConnectionError, requests.exceptions.HTTPError, requests.exceptions.Timeout, ) as ex: raise NeatoException("Unable to connect to neato the neato serves.") from ex return response def _get(self, path: str, **kwargs) -> requests.Response: """Get request without error handling. Refreshes the token if necessary. """ headers = self.generate_headers(kwargs.pop("headers", None)) try: return self._oauth.get(path, headers=headers, **kwargs) except TokenExpiredError: self._oauth.token = self.refresh_tokens() return self._oauth.get(path, headers=self.headers, **kwargs) class PasswordlessSession(Session): def __init__( self, token: Optional[Dict[str, str]] = None, client_id: str = None, token_updater: Optional[Callable[[str], None]] = None, vendor: Vendor = Vorwerk(), ): super().__init__(vendor=vendor) self._token = token self._client_id = client_id self._token_updater = token_updater def send_email_otp(self, email: str): """Request an authorization code via email.""" response = requests.post( self.vendor.passwordless_endpoint, data=json.dumps( { "client_id": self._client_id, "connection": "email", "email": email, "send": "code", } ), headers={"Content-Type": "application/json"}, timeout=10, ) response.raise_for_status() def fetch_token_passwordless(self, email: str, code: str): """Fetch an access token using the emailed code.""" response = requests.post( self.vendor.token_endpoint, data=json.dumps( { "prompt": "login", "grant_type": "http://auth0.com/oauth/grant-type/passwordless/otp", "scope": " ".join(self.vendor.scope), "locale": "en", "otp": code, "source": self.vendor.source, "platform": "ios", "audience": self.vendor.audience, "username": email, "client_id": self._client_id, "realm": "email", "country_code": "DE", } ), headers={"Content-Type": "application/json"}, timeout=10, ) response.raise_for_status() self._token = response.json() def get(self, path: str, **kwargs) -> requests.Response: """Make a get request.""" url = self.urljoin(path) headers = self.generate_headers(kwargs.pop("headers", None)) # pylint: disable=consider-using-f-string headers["Authorization"] = "Auth0Bearer {}".format(self._token.get("id_token")) try: response = requests.get(url, headers=headers, timeout=10) response.raise_for_status() except ( requests.exceptions.ConnectionError, requests.exceptions.HTTPError, requests.exceptions.Timeout, ) as ex: raise NeatoException("Unable to connect to neato servers.") from ex return response 07070100000011000081A40000000000000000000000016618088E000000AE000000000000000000000000000000000000002400000000pybotvac-0.0.25/pybotvac/version.pyimport pkg_resources try: __version__ = pkg_resources.get_distribution("pybotvac").version except Exception: # pylint: disable=broad-except __version__ = "unknown" 07070100000012000081A40000000000000000000000016618088E00000216000000000000000000000000000000000000002400000000pybotvac-0.0.25/pybotvac/vorwerk.pyimport os from .neato import Vendor class Vorwerk(Vendor): name = "vorwerk" endpoint = "https://beehive.ksecosys.com/" passwordless_endpoint = "https://mykobold.eu.auth0.com/passwordless/start" token_endpoint = "https://mykobold.eu.auth0.com/oauth/token" # nosec scope = ["openid", "email", "profile", "read:current_user", "offline_access"] audience = "https://mykobold.eu.auth0.com/userinfo" source = "vorwerk_auth0" cert_path = os.path.join(os.path.dirname(__file__), "cert", "ksecosys.com.crt") 07070100000013000081A40000000000000000000000016618088E0000006A000000000000000000000000000000000000002100000000pybotvac-0.0.25/requirements.txturllib3 requests requests_oauthlib voluptuous bandit>=1 black codespell>=2 flake8>=3 isort>=5 pylint>=2.6 07070100000014000041ED0000000000000000000000026618088E00000000000000000000000000000000000000000000001700000000pybotvac-0.0.25/sample07070100000015000081A40000000000000000000000016618088E00000783000000000000000000000000000000000000002100000000pybotvac-0.0.25/sample/sample.pyimport sys from pybotvac import ( Account, Neato, OAuthSession, PasswordlessSession, PasswordSession, Vorwerk, ) # Set email and password if you plan to use password authentication. # Set Client ID and Secret if you plan to use OAuth2. # If you plan to use email OTP, all you need to do is specify your email and a Client ID. email = "Your email" password = "Your password" client_id = "Your client it" client_secret = "Your client secret" redirect_uri = "Your redirect URI" # Set your vendor vendor = Neato() ########################## # Authenticate via Email and Password ########################## # session = PasswordSession(email=email, password=password, vendor=vendor) # account = Account(session) ########################## # Authenticate via OAuth2 ########################## session = OAuthSession( client_id=client_id, client_secret=client_secret, redirect_uri=redirect_uri, vendor=vendor, ) authorization_url = session.get_authorization_url() print("Visit: " + authorization_url) authorization_response = input("Enter the full callback URL: ") token = session.fetch_token(authorization_response) account = Account(session) ########################## # Authenticate via One Time Password ########################## # session = PasswordlessSession(client_id=client_id, vendor=vendor) # session.send_email_otp(email) # code = input("Enter the code: ") # session.fetch_token_passwordless(email, code) # account = Account(session) print("Robots:") for robot in account.robots: print(robot) print() print("State:\n", robot.state) print() print("Schedule enabled:", robot.schedule_enabled) print("Disabling schedule") robot.schedule_enabled = False print("Schedule enabled:", robot.schedule_enabled) print("Enabling schedule") robot.schedule_enabled = True print("Schedule enabled:", robot.schedule_enabled) print() 07070100000016000081A40000000000000000000000016618088E00000155000000000000000000000000000000000000001A00000000pybotvac-0.0.25/setup.cfg[codespell] skip = ./.git,./.mypy_cache,./.vscode,./venv [flake8] exclude = .vscode,venv max-line-length = 88 ignore = # Import error, pylint covers this F401, # Formatting errors that are covered by black D202, E203, E501, W503, W504, [isort] profile=black multi_line_output = 3 src_paths=pybotvac, sample 07070100000017000081A40000000000000000000000016618088E000002C3000000000000000000000000000000000000001900000000pybotvac-0.0.25/setup.pyfrom setuptools import setup with open("README.md", "r") as f: long_description = f.read() setup( name="pybotvac", version="0.0.25", description="Python package for controlling Neato pybotvac Connected vacuum robot", long_description=long_description, long_description_content_type="text/markdown", author="Stian Askeland", author_email="stianaske@gmail.com", url="https://github.com/stianaske/pybotvac", license="Licensed under the MIT license. See LICENSE file for details", packages=["pybotvac"], package_dir={"pybotvac": "pybotvac"}, package_data={"pybotvac": ["cert/*.crt"]}, install_requires=["requests", "requests_oauthlib", "voluptuous"], ) 07070100000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000B00000000TRAILER!!!147 blocks
Locations
Projects
Search
Status Monitor
Help
OpenBuildService.org
Documentation
API Documentation
Code of Conduct
Contact
Support
@OBShq
Terms
openSUSE Build Service is sponsored by
The Open Build Service is an
openSUSE project
.
Sign Up
Log In
Places
Places
All Projects
Status Monitor