├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── .vscode ├── launch.json └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── commitlint.config.mjs ├── docs ├── Makefile ├── make.bat ├── requirements.txt └── source │ ├── _templates │ └── footer.html │ ├── api_commands.rst │ ├── conf.py │ ├── error.rst │ ├── index.rst │ ├── status.rst │ ├── supported_devices.rst │ └── usage.rst ├── mypy.ini ├── poetry.lock ├── pyproject.toml ├── roborock ├── __init__.py ├── api.py ├── cli.py ├── cloud_api.py ├── code_mappings.py ├── command_cache.py ├── const.py ├── containers.py ├── exceptions.py ├── local_api.py ├── mqtt │ ├── __init__.py │ ├── roborock_session.py │ └── session.py ├── protocol.py ├── py.typed ├── roborock_future.py ├── roborock_message.py ├── roborock_typing.py ├── util.py ├── version_1_apis │ ├── __init__.py │ ├── roborock_client_v1.py │ ├── roborock_local_client_v1.py │ └── roborock_mqtt_client_v1.py ├── version_a01_apis │ ├── __init__.py │ ├── roborock_client_a01.py │ └── roborock_mqtt_client_a01.py └── web_api.py └── tests ├── __init__.py ├── conftest.py ├── mock_data.py ├── mqtt └── test_roborock_session.py ├── mqtt_packet.py ├── test_a01_api.py ├── test_api.py ├── test_containers.py ├── test_local_api_v1.py ├── test_queue.py ├── test_roborock_message.py ├── test_util.py └── test_web_api.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | 9 | - package-ecosystem: "pip" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | concurrency: 10 | group: ${{ github.head_ref || github.run_id }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | # Make sure commit messages follow the conventional commits convention: 15 | # https://www.conventionalcommits.org 16 | commitlint: 17 | name: Lint Commit Messages 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | - uses: wagoid/commitlint-github-action@v6.2.1 24 | lint: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: actions/setup-python@v5 29 | with: 30 | python-version: "3.11" 31 | - uses: pre-commit/action@v3.0.1 32 | 33 | test: 34 | strategy: 35 | fail-fast: false 36 | matrix: 37 | python-version: 38 | - "3.11" 39 | - "3.12" 40 | - "3.13" 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v4 44 | - name: Set up Python 45 | uses: actions/setup-python@v5 46 | with: 47 | python-version: ${{ matrix.python-version }} 48 | - uses: snok/install-poetry@v1.4.1 49 | - name: Install Dependencies 50 | run: poetry install 51 | shell: bash 52 | - name: Test with Pytest 53 | run: poetry run pytest 54 | shell: bash 55 | release: 56 | runs-on: ubuntu-latest 57 | needs: 58 | - test 59 | concurrency: release 60 | if: github.ref == 'refs/heads/main' 61 | permissions: 62 | contents: write 63 | issues: write 64 | pull-requests: write 65 | id-token: write 66 | actions: write 67 | packages: write 68 | environment: 69 | name: release 70 | 71 | steps: 72 | - uses: actions/checkout@v4 73 | with: 74 | fetch-depth: 0 75 | persist-credentials: false 76 | - name: Python Semantic Release 77 | id: release 78 | uses: python-semantic-release/python-semantic-release@v9.21.0 79 | with: 80 | github_token: ${{ secrets.GH_TOKEN }} 81 | 82 | - name: Publish package distributions to PyPI 83 | uses: pypa/gh-action-pypi-publish@v1.12.4 84 | 85 | # NOTE: DO NOT wrap the conditional in ${{ }} as it will always evaluate to true. 86 | # See https://github.com/actions/runner/issues/1173 87 | if: steps.release.outputs.released == 'true' 88 | 89 | - name: Publish package distributions to GitHub Releases 90 | uses: python-semantic-release/upload-to-gh-release@v9.8.9 91 | if: steps.release.outputs.released == 'true' 92 | with: 93 | github_token: ${{ secrets.GH_TOKEN }} 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | venv 3 | .venv 4 | .idea 5 | roborock/__pycache__ 6 | *.pyc 7 | .coverage 8 | 9 | # Sphinx documentation 10 | docs/_build/ 11 | 12 | # mkdocs documentation 13 | /site 14 | /docs/build/ 15 | .DS_Store 16 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | exclude: "CHANGELOG.md" 4 | default_stages: [ commit ] 5 | 6 | repos: 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v4.5.0 9 | hooks: 10 | - id: debug-statements 11 | - id: check-builtin-literals 12 | - id: check-case-conflict 13 | - id: check-docstring-first 14 | - id: check-json 15 | - id: check-toml 16 | - id: check-yaml 17 | - id: detect-private-key 18 | - id: end-of-file-fixer 19 | - id: trailing-whitespace 20 | - repo: https://github.com/python-poetry/poetry 21 | rev: 1.7.1 22 | hooks: 23 | - id: poetry-check 24 | - repo: https://github.com/codespell-project/codespell 25 | rev: v2.2.6 26 | hooks: 27 | - id: codespell 28 | - repo: https://github.com/charliermarsh/ruff-pre-commit 29 | rev: v0.1.8 30 | hooks: 31 | - id: ruff-format 32 | - id: ruff 33 | args: 34 | - --fix 35 | - repo: https://github.com/pre-commit/mirrors-mypy 36 | rev: v1.7.1 37 | hooks: 38 | - id: mypy 39 | exclude: cli.py 40 | additional_dependencies: [ "types-paho-mqtt" ] 41 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.10" 7 | 8 | python: 9 | install: 10 | - requirements: docs/requirements.txt 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Python: Current File", 6 | "type": "python", 7 | "request": "launch", 8 | "program": "${file}", 9 | "console": "integratedTerminal", 10 | "justMyCode": false 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "esbonio.sphinx.confDir": "" 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Roborock 2 | 3 |

4 | 5 | PyPI Version 6 | 7 | Supported Python versions 8 | License 9 |

10 | 11 | Roborock library for online and offline control of your vacuums. 12 | 13 | ## Installation 14 | 15 | Install this via pip (or your favourite package manager): 16 | 17 | `pip install python-roborock` 18 | 19 | ## Functionality 20 | 21 | You can see all of the commands supported [here](https://python-roborock.readthedocs.io/en/latest/api_commands.html) 22 | 23 | ## Sending Commands 24 | 25 | Here is an example that requires no manual intervention and can be done all automatically. You can skip some steps by 26 | caching values or looking at them and grabbing them manually. 27 | ```python 28 | import asyncio 29 | 30 | from roborock import HomeDataProduct, DeviceData, RoborockCommand 31 | from roborock.version_1_apis import RoborockMqttClientV1, RoborockLocalClientV1 32 | from roborock.web_api import RoborockApiClient 33 | 34 | async def main(): 35 | web_api = RoborockApiClient(username="youremailhere") 36 | # Login via your password 37 | user_data = await web_api.pass_login(password="pass_here") 38 | # Or login via a code 39 | await web_api.request_code() 40 | code = input("What is the code?") 41 | user_data = await web_api.code_login(code) 42 | 43 | # Get home data 44 | home_data = await web_api.get_home_data_v2(user_data) 45 | 46 | # Get the device you want 47 | device = home_data.devices[0] 48 | 49 | # Get product ids: 50 | product_info: dict[str, HomeDataProduct] = { 51 | product.id: product for product in home_data.products 52 | } 53 | # Create the Mqtt(aka cloud required) Client 54 | device_data = DeviceData(device, product_info[device.product_id].model) 55 | mqtt_client = RoborockMqttClientV1(user_data, device_data) 56 | networking = await mqtt_client.get_networking() 57 | local_device_data = DeviceData(device, product_info[device.product_id].model, networking.ip) 58 | local_client = RoborockLocalClientV1(local_device_data) 59 | # You can use the send_command to send any command to the device 60 | status = await local_client.send_command(RoborockCommand.GET_STATUS) 61 | # Or use existing functions that will give you data classes 62 | status = await local_client.get_status() 63 | 64 | asyncio.run(main()) 65 | ``` 66 | 67 | ## Supported devices 68 | 69 | You can find what devices are supported 70 | [here](https://python-roborock.readthedocs.io/en/latest/supported_devices.html). 71 | Please note this may not immediately contain the latest devices. 72 | 73 | 74 | ## Credits 75 | 76 | Thanks @rovo89 for https://gist.github.com/rovo89/dff47ed19fca0dfdda77503e66c2b7c7 And thanks @PiotrMachowski for https://github.com/PiotrMachowski/Home-Assistant-custom-components-Xiaomi-Cloud-Map-Extractor 77 | -------------------------------------------------------------------------------- /commitlint.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ["@commitlint/config-conventional"], 3 | ignores: [(msg) => /Signed-off-by: dependabot\[bot]/m.test(msg)], 4 | rules: { 5 | // Disable the rule that enforces lowercase in subject 6 | "subject-case": [0], // 0 = disable, 1 = warn, 2 = error 7 | }, 8 | 9 | }; 10 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | 3 | sphinx_rtd_theme 4 | -------------------------------------------------------------------------------- /docs/source/_templates/footer.html: -------------------------------------------------------------------------------- 1 | {% extends "!footer.html" %} 2 | {%- block contentinfo %} 3 | {{ super() }} 4 |

We are looking for contributors to help with our documentation, if you are interested please contribute here. 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | 3 | # -- Project information 4 | 5 | project = "Python Roborock" 6 | author = "Humberto gontijo & Lash-L" 7 | 8 | release = "0.1" 9 | version = "0.1.0" 10 | 11 | # -- General configuration 12 | 13 | extensions = [ 14 | "sphinx.ext.duration", 15 | "sphinx.ext.doctest", 16 | "sphinx.ext.autodoc", 17 | "sphinx.ext.autosummary", 18 | "sphinx.ext.intersphinx", 19 | "sphinx.ext.autosectionlabel", 20 | "sphinx_rtd_theme", 21 | ] 22 | 23 | intersphinx_mapping = { 24 | "python": ("https://docs.python.org/3/", None), 25 | "sphinx": ("https://www.sphinx-doc.org/en/master/", None), 26 | } 27 | intersphinx_disabled_domains = ["std"] 28 | 29 | templates_path = ["_templates"] 30 | 31 | # -- Options for HTML output 32 | 33 | html_theme = "sphinx_rtd_theme" 34 | 35 | # -- Options for EPUB output 36 | epub_show_urls = "footnote" 37 | -------------------------------------------------------------------------------- /docs/source/error.rst: -------------------------------------------------------------------------------- 1 | Error 2 | ===== 3 | 4 | Dock Errors 5 | ----------- 6 | 7 | These are the potential errors your dock can have and their corresponding number: 8 | ok = 0 9 | 10 | duct_blockage = 34 11 | 12 | water_empty = 38 13 | 14 | waste_water_tank_full = 39 15 | 16 | dirty_tank_latch_open = 44 17 | 18 | no_dustbin = 46 19 | 20 | cleaning_tank_full_or_blocked = 53 21 | 22 | 23 | Vacuum Errors 24 | ------------- 25 | 26 | These are the potential errors your vacuum can have and their corresponding code 27 | 28 | lidar_blocked = 1 29 | 30 | bumper_stuck = 2 31 | 32 | wheels_suspended = 3 33 | 34 | cliff_sensor_error = 4 35 | 36 | main_brush_jammed = 5 37 | 38 | side_brush_jammed = 6 39 | 40 | wheels_jammed = 7 41 | 42 | robot_trapped = 8 43 | 44 | no_dustbin = 9 45 | 46 | low_battery = 12 47 | 48 | charging_error = 13 49 | 50 | battery_error = 14 51 | 52 | wall_sensor_dirty = 15 53 | 54 | robot_tilted = 16 55 | 56 | side_brush_error = 17 57 | 58 | fan_error = 18 59 | 60 | vertical_bumper_pressed = 21 61 | 62 | dock_locator_error = 22 63 | 64 | return_to_dock_fail = 23 65 | 66 | nogo_zone_detected = 24 67 | 68 | vibrarise_jammed = 27 69 | 70 | robot_on_carpet = 28 71 | 72 | filter_blocked = 29 73 | 74 | invisible_wall_detected = 30 75 | 76 | cannot_cross_carpet = 31 77 | 78 | internal_error = 32 79 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to Roborock's documentation! 2 | ==================================== 3 | 4 | **Roborock** is a Python library for controlling your Roborock vacuum 5 | 6 | .. note:: 7 | 8 | This project is under active development. 9 | 10 | You can get a Home Assistant integration for Roborock in core 11 | `here `__ or as a custom integration 12 | `here `__ 13 | 14 | Contents 15 | -------- 16 | 17 | .. toctree:: 18 | 19 | usage 20 | status 21 | error 22 | api_commands 23 | supported_devices 24 | -------------------------------------------------------------------------------- /docs/source/status.rst: -------------------------------------------------------------------------------- 1 | Status 2 | ====== 3 | Status is a core piece of information for our system. It is used to get a wide variety of data about the vacuum and is broadcasted. 4 | 5 | msg_ver: 6 | 7 | msg_seq: 8 | 9 | state: 10 | 11 | battery: The battery percentage of the vacuum 12 | 13 | clean_time: How long (total) this vacuum has cleaned for 14 | 15 | clean_area: How much area (total) this vacuum has cleaned in micrometers 16 | 17 | error_code: The error code of the vacuum 18 | 19 | map_present: 20 | 21 | in_cleaning: If the vacuum is currently cleaning 22 | 23 | in_returning: If the vacuum is currently returning to the dock. 24 | 25 | in_fresh_state: 26 | 27 | lab_status: 28 | 29 | water_box_status: 30 | 31 | back_type: 32 | 33 | wash_phase: 34 | 35 | wash_ready: 36 | 37 | fan_power: The strength of the fan suction. Listed as an integer that corresponds to a enum value. 38 | 39 | dnd_enabled: 0 or 1 that states if there is a dnd time enabled (does not mean that dnd is on now) 40 | 41 | map_status: 42 | 43 | is_locating: 44 | 45 | lock_status: 46 | 47 | water_box_mode: 48 | 49 | water_box_carriage_status: 50 | 51 | mop_forbidden_enable: 52 | 53 | camera_status: 54 | 55 | is_exploring: 56 | 57 | home_sec_status: 58 | 59 | home_sec_enable_password: 60 | 61 | adbumper_status: 62 | 63 | water_shortage_status: 64 | 65 | dock_type: 66 | 67 | dust_collection_status: 68 | 69 | auto_dust_collection: 70 | 71 | avoid_count: 72 | 73 | mop_mode: 74 | 75 | debug_mode: 76 | 77 | collision_avoid_status: 78 | 79 | switch_map_mode: 80 | 81 | dock_error_status: 82 | 83 | charge_status: 84 | 85 | unsave_map_reason: 86 | 87 | unsave_map_flag: 88 | 89 | wash_status: 90 | 91 | distance_off: 92 | 93 | in_warmup: 94 | 95 | dry_status: 96 | 97 | rdt: 98 | 99 | clean_percent: 100 | 101 | rss: 102 | 103 | dss: 104 | 105 | common_status: 106 | 107 | corner_clean_mode: 108 | -------------------------------------------------------------------------------- /docs/source/supported_devices.rst: -------------------------------------------------------------------------------- 1 | Supported Devices 2 | ================== 3 | 4 | Note: These links are tracking links with Amazon or Roborock. This allows us to get some analytics and helps us get 5 | 'negotiation' power with Roborock. We would like to be able to open a channel of communication with Roborock, and 6 | getting information like this is a great first step. 7 | 8 | Note, I have only added links to the new devices, older devices are no longer sold directly by roborock, so to buy them 9 | you have to find them used. 10 | 11 | .. list-table:: Robot Vacuums 12 | :widths: 30 20 20 13 | :header-rows: 1 14 | 15 | * - Vacuum Model 16 | - Amazon 17 | - Roborock 18 | * - Roborock S4 19 | - 20 | - 21 | * - Roborock S4 Max 22 | - 23 | - 24 | * - Roborock S5 Max 25 | - 26 | - 27 | * - Roborock S6 28 | - 29 | - 30 | * - Roborock S6 Pure 31 | - 32 | - 33 | * - Roborock S6 Max 34 | - 35 | - 36 | * - Roborock S6 MaxV 37 | - 38 | - 39 | * - Roborock S7 40 | - 41 | - 42 | * - Roborock S7 MaxV 43 | - 44 | - 45 | * - Roborock S7 Max Ultra 46 | - `Link `__ 47 | - `Link `__ 48 | * - Roborock S8 49 | - `Link `__ 50 | - `Link `__ 51 | * - Roborock S8 Pro Ultra 52 | - `Link `__ 53 | - `Link `__ 54 | * - Roborock Q5 55 | - `Link `__ 56 | - `Link `__ 57 | * - Roborock Q5 Pro 58 | - `Link `__ 59 | - `Link `__ 60 | * - Roborock Q7 61 | - `Link `__ 62 | - `Link `__ 63 | * - Roborock Q7 Max 64 | - `Link `__ 65 | - `Link `__ 66 | * - Roborock Q8 Max 67 | - `Link `__ 68 | - `Link `__ 69 | * - Roborock Q Revo 70 | - `Link `__ 71 | - `Link `__ 72 | 73 | 74 | Roborock has recently added two other categories of devices, handheld vacuums, and washing machines. 75 | Neither are supported at this time. 76 | 77 | There are plans to support the handheld ones, but it uses a newer version of the api that I am still trying to reverse 78 | engineer. 79 | -------------------------------------------------------------------------------- /docs/source/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | 4 | Installation 5 | ------------ 6 | 7 | To use Python-Roborock, first install it using pip: 8 | 9 | .. code-block:: console 10 | 11 | (.venv) $ pip install python-roborock 12 | 13 | Login 14 | ----- 15 | 16 | .. code-block:: console 17 | 18 | (.venv) $ roborock login --email username --password password 19 | 20 | List devices 21 | ------------ 22 | 23 | This will list all devices associated with the account: 24 | 25 | .. code-block:: console 26 | 27 | (.venv) $ roborock list-devices 28 | 29 | Known devices MyRobot: 7kI9d66UoPXd6sd9gfd75W 30 | 31 | 32 | The deviceId 7kI9d66UoPXd6sd9gfd75W can be used to run commands on the device. 33 | 34 | Run a command 35 | ------------- 36 | 37 | To run a command: 38 | 39 | .. code-block:: console 40 | 41 | (.venv) $ roborock -d command --device_id 7kI9d66UoPXd6sd9gfd75W --cmd get_status 42 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | check_untyped_defs = True 3 | 4 | [mypy-construct] 5 | ignore_missing_imports = True 6 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "python-roborock" 3 | version = "2.19.0" 4 | description = "A package to control Roborock vacuums." 5 | authors = ["humbertogontijo "] 6 | license = "GPL-3.0-only" 7 | readme = "README.md" 8 | repository = "https://github.com/humbertogontijo/python-roborock" 9 | documentation = "https://python-roborock.readthedocs.io/" 10 | classifiers = [ 11 | "Development Status :: 5 - Production/Stable", 12 | "Intended Audience :: Developers", 13 | "Natural Language :: English", 14 | "Operating System :: OS Independent", 15 | "Topic :: Software Development :: Libraries", 16 | ] 17 | packages = [{include = "roborock"}] 18 | keywords = ["roborock", "vacuum", "homeassistant"] 19 | 20 | [tool.poetry.scripts] 21 | roborock = "roborock.cli:main" 22 | 23 | [tool.poetry.dependencies] 24 | python = "^3.11" 25 | click = ">=8" 26 | aiohttp = "^3.8.2" 27 | async-timeout = "*" 28 | pycryptodome = "^3.18" 29 | pycryptodomex = {version = "^3.18", markers = "sys_platform == 'darwin'"} 30 | paho-mqtt = ">=1.6.1,<3.0.0" 31 | construct = "^2.10.57" 32 | vacuum-map-parser-roborock = "*" 33 | pyrate-limiter = "^3.7.0" 34 | aiomqtt = "^2.3.2" 35 | 36 | 37 | [build-system] 38 | requires = ["poetry-core==1.8.0"] 39 | build-backend = "poetry.core.masonry.api" 40 | 41 | [tool.poetry.group.dev.dependencies] 42 | pytest-asyncio = "*" 43 | pytest = "*" 44 | pre-commit = ">=3.5,<5.0" 45 | mypy = "*" 46 | ruff = "*" 47 | codespell = "*" 48 | pyshark = "^0.6" 49 | aioresponses = "^0.7.7" 50 | freezegun = "^1.5.1" 51 | pytest-timeout = "^2.3.1" 52 | 53 | [tool.semantic_release] 54 | branch = "main" 55 | version_toml = ["pyproject.toml:tool.poetry.version"] 56 | build_command = "pip install poetry && poetry build" 57 | [tool.semantic_release.commit_parser_options] 58 | allowed_tags = [ 59 | "chore", 60 | "docs", 61 | "feat", 62 | "fix", 63 | "refactor" 64 | ] 65 | major_tags= ["refactor"] 66 | 67 | [tool.ruff] 68 | ignore = ["F403", "E741"] 69 | line-length = 120 70 | select=["E", "F", "UP", "I"] 71 | 72 | [tool.ruff.lint.per-file-ignores] 73 | "*/__init__.py" = ["F401"] 74 | 75 | [tool.pytest.ini_options] 76 | asyncio_mode = "auto" 77 | asyncio_default_fixture_loop_scope = "function" 78 | timeout = 20 79 | -------------------------------------------------------------------------------- /roborock/__init__.py: -------------------------------------------------------------------------------- 1 | """Roborock API.""" 2 | 3 | from roborock.code_mappings import * 4 | from roborock.containers import * 5 | from roborock.exceptions import * 6 | from roborock.roborock_typing import * 7 | -------------------------------------------------------------------------------- /roborock/api.py: -------------------------------------------------------------------------------- 1 | """The Roborock api.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import base64 7 | import logging 8 | import secrets 9 | import time 10 | from abc import ABC, abstractmethod 11 | from typing import Any 12 | 13 | from .containers import ( 14 | DeviceData, 15 | ) 16 | from .exceptions import ( 17 | RoborockTimeout, 18 | UnknownMethodError, 19 | ) 20 | from .roborock_future import RoborockFuture 21 | from .roborock_message import ( 22 | RoborockMessage, 23 | RoborockMessageProtocol, 24 | ) 25 | from .util import get_next_int 26 | 27 | _LOGGER = logging.getLogger(__name__) 28 | KEEPALIVE = 60 29 | 30 | 31 | class RoborockClient(ABC): 32 | """Roborock client base class.""" 33 | 34 | _logger: logging.LoggerAdapter 35 | queue_timeout: int 36 | 37 | def __init__(self, device_info: DeviceData) -> None: 38 | """Initialize RoborockClient.""" 39 | self.device_info = device_info 40 | self._nonce = secrets.token_bytes(16) 41 | self._waiting_queue: dict[int, RoborockFuture] = {} 42 | self._last_device_msg_in = time.monotonic() 43 | self._last_disconnection = time.monotonic() 44 | self.keep_alive = KEEPALIVE 45 | self._diagnostic_data: dict[str, dict[str, Any]] = { 46 | "misc_info": {"Nonce": base64.b64encode(self._nonce).decode("utf-8")} 47 | } 48 | self.is_available: bool = True 49 | 50 | async def async_release(self) -> None: 51 | await self.async_disconnect() 52 | 53 | @property 54 | def diagnostic_data(self) -> dict: 55 | return self._diagnostic_data 56 | 57 | @abstractmethod 58 | async def async_connect(self): 59 | """Connect to the Roborock device.""" 60 | 61 | @abstractmethod 62 | async def async_disconnect(self) -> Any: 63 | """Disconnect from the Roborock device.""" 64 | 65 | @abstractmethod 66 | def is_connected(self) -> bool: 67 | """Return True if the client is connected to the device.""" 68 | 69 | @abstractmethod 70 | def on_message_received(self, messages: list[RoborockMessage]) -> None: 71 | """Handle received incoming messages from the device.""" 72 | 73 | def on_connection_lost(self, exc: Exception | None) -> None: 74 | self._last_disconnection = time.monotonic() 75 | self._logger.info("Roborock client disconnected") 76 | if exc is not None: 77 | self._logger.warning(exc) 78 | 79 | def should_keepalive(self) -> bool: 80 | now = time.monotonic() 81 | # noinspection PyUnresolvedReferences 82 | if now - self._last_disconnection > self.keep_alive**2 and now - self._last_device_msg_in > self.keep_alive: 83 | return False 84 | return True 85 | 86 | async def validate_connection(self) -> None: 87 | if not self.should_keepalive(): 88 | self._logger.info("Resetting Roborock connection due to kepalive timeout") 89 | await self.async_disconnect() 90 | await self.async_connect() 91 | 92 | async def _wait_response(self, request_id: int, queue: RoborockFuture) -> Any: 93 | try: 94 | response = await queue.async_get(self.queue_timeout) 95 | if response == "unknown_method": 96 | raise UnknownMethodError("Unknown method") 97 | return response 98 | except (asyncio.TimeoutError, asyncio.CancelledError): 99 | raise RoborockTimeout(f"id={request_id} Timeout after {self.queue_timeout} seconds") from None 100 | finally: 101 | self._waiting_queue.pop(request_id, None) 102 | 103 | def _async_response(self, request_id: int, protocol_id: int = 0) -> Any: 104 | queue = RoborockFuture(protocol_id) 105 | if request_id in self._waiting_queue and not ( 106 | request_id == 2 and protocol_id == RoborockMessageProtocol.PING_REQUEST 107 | ): 108 | new_id = get_next_int(10000, 32767) 109 | self._logger.warning( 110 | "Attempting to create a future with an existing id %s (%s)... New id is %s. " 111 | "Code may not function properly.", 112 | request_id, 113 | protocol_id, 114 | new_id, 115 | ) 116 | request_id = new_id 117 | self._waiting_queue[request_id] = queue 118 | return asyncio.ensure_future(self._wait_response(request_id, queue)) 119 | 120 | @abstractmethod 121 | async def send_message(self, roborock_message: RoborockMessage): 122 | """Send a message to the Roborock device.""" 123 | -------------------------------------------------------------------------------- /roborock/cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import logging 5 | from pathlib import Path 6 | from typing import Any 7 | 8 | import click 9 | from pyshark import FileCapture # type: ignore 10 | from pyshark.capture.live_capture import LiveCapture, UnknownInterfaceException # type: ignore 11 | from pyshark.packet.packet import Packet # type: ignore 12 | 13 | from roborock import RoborockException 14 | from roborock.containers import DeviceData, HomeDataProduct, LoginData 15 | from roborock.protocol import MessageParser 16 | from roborock.util import run_sync 17 | from roborock.version_1_apis.roborock_local_client_v1 import RoborockLocalClientV1 18 | from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 19 | from roborock.web_api import RoborockApiClient 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | class RoborockContext: 25 | roborock_file = Path("~/.roborock").expanduser() 26 | _login_data: LoginData | None = None 27 | 28 | def __init__(self): 29 | self.reload() 30 | 31 | def reload(self): 32 | if self.roborock_file.is_file(): 33 | with open(self.roborock_file) as f: 34 | data = json.load(f) 35 | if data: 36 | self._login_data = LoginData.from_dict(data) 37 | 38 | def update(self, login_data: LoginData): 39 | data = json.dumps(login_data.as_dict(), default=vars) 40 | with open(self.roborock_file, "w") as f: 41 | f.write(data) 42 | self.reload() 43 | 44 | def validate(self): 45 | if self._login_data is None: 46 | raise RoborockException("You must login first") 47 | 48 | def login_data(self): 49 | self.validate() 50 | return self._login_data 51 | 52 | 53 | @click.option("-d", "--debug", default=False, count=True) 54 | @click.version_option(package_name="python-roborock") 55 | @click.group() 56 | @click.pass_context 57 | def cli(ctx, debug: int): 58 | logging_config: dict[str, Any] = {"level": logging.DEBUG if debug > 0 else logging.INFO} 59 | logging.basicConfig(**logging_config) # type: ignore 60 | ctx.obj = RoborockContext() 61 | 62 | 63 | @click.command() 64 | @click.option("--email", required=True) 65 | @click.option("--password", required=True) 66 | @click.pass_context 67 | @run_sync() 68 | async def login(ctx, email, password): 69 | """Login to Roborock account.""" 70 | context: RoborockContext = ctx.obj 71 | try: 72 | context.validate() 73 | _LOGGER.info("Already logged in") 74 | return 75 | except RoborockException: 76 | pass 77 | client = RoborockApiClient(email) 78 | user_data = await client.pass_login(password) 79 | context.update(LoginData(user_data=user_data, email=email)) 80 | 81 | 82 | async def _discover(ctx): 83 | context: RoborockContext = ctx.obj 84 | login_data = context.login_data() 85 | if not login_data: 86 | raise Exception("You need to login first") 87 | client = RoborockApiClient(login_data.email) 88 | home_data = await client.get_home_data(login_data.user_data) 89 | login_data.home_data = home_data 90 | context.update(login_data) 91 | click.echo(f"Discovered devices {', '.join([device.name for device in home_data.get_all_devices()])}") 92 | 93 | 94 | @click.command() 95 | @click.pass_context 96 | @run_sync() 97 | async def discover(ctx): 98 | await _discover(ctx) 99 | 100 | 101 | @click.command() 102 | @click.pass_context 103 | @run_sync() 104 | async def list_devices(ctx): 105 | context: RoborockContext = ctx.obj 106 | login_data = context.login_data() 107 | if not login_data.home_data: 108 | await _discover(ctx) 109 | login_data = context.login_data() 110 | home_data = login_data.home_data 111 | device_name_id = ", ".join( 112 | [f"{device.name}: {device.duid}" for device in home_data.devices + home_data.received_devices] 113 | ) 114 | click.echo(f"Known devices {device_name_id}") 115 | 116 | 117 | @click.command() 118 | @click.option("--device_id", required=True) 119 | @click.pass_context 120 | @run_sync() 121 | async def list_scenes(ctx, device_id): 122 | context: RoborockContext = ctx.obj 123 | login_data = context.login_data() 124 | if not login_data.home_data: 125 | await _discover(ctx) 126 | login_data = context.login_data() 127 | client = RoborockApiClient(login_data.email) 128 | scenes = await client.get_scenes(login_data.user_data, device_id) 129 | output_list = [] 130 | for scene in scenes: 131 | output_list.append(scene.as_dict()) 132 | click.echo(json.dumps(output_list, indent=4)) 133 | 134 | 135 | @click.command() 136 | @click.option("--scene_id", required=True) 137 | @click.pass_context 138 | @run_sync() 139 | async def execute_scene(ctx, scene_id): 140 | context: RoborockContext = ctx.obj 141 | login_data = context.login_data() 142 | if not login_data.home_data: 143 | await _discover(ctx) 144 | login_data = context.login_data() 145 | client = RoborockApiClient(login_data.email) 146 | await client.execute_scene(login_data.user_data, scene_id) 147 | 148 | 149 | @click.command() 150 | @click.option("--device_id", required=True) 151 | @click.pass_context 152 | @run_sync() 153 | async def status(ctx, device_id): 154 | context: RoborockContext = ctx.obj 155 | login_data = context.login_data() 156 | if not login_data.home_data: 157 | await _discover(ctx) 158 | login_data = context.login_data() 159 | home_data = login_data.home_data 160 | devices = home_data.devices + home_data.received_devices 161 | device = next(device for device in devices if device.duid == device_id) 162 | product_info: dict[str, HomeDataProduct] = {product.id: product for product in home_data.products} 163 | device_data = DeviceData(device, product_info[device.product_id].model) 164 | mqtt_client = RoborockMqttClientV1(login_data.user_data, device_data) 165 | networking = await mqtt_client.get_networking() 166 | local_device_data = DeviceData(device, product_info[device.product_id].model, networking.ip) 167 | local_client = RoborockLocalClientV1(local_device_data) 168 | status = await local_client.get_status() 169 | click.echo(json.dumps(status.as_dict(), indent=4)) 170 | 171 | 172 | @click.command() 173 | @click.option("--device_id", required=True) 174 | @click.option("--cmd", required=True) 175 | @click.option("--params", required=False) 176 | @click.pass_context 177 | @run_sync() 178 | async def command(ctx, cmd, device_id, params): 179 | context: RoborockContext = ctx.obj 180 | login_data = context.login_data() 181 | if not login_data.home_data: 182 | await _discover(ctx) 183 | login_data = context.login_data() 184 | home_data = login_data.home_data 185 | devices = home_data.devices + home_data.received_devices 186 | device = next(device for device in devices if device.duid == device_id) 187 | model = next( 188 | (product.model for product in home_data.products if device is not None and product.id == device.product_id), 189 | None, 190 | ) 191 | if model is None: 192 | raise RoborockException(f"Could not find model for device {device.name}") 193 | device_info = DeviceData(device=device, model=model) 194 | mqtt_client = RoborockMqttClientV1(login_data.user_data, device_info) 195 | await mqtt_client.send_command(cmd, json.loads(params) if params is not None else None) 196 | await mqtt_client.async_release() 197 | 198 | 199 | @click.command() 200 | @click.option("--local_key", required=True) 201 | @click.option("--device_ip", required=True) 202 | @click.option("--file", required=False) 203 | @click.pass_context 204 | @run_sync() 205 | async def parser(_, local_key, device_ip, file): 206 | file_provided = file is not None 207 | if file_provided: 208 | capture = FileCapture(file) 209 | else: 210 | _LOGGER.info("Listen for interface rvi0 since no file was provided") 211 | capture = LiveCapture(interface="rvi0") 212 | buffer = {"data": b""} 213 | 214 | def on_package(packet: Packet): 215 | if hasattr(packet, "ip"): 216 | if packet.transport_layer == "TCP" and (packet.ip.dst == device_ip or packet.ip.src == device_ip): 217 | if hasattr(packet, "DATA"): 218 | if hasattr(packet.DATA, "data"): 219 | if packet.ip.dst == device_ip: 220 | try: 221 | f, buffer["data"] = MessageParser.parse( 222 | buffer["data"] + bytes.fromhex(packet.DATA.data), 223 | local_key, 224 | ) 225 | print(f"Received request: {f}") 226 | except BaseException as e: 227 | print(e) 228 | pass 229 | elif packet.ip.src == device_ip: 230 | try: 231 | f, buffer["data"] = MessageParser.parse( 232 | buffer["data"] + bytes.fromhex(packet.DATA.data), 233 | local_key, 234 | ) 235 | print(f"Received response: {f}") 236 | except BaseException as e: 237 | print(e) 238 | pass 239 | 240 | try: 241 | await capture.packets_from_tshark(on_package, close_tshark=not file_provided) 242 | except UnknownInterfaceException: 243 | raise RoborockException( 244 | "You need to run 'rvictl -s XXXXXXXX-XXXXXXXXXXXXXXXX' first, with an iPhone connected to usb port" 245 | ) 246 | 247 | 248 | cli.add_command(login) 249 | cli.add_command(discover) 250 | cli.add_command(list_devices) 251 | cli.add_command(list_scenes) 252 | cli.add_command(execute_scene) 253 | cli.add_command(status) 254 | cli.add_command(command) 255 | cli.add_command(parser) 256 | 257 | 258 | def main(): 259 | return cli() 260 | 261 | 262 | if __name__ == "__main__": 263 | main() 264 | -------------------------------------------------------------------------------- /roborock/cloud_api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | import threading 6 | from abc import ABC 7 | from asyncio import Lock 8 | from typing import Any 9 | from urllib.parse import urlparse 10 | 11 | import paho.mqtt.client as mqtt 12 | 13 | from .api import KEEPALIVE, RoborockClient 14 | from .containers import DeviceData, UserData 15 | from .exceptions import RoborockException, VacuumError 16 | from .protocol import MessageParser, md5hex 17 | from .roborock_future import RoborockFuture 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | CONNECT_REQUEST_ID = 0 21 | DISCONNECT_REQUEST_ID = 1 22 | 23 | 24 | class _Mqtt(mqtt.Client): 25 | """Internal MQTT client. 26 | 27 | This is a subclass of the Paho MQTT client that adds some additional functionality 28 | for error cases where things get stuck. 29 | """ 30 | 31 | _thread: threading.Thread 32 | 33 | def __init__(self) -> None: 34 | """Initialize the MQTT client.""" 35 | super().__init__(protocol=mqtt.MQTTv5) 36 | 37 | def maybe_restart_loop(self) -> None: 38 | """Ensure that the MQTT loop is running in case it previously exited.""" 39 | if not self._thread or not self._thread.is_alive(): 40 | if self._thread: 41 | _LOGGER.info("Stopping mqtt loop") 42 | super().loop_stop() 43 | _LOGGER.info("Starting mqtt loop") 44 | super().loop_start() 45 | 46 | 47 | class RoborockMqttClient(RoborockClient, ABC): 48 | """Roborock MQTT client base class.""" 49 | 50 | def __init__(self, user_data: UserData, device_info: DeviceData) -> None: 51 | """Initialize the Roborock MQTT client.""" 52 | rriot = user_data.rriot 53 | if rriot is None: 54 | raise RoborockException("Got no rriot data from user_data") 55 | RoborockClient.__init__(self, device_info) 56 | self._mqtt_user = rriot.u 57 | self._hashed_user = md5hex(self._mqtt_user + ":" + rriot.k)[2:10] 58 | url = urlparse(rriot.r.m) 59 | if not isinstance(url.hostname, str): 60 | raise RoborockException("Url parsing returned an invalid hostname") 61 | self._mqtt_host = str(url.hostname) 62 | self._mqtt_port = url.port 63 | self._mqtt_ssl = url.scheme == "ssl" 64 | 65 | self._mqtt_client = _Mqtt() 66 | self._mqtt_client.on_connect = self._mqtt_on_connect 67 | self._mqtt_client.on_message = self._mqtt_on_message 68 | self._mqtt_client.on_disconnect = self._mqtt_on_disconnect 69 | if self._mqtt_ssl: 70 | self._mqtt_client.tls_set() 71 | 72 | self._mqtt_password = rriot.s 73 | self._hashed_password = md5hex(self._mqtt_password + ":" + rriot.k)[16:] 74 | self._mqtt_client.username_pw_set(self._hashed_user, self._hashed_password) 75 | self._waiting_queue: dict[int, RoborockFuture] = {} 76 | self._mutex = Lock() 77 | 78 | def _mqtt_on_connect(self, *args, **kwargs): 79 | _, __, ___, rc, ____ = args 80 | connection_queue = self._waiting_queue.get(CONNECT_REQUEST_ID) 81 | if rc != mqtt.MQTT_ERR_SUCCESS: 82 | message = f"Failed to connect ({mqtt.error_string(rc)})" 83 | self._logger.error(message) 84 | if connection_queue: 85 | connection_queue.set_exception(VacuumError(message)) 86 | else: 87 | self._logger.debug("Failed to notify connect future, not in queue") 88 | return 89 | self._logger.info(f"Connected to mqtt {self._mqtt_host}:{self._mqtt_port}") 90 | topic = f"rr/m/o/{self._mqtt_user}/{self._hashed_user}/{self.device_info.device.duid}" 91 | (result, mid) = self._mqtt_client.subscribe(topic) 92 | if result != 0: 93 | message = f"Failed to subscribe ({mqtt.error_string(rc)})" 94 | self._logger.error(message) 95 | if connection_queue: 96 | connection_queue.set_exception(VacuumError(message)) 97 | return 98 | self._logger.info(f"Subscribed to topic {topic}") 99 | if connection_queue: 100 | connection_queue.set_result(True) 101 | 102 | def _mqtt_on_message(self, *args, **kwargs): 103 | client, __, msg = args 104 | try: 105 | messages, _ = MessageParser.parse(msg.payload, local_key=self.device_info.device.local_key) 106 | super().on_message_received(messages) 107 | except Exception as ex: 108 | self._logger.exception(ex) 109 | 110 | def _mqtt_on_disconnect(self, *args, **kwargs): 111 | _, __, rc, ___ = args 112 | try: 113 | exc = RoborockException(mqtt.error_string(rc)) if rc != mqtt.MQTT_ERR_SUCCESS else None 114 | super().on_connection_lost(exc) 115 | connection_queue = self._waiting_queue.get(DISCONNECT_REQUEST_ID) 116 | if connection_queue: 117 | connection_queue.set_result(True) 118 | except Exception as ex: 119 | self._logger.exception(ex) 120 | 121 | def is_connected(self) -> bool: 122 | """Check if the mqtt client is connected.""" 123 | return self._mqtt_client.is_connected() 124 | 125 | def _sync_disconnect(self) -> Any: 126 | if not self.is_connected(): 127 | return None 128 | 129 | self._logger.info("Disconnecting from mqtt") 130 | disconnected_future = self._async_response(DISCONNECT_REQUEST_ID) 131 | rc = self._mqtt_client.disconnect() 132 | 133 | if rc == mqtt.MQTT_ERR_NO_CONN: 134 | disconnected_future.cancel() 135 | return None 136 | 137 | if rc != mqtt.MQTT_ERR_SUCCESS: 138 | disconnected_future.cancel() 139 | raise RoborockException(f"Failed to disconnect ({mqtt.error_string(rc)})") 140 | 141 | return disconnected_future 142 | 143 | def _sync_connect(self) -> Any: 144 | if self.is_connected(): 145 | self._mqtt_client.maybe_restart_loop() 146 | return None 147 | 148 | if self._mqtt_port is None or self._mqtt_host is None: 149 | raise RoborockException("Mqtt information was not entered. Cannot connect.") 150 | 151 | self._logger.debug("Connecting to mqtt") 152 | connected_future = self._async_response(CONNECT_REQUEST_ID) 153 | self._mqtt_client.connect(host=self._mqtt_host, port=self._mqtt_port, keepalive=KEEPALIVE) 154 | self._mqtt_client.maybe_restart_loop() 155 | return connected_future 156 | 157 | async def async_disconnect(self) -> None: 158 | async with self._mutex: 159 | if disconnected_future := self._sync_disconnect(): 160 | # There are no errors set on this future 161 | await disconnected_future 162 | loop = asyncio.get_running_loop() 163 | await loop.run_in_executor(None, self._mqtt_client.loop_stop) 164 | 165 | async def async_connect(self) -> None: 166 | async with self._mutex: 167 | if connected_future := self._sync_connect(): 168 | try: 169 | await connected_future 170 | except VacuumError as err: 171 | raise RoborockException(err) from err 172 | 173 | def _send_msg_raw(self, msg: bytes) -> None: 174 | info = self._mqtt_client.publish( 175 | f"rr/m/i/{self._mqtt_user}/{self._hashed_user}/{self.device_info.device.duid}", msg 176 | ) 177 | if info.rc != mqtt.MQTT_ERR_SUCCESS: 178 | raise RoborockException(f"Failed to publish ({mqtt.error_string(info.rc)})") 179 | -------------------------------------------------------------------------------- /roborock/code_mappings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from enum import Enum, IntEnum 5 | 6 | _LOGGER = logging.getLogger(__name__) 7 | completed_warnings = set() 8 | 9 | 10 | class RoborockEnum(IntEnum): 11 | """Roborock Enum for codes with int values""" 12 | 13 | @property 14 | def name(self) -> str: 15 | return super().name.lower() 16 | 17 | @classmethod 18 | def _missing_(cls: type[RoborockEnum], key) -> RoborockEnum: 19 | if hasattr(cls, "unknown"): 20 | warning = f"Missing {cls.__name__} code: {key} - defaulting to 'unknown'" 21 | if warning not in completed_warnings: 22 | completed_warnings.add(warning) 23 | _LOGGER.warning(warning) 24 | return cls.unknown # type: ignore 25 | default_value = next(item for item in cls) 26 | warning = f"Missing {cls.__name__} code: {key} - defaulting to {default_value}" 27 | if warning not in completed_warnings: 28 | completed_warnings.add(warning) 29 | _LOGGER.warning(warning) 30 | return default_value 31 | 32 | @classmethod 33 | def as_dict(cls: type[RoborockEnum]): 34 | return {i.name: i.value for i in cls if i.name != "missing"} 35 | 36 | @classmethod 37 | def as_enum_dict(cls: type[RoborockEnum]): 38 | return {i.value: i for i in cls if i.name != "missing"} 39 | 40 | @classmethod 41 | def values(cls: type[RoborockEnum]) -> list[int]: 42 | return list(cls.as_dict().values()) 43 | 44 | @classmethod 45 | def keys(cls: type[RoborockEnum]) -> list[str]: 46 | return list(cls.as_dict().keys()) 47 | 48 | @classmethod 49 | def items(cls: type[RoborockEnum]): 50 | return cls.as_dict().items() 51 | 52 | 53 | class RoborockStateCode(RoborockEnum): 54 | unknown = 0 55 | starting = 1 56 | charger_disconnected = 2 57 | idle = 3 58 | remote_control_active = 4 59 | cleaning = 5 60 | returning_home = 6 61 | manual_mode = 7 62 | charging = 8 63 | charging_problem = 9 64 | paused = 10 65 | spot_cleaning = 11 66 | error = 12 67 | shutting_down = 13 68 | updating = 14 69 | docking = 15 70 | going_to_target = 16 71 | zoned_cleaning = 17 72 | segment_cleaning = 18 73 | emptying_the_bin = 22 # on s7+ 74 | washing_the_mop = 23 # on a46 75 | washing_the_mop_2 = 25 76 | going_to_wash_the_mop = 26 # on a46 77 | in_call = 28 78 | mapping = 29 79 | egg_attack = 30 80 | patrol = 32 81 | attaching_the_mop = 33 # on g20s ultra 82 | detaching_the_mop = 34 # on g20s ultra 83 | charging_complete = 100 84 | device_offline = 101 85 | locked = 103 86 | air_drying_stopping = 202 87 | robot_status_mopping = 6301 88 | clean_mop_cleaning = 6302 89 | clean_mop_mopping = 6303 90 | segment_mopping = 6304 91 | segment_clean_mop_cleaning = 6305 92 | segment_clean_mop_mopping = 6306 93 | zoned_mopping = 6307 94 | zoned_clean_mop_cleaning = 6308 95 | zoned_clean_mop_mopping = 6309 96 | back_to_dock_washing_duster = 6310 97 | 98 | 99 | class RoborockDyadStateCode(RoborockEnum): 100 | unknown = -999 101 | fetching = -998 # Obtaining Status 102 | fetch_failed = -997 # Failed to obtain device status. Try again later. 103 | updating = -996 104 | washing = 1 105 | ready = 2 106 | charging = 3 107 | mop_washing = 4 108 | self_clean_cleaning = 5 109 | self_clean_deep_cleaning = 6 110 | self_clean_rinsing = 7 111 | self_clean_dehydrating = 8 112 | drying = 9 113 | ventilating = 10 # drying 114 | reserving = 12 115 | mop_washing_paused = 13 116 | dusting_mode = 14 117 | 118 | 119 | class RoborockErrorCode(RoborockEnum): 120 | none = 0 121 | lidar_blocked = 1 122 | bumper_stuck = 2 123 | wheels_suspended = 3 124 | cliff_sensor_error = 4 125 | main_brush_jammed = 5 126 | side_brush_jammed = 6 127 | wheels_jammed = 7 128 | robot_trapped = 8 129 | no_dustbin = 9 130 | strainer_error = 10 # Filter is wet or blocked 131 | compass_error = 11 # Strong magnetic field detected 132 | low_battery = 12 133 | charging_error = 13 134 | battery_error = 14 135 | wall_sensor_dirty = 15 136 | robot_tilted = 16 137 | side_brush_error = 17 138 | fan_error = 18 139 | dock = 19 # Dock not connected to power 140 | optical_flow_sensor_dirt = 20 141 | vertical_bumper_pressed = 21 142 | dock_locator_error = 22 143 | return_to_dock_fail = 23 144 | nogo_zone_detected = 24 145 | visual_sensor = 25 # Camera error 146 | light_touch = 26 # Wall sensor error 147 | vibrarise_jammed = 27 148 | robot_on_carpet = 28 149 | filter_blocked = 29 150 | invisible_wall_detected = 30 151 | cannot_cross_carpet = 31 152 | internal_error = 32 153 | collect_dust_error_3 = 34 # Clean auto-empty dock 154 | collect_dust_error_4 = 35 # Auto empty dock voltage error 155 | mopping_roller_1 = 36 # Wash roller may be jammed 156 | mopping_roller_error_2 = 37 # wash roller not lowered properly 157 | clear_water_box_hoare = 38 # Check the clean water tank 158 | dirty_water_box_hoare = 39 # Check the dirty water tank 159 | sink_strainer_hoare = 40 # Reinstall the water filter 160 | clear_water_box_exception = 41 # Clean water tank empty 161 | clear_brush_exception = 42 # Check that the water filter has been correctly installed 162 | clear_brush_exception_2 = 43 # Positioning button error 163 | filter_screen_exception = 44 # Clean the dock water filter 164 | mopping_roller_2 = 45 # Wash roller may be jammed 165 | up_water_exception = 48 166 | drain_water_exception = 49 167 | temperature_protection = 51 # Unit temperature protection 168 | clean_carousel_exception = 52 169 | clean_carousel_water_full = 53 170 | water_carriage_drop = 54 171 | check_clean_carouse = 55 172 | audio_error = 56 173 | 174 | 175 | class RoborockFanPowerCode(RoborockEnum): 176 | """Describes the fan power of the vacuum cleaner.""" 177 | 178 | # Fan speeds should have the first letter capitalized - as there is no way to change the name in translations as 179 | # far as I am aware 180 | 181 | 182 | class RoborockFanSpeedV1(RoborockFanPowerCode): 183 | silent = 38 184 | standard = 60 185 | medium = 77 186 | turbo = 90 187 | 188 | 189 | class RoborockFanSpeedV2(RoborockFanPowerCode): 190 | silent = 101 191 | balanced = 102 192 | turbo = 103 193 | max = 104 194 | gentle = 105 195 | auto = 106 196 | 197 | 198 | class RoborockFanSpeedV3(RoborockFanPowerCode): 199 | silent = 38 200 | standard = 60 201 | medium = 75 202 | turbo = 100 203 | 204 | 205 | class RoborockFanSpeedE2(RoborockFanPowerCode): 206 | gentle = 41 207 | silent = 50 208 | standard = 68 209 | medium = 79 210 | turbo = 100 211 | 212 | 213 | class RoborockFanSpeedS7(RoborockFanPowerCode): 214 | off = 105 215 | quiet = 101 216 | balanced = 102 217 | turbo = 103 218 | max = 104 219 | custom = 106 220 | 221 | 222 | class RoborockFanSpeedS7MaxV(RoborockFanPowerCode): 223 | off = 105 224 | quiet = 101 225 | balanced = 102 226 | turbo = 103 227 | max = 104 228 | custom = 106 229 | max_plus = 108 230 | 231 | 232 | class RoborockFanSpeedS6Pure(RoborockFanPowerCode): 233 | gentle = 105 234 | quiet = 101 235 | balanced = 102 236 | turbo = 103 237 | max = 104 238 | custom = 106 239 | 240 | 241 | class RoborockFanSpeedQ7Max(RoborockFanPowerCode): 242 | quiet = 101 243 | balanced = 102 244 | turbo = 103 245 | max = 104 246 | 247 | 248 | class RoborockFanSpeedQRevoMaster(RoborockFanPowerCode): 249 | off = 105 250 | quiet = 101 251 | balanced = 102 252 | turbo = 103 253 | max = 104 254 | custom = 106 255 | max_plus = 108 256 | smart_mode = 110 257 | 258 | 259 | class RoborockFanSpeedQRevoCurv(RoborockFanPowerCode): 260 | quiet = 101 261 | balanced = 102 262 | turbo = 103 263 | max = 104 264 | off = 105 265 | custom = 106 266 | max_plus = 108 267 | smart_mode = 110 268 | 269 | 270 | class RoborockFanSpeedP10(RoborockFanPowerCode): 271 | off = 105 272 | quiet = 101 273 | balanced = 102 274 | turbo = 103 275 | max = 104 276 | custom = 106 277 | max_plus = 108 278 | 279 | 280 | class RoborockFanSpeedS8MaxVUltra(RoborockFanPowerCode): 281 | off = 105 282 | quiet = 101 283 | balanced = 102 284 | turbo = 103 285 | max = 104 286 | custom = 106 287 | max_plus = 108 288 | smart_mode = 110 289 | 290 | 291 | class RoborockMopModeCode(RoborockEnum): 292 | """Describes the mop mode of the vacuum cleaner.""" 293 | 294 | 295 | class RoborockMopModeQRevoCurv(RoborockMopModeCode): 296 | standard = 300 297 | deep = 301 298 | custom = 302 299 | deep_plus = 303 300 | fast = 304 301 | smart_mode = 306 302 | 303 | 304 | class RoborockMopModeS7(RoborockMopModeCode): 305 | """Describes the mop mode of the vacuum cleaner.""" 306 | 307 | standard = 300 308 | deep = 301 309 | custom = 302 310 | deep_plus = 303 311 | 312 | 313 | class RoborockMopModeS8ProUltra(RoborockMopModeCode): 314 | standard = 300 315 | deep = 301 316 | deep_plus = 303 317 | fast = 304 318 | custom = 302 319 | 320 | 321 | class RoborockMopModeS8MaxVUltra(RoborockMopModeCode): 322 | standard = 300 323 | deep = 301 324 | custom = 302 325 | deep_plus = 303 326 | fast = 304 327 | deep_plus_pearl = 305 328 | smart_mode = 306 329 | 330 | 331 | class RoborockMopModeQRevoMaster(RoborockMopModeCode): 332 | standard = 300 333 | deep = 301 334 | custom = 302 335 | deep_plus = 303 336 | fast = 304 337 | smart_mode = 306 338 | 339 | 340 | class RoborockMopIntensityCode(RoborockEnum): 341 | """Describes the mop intensity of the vacuum cleaner.""" 342 | 343 | 344 | class RoborockMopIntensityS7(RoborockMopIntensityCode): 345 | """Describes the mop intensity of the vacuum cleaner.""" 346 | 347 | off = 200 348 | mild = 201 349 | moderate = 202 350 | intense = 203 351 | custom = 204 352 | 353 | 354 | class RoborockMopIntensityV2(RoborockMopIntensityCode): 355 | """Describes the mop intensity of the vacuum cleaner.""" 356 | 357 | off = 200 358 | low = 201 359 | medium = 202 360 | high = 203 361 | custom = 207 362 | 363 | 364 | class RoborockMopIntensityQRevoMaster(RoborockMopIntensityCode): 365 | """Describes the mop intensity of the vacuum cleaner.""" 366 | 367 | off = 200 368 | low = 201 369 | medium = 202 370 | high = 203 371 | custom = 204 372 | custom_water_flow = 207 373 | smart_mode = 209 374 | 375 | 376 | class RoborockMopIntensityQRevoCurv(RoborockMopIntensityCode): 377 | off = 200 378 | low = 201 379 | medium = 202 380 | high = 203 381 | custom = 204 382 | custom_water_flow = 207 383 | smart_mode = 209 384 | 385 | 386 | class RoborockMopIntensityP10(RoborockMopIntensityCode): 387 | """Describes the mop intensity of the vacuum cleaner.""" 388 | 389 | off = 200 390 | low = 201 391 | medium = 202 392 | high = 203 393 | custom = 204 394 | custom_water_flow = 207 395 | 396 | 397 | class RoborockMopIntensityS8MaxVUltra(RoborockMopIntensityCode): 398 | off = 200 399 | low = 201 400 | medium = 202 401 | high = 203 402 | custom = 204 403 | max = 208 404 | smart_mode = 209 405 | custom_water_flow = 207 406 | 407 | 408 | class RoborockMopIntensityS5Max(RoborockMopIntensityCode): 409 | """Describes the mop intensity of the vacuum cleaner.""" 410 | 411 | off = 200 412 | low = 201 413 | medium = 202 414 | high = 203 415 | custom = 204 416 | custom_water_flow = 207 417 | 418 | 419 | class RoborockMopIntensityS6MaxV(RoborockMopIntensityCode): 420 | """Describes the mop intensity of the vacuum cleaner.""" 421 | 422 | off = 200 423 | low = 201 424 | medium = 202 425 | high = 203 426 | custom = 204 427 | custom_water_flow = 207 428 | 429 | 430 | class RoborockMopIntensityQ7Max(RoborockMopIntensityCode): 431 | """Describes the mop intensity of the vacuum cleaner.""" 432 | 433 | off = 200 434 | low = 201 435 | medium = 202 436 | high = 203 437 | custom_water_flow = 207 438 | 439 | 440 | class RoborockDockErrorCode(RoborockEnum): 441 | """Describes the error code of the dock.""" 442 | 443 | ok = 0 444 | duct_blockage = 34 445 | water_empty = 38 446 | waste_water_tank_full = 39 447 | maintenance_brush_jammed = 42 448 | dirty_tank_latch_open = 44 449 | no_dustbin = 46 450 | cleaning_tank_full_or_blocked = 53 451 | 452 | 453 | class RoborockDockTypeCode(RoborockEnum): 454 | unknown = -9999 455 | no_dock = 0 456 | auto_empty_dock = 1 457 | empty_wash_fill_dock = 3 458 | auto_empty_dock_pure = 5 459 | s7_max_ultra_dock = 6 460 | s8_dock = 7 461 | p10_dock = 8 462 | p10_pro_dock = 9 463 | s8_maxv_ultra_dock = 10 464 | qrevo_master_dock = 14 465 | qrevo_s_dock = 15 466 | saros_r10_dock = 16 467 | qrevo_curv_dock = 17 468 | saros_10_dock = 18 469 | 470 | 471 | class RoborockDockDustCollectionModeCode(RoborockEnum): 472 | """Describes the dust collection mode of the vacuum cleaner.""" 473 | 474 | # TODO: Get the correct values for various different docks 475 | unknown = -9999 476 | smart = 0 477 | light = 1 478 | balanced = 2 479 | max = 4 480 | 481 | 482 | class RoborockDockWashTowelModeCode(RoborockEnum): 483 | """Describes the wash towel mode of the vacuum cleaner.""" 484 | 485 | # TODO: Get the correct values for various different docks 486 | unknown = -9999 487 | light = 0 488 | balanced = 1 489 | deep = 2 490 | smart = 10 491 | 492 | 493 | class RoborockCategory(Enum): 494 | """Describes the category of the device.""" 495 | 496 | WET_DRY_VAC = "roborock.wetdryvac" 497 | VACUUM = "robot.vacuum.cleaner" 498 | WASHING_MACHINE = "roborock.wm" 499 | UNKNOWN = "UNKNOWN" 500 | 501 | def __missing__(self, key): 502 | _LOGGER.warning("Missing key %s from category", key) 503 | return RoborockCategory.UNKNOWN 504 | 505 | 506 | class RoborockFinishReason(RoborockEnum): 507 | manual_interrupt = 21 # Cleaning interrupted by user 508 | cleanup_interrupted = 24 # Cleanup interrupted 509 | manual_interrupt_2 = 21 510 | breakpoint = 32 # Could not continue cleaning 511 | breakpoint_2 = 33 512 | cleanup_interrupted_2 = 34 513 | manual_interrupt_3 = 35 514 | manual_interrupt_4 = 36 515 | manual_interrupt_5 = 37 516 | manual_interrupt_6 = 43 517 | locate_fail = 45 # Positioning Failed 518 | cleanup_interrupted_3 = 64 519 | locate_fail_2 = 65 520 | manual_interrupt_7 = 48 521 | manual_interrupt_8 = 49 522 | manual_interrupt_9 = 50 523 | cleanup_interrupted_4 = 51 524 | finished_cleaning = 52 # Finished cleaning 525 | finished_cleaning_2 = 54 526 | finished_cleaning_3 = 55 527 | finished_cleaning_4 = 56 528 | finished_clenaing_5 = 57 529 | manual_interrupt_10 = 60 530 | area_unreachable = 61 # Area unreachable 531 | area_unreachable_2 = 62 532 | washing_error = 67 # Washing error 533 | back_to_wash_failure = 68 # Failed to return to the dock 534 | cleanup_interrupted_5 = 101 535 | breakpoint_4 = 102 536 | manual_interrupt_11 = 103 537 | cleanup_interrupted_6 = 104 538 | cleanup_interrupted_7 = 105 539 | cleanup_interrupted_8 = 106 540 | cleanup_interrupted_9 = 107 541 | cleanup_interrupted_10 = 109 542 | cleanup_interrupted_11 = 110 543 | patrol_success = 114 # Cruise completed 544 | patrol_fail = 115 # Cruise failed 545 | pet_patrol_success = 116 # Pet found 546 | pet_patrol_fail = 117 # Pet found failed 547 | 548 | 549 | class RoborockInCleaning(RoborockEnum): 550 | complete = 0 551 | global_clean_not_complete = 1 552 | zone_clean_not_complete = 2 553 | segment_clean_not_complete = 3 554 | 555 | 556 | class RoborockCleanType(RoborockEnum): 557 | all_zone = 1 558 | draw_zone = 2 559 | select_zone = 3 560 | quick_build = 4 561 | video_patrol = 5 562 | pet_patrol = 6 563 | 564 | 565 | class RoborockStartType(RoborockEnum): 566 | button = 1 567 | app = 2 568 | schedule = 3 569 | mi_home = 4 570 | quick_start = 5 571 | voice_control = 13 572 | routines = 101 573 | alexa = 801 574 | google = 802 575 | ifttt = 803 576 | yandex = 804 577 | homekit = 805 578 | xiaoai = 806 579 | tmall_genie = 807 580 | duer = 808 581 | dingdong = 809 582 | siri = 810 583 | clova = 811 584 | wechat = 901 585 | alipay = 902 586 | aqara = 903 587 | hisense = 904 588 | huawei = 905 589 | widget_launch = 820 590 | smart_watch = 821 591 | 592 | 593 | class DyadSelfCleanMode(RoborockEnum): 594 | self_clean = 1 595 | self_clean_and_dry = 2 596 | dry = 3 597 | ventilation = 4 598 | 599 | 600 | class DyadSelfCleanLevel(RoborockEnum): 601 | normal = 1 602 | deep = 2 603 | 604 | 605 | class DyadWarmLevel(RoborockEnum): 606 | normal = 1 607 | deep = 2 608 | 609 | 610 | class DyadMode(RoborockEnum): 611 | wash = 1 612 | wash_and_dry = 2 613 | dry = 3 614 | 615 | 616 | class DyadCleanMode(RoborockEnum): 617 | auto = 1 618 | max = 2 619 | dehydration = 3 620 | power_saving = 4 621 | 622 | 623 | class DyadSuction(RoborockEnum): 624 | l1 = 1 625 | l2 = 2 626 | l3 = 3 627 | l4 = 4 628 | l5 = 5 629 | l6 = 6 630 | 631 | 632 | class DyadWaterLevel(RoborockEnum): 633 | l1 = 1 634 | l2 = 2 635 | l3 = 3 636 | l4 = 4 637 | 638 | 639 | class DyadBrushSpeed(RoborockEnum): 640 | l1 = 1 641 | l2 = 2 642 | 643 | 644 | class DyadCleanser(RoborockEnum): 645 | none = 0 646 | normal = 1 647 | deep = 2 648 | max = 3 649 | 650 | 651 | class DyadError(RoborockEnum): 652 | none = 0 653 | dirty_tank_full = 20000 # Dirty tank full. Empty it 654 | water_level_sensor_stuck = 20001 # Water level sensor is stuck. Clean it. 655 | clean_tank_empty = 20002 # Clean tank empty. Refill now 656 | clean_head_entangled = 20003 # Check if the cleaning head is entangled with foreign objects. 657 | clean_head_too_hot = 20004 # Cleaning head temperature protection. Wait for the temperature to return to normal. 658 | fan_protection_e5 = 10005 # Fan protection (E5). Restart the vacuum cleaner. 659 | cleaning_head_blocked = 20005 # Remove blockages from the cleaning head and pipes. 660 | temperature_protection = 20006 # Temperature protection. Wait for the temperature to return to normal 661 | fan_protection_e4 = 10004 # Fan protection (E4). Restart the vacuum cleaner. 662 | fan_protection_e9 = 10009 # Fan protection (E9). Restart the vacuum cleaner. 663 | battery_temperature_protection_e0 = 10000 664 | battery_temperature_protection = ( 665 | 20007 # Battery temperature protection. Wait for the temperature to return to a normal range. 666 | ) 667 | battery_temperature_protection_2 = 20008 668 | power_adapter_error = 20009 # Check if the power adapter is working properly. 669 | dirty_charging_contacts = 10007 # Disconnection between the device and dock. Wipe charging contacts. 670 | low_battery = 20017 # Low battery level. Charge before starting self-cleaning. 671 | battery_under_10 = 20018 # Charge until the battery level exceeds 10% before manually starting self-cleaning. 672 | 673 | 674 | class ZeoMode(RoborockEnum): 675 | wash = 1 676 | wash_and_dry = 2 677 | dry = 3 678 | 679 | 680 | class ZeoState(RoborockEnum): 681 | standby = 1 682 | weighing = 2 683 | soaking = 3 684 | washing = 4 685 | rinsing = 5 686 | spinning = 6 687 | drying = 7 688 | cooling = 8 689 | under_delay_start = 9 690 | done = 10 691 | 692 | 693 | class ZeoProgram(RoborockEnum): 694 | standard = 1 695 | quick = 2 696 | sanitize = 3 697 | wool = 4 698 | air_refresh = 5 699 | custom = 6 700 | bedding = 7 701 | down = 8 702 | silk = 9 703 | rinse_and_spin = 10 704 | spin = 11 705 | down_clean = 12 706 | baby_care = 13 707 | anti_allergen = 14 708 | sportswear = 15 709 | night = 16 710 | new_clothes = 17 711 | shirts = 18 712 | synthetics = 19 713 | underwear = 20 714 | gentle = 21 715 | intensive = 22 716 | cotton_linen = 23 717 | season = 24 718 | warming = 25 719 | bra = 26 720 | panties = 27 721 | boiling_wash = 28 722 | socks = 30 723 | towels = 31 724 | anti_mite = 32 725 | exo_40_60 = 33 726 | twenty_c = 34 727 | t_shirts = 35 728 | stain_removal = 36 729 | 730 | 731 | class ZeoSoak(RoborockEnum): 732 | normal = 0 733 | low = 1 734 | medium = 2 735 | high = 3 736 | max = 4 737 | 738 | 739 | class ZeoTemperature(RoborockEnum): 740 | normal = 1 741 | low = 2 742 | medium = 3 743 | high = 4 744 | max = 5 745 | twenty_c = 6 746 | 747 | 748 | class ZeoRinse(RoborockEnum): 749 | none = 0 750 | min = 1 751 | low = 2 752 | mid = 3 753 | high = 4 754 | max = 5 755 | 756 | 757 | class ZeoSpin(RoborockEnum): 758 | none = 1 759 | very_low = 2 760 | low = 3 761 | mid = 4 762 | high = 5 763 | very_high = 6 764 | max = 7 765 | 766 | 767 | class ZeoDryingMode(RoborockEnum): 768 | none = 0 769 | quick = 1 770 | iron = 2 771 | store = 3 772 | 773 | 774 | class ZeoDetergentType(RoborockEnum): 775 | empty = 0 776 | low = 1 777 | medium = 2 778 | high = 3 779 | 780 | 781 | class ZeoSoftenerType(RoborockEnum): 782 | empty = 0 783 | low = 1 784 | medium = 2 785 | high = 3 786 | 787 | 788 | class ZeoError(RoborockEnum): 789 | none = 0 790 | refill_error = 1 791 | drain_error = 2 792 | door_lock_error = 3 793 | water_level_error = 4 794 | inverter_error = 5 795 | heating_error = 6 796 | temperature_error = 7 797 | communication_error = 10 798 | drying_error = 11 799 | drying_error_e_12 = 12 800 | drying_error_e_13 = 13 801 | drying_error_e_14 = 14 802 | drying_error_e_15 = 15 803 | drying_error_e_16 = 16 804 | drying_error_water_flow = 17 # Check for normal water flow 805 | drying_error_restart = 18 # Restart the washer and try again 806 | spin_error = 19 # re-arrange clothes 807 | -------------------------------------------------------------------------------- /roborock/command_cache.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Mapping 4 | from dataclasses import dataclass, field 5 | from enum import Enum 6 | 7 | from roborock import RoborockCommand 8 | 9 | GET_PREFIX = "get_" 10 | SET_PREFIX = ("set_", "change_", "close_") 11 | 12 | 13 | class CacheableAttribute(str, Enum): 14 | status = "status" 15 | consumable = "consumable" 16 | sound_volume = "sound_volume" 17 | camera_status = "camera_status" 18 | carpet_clean_mode = "carpet_clean_mode" 19 | carpet_mode = "carpet_mode" 20 | child_lock_status = "child_lock_status" 21 | collision_avoid_status = "collision_avoid_status" 22 | customize_clean_mode = "customize_clean_mode" 23 | custom_mode = "custom_mode" 24 | dnd_timer = "dnd_timer" 25 | dust_collection_mode = "dust_collection_mode" 26 | flow_led_status = "flow_led_status" 27 | identify_furniture_status = "identify_furniture_status" 28 | identify_ground_material_status = "identify_ground_material_status" 29 | led_status = "led_status" 30 | server_timer = "server_timer" 31 | smart_wash_params = "smart_wash_params" 32 | timezone = "timezone" 33 | valley_electricity_timer = "valley_electricity_timer" 34 | wash_towel_mode = "wash_towel_mode" 35 | 36 | 37 | @dataclass 38 | class RoborockAttribute: 39 | attribute: str 40 | get_command: RoborockCommand 41 | add_command: RoborockCommand | None = None 42 | set_command: RoborockCommand | None = None 43 | close_command: RoborockCommand | None = None 44 | additional_change_commands: list[RoborockCommand] = field(default_factory=list) 45 | 46 | 47 | cache_map: Mapping[CacheableAttribute, RoborockAttribute] = { 48 | CacheableAttribute.status: RoborockAttribute( 49 | attribute="status", 50 | get_command=RoborockCommand.GET_STATUS, 51 | additional_change_commands=[ 52 | RoborockCommand.SET_WATER_BOX_CUSTOM_MODE, 53 | RoborockCommand.SET_MOP_MODE, 54 | ], 55 | ), 56 | CacheableAttribute.consumable: RoborockAttribute( 57 | attribute="consumable", 58 | get_command=RoborockCommand.GET_CONSUMABLE, 59 | ), 60 | CacheableAttribute.sound_volume: RoborockAttribute( 61 | attribute="sound_volume", 62 | get_command=RoborockCommand.GET_SOUND_VOLUME, 63 | set_command=RoborockCommand.CHANGE_SOUND_VOLUME, 64 | ), 65 | CacheableAttribute.camera_status: RoborockAttribute( 66 | attribute="camera_status", 67 | get_command=RoborockCommand.GET_CAMERA_STATUS, 68 | set_command=RoborockCommand.SET_CAMERA_STATUS, 69 | ), 70 | CacheableAttribute.carpet_clean_mode: RoborockAttribute( 71 | attribute="carpet_clean_mode", 72 | get_command=RoborockCommand.GET_CARPET_CLEAN_MODE, 73 | set_command=RoborockCommand.SET_CARPET_CLEAN_MODE, 74 | ), 75 | CacheableAttribute.carpet_mode: RoborockAttribute( 76 | attribute="carpet_mode", 77 | get_command=RoborockCommand.GET_CARPET_MODE, 78 | set_command=RoborockCommand.SET_CARPET_MODE, 79 | ), 80 | CacheableAttribute.child_lock_status: RoborockAttribute( 81 | attribute="child_lock_status", 82 | get_command=RoborockCommand.GET_CHILD_LOCK_STATUS, 83 | set_command=RoborockCommand.SET_CHILD_LOCK_STATUS, 84 | ), 85 | CacheableAttribute.collision_avoid_status: RoborockAttribute( 86 | attribute="collision_avoid_status", 87 | get_command=RoborockCommand.GET_COLLISION_AVOID_STATUS, 88 | set_command=RoborockCommand.SET_COLLISION_AVOID_STATUS, 89 | ), 90 | CacheableAttribute.customize_clean_mode: RoborockAttribute( 91 | attribute="customize_clean_mode", 92 | get_command=RoborockCommand.GET_CUSTOMIZE_CLEAN_MODE, 93 | set_command=RoborockCommand.SET_CUSTOMIZE_CLEAN_MODE, 94 | ), 95 | CacheableAttribute.custom_mode: RoborockAttribute( 96 | attribute="custom_mode", 97 | get_command=RoborockCommand.GET_CUSTOM_MODE, 98 | set_command=RoborockCommand.SET_CUSTOM_MODE, 99 | ), 100 | CacheableAttribute.dnd_timer: RoborockAttribute( 101 | attribute="dnd_timer", 102 | get_command=RoborockCommand.GET_DND_TIMER, 103 | set_command=RoborockCommand.SET_DND_TIMER, 104 | close_command=RoborockCommand.CLOSE_DND_TIMER, 105 | ), 106 | CacheableAttribute.dust_collection_mode: RoborockAttribute( 107 | attribute="dust_collection_mode", 108 | get_command=RoborockCommand.GET_DUST_COLLECTION_MODE, 109 | set_command=RoborockCommand.SET_DUST_COLLECTION_MODE, 110 | ), 111 | CacheableAttribute.flow_led_status: RoborockAttribute( 112 | attribute="flow_led_status", 113 | get_command=RoborockCommand.GET_FLOW_LED_STATUS, 114 | set_command=RoborockCommand.SET_FLOW_LED_STATUS, 115 | ), 116 | CacheableAttribute.identify_furniture_status: RoborockAttribute( 117 | attribute="identify_furniture_status", 118 | get_command=RoborockCommand.GET_IDENTIFY_FURNITURE_STATUS, 119 | set_command=RoborockCommand.SET_IDENTIFY_FURNITURE_STATUS, 120 | ), 121 | CacheableAttribute.identify_ground_material_status: RoborockAttribute( 122 | attribute="identify_ground_material_status", 123 | get_command=RoborockCommand.GET_IDENTIFY_GROUND_MATERIAL_STATUS, 124 | set_command=RoborockCommand.SET_IDENTIFY_GROUND_MATERIAL_STATUS, 125 | ), 126 | CacheableAttribute.led_status: RoborockAttribute( 127 | attribute="led_status", 128 | get_command=RoborockCommand.GET_LED_STATUS, 129 | set_command=RoborockCommand.SET_LED_STATUS, 130 | ), 131 | CacheableAttribute.server_timer: RoborockAttribute( 132 | attribute="server_timer", 133 | get_command=RoborockCommand.GET_SERVER_TIMER, 134 | add_command=RoborockCommand.SET_SERVER_TIMER, 135 | set_command=RoborockCommand.UPD_SERVER_TIMER, 136 | close_command=RoborockCommand.DEL_SERVER_TIMER, 137 | ), 138 | CacheableAttribute.smart_wash_params: RoborockAttribute( 139 | attribute="smart_wash_params", 140 | get_command=RoborockCommand.GET_SMART_WASH_PARAMS, 141 | set_command=RoborockCommand.SET_SMART_WASH_PARAMS, 142 | ), 143 | CacheableAttribute.timezone: RoborockAttribute( 144 | attribute="timezone", get_command=RoborockCommand.GET_TIMEZONE, set_command=RoborockCommand.SET_TIMEZONE 145 | ), 146 | CacheableAttribute.valley_electricity_timer: RoborockAttribute( 147 | attribute="valley_electricity_timer", 148 | get_command=RoborockCommand.GET_VALLEY_ELECTRICITY_TIMER, 149 | set_command=RoborockCommand.SET_VALLEY_ELECTRICITY_TIMER, 150 | close_command=RoborockCommand.CLOSE_VALLEY_ELECTRICITY_TIMER, 151 | ), 152 | CacheableAttribute.wash_towel_mode: RoborockAttribute( 153 | attribute="wash_towel_mode", 154 | get_command=RoborockCommand.GET_WASH_TOWEL_MODE, 155 | set_command=RoborockCommand.SET_WASH_TOWEL_MODE, 156 | ), 157 | } 158 | 159 | 160 | def get_change_commands(attr: RoborockAttribute) -> list[RoborockCommand]: 161 | commands = [ 162 | attr.add_command, 163 | attr.set_command, 164 | attr.close_command, 165 | *attr.additional_change_commands, 166 | ] 167 | 168 | return [command for command in commands if command is not None] 169 | 170 | 171 | cache_map_by_get_command: dict[RoborockCommand | str, CacheableAttribute] = { 172 | attribute.get_command: cacheable_attribute for cacheable_attribute, attribute in cache_map.items() 173 | } 174 | 175 | cache_map_by_change_command: dict[RoborockCommand | str, CacheableAttribute] = { 176 | command: cacheable_attribute 177 | for cacheable_attribute, attribute in cache_map.items() 178 | for command in get_change_commands(attribute) 179 | } 180 | 181 | 182 | def get_cache_map(): 183 | return cache_map 184 | 185 | 186 | class CommandType(Enum): 187 | OTHER = -1 188 | GET = 0 189 | CHANGE = 1 190 | 191 | 192 | @dataclass 193 | class CacheableAttributeResult: 194 | attribute: CacheableAttribute 195 | type: CommandType 196 | 197 | 198 | def find_cacheable_attribute(method: RoborockCommand | str) -> CacheableAttributeResult | None: 199 | if method is None: 200 | return None 201 | 202 | cacheable_attribute = None 203 | command_type = CommandType.OTHER 204 | if cacheable_attribute := cache_map_by_get_command.get(method, None): 205 | command_type = CommandType.GET 206 | elif cacheable_attribute := cache_map_by_change_command.get(method, None): 207 | command_type = CommandType.CHANGE 208 | 209 | if cacheable_attribute: 210 | return CacheableAttributeResult(attribute=CacheableAttribute(cacheable_attribute), type=command_type) 211 | else: 212 | return None 213 | -------------------------------------------------------------------------------- /roborock/const.py: -------------------------------------------------------------------------------- 1 | # Total time in seconds consumables have before Roborock recommends replacing 2 | MAIN_BRUSH_REPLACE_TIME = 1080000 3 | SIDE_BRUSH_REPLACE_TIME = 720000 4 | FILTER_REPLACE_TIME = 540000 5 | SENSOR_DIRTY_REPLACE_TIME = 108000 6 | MOP_ROLLER_REPLACE_TIME = 1080000 7 | STRAINER_REPLACE_TIME = 540000 8 | CLEANING_BRUSH_REPLACE_TIME = 1080000 9 | DUST_COLLECTION_REPLACE_TIME = 81000 10 | FLOOR_CLEANER_REPLACE_TIME = 1080000 11 | 12 | 13 | ROBOROCK_V1 = "ROBOROCK.vacuum.v1" 14 | ROBOROCK_S4 = "roborock.vacuum.s4" 15 | ROBOROCK_S4_MAX = "roborock.vacuum.a19" 16 | ROBOROCK_S5 = "roborock.vacuum.s5" 17 | ROBOROCK_S5_MAX = "roborock.vacuum.s5e" 18 | ROBOROCK_S6 = "roborock.vacuum.s6" 19 | ROBOROCK_T6 = "roborock.vacuum.t6" # cn s6 20 | ROBOROCK_E4 = "roborock.vacuum.a01" 21 | ROBOROCK_S6_PURE = "roborock.vacuum.a08" 22 | ROBOROCK_T7 = "roborock.vacuum.a11" # cn s7 23 | ROBOROCK_T7S = "roborock.vacuum.a14" 24 | ROBOROCK_T7SPLUS = "roborock.vacuum.a23" 25 | ROBOROCK_S7_MAXV = "roborock.vacuum.a27" 26 | ROBOROCK_S7_MAXV_ULTRA = "roborock.vacuum.a65" 27 | ROBOROCK_S7_PRO_ULTRA = "roborock.vacuum.a62" 28 | ROBOROCK_Q5 = "roborock.vacuum.a34" 29 | ROBOROCK_Q5_PRO = "roborock.vacuum.a72" 30 | ROBOROCK_Q7 = "roborock.vacuum.a40" 31 | ROBOROCK_Q7_MAX = "roborock.vacuum.a38" 32 | ROBOROCK_Q7PLUS = "roborock.vacuum.a40" 33 | ROBOROCK_QREVO_MASTER = "roborock.vacuum.a117" 34 | ROBOROCK_QREVO_CURV = "roborock.vacuum.a135" 35 | ROBOROCK_Q8_MAX = "roborock.vacuum.a73" 36 | ROBOROCK_G10S_PRO = "roborock.vacuum.a26" 37 | ROBOROCK_G20S_Ultra = "roborock.vacuum.a143" # cn saros_r10 38 | ROBOROCK_G10S = "roborock.vacuum.a46" 39 | ROBOROCK_G10 = "roborock.vacuum.a29" 40 | ROCKROBO_G10_SG = "roborock.vacuum.a30" # Variant of the G10, has similar features as S7 41 | ROBOROCK_S7 = "roborock.vacuum.a15" 42 | ROBOROCK_S6_MAXV = "roborock.vacuum.a10" 43 | ROBOROCK_E2 = "roborock.vacuum.e2" 44 | ROBOROCK_1S = "roborock.vacuum.m1s" 45 | ROBOROCK_C1 = "roborock.vacuum.c1" 46 | ROBOROCK_S8_PRO_ULTRA = "roborock.vacuum.a70" 47 | ROBOROCK_S8 = "roborock.vacuum.a51" 48 | ROBOROCK_P10 = "roborock.vacuum.a75" # also known as q_revo 49 | ROBOROCK_S8_MAXV_ULTRA = "roborock.vacuum.a97" 50 | ROBOROCK_QREVO_S = "roborock.vacuum.a104" 51 | ROBOROCK_QREVO_PRO = "roborock.vacuum.a101" 52 | ROBOROCK_QREVO_MAXV = "roborock.vacuum.a87" 53 | 54 | ROBOROCK_DYAD_AIR = "roborock.wetdryvac.a107" 55 | ROBOROCK_DYAD_PRO_COMBO = "roborock.wetdryvac.a83" 56 | ROBOROCK_DYAD_PRO = "roborock.wetdryvac.a56" 57 | 58 | # These are the devices that show up when you add a device - more could be supported and just not show up 59 | SUPPORTED_VACUUMS = [ 60 | ROBOROCK_G10, 61 | ROBOROCK_G10S_PRO, 62 | ROBOROCK_G20S_Ultra, 63 | ROBOROCK_Q5, 64 | ROBOROCK_Q7, 65 | ROBOROCK_Q7_MAX, 66 | ROBOROCK_S4, 67 | ROBOROCK_S5_MAX, 68 | ROBOROCK_S6, 69 | ROBOROCK_S6_MAXV, 70 | ROBOROCK_S6_PURE, 71 | ROBOROCK_S7_MAXV, 72 | ROBOROCK_S8_PRO_ULTRA, 73 | ROBOROCK_S8, 74 | ROBOROCK_S4_MAX, 75 | ROBOROCK_S7, 76 | ROBOROCK_P10, 77 | ROCKROBO_G10_SG, 78 | ] 79 | -------------------------------------------------------------------------------- /roborock/exceptions.py: -------------------------------------------------------------------------------- 1 | """Roborock exceptions.""" 2 | from __future__ import annotations 3 | 4 | 5 | class RoborockException(Exception): 6 | """Class for Roborock exceptions.""" 7 | 8 | 9 | class RoborockTimeout(RoborockException): 10 | """Class for Roborock timeout exceptions.""" 11 | 12 | 13 | class RoborockConnectionException(RoborockException): 14 | """Class for Roborock connection exceptions.""" 15 | 16 | 17 | class RoborockBackoffException(RoborockException): 18 | """Class for Roborock exceptions when many retries were made.""" 19 | 20 | 21 | class VacuumError(RoborockException): 22 | """Class for vacuum errors.""" 23 | 24 | 25 | class CommandVacuumError(RoborockException): 26 | """Class for command vacuum errors.""" 27 | 28 | def __init__(self, command: str | None, vacuum_error: VacuumError): 29 | self.message = f"{command or 'unknown'}: {str(vacuum_error)}" 30 | super().__init__(self.message) 31 | 32 | 33 | class UnknownMethodError(RoborockException): 34 | """Class for an invalid method being sent.""" 35 | 36 | 37 | class RoborockAccountDoesNotExist(RoborockException): 38 | """Class for Roborock account does not exist exceptions.""" 39 | 40 | 41 | class RoborockUrlException(RoborockException): 42 | """Class for being unable to get the URL for the Roborock account.""" 43 | 44 | 45 | class RoborockInvalidCode(RoborockException): 46 | """Class for Roborock invalid code exceptions.""" 47 | 48 | 49 | class RoborockInvalidEmail(RoborockException): 50 | """Class for Roborock invalid formatted email exceptions.""" 51 | 52 | 53 | class RoborockInvalidUserAgreement(RoborockException): 54 | """Class for Roborock invalid user agreement exceptions.""" 55 | 56 | 57 | class RoborockNoUserAgreement(RoborockException): 58 | """Class for Roborock no user agreement exceptions.""" 59 | 60 | 61 | class RoborockInvalidCredentials(RoborockException): 62 | """Class for Roborock credentials have expired or changed.""" 63 | 64 | 65 | class RoborockTooFrequentCodeRequests(RoborockException): 66 | """Class for Roborock too frequent code requests exceptions.""" 67 | 68 | 69 | class RoborockMissingParameters(RoborockException): 70 | """Class for Roborock missing parameters exceptions.""" 71 | 72 | 73 | class RoborockTooManyRequest(RoborockException): 74 | """Class for Roborock too many request exceptions.""" 75 | 76 | 77 | class RoborockRateLimit(RoborockException): 78 | """Class for our rate limits exceptions.""" 79 | -------------------------------------------------------------------------------- /roborock/local_api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | from abc import ABC 6 | from asyncio import Lock, TimerHandle, Transport, get_running_loop 7 | from collections.abc import Callable 8 | from dataclasses import dataclass 9 | 10 | import async_timeout 11 | 12 | from . import DeviceData 13 | from .api import RoborockClient 14 | from .exceptions import RoborockConnectionException, RoborockException 15 | from .protocol import MessageParser 16 | from .roborock_message import RoborockMessage, RoborockMessageProtocol 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | 21 | @dataclass 22 | class _LocalProtocol(asyncio.Protocol): 23 | """Callbacks for the Roborock local client transport.""" 24 | 25 | messages_cb: Callable[[bytes], None] 26 | connection_lost_cb: Callable[[Exception | None], None] 27 | 28 | def data_received(self, bytes) -> None: 29 | """Called when data is received from the transport.""" 30 | self.messages_cb(bytes) 31 | 32 | def connection_lost(self, exc: Exception | None) -> None: 33 | """Called when the transport connection is lost.""" 34 | self.connection_lost_cb(exc) 35 | 36 | 37 | class RoborockLocalClient(RoborockClient, ABC): 38 | """Roborock local client base class.""" 39 | 40 | def __init__(self, device_data: DeviceData): 41 | """Initialize the Roborock local client.""" 42 | if device_data.host is None: 43 | raise RoborockException("Host is required") 44 | self.host = device_data.host 45 | self._batch_structs: list[RoborockMessage] = [] 46 | self._executing = False 47 | self.remaining = b"" 48 | self.transport: Transport | None = None 49 | self._mutex = Lock() 50 | self.keep_alive_task: TimerHandle | None = None 51 | RoborockClient.__init__(self, device_data) 52 | self._local_protocol = _LocalProtocol(self._data_received, self._connection_lost) 53 | 54 | def _data_received(self, message): 55 | """Called when data is received from the transport.""" 56 | if self.remaining: 57 | message = self.remaining + message 58 | self.remaining = b"" 59 | parser_msg, self.remaining = MessageParser.parse(message, local_key=self.device_info.device.local_key) 60 | self.on_message_received(parser_msg) 61 | 62 | def _connection_lost(self, exc: Exception | None): 63 | """Called when the transport connection is lost.""" 64 | self._sync_disconnect() 65 | self.on_connection_lost(exc) 66 | 67 | def is_connected(self): 68 | return self.transport and self.transport.is_reading() 69 | 70 | async def keep_alive_func(self, _=None): 71 | try: 72 | await self.ping() 73 | except RoborockException: 74 | pass 75 | loop = asyncio.get_running_loop() 76 | self.keep_alive_task = loop.call_later(10, lambda: asyncio.create_task(self.keep_alive_func())) 77 | 78 | async def async_connect(self) -> None: 79 | should_ping = False 80 | async with self._mutex: 81 | try: 82 | if not self.is_connected(): 83 | self._sync_disconnect() 84 | async with async_timeout.timeout(self.queue_timeout): 85 | self._logger.debug(f"Connecting to {self.host}") 86 | loop = get_running_loop() 87 | self.transport, _ = await loop.create_connection( # type: ignore 88 | lambda: self._local_protocol, self.host, 58867 89 | ) 90 | self._logger.info(f"Connected to {self.host}") 91 | should_ping = True 92 | except BaseException as e: 93 | raise RoborockConnectionException(f"Failed connecting to {self.host}") from e 94 | if should_ping: 95 | await self.hello() 96 | await self.keep_alive_func() 97 | 98 | def _sync_disconnect(self) -> None: 99 | loop = asyncio.get_running_loop() 100 | if self.transport and loop.is_running(): 101 | self._logger.debug(f"Disconnecting from {self.host}") 102 | self.transport.close() 103 | if self.keep_alive_task: 104 | self.keep_alive_task.cancel() 105 | 106 | async def async_disconnect(self) -> None: 107 | async with self._mutex: 108 | self._sync_disconnect() 109 | 110 | async def hello(self): 111 | request_id = 1 112 | protocol = RoborockMessageProtocol.HELLO_REQUEST 113 | try: 114 | return await self.send_message( 115 | RoborockMessage( 116 | protocol=protocol, 117 | seq=request_id, 118 | random=22, 119 | ) 120 | ) 121 | except Exception as e: 122 | self._logger.error(e) 123 | 124 | async def ping(self) -> None: 125 | request_id = 2 126 | protocol = RoborockMessageProtocol.PING_REQUEST 127 | return await self.send_message( 128 | RoborockMessage( 129 | protocol=protocol, 130 | seq=request_id, 131 | random=23, 132 | ) 133 | ) 134 | 135 | def _send_msg_raw(self, data: bytes): 136 | try: 137 | if not self.transport: 138 | raise RoborockException("Can not send message without connection") 139 | self.transport.write(data) 140 | except Exception as e: 141 | raise RoborockException(e) from e 142 | -------------------------------------------------------------------------------- /roborock/mqtt/__init__.py: -------------------------------------------------------------------------------- 1 | """This module contains the low level MQTT client for the Roborock vacuum cleaner. 2 | 3 | This is not meant to be used directly, but rather as a base for the higher level 4 | modules. 5 | """ 6 | 7 | __all__: list[str] = [] 8 | -------------------------------------------------------------------------------- /roborock/mqtt/roborock_session.py: -------------------------------------------------------------------------------- 1 | """An MQTT session for sending and receiving messages. 2 | 3 | See create_mqtt_session for a factory function to create an MQTT session. 4 | 5 | This is a thin wrapper around the async MQTT client that handles dispatching messages 6 | from a topic to a callback function, since the async MQTT client does not 7 | support this out of the box. It also handles the authentication process and 8 | receiving messages from the vacuum cleaner. 9 | """ 10 | 11 | import asyncio 12 | import datetime 13 | import logging 14 | from collections.abc import Callable 15 | from contextlib import asynccontextmanager 16 | 17 | import aiomqtt 18 | from aiomqtt import MqttError, TLSParameters 19 | 20 | from .session import MqttParams, MqttSession, MqttSessionException 21 | 22 | _LOGGER = logging.getLogger(__name__) 23 | _MQTT_LOGGER = logging.getLogger(f"{__name__}.aiomqtt") 24 | 25 | KEEPALIVE = 60 26 | 27 | # Exponential backoff parameters 28 | MIN_BACKOFF_INTERVAL = datetime.timedelta(seconds=10) 29 | MAX_BACKOFF_INTERVAL = datetime.timedelta(minutes=30) 30 | BACKOFF_MULTIPLIER = 1.5 31 | 32 | 33 | class RoborockMqttSession(MqttSession): 34 | """An MQTT session for sending and receiving messages. 35 | 36 | You can start a session invoking the start() method which will connect to 37 | the MQTT broker. A caller may subscribe to a topic, and the session keeps 38 | track of which callbacks to invoke for each topic. 39 | 40 | The client is run as a background task that will run until shutdown. Once 41 | connected, the client will wait for messages to be received in a loop. If 42 | the connection is lost, the client will be re-created and reconnected. There 43 | is backoff to avoid spamming the broker with connection attempts. The client 44 | will automatically re-establish any subscriptions when the connection is 45 | re-established. 46 | """ 47 | 48 | def __init__(self, params: MqttParams): 49 | self._params = params 50 | self._background_task: asyncio.Task[None] | None = None 51 | self._healthy = False 52 | self._backoff = MIN_BACKOFF_INTERVAL 53 | self._client: aiomqtt.Client | None = None 54 | self._client_lock = asyncio.Lock() 55 | self._listeners: dict[str, list[Callable[[bytes], None]]] = {} 56 | 57 | @property 58 | def connected(self) -> bool: 59 | """True if the session is connected to the broker.""" 60 | return self._healthy 61 | 62 | async def start(self) -> None: 63 | """Start the MQTT session. 64 | 65 | This has special behavior for the first connection attempt where any 66 | failures are raised immediately. This is to allow the caller to 67 | handle the failure and retry if desired itself. Once connected, 68 | the session will retry connecting in the background. 69 | """ 70 | start_future: asyncio.Future[None] = asyncio.Future() 71 | loop = asyncio.get_event_loop() 72 | self._background_task = loop.create_task(self._run_task(start_future)) 73 | try: 74 | await start_future 75 | except MqttError as err: 76 | raise MqttSessionException(f"Error starting MQTT session: {err}") from err 77 | except Exception as err: 78 | raise MqttSessionException(f"Unexpected error starting session: {err}") from err 79 | else: 80 | _LOGGER.debug("MQTT session started successfully") 81 | 82 | async def close(self) -> None: 83 | """Cancels the MQTT loop and shutdown the client library.""" 84 | if self._background_task: 85 | self._background_task.cancel() 86 | try: 87 | await self._background_task 88 | except asyncio.CancelledError: 89 | pass 90 | async with self._client_lock: 91 | if self._client: 92 | await self._client.close() 93 | 94 | self._healthy = False 95 | 96 | async def _run_task(self, start_future: asyncio.Future[None] | None) -> None: 97 | """Run the MQTT loop.""" 98 | _LOGGER.info("Starting MQTT session") 99 | while True: 100 | try: 101 | async with self._mqtt_client(self._params) as client: 102 | # Reset backoff once we've successfully connected 103 | self._backoff = MIN_BACKOFF_INTERVAL 104 | self._healthy = True 105 | if start_future: 106 | start_future.set_result(None) 107 | start_future = None 108 | 109 | await self._process_message_loop(client) 110 | 111 | except MqttError as err: 112 | if start_future: 113 | _LOGGER.info("MQTT error starting session: %s", err) 114 | start_future.set_exception(err) 115 | return 116 | _LOGGER.info("MQTT error: %s", err) 117 | except asyncio.CancelledError as err: 118 | if start_future: 119 | _LOGGER.debug("MQTT loop was cancelled") 120 | start_future.set_exception(err) 121 | _LOGGER.debug("MQTT loop was cancelled whiel starting") 122 | return 123 | # Catch exceptions to avoid crashing the loop 124 | # and to allow the loop to retry. 125 | except Exception as err: 126 | # This error is thrown when the MQTT loop is cancelled 127 | # and the generator is not stopped. 128 | if "generator didn't stop" in str(err): 129 | _LOGGER.debug("MQTT loop was cancelled") 130 | return 131 | if start_future: 132 | _LOGGER.error("Uncaught error starting MQTT session: %s", err) 133 | start_future.set_exception(err) 134 | return 135 | _LOGGER.error("Uncaught error during MQTT session: %s", err) 136 | 137 | self._healthy = False 138 | _LOGGER.info("MQTT session disconnected, retrying in %s seconds", self._backoff.total_seconds()) 139 | await asyncio.sleep(self._backoff.total_seconds()) 140 | self._backoff = min(self._backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF_INTERVAL) 141 | 142 | @asynccontextmanager 143 | async def _mqtt_client(self, params: MqttParams) -> aiomqtt.Client: 144 | """Connect to the MQTT broker and listen for messages.""" 145 | _LOGGER.debug("Connecting to %s:%s for %s", params.host, params.port, params.username) 146 | try: 147 | async with aiomqtt.Client( 148 | hostname=params.host, 149 | port=params.port, 150 | username=params.username, 151 | password=params.password, 152 | keepalive=KEEPALIVE, 153 | protocol=aiomqtt.ProtocolVersion.V5, 154 | tls_params=TLSParameters() if params.tls else None, 155 | timeout=params.timeout, 156 | logger=_MQTT_LOGGER, 157 | ) as client: 158 | _LOGGER.debug("Connected to MQTT broker") 159 | # Re-establish any existing subscriptions 160 | async with self._client_lock: 161 | self._client = client 162 | for topic in self._listeners: 163 | _LOGGER.debug("Re-establising subscription to topic %s", topic) 164 | # TODO: If this fails it will break the whole connection. Make 165 | # this retry again in the background with backoff. 166 | await client.subscribe(topic) 167 | 168 | yield client 169 | finally: 170 | async with self._client_lock: 171 | self._client = None 172 | 173 | async def _process_message_loop(self, client: aiomqtt.Client) -> None: 174 | _LOGGER.debug("client=%s", client) 175 | _LOGGER.debug("Processing MQTT messages: %s", client.messages) 176 | async for message in client.messages: 177 | _LOGGER.debug("Received message: %s", message) 178 | for listener in self._listeners.get(message.topic.value, []): 179 | try: 180 | listener(message.payload) 181 | except asyncio.CancelledError: 182 | raise 183 | except Exception as e: 184 | _LOGGER.error("Uncaught exception in subscriber callback: %s", e) 185 | 186 | async def subscribe(self, topic: str, callback: Callable[[bytes], None]) -> Callable[[], None]: 187 | """Subscribe to messages on the specified topic and invoke the callback for new messages. 188 | 189 | The callback will be called with the message payload as a bytes object. The callback 190 | should not block since it runs in the async loop. It should not raise any exceptions. 191 | 192 | The returned callable unsubscribes from the topic when called. 193 | """ 194 | _LOGGER.debug("Subscribing to topic %s", topic) 195 | if topic not in self._listeners: 196 | self._listeners[topic] = [] 197 | self._listeners[topic].append(callback) 198 | 199 | async with self._client_lock: 200 | if self._client: 201 | _LOGGER.debug("Establishing subscription to topic %s", topic) 202 | try: 203 | await self._client.subscribe(topic) 204 | except MqttError as err: 205 | raise MqttSessionException(f"Error subscribing to topic: {err}") from err 206 | else: 207 | _LOGGER.debug("Client not connected, will establish subscription later") 208 | 209 | return lambda: self._listeners[topic].remove(callback) 210 | 211 | async def publish(self, topic: str, message: bytes) -> None: 212 | """Publish a message on the topic.""" 213 | _LOGGER.debug("Sending message to topic %s: %s", topic, message) 214 | client: aiomqtt.Client 215 | async with self._client_lock: 216 | if self._client is None: 217 | raise MqttSessionException("Could not publish message, MQTT client not connected") 218 | client = self._client 219 | try: 220 | await client.publish(topic, message) 221 | except MqttError as err: 222 | raise MqttSessionException(f"Error publishing message: {err}") from err 223 | 224 | 225 | async def create_mqtt_session(params: MqttParams) -> MqttSession: 226 | """Create an MQTT session. 227 | 228 | This function is a factory for creating an MQTT session. This will 229 | raise an exception if initial attempt to connect fails. Once connected, 230 | the session will retry connecting on failure in the background. 231 | """ 232 | session = RoborockMqttSession(params) 233 | await session.start() 234 | return session 235 | -------------------------------------------------------------------------------- /roborock/mqtt/session.py: -------------------------------------------------------------------------------- 1 | """An MQTT session for sending and receiving messages.""" 2 | 3 | from abc import ABC, abstractmethod 4 | from collections.abc import Callable 5 | from dataclasses import dataclass 6 | 7 | from roborock.exceptions import RoborockException 8 | 9 | DEFAULT_TIMEOUT = 30.0 10 | 11 | 12 | @dataclass 13 | class MqttParams: 14 | """MQTT parameters for the connection.""" 15 | 16 | host: str 17 | """MQTT host to connect to.""" 18 | 19 | port: int 20 | """MQTT port to connect to.""" 21 | 22 | tls: bool 23 | """Use TLS for the connection.""" 24 | 25 | username: str 26 | """MQTT username to use for authentication.""" 27 | 28 | password: str 29 | """MQTT password to use for authentication.""" 30 | 31 | timeout: float = DEFAULT_TIMEOUT 32 | """Timeout for communications with the broker in seconds.""" 33 | 34 | 35 | class MqttSession(ABC): 36 | """An MQTT session for sending and receiving messages.""" 37 | 38 | @property 39 | @abstractmethod 40 | def connected(self) -> bool: 41 | """True if the session is connected to the broker.""" 42 | 43 | @abstractmethod 44 | async def subscribe(self, device_id: str, callback: Callable[[bytes], None]) -> Callable[[], None]: 45 | """Invoke the callback when messages are received on the topic. 46 | 47 | The returned callable unsubscribes from the topic when called. 48 | """ 49 | 50 | @abstractmethod 51 | async def publish(self, topic: str, message: bytes) -> None: 52 | """Publish a message on the specified topic. 53 | 54 | This will raise an exception if the message could not be sent. 55 | """ 56 | 57 | @abstractmethod 58 | async def close(self) -> None: 59 | """Cancels the mqtt loop""" 60 | 61 | 62 | class MqttSessionException(RoborockException): 63 | """ "Raised when there is an error communicating with MQTT.""" 64 | -------------------------------------------------------------------------------- /roborock/protocol.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import binascii 5 | import gzip 6 | import hashlib 7 | import json 8 | import logging 9 | from asyncio import BaseTransport, Lock 10 | from collections.abc import Callable 11 | 12 | from construct import ( # type: ignore 13 | Bytes, 14 | Checksum, 15 | ChecksumError, 16 | Construct, 17 | Container, 18 | GreedyBytes, 19 | GreedyRange, 20 | Int16ub, 21 | Int32ub, 22 | Optional, 23 | Peek, 24 | RawCopy, 25 | Struct, 26 | bytestringtype, 27 | stream_seek, 28 | stream_tell, 29 | ) 30 | from Crypto.Cipher import AES 31 | from Crypto.Util.Padding import pad, unpad 32 | 33 | from roborock import BroadcastMessage, RoborockException 34 | from roborock.roborock_message import RoborockMessage 35 | 36 | _LOGGER = logging.getLogger(__name__) 37 | SALT = b"TXdfu$jyZ#TZHsg4" 38 | A01_HASH = "726f626f726f636b2d67a6d6da" 39 | BROADCAST_TOKEN = b"qWKYcdQWrbm9hPqe" 40 | AP_CONFIG = 1 41 | SOCK_DISCOVERY = 2 42 | 43 | 44 | def md5hex(message: str) -> str: 45 | md5 = hashlib.md5() 46 | md5.update(message.encode()) 47 | return md5.hexdigest() 48 | 49 | 50 | class RoborockProtocol(asyncio.DatagramProtocol): 51 | def __init__(self, timeout: int = 5): 52 | self.timeout = timeout 53 | self.transport: BaseTransport | None = None 54 | self.devices_found: list[BroadcastMessage] = [] 55 | self._mutex = Lock() 56 | 57 | def __del__(self): 58 | self.close() 59 | 60 | def datagram_received(self, data, _): 61 | [broadcast_message], _ = BroadcastParser.parse(data) 62 | if broadcast_message.payload: 63 | parsed_message = BroadcastMessage.from_dict(json.loads(broadcast_message.payload)) 64 | _LOGGER.debug(f"Received broadcast: {parsed_message}") 65 | self.devices_found.append(parsed_message) 66 | 67 | async def discover(self): 68 | async with self._mutex: 69 | try: 70 | loop = asyncio.get_event_loop() 71 | self.transport, _ = await loop.create_datagram_endpoint(lambda: self, local_addr=("0.0.0.0", 58866)) 72 | await asyncio.sleep(self.timeout) 73 | return self.devices_found 74 | finally: 75 | self.close() 76 | self.devices_found = [] 77 | 78 | def close(self): 79 | self.transport.close() if self.transport else None 80 | 81 | 82 | class Utils: 83 | """Util class for protocol manipulation.""" 84 | 85 | @staticmethod 86 | def verify_token(token: bytes): 87 | """Checks if the given token is of correct type and length.""" 88 | if not isinstance(token, bytes): 89 | raise TypeError("Token must be bytes") 90 | if len(token) != 16: 91 | raise ValueError("Wrong token length") 92 | 93 | @staticmethod 94 | def ensure_bytes(msg: bytes | str) -> bytes: 95 | if isinstance(msg, str): 96 | return msg.encode() 97 | return msg 98 | 99 | @staticmethod 100 | def encode_timestamp(_timestamp: int) -> bytes: 101 | hex_value = f"{_timestamp:x}".zfill(8) 102 | return "".join(list(map(lambda idx: hex_value[idx], [5, 6, 3, 7, 1, 2, 0, 4]))).encode() 103 | 104 | @staticmethod 105 | def md5(data: bytes) -> bytes: 106 | """Calculates a md5 hashsum for the given bytes object.""" 107 | checksum = hashlib.md5() # nosec 108 | checksum.update(data) 109 | return checksum.digest() 110 | 111 | @staticmethod 112 | def encrypt_ecb(plaintext: bytes, token: bytes) -> bytes: 113 | """Encrypt plaintext with a given token using ecb mode. 114 | 115 | :param bytes plaintext: Plaintext (json) to encrypt 116 | :param bytes token: Token to use 117 | :return: Encrypted bytes 118 | """ 119 | if not isinstance(plaintext, bytes): 120 | raise TypeError("plaintext requires bytes") 121 | Utils.verify_token(token) 122 | cipher = AES.new(token, AES.MODE_ECB) 123 | if plaintext: 124 | plaintext = pad(plaintext, AES.block_size) 125 | return cipher.encrypt(plaintext) 126 | return plaintext 127 | 128 | @staticmethod 129 | def decrypt_ecb(ciphertext: bytes, token: bytes) -> bytes: 130 | """Decrypt ciphertext with a given token using ecb mode. 131 | 132 | :param bytes ciphertext: Ciphertext to decrypt 133 | :param bytes token: Token to use 134 | :return: Decrypted bytes object 135 | """ 136 | if not isinstance(ciphertext, bytes): 137 | raise TypeError("ciphertext requires bytes") 138 | if ciphertext: 139 | Utils.verify_token(token) 140 | 141 | aes_key = token 142 | decipher = AES.new(aes_key, AES.MODE_ECB) 143 | return unpad(decipher.decrypt(ciphertext), AES.block_size) 144 | return ciphertext 145 | 146 | @staticmethod 147 | def decrypt_cbc(ciphertext: bytes, token: bytes) -> bytes: 148 | """Decrypt ciphertext with a given token using cbc mode. 149 | 150 | :param bytes ciphertext: Ciphertext to decrypt 151 | :param bytes token: Token to use 152 | :return: Decrypted bytes object 153 | """ 154 | if not isinstance(ciphertext, bytes): 155 | raise TypeError("ciphertext requires bytes") 156 | if ciphertext: 157 | Utils.verify_token(token) 158 | 159 | iv = bytes(AES.block_size) 160 | decipher = AES.new(token, AES.MODE_CBC, iv) 161 | return unpad(decipher.decrypt(ciphertext), AES.block_size) 162 | return ciphertext 163 | 164 | @staticmethod 165 | def crc(data: bytes) -> int: 166 | """Gather bytes for checksum calculation.""" 167 | return binascii.crc32(data) 168 | 169 | @staticmethod 170 | def decompress(compressed_data: bytes): 171 | """Decompress data using gzip.""" 172 | return gzip.decompress(compressed_data) 173 | 174 | 175 | class EncryptionAdapter(Construct): 176 | """Adapter to handle communication encryption.""" 177 | 178 | def __init__(self, token_func: Callable): 179 | super().__init__() 180 | self.token_func = token_func 181 | 182 | def _parse(self, stream, context, path): 183 | subcon1 = Optional(Int16ub) 184 | length = subcon1.parse_stream(stream, **context) 185 | if not length: 186 | if length == 0: 187 | subcon1.parse_stream(stream, **context) # seek 2 188 | return None 189 | subcon2 = Bytes(length) 190 | obj = subcon2.parse_stream(stream, **context) 191 | return self._decode(obj, context, path) 192 | 193 | def _build(self, obj, stream, context, path): 194 | if obj is not None: 195 | obj2 = self._encode(obj, context, path) 196 | subcon1 = Int16ub 197 | length = len(obj2) 198 | subcon1.build_stream(length, stream, **context) 199 | subcon2 = Bytes(length) 200 | subcon2.build_stream(obj2, stream, **context) 201 | return obj 202 | 203 | def _encode(self, obj, context, _): 204 | """Encrypt the given payload with the token stored in the context. 205 | 206 | :param obj: JSON object to encrypt 207 | """ 208 | if context.version == b"A01": 209 | iv = md5hex(format(context.random, "08x") + A01_HASH)[8:24] 210 | decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8")) 211 | f = decipher.encrypt(obj) 212 | return f 213 | token = self.token_func(context) 214 | encrypted = Utils.encrypt_ecb(obj, token) 215 | return encrypted 216 | 217 | def _decode(self, obj, context, _): 218 | """Decrypts the given payload with the token stored in the context.""" 219 | if context.version == b"A01": 220 | iv = md5hex(format(context.random, "08x") + A01_HASH)[8:24] 221 | decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8")) 222 | f = decipher.decrypt(obj) 223 | return f 224 | token = self.token_func(context) 225 | decrypted = Utils.decrypt_ecb(obj, token) 226 | return decrypted 227 | 228 | 229 | class OptionalChecksum(Checksum): 230 | def _parse(self, stream, context, path): 231 | if not context.message.value.payload: 232 | return 233 | hash1 = self.checksumfield.parse_stream(stream, **context) 234 | hash2 = self.hashfunc(self.bytesfunc(context)) 235 | if hash1 != hash2: 236 | raise ChecksumError( 237 | f"wrong checksum, read {hash1 if not isinstance(hash1, bytestringtype) else binascii.hexlify(hash1)}, " 238 | f"computed {hash2 if not isinstance(hash2, bytestringtype) else binascii.hexlify(hash2)}", 239 | path=path, 240 | ) 241 | return hash1 242 | 243 | 244 | class PrefixedStruct(Struct): 245 | def _parse(self, stream, context, path): 246 | subcon1 = Peek(Optional(Bytes(3))) 247 | peek_version = subcon1.parse_stream(stream, **context) 248 | if peek_version not in (b"1.0", b"A01"): 249 | subcon2 = Bytes(4) 250 | subcon2.parse_stream(stream, **context) 251 | return super()._parse(stream, context, path) 252 | 253 | def _build(self, obj, stream, context, path): 254 | prefixed = context.search("prefixed") 255 | if not prefixed: 256 | return super()._build(obj, stream, context, path) 257 | offset = stream_tell(stream, path) 258 | stream_seek(stream, offset + 4, 0, path) 259 | super()._build(obj, stream, context, path) 260 | new_offset = stream_tell(stream, path) 261 | subcon1 = Bytes(4) 262 | stream_seek(stream, offset, 0, path) 263 | subcon1.build_stream(new_offset - offset - subcon1.sizeof(**context), stream, **context) 264 | stream_seek(stream, new_offset + 4, 0, path) 265 | return obj 266 | 267 | 268 | _Message = RawCopy( 269 | Struct( 270 | "version" / Bytes(3), 271 | "seq" / Int32ub, 272 | "random" / Int32ub, 273 | "timestamp" / Int32ub, 274 | "protocol" / Int16ub, 275 | "payload" 276 | / EncryptionAdapter( 277 | lambda ctx: Utils.md5( 278 | Utils.encode_timestamp(ctx.timestamp) + Utils.ensure_bytes(ctx.search("local_key")) + SALT 279 | ), 280 | ), 281 | ) 282 | ) 283 | 284 | _Messages = Struct( 285 | "messages" 286 | / GreedyRange( 287 | PrefixedStruct( 288 | "message" / _Message, 289 | "checksum" / OptionalChecksum(Optional(Int32ub), Utils.crc, lambda ctx: ctx.message.data), 290 | ) 291 | ), 292 | "remaining" / Optional(GreedyBytes), 293 | ) 294 | 295 | _BroadcastMessage = Struct( 296 | "message" 297 | / RawCopy( 298 | Struct( 299 | "version" / Bytes(3), 300 | "seq" / Int32ub, 301 | "protocol" / Int16ub, 302 | "payload" / EncryptionAdapter(lambda ctx: BROADCAST_TOKEN), 303 | ) 304 | ), 305 | "checksum" / Checksum(Int32ub, Utils.crc, lambda ctx: ctx.message.data), 306 | ) 307 | 308 | 309 | class _Parser: 310 | def __init__(self, con: Construct, required_local_key: bool): 311 | self.con = con 312 | self.required_local_key = required_local_key 313 | 314 | def parse(self, data: bytes, local_key: str | None = None) -> tuple[list[RoborockMessage], bytes]: 315 | if self.required_local_key and local_key is None: 316 | raise RoborockException("Local key is required") 317 | parsed = self.con.parse(data, local_key=local_key) 318 | parsed_messages = [Container({"message": parsed.message})] if parsed.get("message") else parsed.messages 319 | messages = [] 320 | for message in parsed_messages: 321 | messages.append( 322 | RoborockMessage( 323 | version=message.message.value.version, 324 | seq=message.message.value.seq, 325 | random=message.message.value.get("random"), 326 | timestamp=message.message.value.get("timestamp"), 327 | protocol=message.message.value.protocol, 328 | payload=message.message.value.payload, 329 | ) 330 | ) 331 | remaining = parsed.get("remaining") or b"" 332 | return messages, remaining 333 | 334 | def build( 335 | self, roborock_messages: list[RoborockMessage] | RoborockMessage, local_key: str, prefixed: bool = True 336 | ) -> bytes: 337 | if isinstance(roborock_messages, RoborockMessage): 338 | roborock_messages = [roborock_messages] 339 | messages = [] 340 | for roborock_message in roborock_messages: 341 | messages.append( 342 | { 343 | "message": { 344 | "value": { 345 | "version": roborock_message.version, 346 | "seq": roborock_message.seq, 347 | "random": roborock_message.random, 348 | "timestamp": roborock_message.timestamp, 349 | "protocol": roborock_message.protocol, 350 | "payload": roborock_message.payload, 351 | } 352 | }, 353 | } 354 | ) 355 | return self.con.build( 356 | {"messages": [message for message in messages], "remaining": b""}, local_key=local_key, prefixed=prefixed 357 | ) 358 | 359 | 360 | MessageParser: _Parser = _Parser(_Messages, True) 361 | BroadcastParser: _Parser = _Parser(_BroadcastMessage, False) 362 | -------------------------------------------------------------------------------- /roborock/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Python-roborock/python-roborock/148a6faa5c37ce619e5749da78be507e50e2b890/roborock/py.typed -------------------------------------------------------------------------------- /roborock/roborock_future.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from asyncio import Future 4 | from typing import Any 5 | 6 | import async_timeout 7 | 8 | from .exceptions import VacuumError 9 | 10 | 11 | class RoborockFuture: 12 | def __init__(self, protocol: int): 13 | self.protocol = protocol 14 | self.fut: Future = Future() 15 | self.loop = self.fut.get_loop() 16 | 17 | def _set_result(self, item: Any) -> None: 18 | if not self.fut.cancelled(): 19 | self.fut.set_result(item) 20 | 21 | def set_result(self, item: Any) -> None: 22 | self.loop.call_soon_threadsafe(self._set_result, item) 23 | 24 | def _set_exception(self, exc: VacuumError) -> None: 25 | if not self.fut.cancelled(): 26 | self.fut.set_exception(exc) 27 | 28 | def set_exception(self, exc: VacuumError) -> None: 29 | self.loop.call_soon_threadsafe(self._set_exception, exc) 30 | 31 | async def async_get(self, timeout: float | int) -> tuple[Any, VacuumError | None]: 32 | try: 33 | async with async_timeout.timeout(timeout): 34 | return await self.fut 35 | finally: 36 | self.fut.cancel() 37 | -------------------------------------------------------------------------------- /roborock/roborock_message.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import math 5 | import time 6 | from dataclasses import dataclass, field 7 | 8 | from roborock import RoborockEnum 9 | from roborock.util import get_next_int 10 | 11 | 12 | class RoborockMessageProtocol(RoborockEnum): 13 | HELLO_REQUEST = 0 14 | HELLO_RESPONSE = 1 15 | PING_REQUEST = 2 16 | PING_RESPONSE = 3 17 | GENERAL_REQUEST = 4 18 | GENERAL_RESPONSE = 5 19 | RPC_REQUEST = 101 20 | RPC_RESPONSE = 102 21 | MAP_RESPONSE = 301 22 | 23 | 24 | class RoborockDataProtocol(RoborockEnum): 25 | ERROR_CODE = 120 26 | STATE = 121 27 | BATTERY = 122 28 | FAN_POWER = 123 29 | WATER_BOX_MODE = 124 30 | MAIN_BRUSH_WORK_TIME = 125 31 | SIDE_BRUSH_WORK_TIME = 126 32 | FILTER_WORK_TIME = 127 33 | ADDITIONAL_PROPS = 128 34 | TASK_COMPLETE = 130 35 | TASK_CANCEL_LOW_POWER = 131 36 | TASK_CANCEL_IN_MOTION = 132 37 | CHARGE_STATUS = 133 38 | DRYING_STATUS = 134 39 | OFFLINE_STATUS = 135 40 | 41 | @classmethod 42 | def _missing_(cls: type[RoborockEnum], key) -> RoborockEnum: 43 | raise ValueError("%s not a valid key for Data Protocol", key) 44 | 45 | 46 | class RoborockDyadDataProtocol(RoborockEnum): 47 | DRYING_STATUS = 134 48 | START = 200 49 | STATUS = 201 50 | SELF_CLEAN_MODE = 202 51 | SELF_CLEAN_LEVEL = 203 52 | WARM_LEVEL = 204 53 | CLEAN_MODE = 205 54 | SUCTION = 206 55 | WATER_LEVEL = 207 56 | BRUSH_SPEED = 208 57 | POWER = 209 58 | COUNTDOWN_TIME = 210 59 | AUTO_SELF_CLEAN_SET = 212 60 | AUTO_DRY = 213 61 | MESH_LEFT = 214 62 | BRUSH_LEFT = 215 63 | ERROR = 216 64 | MESH_RESET = 218 65 | BRUSH_RESET = 219 66 | VOLUME_SET = 221 67 | STAND_LOCK_AUTO_RUN = 222 68 | AUTO_SELF_CLEAN_SET_MODE = 223 69 | AUTO_DRY_MODE = 224 70 | SILENT_DRY_DURATION = 225 71 | SILENT_MODE = 226 72 | SILENT_MODE_START_TIME = 227 73 | SILENT_MODE_END_TIME = 228 74 | RECENT_RUN_TIME = 229 75 | TOTAL_RUN_TIME = 230 76 | FEATURE_INFO = 235 77 | RECOVER_SETTINGS = 236 78 | DRY_COUNTDOWN = 237 79 | ID_QUERY = 10000 80 | F_C = 10001 81 | SCHEDULE_TASK = 10002 82 | SND_SWITCH = 10003 83 | SND_STATE = 10004 84 | PRODUCT_INFO = 10005 85 | PRIVACY_INFO = 10006 86 | OTA_NFO = 10007 87 | RPC_REQUEST = 10101 88 | RPC_RESPONSE = 10102 89 | 90 | 91 | class RoborockZeoProtocol(RoborockEnum): 92 | START = 200 # rw 93 | PAUSE = 201 # rw 94 | SHUTDOWN = 202 # rw 95 | STATE = 203 # ro 96 | MODE = 204 # rw 97 | PROGRAM = 205 # rw 98 | CHILD_LOCK = 206 # rw 99 | TEMP = 207 # rw 100 | RINSE_TIMES = 208 # rw 101 | SPIN_LEVEL = 209 # rw 102 | DRYING_MODE = 210 # rw 103 | DETERGENT_SET = 211 # rw 104 | SOFTENER_SET = 212 # rw 105 | DETERGENT_TYPE = 213 # rw 106 | SOFTENER_TYPE = 214 # rw 107 | COUNTDOWN = 217 # rw 108 | WASHING_LEFT = 218 # ro 109 | DOORLOCK_STATE = 219 # ro 110 | ERROR = 220 # ro 111 | CUSTOM_PARAM_SAVE = 221 # rw 112 | CUSTOM_PARAM_GET = 222 # ro 113 | SOUND_SET = 223 # rw 114 | TIMES_AFTER_CLEAN = 224 # ro 115 | DEFAULT_SETTING = 225 # rw 116 | DETERGENT_EMPTY = 226 # ro 117 | SOFTENER_EMPTY = 227 # ro 118 | LIGHT_SETTING = 229 # rw 119 | DETERGENT_VOLUME = 230 # rw 120 | SOFTENER_VOLUME = 231 # rw 121 | APP_AUTHORIZATION = 232 # rw 122 | ID_QUERY = 10000 123 | F_C = 10001 124 | SND_STATE = 10004 125 | PRODUCT_INFO = 10005 126 | PRIVACY_INFO = 10006 127 | OTA_NFO = 10007 128 | WASHING_LOG = 10008 129 | RPC_REQ = 10101 130 | RPC_RESp = 10102 131 | 132 | 133 | ROBOROCK_DATA_STATUS_PROTOCOL = [ 134 | RoborockDataProtocol.ERROR_CODE, 135 | RoborockDataProtocol.STATE, 136 | RoborockDataProtocol.BATTERY, 137 | RoborockDataProtocol.FAN_POWER, 138 | RoborockDataProtocol.WATER_BOX_MODE, 139 | RoborockDataProtocol.CHARGE_STATUS, 140 | ] 141 | 142 | ROBOROCK_DATA_CONSUMABLE_PROTOCOL = [ 143 | RoborockDataProtocol.MAIN_BRUSH_WORK_TIME, 144 | RoborockDataProtocol.SIDE_BRUSH_WORK_TIME, 145 | RoborockDataProtocol.FILTER_WORK_TIME, 146 | ] 147 | 148 | 149 | @dataclass 150 | class MessageRetry: 151 | method: str 152 | retry_id: int 153 | 154 | 155 | @dataclass 156 | class RoborockMessage: 157 | protocol: RoborockMessageProtocol 158 | payload: bytes | None = None 159 | seq: int = field(default_factory=lambda: get_next_int(100000, 999999)) 160 | version: bytes = b"1.0" 161 | random: int = field(default_factory=lambda: get_next_int(10000, 99999)) 162 | timestamp: int = field(default_factory=lambda: math.floor(time.time())) 163 | message_retry: MessageRetry | None = None 164 | 165 | def get_request_id(self) -> int | None: 166 | if self.payload: 167 | payload = json.loads(self.payload.decode()) 168 | for data_point_number, data_point in payload.get("dps").items(): 169 | if data_point_number in ["101", "102"]: 170 | data_point_response = json.loads(data_point) 171 | return data_point_response.get("id") 172 | return None 173 | 174 | def get_retry_id(self) -> int | None: 175 | if self.message_retry: 176 | return self.message_retry.retry_id 177 | return self.get_request_id() 178 | 179 | def get_method(self) -> str | None: 180 | if self.message_retry: 181 | return self.message_retry.method 182 | protocol = self.protocol 183 | if self.payload and protocol in [4, 5, 101, 102]: 184 | payload = json.loads(self.payload.decode()) 185 | for data_point_number, data_point in payload.get("dps").items(): 186 | if data_point_number in ["101", "102"]: 187 | data_point_response = json.loads(data_point) 188 | return data_point_response.get("method") 189 | return None 190 | 191 | def get_params(self) -> list | dict | None: 192 | protocol = self.protocol 193 | if self.payload and protocol in [4, 101, 102]: 194 | payload = json.loads(self.payload.decode()) 195 | for data_point_number, data_point in payload.get("dps").items(): 196 | if data_point_number in ["101", "102"]: 197 | data_point_response = json.loads(data_point) 198 | return data_point_response.get("params") 199 | return None 200 | -------------------------------------------------------------------------------- /roborock/util.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import datetime 5 | import functools 6 | import logging 7 | from asyncio import AbstractEventLoop, TimerHandle 8 | from collections.abc import Callable, Coroutine, MutableMapping 9 | from typing import Any, TypeVar 10 | 11 | from roborock import RoborockException 12 | 13 | T = TypeVar("T") 14 | DEFAULT_TIME_ZONE: datetime.tzinfo | None = datetime.datetime.now().astimezone().tzinfo 15 | 16 | 17 | def unpack_list(value: list[T], size: int) -> list[T | None]: 18 | return (value + [None] * size)[:size] # type: ignore 19 | 20 | 21 | def get_running_loop_or_create_one() -> AbstractEventLoop: 22 | try: 23 | loop = asyncio.get_event_loop() 24 | except RuntimeError: 25 | loop = asyncio.new_event_loop() 26 | asyncio.set_event_loop(loop) 27 | return loop 28 | 29 | 30 | def parse_datetime_to_roborock_datetime( 31 | start_datetime: datetime.datetime, end_datetime: datetime.datetime 32 | ) -> tuple[datetime.datetime, datetime.datetime]: 33 | now = datetime.datetime.now(DEFAULT_TIME_ZONE) 34 | start_datetime = start_datetime.replace( 35 | year=now.year, month=now.month, day=now.day, second=0, microsecond=0, tzinfo=DEFAULT_TIME_ZONE 36 | ) 37 | end_datetime = end_datetime.replace( 38 | year=now.year, month=now.month, day=now.day, second=0, microsecond=0, tzinfo=DEFAULT_TIME_ZONE 39 | ) 40 | if start_datetime > end_datetime: 41 | end_datetime += datetime.timedelta(days=1) 42 | elif end_datetime < now: 43 | start_datetime += datetime.timedelta(days=1) 44 | end_datetime += datetime.timedelta(days=1) 45 | 46 | return start_datetime, end_datetime 47 | 48 | 49 | def parse_time_to_datetime( 50 | start_time: datetime.time, end_time: datetime.time 51 | ) -> tuple[datetime.datetime, datetime.datetime]: 52 | """Help to handle time data.""" 53 | start_datetime = datetime.datetime.now(DEFAULT_TIME_ZONE).replace( 54 | hour=start_time.hour, minute=start_time.minute, second=0, microsecond=0 55 | ) 56 | end_datetime = datetime.datetime.now(DEFAULT_TIME_ZONE).replace( 57 | hour=end_time.hour, minute=end_time.minute, second=0, microsecond=0 58 | ) 59 | 60 | return parse_datetime_to_roborock_datetime(start_datetime, end_datetime) 61 | 62 | 63 | def run_sync(): 64 | loop = get_running_loop_or_create_one() 65 | 66 | def decorator(func): 67 | @functools.wraps(func) 68 | def wrapped(*args, **kwargs): 69 | return loop.run_until_complete(func(*args, **kwargs)) 70 | 71 | return wrapped 72 | 73 | return decorator 74 | 75 | 76 | class RepeatableTask: 77 | def __init__(self, callback: Callable[[], Coroutine], interval: int): 78 | self.callback = callback 79 | self.interval = interval 80 | self._task: TimerHandle | None = None 81 | 82 | async def _run_task(self): 83 | response = None 84 | try: 85 | response = await self.callback() 86 | except RoborockException: 87 | pass 88 | loop = asyncio.get_running_loop() 89 | self._task = loop.call_later(self.interval, self._run_task_soon) 90 | return response 91 | 92 | def _run_task_soon(self): 93 | asyncio.create_task(self._run_task()) 94 | 95 | def cancel(self): 96 | if self._task: 97 | self._task.cancel() 98 | 99 | async def reset(self): 100 | self.cancel() 101 | return await self._run_task() 102 | 103 | 104 | class RoborockLoggerAdapter(logging.LoggerAdapter): 105 | def __init__(self, prefix: str, logger: logging.Logger) -> None: 106 | super().__init__(logger, {}) 107 | self.prefix = prefix 108 | 109 | def process(self, msg: str, kwargs: MutableMapping[str, Any]) -> tuple[str, MutableMapping[str, Any]]: 110 | return f"[{self.prefix}] {msg}", kwargs 111 | 112 | 113 | counter_map: dict[tuple[int, int], int] = {} 114 | 115 | 116 | def get_next_int(min_val: int, max_val: int): 117 | """Gets a random int in the range, precached to help keep it fast.""" 118 | if (min_val, max_val) not in counter_map: 119 | # If we have never seen this range, or if the cache is getting low, make a bunch of preshuffled values. 120 | counter_map[(min_val, max_val)] = min_val 121 | counter_map[(min_val, max_val)] += 1 122 | return counter_map[(min_val, max_val)] % max_val + min_val 123 | -------------------------------------------------------------------------------- /roborock/version_1_apis/__init__.py: -------------------------------------------------------------------------------- 1 | from .roborock_client_v1 import AttributeCache, RoborockClientV1 2 | from .roborock_local_client_v1 import RoborockLocalClientV1 3 | from .roborock_mqtt_client_v1 import RoborockMqttClientV1 4 | -------------------------------------------------------------------------------- /roborock/version_1_apis/roborock_local_client_v1.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from roborock.local_api import RoborockLocalClient 4 | 5 | from .. import CommandVacuumError, DeviceData, RoborockCommand, RoborockException 6 | from ..exceptions import VacuumError 7 | from ..protocol import MessageParser 8 | from ..roborock_message import MessageRetry, RoborockMessage, RoborockMessageProtocol 9 | from ..util import RoborockLoggerAdapter 10 | from .roborock_client_v1 import COMMANDS_SECURED, RoborockClientV1 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | class RoborockLocalClientV1(RoborockLocalClient, RoborockClientV1): 16 | """Roborock local client for v1 devices.""" 17 | 18 | def __init__(self, device_data: DeviceData, queue_timeout: int = 4): 19 | """Initialize the Roborock local client.""" 20 | RoborockLocalClient.__init__(self, device_data) 21 | RoborockClientV1.__init__(self, device_data, "abc") 22 | self.queue_timeout = queue_timeout 23 | self._logger = RoborockLoggerAdapter(device_data.device.name, _LOGGER) 24 | 25 | def build_roborock_message( 26 | self, method: RoborockCommand | str, params: list | dict | int | None = None 27 | ) -> RoborockMessage: 28 | secured = True if method in COMMANDS_SECURED else False 29 | request_id, timestamp, payload = self._get_payload(method, params, secured) 30 | self._logger.debug("Building message id %s for method %s", request_id, method) 31 | request_protocol = RoborockMessageProtocol.GENERAL_REQUEST 32 | message_retry: MessageRetry | None = None 33 | if method == RoborockCommand.RETRY_REQUEST and isinstance(params, dict): 34 | message_retry = MessageRetry(method=params["method"], retry_id=params["retry_id"]) 35 | return RoborockMessage( 36 | timestamp=timestamp, protocol=request_protocol, payload=payload, message_retry=message_retry 37 | ) 38 | 39 | async def _send_command( 40 | self, 41 | method: RoborockCommand | str, 42 | params: list | dict | int | None = None, 43 | ): 44 | roborock_message = self.build_roborock_message(method, params) 45 | return await self.send_message(roborock_message) 46 | 47 | async def send_message(self, roborock_message: RoborockMessage): 48 | await self.validate_connection() 49 | method = roborock_message.get_method() 50 | params = roborock_message.get_params() 51 | request_id: int | None 52 | if not method or not method.startswith("get"): 53 | request_id = roborock_message.seq 54 | response_protocol = request_id + 1 55 | else: 56 | request_id = roborock_message.get_request_id() 57 | response_protocol = RoborockMessageProtocol.GENERAL_REQUEST 58 | if request_id is None: 59 | raise RoborockException(f"Failed build message {roborock_message}") 60 | local_key = self.device_info.device.local_key 61 | msg = MessageParser.build(roborock_message, local_key=local_key) 62 | if method: 63 | self._logger.debug(f"id={request_id} Requesting method {method} with {params}") 64 | # Send the command to the Roborock device 65 | async_response = self._async_response(request_id, response_protocol) 66 | self._send_msg_raw(msg) 67 | diagnostic_key = method if method is not None else "unknown" 68 | try: 69 | response = await async_response 70 | except VacuumError as err: 71 | self._diagnostic_data[diagnostic_key] = { 72 | "params": roborock_message.get_params(), 73 | "error": err, 74 | } 75 | raise CommandVacuumError(method, err) from err 76 | self._diagnostic_data[diagnostic_key] = { 77 | "params": roborock_message.get_params(), 78 | "response": response, 79 | } 80 | if roborock_message.protocol == RoborockMessageProtocol.GENERAL_REQUEST: 81 | self._logger.debug(f"id={request_id} Response from method {roborock_message.get_method()}: {response}") 82 | if response == "retry": 83 | retry_id = roborock_message.get_retry_id() 84 | return self.send_command( 85 | RoborockCommand.RETRY_REQUEST, {"retry_id": retry_id, "retry_count": 8, "method": method} 86 | ) 87 | return response 88 | -------------------------------------------------------------------------------- /roborock/version_1_apis/roborock_mqtt_client_v1.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import logging 3 | 4 | from vacuum_map_parser_base.config.color import ColorsPalette 5 | from vacuum_map_parser_base.config.image_config import ImageConfig 6 | from vacuum_map_parser_base.config.size import Sizes 7 | from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser 8 | 9 | from roborock.cloud_api import RoborockMqttClient 10 | 11 | from ..containers import DeviceData, UserData 12 | from ..exceptions import CommandVacuumError, RoborockException, VacuumError 13 | from ..protocol import MessageParser, Utils 14 | from ..roborock_message import ( 15 | RoborockMessage, 16 | RoborockMessageProtocol, 17 | ) 18 | from ..roborock_typing import RoborockCommand 19 | from ..util import RoborockLoggerAdapter 20 | from .roborock_client_v1 import COMMANDS_SECURED, CUSTOM_COMMANDS, RoborockClientV1 21 | 22 | _LOGGER = logging.getLogger(__name__) 23 | 24 | 25 | class RoborockMqttClientV1(RoborockMqttClient, RoborockClientV1): 26 | """Roborock mqtt client for v1 devices.""" 27 | 28 | def __init__(self, user_data: UserData, device_info: DeviceData, queue_timeout: int = 10) -> None: 29 | """Initialize the Roborock mqtt client.""" 30 | rriot = user_data.rriot 31 | if rriot is None: 32 | raise RoborockException("Got no rriot data from user_data") 33 | endpoint = base64.b64encode(Utils.md5(rriot.k.encode())[8:14]).decode() 34 | 35 | RoborockMqttClient.__init__(self, user_data, device_info) 36 | RoborockClientV1.__init__(self, device_info, endpoint) 37 | self.queue_timeout = queue_timeout 38 | self._logger = RoborockLoggerAdapter(device_info.device.name, _LOGGER) 39 | 40 | async def send_message(self, roborock_message: RoborockMessage): 41 | await self.validate_connection() 42 | method = roborock_message.get_method() 43 | params = roborock_message.get_params() 44 | request_id = roborock_message.get_request_id() 45 | if request_id is None: 46 | raise RoborockException(f"Failed build message {roborock_message}") 47 | response_protocol = ( 48 | RoborockMessageProtocol.MAP_RESPONSE if method in COMMANDS_SECURED else RoborockMessageProtocol.RPC_RESPONSE 49 | ) 50 | 51 | local_key = self.device_info.device.local_key 52 | msg = MessageParser.build(roborock_message, local_key, False) 53 | self._logger.debug(f"id={request_id} Requesting method {method} with {params}") 54 | async_response = self._async_response(request_id, response_protocol) 55 | self._send_msg_raw(msg) 56 | diagnostic_key = method if method is not None else "unknown" 57 | try: 58 | response = await async_response 59 | except VacuumError as err: 60 | self._diagnostic_data[diagnostic_key] = { 61 | "params": roborock_message.get_params(), 62 | "error": err, 63 | } 64 | raise CommandVacuumError(method, err) from err 65 | self._diagnostic_data[diagnostic_key] = { 66 | "params": roborock_message.get_params(), 67 | "response": response, 68 | } 69 | if response_protocol == RoborockMessageProtocol.MAP_RESPONSE: 70 | self._logger.debug(f"id={request_id} Response from {method}: {len(response)} bytes") 71 | else: 72 | self._logger.debug(f"id={request_id} Response from {method}: {response}") 73 | return response 74 | 75 | async def _send_command( 76 | self, 77 | method: RoborockCommand | str, 78 | params: list | dict | int | None = None, 79 | ): 80 | if method in CUSTOM_COMMANDS: 81 | # When we have more custom commands do something more complicated here 82 | return await self._get_calibration_points() 83 | request_id, timestamp, payload = self._get_payload(method, params, True) 84 | self._logger.debug("Building message id %s for method %s", request_id, method) 85 | request_protocol = RoborockMessageProtocol.RPC_REQUEST 86 | roborock_message = RoborockMessage(timestamp=timestamp, protocol=request_protocol, payload=payload) 87 | return await self.send_message(roborock_message) 88 | 89 | async def _get_calibration_points(self): 90 | map: bytes = await self.send_command(RoborockCommand.GET_MAP_V1) 91 | parser = RoborockMapDataParser(ColorsPalette(), Sizes(), [], ImageConfig(), []) 92 | parsed_map = parser.parse(map) 93 | calibration = parsed_map.calibration() 94 | self._logger.info(parsed_map.calibration()) 95 | return calibration 96 | 97 | async def get_map_v1(self) -> bytes | None: 98 | return await self.send_command(RoborockCommand.GET_MAP_V1) 99 | -------------------------------------------------------------------------------- /roborock/version_a01_apis/__init__.py: -------------------------------------------------------------------------------- 1 | from .roborock_client_a01 import RoborockClientA01 2 | from .roborock_mqtt_client_a01 import RoborockMqttClientA01 3 | -------------------------------------------------------------------------------- /roborock/version_a01_apis/roborock_client_a01.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import json 3 | import logging 4 | import typing 5 | from abc import ABC, abstractmethod 6 | from collections.abc import Callable 7 | from datetime import time 8 | 9 | from Crypto.Cipher import AES 10 | from Crypto.Util.Padding import unpad 11 | 12 | from roborock import DeviceData 13 | from roborock.api import RoborockClient 14 | from roborock.code_mappings import ( 15 | DyadBrushSpeed, 16 | DyadCleanMode, 17 | DyadError, 18 | DyadSelfCleanLevel, 19 | DyadSelfCleanMode, 20 | DyadSuction, 21 | DyadWarmLevel, 22 | DyadWaterLevel, 23 | RoborockDyadStateCode, 24 | ZeoDetergentType, 25 | ZeoDryingMode, 26 | ZeoError, 27 | ZeoMode, 28 | ZeoProgram, 29 | ZeoRinse, 30 | ZeoSoftenerType, 31 | ZeoSpin, 32 | ZeoState, 33 | ZeoTemperature, 34 | ) 35 | from roborock.containers import DyadProductInfo, DyadSndState, RoborockCategory 36 | from roborock.roborock_message import ( 37 | RoborockDyadDataProtocol, 38 | RoborockMessage, 39 | RoborockMessageProtocol, 40 | RoborockZeoProtocol, 41 | ) 42 | 43 | _LOGGER = logging.getLogger(__name__) 44 | 45 | 46 | @dataclasses.dataclass 47 | class A01ProtocolCacheEntry: 48 | post_process_fn: Callable 49 | value: typing.Any | None = None 50 | 51 | 52 | # Right now this cache is not active, it was too much complexity for the initial addition of dyad. 53 | protocol_entries = { 54 | RoborockDyadDataProtocol.STATUS: A01ProtocolCacheEntry(lambda val: RoborockDyadStateCode(val).name), 55 | RoborockDyadDataProtocol.SELF_CLEAN_MODE: A01ProtocolCacheEntry(lambda val: DyadSelfCleanMode(val).name), 56 | RoborockDyadDataProtocol.SELF_CLEAN_LEVEL: A01ProtocolCacheEntry(lambda val: DyadSelfCleanLevel(val).name), 57 | RoborockDyadDataProtocol.WARM_LEVEL: A01ProtocolCacheEntry(lambda val: DyadWarmLevel(val).name), 58 | RoborockDyadDataProtocol.CLEAN_MODE: A01ProtocolCacheEntry(lambda val: DyadCleanMode(val).name), 59 | RoborockDyadDataProtocol.SUCTION: A01ProtocolCacheEntry(lambda val: DyadSuction(val).name), 60 | RoborockDyadDataProtocol.WATER_LEVEL: A01ProtocolCacheEntry(lambda val: DyadWaterLevel(val).name), 61 | RoborockDyadDataProtocol.BRUSH_SPEED: A01ProtocolCacheEntry(lambda val: DyadBrushSpeed(val).name), 62 | RoborockDyadDataProtocol.POWER: A01ProtocolCacheEntry(lambda val: int(val)), 63 | RoborockDyadDataProtocol.AUTO_DRY: A01ProtocolCacheEntry(lambda val: bool(val)), 64 | RoborockDyadDataProtocol.MESH_LEFT: A01ProtocolCacheEntry(lambda val: int(360000 - val * 60)), 65 | RoborockDyadDataProtocol.BRUSH_LEFT: A01ProtocolCacheEntry(lambda val: int(360000 - val * 60)), 66 | RoborockDyadDataProtocol.ERROR: A01ProtocolCacheEntry(lambda val: DyadError(val).name), 67 | RoborockDyadDataProtocol.VOLUME_SET: A01ProtocolCacheEntry(lambda val: int(val)), 68 | RoborockDyadDataProtocol.STAND_LOCK_AUTO_RUN: A01ProtocolCacheEntry(lambda val: bool(val)), 69 | RoborockDyadDataProtocol.AUTO_DRY_MODE: A01ProtocolCacheEntry(lambda val: bool(val)), 70 | RoborockDyadDataProtocol.SILENT_DRY_DURATION: A01ProtocolCacheEntry(lambda val: int(val)), # in minutes 71 | RoborockDyadDataProtocol.SILENT_MODE: A01ProtocolCacheEntry(lambda val: bool(val)), 72 | RoborockDyadDataProtocol.SILENT_MODE_START_TIME: A01ProtocolCacheEntry( 73 | lambda val: time(hour=int(val / 60), minute=val % 60) 74 | ), # in minutes since 00:00 75 | RoborockDyadDataProtocol.SILENT_MODE_END_TIME: A01ProtocolCacheEntry( 76 | lambda val: time(hour=int(val / 60), minute=val % 60) 77 | ), # in minutes since 00:00 78 | RoborockDyadDataProtocol.RECENT_RUN_TIME: A01ProtocolCacheEntry( 79 | lambda val: [int(v) for v in val.split(",")] 80 | ), # minutes of cleaning in past few days. 81 | RoborockDyadDataProtocol.TOTAL_RUN_TIME: A01ProtocolCacheEntry(lambda val: int(val)), 82 | RoborockDyadDataProtocol.SND_STATE: A01ProtocolCacheEntry(lambda val: DyadSndState.from_dict(val)), 83 | RoborockDyadDataProtocol.PRODUCT_INFO: A01ProtocolCacheEntry(lambda val: DyadProductInfo.from_dict(val)), 84 | } 85 | 86 | zeo_data_protocol_entries = { 87 | # ro 88 | RoborockZeoProtocol.STATE: A01ProtocolCacheEntry(lambda val: ZeoState(val).name), 89 | RoborockZeoProtocol.COUNTDOWN: A01ProtocolCacheEntry(lambda val: int(val)), 90 | RoborockZeoProtocol.WASHING_LEFT: A01ProtocolCacheEntry(lambda val: int(val)), 91 | RoborockZeoProtocol.ERROR: A01ProtocolCacheEntry(lambda val: ZeoError(val).name), 92 | RoborockZeoProtocol.TIMES_AFTER_CLEAN: A01ProtocolCacheEntry(lambda val: int(val)), 93 | RoborockZeoProtocol.DETERGENT_EMPTY: A01ProtocolCacheEntry(lambda val: bool(val)), 94 | RoborockZeoProtocol.SOFTENER_EMPTY: A01ProtocolCacheEntry(lambda val: bool(val)), 95 | # rw 96 | RoborockZeoProtocol.MODE: A01ProtocolCacheEntry(lambda val: ZeoMode(val).name), 97 | RoborockZeoProtocol.PROGRAM: A01ProtocolCacheEntry(lambda val: ZeoProgram(val).name), 98 | RoborockZeoProtocol.TEMP: A01ProtocolCacheEntry(lambda val: ZeoTemperature(val).name), 99 | RoborockZeoProtocol.RINSE_TIMES: A01ProtocolCacheEntry(lambda val: ZeoRinse(val).name), 100 | RoborockZeoProtocol.SPIN_LEVEL: A01ProtocolCacheEntry(lambda val: ZeoSpin(val).name), 101 | RoborockZeoProtocol.DRYING_MODE: A01ProtocolCacheEntry(lambda val: ZeoDryingMode(val).name), 102 | RoborockZeoProtocol.DETERGENT_TYPE: A01ProtocolCacheEntry(lambda val: ZeoDetergentType(val).name), 103 | RoborockZeoProtocol.SOFTENER_TYPE: A01ProtocolCacheEntry(lambda val: ZeoSoftenerType(val).name), 104 | RoborockZeoProtocol.SOUND_SET: A01ProtocolCacheEntry(lambda val: bool(val)), 105 | } 106 | 107 | 108 | class RoborockClientA01(RoborockClient, ABC): 109 | """Roborock client base class for A01 devices.""" 110 | 111 | def __init__(self, device_info: DeviceData, category: RoborockCategory): 112 | """Initialize the Roborock client.""" 113 | super().__init__(device_info) 114 | self.category = category 115 | 116 | def on_message_received(self, messages: list[RoborockMessage]) -> None: 117 | for message in messages: 118 | protocol = message.protocol 119 | if message.payload and protocol in [ 120 | RoborockMessageProtocol.RPC_RESPONSE, 121 | RoborockMessageProtocol.GENERAL_REQUEST, 122 | ]: 123 | payload = message.payload 124 | try: 125 | payload = unpad(payload, AES.block_size) 126 | except Exception as err: 127 | self._logger.debug("Failed to unpad payload: %s", err) 128 | continue 129 | payload_json = json.loads(payload.decode()) 130 | for data_point_number, data_point in payload_json.get("dps").items(): 131 | data_point_protocol: RoborockDyadDataProtocol | RoborockZeoProtocol 132 | self._logger.debug("received msg with dps, protocol: %s, %s", data_point_number, protocol) 133 | entries: dict 134 | if self.category == RoborockCategory.WET_DRY_VAC: 135 | data_point_protocol = RoborockDyadDataProtocol(int(data_point_number)) 136 | entries = protocol_entries 137 | elif self.category == RoborockCategory.WASHING_MACHINE: 138 | data_point_protocol = RoborockZeoProtocol(int(data_point_number)) 139 | entries = zeo_data_protocol_entries 140 | else: 141 | continue 142 | if data_point_protocol in entries: 143 | # Auto convert into data struct we want. 144 | converted_response = entries[data_point_protocol].post_process_fn(data_point) 145 | queue = self._waiting_queue.get(int(data_point_number)) 146 | if queue and queue.protocol == protocol: 147 | queue.set_result(converted_response) 148 | 149 | @abstractmethod 150 | async def update_values( 151 | self, dyad_data_protocols: list[RoborockDyadDataProtocol | RoborockZeoProtocol] 152 | ) -> dict[RoborockDyadDataProtocol | RoborockZeoProtocol, typing.Any]: 153 | """This should handle updating for each given protocol.""" 154 | -------------------------------------------------------------------------------- /roborock/version_a01_apis/roborock_mqtt_client_a01.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | import typing 5 | 6 | from Crypto.Cipher import AES 7 | from Crypto.Util.Padding import pad, unpad 8 | 9 | from roborock.cloud_api import RoborockMqttClient 10 | from roborock.containers import DeviceData, RoborockCategory, UserData 11 | from roborock.exceptions import RoborockException 12 | from roborock.protocol import MessageParser 13 | from roborock.roborock_message import ( 14 | RoborockDyadDataProtocol, 15 | RoborockMessage, 16 | RoborockMessageProtocol, 17 | RoborockZeoProtocol, 18 | ) 19 | 20 | from ..util import RoborockLoggerAdapter 21 | from .roborock_client_a01 import RoborockClientA01 22 | 23 | _LOGGER = logging.getLogger(__name__) 24 | 25 | 26 | class RoborockMqttClientA01(RoborockMqttClient, RoborockClientA01): 27 | """Roborock mqtt client for A01 devices.""" 28 | 29 | def __init__( 30 | self, user_data: UserData, device_info: DeviceData, category: RoborockCategory, queue_timeout: int = 10 31 | ) -> None: 32 | """Initialize the Roborock mqtt client.""" 33 | rriot = user_data.rriot 34 | if rriot is None: 35 | raise RoborockException("Got no rriot data from user_data") 36 | 37 | RoborockMqttClient.__init__(self, user_data, device_info) 38 | RoborockClientA01.__init__(self, device_info, category) 39 | self.queue_timeout = queue_timeout 40 | self._logger = RoborockLoggerAdapter(device_info.device.name, _LOGGER) 41 | 42 | async def send_message(self, roborock_message: RoborockMessage): 43 | await self.validate_connection() 44 | response_protocol = RoborockMessageProtocol.RPC_RESPONSE 45 | 46 | local_key = self.device_info.device.local_key 47 | m = MessageParser.build(roborock_message, local_key, prefixed=False) 48 | # self._logger.debug(f"id={request_id} Requesting method {method} with {params}") 49 | payload = json.loads(unpad(roborock_message.payload, AES.block_size)) 50 | futures = [] 51 | if "10000" in payload["dps"]: 52 | for dps in json.loads(payload["dps"]["10000"]): 53 | futures.append(self._async_response(dps, response_protocol)) 54 | self._send_msg_raw(m) 55 | responses = await asyncio.gather(*futures, return_exceptions=True) 56 | dps_responses: dict[int, typing.Any] = {} 57 | if "10000" in payload["dps"]: 58 | for i, dps in enumerate(json.loads(payload["dps"]["10000"])): 59 | response = responses[i] 60 | if isinstance(response, BaseException): 61 | self._logger.warning("Timed out get req for %s after %s s", dps, self.queue_timeout) 62 | dps_responses[dps] = None 63 | else: 64 | dps_responses[dps] = response 65 | return dps_responses 66 | 67 | async def update_values( 68 | self, dyad_data_protocols: list[RoborockDyadDataProtocol | RoborockZeoProtocol] 69 | ) -> dict[RoborockDyadDataProtocol | RoborockZeoProtocol, typing.Any]: 70 | payload = {"dps": {RoborockDyadDataProtocol.ID_QUERY: str([int(protocol) for protocol in dyad_data_protocols])}} 71 | return await self.send_message( 72 | RoborockMessage( 73 | protocol=RoborockMessageProtocol.RPC_REQUEST, 74 | version=b"A01", 75 | payload=pad(json.dumps(payload).encode("utf-8"), AES.block_size), 76 | ) 77 | ) 78 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Python-roborock/python-roborock/148a6faa5c37ce619e5749da78be507e50e2b890/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import io 3 | import logging 4 | import re 5 | from asyncio import Protocol 6 | from collections.abc import AsyncGenerator, Callable, Generator 7 | from queue import Queue 8 | from typing import Any 9 | from unittest.mock import Mock, patch 10 | 11 | import pytest 12 | from aioresponses import aioresponses 13 | 14 | from roborock import HomeData, UserData 15 | from roborock.containers import DeviceData 16 | from roborock.version_1_apis.roborock_local_client_v1 import RoborockLocalClientV1 17 | from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 18 | from tests.mock_data import HOME_DATA_RAW, HOME_DATA_SCENES_RAW, TEST_LOCAL_API_HOST, USER_DATA 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | 23 | # Used by fixtures to handle incoming requests and prepare responses 24 | RequestHandler = Callable[[bytes], bytes | None] 25 | QUEUE_TIMEOUT = 10 26 | 27 | 28 | class FakeSocketHandler: 29 | """Fake socket used by the test to simulate a connection to the broker. 30 | 31 | The socket handler is used to intercept the socket send and recv calls and 32 | populate the response buffer with data to be sent back to the client. The 33 | handle request callback handles the incoming requests and prepares the responses. 34 | """ 35 | 36 | def __init__(self, handle_request: RequestHandler, response_queue: Queue[bytes]) -> None: 37 | self.response_buf = io.BytesIO() 38 | self.handle_request = handle_request 39 | self.response_queue = response_queue 40 | 41 | def pending(self) -> int: 42 | """Return the number of bytes in the response buffer.""" 43 | return len(self.response_buf.getvalue()) 44 | 45 | def handle_socket_recv(self, read_size: int) -> bytes: 46 | """Intercept a client recv() and populate the buffer.""" 47 | if self.pending() == 0: 48 | raise BlockingIOError("No response queued") 49 | 50 | self.response_buf.seek(0) 51 | data = self.response_buf.read(read_size) 52 | _LOGGER.debug("Response: 0x%s", data.hex()) 53 | # Consume the rest of the data in the buffer 54 | remaining_data = self.response_buf.read() 55 | self.response_buf = io.BytesIO(remaining_data) 56 | return data 57 | 58 | def handle_socket_send(self, client_request: bytes) -> int: 59 | """Receive an incoming request from the client.""" 60 | _LOGGER.debug("Request: 0x%s", client_request.hex()) 61 | if (response := self.handle_request(client_request)) is not None: 62 | # Enqueue a response to be sent back to the client in the buffer. 63 | # The buffer will be emptied when the client calls recv() on the socket 64 | _LOGGER.debug("Queued: 0x%s", response.hex()) 65 | self.response_buf.write(response) 66 | return len(client_request) 67 | 68 | def push_response(self) -> None: 69 | """Push a response to the client.""" 70 | if not self.response_queue.empty(): 71 | response = self.response_queue.get() 72 | # Enqueue a response to be sent back to the client in the buffer. 73 | # The buffer will be emptied when the client calls recv() on the socket 74 | _LOGGER.debug("Queued: 0x%s", response.hex()) 75 | self.response_buf.write(response) 76 | 77 | 78 | @pytest.fixture(name="received_requests") 79 | def received_requests_fixture() -> Queue[bytes]: 80 | """Fixture that provides access to the received requests.""" 81 | return Queue() 82 | 83 | 84 | @pytest.fixture(name="response_queue") 85 | def response_queue_fixture() -> Generator[Queue[bytes], None, None]: 86 | """Fixture that provides access to the received requests.""" 87 | response_queue: Queue[bytes] = Queue() 88 | yield response_queue 89 | assert response_queue.empty(), "Not all fake responses were consumed" 90 | 91 | 92 | @pytest.fixture(name="request_handler") 93 | def request_handler_fixture(received_requests: Queue[bytes], response_queue: Queue[bytes]) -> RequestHandler: 94 | """Fixture records incoming requests and replies with responses from the queue.""" 95 | 96 | def handle_request(client_request: bytes) -> bytes | None: 97 | """Handle an incoming request from the client.""" 98 | received_requests.put(client_request) 99 | 100 | # Insert a prepared response into the response buffer 101 | if not response_queue.empty(): 102 | return response_queue.get() 103 | return None 104 | 105 | return handle_request 106 | 107 | 108 | @pytest.fixture(name="fake_socket_handler") 109 | def fake_socket_handler_fixture(request_handler: RequestHandler, response_queue: Queue[bytes]) -> FakeSocketHandler: 110 | """Fixture that creates a fake MQTT broker.""" 111 | return FakeSocketHandler(request_handler, response_queue) 112 | 113 | 114 | @pytest.fixture(name="mock_sock") 115 | def mock_sock_fixture(fake_socket_handler: FakeSocketHandler) -> Mock: 116 | """Fixture that creates a mock socket connection and wires it to the handler.""" 117 | mock_sock = Mock() 118 | mock_sock.recv = fake_socket_handler.handle_socket_recv 119 | mock_sock.send = fake_socket_handler.handle_socket_send 120 | mock_sock.pending = fake_socket_handler.pending 121 | return mock_sock 122 | 123 | 124 | @pytest.fixture(name="mock_create_connection") 125 | def create_connection_fixture(mock_sock: Mock) -> Generator[None, None, None]: 126 | """Fixture that overrides the MQTT socket creation to wire it up to the mock socket.""" 127 | with patch("paho.mqtt.client.socket.create_connection", return_value=mock_sock): 128 | yield 129 | 130 | 131 | @pytest.fixture(name="mock_select") 132 | def select_fixture(mock_sock: Mock, fake_socket_handler: FakeSocketHandler) -> Generator[None, None, None]: 133 | """Fixture that overrides the MQTT client select calls to make select work on the mock socket. 134 | 135 | This patch select to activate our mock socket when ready with data. Internal mqtt sockets are 136 | always ready since they are used internally to wake the select loop. Ours is ready if there 137 | is data in the buffer. 138 | """ 139 | 140 | def is_ready(sock: Any) -> bool: 141 | return sock is not mock_sock or (fake_socket_handler.pending() > 0) 142 | 143 | def handle_select(rlist: list, wlist: list, *args: Any) -> list: 144 | return [list(filter(is_ready, rlist)), list(filter(is_ready, wlist))] 145 | 146 | with patch("paho.mqtt.client.select.select", side_effect=handle_select): 147 | yield 148 | 149 | 150 | @pytest.fixture(name="mqtt_client") 151 | async def mqtt_client(mock_create_connection: None, mock_select: None) -> AsyncGenerator[RoborockMqttClientV1, None]: 152 | user_data = UserData.from_dict(USER_DATA) 153 | home_data = HomeData.from_dict(HOME_DATA_RAW) 154 | device_info = DeviceData( 155 | device=home_data.devices[0], 156 | model=home_data.products[0].model, 157 | ) 158 | client = RoborockMqttClientV1(user_data, device_info, queue_timeout=QUEUE_TIMEOUT) 159 | try: 160 | yield client 161 | finally: 162 | if not client.is_connected(): 163 | try: 164 | await client.async_release() 165 | except Exception: 166 | pass 167 | 168 | 169 | @pytest.fixture(name="mock_rest", autouse=True) 170 | def mock_rest() -> aioresponses: 171 | """Mock all rest endpoints so they won't hit real endpoints""" 172 | with aioresponses() as mocked: 173 | # Match the base URL and allow any query params 174 | mocked.post( 175 | re.compile(r"https://euiot\.roborock\.com/api/v1/getUrlByEmail.*"), 176 | status=200, 177 | payload={ 178 | "code": 200, 179 | "data": {"country": "US", "countrycode": "1", "url": "https://usiot.roborock.com"}, 180 | "msg": "success", 181 | }, 182 | ) 183 | mocked.post( 184 | re.compile(r"https://.*iot\.roborock\.com/api/v1/login.*"), 185 | status=200, 186 | payload={"code": 200, "data": USER_DATA, "msg": "success"}, 187 | ) 188 | mocked.post( 189 | re.compile(r"https://.*iot\.roborock\.com/api/v1/loginWithCode.*"), 190 | status=200, 191 | payload={"code": 200, "data": USER_DATA, "msg": "success"}, 192 | ) 193 | mocked.post( 194 | re.compile(r"https://.*iot\.roborock\.com/api/v1/sendEmailCode.*"), 195 | status=200, 196 | payload={"code": 200, "data": None, "msg": "success"}, 197 | ) 198 | mocked.get( 199 | re.compile(r"https://.*iot\.roborock\.com/api/v1/getHomeDetail.*"), 200 | status=200, 201 | payload={ 202 | "code": 200, 203 | "data": {"deviceListOrder": None, "id": 123456, "name": "My Home", "rrHomeId": 123456, "tuyaHomeId": 0}, 204 | "msg": "success", 205 | }, 206 | ) 207 | mocked.get( 208 | re.compile(r"https://api-.*\.roborock\.com/v2/user/homes*"), 209 | status=200, 210 | payload={"api": None, "code": 200, "result": HOME_DATA_RAW, "status": "ok", "success": True}, 211 | ) 212 | mocked.post( 213 | re.compile(r"https://api-.*\.roborock\.com/nc/prepare"), 214 | status=200, 215 | payload={ 216 | "api": None, 217 | "result": {"r": "US", "s": "ffffff", "t": "eOf6d2BBBB"}, 218 | "status": "ok", 219 | "success": True, 220 | }, 221 | ) 222 | 223 | mocked.get( 224 | re.compile(r"https://api-.*\.roborock\.com/user/devices/newadd/*"), 225 | status=200, 226 | payload={ 227 | "api": "获取新增设备信息", 228 | "result": { 229 | "activeTime": 1737724598, 230 | "attribute": None, 231 | "cid": None, 232 | "createTime": 0, 233 | "deviceStatus": None, 234 | "duid": "rand_duid", 235 | "extra": "{}", 236 | "f": False, 237 | "featureSet": "0", 238 | "fv": "02.16.12", 239 | "iconUrl": "", 240 | "lat": None, 241 | "localKey": "random_lk", 242 | "lon": None, 243 | "name": "S7", 244 | "newFeatureSet": "0000000000002000", 245 | "online": True, 246 | "productId": "rand_prod_id", 247 | "pv": "1.0", 248 | "roomId": None, 249 | "runtimeEnv": None, 250 | "setting": None, 251 | "share": False, 252 | "shareTime": None, 253 | "silentOtaSwitch": False, 254 | "sn": "Rand_sn", 255 | "timeZoneId": "America/New_York", 256 | "tuyaMigrated": False, 257 | "tuyaUuid": None, 258 | }, 259 | "status": "ok", 260 | "success": True, 261 | }, 262 | ) 263 | mocked.get( 264 | re.compile(r"https://api-.*\.roborock\.com/user/scene/device/.*"), 265 | status=200, 266 | payload={"api": None, "code": 200, "result": HOME_DATA_SCENES_RAW, "status": "ok", "success": True}, 267 | ) 268 | mocked.post( 269 | re.compile(r"https://api-.*\.roborock\.com/user/scene/.*/execute"), 270 | status=200, 271 | payload={"api": None, "code": 200, "result": None, "status": "ok", "success": True}, 272 | ) 273 | yield mocked 274 | 275 | 276 | @pytest.fixture(autouse=True) 277 | def skip_rate_limit(): 278 | """Don't rate limit tests as they aren't actually hitting the api.""" 279 | with ( 280 | patch("roborock.web_api.RoborockApiClient._login_limiter.try_acquire"), 281 | patch("roborock.web_api.RoborockApiClient._home_data_limiter.try_acquire"), 282 | ): 283 | yield 284 | 285 | 286 | @pytest.fixture(name="mock_create_local_connection") 287 | def create_local_connection_fixture(request_handler: RequestHandler) -> Generator[None, None, None]: 288 | """Fixture that overrides the transport creation to wire it up to the mock socket.""" 289 | 290 | async def create_connection(protocol_factory: Callable[[], Protocol], *args) -> tuple[Any, Any]: 291 | protocol = protocol_factory() 292 | 293 | def handle_write(data: bytes) -> None: 294 | _LOGGER.debug("Received: %s", data) 295 | response = request_handler(data) 296 | if response is not None: 297 | _LOGGER.debug("Replying with %s", response) 298 | loop = asyncio.get_running_loop() 299 | loop.call_soon(protocol.data_received, response) 300 | 301 | closed = asyncio.Event() 302 | 303 | mock_transport = Mock() 304 | mock_transport.write = handle_write 305 | mock_transport.close = closed.set 306 | mock_transport.is_reading = lambda: not closed.is_set() 307 | 308 | return (mock_transport, "proto") 309 | 310 | with patch("roborock.local_api.get_running_loop") as mock_loop: 311 | mock_loop.return_value.create_connection.side_effect = create_connection 312 | yield 313 | 314 | 315 | @pytest.fixture(name="local_client") 316 | async def local_client_fixture(mock_create_local_connection: None) -> AsyncGenerator[RoborockLocalClientV1, None]: 317 | home_data = HomeData.from_dict(HOME_DATA_RAW) 318 | device_info = DeviceData( 319 | device=home_data.devices[0], 320 | model=home_data.products[0].model, 321 | host=TEST_LOCAL_API_HOST, 322 | ) 323 | client = RoborockLocalClientV1(device_info, queue_timeout=QUEUE_TIMEOUT) 324 | try: 325 | yield client 326 | finally: 327 | if not client.is_connected(): 328 | try: 329 | await client.async_release() 330 | except Exception: 331 | pass 332 | -------------------------------------------------------------------------------- /tests/mqtt/test_roborock_session.py: -------------------------------------------------------------------------------- 1 | """Tests for the MQTT session module.""" 2 | 3 | import asyncio 4 | from collections.abc import Callable, Generator 5 | from queue import Queue 6 | from typing import Any 7 | from unittest.mock import AsyncMock, Mock, patch 8 | 9 | import aiomqtt 10 | import paho.mqtt.client as mqtt 11 | import pytest 12 | 13 | from roborock.mqtt.roborock_session import create_mqtt_session 14 | from roborock.mqtt.session import MqttParams, MqttSessionException 15 | from tests import mqtt_packet 16 | from tests.conftest import FakeSocketHandler 17 | 18 | # We mock out the connection so these params are not used/verified 19 | FAKE_PARAMS = MqttParams( 20 | host="localhost", 21 | port=1883, 22 | tls=False, 23 | username="username", 24 | password="password", 25 | timeout=10.0, 26 | ) 27 | 28 | 29 | @pytest.fixture(autouse=True) 30 | def mqtt_server_fixture(mock_create_connection: None, mock_select: None) -> None: 31 | """Fixture to prepare a fake MQTT server.""" 32 | 33 | 34 | @pytest.fixture(autouse=True) 35 | def mock_client_fixture(event_loop: asyncio.AbstractEventLoop) -> Generator[None, None, None]: 36 | """Fixture to patch the MQTT underlying sync client. 37 | 38 | The tests use fake sockets, so this ensures that the async mqtt client does not 39 | attempt to listen on them directly. We instead just poll the socket for 40 | data ourselves. 41 | """ 42 | 43 | orig_class = mqtt.Client 44 | 45 | async def poll_sockets(client: mqtt.Client) -> None: 46 | """Poll the mqtt client sockets in a loop to pick up new data.""" 47 | while True: 48 | event_loop.call_soon_threadsafe(client.loop_read) 49 | event_loop.call_soon_threadsafe(client.loop_write) 50 | await asyncio.sleep(0.1) 51 | 52 | task: asyncio.Task[None] | None = None 53 | 54 | def new_client(*args: Any, **kwargs: Any) -> mqtt.Client: 55 | """Create a new mqtt client and start the socket polling task.""" 56 | nonlocal task 57 | client = orig_class(*args, **kwargs) 58 | task = event_loop.create_task(poll_sockets(client)) 59 | return client 60 | 61 | with patch("aiomqtt.client.Client._on_socket_open"), patch("aiomqtt.client.Client._on_socket_close"), patch( 62 | "aiomqtt.client.Client._on_socket_register_write" 63 | ), patch("aiomqtt.client.Client._on_socket_unregister_write"), patch( 64 | "aiomqtt.client.mqtt.Client", side_effect=new_client 65 | ): 66 | yield 67 | if task: 68 | task.cancel() 69 | 70 | 71 | @pytest.fixture 72 | def push_response(response_queue: Queue, fake_socket_handler: FakeSocketHandler) -> Callable[[bytes], None]: 73 | """Fixtures to push messages.""" 74 | 75 | def push(message: bytes) -> None: 76 | response_queue.put(message) 77 | fake_socket_handler.push_response() 78 | 79 | return push 80 | 81 | 82 | class Subscriber: 83 | """Mock subscriber class. 84 | 85 | This will capture messages published on the session so the tests can verify 86 | they were received. 87 | """ 88 | 89 | def __init__(self) -> None: 90 | """Initialize the subscriber.""" 91 | self.messages: list[bytes] = [] 92 | self.event: asyncio.Event = asyncio.Event() 93 | 94 | def append(self, message: bytes) -> None: 95 | """Append a message to the subscriber.""" 96 | self.messages.append(message) 97 | self.event.set() 98 | 99 | async def wait(self) -> None: 100 | """Wait for a message to be received.""" 101 | await self.event.wait() 102 | self.event.clear() 103 | 104 | 105 | async def test_session(push_response: Callable[[bytes], None]) -> None: 106 | """Test the MQTT session.""" 107 | 108 | push_response(mqtt_packet.gen_connack(rc=0, flags=2)) 109 | session = await create_mqtt_session(FAKE_PARAMS) 110 | assert session.connected 111 | 112 | push_response(mqtt_packet.gen_suback(mid=1)) 113 | subscriber1 = Subscriber() 114 | unsub1 = await session.subscribe("topic-1", subscriber1.append) 115 | 116 | push_response(mqtt_packet.gen_suback(mid=2)) 117 | subscriber2 = Subscriber() 118 | await session.subscribe("topic-2", subscriber2.append) 119 | 120 | push_response(mqtt_packet.gen_publish("topic-1", mid=3, payload=b"12345")) 121 | await subscriber1.wait() 122 | assert subscriber1.messages == [b"12345"] 123 | assert not subscriber2.messages 124 | 125 | push_response(mqtt_packet.gen_publish("topic-2", mid=4, payload=b"67890")) 126 | await subscriber2.wait() 127 | assert subscriber2.messages == [b"67890"] 128 | 129 | push_response(mqtt_packet.gen_publish("topic-1", mid=5, payload=b"ABC")) 130 | await subscriber1.wait() 131 | assert subscriber1.messages == [b"12345", b"ABC"] 132 | assert subscriber2.messages == [b"67890"] 133 | 134 | # Messages are no longer received after unsubscribing 135 | unsub1() 136 | push_response(mqtt_packet.gen_publish("topic-1", payload=b"ignored")) 137 | assert subscriber1.messages == [b"12345", b"ABC"] 138 | 139 | assert session.connected 140 | await session.close() 141 | assert not session.connected 142 | 143 | 144 | async def test_session_no_subscribers(push_response: Callable[[bytes], None]) -> None: 145 | """Test the MQTT session.""" 146 | 147 | push_response(mqtt_packet.gen_connack(rc=0, flags=2)) 148 | push_response(mqtt_packet.gen_publish("topic-1", mid=3, payload=b"12345")) 149 | push_response(mqtt_packet.gen_publish("topic-2", mid=4, payload=b"67890")) 150 | session = await create_mqtt_session(FAKE_PARAMS) 151 | assert session.connected 152 | 153 | await session.close() 154 | assert not session.connected 155 | 156 | 157 | async def test_publish_command(push_response: Callable[[bytes], None]) -> None: 158 | """Test publishing during an MQTT session.""" 159 | 160 | push_response(mqtt_packet.gen_connack(rc=0, flags=2)) 161 | session = await create_mqtt_session(FAKE_PARAMS) 162 | 163 | push_response(mqtt_packet.gen_publish("topic-1", mid=3, payload=b"12345")) 164 | await session.publish("topic-1", message=b"payload") 165 | 166 | assert session.connected 167 | await session.close() 168 | assert not session.connected 169 | 170 | 171 | class FakeAsyncIterator: 172 | """Fake async iterator that waits for messages to arrive, but they never do. 173 | 174 | This is used for testing exceptions in other client functions. 175 | """ 176 | 177 | def __aiter__(self): 178 | return self 179 | 180 | async def __anext__(self) -> None: 181 | """Iterator that does not generate any messages.""" 182 | while True: 183 | await asyncio.sleep(1) 184 | 185 | 186 | async def test_publish_failure() -> None: 187 | """Test an MQTT error is received when publishing a message.""" 188 | 189 | mock_client = AsyncMock() 190 | mock_client.messages = FakeAsyncIterator() 191 | 192 | mock_aenter = AsyncMock() 193 | mock_aenter.return_value = mock_client 194 | 195 | with patch("roborock.mqtt.roborock_session.aiomqtt.Client.__aenter__", mock_aenter): 196 | session = await create_mqtt_session(FAKE_PARAMS) 197 | assert session.connected 198 | 199 | mock_client.publish.side_effect = aiomqtt.MqttError 200 | 201 | with pytest.raises(MqttSessionException, match="Error publishing message"): 202 | await session.publish("topic-1", message=b"payload") 203 | 204 | 205 | async def test_subscribe_failure() -> None: 206 | """Test an MQTT error while subscribing.""" 207 | 208 | mock_client = AsyncMock() 209 | mock_client.messages = FakeAsyncIterator() 210 | 211 | mock_aenter = AsyncMock() 212 | mock_aenter.return_value = mock_client 213 | 214 | mock_shim = Mock() 215 | mock_shim.return_value.__aenter__ = mock_aenter 216 | mock_shim.return_value.__aexit__ = AsyncMock() 217 | 218 | with patch("roborock.mqtt.roborock_session.aiomqtt.Client", mock_shim): 219 | session = await create_mqtt_session(FAKE_PARAMS) 220 | assert session.connected 221 | 222 | mock_client.subscribe.side_effect = aiomqtt.MqttError 223 | 224 | subscriber1 = Subscriber() 225 | with pytest.raises(MqttSessionException, match="Error subscribing to topic"): 226 | await session.subscribe("topic-1", subscriber1.append) 227 | 228 | assert not subscriber1.messages 229 | -------------------------------------------------------------------------------- /tests/mqtt_packet.py: -------------------------------------------------------------------------------- 1 | """Module for crafting MQTT packets. 2 | 3 | This library is copied from the paho mqtt client library tests, with just the 4 | parts needed for some roborock messages. This message format in this file is 5 | not specific to roborock. 6 | """ 7 | 8 | import struct 9 | 10 | PROP_RECEIVE_MAXIMUM = 33 11 | PROP_TOPIC_ALIAS_MAXIMUM = 34 12 | 13 | 14 | def gen_uint16_prop(identifier: int, word: int) -> bytes: 15 | """Generate a property with a uint16_t value.""" 16 | prop = struct.pack("!BH", identifier, word) 17 | return prop 18 | 19 | 20 | def pack_varint(varint: int) -> bytes: 21 | """Pack a variable integer.""" 22 | s = b"" 23 | while True: 24 | byte = varint % 128 25 | varint = varint // 128 26 | # If there are more digits to encode, set the top bit of this digit 27 | if varint > 0: 28 | byte = byte | 0x80 29 | 30 | s = s + struct.pack("!B", byte) 31 | if varint == 0: 32 | return s 33 | 34 | 35 | def prop_finalise(props: bytes) -> bytes: 36 | """Finalise the properties.""" 37 | if props is None: 38 | return pack_varint(0) 39 | else: 40 | return pack_varint(len(props)) + props 41 | 42 | 43 | def gen_connack(flags=0, rc=0, properties=b"", property_helper=True): 44 | """Generate a CONNACK packet.""" 45 | if property_helper: 46 | if properties is not None: 47 | properties = ( 48 | gen_uint16_prop(PROP_TOPIC_ALIAS_MAXIMUM, 10) + properties + gen_uint16_prop(PROP_RECEIVE_MAXIMUM, 20) 49 | ) 50 | else: 51 | properties = b"" 52 | properties = prop_finalise(properties) 53 | 54 | packet = struct.pack("!BBBB", 32, 2 + len(properties), flags, rc) + properties 55 | 56 | return packet 57 | 58 | 59 | def gen_suback(mid: int, qos: int = 0) -> bytes: 60 | """Generate a SUBACK packet.""" 61 | return struct.pack("!BBHBB", 144, 2 + 1 + 1, mid, 0, qos) 62 | 63 | 64 | def _gen_short(cmd: int, reason_code: int) -> bytes: 65 | return struct.pack("!BBB", cmd, 1, reason_code) 66 | 67 | 68 | def gen_disconnect(reason_code: int = 0) -> bytes: 69 | """Generate a DISCONNECT packet.""" 70 | return _gen_short(0xE0, reason_code) 71 | 72 | 73 | def _gen_command_with_mid(cmd: int, mid: int, reason_code: int = 0) -> bytes: 74 | return struct.pack("!BBHB", cmd, 3, mid, reason_code) 75 | 76 | 77 | def gen_puback(mid: int, reason_code: int = 0) -> bytes: 78 | """Generate a PUBACK packet.""" 79 | return _gen_command_with_mid(64, mid, reason_code) 80 | 81 | 82 | def _pack_remaining_length(remaining_length: int) -> bytes: 83 | """Pack a remaining length.""" 84 | s = b"" 85 | while True: 86 | byte = remaining_length % 128 87 | remaining_length = remaining_length // 128 88 | # If there are more digits to encode, set the top bit of this digit 89 | if remaining_length > 0: 90 | byte = byte | 0x80 91 | 92 | s = s + struct.pack("!B", byte) 93 | if remaining_length == 0: 94 | return s 95 | 96 | 97 | def gen_publish( 98 | topic: str, 99 | payload: bytes | None = None, 100 | retain: bool = False, 101 | dup: bool = False, 102 | mid: int = 0, 103 | properties: bytes = b"", 104 | ) -> bytes: 105 | """Generate a PUBLISH packet.""" 106 | if isinstance(topic, str): 107 | topic_b = topic.encode("utf-8") 108 | rl = 2 + len(topic_b) 109 | pack_format = "H" + str(len(topic_b)) + "s" 110 | 111 | properties = prop_finalise(properties) 112 | rl += len(properties) 113 | # This will break if len(properties) > 127 114 | pack_format = pack_format + "%ds" % (len(properties)) 115 | 116 | if payload is not None: 117 | # payload = payload.encode("utf-8") 118 | rl = rl + len(payload) 119 | pack_format = pack_format + str(len(payload)) + "s" 120 | else: 121 | payload = b"" 122 | pack_format = pack_format + "0s" 123 | 124 | rlpacked = _pack_remaining_length(rl) 125 | cmd = 48 126 | if retain: 127 | cmd = cmd + 1 128 | if dup: 129 | cmd = cmd + 8 130 | 131 | return struct.pack( 132 | "!B" + str(len(rlpacked)) + "s" + pack_format, cmd, rlpacked, len(topic_b), topic_b, properties, payload 133 | ) 134 | -------------------------------------------------------------------------------- /tests/test_a01_api.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from collections.abc import AsyncGenerator 4 | from queue import Queue 5 | from typing import Any 6 | from unittest.mock import patch 7 | 8 | import paho.mqtt.client as mqtt 9 | import pytest 10 | from Crypto.Cipher import AES 11 | from Crypto.Util.Padding import pad 12 | 13 | from roborock import ( 14 | HomeData, 15 | UserData, 16 | ) 17 | from roborock.containers import DeviceData, RoborockCategory 18 | from roborock.exceptions import RoborockException 19 | from roborock.protocol import MessageParser 20 | from roborock.roborock_message import ( 21 | RoborockMessage, 22 | RoborockMessageProtocol, 23 | RoborockZeoProtocol, 24 | ) 25 | from roborock.version_a01_apis import RoborockMqttClientA01 26 | from tests.mock_data import ( 27 | HOME_DATA_RAW, 28 | LOCAL_KEY, 29 | MQTT_PUBLISH_TOPIC, 30 | USER_DATA, 31 | WASHER_PRODUCT, 32 | ZEO_ONE_DEVICE, 33 | ) 34 | 35 | from . import mqtt_packet 36 | from .conftest import QUEUE_TIMEOUT 37 | 38 | 39 | @pytest.fixture(name="a01_mqtt_client") 40 | async def a01_mqtt_client_fixture( 41 | mock_create_connection: None, mock_select: None 42 | ) -> AsyncGenerator[RoborockMqttClientA01, None]: 43 | user_data = UserData.from_dict(USER_DATA) 44 | home_data = HomeData.from_dict( 45 | { 46 | **HOME_DATA_RAW, 47 | "devices": [ZEO_ONE_DEVICE], 48 | "products": [WASHER_PRODUCT], 49 | } 50 | ) 51 | device_info = DeviceData( 52 | device=home_data.devices[0], 53 | model=home_data.products[0].model, 54 | ) 55 | client = RoborockMqttClientA01( 56 | user_data, device_info, RoborockCategory.WASHING_MACHINE, queue_timeout=QUEUE_TIMEOUT 57 | ) 58 | try: 59 | yield client 60 | finally: 61 | if not client.is_connected(): 62 | try: 63 | await client.async_release() 64 | except Exception: 65 | pass 66 | 67 | 68 | @pytest.fixture(name="connected_a01_mqtt_client") 69 | async def connected_a01_mqtt_client_fixture( 70 | response_queue: Queue, a01_mqtt_client: RoborockMqttClientA01 71 | ) -> AsyncGenerator[RoborockMqttClientA01, None]: 72 | response_queue.put(mqtt_packet.gen_connack(rc=0, flags=2)) 73 | response_queue.put(mqtt_packet.gen_suback(1, 0)) 74 | await a01_mqtt_client.async_connect() 75 | yield a01_mqtt_client 76 | 77 | 78 | async def test_async_connect(received_requests: Queue, connected_a01_mqtt_client: RoborockMqttClientA01) -> None: 79 | """Test connecting to the MQTT broker.""" 80 | 81 | assert connected_a01_mqtt_client.is_connected() 82 | # Connecting again is a no-op 83 | await connected_a01_mqtt_client.async_connect() 84 | assert connected_a01_mqtt_client.is_connected() 85 | 86 | await connected_a01_mqtt_client.async_disconnect() 87 | assert not connected_a01_mqtt_client.is_connected() 88 | 89 | # Broker received a connect and subscribe. Disconnect packet is not 90 | # guaranteed to be captured by the time the async_disconnect returns 91 | assert received_requests.qsize() >= 2 # Connect and Subscribe 92 | 93 | 94 | async def test_connect_failure( 95 | received_requests: Queue, response_queue: Queue, a01_mqtt_client: RoborockMqttClientA01 96 | ) -> None: 97 | """Test the broker responding with a connect failure.""" 98 | 99 | response_queue.put(mqtt_packet.gen_connack(rc=1)) 100 | 101 | with pytest.raises(RoborockException, match="Failed to connect"): 102 | await a01_mqtt_client.async_connect() 103 | assert not a01_mqtt_client.is_connected() 104 | assert received_requests.qsize() == 1 # Connect attempt 105 | 106 | 107 | async def test_disconnect_already_disconnected(connected_a01_mqtt_client: RoborockMqttClientA01) -> None: 108 | """Test the MQTT client error handling for a no-op disconnect.""" 109 | 110 | assert connected_a01_mqtt_client.is_connected() 111 | 112 | # Make the MQTT client simulate returning that it already thinks it is disconnected 113 | with patch("roborock.cloud_api.mqtt.Client.disconnect", return_value=mqtt.MQTT_ERR_NO_CONN): 114 | await connected_a01_mqtt_client.async_disconnect() 115 | 116 | 117 | async def test_disconnect_failure(connected_a01_mqtt_client: RoborockMqttClientA01) -> None: 118 | """Test that the MQTT client ignores MQTT client error handling for a no-op disconnect.""" 119 | 120 | assert connected_a01_mqtt_client.is_connected() 121 | 122 | # Make the MQTT client returns with an error when disconnecting 123 | with patch("roborock.cloud_api.mqtt.Client.disconnect", return_value=mqtt.MQTT_ERR_PROTOCOL), pytest.raises( 124 | RoborockException, match="Failed to disconnect" 125 | ): 126 | await connected_a01_mqtt_client.async_disconnect() 127 | 128 | 129 | async def test_async_release(connected_a01_mqtt_client: RoborockMqttClientA01) -> None: 130 | """Test the async_release API will disconnect the client.""" 131 | await connected_a01_mqtt_client.async_release() 132 | assert not connected_a01_mqtt_client.is_connected() 133 | 134 | 135 | async def test_subscribe_failure( 136 | received_requests: Queue, response_queue: Queue, a01_mqtt_client: RoborockMqttClientA01 137 | ) -> None: 138 | """Test the broker responding with the wrong message type on subscribe.""" 139 | 140 | response_queue.put(mqtt_packet.gen_connack(rc=0, flags=2)) 141 | 142 | with patch("roborock.cloud_api.mqtt.Client.subscribe", return_value=(mqtt.MQTT_ERR_NO_CONN, None)), pytest.raises( 143 | RoborockException, match="Failed to subscribe" 144 | ): 145 | await a01_mqtt_client.async_connect() 146 | 147 | assert received_requests.qsize() == 1 # Connect attempt 148 | 149 | # NOTE: The client is "connected" but not "subscribed" and cannot recover 150 | # from this state without disconnecting first. This can likely be improved. 151 | assert a01_mqtt_client.is_connected() 152 | 153 | # Attempting to reconnect is a no-op since the client already thinks it is connected 154 | await a01_mqtt_client.async_connect() 155 | assert a01_mqtt_client.is_connected() 156 | assert received_requests.qsize() == 1 157 | 158 | 159 | def build_rpc_response(message: dict[Any, Any]) -> bytes: 160 | """Build an encoded RPC response message.""" 161 | return MessageParser.build( 162 | [ 163 | RoborockMessage( 164 | protocol=RoborockMessageProtocol.RPC_RESPONSE, 165 | payload=pad( 166 | json.dumps( 167 | { 168 | "dps": message, # {10000: json.dumps(message)}, 169 | } 170 | ).encode(), 171 | AES.block_size, 172 | ), 173 | version=b"A01", 174 | seq=2020, 175 | ), 176 | ], 177 | local_key=LOCAL_KEY, 178 | ) 179 | 180 | 181 | async def test_update_values( 182 | received_requests: Queue, 183 | response_queue: Queue, 184 | connected_a01_mqtt_client: RoborockMqttClientA01, 185 | ) -> None: 186 | """Test sending an arbitrary MQTT message and parsing the response.""" 187 | 188 | message = build_rpc_response( 189 | { 190 | 203: 6, # spinning 191 | } 192 | ) 193 | response_queue.put(mqtt_packet.gen_publish(MQTT_PUBLISH_TOPIC, payload=message)) 194 | 195 | data = await connected_a01_mqtt_client.update_values([RoborockZeoProtocol.STATE]) 196 | assert data.get(RoborockZeoProtocol.STATE) == "spinning" 197 | 198 | 199 | async def test_publish_failure( 200 | connected_a01_mqtt_client: RoborockMqttClientA01, 201 | ) -> None: 202 | """Test a failure return code when publishing a messaage.""" 203 | 204 | msg = mqtt.MQTTMessageInfo(0) 205 | msg.rc = mqtt.MQTT_ERR_PROTOCOL 206 | with patch("roborock.cloud_api.mqtt.Client.publish", return_value=msg), pytest.raises( 207 | RoborockException, match="Failed to publish" 208 | ): 209 | await connected_a01_mqtt_client.update_values([RoborockZeoProtocol.STATE]) 210 | 211 | 212 | async def test_future_timeout( 213 | connected_a01_mqtt_client: RoborockMqttClientA01, 214 | ) -> None: 215 | """Test a timeout raised while waiting for an RPC response.""" 216 | with patch("roborock.roborock_future.async_timeout.timeout", side_effect=asyncio.TimeoutError): 217 | data = await connected_a01_mqtt_client.update_values([RoborockZeoProtocol.STATE]) 218 | assert data.get(RoborockZeoProtocol.STATE) is None 219 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | from collections.abc import AsyncGenerator 5 | from queue import Queue 6 | from typing import Any 7 | from unittest.mock import AsyncMock, patch 8 | 9 | import paho.mqtt.client as mqtt 10 | import pytest 11 | 12 | from roborock import ( 13 | HomeData, 14 | RoborockDockDustCollectionModeCode, 15 | RoborockDockTypeCode, 16 | RoborockDockWashTowelModeCode, 17 | UserData, 18 | ) 19 | from roborock.containers import DeviceData, RoomMapping, S7MaxVStatus 20 | from roborock.exceptions import RoborockException, RoborockTimeout 21 | from roborock.protocol import MessageParser 22 | from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol 23 | from roborock.version_1_apis import RoborockMqttClientV1 24 | from roborock.web_api import PreparedRequest, RoborockApiClient 25 | from tests.mock_data import ( 26 | BASE_URL_REQUEST, 27 | GET_CODE_RESPONSE, 28 | HOME_DATA_RAW, 29 | LOCAL_KEY, 30 | MQTT_PUBLISH_TOPIC, 31 | STATUS, 32 | USER_DATA, 33 | ) 34 | 35 | from . import mqtt_packet 36 | 37 | 38 | def test_can_create_prepared_request(): 39 | PreparedRequest("https://sample.com", AsyncMock()) 40 | 41 | 42 | async def test_can_create_mqtt_roborock(): 43 | home_data = HomeData.from_dict(HOME_DATA_RAW) 44 | device_info = DeviceData(device=home_data.devices[0], model=home_data.products[0].model) 45 | RoborockMqttClientV1(UserData.from_dict(USER_DATA), device_info) 46 | 47 | 48 | async def test_get_base_url_no_url(): 49 | rc = RoborockApiClient("sample@gmail.com") 50 | with patch("roborock.web_api.PreparedRequest.request") as mock_request: 51 | mock_request.return_value = BASE_URL_REQUEST 52 | await rc._get_base_url() 53 | assert rc.base_url == "https://sample.com" 54 | 55 | 56 | async def test_request_code(): 57 | rc = RoborockApiClient("sample@gmail.com") 58 | with patch("roborock.web_api.RoborockApiClient._get_base_url"), patch( 59 | "roborock.web_api.RoborockApiClient._get_header_client_id" 60 | ), patch("roborock.web_api.PreparedRequest.request") as mock_request: 61 | mock_request.return_value = GET_CODE_RESPONSE 62 | await rc.request_code() 63 | 64 | 65 | async def test_get_home_data(): 66 | rc = RoborockApiClient("sample@gmail.com") 67 | with patch("roborock.web_api.RoborockApiClient._get_base_url"), patch( 68 | "roborock.web_api.RoborockApiClient._get_header_client_id" 69 | ), patch("roborock.web_api.PreparedRequest.request") as mock_prepared_request: 70 | mock_prepared_request.side_effect = [ 71 | {"code": 200, "msg": "success", "data": {"rrHomeId": 1}}, 72 | {"code": 200, "success": True, "result": HOME_DATA_RAW}, 73 | ] 74 | 75 | user_data = UserData.from_dict(USER_DATA) 76 | result = await rc.get_home_data(user_data) 77 | 78 | assert result == HomeData.from_dict(HOME_DATA_RAW) 79 | 80 | 81 | async def test_get_dust_collection_mode(): 82 | home_data = HomeData.from_dict(HOME_DATA_RAW) 83 | device_info = DeviceData(device=home_data.devices[0], model=home_data.products[0].model) 84 | rmc = RoborockMqttClientV1(UserData.from_dict(USER_DATA), device_info) 85 | with patch("roborock.version_1_apis.roborock_client_v1.AttributeCache.async_value") as command: 86 | command.return_value = {"mode": 1} 87 | dust = await rmc.get_dust_collection_mode() 88 | assert dust is not None 89 | assert dust.mode == RoborockDockDustCollectionModeCode.light 90 | 91 | 92 | async def test_get_mop_wash_mode(): 93 | home_data = HomeData.from_dict(HOME_DATA_RAW) 94 | device_info = DeviceData(device=home_data.devices[0], model=home_data.products[0].model) 95 | rmc = RoborockMqttClientV1(UserData.from_dict(USER_DATA), device_info) 96 | with patch("roborock.version_1_apis.roborock_client_v1.AttributeCache.async_value") as command: 97 | command.return_value = {"smart_wash": 0, "wash_interval": 1500} 98 | mop_wash = await rmc.get_smart_wash_params() 99 | assert mop_wash is not None 100 | assert mop_wash.smart_wash == 0 101 | assert mop_wash.wash_interval == 1500 102 | 103 | 104 | async def test_get_washing_mode(): 105 | home_data = HomeData.from_dict(HOME_DATA_RAW) 106 | device_info = DeviceData(device=home_data.devices[0], model=home_data.products[0].model) 107 | rmc = RoborockMqttClientV1(UserData.from_dict(USER_DATA), device_info) 108 | with patch("roborock.version_1_apis.roborock_client_v1.AttributeCache.async_value") as command: 109 | command.return_value = {"wash_mode": 2} 110 | washing_mode = await rmc.get_wash_towel_mode() 111 | assert washing_mode is not None 112 | assert washing_mode.wash_mode == RoborockDockWashTowelModeCode.deep 113 | assert washing_mode.wash_mode == 2 114 | 115 | 116 | async def test_get_prop(): 117 | home_data = HomeData.from_dict(HOME_DATA_RAW) 118 | device_info = DeviceData(device=home_data.devices[0], model=home_data.products[0].model) 119 | rmc = RoborockMqttClientV1(UserData.from_dict(USER_DATA), device_info) 120 | with patch("roborock.version_1_apis.roborock_mqtt_client_v1.RoborockMqttClientV1.get_status") as get_status, patch( 121 | "roborock.version_1_apis.roborock_client_v1.RoborockClientV1.send_command" 122 | ), patch("roborock.version_1_apis.roborock_client_v1.AttributeCache.async_value"), patch( 123 | "roborock.version_1_apis.roborock_mqtt_client_v1.RoborockMqttClientV1.get_dust_collection_mode" 124 | ): 125 | status = S7MaxVStatus.from_dict(STATUS) 126 | status.dock_type = RoborockDockTypeCode.auto_empty_dock_pure 127 | get_status.return_value = status 128 | 129 | props = await rmc.get_prop() 130 | assert props 131 | assert props.dock_summary 132 | assert props.dock_summary.wash_towel_mode is None 133 | assert props.dock_summary.smart_wash_params is None 134 | assert props.dock_summary.dust_collection_mode is not None 135 | 136 | 137 | @pytest.fixture(name="connected_mqtt_client") 138 | async def connected_mqtt_client_fixture( 139 | response_queue: Queue, mqtt_client: RoborockMqttClientV1 140 | ) -> AsyncGenerator[RoborockMqttClientV1, None]: 141 | response_queue.put(mqtt_packet.gen_connack(rc=0, flags=2)) 142 | response_queue.put(mqtt_packet.gen_suback(1, 0)) 143 | await mqtt_client.async_connect() 144 | yield mqtt_client 145 | 146 | 147 | async def test_async_connect(received_requests: Queue, connected_mqtt_client: RoborockMqttClientV1) -> None: 148 | """Test connecting to the MQTT broker.""" 149 | 150 | assert connected_mqtt_client.is_connected() 151 | # Connecting again is a no-op 152 | await connected_mqtt_client.async_connect() 153 | assert connected_mqtt_client.is_connected() 154 | 155 | await connected_mqtt_client.async_disconnect() 156 | assert not connected_mqtt_client.is_connected() 157 | 158 | # Broker received a connect and subscribe. Disconnect packet is not 159 | # guaranteed to be captured by the time the async_disconnect returns 160 | assert received_requests.qsize() >= 2 # Connect and Subscribe 161 | 162 | 163 | async def test_connect_failure_response( 164 | received_requests: Queue, response_queue: Queue, mqtt_client: RoborockMqttClientV1 165 | ) -> None: 166 | """Test the broker responding with a connect failure.""" 167 | 168 | response_queue.put(mqtt_packet.gen_connack(rc=1)) 169 | 170 | with pytest.raises(RoborockException, match="Failed to connect"): 171 | await mqtt_client.async_connect() 172 | assert not mqtt_client.is_connected() 173 | assert received_requests.qsize() == 1 # Connect attempt 174 | 175 | 176 | async def test_disconnect_already_disconnected(connected_mqtt_client: RoborockMqttClientV1) -> None: 177 | """Test the MQTT client error handling for a no-op disconnect.""" 178 | 179 | assert connected_mqtt_client.is_connected() 180 | 181 | # Make the MQTT client simulate returning that it already thinks it is disconnected 182 | with patch("roborock.cloud_api.mqtt.Client.disconnect", return_value=mqtt.MQTT_ERR_NO_CONN): 183 | await connected_mqtt_client.async_disconnect() 184 | 185 | 186 | async def test_disconnect_failure(connected_mqtt_client: RoborockMqttClientV1) -> None: 187 | """Test that the MQTT client ignores MQTT client error handling for a no-op disconnect.""" 188 | 189 | assert connected_mqtt_client.is_connected() 190 | 191 | # Make the MQTT client returns with an error when disconnecting 192 | with patch("roborock.cloud_api.mqtt.Client.disconnect", return_value=mqtt.MQTT_ERR_PROTOCOL), pytest.raises( 193 | RoborockException, match="Failed to disconnect" 194 | ): 195 | await connected_mqtt_client.async_disconnect() 196 | 197 | 198 | async def test_disconnect_failure_response( 199 | received_requests: Queue, 200 | response_queue: Queue, 201 | connected_mqtt_client: RoborockMqttClientV1, 202 | caplog: pytest.LogCaptureFixture, 203 | ) -> None: 204 | """Test the broker responding with a connect failure.""" 205 | 206 | # Enqueue a failed message -- however, the client does not process any 207 | # further messages and there is no parsing error, and no failed log messages. 208 | response_queue.put(mqtt_packet.gen_disconnect(reason_code=1)) 209 | assert connected_mqtt_client.is_connected() 210 | with caplog.at_level(logging.ERROR, logger="homeassistant.components.nest"): 211 | await connected_mqtt_client.async_disconnect() 212 | assert not connected_mqtt_client.is_connected() 213 | assert not caplog.records 214 | 215 | 216 | async def test_async_release(connected_mqtt_client: RoborockMqttClientV1) -> None: 217 | """Test the async_release API will disconnect the client.""" 218 | await connected_mqtt_client.async_release() 219 | assert not connected_mqtt_client.is_connected() 220 | 221 | 222 | async def test_subscribe_failure( 223 | received_requests: Queue, response_queue: Queue, mqtt_client: RoborockMqttClientV1 224 | ) -> None: 225 | """Test the broker responding with the wrong message type on subscribe.""" 226 | 227 | response_queue.put(mqtt_packet.gen_connack(rc=0, flags=2)) 228 | 229 | with patch("roborock.cloud_api.mqtt.Client.subscribe", return_value=(mqtt.MQTT_ERR_NO_CONN, None)), pytest.raises( 230 | RoborockException, match="Failed to subscribe" 231 | ): 232 | await mqtt_client.async_connect() 233 | 234 | assert received_requests.qsize() == 1 # Connect attempt 235 | 236 | # NOTE: The client is "connected" but not "subscribed" and cannot recover 237 | # from this state without disconnecting first. This can likely be improved. 238 | assert mqtt_client.is_connected() 239 | 240 | # Attempting to reconnect is a no-op since the client already thinks it is connected 241 | await mqtt_client.async_connect() 242 | assert mqtt_client.is_connected() 243 | assert received_requests.qsize() == 1 244 | 245 | 246 | def build_rpc_response(message: dict[str, Any]) -> bytes: 247 | """Build an encoded RPC response message.""" 248 | return MessageParser.build( 249 | [ 250 | RoborockMessage( 251 | protocol=RoborockMessageProtocol.RPC_RESPONSE, 252 | payload=json.dumps( 253 | { 254 | "dps": {102: json.dumps(message)}, 255 | } 256 | ).encode(), 257 | seq=2020, 258 | ), 259 | ], 260 | local_key=LOCAL_KEY, 261 | ) 262 | 263 | 264 | async def test_get_room_mapping( 265 | received_requests: Queue, 266 | response_queue: Queue, 267 | connected_mqtt_client: RoborockMqttClientV1, 268 | ) -> None: 269 | """Test sending an arbitrary MQTT message and parsing the response.""" 270 | 271 | test_request_id = 5050 272 | message = build_rpc_response( 273 | { 274 | "id": test_request_id, 275 | "result": [[16, "2362048"], [17, "2362044"]], 276 | } 277 | ) 278 | response_queue.put(mqtt_packet.gen_publish(MQTT_PUBLISH_TOPIC, payload=message)) 279 | 280 | with patch("roborock.version_1_apis.roborock_client_v1.get_next_int", return_value=test_request_id): 281 | room_mapping = await connected_mqtt_client.get_room_mapping() 282 | 283 | assert room_mapping == [ 284 | RoomMapping(segment_id=16, iot_id="2362048"), 285 | RoomMapping(segment_id=17, iot_id="2362044"), 286 | ] 287 | 288 | 289 | async def test_publish_failure( 290 | connected_mqtt_client: RoborockMqttClientV1, 291 | ) -> None: 292 | """Test a failure return code when publishing a messaage.""" 293 | 294 | msg = mqtt.MQTTMessageInfo(0) 295 | msg.rc = mqtt.MQTT_ERR_PROTOCOL 296 | with patch("roborock.cloud_api.mqtt.Client.publish", return_value=msg), pytest.raises( 297 | RoborockException, match="Failed to publish" 298 | ): 299 | await connected_mqtt_client.get_room_mapping() 300 | 301 | 302 | async def test_future_timeout( 303 | connected_mqtt_client: RoborockMqttClientV1, 304 | ) -> None: 305 | """Test a timeout raised while waiting for an RPC response.""" 306 | with patch("roborock.roborock_future.async_timeout.timeout", side_effect=asyncio.TimeoutError), pytest.raises( 307 | RoborockTimeout, match="Timeout after" 308 | ): 309 | await connected_mqtt_client.get_room_mapping() 310 | -------------------------------------------------------------------------------- /tests/test_containers.py: -------------------------------------------------------------------------------- 1 | from roborock import CleanRecord, CleanSummary, Consumable, DnDTimer, HomeData, S7MaxVStatus, UserData 2 | from roborock.code_mappings import ( 3 | RoborockCategory, 4 | RoborockDockErrorCode, 5 | RoborockDockTypeCode, 6 | RoborockErrorCode, 7 | RoborockFanSpeedS7MaxV, 8 | RoborockMopIntensityS7, 9 | RoborockMopModeS7, 10 | RoborockStateCode, 11 | ) 12 | 13 | from .mock_data import ( 14 | CLEAN_RECORD, 15 | CLEAN_SUMMARY, 16 | CONSUMABLE, 17 | DND_TIMER, 18 | HOME_DATA_RAW, 19 | LOCAL_KEY, 20 | PRODUCT_ID, 21 | STATUS, 22 | USER_DATA, 23 | ) 24 | 25 | 26 | def test_user_data(): 27 | ud = UserData.from_dict(USER_DATA) 28 | assert ud.uid == 123456 29 | assert ud.tokentype == "token_type" 30 | assert ud.token == "abc123" 31 | assert ud.rruid == "abc123" 32 | assert ud.region == "us" 33 | assert ud.country == "US" 34 | assert ud.countrycode == "1" 35 | assert ud.nickname == "user_nickname" 36 | assert ud.rriot.u == "user123" 37 | assert ud.rriot.s == "pass123" 38 | assert ud.rriot.h == "unknown123" 39 | assert ud.rriot.k == "domain123" 40 | assert ud.rriot.r.r == "US" 41 | assert ud.rriot.r.a == "https://api-us.roborock.com" 42 | assert ud.rriot.r.m == "tcp://mqtt-us.roborock.com:8883" 43 | assert ud.rriot.r.l == "https://wood-us.roborock.com" 44 | assert ud.tuya_device_state == 2 45 | assert ud.avatarurl == "https://files.roborock.com/iottest/default_avatar.png" 46 | 47 | 48 | def test_home_data(): 49 | hd = HomeData.from_dict(HOME_DATA_RAW) 50 | assert hd.id == 123456 51 | assert hd.name == "My Home" 52 | assert hd.lon is None 53 | assert hd.lat is None 54 | assert hd.geo_name is None 55 | product = hd.products[0] 56 | assert product.id == PRODUCT_ID 57 | assert product.name == "Roborock S7 MaxV" 58 | assert product.code == "a27" 59 | assert product.model == "roborock.vacuum.a27" 60 | assert product.icon_url is None 61 | assert product.attribute is None 62 | assert product.capability == 0 63 | assert product.category == RoborockCategory.VACUUM 64 | schema = product.schema 65 | assert schema[0].id == "101" 66 | assert schema[0].name == "rpc_request" 67 | assert schema[0].code == "rpc_request_code" 68 | assert schema[0].mode == "rw" 69 | assert schema[0].type == "RAW" 70 | assert schema[0].product_property is None 71 | assert schema[0].desc is None 72 | device = hd.devices[0] 73 | assert device.duid == "abc123" 74 | assert device.name == "Roborock S7 MaxV" 75 | assert device.attribute is None 76 | assert device.active_time == 1672364449 77 | assert device.local_key == LOCAL_KEY 78 | assert device.runtime_env is None 79 | assert device.time_zone_id == "America/Los_Angeles" 80 | assert device.icon_url == "no_url" 81 | assert device.product_id == "product123" 82 | assert device.lon is None 83 | assert device.lat is None 84 | assert not device.share 85 | assert device.share_time is None 86 | assert device.online 87 | assert device.fv == "02.56.02" 88 | assert device.pv == "1.0" 89 | assert device.room_id == 2362003 90 | assert device.tuya_uuid is None 91 | assert not device.tuya_migrated 92 | assert device.extra == '{"RRPhotoPrivacyVersion": "1"}' 93 | assert device.sn == "abc123" 94 | assert device.feature_set == "2234201184108543" 95 | assert device.new_feature_set == "0000000000002041" 96 | # status = device.device_status 97 | # assert status.name == 98 | assert device.silent_ota_switch 99 | assert hd.rooms[0].id == 2362048 100 | assert hd.rooms[0].name == "Example room 1" 101 | 102 | 103 | def test_serialize_and_unserialize(): 104 | ud = UserData.from_dict(USER_DATA) 105 | ud_dict = ud.as_dict() 106 | assert ud_dict == USER_DATA 107 | 108 | 109 | def test_consumable(): 110 | c = Consumable.from_dict(CONSUMABLE) 111 | assert c.main_brush_work_time == 74382 112 | assert c.side_brush_work_time == 74383 113 | assert c.filter_work_time == 74384 114 | assert c.filter_element_work_time == 0 115 | assert c.sensor_dirty_time == 74385 116 | assert c.strainer_work_times == 65 117 | assert c.dust_collection_work_times == 25 118 | assert c.cleaning_brush_work_times == 66 119 | 120 | 121 | def test_status(): 122 | s = S7MaxVStatus.from_dict(STATUS) 123 | assert s.msg_ver == 2 124 | assert s.msg_seq == 458 125 | assert s.state == RoborockStateCode.charging 126 | assert s.battery == 100 127 | assert s.clean_time == 1176 128 | assert s.clean_area == 20965000 129 | assert s.square_meter_clean_area == 21.0 130 | assert s.error_code == RoborockErrorCode.none 131 | assert s.map_present == 1 132 | assert s.in_cleaning == 0 133 | assert s.in_returning == 0 134 | assert s.in_fresh_state == 1 135 | assert s.lab_status == 1 136 | assert s.water_box_status == 1 137 | assert s.back_type == -1 138 | assert s.wash_phase == 0 139 | assert s.wash_ready == 0 140 | assert s.fan_power == 102 141 | assert s.dnd_enabled == 0 142 | assert s.map_status == 3 143 | assert s.is_locating == 0 144 | assert s.lock_status == 0 145 | assert s.water_box_mode == 203 146 | assert s.water_box_carriage_status == 1 147 | assert s.mop_forbidden_enable == 1 148 | assert s.camera_status == 3457 149 | assert s.is_exploring == 0 150 | assert s.home_sec_status == 0 151 | assert s.home_sec_enable_password == 0 152 | assert s.adbumper_status == [0, 0, 0] 153 | assert s.water_shortage_status == 0 154 | assert s.dock_type == RoborockDockTypeCode.empty_wash_fill_dock 155 | assert s.dust_collection_status == 0 156 | assert s.auto_dust_collection == 1 157 | assert s.avoid_count == 19 158 | assert s.mop_mode == 300 159 | assert s.debug_mode == 0 160 | assert s.collision_avoid_status == 1 161 | assert s.switch_map_mode == 0 162 | assert s.dock_error_status == RoborockDockErrorCode.ok 163 | assert s.charge_status == 1 164 | assert s.unsave_map_reason == 0 165 | assert s.unsave_map_flag == 0 166 | assert s.fan_power == RoborockFanSpeedS7MaxV.balanced 167 | assert s.mop_mode == RoborockMopModeS7.standard 168 | assert s.water_box_mode == RoborockMopIntensityS7.intense 169 | 170 | 171 | def test_dnd_timer(): 172 | dnd = DnDTimer.from_dict(DND_TIMER) 173 | assert dnd.start_hour == 22 174 | assert dnd.start_minute == 0 175 | assert dnd.end_hour == 7 176 | assert dnd.end_minute == 0 177 | assert dnd.enabled == 1 178 | 179 | 180 | def test_clean_summary(): 181 | cs = CleanSummary.from_dict(CLEAN_SUMMARY) 182 | assert cs.clean_time == 74382 183 | assert cs.clean_area == 1159182500 184 | assert cs.square_meter_clean_area == 1159.2 185 | assert cs.clean_count == 31 186 | assert cs.dust_collection_count == 25 187 | assert len(cs.records) == 2 188 | assert cs.records[1] == 1672458041 189 | 190 | 191 | def test_clean_record(): 192 | cr = CleanRecord.from_dict(CLEAN_RECORD) 193 | assert cr.begin == 1672543330 194 | assert cr.end == 1672544638 195 | assert cr.duration == 1176 196 | assert cr.area == 20965000 197 | assert cr.square_meter_area == 21.0 198 | assert cr.error == 0 199 | assert cr.complete == 1 200 | assert cr.start_type == 2 201 | assert cr.clean_type == 3 202 | assert cr.finish_reason == 56 203 | assert cr.dust_collection_status == 1 204 | assert cr.avoid_count == 19 205 | assert cr.wash_count == 2 206 | assert cr.map_flag == 0 207 | 208 | 209 | def test_no_value(): 210 | modified_status = STATUS.copy() 211 | modified_status["dock_type"] = 9999 212 | s = S7MaxVStatus.from_dict(modified_status) 213 | assert s.dock_type == RoborockDockTypeCode.unknown 214 | assert -9999 not in RoborockDockTypeCode.keys() 215 | assert "missing" not in RoborockDockTypeCode.values() 216 | -------------------------------------------------------------------------------- /tests/test_local_api_v1.py: -------------------------------------------------------------------------------- 1 | """Tests for the Roborock Local Client V1.""" 2 | 3 | import json 4 | from collections.abc import AsyncGenerator 5 | from queue import Queue 6 | from typing import Any 7 | from unittest.mock import patch 8 | 9 | import pytest 10 | 11 | from roborock.containers import RoomMapping 12 | from roborock.protocol import MessageParser 13 | from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol 14 | from roborock.version_1_apis import RoborockLocalClientV1 15 | 16 | from .mock_data import LOCAL_KEY 17 | 18 | 19 | def build_rpc_response(seq: int, message: dict[str, Any]) -> bytes: 20 | """Build an encoded RPC response message.""" 21 | return build_raw_response( 22 | protocol=RoborockMessageProtocol.GENERAL_REQUEST, 23 | seq=seq, 24 | payload=json.dumps( 25 | { 26 | "dps": {102: json.dumps(message)}, 27 | } 28 | ).encode(), 29 | ) 30 | 31 | 32 | def build_raw_response(protocol: RoborockMessageProtocol, seq: int, payload: bytes) -> bytes: 33 | """Build an encoded RPC response message.""" 34 | message = RoborockMessage( 35 | protocol=protocol, 36 | random=23, 37 | seq=seq, 38 | payload=payload, 39 | ) 40 | return MessageParser.build(message, local_key=LOCAL_KEY) 41 | 42 | 43 | async def test_async_connect( 44 | local_client: RoborockLocalClientV1, 45 | received_requests: Queue, 46 | response_queue: Queue, 47 | ): 48 | """Test that we can connect to the Roborock device.""" 49 | response_queue.put(build_raw_response(RoborockMessageProtocol.HELLO_RESPONSE, 1, b"ignored")) 50 | response_queue.put(build_raw_response(RoborockMessageProtocol.PING_RESPONSE, 2, b"ignored")) 51 | 52 | await local_client.async_connect() 53 | assert local_client.is_connected() 54 | assert received_requests.qsize() == 2 55 | 56 | await local_client.async_disconnect() 57 | assert not local_client.is_connected() 58 | 59 | 60 | @pytest.fixture(name="connected_local_client") 61 | async def connected_local_client_fixture( 62 | response_queue: Queue, 63 | local_client: RoborockLocalClientV1, 64 | ) -> AsyncGenerator[RoborockLocalClientV1, None]: 65 | response_queue.put(build_raw_response(RoborockMessageProtocol.HELLO_RESPONSE, 1, b"ignored")) 66 | response_queue.put(build_raw_response(RoborockMessageProtocol.PING_RESPONSE, 2, b"ignored")) 67 | await local_client.async_connect() 68 | yield local_client 69 | 70 | 71 | async def test_get_room_mapping( 72 | received_requests: Queue, 73 | response_queue: Queue, 74 | connected_local_client: RoborockLocalClientV1, 75 | ) -> None: 76 | """Test sending an arbitrary MQTT message and parsing the response.""" 77 | 78 | test_request_id = 5050 79 | 80 | message = build_rpc_response( 81 | seq=test_request_id, 82 | message={ 83 | "id": test_request_id, 84 | "result": [[16, "2362048"], [17, "2362044"]], 85 | }, 86 | ) 87 | response_queue.put(message) 88 | 89 | with patch("roborock.version_1_apis.roborock_client_v1.get_next_int", return_value=test_request_id): 90 | room_mapping = await connected_local_client.get_room_mapping() 91 | 92 | assert room_mapping == [ 93 | RoomMapping(segment_id=16, iot_id="2362048"), 94 | RoomMapping(segment_id=17, iot_id="2362044"), 95 | ] 96 | -------------------------------------------------------------------------------- /tests/test_queue.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import pytest 4 | 5 | from roborock.exceptions import VacuumError 6 | from roborock.roborock_future import RoborockFuture 7 | 8 | 9 | def test_can_create(): 10 | RoborockFuture(1) 11 | 12 | 13 | @pytest.mark.asyncio 14 | async def test_set_result(): 15 | rq = RoborockFuture(1) 16 | rq.set_result("test") 17 | assert await rq.async_get(1) == "test" 18 | 19 | 20 | @pytest.mark.asyncio 21 | async def test_set_exception(): 22 | rq = RoborockFuture(1) 23 | rq.set_exception(VacuumError("test")) 24 | with pytest.raises(VacuumError): 25 | assert await rq.async_get(1) 26 | 27 | 28 | @pytest.mark.asyncio 29 | async def test_get_timeout(): 30 | rq = RoborockFuture(1) 31 | with pytest.raises(asyncio.TimeoutError): 32 | await rq.async_get(0.01) 33 | -------------------------------------------------------------------------------- /tests/test_roborock_message.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from freezegun import freeze_time 4 | 5 | from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol 6 | 7 | 8 | def test_roborock_message() -> None: 9 | """Test the RoborockMessage class is initialized.""" 10 | with freeze_time("2025-01-20T12:00:00"): 11 | message1 = RoborockMessage( 12 | protocol=RoborockMessageProtocol.RPC_REQUEST, 13 | payload=json.dumps({"dps": {"101": json.dumps({"id": 4321})}}).encode(), 14 | message_retry=None, 15 | ) 16 | assert message1.get_request_id() == 4321 17 | 18 | with freeze_time("2025-01-20T11:00:00"): # Back in time 1hr to test timestamp 19 | message2 = RoborockMessage( 20 | protocol=RoborockMessageProtocol.RPC_RESPONSE, 21 | payload=json.dumps({"dps": {"94": json.dumps({"id": 444}), "102": json.dumps({"id": 333})}}).encode(), 22 | message_retry=None, 23 | ) 24 | assert message2.get_request_id() == 333 25 | 26 | # Ensure the sequence, random numbers, etc are initialized properly 27 | assert message1.seq != message2.seq 28 | assert message1.random != message2.random 29 | assert message1.timestamp > message2.timestamp 30 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from roborock.util import parse_time_to_datetime 6 | 7 | 8 | @pytest.mark.skip 9 | def validate(start: datetime.datetime, end: datetime.datetime) -> bool: 10 | duration = end - start 11 | return duration > datetime.timedelta() 12 | 13 | 14 | # start_date < now < end_date 15 | def test_start_date_lower_than_now_lower_than_end_date(): 16 | start, end = parse_time_to_datetime( 17 | (datetime.datetime.now() - datetime.timedelta(hours=2)).time(), 18 | (datetime.datetime.now() - datetime.timedelta(hours=1)).time(), 19 | ) 20 | assert validate(start, end) 21 | 22 | 23 | # start_date > now > end_date 24 | def test_start_date_greater_than_now_greater_tat_end_date(): 25 | start, end = parse_time_to_datetime( 26 | (datetime.datetime.now() + datetime.timedelta(hours=1)).time(), 27 | (datetime.datetime.now() + datetime.timedelta(hours=2)).time(), 28 | ) 29 | assert validate(start, end) 30 | 31 | 32 | # start_date < now > end_date 33 | def test_start_date_lower_than_now_greater_than_end_date(): 34 | start, end = parse_time_to_datetime( 35 | (datetime.datetime.now() - datetime.timedelta(hours=1)).time(), 36 | (datetime.datetime.now() + datetime.timedelta(hours=1)).time(), 37 | ) 38 | assert validate(start, end) 39 | 40 | 41 | # start_date > now < end_date 42 | def test_start_date_greater_than_now_lower_than_end_date(): 43 | start, end = parse_time_to_datetime( 44 | (datetime.datetime.now() + datetime.timedelta(hours=1)).time(), 45 | (datetime.datetime.now() - datetime.timedelta(hours=1)).time(), 46 | ) 47 | assert validate(start, end) 48 | 49 | 50 | # start_date < end_date < now 51 | def test_start_date_lower_than_end_date_lower_than_now(): 52 | start, end = parse_time_to_datetime( 53 | (datetime.datetime.now() - datetime.timedelta(hours=2)).time(), 54 | (datetime.datetime.now() - datetime.timedelta(hours=1)).time(), 55 | ) 56 | assert validate(start, end) 57 | 58 | 59 | # start_date > end_date > now 60 | def test_start_date_greater_than_end_date_greater_than_now(): 61 | start, end = parse_time_to_datetime( 62 | (datetime.datetime.now() + datetime.timedelta(hours=2)).time(), 63 | (datetime.datetime.now() + datetime.timedelta(hours=1)).time(), 64 | ) 65 | assert validate(start, end) 66 | -------------------------------------------------------------------------------- /tests/test_web_api.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | 3 | from roborock import HomeData, HomeDataScene, UserData 4 | from roborock.web_api import RoborockApiClient 5 | from tests.mock_data import HOME_DATA_RAW, USER_DATA 6 | 7 | 8 | async def test_pass_login_flow() -> None: 9 | """Test that we can login with a password and we get back the correct userdata object.""" 10 | my_session = aiohttp.ClientSession() 11 | api = RoborockApiClient(username="test_user@gmail.com", session=my_session) 12 | ud = await api.pass_login("password") 13 | assert ud == UserData.from_dict(USER_DATA) 14 | assert not my_session.closed 15 | 16 | 17 | async def test_code_login_flow() -> None: 18 | """Test that we can login with a code and we get back the correct userdata object.""" 19 | api = RoborockApiClient(username="test_user@gmail.com") 20 | await api.request_code() 21 | ud = await api.code_login(4123) 22 | assert ud == UserData.from_dict(USER_DATA) 23 | 24 | 25 | async def test_get_home_data_v2(): 26 | """Test a full standard flow where we get the home data to end it off. 27 | This matches what HA does""" 28 | api = RoborockApiClient(username="test_user@gmail.com") 29 | await api.request_code() 30 | ud = await api.code_login(4123) 31 | hd = await api.get_home_data_v2(ud) 32 | assert hd == HomeData.from_dict(HOME_DATA_RAW) 33 | 34 | 35 | async def test_nc_prepare(): 36 | """Test adding a device and that nothing breaks""" 37 | api = RoborockApiClient(username="test_user@gmail.com") 38 | await api.request_code() 39 | ud = await api.code_login(4123) 40 | prepare = await api.nc_prepare(ud, "America/New_York") 41 | new_device = await api.add_device(ud, prepare["s"], prepare["t"]) 42 | assert new_device["duid"] == "rand_duid" 43 | 44 | 45 | async def test_get_scenes(): 46 | """Test that we can get scenes""" 47 | api = RoborockApiClient(username="test_user@gmail.com") 48 | ud = await api.pass_login("password") 49 | sc = await api.get_scenes(ud, "123456") 50 | assert sc == [ 51 | HomeDataScene.from_dict( 52 | { 53 | "id": 1234567, 54 | "name": "My plan", 55 | } 56 | ) 57 | ] 58 | 59 | 60 | async def test_execute_scene(mock_rest): 61 | """Test that we can execute a scene""" 62 | api = RoborockApiClient(username="test_user@gmail.com") 63 | ud = await api.pass_login("password") 64 | await api.execute_scene(ud, 123456) 65 | mock_rest.assert_any_call("https://api-us.roborock.com/user/scene/123456/execute", "post") 66 | --------------------------------------------------------------------------------