├── .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 |
6 |
7 |
8 |
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 |
--------------------------------------------------------------------------------