pax_global_header00006660000000000000000000000064151336364320014520gustar00rootroot0000000000000052 comment=70937470026e9032c2ebd6920a4c2511d7137a7a Python-roborock-python-roborock-d6da2db/000077500000000000000000000000001513363643200205245ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/.github/000077500000000000000000000000001513363643200220645ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/.github/dependabot.yml000066400000000000000000000005341513363643200247160ustar00rootroot00000000000000# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" - package-ecosystem: "uv" directory: "/" schedule: interval: "weekly" Python-roborock-python-roborock-d6da2db/.github/workflows/000077500000000000000000000000001513363643200241215ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/.github/workflows/ci.yml000066400000000000000000000103701513363643200252400ustar00rootroot00000000000000name: CI on: push: branches: - main pull_request: concurrency: group: ${{ github.head_ref || github.run_id }} cancel-in-progress: true jobs: # Make sure commit messages follow the conventional commits convention: # https://www.conventionalcommits.org commitlint: name: Lint Commit Messages runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: wagoid/commitlint-github-action@v6.2.1 lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: python-version: "3.11" - uses: pre-commit/action@v3.0.1 test: strategy: fail-fast: false matrix: python-version: - "3.11" - "3.14" runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Set up uv uses: astral-sh/setup-uv@v7 with: python-version: ${{ matrix.python-version }} activate-environment: true - run: uv pip install pip - name: Test with Pytest run: uv run pytest --log-cli-level=DEBUG -vv -s --cov --cov-branch --cov-report=xml shell: bash - name: Upload results to Codecov uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} slug: Python-roborock/python-roborock build: name: Build Package runs-on: ubuntu-latest if: github.ref != 'refs/heads/main' steps: - uses: actions/checkout@v6 - name: Set up uv uses: astral-sh/setup-uv@v7 with: python-version: "3.11" activate-environment: true - name: Build package run: uv build # Test semantic-release configuration on PR branches test-release: name: Test Semantic Release runs-on: ubuntu-latest if: github.event_name == 'pull_request' steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Setup Git for Semantic Release Testing run: | # Setup a temp branch to test git checkout -B temp-main-branch git merge ${{ github.event.pull_request.head.sha }} --no-edit git config user.name "GitHub Actions" git config user.email "actions@github.com" - name: Test Semantic Release (No-op) id: test-release uses: python-semantic-release/python-semantic-release@v10.5.3 with: github_token: ${{ secrets.GITHUB_TOKEN }} changelog: true # Use noop mode to test without making changes no_operation_mode: true - name: Test Semantic Release (Dry Run) id: test-release-dry uses: python-semantic-release/python-semantic-release@v10.5.3 with: github_token: ${{ secrets.GITHUB_TOKEN }} changelog: true # Use dry run mode to test without committing commit: false tag: false push: false vcs_release: false release: runs-on: ubuntu-latest needs: - test concurrency: release if: github.ref == 'refs/heads/main' permissions: contents: write issues: write pull-requests: write id-token: write actions: write packages: write environment: name: release steps: - uses: actions/checkout@v6 with: fetch-depth: 0 persist-credentials: false - name: Python Semantic Release id: release uses: python-semantic-release/python-semantic-release@v10.5.3 with: github_token: ${{ secrets.GH_TOKEN }} changelog: true - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@v1.13.0 # NOTE: DO NOT wrap the conditional in ${{ }} as it will always evaluate to true. # See https://github.com/actions/runner/issues/1173 if: steps.release.outputs.released == 'true' with: packages-dir: dist print-hash: true verbose: true - name: Publish package distributions to GitHub Releases uses: python-semantic-release/publish-action@v10.5.3 if: steps.release.outputs.released == 'true' with: github_token: ${{ secrets.GITHUB_TOKEN }} tag: ${{ steps.release.outputs.tag }} Python-roborock-python-roborock-d6da2db/.github/workflows/pages.yml000066400000000000000000000017761513363643200257560ustar00rootroot00000000000000--- name: Deploy static content to Pages on: push: branches: - main workflow_dispatch: permissions: contents: read pages: write id-token: write actions: read concurrency: group: "pages" cancel-in-progress: true jobs: deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest strategy: fail-fast: false steps: - uses: actions/checkout@v6 - name: Set up uv uses: astral-sh/setup-uv@v7 with: python-version: ${{ matrix.python-version }} activate-environment: true - run: uv pip install . - run: uv run pdoc ./roborock -o docs/pdoc - name: Setup Pages uses: actions/configure-pages@v5 - name: Upload artifact uses: actions/upload-pages-artifact@v4 with: # Upload pdoc output path: 'docs/pdoc/' - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 Python-roborock-python-roborock-d6da2db/.gitignore000066400000000000000000000003401513363643200225110ustar00rootroot00000000000000dist venv .venv .idea roborock/__pycache__ *.pyc .coverage # Sphinx documentation docs/_build/ # mkdocs documentation /site /docs/build/ .DS_Store # gemini-cli settings .gemini/ # GitHub App credentials gha-creds-*.json Python-roborock-python-roborock-d6da2db/.pre-commit-config.yaml000066400000000000000000000032551513363643200250120ustar00rootroot00000000000000# See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks exclude: "CHANGELOG.md" default_stages: [ pre-commit ] repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 hooks: - id: debug-statements - id: check-builtin-literals - id: check-case-conflict - id: check-docstring-first - id: check-json - id: check-toml - id: check-yaml - id: detect-private-key - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/astral-sh/uv-pre-commit rev: 0.9.1 hooks: - id: uv-sync args: ["--locked", "--all-packages"] - repo: https://github.com/codespell-project/codespell rev: v2.2.6 hooks: - id: codespell exclude: > (?x)^( .*\.ambr )$ - repo: https://github.com/charliermarsh/ruff-pre-commit rev: v0.13.2 hooks: - id: ruff-format - id: ruff args: - --fix - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.7.1 hooks: - id: mypy exclude: cli.py additional_dependencies: [ "types-paho-mqtt" ] - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook rev: v9.23.0 hooks: - id: commitlint stages: [commit-msg] additional_dependencies: ['@commitlint/config-conventional'] Python-roborock-python-roborock-d6da2db/.readthedocs.yaml000066400000000000000000000002001513363643200237430ustar00rootroot00000000000000version: 2 build: os: ubuntu-22.04 tools: python: "3.10" python: install: - requirements: docs/requirements.txt Python-roborock-python-roborock-d6da2db/.vscode/000077500000000000000000000000001513363643200220655ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/.vscode/launch.json000066400000000000000000000004501513363643200242310ustar00rootroot00000000000000{ "version": "0.2.0", "configurations": [ { "name": "Python: Current File", "type": "python", "request": "launch", "program": "${file}", "console": "integratedTerminal", "justMyCode": false } ] } Python-roborock-python-roborock-d6da2db/.vscode/settings.json000066400000000000000000000000451513363643200246170ustar00rootroot00000000000000{ "esbonio.sphinx.confDir": "" } Python-roborock-python-roborock-d6da2db/CHANGELOG.md000066400000000000000000010044371513363643200223460ustar00rootroot00000000000000# CHANGELOG ## v4.7.2 (2026-01-20) ### Bug Fixes - Handle different error format for map status ([#744](https://github.com/Python-roborock/python-roborock/pull/744), [`9897379`](https://github.com/Python-roborock/python-roborock/commit/98973795af550ed7940c9c637c85adc84ec5a511)) ## v4.7.1 (2026-01-19) ### Bug Fixes - Add rooms from map_info which is occassionally available ([#750](https://github.com/Python-roborock/python-roborock/pull/750), [`814054e`](https://github.com/Python-roborock/python-roborock/commit/814054ee4200c5f172d3f658843a9c8ee99c7f52)) ## v4.7.0 (2026-01-18) ### Chores - Address PR comments ([#747](https://github.com/Python-roborock/python-roborock/pull/747), [`a97e90a`](https://github.com/Python-roborock/python-roborock/commit/a97e90aa11b4e60732014d8d65265a334568f32c)) - Include snapshots ([#747](https://github.com/Python-roborock/python-roborock/pull/747), [`a97e90a`](https://github.com/Python-roborock/python-roborock/commit/a97e90aa11b4e60732014d8d65265a334568f32c)) - **deps-dev**: Bump ruff from 0.14.10 to 0.14.11 ([#742](https://github.com/Python-roborock/python-roborock/pull/742), [`9274642`](https://github.com/Python-roborock/python-roborock/commit/92746429ddb029e20073dab127598645a223c856)) ### Features - Add from diagnostics ([#747](https://github.com/Python-roborock/python-roborock/pull/747), [`a97e90a`](https://github.com/Python-roborock/python-roborock/commit/a97e90aa11b4e60732014d8d65265a334568f32c)) - Improve device_info ([#747](https://github.com/Python-roborock/python-roborock/pull/747), [`a97e90a`](https://github.com/Python-roborock/python-roborock/commit/a97e90aa11b4e60732014d8d65265a334568f32c)) ## v4.6.0 (2026-01-18) ### Chores - **deps**: Bump aiohttp from 3.13.2 to 3.13.3 ([#732](https://github.com/Python-roborock/python-roborock/pull/732), [`e438364`](https://github.com/Python-roborock/python-roborock/commit/e438364e7619b2e9658cdffeace9b2b6e4e19269)) ### Features - Add 2 new states for zeostate in zeo_code_mappings ([#689](https://github.com/Python-roborock/python-roborock/pull/689), [`3482e4e`](https://github.com/Python-roborock/python-roborock/commit/3482e4eaafcea7dbc004c28e094e260cdf822e79)) ## v4.5.0 (2026-01-14) ### Chores - Add test ([#743](https://github.com/Python-roborock/python-roborock/pull/743), [`e26e351`](https://github.com/Python-roborock/python-roborock/commit/e26e351474a006485c6a7b5a5dcdbbe9fab8572e)) ### Features - Raise no account error when bad login ([#743](https://github.com/Python-roborock/python-roborock/pull/743), [`e26e351`](https://github.com/Python-roborock/python-roborock/commit/e26e351474a006485c6a7b5a5dcdbbe9fab8572e)) ## v4.4.0 (2026-01-12) ### Features - Iterate possible iot domains on 3030 error ([#733](https://github.com/Python-roborock/python-roborock/pull/733), [`f2e1d51`](https://github.com/Python-roborock/python-roborock/commit/f2e1d5156dd905e296d5ed38605d4fd6f97bfbb4)) ## v4.3.0 (2026-01-10) ### Chores - Add function to create field metadata ([#740](https://github.com/Python-roborock/python-roborock/pull/740), [`bdc1591`](https://github.com/Python-roborock/python-roborock/commit/bdc159192cfb2afa02199171288a20b228abb7f6)) - Simplify supported_schema_codes ([#740](https://github.com/Python-roborock/python-roborock/pull/740), [`bdc1591`](https://github.com/Python-roborock/python-roborock/commit/bdc159192cfb2afa02199171288a20b228abb7f6)) - Update pydoc for DeviceFeaturesTrait ([#740](https://github.com/Python-roborock/python-roborock/pull/740), [`bdc1591`](https://github.com/Python-roborock/python-roborock/commit/bdc159192cfb2afa02199171288a20b228abb7f6)) - Update test descrition ([#740](https://github.com/Python-roborock/python-roborock/pull/740), [`bdc1591`](https://github.com/Python-roborock/python-roborock/commit/bdc159192cfb2afa02199171288a20b228abb7f6)) - Update to use StrEnum ([#740](https://github.com/Python-roborock/python-roborock/pull/740), [`bdc1591`](https://github.com/Python-roborock/python-roborock/commit/bdc159192cfb2afa02199171288a20b228abb7f6)) ### Features - Add an approach for determining if a dataclass field is supported ([#740](https://github.com/Python-roborock/python-roborock/pull/740), [`bdc1591`](https://github.com/Python-roborock/python-roborock/commit/bdc159192cfb2afa02199171288a20b228abb7f6)) ## v4.2.2 (2026-01-09) ### Bug Fixes - Decrease home data rate limits ([#741](https://github.com/Python-roborock/python-roborock/pull/741), [`29eb984`](https://github.com/Python-roborock/python-roborock/commit/29eb984d22494b08f26ec6e220b7c823b67d3242)) ### Chores - Add additional Home data to diagnostics ([#723](https://github.com/Python-roborock/python-roborock/pull/723), [`c29dfc8`](https://github.com/Python-roborock/python-roborock/commit/c29dfc81f4de1bb293b2918482cf681197ef3698)) - Add CONTRIBUTING.md ([#734](https://github.com/Python-roborock/python-roborock/pull/734), [`881b7d6`](https://github.com/Python-roborock/python-roborock/commit/881b7d687789c57eec20bf9011a195b4befff129)) - Add CONTRIBUTINGmd ([#734](https://github.com/Python-roborock/python-roborock/pull/734), [`881b7d6`](https://github.com/Python-roborock/python-roborock/commit/881b7d687789c57eec20bf9011a195b4befff129)) - Add s5e device and product data examples ([#737](https://github.com/Python-roborock/python-roborock/pull/737), [`586bb3f`](https://github.com/Python-roborock/python-roborock/commit/586bb3f77e4655d4aae2d201746980b1c227160d)) - Add Saros 10R API response data ([#726](https://github.com/Python-roborock/python-roborock/pull/726), [`fafc8d8`](https://github.com/Python-roborock/python-roborock/commit/fafc8d86833a2aac3ee69c7a1f353f83551eeb6f)) - Fix diagnostic lint issues ([#723](https://github.com/Python-roborock/python-roborock/pull/723), [`c29dfc8`](https://github.com/Python-roborock/python-roborock/commit/c29dfc81f4de1bb293b2918482cf681197ef3698)) - Fix mock data lint ([#726](https://github.com/Python-roborock/python-roborock/pull/726), [`fafc8d8`](https://github.com/Python-roborock/python-roborock/commit/fafc8d86833a2aac3ee69c7a1f353f83551eeb6f)) - Fix schema redaction ([#723](https://github.com/Python-roborock/python-roborock/pull/723), [`c29dfc8`](https://github.com/Python-roborock/python-roborock/commit/c29dfc81f4de1bb293b2918482cf681197ef3698)) - Improve redaction logic to support more complex paths ([#723](https://github.com/Python-roborock/python-roborock/pull/723), [`c29dfc8`](https://github.com/Python-roborock/python-roborock/commit/c29dfc81f4de1bb293b2918482cf681197ef3698)) - Remove duplicate data in test_q7_device ([#736](https://github.com/Python-roborock/python-roborock/pull/736), [`cd6cbbe`](https://github.com/Python-roborock/python-roborock/commit/cd6cbbe1be22a619a88d76783c60c936dbbc744d)) - Update device snapshots and lint errors ([#723](https://github.com/Python-roborock/python-roborock/pull/723), [`c29dfc8`](https://github.com/Python-roborock/python-roborock/commit/c29dfc81f4de1bb293b2918482cf681197ef3698)) - Update e2e tests for q7 to use different product data ([#736](https://github.com/Python-roborock/python-roborock/pull/736), [`cd6cbbe`](https://github.com/Python-roborock/python-roborock/commit/cd6cbbe1be22a619a88d76783c60c936dbbc744d)) - Update end to end q7 tests ([#736](https://github.com/Python-roborock/python-roborock/pull/736), [`cd6cbbe`](https://github.com/Python-roborock/python-roborock/commit/cd6cbbe1be22a619a88d76783c60c936dbbc744d)) - Update steps to activate virtual environment ([#734](https://github.com/Python-roborock/python-roborock/pull/734), [`881b7d6`](https://github.com/Python-roborock/python-roborock/commit/881b7d687789c57eec20bf9011a195b4befff129)) - Use built-in as_dict method for creating diagnostic data ([#723](https://github.com/Python-roborock/python-roborock/pull/723), [`c29dfc8`](https://github.com/Python-roborock/python-roborock/commit/c29dfc81f4de1bb293b2918482cf681197ef3698)) ## v4.2.1 (2026-01-05) ### Bug Fixes - Bump aiomqtt ([#730](https://github.com/Python-roborock/python-roborock/pull/730), [`21af4f3`](https://github.com/Python-roborock/python-roborock/commit/21af4f30412d96eb5ac53f372b74b0e03ca6580e)) ### Chores - Add a01 and b01 q7 byte level tests ([#724](https://github.com/Python-roborock/python-roborock/pull/724), [`f20ade9`](https://github.com/Python-roborock/python-roborock/commit/f20ade97843241aa286405c4eacbb9f1939cbdf3)) - Add docs for v1 device features ([#727](https://github.com/Python-roborock/python-roborock/pull/727), [`f031acf`](https://github.com/Python-roborock/python-roborock/commit/f031acffa2381c2eb9e4af6fbf7967ae22b1d7dc)) - Documentation cleanup and updates ([#725](https://github.com/Python-roborock/python-roborock/pull/725), [`bbeb0d9`](https://github.com/Python-roborock/python-roborock/commit/bbeb0d95e11274bd024cfac23988f01acf814888)) - Remove empty line in device features documentation ([#727](https://github.com/Python-roborock/python-roborock/pull/727), [`f031acf`](https://github.com/Python-roborock/python-roborock/commit/f031acffa2381c2eb9e4af6fbf7967ae22b1d7dc)) - Remove some information from the summart ([#727](https://github.com/Python-roborock/python-roborock/pull/727), [`f031acf`](https://github.com/Python-roborock/python-roborock/commit/f031acffa2381c2eb9e4af6fbf7967ae22b1d7dc)) - Restructure the channel modules ([#728](https://github.com/Python-roborock/python-roborock/pull/728), [`9fcc0a8`](https://github.com/Python-roborock/python-roborock/commit/9fcc0a8ca075097b7d903a57cc0fc33ed149bd97)) ## v4.2.0 (2025-12-30) ### Chores - Add end to end tests for Q10 devices ([#721](https://github.com/Python-roborock/python-roborock/pull/721), [`8d76119`](https://github.com/Python-roborock/python-roborock/commit/8d761194bc1daaa82564fc49e3ef63f85a209dba)) - Remove unused timeout field ([#721](https://github.com/Python-roborock/python-roborock/pull/721), [`8d76119`](https://github.com/Python-roborock/python-roborock/commit/8d761194bc1daaa82564fc49e3ef63f85a209dba)) ### Features - Recognize Q10 devices and add a command trait ([#721](https://github.com/Python-roborock/python-roborock/pull/721), [`8d76119`](https://github.com/Python-roborock/python-roborock/commit/8d761194bc1daaa82564fc49e3ef63f85a209dba)) ## v4.1.1 (2025-12-29) ### Bug Fixes - Fix CLI to no longer depend on old API ([#717](https://github.com/Python-roborock/python-roborock/pull/717), [`a4fde4a`](https://github.com/Python-roborock/python-roborock/commit/a4fde4a1756dee6d631a1eab24e0a57bf68af6e6)) ### Chores - Fix cli lint errors ([#717](https://github.com/Python-roborock/python-roborock/pull/717), [`a4fde4a`](https://github.com/Python-roborock/python-roborock/commit/a4fde4a1756dee6d631a1eab24e0a57bf68af6e6)) ## v4.1.0 (2025-12-29) ### Bug Fixes - Return self for classmethods of roborockmodeenum ([#720](https://github.com/Python-roborock/python-roborock/pull/720), [`0cc41e8`](https://github.com/Python-roborock/python-roborock/commit/0cc41e8127740b5f763d7dd2735e7427e4ae9afe)) ### Features - Expose prefer-cache to create_device_manager caller ([#719](https://github.com/Python-roborock/python-roborock/pull/719), [`1d098d6`](https://github.com/Python-roborock/python-roborock/commit/1d098d6775d86a8ffd425d42bf2a6f8cd8bcc9a7)) ## v4.0.2 (2025-12-29) ### Bug Fixes - Add b01 q10 protocol encoding/decoding and tests ([#718](https://github.com/Python-roborock/python-roborock/pull/718), [`656f715`](https://github.com/Python-roborock/python-roborock/commit/656f715807c7605e9b0ce674c12b4fd0ad4a549f)) - Support unknown q10 DPS enum codes ([#718](https://github.com/Python-roborock/python-roborock/pull/718), [`656f715`](https://github.com/Python-roborock/python-roborock/commit/656f715807c7605e9b0ce674c12b4fd0ad4a549f)) ## v4.0.1 (2025-12-29) ### Bug Fixes - Fix wind and water mappings for Q7 ([#716](https://github.com/Python-roborock/python-roborock/pull/716), [`421a9c4`](https://github.com/Python-roborock/python-roborock/commit/421a9c4970e8dc8e30552025ad37326d318476fe)) - Fix wind and water mappings for Q7 (#715) ([#716](https://github.com/Python-roborock/python-roborock/pull/716), [`421a9c4`](https://github.com/Python-roborock/python-roborock/commit/421a9c4970e8dc8e30552025ad37326d318476fe)) - Improve device startup connection reliability for L01 devices ([#708](https://github.com/Python-roborock/python-roborock/pull/708), [`9cf83a4`](https://github.com/Python-roborock/python-roborock/commit/9cf83a4a762e03e70ed59fb5b1c1982ff52b43b2)) - Update device startup connection behavior ([#708](https://github.com/Python-roborock/python-roborock/pull/708), [`9cf83a4`](https://github.com/Python-roborock/python-roborock/commit/9cf83a4a762e03e70ed59fb5b1c1982ff52b43b2)) ### Chores - Update tests/e2e/test_device_manager.py ([#708](https://github.com/Python-roborock/python-roborock/pull/708), [`9cf83a4`](https://github.com/Python-roborock/python-roborock/commit/9cf83a4a762e03e70ed59fb5b1c1982ff52b43b2)) ## v4.0.0 (2025-12-29) ### Bug Fixes - Allow startup with unsupported devices ([#707](https://github.com/Python-roborock/python-roborock/pull/707), [`7e40857`](https://github.com/Python-roborock/python-roborock/commit/7e40857d0e723f73e4501e7be6068ffa12ebd086)) - Properly shutdown the context in the CLI ([#710](https://github.com/Python-roborock/python-roborock/pull/710), [`bf31b9b`](https://github.com/Python-roborock/python-roborock/commit/bf31b9b5e7bc22b04e15791cbbcca47e08bcef34)) ### Chores - Add an end to end device manager test ([#705](https://github.com/Python-roborock/python-roborock/pull/705), [`5e5b9d3`](https://github.com/Python-roborock/python-roborock/commit/5e5b9d38a542a34b486edd21a0fc27fbea9221ef)) - Add end to end tests of the device cache ([#705](https://github.com/Python-roborock/python-roborock/pull/705), [`5e5b9d3`](https://github.com/Python-roborock/python-roborock/commit/5e5b9d38a542a34b486edd21a0fc27fbea9221ef)) - Add explicit Q7 request message handling code ([#712](https://github.com/Python-roborock/python-roborock/pull/712), [`a0aee33`](https://github.com/Python-roborock/python-roborock/commit/a0aee338539a060b31b156d607afa9d476e31f95)) - Apply suggestions from code review ([#707](https://github.com/Python-roborock/python-roborock/pull/707), [`7e40857`](https://github.com/Python-roborock/python-roborock/commit/7e40857d0e723f73e4501e7be6068ffa12ebd086)) - Fix exception catching ([#710](https://github.com/Python-roborock/python-roborock/pull/710), [`bf31b9b`](https://github.com/Python-roborock/python-roborock/commit/bf31b9b5e7bc22b04e15791cbbcca47e08bcef34)) - Fix formatting in tests. ([#714](https://github.com/Python-roborock/python-roborock/pull/714), [`e00ce88`](https://github.com/Python-roborock/python-roborock/commit/e00ce886ba8012189c88e3f3a01b8f5d8cb4124e)) - Fix lint errors in code mappings test ([#711](https://github.com/Python-roborock/python-roborock/pull/711), [`4725574`](https://github.com/Python-roborock/python-roborock/commit/4725574cb8f14c13e5b66e5051da83d6f2670456)) - Fix lint errors in q7 protocol tests ([#712](https://github.com/Python-roborock/python-roborock/pull/712), [`a0aee33`](https://github.com/Python-roborock/python-roborock/commit/a0aee338539a060b31b156d607afa9d476e31f95)) - Fix lint formatting ([#707](https://github.com/Python-roborock/python-roborock/pull/707), [`7e40857`](https://github.com/Python-roborock/python-roborock/commit/7e40857d0e723f73e4501e7be6068ffa12ebd086)) - Fix protocol test paths ([#712](https://github.com/Python-roborock/python-roborock/pull/712), [`a0aee33`](https://github.com/Python-roborock/python-roborock/commit/a0aee338539a060b31b156d607afa9d476e31f95)) - Improve error handling for session loop ([#710](https://github.com/Python-roborock/python-roborock/pull/710), [`bf31b9b`](https://github.com/Python-roborock/python-roborock/commit/bf31b9b5e7bc22b04e15791cbbcca47e08bcef34)) - Split up test_containers.py into data subdirectories ([#714](https://github.com/Python-roborock/python-roborock/pull/714), [`e00ce88`](https://github.com/Python-roborock/python-roborock/commit/e00ce886ba8012189c88e3f3a01b8f5d8cb4124e)) - Update diagnostics counters ([#707](https://github.com/Python-roborock/python-roborock/pull/707), [`7e40857`](https://github.com/Python-roborock/python-roborock/commit/7e40857d0e723f73e4501e7be6068ffa12ebd086)) - Update error building tests ([#712](https://github.com/Python-roborock/python-roborock/pull/712), [`a0aee33`](https://github.com/Python-roborock/python-roborock/commit/a0aee338539a060b31b156d607afa9d476e31f95)) ### Features - Allow RoborockModeEnum parsing by either enum name, value name, or code ([#711](https://github.com/Python-roborock/python-roborock/pull/711), [`4725574`](https://github.com/Python-roborock/python-roborock/commit/4725574cb8f14c13e5b66e5051da83d6f2670456)) - Allow RoborockModeEnum parsing by either enum name, value name, or code. ([#711](https://github.com/Python-roborock/python-roborock/pull/711), [`4725574`](https://github.com/Python-roborock/python-roborock/commit/4725574cb8f14c13e5b66e5051da83d6f2670456)) - **api**: Remove original Cloud and Local APIs ([#713](https://github.com/Python-roborock/python-roborock/pull/713), [`557810f`](https://github.com/Python-roborock/python-roborock/commit/557810f2d7ad4f56c94d6a981223f90bafdd0b5a)) ### Breaking Changes - **api**: Removes older cloud and local APIs. ## v3.21.1 (2025-12-24) ### Bug Fixes - Fix typing for send() for q7 ([#706](https://github.com/Python-roborock/python-roborock/pull/706), [`1d32f2e`](https://github.com/Python-roborock/python-roborock/commit/1d32f2ef438f34286bb0ed1714d0e7479851a8a8)) ## v3.21.0 (2025-12-23) ### Bug Fixes - Add a hook for handling background rate limit errors ([#695](https://github.com/Python-roborock/python-roborock/pull/695), [`e38bc9f`](https://github.com/Python-roborock/python-roborock/commit/e38bc9f10bad27b9622d1f6216339426e00d239d)) ### Chores - Add protocol snapshot tests for the mqtt and local e2e tests ([#697](https://github.com/Python-roborock/python-roborock/pull/697), [`6293a67`](https://github.com/Python-roborock/python-roborock/commit/6293a676e508cb42acf17852c37bf6f69547636a)) - Add protocol snapshot tests for the mqtt and local e2e tests. ([#697](https://github.com/Python-roborock/python-roborock/pull/697), [`6293a67`](https://github.com/Python-roborock/python-roborock/commit/6293a676e508cb42acf17852c37bf6f69547636a)) - Address co-pilot review feedback ([#699](https://github.com/Python-roborock/python-roborock/pull/699), [`c317f8e`](https://github.com/Python-roborock/python-roborock/commit/c317f8e4e6d4deda755b511f0c382db7fd68b911)) - Fix lint ([#697](https://github.com/Python-roborock/python-roborock/pull/697), [`6293a67`](https://github.com/Python-roborock/python-roborock/commit/6293a676e508cb42acf17852c37bf6f69547636a)) - Fix lint errors ([#704](https://github.com/Python-roborock/python-roborock/pull/704), [`b9a241c`](https://github.com/Python-roborock/python-roborock/commit/b9a241c9274a9a204ac5e7c3854e239f64c819c0)) - Fix lint errors ([#697](https://github.com/Python-roborock/python-roborock/pull/697), [`6293a67`](https://github.com/Python-roborock/python-roborock/commit/6293a676e508cb42acf17852c37bf6f69547636a)) - Fix lint errors ([#695](https://github.com/Python-roborock/python-roborock/pull/695), [`e38bc9f`](https://github.com/Python-roborock/python-roborock/commit/e38bc9f10bad27b9622d1f6216339426e00d239d)) - Fix lint errors ([#699](https://github.com/Python-roborock/python-roborock/pull/699), [`c317f8e`](https://github.com/Python-roborock/python-roborock/commit/c317f8e4e6d4deda755b511f0c382db7fd68b911)) - Fix merge conflicts ([#697](https://github.com/Python-roborock/python-roborock/pull/697), [`6293a67`](https://github.com/Python-roborock/python-roborock/commit/6293a676e508cb42acf17852c37bf6f69547636a)) - Organize test fixtures ([#699](https://github.com/Python-roborock/python-roborock/pull/699), [`c317f8e`](https://github.com/Python-roborock/python-roborock/commit/c317f8e4e6d4deda755b511f0c382db7fd68b911)) - Remove duplicate captured request log ([#699](https://github.com/Python-roborock/python-roborock/pull/699), [`c317f8e`](https://github.com/Python-roborock/python-roborock/commit/c317f8e4e6d4deda755b511f0c382db7fd68b911)) - Remove duplicate params ([#697](https://github.com/Python-roborock/python-roborock/pull/697), [`6293a67`](https://github.com/Python-roborock/python-roborock/commit/6293a676e508cb42acf17852c37bf6f69547636a)) - Remove unnecessary whitespace ([#697](https://github.com/Python-roborock/python-roborock/pull/697), [`6293a67`](https://github.com/Python-roborock/python-roborock/commit/6293a676e508cb42acf17852c37bf6f69547636a)) - Resolving merge conflict ([#697](https://github.com/Python-roborock/python-roborock/pull/697), [`6293a67`](https://github.com/Python-roborock/python-roborock/commit/6293a676e508cb42acf17852c37bf6f69547636a)) - Small tweaks to test fixtures ([#704](https://github.com/Python-roborock/python-roborock/pull/704), [`b9a241c`](https://github.com/Python-roborock/python-roborock/commit/b9a241c9274a9a204ac5e7c3854e239f64c819c0)) - Update device test snapshots ([#704](https://github.com/Python-roborock/python-roborock/pull/704), [`b9a241c`](https://github.com/Python-roborock/python-roborock/commit/b9a241c9274a9a204ac5e7c3854e239f64c819c0)) - Update test fixtures ([#704](https://github.com/Python-roborock/python-roborock/pull/704), [`b9a241c`](https://github.com/Python-roborock/python-roborock/commit/b9a241c9274a9a204ac5e7c3854e239f64c819c0)) - **deps-dev**: Bump pre-commit from 4.5.0 to 4.5.1 ([#701](https://github.com/Python-roborock/python-roborock/pull/701), [`8cd51cc`](https://github.com/Python-roborock/python-roborock/commit/8cd51cce07a244813f26b169f6f97b457c6a629f)) - **deps-dev**: Bump ruff from 0.14.9 to 0.14.10 ([#700](https://github.com/Python-roborock/python-roborock/pull/700), [`942d3a1`](https://github.com/Python-roborock/python-roborock/commit/942d3a1acc335726405decc3a7fc7b7b2fd6e698)) ### Features - Revert whitespace change. ([#704](https://github.com/Python-roborock/python-roborock/pull/704), [`b9a241c`](https://github.com/Python-roborock/python-roborock/commit/b9a241c9274a9a204ac5e7c3854e239f64c819c0)) - Small tweaks to test fixtures ([#704](https://github.com/Python-roborock/python-roborock/pull/704), [`b9a241c`](https://github.com/Python-roborock/python-roborock/commit/b9a241c9274a9a204ac5e7c3854e239f64c819c0)) ## v3.20.1 (2025-12-22) ### Bug Fixes - Improve debug logs redaction ([#698](https://github.com/Python-roborock/python-roborock/pull/698), [`067794c`](https://github.com/Python-roborock/python-roborock/commit/067794c0b24847520b423fdaacda679aab550cbd)) ### Chores - Address co-pilot readability comments ([#698](https://github.com/Python-roborock/python-roborock/pull/698), [`067794c`](https://github.com/Python-roborock/python-roborock/commit/067794c0b24847520b423fdaacda679aab550cbd)) ## v3.20.0 (2025-12-22) ### Bug Fixes - Catch broad exception ([#690](https://github.com/Python-roborock/python-roborock/pull/690), [`f9f8e43`](https://github.com/Python-roborock/python-roborock/commit/f9f8e43ca97f1136191db92174e937fc1906822d)) - Lower log level for mqtt channel publish exceptions ([#696](https://github.com/Python-roborock/python-roborock/pull/696), [`642004a`](https://github.com/Python-roborock/python-roborock/commit/642004a3d7f439f7d614aa439e6705377c626a11)) - Reduce log level of decode errors ([#691](https://github.com/Python-roborock/python-roborock/pull/691), [`98d89f0`](https://github.com/Python-roborock/python-roborock/commit/98d89f027c57195869b65123c8396a20e7a7d648)) - Try to fix fan setting ([#690](https://github.com/Python-roborock/python-roborock/pull/690), [`f9f8e43`](https://github.com/Python-roborock/python-roborock/commit/f9f8e43ca97f1136191db92174e937fc1906822d)) ### Chores - Add self.send ([#690](https://github.com/Python-roborock/python-roborock/pull/690), [`f9f8e43`](https://github.com/Python-roborock/python-roborock/commit/f9f8e43ca97f1136191db92174e937fc1906822d)) - Add testing ([#690](https://github.com/Python-roborock/python-roborock/pull/690), [`f9f8e43`](https://github.com/Python-roborock/python-roborock/commit/f9f8e43ca97f1136191db92174e937fc1906822d)) - Address PR comments ([#690](https://github.com/Python-roborock/python-roborock/pull/690), [`f9f8e43`](https://github.com/Python-roborock/python-roborock/commit/f9f8e43ca97f1136191db92174e937fc1906822d)) - Change typing ([#690](https://github.com/Python-roborock/python-roborock/pull/690), [`f9f8e43`](https://github.com/Python-roborock/python-roborock/commit/f9f8e43ca97f1136191db92174e937fc1906822d)) - Fix tests ([#691](https://github.com/Python-roborock/python-roborock/pull/691), [`98d89f0`](https://github.com/Python-roborock/python-roborock/commit/98d89f027c57195869b65123c8396a20e7a7d648)) - More debug logs and error handling ([#690](https://github.com/Python-roborock/python-roborock/pull/690), [`f9f8e43`](https://github.com/Python-roborock/python-roborock/commit/f9f8e43ca97f1136191db92174e937fc1906822d)) - Move send and add docs ([#690](https://github.com/Python-roborock/python-roborock/pull/690), [`f9f8e43`](https://github.com/Python-roborock/python-roborock/commit/f9f8e43ca97f1136191db92174e937fc1906822d)) - Update tests ([#691](https://github.com/Python-roborock/python-roborock/pull/691), [`98d89f0`](https://github.com/Python-roborock/python-roborock/commit/98d89f027c57195869b65123c8396a20e7a7d648)) ### Features - Add some basic setters for q7 ([#690](https://github.com/Python-roborock/python-roborock/pull/690), [`f9f8e43`](https://github.com/Python-roborock/python-roborock/commit/f9f8e43ca97f1136191db92174e937fc1906822d)) - Add some more actions ([#690](https://github.com/Python-roborock/python-roborock/pull/690), [`f9f8e43`](https://github.com/Python-roborock/python-roborock/commit/f9f8e43ca97f1136191db92174e937fc1906822d)) ## v3.19.1 (2025-12-20) ### Bug Fixes - Revert A01 padding ([#694](https://github.com/Python-roborock/python-roborock/pull/694), [`ac622cc`](https://github.com/Python-roborock/python-roborock/commit/ac622cc07b497b03981fadd97c039555c31a0bae)) ### Chores - Update snapshot ([#694](https://github.com/Python-roborock/python-roborock/pull/694), [`ac622cc`](https://github.com/Python-roborock/python-roborock/commit/ac622cc07b497b03981fadd97c039555c31a0bae)) ## v3.19.0 (2025-12-17) ### Bug Fixes - Handle AppInitStatus with omitted new_feature_info_str ([#688](https://github.com/Python-roborock/python-roborock/pull/688), [`aaeee22`](https://github.com/Python-roborock/python-roborock/commit/aaeee224bc2a715f04ef05b20ef75eb0d2aaa0b9)) ### Chores - Add additional test coverage for default string value ([#688](https://github.com/Python-roborock/python-roborock/pull/688), [`aaeee22`](https://github.com/Python-roborock/python-roborock/commit/aaeee224bc2a715f04ef05b20ef75eb0d2aaa0b9)) - Add snapshot tests for device payloads ([#676](https://github.com/Python-roborock/python-roborock/pull/676), [`cd7ef7c`](https://github.com/Python-roborock/python-roborock/commit/cd7ef7c96a16568efd14e29013cbbfded8fe7d86)) - Add socket based tests for the new APIs ([#677](https://github.com/Python-roborock/python-roborock/pull/677), [`7d113db`](https://github.com/Python-roborock/python-roborock/commit/7d113db6ea75b4864b7edb1657535ad4dc2b9f8f)) - Apply co-pilot suggestion for dataclass initialization ([#673](https://github.com/Python-roborock/python-roborock/pull/673), [`33c174b`](https://github.com/Python-roborock/python-roborock/commit/33c174b0685c4dc00df6a81437e9b9995934eb61)) - Clean up tests from previous pr ([#687](https://github.com/Python-roborock/python-roborock/pull/687), [`211429b`](https://github.com/Python-roborock/python-roborock/commit/211429bdcf188bf248d1f28f123c6297016b458b)) - Fix lint errors ([#676](https://github.com/Python-roborock/python-roborock/pull/676), [`cd7ef7c`](https://github.com/Python-roborock/python-roborock/commit/cd7ef7c96a16568efd14e29013cbbfded8fe7d86)) - Fix lint errors in test_device_manager.py ([#673](https://github.com/Python-roborock/python-roborock/pull/673), [`33c174b`](https://github.com/Python-roborock/python-roborock/commit/33c174b0685c4dc00df6a81437e9b9995934eb61)) - Fix local session ([#677](https://github.com/Python-roborock/python-roborock/pull/677), [`7d113db`](https://github.com/Python-roborock/python-roborock/commit/7d113db6ea75b4864b7edb1657535ad4dc2b9f8f)) - Remove duplicate test ([#673](https://github.com/Python-roborock/python-roborock/pull/673), [`33c174b`](https://github.com/Python-roborock/python-roborock/commit/33c174b0685c4dc00df6a81437e9b9995934eb61)) - Remove unnecessary whitespace ([#676](https://github.com/Python-roborock/python-roborock/pull/676), [`cd7ef7c`](https://github.com/Python-roborock/python-roborock/commit/cd7ef7c96a16568efd14e29013cbbfded8fe7d86)) - Update default value for new feature string to empty string ([#688](https://github.com/Python-roborock/python-roborock/pull/688), [`aaeee22`](https://github.com/Python-roborock/python-roborock/commit/aaeee224bc2a715f04ef05b20ef75eb0d2aaa0b9)) - Update roborock/diagnostics.py ([#673](https://github.com/Python-roborock/python-roborock/pull/673), [`33c174b`](https://github.com/Python-roborock/python-roborock/commit/33c174b0685c4dc00df6a81437e9b9995934eb61)) - Update tests/conftest.py ([#676](https://github.com/Python-roborock/python-roborock/pull/676), [`cd7ef7c`](https://github.com/Python-roborock/python-roborock/commit/cd7ef7c96a16568efd14e29013cbbfded8fe7d86)) ### Features - Add diagnostics library for tracking stats/counters ([#673](https://github.com/Python-roborock/python-roborock/pull/673), [`33c174b`](https://github.com/Python-roborock/python-roborock/commit/33c174b0685c4dc00df6a81437e9b9995934eb61)) ## v3.18.0 (2025-12-17) ### Bug Fixes - Use value instead of name to get lower cased ([#686](https://github.com/Python-roborock/python-roborock/pull/686), [`728e53a`](https://github.com/Python-roborock/python-roborock/commit/728e53a44949c9044fc64e53725fe0103b43b4a8)) ### Chores - Fix pydoc string ([#674](https://github.com/Python-roborock/python-roborock/pull/674), [`c576d5f`](https://github.com/Python-roborock/python-roborock/commit/c576d5ff1e1247c20a1b1c0f4895b8870f929734)) - Fix typo in README.md ([#685](https://github.com/Python-roborock/python-roborock/pull/685), [`d01287a`](https://github.com/Python-roborock/python-roborock/commit/d01287a3a9883ee9698fbe6ad9bd95e4e8779b5e)) - Improve library user documentation ([#685](https://github.com/Python-roborock/python-roborock/pull/685), [`d01287a`](https://github.com/Python-roborock/python-roborock/commit/d01287a3a9883ee9698fbe6ad9bd95e4e8779b5e)) - Remove unnecessary assert in test ([#674](https://github.com/Python-roborock/python-roborock/pull/674), [`c576d5f`](https://github.com/Python-roborock/python-roborock/commit/c576d5ff1e1247c20a1b1c0f4895b8870f929734)) - Style cleanup re-raising a bare exception ([#674](https://github.com/Python-roborock/python-roborock/pull/674), [`c576d5f`](https://github.com/Python-roborock/python-roborock/commit/c576d5ff1e1247c20a1b1c0f4895b8870f929734)) - Update roborock/data/code_mappings.py ([#686](https://github.com/Python-roborock/python-roborock/pull/686), [`728e53a`](https://github.com/Python-roborock/python-roborock/commit/728e53a44949c9044fc64e53725fe0103b43b4a8)) - **deps-dev**: Bump pytest from 8.4.2 to 9.0.2 ([#681](https://github.com/Python-roborock/python-roborock/pull/681), [`5520a56`](https://github.com/Python-roborock/python-roborock/commit/5520a562f1913e11dea6a007b4b2accb3d30d222)) ### Features - Allow device manager to perform rediscovery of devices ([#674](https://github.com/Python-roborock/python-roborock/pull/674), [`c576d5f`](https://github.com/Python-roborock/python-roborock/commit/c576d5ff1e1247c20a1b1c0f4895b8870f929734)) - Improvements to B01 for HA integration ([#686](https://github.com/Python-roborock/python-roborock/pull/686), [`728e53a`](https://github.com/Python-roborock/python-roborock/commit/728e53a44949c9044fc64e53725fe0103b43b4a8)) ## v3.17.0 (2025-12-15) ### Chores - **deps**: Bump python-semantic-release/publish-action ([#679](https://github.com/Python-roborock/python-roborock/pull/679), [`3cf1a9a`](https://github.com/Python-roborock/python-roborock/commit/3cf1a9af0d65482c65a14b2d266ff3b134dcb6f8)) - **deps**: Bump python-semantic-release/python-semantic-release ([#680](https://github.com/Python-roborock/python-roborock/pull/680), [`2afa86c`](https://github.com/Python-roborock/python-roborock/commit/2afa86cdf234ef5626dbf9f2f778d0a3b23ac5a7)) - **deps-dev**: Bump mypy from 1.19.0 to 1.19.1 ([#683](https://github.com/Python-roborock/python-roborock/pull/683), [`bfb2c63`](https://github.com/Python-roborock/python-roborock/commit/bfb2c63e85d96c2c663686e05c598d0e724685a9)) - **deps-dev**: Bump ruff from 0.14.6 to 0.14.9 ([#682](https://github.com/Python-roborock/python-roborock/pull/682), [`cfd51e4`](https://github.com/Python-roborock/python-roborock/commit/cfd51e4a75eecb66f60ac137ece36b2fa7583ea9)) ### Features - Improvements to B01 for HA integration ([#678](https://github.com/Python-roborock/python-roborock/pull/678), [`97fb0b7`](https://github.com/Python-roborock/python-roborock/commit/97fb0b75ff4aa164d81340d276991537e0c9662e)) ## v3.16.1 (2025-12-14) ### Bug Fixes - Share a HealthManager instance across all mqtt channels ([#672](https://github.com/Python-roborock/python-roborock/pull/672), [`4ad95dd`](https://github.com/Python-roborock/python-roborock/commit/4ad95ddee4d4d4cd64c7908f150c71d81f45e705)) ## v3.16.0 (2025-12-14) ### Bug Fixes - Fix bugs in the subscription idle timeout ([#665](https://github.com/Python-roborock/python-roborock/pull/665), [`85b7bee`](https://github.com/Python-roborock/python-roborock/commit/85b7beeb810cfb3d501658cd44f06b2c0052ca33)) - Harden the device connection logic used in startup ([#666](https://github.com/Python-roborock/python-roborock/pull/666), [`19703f4`](https://github.com/Python-roborock/python-roborock/commit/19703f42fe692a38f8f8639b1136a7585eae76fc)) - Harden the initial startup logic ([#666](https://github.com/Python-roborock/python-roborock/pull/666), [`19703f4`](https://github.com/Python-roborock/python-roborock/commit/19703f42fe692a38f8f8639b1136a7585eae76fc)) ### Chores - Apply suggestions from code review ([#675](https://github.com/Python-roborock/python-roborock/pull/675), [`ab2de5b`](https://github.com/Python-roborock/python-roborock/commit/ab2de5bda7b8e1ff1ad46c7f2bf3b39dc9af4ace)) - Clarify comments and docstrings ([#666](https://github.com/Python-roborock/python-roborock/pull/666), [`19703f4`](https://github.com/Python-roborock/python-roborock/commit/19703f42fe692a38f8f8639b1136a7585eae76fc)) - Fix logging ([#666](https://github.com/Python-roborock/python-roborock/pull/666), [`19703f4`](https://github.com/Python-roborock/python-roborock/commit/19703f42fe692a38f8f8639b1136a7585eae76fc)) - Reduce whitespace changes ([#666](https://github.com/Python-roborock/python-roborock/pull/666), [`19703f4`](https://github.com/Python-roborock/python-roborock/commit/19703f42fe692a38f8f8639b1136a7585eae76fc)) - Revert whitespace change ([#666](https://github.com/Python-roborock/python-roborock/pull/666), [`19703f4`](https://github.com/Python-roborock/python-roborock/commit/19703f42fe692a38f8f8639b1136a7585eae76fc)) ### Features - Add basic schedule getting ([#675](https://github.com/Python-roborock/python-roborock/pull/675), [`ab2de5b`](https://github.com/Python-roborock/python-roborock/commit/ab2de5bda7b8e1ff1ad46c7f2bf3b39dc9af4ace)) ## v3.15.0 (2025-12-14) ### Chores - Address some comments ([#662](https://github.com/Python-roborock/python-roborock/pull/662), [`b3664bc`](https://github.com/Python-roborock/python-roborock/commit/b3664bcc0764d1dfbde2af9588dc0821c3ca1317)) - Apply suggestions from code review ([#662](https://github.com/Python-roborock/python-roborock/pull/662), [`b3664bc`](https://github.com/Python-roborock/python-roborock/commit/b3664bcc0764d1dfbde2af9588dc0821c3ca1317)) - Fix test naming ([#662](https://github.com/Python-roborock/python-roborock/pull/662), [`b3664bc`](https://github.com/Python-roborock/python-roborock/commit/b3664bcc0764d1dfbde2af9588dc0821c3ca1317)) - Small tweaks ([#662](https://github.com/Python-roborock/python-roborock/pull/662), [`b3664bc`](https://github.com/Python-roborock/python-roborock/commit/b3664bcc0764d1dfbde2af9588dc0821c3ca1317)) - Update roborock/devices/b01_channel.py ([#662](https://github.com/Python-roborock/python-roborock/pull/662), [`b3664bc`](https://github.com/Python-roborock/python-roborock/commit/b3664bcc0764d1dfbde2af9588dc0821c3ca1317)) - Update snapshot ([#662](https://github.com/Python-roborock/python-roborock/pull/662), [`b3664bc`](https://github.com/Python-roborock/python-roborock/commit/b3664bcc0764d1dfbde2af9588dc0821c3ca1317)) ### Features - Add b01 Q7 basic getter support ([#662](https://github.com/Python-roborock/python-roborock/pull/662), [`b3664bc`](https://github.com/Python-roborock/python-roborock/commit/b3664bcc0764d1dfbde2af9588dc0821c3ca1317)) - Add b01 Q7 support ([#662](https://github.com/Python-roborock/python-roborock/pull/662), [`b3664bc`](https://github.com/Python-roborock/python-roborock/commit/b3664bcc0764d1dfbde2af9588dc0821c3ca1317)) ## v3.14.3 (2025-12-14) ### Bug Fixes - Allow firmware version as an optional field ([#670](https://github.com/Python-roborock/python-roborock/pull/670), [`0f70bf9`](https://github.com/Python-roborock/python-roborock/commit/0f70bf9dd2010c2c72b3b9543d891a1071dc22c4)) ### Chores - Add test for example offline device ([#670](https://github.com/Python-roborock/python-roborock/pull/670), [`0f70bf9`](https://github.com/Python-roborock/python-roborock/commit/0f70bf9dd2010c2c72b3b9543d891a1071dc22c4)) ## v3.14.2 (2025-12-14) ### Bug Fixes - Additional device logging improvements ([#668](https://github.com/Python-roborock/python-roborock/pull/668), [`a86db71`](https://github.com/Python-roborock/python-roborock/commit/a86db717a07d24b0e6ab471ee814b0853b523918)) - Improve device logging ([#668](https://github.com/Python-roborock/python-roborock/pull/668), [`a86db71`](https://github.com/Python-roborock/python-roborock/commit/a86db717a07d24b0e6ab471ee814b0853b523918)) ### Chores - Further readability improvements to device logging ([#668](https://github.com/Python-roborock/python-roborock/pull/668), [`a86db71`](https://github.com/Python-roborock/python-roborock/commit/a86db717a07d24b0e6ab471ee814b0853b523918)) - Improve device logging container summary string ([#668](https://github.com/Python-roborock/python-roborock/pull/668), [`a86db71`](https://github.com/Python-roborock/python-roborock/commit/a86db717a07d24b0e6ab471ee814b0853b523918)) ## v3.14.1 (2025-12-14) ### Bug Fixes - Fix diagnostic data redaction to use camelized keys ([#669](https://github.com/Python-roborock/python-roborock/pull/669), [`6a20e27`](https://github.com/Python-roborock/python-roborock/commit/6a20e27506d01fbb30683c2d74d26ab073aa3036)) ### Chores - Remove redundant/broken part of the readme ([#667](https://github.com/Python-roborock/python-roborock/pull/667), [`b629a61`](https://github.com/Python-roborock/python-roborock/commit/b629a61f28f3bb64914a9bc461ce9f7a27a30c35)) - **deps**: Bump pdoc from 15.0.4 to 16.0.0 ([#652](https://github.com/Python-roborock/python-roborock/pull/652), [`5f4c14e`](https://github.com/Python-roborock/python-roborock/commit/5f4c14ead4eda21cd6954e3898d79a6eaa983f62)) ## v3.14.0 (2025-12-14) ### Bug Fixes - Add device logger ([#663](https://github.com/Python-roborock/python-roborock/pull/663), [`06d051c`](https://github.com/Python-roborock/python-roborock/commit/06d051c7b8203e23970d52d65abec88a2757227f)) - Update roborock/devices/device.py ([#664](https://github.com/Python-roborock/python-roborock/pull/664), [`494c5b4`](https://github.com/Python-roborock/python-roborock/commit/494c5b4f2b447f12f5ef90167cad16e08a8230ac)) ### Chores - Add details about test structure ([#633](https://github.com/Python-roborock/python-roborock/pull/633), [`109d05b`](https://github.com/Python-roborock/python-roborock/commit/109d05ba86275f2cdd65c5cda12fc423cbfb5850)) - Add more tests for already connected devices ([#664](https://github.com/Python-roborock/python-roborock/pull/664), [`494c5b4`](https://github.com/Python-roborock/python-roborock/commit/494c5b4f2b447f12f5ef90167cad16e08a8230ac)) - Apply suggestions from code review ([#663](https://github.com/Python-roborock/python-roborock/pull/663), [`06d051c`](https://github.com/Python-roborock/python-roborock/commit/06d051c7b8203e23970d52d65abec88a2757227f)) - Document combined mqtt channels ([#633](https://github.com/Python-roborock/python-roborock/pull/633), [`109d05b`](https://github.com/Python-roborock/python-roborock/commit/109d05ba86275f2cdd65c5cda12fc423cbfb5850)) - Fix lint errors ([#664](https://github.com/Python-roborock/python-roborock/pull/664), [`494c5b4`](https://github.com/Python-roborock/python-roborock/commit/494c5b4f2b447f12f5ef90167cad16e08a8230ac)) - Fix lint errors ([#633](https://github.com/Python-roborock/python-roborock/pull/633), [`109d05b`](https://github.com/Python-roborock/python-roborock/commit/109d05ba86275f2cdd65c5cda12fc423cbfb5850)) - Fix lint errors in readme ([#633](https://github.com/Python-roborock/python-roborock/pull/633), [`109d05b`](https://github.com/Python-roborock/python-roborock/commit/109d05ba86275f2cdd65c5cda12fc423cbfb5850)) - Fix typo ([#633](https://github.com/Python-roborock/python-roborock/pull/633), [`109d05b`](https://github.com/Python-roborock/python-roborock/commit/109d05ba86275f2cdd65c5cda12fc423cbfb5850)) - Update device traits by protocol ([#633](https://github.com/Python-roborock/python-roborock/pull/633), [`109d05b`](https://github.com/Python-roborock/python-roborock/commit/109d05ba86275f2cdd65c5cda12fc423cbfb5850)) - Update devices documentation with design details ([#633](https://github.com/Python-roborock/python-roborock/pull/633), [`109d05b`](https://github.com/Python-roborock/python-roborock/commit/109d05ba86275f2cdd65c5cda12fc423cbfb5850)) - Use the existing device logger ([#663](https://github.com/Python-roborock/python-roborock/pull/663), [`06d051c`](https://github.com/Python-roborock/python-roborock/commit/06d051c7b8203e23970d52d65abec88a2757227f)) ### Features - Add ability to listen for ready devices ([#664](https://github.com/Python-roborock/python-roborock/pull/664), [`494c5b4`](https://github.com/Python-roborock/python-roborock/commit/494c5b4f2b447f12f5ef90167cad16e08a8230ac)) ## v3.13.1 (2025-12-12) ### Bug Fixes - Clean up some naming ([#635](https://github.com/Python-roborock/python-roborock/pull/635), [`9a1a360`](https://github.com/Python-roborock/python-roborock/commit/9a1a3600fb3eff612088e9203a04f795678e9da7)) - Update roborock/devices/traits/b01/__init__.py ([#635](https://github.com/Python-roborock/python-roborock/pull/635), [`9a1a360`](https://github.com/Python-roborock/python-roborock/commit/9a1a3600fb3eff612088e9203a04f795678e9da7)) - Use strip not split ([#635](https://github.com/Python-roborock/python-roborock/pull/635), [`9a1a360`](https://github.com/Python-roborock/python-roborock/commit/9a1a3600fb3eff612088e9203a04f795678e9da7)) ### Chores - Refactor to separate b01 q7 and q10 logic ([#635](https://github.com/Python-roborock/python-roborock/pull/635), [`9a1a360`](https://github.com/Python-roborock/python-roborock/commit/9a1a3600fb3eff612088e9203a04f795678e9da7)) - Refactor to seperate b01 ss and sc logic ([#635](https://github.com/Python-roborock/python-roborock/pull/635), [`9a1a360`](https://github.com/Python-roborock/python-roborock/commit/9a1a3600fb3eff612088e9203a04f795678e9da7)) - Share duplicated code ([#635](https://github.com/Python-roborock/python-roborock/pull/635), [`9a1a360`](https://github.com/Python-roborock/python-roborock/commit/9a1a3600fb3eff612088e9203a04f795678e9da7)) - Update roborock/devices/device_manager.py ([#635](https://github.com/Python-roborock/python-roborock/pull/635), [`9a1a360`](https://github.com/Python-roborock/python-roborock/commit/9a1a3600fb3eff612088e9203a04f795678e9da7)) - Update roborock/devices/traits/b01/q7/__init__.py ([#635](https://github.com/Python-roborock/python-roborock/pull/635), [`9a1a360`](https://github.com/Python-roborock/python-roborock/commit/9a1a3600fb3eff612088e9203a04f795678e9da7)) ## v3.13.0 (2025-12-12) ### Bug Fixes - Update device cache handling in cli ([#660](https://github.com/Python-roborock/python-roborock/pull/660), [`405a4fb`](https://github.com/Python-roborock/python-roborock/commit/405a4fba281b09269c0a065f79dedfd9dc9b5a8b)) - Update device cache handling in cli. ([#660](https://github.com/Python-roborock/python-roborock/pull/660), [`405a4fb`](https://github.com/Python-roborock/python-roborock/commit/405a4fba281b09269c0a065f79dedfd9dc9b5a8b)) ### Features - Add additional fields to HomeDataDevice class ([#660](https://github.com/Python-roborock/python-roborock/pull/660), [`405a4fb`](https://github.com/Python-roborock/python-roborock/commit/405a4fba281b09269c0a065f79dedfd9dc9b5a8b)) ## v3.12.2 (2025-12-10) ### Bug Fixes - Filter tests to be warnings only ([#656](https://github.com/Python-roborock/python-roborock/pull/656), [`e725eab`](https://github.com/Python-roborock/python-roborock/commit/e725eabab7c498569c5e17be9a7b435c917745f1)) - Handle random length bytes before version bytes ([#656](https://github.com/Python-roborock/python-roborock/pull/656), [`e725eab`](https://github.com/Python-roborock/python-roborock/commit/e725eabab7c498569c5e17be9a7b435c917745f1)) ### Chores - Add debug to help us determine if buffer is source of problem ([#656](https://github.com/Python-roborock/python-roborock/pull/656), [`e725eab`](https://github.com/Python-roborock/python-roborock/commit/e725eabab7c498569c5e17be9a7b435c917745f1)) - Apply suggestions from code review ([#656](https://github.com/Python-roborock/python-roborock/pull/656), [`e725eab`](https://github.com/Python-roborock/python-roborock/commit/e725eabab7c498569c5e17be9a7b435c917745f1)) - Only log if remaining ([#656](https://github.com/Python-roborock/python-roborock/pull/656), [`e725eab`](https://github.com/Python-roborock/python-roborock/commit/e725eabab7c498569c5e17be9a7b435c917745f1)) - Update roborock/protocol.py ([#656](https://github.com/Python-roborock/python-roborock/pull/656), [`e725eab`](https://github.com/Python-roborock/python-roborock/commit/e725eabab7c498569c5e17be9a7b435c917745f1)) ## v3.12.1 (2025-12-10) ### Bug Fixes - Redact additional keys from diagnostic data ([#659](https://github.com/Python-roborock/python-roborock/pull/659), [`0330755`](https://github.com/Python-roborock/python-roborock/commit/033075559fb63f696073e235d36f4a906c324881)) ### Chores - Update comments on redaction ([#659](https://github.com/Python-roborock/python-roborock/pull/659), [`0330755`](https://github.com/Python-roborock/python-roborock/commit/033075559fb63f696073e235d36f4a906c324881)) ## v3.12.0 (2025-12-10) ### Bug Fixes - Align V4 code login with app ([#657](https://github.com/Python-roborock/python-roborock/pull/657), [`2328d45`](https://github.com/Python-roborock/python-roborock/commit/2328d4596c6bda35686944880b601c35b390ac9c)) ### Chores - **deps**: Bump mypy from 1.18.2 to 1.19.0 ([#654](https://github.com/Python-roborock/python-roborock/pull/654), [`2799a19`](https://github.com/Python-roborock/python-roborock/commit/2799a19263a511a2d141a97cdb6e9814961a4b0f)) - **deps**: Bump syrupy from 4.9.1 to 5.0.0 ([#655](https://github.com/Python-roborock/python-roborock/pull/655), [`cc2d00f`](https://github.com/Python-roborock/python-roborock/commit/cc2d00fdd21968c0cfe8da8704644dc2c7ff8091)) ### Features - Log when we see a new key we have never seen before for easier reverse engineering ([#658](https://github.com/Python-roborock/python-roborock/pull/658), [`81dde05`](https://github.com/Python-roborock/python-roborock/commit/81dde05eac61e7dc6e0fdb9eb0b7e0ffc97cf9d8)) ## v3.11.1 (2025-12-10) ### Bug Fixes - Throw MQTT authentication errors as authentication related exceptions ([#634](https://github.com/Python-roborock/python-roborock/pull/634), [`4ad9bcd`](https://github.com/Python-roborock/python-roborock/commit/4ad9bcdc1eddc3a0698056fce19f33d0ea0a119b)) - Update the exception handling behavior to account for ambiguity ([#634](https://github.com/Python-roborock/python-roborock/pull/634), [`4ad9bcd`](https://github.com/Python-roborock/python-roborock/commit/4ad9bcdc1eddc3a0698056fce19f33d0ea0a119b)) ## v3.11.0 (2025-12-10) ### Features - Add mappings for cleaning fluid states ([#636](https://github.com/Python-roborock/python-roborock/pull/636), [`32c717e`](https://github.com/Python-roborock/python-roborock/commit/32c717ebdcc963af691398d176b7175c59d7616c)) ## v3.10.10 (2025-12-08) ### Bug Fixes - Fix exception when sending dyad/zeo requests ([#651](https://github.com/Python-roborock/python-roborock/pull/651), [`a1014a6`](https://github.com/Python-roborock/python-roborock/commit/a1014a60320c45d82c80c2c47f2cb7cc6f242252)) ### Chores - Fix lint ([#651](https://github.com/Python-roborock/python-roborock/pull/651), [`a1014a6`](https://github.com/Python-roborock/python-roborock/commit/a1014a60320c45d82c80c2c47f2cb7cc6f242252)) - Fix tests to be focused on value encoder ([#651](https://github.com/Python-roborock/python-roborock/pull/651), [`a1014a6`](https://github.com/Python-roborock/python-roborock/commit/a1014a60320c45d82c80c2c47f2cb7cc6f242252)) ## v3.10.9 (2025-12-07) ### Bug Fixes - Convert a01 values ([#647](https://github.com/Python-roborock/python-roborock/pull/647), [`f875e7a`](https://github.com/Python-roborock/python-roborock/commit/f875e7a65f5f422da97d0f2881956ec077c8a7df)) - Update tests and conversion logic ([#647](https://github.com/Python-roborock/python-roborock/pull/647), [`f875e7a`](https://github.com/Python-roborock/python-roborock/commit/f875e7a65f5f422da97d0f2881956ec077c8a7df)) ### Chores - Small changes to comments ([#647](https://github.com/Python-roborock/python-roborock/pull/647), [`f875e7a`](https://github.com/Python-roborock/python-roborock/commit/f875e7a65f5f422da97d0f2881956ec077c8a7df)) ## v3.10.8 (2025-12-07) ### Bug Fixes - Encode a01 values as json strings ([#645](https://github.com/Python-roborock/python-roborock/pull/645), [`7301a2a`](https://github.com/Python-roborock/python-roborock/commit/7301a2a7145b3ffa862b5ae83f2961b1b28b2867)) - Update where the string conversion happens ([#645](https://github.com/Python-roborock/python-roborock/pull/645), [`7301a2a`](https://github.com/Python-roborock/python-roborock/commit/7301a2a7145b3ffa862b5ae83f2961b1b28b2867)) ### Chores - Remove unnecessary imports ([#645](https://github.com/Python-roborock/python-roborock/pull/645), [`7301a2a`](https://github.com/Python-roborock/python-roborock/commit/7301a2a7145b3ffa862b5ae83f2961b1b28b2867)) - Update tests to capture bug fix ([#645](https://github.com/Python-roborock/python-roborock/pull/645), [`7301a2a`](https://github.com/Python-roborock/python-roborock/commit/7301a2a7145b3ffa862b5ae83f2961b1b28b2867)) ## v3.10.7 (2025-12-07) ### Bug Fixes - Add test coverage for a01 traits ([#649](https://github.com/Python-roborock/python-roborock/pull/649), [`89874cb`](https://github.com/Python-roborock/python-roborock/commit/89874cb5de97362c29d31f50916b2355e1d3f90f)) ### Chores - Add codecov support ([#646](https://github.com/Python-roborock/python-roborock/pull/646), [`3928280`](https://github.com/Python-roborock/python-roborock/commit/39282809217ec6d4b6e0c4f4f7729fbfd48ecadb)) - Add more test coverage for a01 API and fix `False` value handling ([#648](https://github.com/Python-roborock/python-roborock/pull/648), [`4bd9b18`](https://github.com/Python-roborock/python-roborock/commit/4bd9b18fddf4df6e05c185ff23d8be2d8fa90763)) - Fix lint errors in tests ([#648](https://github.com/Python-roborock/python-roborock/pull/648), [`4bd9b18`](https://github.com/Python-roborock/python-roborock/commit/4bd9b18fddf4df6e05c185ff23d8be2d8fa90763)) - Use raw return values ([#649](https://github.com/Python-roborock/python-roborock/pull/649), [`89874cb`](https://github.com/Python-roborock/python-roborock/commit/89874cb5de97362c29d31f50916b2355e1d3f90f)) ## v3.10.6 (2025-12-07) ### Bug Fixes - Handle base64 serializing wrong ([#643](https://github.com/Python-roborock/python-roborock/pull/643), [`d933ec8`](https://github.com/Python-roborock/python-roborock/commit/d933ec82f470fec47339f938065ab70a635112fd)) ## v3.10.5 (2025-12-07) ### Bug Fixes - Consider RPC channel health based on MQTT session ([#642](https://github.com/Python-roborock/python-roborock/pull/642), [`b1738fe`](https://github.com/Python-roborock/python-roborock/commit/b1738fec4edde302c5f0fb478146faaa3d864ee8)) ## v3.10.4 (2025-12-07) ### Bug Fixes - Lower log level for internal protocol connection details ([#637](https://github.com/Python-roborock/python-roborock/pull/637), [`6945c6a`](https://github.com/Python-roborock/python-roborock/commit/6945c6ad25f39930cdea23d2f7004824f681a6e7)) - Revert CLIENT_KEEPALIVE back to 60 ([#641](https://github.com/Python-roborock/python-roborock/pull/641), [`632b88b`](https://github.com/Python-roborock/python-roborock/commit/632b88b22e2ac722c5c4849b7b217fa4a88f757c)) ### Chores - Fix lint erors ([#637](https://github.com/Python-roborock/python-roborock/pull/637), [`6945c6a`](https://github.com/Python-roborock/python-roborock/commit/6945c6ad25f39930cdea23d2f7004824f681a6e7)) - Remove tests for logging ([#637](https://github.com/Python-roborock/python-roborock/pull/637), [`6945c6a`](https://github.com/Python-roborock/python-roborock/commit/6945c6ad25f39930cdea23d2f7004824f681a6e7)) ## v3.10.3 (2025-12-06) ### Bug Fixes - Ensure immediate local connection is attempted ([#640](https://github.com/Python-roborock/python-roborock/pull/640), [`3c918ae`](https://github.com/Python-roborock/python-roborock/commit/3c918aec33483b93ae9d632cc4ada286b6761b70)) - Fix mqtt rate limiting and broken local connections ([#638](https://github.com/Python-roborock/python-roborock/pull/638), [`4249769`](https://github.com/Python-roborock/python-roborock/commit/42497696e92dad79147e404be96e73b9e408bd0b)) ### Chores - Add back test case and add test ids ([#638](https://github.com/Python-roborock/python-roborock/pull/638), [`4249769`](https://github.com/Python-roborock/python-roborock/commit/42497696e92dad79147e404be96e73b9e408bd0b)) - Fix lint errors ([#640](https://github.com/Python-roborock/python-roborock/pull/640), [`3c918ae`](https://github.com/Python-roborock/python-roborock/commit/3c918aec33483b93ae9d632cc4ada286b6761b70)) - Fix lint errors ([#638](https://github.com/Python-roborock/python-roborock/pull/638), [`4249769`](https://github.com/Python-roborock/python-roborock/commit/42497696e92dad79147e404be96e73b9e408bd0b)) - Update roborock/devices/v1_channel.py ([#638](https://github.com/Python-roborock/python-roborock/pull/638), [`4249769`](https://github.com/Python-roborock/python-roborock/commit/42497696e92dad79147e404be96e73b9e408bd0b)) ## v3.10.2 (2025-12-05) ### Bug Fixes - Keep MQTT topic subscriptions alive with an idle timeout ([#632](https://github.com/Python-roborock/python-roborock/pull/632), [`d0d2e42`](https://github.com/Python-roborock/python-roborock/commit/d0d2e425e3005f3f83f4a57079fcef4736171b7a)) ### Chores - Add tests that reproduce key parsing bugs ([#631](https://github.com/Python-roborock/python-roborock/pull/631), [`87e14a2`](https://github.com/Python-roborock/python-roborock/commit/87e14a265a6c6bbe18fbe63f360ca57ca63db9c3)) - Fix lint errors ([#631](https://github.com/Python-roborock/python-roborock/pull/631), [`87e14a2`](https://github.com/Python-roborock/python-roborock/commit/87e14a265a6c6bbe18fbe63f360ca57ca63db9c3)) ## v3.10.1 (2025-12-05) ### Bug Fixes - Add fallback ([#630](https://github.com/Python-roborock/python-roborock/pull/630), [`e4fa8c6`](https://github.com/Python-roborock/python-roborock/commit/e4fa8c60bb29978b06704ce22dc4a2cda0e28875)) - Ensure keys are correct type when serializing from data ([#630](https://github.com/Python-roborock/python-roborock/pull/630), [`e4fa8c6`](https://github.com/Python-roborock/python-roborock/commit/e4fa8c60bb29978b06704ce22dc4a2cda0e28875)) - Ensure keys are valid type when serializing from data ([#630](https://github.com/Python-roborock/python-roborock/pull/630), [`e4fa8c6`](https://github.com/Python-roborock/python-roborock/commit/e4fa8c60bb29978b06704ce22dc4a2cda0e28875)) ## v3.10.0 (2025-12-04) ### Bug Fixes - Catch UnicodeDecodeError when parsing messages ([#629](https://github.com/Python-roborock/python-roborock/pull/629), [`e8c3b75`](https://github.com/Python-roborock/python-roborock/commit/e8c3b75a9d3efb8ff79a6d4e8544549a5abe766a)) - Reset keep_alive_task to None ([#627](https://github.com/Python-roborock/python-roborock/pull/627), [`a802f66`](https://github.com/Python-roborock/python-roborock/commit/a802f66fec913be82a25ae45d96555c2d328964b)) ### Chores - Copilot test ([#627](https://github.com/Python-roborock/python-roborock/pull/627), [`a802f66`](https://github.com/Python-roborock/python-roborock/commit/a802f66fec913be82a25ae45d96555c2d328964b)) ### Features - Add comprehensive test coverage for keep-alive functionality ([#627](https://github.com/Python-roborock/python-roborock/pull/627), [`a802f66`](https://github.com/Python-roborock/python-roborock/commit/a802f66fec913be82a25ae45d96555c2d328964b)) - Add pinging to local client ([#627](https://github.com/Python-roborock/python-roborock/pull/627), [`a802f66`](https://github.com/Python-roborock/python-roborock/commit/a802f66fec913be82a25ae45d96555c2d328964b)) ### Refactoring - Address code review feedback on keep-alive tests ([#627](https://github.com/Python-roborock/python-roborock/pull/627), [`a802f66`](https://github.com/Python-roborock/python-roborock/commit/a802f66fec913be82a25ae45d96555c2d328964b)) ## v3.9.3 (2025-12-03) ### Bug Fixes - Use correct index for clean records ([#620](https://github.com/Python-roborock/python-roborock/pull/620), [`f129603`](https://github.com/Python-roborock/python-roborock/commit/f1296032e7b8c8c1348882d58e9da5ecc8287eee)) ## v3.9.2 (2025-12-03) ### Bug Fixes - Add device info getters and setters ([#614](https://github.com/Python-roborock/python-roborock/pull/614), [`ee02a71`](https://github.com/Python-roborock/python-roborock/commit/ee02a71a8d99848256f2bb69533e9d1827f52585)) - Fix issues with the cache clobbering information for each device ([#614](https://github.com/Python-roborock/python-roborock/pull/614), [`ee02a71`](https://github.com/Python-roborock/python-roborock/commit/ee02a71a8d99848256f2bb69533e9d1827f52585)) - Update DeviceCache interface ([#614](https://github.com/Python-roborock/python-roborock/pull/614), [`ee02a71`](https://github.com/Python-roborock/python-roborock/commit/ee02a71a8d99848256f2bb69533e9d1827f52585)) ### Chores - Fix test snapshots ([#614](https://github.com/Python-roborock/python-roborock/pull/614), [`ee02a71`](https://github.com/Python-roborock/python-roborock/commit/ee02a71a8d99848256f2bb69533e9d1827f52585)) - Remove unnecessary imports ([#614](https://github.com/Python-roborock/python-roborock/pull/614), [`ee02a71`](https://github.com/Python-roborock/python-roborock/commit/ee02a71a8d99848256f2bb69533e9d1827f52585)) ## v3.9.1 (2025-12-03) ### Bug Fixes - Fix DeviceFeatures so that it can be serialized and deserialized properly. ([#615](https://github.com/Python-roborock/python-roborock/pull/615), [`88b2055`](https://github.com/Python-roborock/python-roborock/commit/88b2055a7aea50d8b45bfb07c3a937b6d8d267d0)) ## v3.9.0 (2025-12-03) ### Bug Fixes - Set default arugments to store/load value functions ([#613](https://github.com/Python-roborock/python-roborock/pull/613), [`ce3d88d`](https://github.com/Python-roborock/python-roborock/commit/ce3d88dd52e78adccf7f705d4076cc963bbe9724)) ### Chores - Remove unncessary logging ([#613](https://github.com/Python-roborock/python-roborock/pull/613), [`ce3d88d`](https://github.com/Python-roborock/python-roborock/commit/ce3d88dd52e78adccf7f705d4076cc963bbe9724)) - Remove unnecessary snapshot files ([#613](https://github.com/Python-roborock/python-roborock/pull/613), [`ce3d88d`](https://github.com/Python-roborock/python-roborock/commit/ce3d88dd52e78adccf7f705d4076cc963bbe9724)) - Remove unused import ([#613](https://github.com/Python-roborock/python-roborock/pull/613), [`ce3d88d`](https://github.com/Python-roborock/python-roborock/commit/ce3d88dd52e78adccf7f705d4076cc963bbe9724)) ### Features - Make CacheData serializable ([#613](https://github.com/Python-roborock/python-roborock/pull/613), [`ce3d88d`](https://github.com/Python-roborock/python-roborock/commit/ce3d88dd52e78adccf7f705d4076cc963bbe9724)) ## v3.8.5 (2025-11-29) ### Bug Fixes - Remove python 3.11 incompatibility ([#609](https://github.com/Python-roborock/python-roborock/pull/609), [`f3487e8`](https://github.com/Python-roborock/python-roborock/commit/f3487e8ec478e000d1330745dff178125796bfb5)) ### Chores - Fix v1 channel typing and improve readability ([#609](https://github.com/Python-roborock/python-roborock/pull/609), [`f3487e8`](https://github.com/Python-roborock/python-roborock/commit/f3487e8ec478e000d1330745dff178125796bfb5)) - Improve doc string readability and grammar ([#609](https://github.com/Python-roborock/python-roborock/pull/609), [`f3487e8`](https://github.com/Python-roborock/python-roborock/commit/f3487e8ec478e000d1330745dff178125796bfb5)) - Refactor v1 rpc channels ([#609](https://github.com/Python-roborock/python-roborock/pull/609), [`f3487e8`](https://github.com/Python-roborock/python-roborock/commit/f3487e8ec478e000d1330745dff178125796bfb5)) - Remove unnecessary docstrings ([#609](https://github.com/Python-roborock/python-roborock/pull/609), [`f3487e8`](https://github.com/Python-roborock/python-roborock/commit/f3487e8ec478e000d1330745dff178125796bfb5)) - Remove unnecessary pydoc on private members ([#609](https://github.com/Python-roborock/python-roborock/pull/609), [`f3487e8`](https://github.com/Python-roborock/python-roborock/commit/f3487e8ec478e000d1330745dff178125796bfb5)) - Remove unnecessary pydoc to make the code more compact ([#609](https://github.com/Python-roborock/python-roborock/pull/609), [`f3487e8`](https://github.com/Python-roborock/python-roborock/commit/f3487e8ec478e000d1330745dff178125796bfb5)) ## v3.8.4 (2025-11-29) ### Bug Fixes - Encode map content bytes as base64 ([#608](https://github.com/Python-roborock/python-roborock/pull/608), [`27c61f9`](https://github.com/Python-roborock/python-roborock/commit/27c61f9b7958edb5b4ca374e60898eb966163802)) - Fallback to the cached network information on failure ([#606](https://github.com/Python-roborock/python-roborock/pull/606), [`80d7d5a`](https://github.com/Python-roborock/python-roborock/commit/80d7d5af72629e83fbc7f2bf418ccecd793dbd58)) - Fallback to the cached network information when failing to lookup network info ([#606](https://github.com/Python-roborock/python-roborock/pull/606), [`80d7d5a`](https://github.com/Python-roborock/python-roborock/commit/80d7d5af72629e83fbc7f2bf418ccecd793dbd58)) - Improve partial update code ([#608](https://github.com/Python-roborock/python-roborock/pull/608), [`27c61f9`](https://github.com/Python-roborock/python-roborock/commit/27c61f9b7958edb5b4ca374e60898eb966163802)) ### Chores - Update roborock/devices/v1_channel.py ([#606](https://github.com/Python-roborock/python-roborock/pull/606), [`80d7d5a`](https://github.com/Python-roborock/python-roborock/commit/80d7d5af72629e83fbc7f2bf418ccecd793dbd58)) ## v3.8.3 (2025-11-29) ### Bug Fixes - Add a health manager for restarting unhealthy mqtt connections ([#605](https://github.com/Python-roborock/python-roborock/pull/605), [`879a641`](https://github.com/Python-roborock/python-roborock/commit/879a6412aafe8e7d0ba7a16e867ff3028873fd02)) - Add ability to restart the mqtt session ([#605](https://github.com/Python-roborock/python-roborock/pull/605), [`879a641`](https://github.com/Python-roborock/python-roborock/commit/879a6412aafe8e7d0ba7a16e867ff3028873fd02)) - Reset start_future each loop ([#605](https://github.com/Python-roborock/python-roborock/pull/605), [`879a641`](https://github.com/Python-roborock/python-roborock/commit/879a6412aafe8e7d0ba7a16e867ff3028873fd02)) ### Chores - Always use utc for now ([#605](https://github.com/Python-roborock/python-roborock/pull/605), [`879a641`](https://github.com/Python-roborock/python-roborock/commit/879a6412aafe8e7d0ba7a16e867ff3028873fd02)) - Cancel the connection and reconnect tasks ([#605](https://github.com/Python-roborock/python-roborock/pull/605), [`879a641`](https://github.com/Python-roborock/python-roborock/commit/879a6412aafe8e7d0ba7a16e867ff3028873fd02)) - Fix async tests ([#605](https://github.com/Python-roborock/python-roborock/pull/605), [`879a641`](https://github.com/Python-roborock/python-roborock/commit/879a6412aafe8e7d0ba7a16e867ff3028873fd02)) ## v3.8.2 (2025-11-28) ### Bug Fixes - Fix device feature discovery ([#603](https://github.com/Python-roborock/python-roborock/pull/603), [`d048001`](https://github.com/Python-roborock/python-roborock/commit/d0480015550edbe3e978902e141563e9c537fad1)) ### Chores - Revert requires_feature ([#603](https://github.com/Python-roborock/python-roborock/pull/603), [`d048001`](https://github.com/Python-roborock/python-roborock/commit/d0480015550edbe3e978902e141563e9c537fad1)) ## v3.8.1 (2025-11-26) ### Bug Fixes - Attempt to fix l01 ([#593](https://github.com/Python-roborock/python-roborock/pull/593), [`87e60d9`](https://github.com/Python-roborock/python-roborock/commit/87e60d9a9cb99ef9ddf99b1691baa2573db4221d)) - Decoding l01 ([#593](https://github.com/Python-roborock/python-roborock/pull/593), [`87e60d9`](https://github.com/Python-roborock/python-roborock/commit/87e60d9a9cb99ef9ddf99b1691baa2573db4221d)) - Ensure traits to always reflect the the status of commands ([#592](https://github.com/Python-roborock/python-roborock/pull/592), [`3d0ad74`](https://github.com/Python-roborock/python-roborock/commit/3d0ad74954948ebf0ea5c1a144aff3f9d111b1f7)) - Fix L01 encoding and decoding ([#593](https://github.com/Python-roborock/python-roborock/pull/593), [`87e60d9`](https://github.com/Python-roborock/python-roborock/commit/87e60d9a9cb99ef9ddf99b1691baa2573db4221d)) - Temp cache of protocol version until restart ([#593](https://github.com/Python-roborock/python-roborock/pull/593), [`87e60d9`](https://github.com/Python-roborock/python-roborock/commit/87e60d9a9cb99ef9ddf99b1691baa2573db4221d)) - Update bad asserts found by co-pilot ([#592](https://github.com/Python-roborock/python-roborock/pull/592), [`3d0ad74`](https://github.com/Python-roborock/python-roborock/commit/3d0ad74954948ebf0ea5c1a144aff3f9d111b1f7)) - Update the messages callback to not mutate the protocol once created. ([#593](https://github.com/Python-roborock/python-roborock/pull/593), [`87e60d9`](https://github.com/Python-roborock/python-roborock/commit/87e60d9a9cb99ef9ddf99b1691baa2573db4221d)) ### Chores - Add comments everywhere on implicit refreshes ([#592](https://github.com/Python-roborock/python-roborock/pull/592), [`3d0ad74`](https://github.com/Python-roborock/python-roborock/commit/3d0ad74954948ebf0ea5c1a144aff3f9d111b1f7)) - Fix lint errors ([#593](https://github.com/Python-roborock/python-roborock/pull/593), [`87e60d9`](https://github.com/Python-roborock/python-roborock/commit/87e60d9a9cb99ef9ddf99b1691baa2573db4221d)) - Fix typos ([#592](https://github.com/Python-roborock/python-roborock/pull/592), [`3d0ad74`](https://github.com/Python-roborock/python-roborock/commit/3d0ad74954948ebf0ea5c1a144aff3f9d111b1f7)) - Remove unnecessary whitespace ([#592](https://github.com/Python-roborock/python-roborock/pull/592), [`3d0ad74`](https://github.com/Python-roborock/python-roborock/commit/3d0ad74954948ebf0ea5c1a144aff3f9d111b1f7)) - Update roborock/devices/traits/v1/common.py ([#592](https://github.com/Python-roborock/python-roborock/pull/592), [`3d0ad74`](https://github.com/Python-roborock/python-roborock/commit/3d0ad74954948ebf0ea5c1a144aff3f9d111b1f7)) - Update working for the CommandTrait ([#592](https://github.com/Python-roborock/python-roborock/pull/592), [`3d0ad74`](https://github.com/Python-roborock/python-roborock/commit/3d0ad74954948ebf0ea5c1a144aff3f9d111b1f7)) - **deps**: Bump actions/checkout from 5 to 6 ([#598](https://github.com/Python-roborock/python-roborock/pull/598), [`a9e91ae`](https://github.com/Python-roborock/python-roborock/commit/a9e91aedaed142f433d52c8b21b3fda3a1f62450)) - **deps**: Bump click from 8.3.0 to 8.3.1 ([#594](https://github.com/Python-roborock/python-roborock/pull/594), [`4b5d6bb`](https://github.com/Python-roborock/python-roborock/commit/4b5d6bb0044deef158484b712f75ef3ab76f1cef)) - **deps**: Bump pre-commit from 4.4.0 to 4.5.0 ([#602](https://github.com/Python-roborock/python-roborock/pull/602), [`50b70a4`](https://github.com/Python-roborock/python-roborock/commit/50b70a454dd80c1b41df855496c72818ecf30cea)) - **deps**: Bump pytest-asyncio from 1.2.0 to 1.3.0 ([#596](https://github.com/Python-roborock/python-roborock/pull/596), [`ee85762`](https://github.com/Python-roborock/python-roborock/commit/ee85762ebe34663c25c3c05509a265f2d624b3ab)) - **deps**: Bump python-semantic-release/publish-action ([#599](https://github.com/Python-roborock/python-roborock/pull/599), [`bcfe314`](https://github.com/Python-roborock/python-roborock/commit/bcfe3141fde31b1930c54ac1ce8f0a3aef9adea7)) - **deps**: Bump python-semantic-release/python-semantic-release ([#600](https://github.com/Python-roborock/python-roborock/pull/600), [`f8061ff`](https://github.com/Python-roborock/python-roborock/commit/f8061ffcf416bd2618ac5b6f4b056650599bcbe8)) - **deps**: Bump ruff from 0.14.4 to 0.14.5 ([#595](https://github.com/Python-roborock/python-roborock/pull/595), [`e561838`](https://github.com/Python-roborock/python-roborock/commit/e561838449be48abebf6ea94ff944222eea4d0ec)) - **deps**: Bump ruff from 0.14.5 to 0.14.6 ([#601](https://github.com/Python-roborock/python-roborock/pull/601), [`c16c529`](https://github.com/Python-roborock/python-roborock/commit/c16c529881d84f370f99a7c5b31255a24a74da3a)) ## v3.8.0 (2025-11-15) ### Bug Fixes - Update roborock/devices/device.py ([#588](https://github.com/Python-roborock/python-roborock/pull/588), [`3994110`](https://github.com/Python-roborock/python-roborock/commit/39941103fe5247a9764d38db5ee0915dd39e043d)) ### Chores - Fix lint ([#589](https://github.com/Python-roborock/python-roborock/pull/589), [`fa69bf2`](https://github.com/Python-roborock/python-roborock/commit/fa69bf2d9d090bf8bc6cf89ead6ab122d5dbcd00)) - Fix lint errors ([#588](https://github.com/Python-roborock/python-roborock/pull/588), [`3994110`](https://github.com/Python-roborock/python-roborock/commit/39941103fe5247a9764d38db5ee0915dd39e043d)) - Update comments to clarify close call ([#588](https://github.com/Python-roborock/python-roborock/pull/588), [`3994110`](https://github.com/Python-roborock/python-roborock/commit/39941103fe5247a9764d38db5ee0915dd39e043d)) - Update documentation to point to the newer device APIs ([#589](https://github.com/Python-roborock/python-roborock/pull/589), [`fa69bf2`](https://github.com/Python-roborock/python-roborock/commit/fa69bf2d9d090bf8bc6cf89ead6ab122d5dbcd00)) - Update pydoc and formatting ([#588](https://github.com/Python-roborock/python-roborock/pull/588), [`3994110`](https://github.com/Python-roborock/python-roborock/commit/39941103fe5247a9764d38db5ee0915dd39e043d)) - Update README.md ([#589](https://github.com/Python-roborock/python-roborock/pull/589), [`fa69bf2`](https://github.com/Python-roborock/python-roborock/commit/fa69bf2d9d090bf8bc6cf89ead6ab122d5dbcd00)) - Update roborock/devices/device.py ([#588](https://github.com/Python-roborock/python-roborock/pull/588), [`3994110`](https://github.com/Python-roborock/python-roborock/commit/39941103fe5247a9764d38db5ee0915dd39e043d)) - Update roborock/devices/traits/b01/__init__.py ([#589](https://github.com/Python-roborock/python-roborock/pull/589), [`fa69bf2`](https://github.com/Python-roborock/python-roborock/commit/fa69bf2d9d090bf8bc6cf89ead6ab122d5dbcd00)) - Update roborock/devices/traits/v1/__init__.py ([#589](https://github.com/Python-roborock/python-roborock/pull/589), [`fa69bf2`](https://github.com/Python-roborock/python-roborock/commit/fa69bf2d9d090bf8bc6cf89ead6ab122d5dbcd00)) - Update typing ([#588](https://github.com/Python-roborock/python-roborock/pull/588), [`3994110`](https://github.com/Python-roborock/python-roborock/commit/39941103fe5247a9764d38db5ee0915dd39e043d)) ### Features - Add examples that show how to use the cache and implement a file cache ([#589](https://github.com/Python-roborock/python-roborock/pull/589), [`fa69bf2`](https://github.com/Python-roborock/python-roborock/commit/fa69bf2d9d090bf8bc6cf89ead6ab122d5dbcd00)) - Connect to devices asynchronously ([#588](https://github.com/Python-roborock/python-roborock/pull/588), [`3994110`](https://github.com/Python-roborock/python-roborock/commit/39941103fe5247a9764d38db5ee0915dd39e043d)) ## v3.7.4 (2025-11-15) ### Bug Fixes - Update trait `refresh` method to return `None` ([#586](https://github.com/Python-roborock/python-roborock/pull/586), [`86ec1a7`](https://github.com/Python-roborock/python-roborock/commit/86ec1a7377159ed9bdceb339804b61c73532f441)) ### Chores - Fix lint errors ([#586](https://github.com/Python-roborock/python-roborock/pull/586), [`86ec1a7`](https://github.com/Python-roborock/python-roborock/commit/86ec1a7377159ed9bdceb339804b61c73532f441)) - Remove unnecessary whitespace ([#586](https://github.com/Python-roborock/python-roborock/pull/586), [`86ec1a7`](https://github.com/Python-roborock/python-roborock/commit/86ec1a7377159ed9bdceb339804b61c73532f441)) - Revert accidental CleanSummary changes ([#586](https://github.com/Python-roborock/python-roborock/pull/586), [`86ec1a7`](https://github.com/Python-roborock/python-roborock/commit/86ec1a7377159ed9bdceb339804b61c73532f441)) - Revert unnecessary change to Clean Summary Trait ([#586](https://github.com/Python-roborock/python-roborock/pull/586), [`86ec1a7`](https://github.com/Python-roborock/python-roborock/commit/86ec1a7377159ed9bdceb339804b61c73532f441)) ## v3.7.3 (2025-11-14) ### Bug Fixes - Switches commands were incorrect ([#591](https://github.com/Python-roborock/python-roborock/pull/591), [`40aa6b6`](https://github.com/Python-roborock/python-roborock/commit/40aa6b67a6e43e273d6e4512ccdc8df11dd4dc8a)) ### Chores - Add device info for roborock.vacuum.s5e ([#587](https://github.com/Python-roborock/python-roborock/pull/587), [`20afa92`](https://github.com/Python-roborock/python-roborock/commit/20afa925e298f9fb36fc3e1ac79bf8cba90fcdf5)) - **deps**: Bump ruff from 0.14.1 to 0.14.4 ([#585](https://github.com/Python-roborock/python-roborock/pull/585), [`324c816`](https://github.com/Python-roborock/python-roborock/commit/324c8165bacfdd7abbc1561c3f4f5768cd9c331c)) ## v3.7.2 (2025-11-11) ### Bug Fixes - Improve Home trait discovery process. ([#580](https://github.com/Python-roborock/python-roborock/pull/580), [`44680e3`](https://github.com/Python-roborock/python-roborock/commit/44680e367991b6eafef0267f6b4209a09929973a)) ### Chores - Refactor test by removing redundant assertion ([#580](https://github.com/Python-roborock/python-roborock/pull/580), [`44680e3`](https://github.com/Python-roborock/python-roborock/commit/44680e367991b6eafef0267f6b4209a09929973a)) - Update tests/devices/traits/v1/test_home.py ([#580](https://github.com/Python-roborock/python-roborock/pull/580), [`44680e3`](https://github.com/Python-roborock/python-roborock/commit/44680e367991b6eafef0267f6b4209a09929973a)) - **deps**: Bump aiohttp from 3.13.0 to 3.13.2 ([#583](https://github.com/Python-roborock/python-roborock/pull/583), [`c7bacad`](https://github.com/Python-roborock/python-roborock/commit/c7bacad32ede1290fbaea261538e1b90476d61c6)) - **deps**: Bump pre-commit from 4.3.0 to 4.4.0 ([#584](https://github.com/Python-roborock/python-roborock/pull/584), [`3adc76b`](https://github.com/Python-roborock/python-roborock/commit/3adc76bdd21c16fcb25d9d3dee9c5857eccea960)) - **deps**: Bump python-semantic-release/publish-action ([#582](https://github.com/Python-roborock/python-roborock/pull/582), [`c76bf06`](https://github.com/Python-roborock/python-roborock/commit/c76bf069191bf221a848c4dfa34104e8b93b81b8)) ## v3.7.1 (2025-11-05) ### Bug Fixes - Fix typing issues in new device traits ([#577](https://github.com/Python-roborock/python-roborock/pull/577), [`3266ae6`](https://github.com/Python-roborock/python-roborock/commit/3266ae6aa9d799c398542c95463d63a8cb77dd4e)) ## v3.7.0 (2025-10-27) ### Chores - Change imports for typing ([#574](https://github.com/Python-roborock/python-roborock/pull/574), [`05c8e94`](https://github.com/Python-roborock/python-roborock/commit/05c8e9458e44a6ca61977cc0d26c2776bb1fcae5)) - Update tests/devices/traits/v1/fixtures.py ([#574](https://github.com/Python-roborock/python-roborock/pull/574), [`05c8e94`](https://github.com/Python-roborock/python-roborock/commit/05c8e9458e44a6ca61977cc0d26c2776bb1fcae5)) ### Features - Add a trait for working with routines ([#574](https://github.com/Python-roborock/python-roborock/pull/574), [`05c8e94`](https://github.com/Python-roborock/python-roborock/commit/05c8e9458e44a6ca61977cc0d26c2776bb1fcae5)) ## v3.6.0 (2025-10-27) ### Chores - Add test coverage for failing to parse bytes ([#572](https://github.com/Python-roborock/python-roborock/pull/572), [`e524d31`](https://github.com/Python-roborock/python-roborock/commit/e524d31fa63cc6c97c10bf890dbde91e7e5d3840)) - Update tests/devices/traits/v1/test_home.py ([#572](https://github.com/Python-roborock/python-roborock/pull/572), [`e524d31`](https://github.com/Python-roborock/python-roborock/commit/e524d31fa63cc6c97c10bf890dbde91e7e5d3840)) ### Features - Add map content to the Home trait ([#572](https://github.com/Python-roborock/python-roborock/pull/572), [`e524d31`](https://github.com/Python-roborock/python-roborock/commit/e524d31fa63cc6c97c10bf890dbde91e7e5d3840)) ## v3.5.0 (2025-10-27) ### Chores - Disable body-max-line-length ([#573](https://github.com/Python-roborock/python-roborock/pull/573), [`6a5db1d`](https://github.com/Python-roborock/python-roborock/commit/6a5db1d9aac274bbfa46624250def64ada2b507b)) - Go back to old web API name ([#570](https://github.com/Python-roborock/python-roborock/pull/570), [`4e7e776`](https://github.com/Python-roborock/python-roborock/commit/4e7e776fed4cae17636659b9e3365ac26347e86b)) ### Features - Simplify device manager creation ([#570](https://github.com/Python-roborock/python-roborock/pull/570), [`4e7e776`](https://github.com/Python-roborock/python-roborock/commit/4e7e776fed4cae17636659b9e3365ac26347e86b)) ## v3.4.0 (2025-10-26) ### Bug Fixes - Only validate connection when sending commands ([#571](https://github.com/Python-roborock/python-roborock/pull/571), [`efa48e9`](https://github.com/Python-roborock/python-roborock/commit/efa48e96a1554b7a7358f9f22873b847d93d663d)) ### Features - Rename home_cache to home_map_info ([#569](https://github.com/Python-roborock/python-roborock/pull/569), [`9aff1cf`](https://github.com/Python-roborock/python-roborock/commit/9aff1cf01aa8e1a56d594cd2be20400cbf1eb324)) ## v3.3.3 (2025-10-25) ### Bug Fixes - FIx bug in clean record parsing ([#567](https://github.com/Python-roborock/python-roborock/pull/567), [`8196bcc`](https://github.com/Python-roborock/python-roborock/commit/8196bccdf5239ef540291bf55fa2bd270a4544ed)) ## v3.3.2 (2025-10-25) ### Bug Fixes - Ensure that result is not none ([#565](https://github.com/Python-roborock/python-roborock/pull/565), [`c0a84eb`](https://github.com/Python-roborock/python-roborock/commit/c0a84eb434ea1b8e15adea7adeba28b7fb1853f6)) - Set to empty dict ([#565](https://github.com/Python-roborock/python-roborock/pull/565), [`c0a84eb`](https://github.com/Python-roborock/python-roborock/commit/c0a84eb434ea1b8e15adea7adeba28b7fb1853f6)) ### Chores - Fix typing for InMemoryCache ([#566](https://github.com/Python-roborock/python-roborock/pull/566), [`904494d`](https://github.com/Python-roborock/python-roborock/commit/904494da3dcf899d9a4d5d4ab589d543dcea1fe2)) - Go back to the other way ([#565](https://github.com/Python-roborock/python-roborock/pull/565), [`c0a84eb`](https://github.com/Python-roborock/python-roborock/commit/c0a84eb434ea1b8e15adea7adeba28b7fb1853f6)) - Update roborock/protocols/v1_protocol.py ([#565](https://github.com/Python-roborock/python-roborock/pull/565), [`c0a84eb`](https://github.com/Python-roborock/python-roborock/commit/c0a84eb434ea1b8e15adea7adeba28b7fb1853f6)) ## v3.3.1 (2025-10-25) ### Bug Fixes - Truncate debug strings for MapContent ([#564](https://github.com/Python-roborock/python-roborock/pull/564), [`5a377de`](https://github.com/Python-roborock/python-roborock/commit/5a377ded245f6948f86775287eb7d70c12ec7740)) ### Chores - Apply github co-pilot recommendation ([#564](https://github.com/Python-roborock/python-roborock/pull/564), [`5a377de`](https://github.com/Python-roborock/python-roborock/commit/5a377ded245f6948f86775287eb7d70c12ec7740)) ## v3.3.0 (2025-10-25) ### Bug Fixes - Lower local timeout ([#559](https://github.com/Python-roborock/python-roborock/pull/559), [`8514461`](https://github.com/Python-roborock/python-roborock/commit/8514461a0d5e69b7c1fc1466ac5f19cb8bd5cbd5)) - Remove unneeded params setting ([#559](https://github.com/Python-roborock/python-roborock/pull/559), [`8514461`](https://github.com/Python-roborock/python-roborock/commit/8514461a0d5e69b7c1fc1466ac5f19cb8bd5cbd5)) ### Chores - Add some tests and adress comments ([#559](https://github.com/Python-roborock/python-roborock/pull/559), [`8514461`](https://github.com/Python-roborock/python-roborock/commit/8514461a0d5e69b7c1fc1466ac5f19cb8bd5cbd5)) - Fix comments ([#559](https://github.com/Python-roborock/python-roborock/pull/559), [`8514461`](https://github.com/Python-roborock/python-roborock/commit/8514461a0d5e69b7c1fc1466ac5f19cb8bd5cbd5)) - Handle failed hello ([#559](https://github.com/Python-roborock/python-roborock/pull/559), [`8514461`](https://github.com/Python-roborock/python-roborock/commit/8514461a0d5e69b7c1fc1466ac5f19cb8bd5cbd5)) - Remove unneeded code ([#559](https://github.com/Python-roborock/python-roborock/pull/559), [`8514461`](https://github.com/Python-roborock/python-roborock/commit/8514461a0d5e69b7c1fc1466ac5f19cb8bd5cbd5)) - Switch to more specific exception ([#559](https://github.com/Python-roborock/python-roborock/pull/559), [`8514461`](https://github.com/Python-roborock/python-roborock/commit/8514461a0d5e69b7c1fc1466ac5f19cb8bd5cbd5)) - Update roborock/devices/local_channel.py ([#559](https://github.com/Python-roborock/python-roborock/pull/559), [`8514461`](https://github.com/Python-roborock/python-roborock/commit/8514461a0d5e69b7c1fc1466ac5f19cb8bd5cbd5)) ### Features - Add l01 to the new device format ([#559](https://github.com/Python-roborock/python-roborock/pull/559), [`8514461`](https://github.com/Python-roborock/python-roborock/commit/8514461a0d5e69b7c1fc1466ac5f19cb8bd5cbd5)) ## v3.2.0 (2025-10-25) ### Bug Fixes - Update redacted values to remove sensitive values ([#560](https://github.com/Python-roborock/python-roborock/pull/560), [`0fc7200`](https://github.com/Python-roborock/python-roborock/commit/0fc720034f9da988fe0ba981128b7d66c4998e60)) - Update shapshots ([#560](https://github.com/Python-roborock/python-roborock/pull/560), [`0fc7200`](https://github.com/Python-roborock/python-roborock/commit/0fc720034f9da988fe0ba981128b7d66c4998e60)) ### Chores - Cleanup lint ([#560](https://github.com/Python-roborock/python-roborock/pull/560), [`0fc7200`](https://github.com/Python-roborock/python-roborock/commit/0fc720034f9da988fe0ba981128b7d66c4998e60)) - Only emit traits that have any values set ([#560](https://github.com/Python-roborock/python-roborock/pull/560), [`0fc7200`](https://github.com/Python-roborock/python-roborock/commit/0fc720034f9da988fe0ba981128b7d66c4998e60)) - Revert mqtt channel changes ([#560](https://github.com/Python-roborock/python-roborock/pull/560), [`0fc7200`](https://github.com/Python-roborock/python-roborock/commit/0fc720034f9da988fe0ba981128b7d66c4998e60)) ### Features - Add diagnostic information to the device ([#560](https://github.com/Python-roborock/python-roborock/pull/560), [`0fc7200`](https://github.com/Python-roborock/python-roborock/commit/0fc720034f9da988fe0ba981128b7d66c4998e60)) ## v3.1.2 (2025-10-25) ### Bug Fixes - Move semantic release build command ([`8ed178d`](https://github.com/Python-roborock/python-roborock/commit/8ed178d9f75245bde3ffcebbf8ef18e171ba563d)) ## v3.1.1 (2025-10-25) ### Bug Fixes - Explicitly pip install uv in release ([`4409ee9`](https://github.com/Python-roborock/python-roborock/commit/4409ee90694e9b3389342692f30fbf3706f2f00c)) ### Chores - Add pre commit step for commitlint ([#563](https://github.com/Python-roborock/python-roborock/pull/563), [`14c811f`](https://github.com/Python-roborock/python-roborock/commit/14c811f30f0bb1f574be59e759aecab5ca61cd65)) ## v3.1.0 (2025-10-25) ### Bug Fixes - Fix enum names to include `none` states ([#561](https://github.com/Python-roborock/python-roborock/pull/561), [`82f6dc2`](https://github.com/Python-roborock/python-roborock/commit/82f6dc29d55fd4f8565312af3cf60abf8abba56c)) ### Chores - Add a temp main branch for testing ([#562](https://github.com/Python-roborock/python-roborock/pull/562), [`7592255`](https://github.com/Python-roborock/python-roborock/commit/75922550bad8f5fc4ece1f2f4a6be74ebb3849c2)) - Build system to make sure it doesn't break ([#562](https://github.com/Python-roborock/python-roborock/pull/562), [`7592255`](https://github.com/Python-roborock/python-roborock/commit/75922550bad8f5fc4ece1f2f4a6be74ebb3849c2)) - Fix branches ([#562](https://github.com/Python-roborock/python-roborock/pull/562), [`7592255`](https://github.com/Python-roborock/python-roborock/commit/75922550bad8f5fc4ece1f2f4a6be74ebb3849c2)) - Fix changelog ([#552](https://github.com/Python-roborock/python-roborock/pull/552), [`e2073ed`](https://github.com/Python-roborock/python-roborock/commit/e2073edc655c1a91caae5f05e1377aebfad2938e)) - Fix test release ([#562](https://github.com/Python-roborock/python-roborock/pull/562), [`7592255`](https://github.com/Python-roborock/python-roborock/commit/75922550bad8f5fc4ece1f2f4a6be74ebb3849c2)) - Get uv release to work properly ([#562](https://github.com/Python-roborock/python-roborock/pull/562), [`7592255`](https://github.com/Python-roborock/python-roborock/commit/75922550bad8f5fc4ece1f2f4a6be74ebb3849c2)) - Switch dependabot from pip to uv ([#554](https://github.com/Python-roborock/python-roborock/pull/554), [`9377e9a`](https://github.com/Python-roborock/python-roborock/commit/9377e9ac305f45339b022b5ef8f0c16b58732300)) - Try running a test release on every PR ([#562](https://github.com/Python-roborock/python-roborock/pull/562), [`7592255`](https://github.com/Python-roborock/python-roborock/commit/75922550bad8f5fc4ece1f2f4a6be74ebb3849c2)) - Update snapshot ([#555](https://github.com/Python-roborock/python-roborock/pull/555), [`b45ad3c`](https://github.com/Python-roborock/python-roborock/commit/b45ad3c487b21639a1f2ba148fe69836b11024c4)) - Update spelling from co-pilot suggestion ([#555](https://github.com/Python-roborock/python-roborock/pull/555), [`b45ad3c`](https://github.com/Python-roborock/python-roborock/commit/b45ad3c487b21639a1f2ba148fe69836b11024c4)) - Update test assertion for network info ([#558](https://github.com/Python-roborock/python-roborock/pull/558), [`b34abde`](https://github.com/Python-roborock/python-roborock/commit/b34abde2e3401c463e7fb821bae1cf20a325ec6d)) - **deps**: Bump ruff from 0.14.0 to 0.14.1 ([#553](https://github.com/Python-roborock/python-roborock/pull/553), [`df438f7`](https://github.com/Python-roborock/python-roborock/commit/df438f7a293fb0c1f1b3cfaf3691eafaa8a3fd8b)) ### Features - Add a trait for reading NetworkInfo from the device ([#558](https://github.com/Python-roborock/python-roborock/pull/558), [`b34abde`](https://github.com/Python-roborock/python-roborock/commit/b34abde2e3401c463e7fb821bae1cf20a325ec6d)) - Combine the clean record trait with the clean summary ([#555](https://github.com/Python-roborock/python-roborock/pull/555), [`b45ad3c`](https://github.com/Python-roborock/python-roborock/commit/b45ad3c487b21639a1f2ba148fe69836b11024c4)) ## v3.0.0 (2025-10-20) ### Features - Add data subpackage ([#551](https://github.com/Python-roborock/python-roborock/pull/551), [`c4d3b86`](https://github.com/Python-roborock/python-roborock/commit/c4d3b86e9862847c0dd47add13e70542141ab214)) ## v2.61.0 (2025-10-19) ### Bug Fixes - Remove DockSummary and make dust collection mode optional based on dock type ([#550](https://github.com/Python-roborock/python-roborock/pull/550), [`03d0f37`](https://github.com/Python-roborock/python-roborock/commit/03d0f37cfca41ed5943e81b6ac30e061d4f4bd3f)) ### Chores - Fix semantic release using poetry ([#549](https://github.com/Python-roborock/python-roborock/pull/549), [`4bf5396`](https://github.com/Python-roborock/python-roborock/commit/4bf5396ac270e864ed433d96baf1c6c5d613106b)) ### Features - Add clean record and dock related traits ([#550](https://github.com/Python-roborock/python-roborock/pull/550), [`03d0f37`](https://github.com/Python-roborock/python-roborock/commit/03d0f37cfca41ed5943e81b6ac30e061d4f4bd3f)) - Add dock summary and clean record traits ([#550](https://github.com/Python-roborock/python-roborock/pull/550), [`03d0f37`](https://github.com/Python-roborock/python-roborock/commit/03d0f37cfca41ed5943e81b6ac30e061d4f4bd3f)) ## v2.60.1 (2025-10-19) ### Bug Fixes - Add a common base type for all switches ([#548](https://github.com/Python-roborock/python-roborock/pull/548), [`767e118`](https://github.com/Python-roborock/python-roborock/commit/767e118ed193cd16cecb61989614b50dab432aab)) ### Chores - Fix lint ([#544](https://github.com/Python-roborock/python-roborock/pull/544), [`fe463c3`](https://github.com/Python-roborock/python-roborock/commit/fe463c36d6862864d03b8040475d57e917c310ce)) - Update pydoc for V1 subscribe ([#544](https://github.com/Python-roborock/python-roborock/pull/544), [`fe463c3`](https://github.com/Python-roborock/python-roborock/commit/fe463c36d6862864d03b8040475d57e917c310ce)) ## v2.60.0 (2025-10-18) ### Chores - Match pyproject version ([`80f9149`](https://github.com/Python-roborock/python-roborock/commit/80f91498359e81c97d4e0666a9d85d52ee6f315a)) ### Features - Add a device property to determine local connection status ([#547](https://github.com/Python-roborock/python-roborock/pull/547), [`962ffe2`](https://github.com/Python-roborock/python-roborock/commit/962ffe2b94da26a6879f1006cb585dcaac36c798)) ## v2.59.0 (2025-10-18) ### Chores - Match version and do chore so it doesn't bump ([#546](https://github.com/Python-roborock/python-roborock/pull/546), [`140de50`](https://github.com/Python-roborock/python-roborock/commit/140de50bda73901ee43b864603cd42802de1570d)) - Match version and do chore so it doesn't bump hopefully ([#546](https://github.com/Python-roborock/python-roborock/pull/546), [`140de50`](https://github.com/Python-roborock/python-roborock/commit/140de50bda73901ee43b864603cd42802de1570d)) - Switch to the non-depreciated action ([#546](https://github.com/Python-roborock/python-roborock/pull/546), [`140de50`](https://github.com/Python-roborock/python-roborock/commit/140de50bda73901ee43b864603cd42802de1570d)) ### Features - Update command trait to allow string commands ([#543](https://github.com/Python-roborock/python-roborock/pull/543), [`1fdddaf`](https://github.com/Python-roborock/python-roborock/commit/1fdddafc8b0ad490beff3f08c5118e826ab8169f)) ## v2.58.1 (2025-10-18) ### Bug Fixes - Re-align version for semantic release ([#545](https://github.com/Python-roborock/python-roborock/pull/545), [`b724a5b`](https://github.com/Python-roborock/python-roborock/commit/b724a5bec52ce7fe723710f28f9f63c3b9fa6673)) ### Chores - Re-align version ([#545](https://github.com/Python-roborock/python-roborock/pull/545), [`b724a5b`](https://github.com/Python-roborock/python-roborock/commit/b724a5bec52ce7fe723710f28f9f63c3b9fa6673)) - Use github_token for release ([#545](https://github.com/Python-roborock/python-roborock/pull/545), [`b724a5b`](https://github.com/Python-roborock/python-roborock/commit/b724a5bec52ce7fe723710f28f9f63c3b9fa6673)) ## v2.58.0 (2025-10-18) ### Bug Fixes - Add everything else back ([#542](https://github.com/Python-roborock/python-roborock/pull/542), [`84c4c48`](https://github.com/Python-roborock/python-roborock/commit/84c4c48fe1c6268db8ceb6dbe8f1ed3318ed78aa)) - Correctly run pdocs on github action ([#542](https://github.com/Python-roborock/python-roborock/pull/542), [`84c4c48`](https://github.com/Python-roborock/python-roborock/commit/84c4c48fe1c6268db8ceb6dbe8f1ed3318ed78aa)) ### Features - Create a home data API client from an existing RoborockApiClient ([#541](https://github.com/Python-roborock/python-roborock/pull/541), [`e7f8e43`](https://github.com/Python-roborock/python-roborock/commit/e7f8e432cb063765f89223332b5994b0ddb639bc)) ## v2.57.1 (2025-10-18) ### Bug Fixes - Bug and add test ([#537](https://github.com/Python-roborock/python-roborock/pull/537), [`6a3d28c`](https://github.com/Python-roborock/python-roborock/commit/6a3d28c24f7444fc5e3cc73392509aca0d5ddd6e)) - Fallback to old version of login ([#537](https://github.com/Python-roborock/python-roborock/pull/537), [`6a3d28c`](https://github.com/Python-roborock/python-roborock/commit/6a3d28c24f7444fc5e3cc73392509aca0d5ddd6e)) ### Chores - **deps-dev**: Bump ruff from 0.13.2 to 0.14.0 ([#530](https://github.com/Python-roborock/python-roborock/pull/530), [`538504d`](https://github.com/Python-roborock/python-roborock/commit/538504dbe66e5edb82a2bb7bfb78272752e0802f)) ## v2.57.0 (2025-10-18) ### Chores - Fix lint ([#540](https://github.com/Python-roborock/python-roborock/pull/540), [`24a0660`](https://github.com/Python-roborock/python-roborock/commit/24a06600633b16a0228876184125e3c5ffe16d02)) - Fix lint errors ([#539](https://github.com/Python-roborock/python-roborock/pull/539), [`fbf1434`](https://github.com/Python-roborock/python-roborock/commit/fbf1434be05103401b2f53c77737a5bfcc719102)) - Fix syntax and lint errors ([#539](https://github.com/Python-roborock/python-roborock/pull/539), [`fbf1434`](https://github.com/Python-roborock/python-roborock/commit/fbf1434be05103401b2f53c77737a5bfcc719102)) - Fix tests ([#539](https://github.com/Python-roborock/python-roborock/pull/539), [`fbf1434`](https://github.com/Python-roborock/python-roborock/commit/fbf1434be05103401b2f53c77737a5bfcc719102)) ### Features - Add a trait for sending commands ([#539](https://github.com/Python-roborock/python-roborock/pull/539), [`fbf1434`](https://github.com/Python-roborock/python-roborock/commit/fbf1434be05103401b2f53c77737a5bfcc719102)) - Expose device and product information on the new Device API ([#540](https://github.com/Python-roborock/python-roborock/pull/540), [`24a0660`](https://github.com/Python-roborock/python-roborock/commit/24a06600633b16a0228876184125e3c5ffe16d02)) - Update cli to use new command interface ([#539](https://github.com/Python-roborock/python-roborock/pull/539), [`fbf1434`](https://github.com/Python-roborock/python-roborock/commit/fbf1434be05103401b2f53c77737a5bfcc719102)) ## v2.56.0 (2025-10-17) ### Bug Fixes - Revert changes to dnd ([#538](https://github.com/Python-roborock/python-roborock/pull/538), [`aaf3636`](https://github.com/Python-roborock/python-roborock/commit/aaf3636553d68c31e89a763fb6da77d83842b6b8)) ### Chores - Add comment about firmware updates ([#538](https://github.com/Python-roborock/python-roborock/pull/538), [`aaf3636`](https://github.com/Python-roborock/python-roborock/commit/aaf3636553d68c31e89a763fb6da77d83842b6b8)) - Add python 3.14 for tests ([#524](https://github.com/Python-roborock/python-roborock/pull/524), [`a6f889d`](https://github.com/Python-roborock/python-roborock/commit/a6f889db0229d5821b04e70514ad5e7f8d5a25df)) - Explicitly install pdoc ([#531](https://github.com/Python-roborock/python-roborock/pull/531), [`5e4c913`](https://github.com/Python-roborock/python-roborock/commit/5e4c9138838eace5c010aa736c61565930520172)) - Fix lint errors ([#538](https://github.com/Python-roborock/python-roborock/pull/538), [`aaf3636`](https://github.com/Python-roborock/python-roborock/commit/aaf3636553d68c31e89a763fb6da77d83842b6b8)) - Fix typing on refresh ([#538](https://github.com/Python-roborock/python-roborock/pull/538), [`aaf3636`](https://github.com/Python-roborock/python-roborock/commit/aaf3636553d68c31e89a763fb6da77d83842b6b8)) - Just install pdoc ([#535](https://github.com/Python-roborock/python-roborock/pull/535), [`ef974cd`](https://github.com/Python-roborock/python-roborock/commit/ef974cd8fe5039aab55adea6d84375236c6a7072)) - Make roborock future test async ([#524](https://github.com/Python-roborock/python-roborock/pull/524), [`a6f889d`](https://github.com/Python-roborock/python-roborock/commit/a6f889db0229d5821b04e70514ad5e7f8d5a25df)) - Remove extra checkout ([#531](https://github.com/Python-roborock/python-roborock/pull/531), [`5e4c913`](https://github.com/Python-roborock/python-roborock/commit/5e4c9138838eace5c010aa736c61565930520172)) - Remove use of decorator and replace with class attribute ([#538](https://github.com/Python-roborock/python-roborock/pull/538), [`aaf3636`](https://github.com/Python-roborock/python-roborock/commit/aaf3636553d68c31e89a763fb6da77d83842b6b8)) - Switch pages to use uv ([#531](https://github.com/Python-roborock/python-roborock/pull/531), [`5e4c913`](https://github.com/Python-roborock/python-roborock/commit/5e4c9138838eace5c010aa736c61565930520172)) ### Features - Add various optional features with support for checking device features ([#538](https://github.com/Python-roborock/python-roborock/pull/538), [`aaf3636`](https://github.com/Python-roborock/python-roborock/commit/aaf3636553d68c31e89a763fb6da77d83842b6b8)) ## v2.55.0 (2025-10-16) ### Bug Fixes - Don't perform discovery when the device is cleaning ([#526](https://github.com/Python-roborock/python-roborock/pull/526), [`8ae82d1`](https://github.com/Python-roborock/python-roborock/commit/8ae82d1437ab60a09b828b399c69d56ced759b03)) - Require both country code and country ([#533](https://github.com/Python-roborock/python-roborock/pull/533), [`f827cbc`](https://github.com/Python-roborock/python-roborock/commit/f827cbccbb5b2204d614a95bf9ae82687c611325)) ### Chores - Add common routine for updating the cache ([#526](https://github.com/Python-roborock/python-roborock/pull/526), [`8ae82d1`](https://github.com/Python-roborock/python-roborock/commit/8ae82d1437ab60a09b828b399c69d56ced759b03)) - Fix lint error found by ruff on type comparison ([#528](https://github.com/Python-roborock/python-roborock/pull/528), [`5a4a03b`](https://github.com/Python-roborock/python-roborock/commit/5a4a03b05db97d7ce02f16b17de61f88daa1ee3d)) - Fix lint errors ([#528](https://github.com/Python-roborock/python-roborock/pull/528), [`5a4a03b`](https://github.com/Python-roborock/python-roborock/commit/5a4a03b05db97d7ce02f16b17de61f88daa1ee3d)) - Hook up the trait to the device and CLI ([#526](https://github.com/Python-roborock/python-roborock/pull/526), [`8ae82d1`](https://github.com/Python-roborock/python-roborock/commit/8ae82d1437ab60a09b828b399c69d56ced759b03)) - Migrate to uv ([#525](https://github.com/Python-roborock/python-roborock/pull/525), [`ec78beb`](https://github.com/Python-roborock/python-roborock/commit/ec78beb57a088d75ac9400c15cc15994f9978852)) - Replace async-timeout with asyncio.timeout ([#527](https://github.com/Python-roborock/python-roborock/pull/527), [`f376027`](https://github.com/Python-roborock/python-roborock/commit/f376027f5933e163441cf1815b820056731a3632)) - Upgrade ruff and apply ruff-format to all files ([#528](https://github.com/Python-roborock/python-roborock/pull/528), [`5a4a03b`](https://github.com/Python-roborock/python-roborock/commit/5a4a03b05db97d7ce02f16b17de61f88daa1ee3d)) ### Features - Add a Home trait that for caching information about maps and rooms ([#526](https://github.com/Python-roborock/python-roborock/pull/526), [`8ae82d1`](https://github.com/Python-roborock/python-roborock/commit/8ae82d1437ab60a09b828b399c69d56ced759b03)) - Add a trait for device features ([#534](https://github.com/Python-roborock/python-roborock/pull/534), [`8539fe4`](https://github.com/Python-roborock/python-roborock/commit/8539fe4b0ab72c388a55300a4724ca42bde83e38)) ## v2.54.0 (2025-10-10) ### Features - Add some extra status attributes ([#514](https://github.com/Python-roborock/python-roborock/pull/514), [`660e929`](https://github.com/Python-roborock/python-roborock/commit/660e9290659b27fb32a9e6dd1b82f6c608b1949e)) - Add support for detecting issues with the dock holders ([#514](https://github.com/Python-roborock/python-roborock/pull/514), [`660e929`](https://github.com/Python-roborock/python-roborock/commit/660e9290659b27fb32a9e6dd1b82f6c608b1949e)) - Get the latest clean info ([#522](https://github.com/Python-roborock/python-roborock/pull/522), [`3ac8f2d`](https://github.com/Python-roborock/python-roborock/commit/3ac8f2dd5490788dbe7f5ee74a1449eff42f802b)) ## v2.53.1 (2025-10-06) ### Bug Fixes - Cli on windows ([#520](https://github.com/Python-roborock/python-roborock/pull/520), [`4127db8`](https://github.com/Python-roborock/python-roborock/commit/4127db857e38db57ee5c84a27e7a6b64fdf40cbf)) ## v2.53.0 (2025-10-05) ### Chores - Fix formatting ([#517](https://github.com/Python-roborock/python-roborock/pull/517), [`e49b3ea`](https://github.com/Python-roborock/python-roborock/commit/e49b3ea1c2cc29a6c41562c3e937659ed9c0816a)) - Fix lint and typing errors ([#517](https://github.com/Python-roborock/python-roborock/pull/517), [`e49b3ea`](https://github.com/Python-roborock/python-roborock/commit/e49b3ea1c2cc29a6c41562c3e937659ed9c0816a)) - Fix lint error ([#517](https://github.com/Python-roborock/python-roborock/pull/517), [`e49b3ea`](https://github.com/Python-roborock/python-roborock/commit/e49b3ea1c2cc29a6c41562c3e937659ed9c0816a)) - Fix test wording ([#517](https://github.com/Python-roborock/python-roborock/pull/517), [`e49b3ea`](https://github.com/Python-roborock/python-roborock/commit/e49b3ea1c2cc29a6c41562c3e937659ed9c0816a)) - Refactor to reuse the same payload functions ([#517](https://github.com/Python-roborock/python-roborock/pull/517), [`e49b3ea`](https://github.com/Python-roborock/python-roborock/commit/e49b3ea1c2cc29a6c41562c3e937659ed9c0816a)) - Remove duplicate code ([#517](https://github.com/Python-roborock/python-roborock/pull/517), [`e49b3ea`](https://github.com/Python-roborock/python-roborock/commit/e49b3ea1c2cc29a6c41562c3e937659ed9c0816a)) ### Features - Add a v1 device trait for map contents ([#517](https://github.com/Python-roborock/python-roborock/pull/517), [`e49b3ea`](https://github.com/Python-roborock/python-roborock/commit/e49b3ea1c2cc29a6c41562c3e937659ed9c0816a)) ## v2.52.0 (2025-10-05) ### Bug Fixes - Fix room mapping parsing bug and add addtiional format samples ([#516](https://github.com/Python-roborock/python-roborock/pull/516), [`a68fbf1`](https://github.com/Python-roborock/python-roborock/commit/a68fbf197a0abd9aaa0418eec21949e65b53b88c)) - Update test ([#516](https://github.com/Python-roborock/python-roborock/pull/516), [`a68fbf1`](https://github.com/Python-roborock/python-roborock/commit/a68fbf197a0abd9aaa0418eec21949e65b53b88c)) ### Chores - Abort bad merges ([#516](https://github.com/Python-roborock/python-roborock/pull/516), [`a68fbf1`](https://github.com/Python-roborock/python-roborock/commit/a68fbf197a0abd9aaa0418eec21949e65b53b88c)) - Add additional example room mapping ([#516](https://github.com/Python-roborock/python-roborock/pull/516), [`a68fbf1`](https://github.com/Python-roborock/python-roborock/commit/a68fbf197a0abd9aaa0418eec21949e65b53b88c)) - Adjust test case ([#519](https://github.com/Python-roborock/python-roborock/pull/519), [`df6c674`](https://github.com/Python-roborock/python-roborock/commit/df6c6740431d75f06868979aed5e07bfa8887ed6)) - Fix lint errors ([#516](https://github.com/Python-roborock/python-roborock/pull/516), [`a68fbf1`](https://github.com/Python-roborock/python-roborock/commit/a68fbf197a0abd9aaa0418eec21949e65b53b88c)) - Fix test warning ([#519](https://github.com/Python-roborock/python-roborock/pull/519), [`df6c674`](https://github.com/Python-roborock/python-roborock/commit/df6c6740431d75f06868979aed5e07bfa8887ed6)) - Fix typing ([#516](https://github.com/Python-roborock/python-roborock/pull/516), [`a68fbf1`](https://github.com/Python-roborock/python-roborock/commit/a68fbf197a0abd9aaa0418eec21949e65b53b88c)) - Switch the rooms trait back to the local API ([#516](https://github.com/Python-roborock/python-roborock/pull/516), [`a68fbf1`](https://github.com/Python-roborock/python-roborock/commit/a68fbf197a0abd9aaa0418eec21949e65b53b88c)) ### Features - Add v1 rooms support to the device traits API ([#516](https://github.com/Python-roborock/python-roborock/pull/516), [`a68fbf1`](https://github.com/Python-roborock/python-roborock/commit/a68fbf197a0abd9aaa0418eec21949e65b53b88c)) ## v2.51.0 (2025-10-05) ### Chores - Add a class comment about availability ([#502](https://github.com/Python-roborock/python-roborock/pull/502), [`6bc3458`](https://github.com/Python-roborock/python-roborock/commit/6bc3458f49fc1072798ce8bfbcdea0d512e19bfd)) - Remove whitespace ([#502](https://github.com/Python-roborock/python-roborock/pull/502), [`6bc3458`](https://github.com/Python-roborock/python-roborock/commit/6bc3458f49fc1072798ce8bfbcdea0d512e19bfd)) ### Features - Add support for getting and reseting consumables ([#502](https://github.com/Python-roborock/python-roborock/pull/502), [`6bc3458`](https://github.com/Python-roborock/python-roborock/commit/6bc3458f49fc1072798ce8bfbcdea0d512e19bfd)) ## v2.50.4 (2025-10-05) ### Bug Fixes - Return in finally ([#518](https://github.com/Python-roborock/python-roborock/pull/518), [`9d400d5`](https://github.com/Python-roborock/python-roborock/commit/9d400d5a20a09395f93c0370718c4589d8155814)) ### Chores - **deps**: Bump actions/upload-pages-artifact from 3 to 4 ([#505](https://github.com/Python-roborock/python-roborock/pull/505), [`e505791`](https://github.com/Python-roborock/python-roborock/commit/e5057919c9d0d90c85f29df35a628a209508121c)) - **deps**: Bump click from 8.2.1 to 8.3.0 ([#495](https://github.com/Python-roborock/python-roborock/pull/495), [`9e12170`](https://github.com/Python-roborock/python-roborock/commit/9e121704267dea8c8bbac0e799dcfa8462bc7de7)) - **deps-dev**: Bump mypy from 1.18.1 to 1.18.2 ([#496](https://github.com/Python-roborock/python-roborock/pull/496), [`31cbf41`](https://github.com/Python-roborock/python-roborock/commit/31cbf41caf1485dcebe5c6590d634e36392c6b3b)) - **deps-dev**: Bump ruff from 0.13.0 to 0.13.2 ([#509](https://github.com/Python-roborock/python-roborock/pull/509), [`3ba07ad`](https://github.com/Python-roborock/python-roborock/commit/3ba07ad572aa28735828c78dd339308e3cb0340d)) ## v2.50.3 (2025-10-03) ### Bug Fixes - Update containers that __post_init__ to use properties ([#503](https://github.com/Python-roborock/python-roborock/pull/503), [`f87f55c`](https://github.com/Python-roborock/python-roborock/commit/f87f55ce2d62f90dd945a283def927b9fca70dab)) ### Chores - Fix lint errors ([#503](https://github.com/Python-roborock/python-roborock/pull/503), [`f87f55c`](https://github.com/Python-roborock/python-roborock/commit/f87f55ce2d62f90dd945a283def927b9fca70dab)) - Fix typo ([#503](https://github.com/Python-roborock/python-roborock/pull/503), [`f87f55c`](https://github.com/Python-roborock/python-roborock/commit/f87f55ce2d62f90dd945a283def927b9fca70dab)) - Include atributes in repr computation ([#503](https://github.com/Python-roborock/python-roborock/pull/503), [`f87f55c`](https://github.com/Python-roborock/python-roborock/commit/f87f55ce2d62f90dd945a283def927b9fca70dab)) - Update to get all properties at runtime ([#503](https://github.com/Python-roborock/python-roborock/pull/503), [`f87f55c`](https://github.com/Python-roborock/python-roborock/commit/f87f55ce2d62f90dd945a283def927b9fca70dab)) ## v2.50.2 (2025-10-03) ### Bug Fixes - Cycle through iot urls ([#490](https://github.com/Python-roborock/python-roborock/pull/490), [`2cee9dd`](https://github.com/Python-roborock/python-roborock/commit/2cee9ddcfb2c608967499405992c6e42f6124855)) ### Chores - Add tests ([#490](https://github.com/Python-roborock/python-roborock/pull/490), [`2cee9dd`](https://github.com/Python-roborock/python-roborock/commit/2cee9ddcfb2c608967499405992c6e42f6124855)) - Convert to store all iot login info together ([#490](https://github.com/Python-roborock/python-roborock/pull/490), [`2cee9dd`](https://github.com/Python-roborock/python-roborock/commit/2cee9ddcfb2c608967499405992c6e42f6124855)) - Remove gemini ([#512](https://github.com/Python-roborock/python-roborock/pull/512), [`632f0f4`](https://github.com/Python-roborock/python-roborock/commit/632f0f4031fe38c621a50e4bf6a7d2097f560aa9)) ## v2.50.1 (2025-10-03) ### Bug Fixes - Use correct replace times ([#513](https://github.com/Python-roborock/python-roborock/pull/513), [`a6ac92c`](https://github.com/Python-roborock/python-roborock/commit/a6ac92c5443833fe19a1d184495171904e04cbe2)) ## v2.50.0 (2025-10-03) ### Bug Fixes - Add a decorator to mark traits as mqtt only ([#499](https://github.com/Python-roborock/python-roborock/pull/499), [`87d9aa6`](https://github.com/Python-roborock/python-roborock/commit/87d9aa61676e11fd0ca56f5fc6c998fbff48645b)) ### Chores - Add additional test coverage ([#499](https://github.com/Python-roborock/python-roborock/pull/499), [`87d9aa6`](https://github.com/Python-roborock/python-roborock/commit/87d9aa61676e11fd0ca56f5fc6c998fbff48645b)) - Add comment describing the decorator check ([#499](https://github.com/Python-roborock/python-roborock/pull/499), [`87d9aa6`](https://github.com/Python-roborock/python-roborock/commit/87d9aa61676e11fd0ca56f5fc6c998fbff48645b)) - Fix lint errors ([#499](https://github.com/Python-roborock/python-roborock/pull/499), [`87d9aa6`](https://github.com/Python-roborock/python-roborock/commit/87d9aa61676e11fd0ca56f5fc6c998fbff48645b)) ### Features - Add v1 api support for the list of maps ([#499](https://github.com/Python-roborock/python-roborock/pull/499), [`87d9aa6`](https://github.com/Python-roborock/python-roborock/commit/87d9aa61676e11fd0ca56f5fc6c998fbff48645b)) ## v2.49.1 (2025-09-29) ### Bug Fixes - Broken current map logic ([#497](https://github.com/Python-roborock/python-roborock/pull/497), [`d7d0a3b`](https://github.com/Python-roborock/python-roborock/commit/d7d0a3b5f4066aab54be5736e01eb2b437c920de)) - The map id ([#497](https://github.com/Python-roborock/python-roborock/pull/497), [`d7d0a3b`](https://github.com/Python-roborock/python-roborock/commit/d7d0a3b5f4066aab54be5736e01eb2b437c920de)) ### Chores - Add no_map constant ([#497](https://github.com/Python-roborock/python-roborock/pull/497), [`d7d0a3b`](https://github.com/Python-roborock/python-roborock/commit/d7d0a3b5f4066aab54be5736e01eb2b437c920de)) - Add test ([#497](https://github.com/Python-roborock/python-roborock/pull/497), [`d7d0a3b`](https://github.com/Python-roborock/python-roborock/commit/d7d0a3b5f4066aab54be5736e01eb2b437c920de)) - Try `poetry run pdoc` to fix CI ([#504](https://github.com/Python-roborock/python-roborock/pull/504), [`da5d80f`](https://github.com/Python-roborock/python-roborock/commit/da5d80fb5c9e425317c3ade3065b2158af0a830f)) ## v2.49.0 (2025-09-29) ### Bug Fixes - Remove functon ([#448](https://github.com/Python-roborock/python-roborock/pull/448), [`27fb9fc`](https://github.com/Python-roborock/python-roborock/commit/27fb9fc00c9c16235a983db0df4cc0d2cfb5f7b3)) - Tchange name of cleanmodesold ([#448](https://github.com/Python-roborock/python-roborock/pull/448), [`27fb9fc`](https://github.com/Python-roborock/python-roborock/commit/27fb9fc00c9c16235a983db0df4cc0d2cfb5f7b3)) ### Chores - Cap product feature map ([#448](https://github.com/Python-roborock/python-roborock/pull/448), [`27fb9fc`](https://github.com/Python-roborock/python-roborock/commit/27fb9fc00c9c16235a983db0df4cc0d2cfb5f7b3)) - Fix lint ([#500](https://github.com/Python-roborock/python-roborock/pull/500), [`d5bb862`](https://github.com/Python-roborock/python-roborock/commit/d5bb8625ba6eb46a13b79df78a02f7e5e25cfd9f)) - Remove more complicated changes ([#448](https://github.com/Python-roborock/python-roborock/pull/448), [`27fb9fc`](https://github.com/Python-roborock/python-roborock/commit/27fb9fc00c9c16235a983db0df4cc0d2cfb5f7b3)) ### Features - Add dynamic clean modes ([#448](https://github.com/Python-roborock/python-roborock/pull/448), [`27fb9fc`](https://github.com/Python-roborock/python-roborock/commit/27fb9fc00c9c16235a983db0df4cc0d2cfb5f7b3)) - Add module for parsing map content ([#500](https://github.com/Python-roborock/python-roborock/pull/500), [`d5bb862`](https://github.com/Python-roborock/python-roborock/commit/d5bb8625ba6eb46a13b79df78a02f7e5e25cfd9f)) - Improve dynamic clean ([#448](https://github.com/Python-roborock/python-roborock/pull/448), [`27fb9fc`](https://github.com/Python-roborock/python-roborock/commit/27fb9fc00c9c16235a983db0df4cc0d2cfb5f7b3)) - Improve dynamic clean modes ([#448](https://github.com/Python-roborock/python-roborock/pull/448), [`27fb9fc`](https://github.com/Python-roborock/python-roborock/commit/27fb9fc00c9c16235a983db0df4cc0d2cfb5f7b3)) ## v2.48.0 (2025-09-29) ### Chores - Add gemini default ci actions ([#493](https://github.com/Python-roborock/python-roborock/pull/493), [`20e3c3d`](https://github.com/Python-roborock/python-roborock/commit/20e3c3da8100dd5e7b4a0b6418b41bb40a2efc36)) - Add imports for public APIs ([#501](https://github.com/Python-roborock/python-roborock/pull/501), [`21c83c0`](https://github.com/Python-roborock/python-roborock/commit/21c83c06116ef0c36dc7069cb2a3b822406de866)) ### Features - Add pdoc for leveraging python docstrings for documentation ([#501](https://github.com/Python-roborock/python-roborock/pull/501), [`21c83c0`](https://github.com/Python-roborock/python-roborock/commit/21c83c06116ef0c36dc7069cb2a3b822406de866)) ## v2.47.1 (2025-09-22) ### Bug Fixes - Improve new v1 apis to use mqtt lazily and work entirely locally ([#491](https://github.com/Python-roborock/python-roborock/pull/491), [`d0212e5`](https://github.com/Python-roborock/python-roborock/commit/d0212e58b032de2cce7c99691bdcec49ac8dfce2)) ### Chores - Extract caching logic to one place ([#491](https://github.com/Python-roborock/python-roborock/pull/491), [`d0212e5`](https://github.com/Python-roborock/python-roborock/commit/d0212e58b032de2cce7c99691bdcec49ac8dfce2)) - Remove unnecessary logging ([#491](https://github.com/Python-roborock/python-roborock/pull/491), [`d0212e5`](https://github.com/Python-roborock/python-roborock/commit/d0212e58b032de2cce7c99691bdcec49ac8dfce2)) - Remove whitespace ([#491](https://github.com/Python-roborock/python-roborock/pull/491), [`d0212e5`](https://github.com/Python-roborock/python-roborock/commit/d0212e58b032de2cce7c99691bdcec49ac8dfce2)) - Update comments ([#491](https://github.com/Python-roborock/python-roborock/pull/491), [`d0212e5`](https://github.com/Python-roborock/python-roborock/commit/d0212e58b032de2cce7c99691bdcec49ac8dfce2)) ## v2.47.0 (2025-09-21) ### Bug Fixes - Add version to ping ([#487](https://github.com/Python-roborock/python-roborock/pull/487), [`bff0e9c`](https://github.com/Python-roborock/python-roborock/commit/bff0e9c96b32d7a5c28e56488a7a92c57b098a46)) - Bug fixes for 1.0 ([#487](https://github.com/Python-roborock/python-roborock/pull/487), [`bff0e9c`](https://github.com/Python-roborock/python-roborock/commit/bff0e9c96b32d7a5c28e56488a7a92c57b098a46)) - Make sure we are connected on message send ([#487](https://github.com/Python-roborock/python-roborock/pull/487), [`bff0e9c`](https://github.com/Python-roborock/python-roborock/commit/bff0e9c96b32d7a5c28e56488a7a92c57b098a46)) - Potentially fix ping? ([#487](https://github.com/Python-roborock/python-roborock/pull/487), [`bff0e9c`](https://github.com/Python-roborock/python-roborock/commit/bff0e9c96b32d7a5c28e56488a7a92c57b098a46)) - Remove excluding ping from id check ([#487](https://github.com/Python-roborock/python-roborock/pull/487), [`bff0e9c`](https://github.com/Python-roborock/python-roborock/commit/bff0e9c96b32d7a5c28e56488a7a92c57b098a46)) - Some misc bug changes ([#487](https://github.com/Python-roborock/python-roborock/pull/487), [`bff0e9c`](https://github.com/Python-roborock/python-roborock/commit/bff0e9c96b32d7a5c28e56488a7a92c57b098a46)) - Some small changes ([#487](https://github.com/Python-roborock/python-roborock/pull/487), [`bff0e9c`](https://github.com/Python-roborock/python-roborock/commit/bff0e9c96b32d7a5c28e56488a7a92c57b098a46)) ### Chores - Add comment about rpc channel hacks and separate property files ([#489](https://github.com/Python-roborock/python-roborock/pull/489), [`362ec1d`](https://github.com/Python-roborock/python-roborock/commit/362ec1d3360e56cc4b98151b9c001bcdad64ffd2)) - Fix return types in CleanSummaryTrait ([#489](https://github.com/Python-roborock/python-roborock/pull/489), [`362ec1d`](https://github.com/Python-roborock/python-roborock/commit/362ec1d3360e56cc4b98151b9c001bcdad64ffd2)) - Init try based on Homey logic ([#487](https://github.com/Python-roborock/python-roborock/pull/487), [`bff0e9c`](https://github.com/Python-roborock/python-roborock/commit/bff0e9c96b32d7a5c28e56488a7a92c57b098a46)) - Only allow a single trait ([#489](https://github.com/Python-roborock/python-roborock/pull/489), [`362ec1d`](https://github.com/Python-roborock/python-roborock/commit/362ec1d3360e56cc4b98151b9c001bcdad64ffd2)) - Overhaul new device trait interfaces ([#489](https://github.com/Python-roborock/python-roborock/pull/489), [`362ec1d`](https://github.com/Python-roborock/python-roborock/commit/362ec1d3360e56cc4b98151b9c001bcdad64ffd2)) - Remove debug ([#487](https://github.com/Python-roborock/python-roborock/pull/487), [`bff0e9c`](https://github.com/Python-roborock/python-roborock/commit/bff0e9c96b32d7a5c28e56488a7a92c57b098a46)) - Remove unnecessarily local variables ([#489](https://github.com/Python-roborock/python-roborock/pull/489), [`362ec1d`](https://github.com/Python-roborock/python-roborock/commit/362ec1d3360e56cc4b98151b9c001bcdad64ffd2)) - Rename b01 properties to match v1 ([#489](https://github.com/Python-roborock/python-roborock/pull/489), [`362ec1d`](https://github.com/Python-roborock/python-roborock/commit/362ec1d3360e56cc4b98151b9c001bcdad64ffd2)) - Set sign_key to private ([#488](https://github.com/Python-roborock/python-roborock/pull/488), [`ed46bce`](https://github.com/Python-roborock/python-roborock/commit/ed46bce0db7201c0416cdf6076b3403f5b1fad5e)) - Some potential clean up ([#487](https://github.com/Python-roborock/python-roborock/pull/487), [`bff0e9c`](https://github.com/Python-roborock/python-roborock/commit/bff0e9c96b32d7a5c28e56488a7a92c57b098a46)) ### Features - Implement L01 protocol ([#487](https://github.com/Python-roborock/python-roborock/pull/487), [`bff0e9c`](https://github.com/Python-roborock/python-roborock/commit/bff0e9c96b32d7a5c28e56488a7a92c57b098a46)) - Update CLI with new properties ([#489](https://github.com/Python-roborock/python-roborock/pull/489), [`362ec1d`](https://github.com/Python-roborock/python-roborock/commit/362ec1d3360e56cc4b98151b9c001bcdad64ffd2)) ## v2.46.0 (2025-09-21) ### Bug Fixes - Address some comments ([#460](https://github.com/Python-roborock/python-roborock/pull/460), [`599da6c`](https://github.com/Python-roborock/python-roborock/commit/599da6c044ba897b5005a2e1536ddc53af84cd4d)) - Handle auth expiring ([#460](https://github.com/Python-roborock/python-roborock/pull/460), [`599da6c`](https://github.com/Python-roborock/python-roborock/commit/599da6c044ba897b5005a2e1536ddc53af84cd4d)) - Mqtt error handling ([#460](https://github.com/Python-roborock/python-roborock/pull/460), [`599da6c`](https://github.com/Python-roborock/python-roborock/commit/599da6c044ba897b5005a2e1536ddc53af84cd4d)) - Str some other rcs ([#460](https://github.com/Python-roborock/python-roborock/pull/460), [`599da6c`](https://github.com/Python-roborock/python-roborock/commit/599da6c044ba897b5005a2e1536ddc53af84cd4d)) ### Chores - Add else back ([#460](https://github.com/Python-roborock/python-roborock/pull/460), [`599da6c`](https://github.com/Python-roborock/python-roborock/commit/599da6c044ba897b5005a2e1536ddc53af84cd4d)) - Clean up ([#460](https://github.com/Python-roborock/python-roborock/pull/460), [`599da6c`](https://github.com/Python-roborock/python-roborock/commit/599da6c044ba897b5005a2e1536ddc53af84cd4d)) - Inverse boolean logic to match variable naming ([#460](https://github.com/Python-roborock/python-roborock/pull/460), [`599da6c`](https://github.com/Python-roborock/python-roborock/commit/599da6c044ba897b5005a2e1536ddc53af84cd4d)) - Remove extra exception ([#460](https://github.com/Python-roborock/python-roborock/pull/460), [`599da6c`](https://github.com/Python-roborock/python-roborock/commit/599da6c044ba897b5005a2e1536ddc53af84cd4d)) ### Features - Add seperate validate connection for the cloud api and bump keepalive ([#460](https://github.com/Python-roborock/python-roborock/pull/460), [`599da6c`](https://github.com/Python-roborock/python-roborock/commit/599da6c044ba897b5005a2e1536ddc53af84cd4d)) ## v2.45.0 (2025-09-21) ### Chores - Add tests ([#486](https://github.com/Python-roborock/python-roborock/pull/486), [`1eebd29`](https://github.com/Python-roborock/python-roborock/commit/1eebd29231534d187699dfaaa7d6f5721a31b5c8)) - **deps**: Bump python-semantic-release/python-semantic-release ([#479](https://github.com/Python-roborock/python-roborock/pull/479), [`68f52ab`](https://github.com/Python-roborock/python-roborock/commit/68f52ab40f782766a73df7640df2b0a92f7d360f)) - **deps-dev**: Bump mypy from 1.17.1 to 1.18.1 ([#478](https://github.com/Python-roborock/python-roborock/pull/478), [`efe460b`](https://github.com/Python-roborock/python-roborock/commit/efe460b2f8150fa34e33129854f6c2abb7ae1c4c)) - **deps-dev**: Bump pytest from 8.4.1 to 8.4.2 ([#466](https://github.com/Python-roborock/python-roborock/pull/466), [`efa2922`](https://github.com/Python-roborock/python-roborock/commit/efa2922cb76e9716ff2ed0bd9edd92fbbcac36ce)) ### Features - Add v4 for code login ([#486](https://github.com/Python-roborock/python-roborock/pull/486), [`1eebd29`](https://github.com/Python-roborock/python-roborock/commit/1eebd29231534d187699dfaaa7d6f5721a31b5c8)) ## v2.44.1 (2025-09-18) ### Bug Fixes - Pass through additional fields to the home data fetcher ([#484](https://github.com/Python-roborock/python-roborock/pull/484), [`6fd180a`](https://github.com/Python-roborock/python-roborock/commit/6fd180a3277fe7d92f44e6af6575edeb6a682a45)) ### Chores - Add test coverage of end to end trait parsin from raw responses ([#482](https://github.com/Python-roborock/python-roborock/pull/482), [`0fac328`](https://github.com/Python-roborock/python-roborock/commit/0fac32824bb7edc71171a6ad6e44c61a298a9d11)) - Add test coverage of end to end trait parsing from raw responses ([#482](https://github.com/Python-roborock/python-roborock/pull/482), [`0fac328`](https://github.com/Python-roborock/python-roborock/commit/0fac32824bb7edc71171a6ad6e44c61a298a9d11)) - Fix lint errors ([#482](https://github.com/Python-roborock/python-roborock/pull/482), [`0fac328`](https://github.com/Python-roborock/python-roborock/commit/0fac32824bb7edc71171a6ad6e44c61a298a9d11)) ## v2.44.0 (2025-09-15) ### Chores - Fix imports ([#477](https://github.com/Python-roborock/python-roborock/pull/477), [`a391c17`](https://github.com/Python-roborock/python-roborock/commit/a391c1765e4b62004a290e1f63d46f7e722d4c49)) - Remove duplicate api_error from bad merge ([#477](https://github.com/Python-roborock/python-roborock/pull/477), [`a391c17`](https://github.com/Python-roborock/python-roborock/commit/a391c1765e4b62004a290e1f63d46f7e722d4c49)) - **deps-dev**: Bump pytest-asyncio from 1.1.0 to 1.2.0 ([#480](https://github.com/Python-roborock/python-roborock/pull/480), [`772a829`](https://github.com/Python-roborock/python-roborock/commit/772a829f115138f9e99e26d3fb6950b743b1e8fe)) - **deps-dev**: Bump ruff from 0.12.9 to 0.13.0 ([#481](https://github.com/Python-roborock/python-roborock/pull/481), [`c56252e`](https://github.com/Python-roborock/python-roborock/commit/c56252eb3882b4a1b4b3bc517206e34f5dcd4657)) ### Features - Add a sound volume trait ([#477](https://github.com/Python-roborock/python-roborock/pull/477), [`a391c17`](https://github.com/Python-roborock/python-roborock/commit/a391c1765e4b62004a290e1f63d46f7e722d4c49)) - Add volume trait ([#477](https://github.com/Python-roborock/python-roborock/pull/477), [`a391c17`](https://github.com/Python-roborock/python-roborock/commit/a391c1765e4b62004a290e1f63d46f7e722d4c49)) ## v2.43.0 (2025-09-15) ### Features - Add a clean summary trait ([#476](https://github.com/Python-roborock/python-roborock/pull/476), [`1585e1c`](https://github.com/Python-roborock/python-roborock/commit/1585e1ccd8cda8008a701e4289f4b2e3febb84f5)) ## v2.42.0 (2025-09-14) ### Chores - **deps**: Bump actions/setup-python from 5 to 6 ([#465](https://github.com/Python-roborock/python-roborock/pull/465), [`7333643`](https://github.com/Python-roborock/python-roborock/commit/7333643417b57890a6fd18bc63929c2c48f45dbe)) - **deps**: Bump pypa/gh-action-pypi-publish from 1.12.4 to 1.13.0 ([#463](https://github.com/Python-roborock/python-roborock/pull/463), [`ff44b2d`](https://github.com/Python-roborock/python-roborock/commit/ff44b2d1a2d5f70ed7b1ac10abe8295f39376180)) ### Features - Add ability to encrypt and decrypt L01 ([#468](https://github.com/Python-roborock/python-roborock/pull/468), [`50aef42`](https://github.com/Python-roborock/python-roborock/commit/50aef42fa130f696fe367b3696547865bc7a690a)) - Add session to CLI ([#473](https://github.com/Python-roborock/python-roborock/pull/473), [`d58072e`](https://github.com/Python-roborock/python-roborock/commit/d58072eb12be15d5e1fcbd171e5434897497544c)) ## v2.41.1 (2025-09-14) ### Bug Fixes - Fix a bug with local / mqtt fallback ([#475](https://github.com/Python-roborock/python-roborock/pull/475), [`9f97a2b`](https://github.com/Python-roborock/python-roborock/commit/9f97a2bcf5189f515e9cd07629b65be7762c19ff)) ## v2.41.0 (2025-09-14) ### Bug Fixes - Fix bug parsing MultiMapsListMapInfo ([#474](https://github.com/Python-roborock/python-roborock/pull/474), [`d79ea3b`](https://github.com/Python-roborock/python-roborock/commit/d79ea3b76d9e1fedbb5fecd7edd21fcf07b29b80)) ### Chores - Revert changes to rpc channel ([#471](https://github.com/Python-roborock/python-roborock/pull/471), [`cce1c1b`](https://github.com/Python-roborock/python-roborock/commit/cce1c1b0a5db4a02be949a310ccd4356126bc229)) - Simplify command sending ([#471](https://github.com/Python-roborock/python-roborock/pull/471), [`cce1c1b`](https://github.com/Python-roborock/python-roborock/commit/cce1c1b0a5db4a02be949a310ccd4356126bc229)) ### Features - Add a DnD trait and fix bugs in the rpc channels ([#471](https://github.com/Python-roborock/python-roborock/pull/471), [`cce1c1b`](https://github.com/Python-roborock/python-roborock/commit/cce1c1b0a5db4a02be949a310ccd4356126bc229)) ## v2.40.1 (2025-09-13) ### Bug Fixes - Bug where the map requested from the app confuses our system ([#469](https://github.com/Python-roborock/python-roborock/pull/469), [`4ddfce0`](https://github.com/Python-roborock/python-roborock/commit/4ddfce0e0abcc21b97285aa7a5e585d5076c4f30)) ## v2.40.0 (2025-09-07) ### Bug Fixes - Missing code ([#462](https://github.com/Python-roborock/python-roborock/pull/462), [`99dd479`](https://github.com/Python-roborock/python-roborock/commit/99dd479029758186d5ad6efcc7420c18b1690dde)) - Wrong package ([#462](https://github.com/Python-roborock/python-roborock/pull/462), [`99dd479`](https://github.com/Python-roborock/python-roborock/commit/99dd479029758186d5ad6efcc7420c18b1690dde)) ### Features - Add l01 discovery ([#462](https://github.com/Python-roborock/python-roborock/pull/462), [`99dd479`](https://github.com/Python-roborock/python-roborock/commit/99dd479029758186d5ad6efcc7420c18b1690dde)) ## v2.39.2 (2025-09-07) ### Bug Fixes - Remove __del__ ([#459](https://github.com/Python-roborock/python-roborock/pull/459), [`62f19ca`](https://github.com/Python-roborock/python-roborock/commit/62f19ca37ee84a817e1e5444619b1bd1031d6626)) ### Chores - Move broadcast_protocol to its own file ([#459](https://github.com/Python-roborock/python-roborock/pull/459), [`62f19ca`](https://github.com/Python-roborock/python-roborock/commit/62f19ca37ee84a817e1e5444619b1bd1031d6626)) - Move broadcast_protocol to t's own file ([#459](https://github.com/Python-roborock/python-roborock/pull/459), [`62f19ca`](https://github.com/Python-roborock/python-roborock/commit/62f19ca37ee84a817e1e5444619b1bd1031d6626)) ## v2.39.1 (2025-09-07) ### Bug Fixes - Add missing finish reason ([#461](https://github.com/Python-roborock/python-roborock/pull/461), [`4d9ba70`](https://github.com/Python-roborock/python-roborock/commit/4d9ba70a9b18d56abd8583ae4f8c6ca33b833e2c)) ### Chores - Add snapshot tests for parsing device wire formats ([#457](https://github.com/Python-roborock/python-roborock/pull/457), [`d966b84`](https://github.com/Python-roborock/python-roborock/commit/d966b845d5c73ab6a15128e65785ee1306c8986b)) - Sort imports ([#457](https://github.com/Python-roborock/python-roborock/pull/457), [`d966b84`](https://github.com/Python-roborock/python-roborock/commit/d966b845d5c73ab6a15128e65785ee1306c8986b)) ## v2.39.0 (2025-08-24) ### Bug Fixes - Add more containers information ([#449](https://github.com/Python-roborock/python-roborock/pull/449), [`5ef1cd8`](https://github.com/Python-roborock/python-roborock/commit/5ef1cd833ea027b1dcd02b66694b37e404a63dc1)) - Get RoborockBase working for other files ([#449](https://github.com/Python-roborock/python-roborock/pull/449), [`5ef1cd8`](https://github.com/Python-roborock/python-roborock/commit/5ef1cd833ea027b1dcd02b66694b37e404a63dc1)) - Make code dynamic ([#449](https://github.com/Python-roborock/python-roborock/pull/449), [`5ef1cd8`](https://github.com/Python-roborock/python-roborock/commit/5ef1cd833ea027b1dcd02b66694b37e404a63dc1)) - Version check ([#449](https://github.com/Python-roborock/python-roborock/pull/449), [`5ef1cd8`](https://github.com/Python-roborock/python-roborock/commit/5ef1cd833ea027b1dcd02b66694b37e404a63dc1)) ### Chores - Fix style and comments ([#456](https://github.com/Python-roborock/python-roborock/pull/456), [`57d82e2`](https://github.com/Python-roborock/python-roborock/commit/57d82e2485fcf1cf63bd651427dd56b17f8cb694)) - Move imports ([#449](https://github.com/Python-roborock/python-roborock/pull/449), [`5ef1cd8`](https://github.com/Python-roborock/python-roborock/commit/5ef1cd833ea027b1dcd02b66694b37e404a63dc1)) - Remove registry ([#449](https://github.com/Python-roborock/python-roborock/pull/449), [`5ef1cd8`](https://github.com/Python-roborock/python-roborock/commit/5ef1cd833ea027b1dcd02b66694b37e404a63dc1)) - Unify callback handling recipes across mqtt and local channels ([#456](https://github.com/Python-roborock/python-roborock/pull/456), [`57d82e2`](https://github.com/Python-roborock/python-roborock/commit/57d82e2485fcf1cf63bd651427dd56b17f8cb694)) ### Features - Improve B01 support ([#449](https://github.com/Python-roborock/python-roborock/pull/449), [`5ef1cd8`](https://github.com/Python-roborock/python-roborock/commit/5ef1cd833ea027b1dcd02b66694b37e404a63dc1)) ## v2.38.0 (2025-08-21) ### Bug Fixes - Change to store info in a yaml file ([#428](https://github.com/Python-roborock/python-roborock/pull/428), [`41d5433`](https://github.com/Python-roborock/python-roborock/commit/41d543362c8163d565feffd2fd48425480159087)) ### Chores - Fix lint errors from merge ([#428](https://github.com/Python-roborock/python-roborock/pull/428), [`41d5433`](https://github.com/Python-roborock/python-roborock/commit/41d543362c8163d565feffd2fd48425480159087)) - Update doc ([#428](https://github.com/Python-roborock/python-roborock/pull/428), [`41d5433`](https://github.com/Python-roborock/python-roborock/commit/41d543362c8163d565feffd2fd48425480159087)) - **deps**: Bump actions/checkout from 4 to 5 ([#454](https://github.com/Python-roborock/python-roborock/pull/454), [`2020f33`](https://github.com/Python-roborock/python-roborock/commit/2020f3386a8d69f94a01d433220b3081b661c86e)) - **deps-dev**: Bump ruff from 0.12.8 to 0.12.9 ([#455](https://github.com/Python-roborock/python-roborock/pull/455), [`aec476c`](https://github.com/Python-roborock/python-roborock/commit/aec476c1c9b09e04f788a9825ed0ec590a205c30)) ### Features - Add the ability to update supported_features via cli ([#428](https://github.com/Python-roborock/python-roborock/pull/428), [`41d5433`](https://github.com/Python-roborock/python-roborock/commit/41d543362c8163d565feffd2fd48425480159087)) ## v2.37.0 (2025-08-19) ### Bug Fixes - Remove query_values response ([#453](https://github.com/Python-roborock/python-roborock/pull/453), [`0004721`](https://github.com/Python-roborock/python-roborock/commit/0004721d5264a13261d8485dd487de512d7c310e)) - Update mqtt channel to correctly handle multiple subscribers ([#453](https://github.com/Python-roborock/python-roborock/pull/453), [`0004721`](https://github.com/Python-roborock/python-roborock/commit/0004721d5264a13261d8485dd487de512d7c310e)) ### Chores - Remove dependencies on `get_request_id` in RequestMessage ([#452](https://github.com/Python-roborock/python-roborock/pull/452), [`f4dcea5`](https://github.com/Python-roborock/python-roborock/commit/f4dcea542477b1208591c2d316048d86080c48af)) - Remove pending rpcs object ([#453](https://github.com/Python-roborock/python-roborock/pull/453), [`0004721`](https://github.com/Python-roborock/python-roborock/commit/0004721d5264a13261d8485dd487de512d7c310e)) - Remove unnecessary whitespace ([#453](https://github.com/Python-roborock/python-roborock/pull/453), [`0004721`](https://github.com/Python-roborock/python-roborock/commit/0004721d5264a13261d8485dd487de512d7c310e)) - Update logging and comments ([#453](https://github.com/Python-roborock/python-roborock/pull/453), [`0004721`](https://github.com/Python-roborock/python-roborock/commit/0004721d5264a13261d8485dd487de512d7c310e)) - **deps**: Bump aiohttp from 3.12.13 to 3.12.15 ([#446](https://github.com/Python-roborock/python-roborock/pull/446), [`b6bcb2a`](https://github.com/Python-roborock/python-roborock/commit/b6bcb2ab2cdebb07b540a573299520675257a1c9)) - **deps**: Bump pyrate-limiter from 3.7.0 to 3.9.0 ([#445](https://github.com/Python-roborock/python-roborock/pull/445), [`8ac85da`](https://github.com/Python-roborock/python-roborock/commit/8ac85da5ecdbda5ef374b0cc492e505041cf8f4e)) - **deps-dev**: Bump freezegun from 1.5.4 to 1.5.5 ([#444](https://github.com/Python-roborock/python-roborock/pull/444), [`e62168a`](https://github.com/Python-roborock/python-roborock/commit/e62168af067501fae2f853fb5924f787470fdd69)) - **deps-dev**: Bump mypy from 1.15.0 to 1.17.1 ([#443](https://github.com/Python-roborock/python-roborock/pull/443), [`241b166`](https://github.com/Python-roborock/python-roborock/commit/241b1661063083b2685c420a8d931325106b341d)) ### Features - Fix a01 and b01 response handling in new api ([#453](https://github.com/Python-roborock/python-roborock/pull/453), [`0004721`](https://github.com/Python-roborock/python-roborock/commit/0004721d5264a13261d8485dd487de512d7c310e)) ## v2.36.0 (2025-08-18) ### Chores - Extract common module for manaing pending RPCs ([#451](https://github.com/Python-roborock/python-roborock/pull/451), [`d8ce60f`](https://github.com/Python-roborock/python-roborock/commit/d8ce60fe985f152c9f3485cbc12f8c04aaf041b1)) - Extract map parser into a separate function to share with new api ([#440](https://github.com/Python-roborock/python-roborock/pull/440), [`2a800c2`](https://github.com/Python-roborock/python-roborock/commit/2a800c2943bf0bb6389349798d26dab65411ae40)) - Remove docstrings ([#450](https://github.com/Python-roborock/python-roborock/pull/450), [`1addf95`](https://github.com/Python-roborock/python-roborock/commit/1addf95d5502dde8900bb4bceca418eaad179733)) - **deps-dev**: Bump pre-commit from 4.2.0 to 4.3.0 ([#442](https://github.com/Python-roborock/python-roborock/pull/442), [`d59d6e3`](https://github.com/Python-roborock/python-roborock/commit/d59d6e331b4a03082d8f494117b28e04766e0e7b)) - **deps-dev**: Bump ruff from 0.12.0 to 0.12.8 ([#441](https://github.com/Python-roborock/python-roborock/pull/441), [`e58bd95`](https://github.com/Python-roborock/python-roborock/commit/e58bd95d631bf0d85a66686fd0fab82528958458)) ### Features - Add container and function for app_init_status ([#450](https://github.com/Python-roborock/python-roborock/pull/450), [`1addf95`](https://github.com/Python-roborock/python-roborock/commit/1addf95d5502dde8900bb4bceca418eaad179733)) ## v2.35.0 (2025-08-11) ### Chores - Avoid re-parsing RoborockMessages and replace with passing explicit arguments ([#439](https://github.com/Python-roborock/python-roborock/pull/439), [`251b3f9`](https://github.com/Python-roborock/python-roborock/commit/251b3f9fbc245a606279dd4a00603efbf93daa26)) ### Features - Add dynamic clean modes ([#437](https://github.com/Python-roborock/python-roborock/pull/437), [`af17544`](https://github.com/Python-roborock/python-roborock/commit/af175440a3e754dc198f9026a4bcfd24b891f5ee)) ## v2.34.2 (2025-08-11) ### Bug Fixes - Merge the local api with the local v1 api ([#438](https://github.com/Python-roborock/python-roborock/pull/438), [`450e35e`](https://github.com/Python-roborock/python-roborock/commit/450e35e23ca591dcf75b916dd3be3daeb4a09e84)) ## v2.34.1 (2025-08-10) ### Bug Fixes - Fix "retry" error handling ([#436](https://github.com/Python-roborock/python-roborock/pull/436), [`eb6da93`](https://github.com/Python-roborock/python-roborock/commit/eb6da93478f89625ca71a381d5a104653d8888f4)) ## v2.34.0 (2025-08-10) ### Chores - Fix lint whitespace ([#435](https://github.com/Python-roborock/python-roborock/pull/435), [`a385a14`](https://github.com/Python-roborock/python-roborock/commit/a385a14816e835ad0d53de1afcd58036877a47ed)) - Fix merge with cache data rename ([#418](https://github.com/Python-roborock/python-roborock/pull/418), [`98ea911`](https://github.com/Python-roborock/python-roborock/commit/98ea911a313c71b65508b7c934b21c8379b3846e)) - Speed up mqtt session shutdown by avoiding a sleep ([#435](https://github.com/Python-roborock/python-roborock/pull/435), [`a385a14`](https://github.com/Python-roborock/python-roborock/commit/a385a14816e835ad0d53de1afcd58036877a47ed)) ### Features - Add some basic B01 support ([#429](https://github.com/Python-roborock/python-roborock/pull/429), [`72274e9`](https://github.com/Python-roborock/python-roborock/commit/72274e9aa23ed31327cd44200fd8c2f0bd26daff)) - Get_home_data_v3 for new devices ([#418](https://github.com/Python-roborock/python-roborock/pull/418), [`98ea911`](https://github.com/Python-roborock/python-roborock/commit/98ea911a313c71b65508b7c934b21c8379b3846e)) - Update cli.py and device_manager.py to use get_home_data_v3 ([#418](https://github.com/Python-roborock/python-roborock/pull/418), [`98ea911`](https://github.com/Python-roborock/python-roborock/commit/98ea911a313c71b65508b7c934b21c8379b3846e)) - Use get_home_data_v3 for tests ([#418](https://github.com/Python-roborock/python-roborock/pull/418), [`98ea911`](https://github.com/Python-roborock/python-roborock/commit/98ea911a313c71b65508b7c934b21c8379b3846e)) ## v2.33.0 (2025-08-10) ### Bug Fixes - Adjust cache implementation defaults ([#432](https://github.com/Python-roborock/python-roborock/pull/432), [`f076a51`](https://github.com/Python-roborock/python-roborock/commit/f076a516b4569aa00ff767f19eab66eddba0b0b9)) ### Chores - Add back generator exception handling ([#434](https://github.com/Python-roborock/python-roborock/pull/434), [`c0f28da`](https://github.com/Python-roborock/python-roborock/commit/c0f28da1e5fbc707c5092baf179c8daa2d97db75)) - Fix lint errors ([#434](https://github.com/Python-roborock/python-roborock/pull/434), [`c0f28da`](https://github.com/Python-roborock/python-roborock/commit/c0f28da1e5fbc707c5092baf179c8daa2d97db75)) - Fix lint errors ([#432](https://github.com/Python-roborock/python-roborock/pull/432), [`f076a51`](https://github.com/Python-roborock/python-roborock/commit/f076a516b4569aa00ff767f19eab66eddba0b0b9)) - Update pytest-asyncio and fix clean shutdown ([#434](https://github.com/Python-roborock/python-roborock/pull/434), [`c0f28da`](https://github.com/Python-roborock/python-roborock/commit/c0f28da1e5fbc707c5092baf179c8daa2d97db75)) ### Features - Add an explicit module for caching ([#432](https://github.com/Python-roborock/python-roborock/pull/432), [`f076a51`](https://github.com/Python-roborock/python-roborock/commit/f076a516b4569aa00ff767f19eab66eddba0b0b9)) - Add explicit cache module ([#432](https://github.com/Python-roborock/python-roborock/pull/432), [`f076a51`](https://github.com/Python-roborock/python-roborock/commit/f076a516b4569aa00ff767f19eab66eddba0b0b9)) - Update the cli cache to also store network info ([#432](https://github.com/Python-roborock/python-roborock/pull/432), [`f076a51`](https://github.com/Python-roborock/python-roborock/commit/f076a516b4569aa00ff767f19eab66eddba0b0b9)) ## v2.32.0 (2025-08-10) ### Bug Fixes - Add test where current_map is none ([#433](https://github.com/Python-roborock/python-roborock/pull/433), [`0e28988`](https://github.com/Python-roborock/python-roborock/commit/0e289881e88632c1093827cf4f7d6b9076405c0b)) ### Chores - **deps**: Bump pycryptodome from 3.22.0 to 3.23.0 ([#403](https://github.com/Python-roborock/python-roborock/pull/403), [`011631c`](https://github.com/Python-roborock/python-roborock/commit/011631ccdd5313bc5de9d72066fd7b255c8368e8)) - **deps**: Bump pycryptodomex from 3.22.0 to 3.23.0 ([#404](https://github.com/Python-roborock/python-roborock/pull/404), [`c87d40b`](https://github.com/Python-roborock/python-roborock/commit/c87d40b446e5ab091465f2911e0470a9042f43cc)) - **deps**: Bump python-semantic-release/python-semantic-release ([#421](https://github.com/Python-roborock/python-roborock/pull/421), [`381acf6`](https://github.com/Python-roborock/python-roborock/commit/381acf64b9c5208950c555a92797c4c0cc0eb5ed)) - **deps-dev**: Bump freezegun from 1.5.1 to 1.5.4 ([#423](https://github.com/Python-roborock/python-roborock/pull/423), [`1d3fe5c`](https://github.com/Python-roborock/python-roborock/commit/1d3fe5c7ca6ea215b3051759068b8a7843f87f4d)) - **deps-dev**: Bump pytest from 8.3.5 to 8.4.1 ([#405](https://github.com/Python-roborock/python-roborock/pull/405), [`65e961b`](https://github.com/Python-roborock/python-roborock/commit/65e961b62bc6e248c966565da2470ae482aeafbd)) ### Features - Add property for accessing the current map from the status object ([#433](https://github.com/Python-roborock/python-roborock/pull/433), [`0e28988`](https://github.com/Python-roborock/python-roborock/commit/0e289881e88632c1093827cf4f7d6b9076405c0b)) ## v2.31.0 (2025-08-10) ### Chores - Fix lint errors ([#427](https://github.com/Python-roborock/python-roborock/pull/427), [`b4e3693`](https://github.com/Python-roborock/python-roborock/commit/b4e3693caad062ffaa20dd907a53eb5b15e5bd96)) ### Features - Update the cli cache to also store network info ([#427](https://github.com/Python-roborock/python-roborock/pull/427), [`b4e3693`](https://github.com/Python-roborock/python-roborock/commit/b4e3693caad062ffaa20dd907a53eb5b15e5bd96)) ## v2.30.0 (2025-08-10) ### Chores - Remove command info ([#430](https://github.com/Python-roborock/python-roborock/pull/430), [`04a83e8`](https://github.com/Python-roborock/python-roborock/commit/04a83e8485e297f329750e41fe663fe90819152e)) ### Features - Add a new type for supported features ([#431](https://github.com/Python-roborock/python-roborock/pull/431), [`b23c358`](https://github.com/Python-roborock/python-roborock/commit/b23c358b2cbc9642a8be908fa0864592f64df0fc)) ## v2.29.1 (2025-08-09) ### Bug Fixes - Add test coverage for extra keys ([#426](https://github.com/Python-roborock/python-roborock/pull/426), [`97dfd16`](https://github.com/Python-roborock/python-roborock/commit/97dfd1647ac16900875f1e77aadfbd7921a9fadc)) ### Chores - Cleanup whitespace ([#426](https://github.com/Python-roborock/python-roborock/pull/426), [`97dfd16`](https://github.com/Python-roborock/python-roborock/commit/97dfd1647ac16900875f1e77aadfbd7921a9fadc)) - Fix typing ([#426](https://github.com/Python-roborock/python-roborock/pull/426), [`97dfd16`](https://github.com/Python-roborock/python-roborock/commit/97dfd1647ac16900875f1e77aadfbd7921a9fadc)) - Remove container ([#426](https://github.com/Python-roborock/python-roborock/pull/426), [`97dfd16`](https://github.com/Python-roborock/python-roborock/commit/97dfd1647ac16900875f1e77aadfbd7921a9fadc)) - Remove unnecessary container ([#426](https://github.com/Python-roborock/python-roborock/pull/426), [`97dfd16`](https://github.com/Python-roborock/python-roborock/commit/97dfd1647ac16900875f1e77aadfbd7921a9fadc)) - Update container parsing using native typing and dataclass ([#426](https://github.com/Python-roborock/python-roborock/pull/426), [`97dfd16`](https://github.com/Python-roborock/python-roborock/commit/97dfd1647ac16900875f1e77aadfbd7921a9fadc)) - Update unknown key test to use simple object ([#426](https://github.com/Python-roborock/python-roborock/pull/426), [`97dfd16`](https://github.com/Python-roborock/python-roborock/commit/97dfd1647ac16900875f1e77aadfbd7921a9fadc)) ## v2.29.0 (2025-08-09) ### Bug Fixes - Add safety check for trait creation ([#425](https://github.com/Python-roborock/python-roborock/pull/425), [`f7d1a55`](https://github.com/Python-roborock/python-roborock/commit/f7d1a553677fd988c24891648410c144565c658b)) - Update mqtt payload encoding signature ([#425](https://github.com/Python-roborock/python-roborock/pull/425), [`f7d1a55`](https://github.com/Python-roborock/python-roborock/commit/f7d1a553677fd988c24891648410c144565c658b)) ### Chores - Address code review feedback ([#425](https://github.com/Python-roborock/python-roborock/pull/425), [`f7d1a55`](https://github.com/Python-roborock/python-roborock/commit/f7d1a553677fd988c24891648410c144565c658b)) - Revert encode_mqtt_payload typing change ([#425](https://github.com/Python-roborock/python-roborock/pull/425), [`f7d1a55`](https://github.com/Python-roborock/python-roborock/commit/f7d1a553677fd988c24891648410c144565c658b)) - Update roborock/devices/v1_channel.py ([#425](https://github.com/Python-roborock/python-roborock/pull/425), [`f7d1a55`](https://github.com/Python-roborock/python-roborock/commit/f7d1a553677fd988c24891648410c144565c658b)) ### Features - Support both a01 and v1 device types with traits ([#425](https://github.com/Python-roborock/python-roborock/pull/425), [`f7d1a55`](https://github.com/Python-roborock/python-roborock/commit/f7d1a553677fd988c24891648410c144565c658b)) - Update cli with v1 status trait ([#425](https://github.com/Python-roborock/python-roborock/pull/425), [`f7d1a55`](https://github.com/Python-roborock/python-roborock/commit/f7d1a553677fd988c24891648410c144565c658b)) ## v2.28.0 (2025-08-09) ### Chores - Add timeout to queue request to diagnose ([#420](https://github.com/Python-roborock/python-roborock/pull/420), [`717654a`](https://github.com/Python-roborock/python-roborock/commit/717654a648a86c1323048fb6cfdb022aef3097ec)) - Attempt to reduce a01 test flakiness by fixing shutdown to reduce number of active threads ([#420](https://github.com/Python-roborock/python-roborock/pull/420), [`717654a`](https://github.com/Python-roborock/python-roborock/commit/717654a648a86c1323048fb6cfdb022aef3097ec)) - Fix a01 client ([#420](https://github.com/Python-roborock/python-roborock/pull/420), [`717654a`](https://github.com/Python-roborock/python-roborock/commit/717654a648a86c1323048fb6cfdb022aef3097ec)) - Fix lint errors ([#420](https://github.com/Python-roborock/python-roborock/pull/420), [`717654a`](https://github.com/Python-roborock/python-roborock/commit/717654a648a86c1323048fb6cfdb022aef3097ec)) - Move device_features to seperate file and add some tests and rework device_features ([#365](https://github.com/Python-roborock/python-roborock/pull/365), [`c6ba0d6`](https://github.com/Python-roborock/python-roborock/commit/c6ba0d669f259744176821927c8606172c5c345d)) - Refactor some of the internal channel details used by the device. ([#424](https://github.com/Python-roborock/python-roborock/pull/424), [`cbd6df2`](https://github.com/Python-roborock/python-roborock/commit/cbd6df23da93681b72d47a68c1d64dcb25b27db5)) - Remove unnecessary command ([#424](https://github.com/Python-roborock/python-roborock/pull/424), [`cbd6df2`](https://github.com/Python-roborock/python-roborock/commit/cbd6df23da93681b72d47a68c1d64dcb25b27db5)) - Rename rpc channels to have v1 in the name ([#424](https://github.com/Python-roborock/python-roborock/pull/424), [`cbd6df2`](https://github.com/Python-roborock/python-roborock/commit/cbd6df23da93681b72d47a68c1d64dcb25b27db5)) - Separate V1 API connection logic from encoding logic ([#424](https://github.com/Python-roborock/python-roborock/pull/424), [`cbd6df2`](https://github.com/Python-roborock/python-roborock/commit/cbd6df23da93681b72d47a68c1d64dcb25b27db5)) - Update to the version from the other PR ([#365](https://github.com/Python-roborock/python-roborock/pull/365), [`c6ba0d6`](https://github.com/Python-roborock/python-roborock/commit/c6ba0d669f259744176821927c8606172c5c345d)) ### Features - Add device_features to automatically determine what is supported ([#365](https://github.com/Python-roborock/python-roborock/pull/365), [`c6ba0d6`](https://github.com/Python-roborock/python-roborock/commit/c6ba0d669f259744176821927c8606172c5c345d)) ## v2.27.0 (2025-08-03) ### Bug Fixes - Simplify local connection handling ([#416](https://github.com/Python-roborock/python-roborock/pull/416), [`c1bdac0`](https://github.com/Python-roborock/python-roborock/commit/c1bdac0ac56a9b86c33fb89c84c9eae92c9ed682)) - Update error message and add pydoc for exception handling on subscribe ([#416](https://github.com/Python-roborock/python-roborock/pull/416), [`c1bdac0`](https://github.com/Python-roborock/python-roborock/commit/c1bdac0ac56a9b86c33fb89c84c9eae92c9ed682)) - Update pydoc for sending a raw command ([#416](https://github.com/Python-roborock/python-roborock/pull/416), [`c1bdac0`](https://github.com/Python-roborock/python-roborock/commit/c1bdac0ac56a9b86c33fb89c84c9eae92c9ed682)) ### Chores - Remove whitespace ([#416](https://github.com/Python-roborock/python-roborock/pull/416), [`c1bdac0`](https://github.com/Python-roborock/python-roborock/commit/c1bdac0ac56a9b86c33fb89c84c9eae92c9ed682)) - **deps**: Bump click from 8.1.8 to 8.2.1 ([#416](https://github.com/Python-roborock/python-roborock/pull/416), [`c1bdac0`](https://github.com/Python-roborock/python-roborock/commit/c1bdac0ac56a9b86c33fb89c84c9eae92c9ed682)) ### Features - Add a v1 protocol channel bridging across MQTT/Local channels ([#416](https://github.com/Python-roborock/python-roborock/pull/416), [`c1bdac0`](https://github.com/Python-roborock/python-roborock/commit/c1bdac0ac56a9b86c33fb89c84c9eae92c9ed682)) - Add a v1 protocol channel that can send messages across MQTT or Local connections, preferring local ([#416](https://github.com/Python-roborock/python-roborock/pull/416), [`c1bdac0`](https://github.com/Python-roborock/python-roborock/commit/c1bdac0ac56a9b86c33fb89c84c9eae92c9ed682)) - Fix tests referencing RoborockStateCode ([#416](https://github.com/Python-roborock/python-roborock/pull/416), [`c1bdac0`](https://github.com/Python-roborock/python-roborock/commit/c1bdac0ac56a9b86c33fb89c84c9eae92c9ed682)) - Fix tests reverted by co-pilot ([#416](https://github.com/Python-roborock/python-roborock/pull/416), [`c1bdac0`](https://github.com/Python-roborock/python-roborock/commit/c1bdac0ac56a9b86c33fb89c84c9eae92c9ed682)) ## v2.26.0 (2025-08-03) ### Chores - Move a01 encoding and decoding to a separate module ([#417](https://github.com/Python-roborock/python-roborock/pull/417), [`5a2dac0`](https://github.com/Python-roborock/python-roborock/commit/5a2dac0ae39d05fd71efa753fc860009d0a07428)) - Remove logging code ([#417](https://github.com/Python-roborock/python-roborock/pull/417), [`5a2dac0`](https://github.com/Python-roborock/python-roborock/commit/5a2dac0ae39d05fd71efa753fc860009d0a07428)) - Remove stale comment in roborock_client_a01.py ([#417](https://github.com/Python-roborock/python-roborock/pull/417), [`5a2dac0`](https://github.com/Python-roborock/python-roborock/commit/5a2dac0ae39d05fd71efa753fc860009d0a07428)) - Revert some logging changes ([#417](https://github.com/Python-roborock/python-roborock/pull/417), [`5a2dac0`](https://github.com/Python-roborock/python-roborock/commit/5a2dac0ae39d05fd71efa753fc860009d0a07428)) ### Features - Add Saros 10 code mappings ([#419](https://github.com/Python-roborock/python-roborock/pull/419), [`54a7e53`](https://github.com/Python-roborock/python-roborock/commit/54a7e53da7a482cd293243dd752bbe3ce77cbda3)) ## v2.25.1 (2025-07-27) ### Bug Fixes - Add saros 10r modes ([#415](https://github.com/Python-roborock/python-roborock/pull/415), [`7ebcde9`](https://github.com/Python-roborock/python-roborock/commit/7ebcde942587ab3de81783b4b6006080cd715466)) ### Chores - **deps**: Bump click from 8.1.8 to 8.2.1 ([#401](https://github.com/Python-roborock/python-roborock/pull/401), [`36f5f2b`](https://github.com/Python-roborock/python-roborock/commit/36f5f2b76aee7d21da63e3f222cffa01d7e303b8)) - **deps**: Bump python-semantic-release/python-semantic-release ([#400](https://github.com/Python-roborock/python-roborock/pull/400), [`fd17a30`](https://github.com/Python-roborock/python-roborock/commit/fd17a307a74ab10550ac129590542528a8bac3ca)) ## v2.25.0 (2025-07-15) ### Chores - Change return type of caplog ([#411](https://github.com/Python-roborock/python-roborock/pull/411), [`f1dd1fe`](https://github.com/Python-roborock/python-roborock/commit/f1dd1fec36629cffb01e1a44ce96e36566bb4246)) - Create module for v1 request encoding ([#413](https://github.com/Python-roborock/python-roborock/pull/413), [`7507423`](https://github.com/Python-roborock/python-roborock/commit/7507423478c0a35375cc51fbffa043f015d73755)) - Delete tests/devices/test_v1_protocol.py ([#413](https://github.com/Python-roborock/python-roborock/pull/413), [`7507423`](https://github.com/Python-roborock/python-roborock/commit/7507423478c0a35375cc51fbffa043f015d73755)) - Enable verbose logging in CI ([#411](https://github.com/Python-roborock/python-roborock/pull/411), [`f1dd1fe`](https://github.com/Python-roborock/python-roborock/commit/f1dd1fec36629cffb01e1a44ce96e36566bb4246)) - Fix CI logging ([#411](https://github.com/Python-roborock/python-roborock/pull/411), [`f1dd1fe`](https://github.com/Python-roborock/python-roborock/commit/f1dd1fec36629cffb01e1a44ce96e36566bb4246)) - Fix lint ([#411](https://github.com/Python-roborock/python-roborock/pull/411), [`f1dd1fe`](https://github.com/Python-roborock/python-roborock/commit/f1dd1fec36629cffb01e1a44ce96e36566bb4246)) - Fix lint in test ([#412](https://github.com/Python-roborock/python-roborock/pull/412), [`ec780c9`](https://github.com/Python-roborock/python-roborock/commit/ec780c94c2de89fc565b24dc02fbaa3a5b531422)) - Fix warning in tests/devices/test_device_manager.py ([#412](https://github.com/Python-roborock/python-roborock/pull/412), [`ec780c9`](https://github.com/Python-roborock/python-roborock/commit/ec780c94c2de89fc565b24dc02fbaa3a5b531422)) - Remove incorrect caplog package ([#411](https://github.com/Python-roborock/python-roborock/pull/411), [`f1dd1fe`](https://github.com/Python-roborock/python-roborock/commit/f1dd1fec36629cffb01e1a44ce96e36566bb4246)) - Remove tests that timeout on CI ([#411](https://github.com/Python-roborock/python-roborock/pull/411), [`f1dd1fe`](https://github.com/Python-roborock/python-roborock/commit/f1dd1fec36629cffb01e1a44ce96e36566bb4246)) - Update log format to include timining information and thread names ([#411](https://github.com/Python-roborock/python-roborock/pull/411), [`f1dd1fe`](https://github.com/Python-roborock/python-roborock/commit/f1dd1fec36629cffb01e1a44ce96e36566bb4246)) ### Features - Simplify local payload encoding by rejecting any cloud commands sent locally ([#413](https://github.com/Python-roborock/python-roborock/pull/413), [`7507423`](https://github.com/Python-roborock/python-roborock/commit/7507423478c0a35375cc51fbffa043f015d73755)) ## v2.24.0 (2025-07-05) ### Features - Add a local channel, similar to the MQTT channel ([#410](https://github.com/Python-roborock/python-roborock/pull/410), [`1fb135b`](https://github.com/Python-roborock/python-roborock/commit/1fb135b763b8abe88d799fc609bdfc07077dee0a)) - Add debug lines ([#409](https://github.com/Python-roborock/python-roborock/pull/409), [`509ff6a`](https://github.com/Python-roborock/python-roborock/commit/509ff6aa223b4e49de1fe4fd70c8e2a2afbcb501)) - Add support for sending/recieving messages ([#409](https://github.com/Python-roborock/python-roborock/pull/409), [`509ff6a`](https://github.com/Python-roborock/python-roborock/commit/509ff6aa223b4e49de1fe4fd70c8e2a2afbcb501)) - Add test coverage for device manager close ([#409](https://github.com/Python-roborock/python-roborock/pull/409), [`509ff6a`](https://github.com/Python-roborock/python-roborock/commit/509ff6aa223b4e49de1fe4fd70c8e2a2afbcb501)) - Add test coverage to device modules ([#409](https://github.com/Python-roborock/python-roborock/pull/409), [`509ff6a`](https://github.com/Python-roborock/python-roborock/commit/509ff6aa223b4e49de1fe4fd70c8e2a2afbcb501)) - Apply suggestions from code review ([#409](https://github.com/Python-roborock/python-roborock/pull/409), [`509ff6a`](https://github.com/Python-roborock/python-roborock/commit/509ff6aa223b4e49de1fe4fd70c8e2a2afbcb501)) - Gather tasks ([#409](https://github.com/Python-roborock/python-roborock/pull/409), [`509ff6a`](https://github.com/Python-roborock/python-roborock/commit/509ff6aa223b4e49de1fe4fd70c8e2a2afbcb501)) - Log a warning when transport is already closed ([#410](https://github.com/Python-roborock/python-roborock/pull/410), [`1fb135b`](https://github.com/Python-roborock/python-roborock/commit/1fb135b763b8abe88d799fc609bdfc07077dee0a)) - Simplify rpc handling and tests ([#409](https://github.com/Python-roborock/python-roborock/pull/409), [`509ff6a`](https://github.com/Python-roborock/python-roborock/commit/509ff6aa223b4e49de1fe4fd70c8e2a2afbcb501)) - Update device manager and device to establish an MQTT subscription ([#409](https://github.com/Python-roborock/python-roborock/pull/409), [`509ff6a`](https://github.com/Python-roborock/python-roborock/commit/509ff6aa223b4e49de1fe4fd70c8e2a2afbcb501)) - Update roborock/devices/mqtt_channel.py ([#409](https://github.com/Python-roborock/python-roborock/pull/409), [`509ff6a`](https://github.com/Python-roborock/python-roborock/commit/509ff6aa223b4e49de1fe4fd70c8e2a2afbcb501)) ## v2.23.0 (2025-07-01) ### Features - Implement set_value method for a01 device protocols ([#408](https://github.com/Python-roborock/python-roborock/pull/408), [`011b253`](https://github.com/Python-roborock/python-roborock/commit/011b2538fc6c0876f2b40465f9a6474bd03d21c6)) ## v2.22.0 (2025-07-01) ### Chores - Increase test timeout to 30 seconds ([#407](https://github.com/Python-roborock/python-roborock/pull/407), [`e59c0b5`](https://github.com/Python-roborock/python-roborock/commit/e59c0b5948a83081a4a248fa2108fed81aa6f036)) ### Features - Add a CLI for exercising the asyncio MQTT session ([#396](https://github.com/Python-roborock/python-roborock/pull/396), [`54547d8`](https://github.com/Python-roborock/python-roborock/commit/54547d87bef080fe3ce03672509ba179bf7feafb)) - Fix lint error ([#396](https://github.com/Python-roborock/python-roborock/pull/396), [`54547d8`](https://github.com/Python-roborock/python-roborock/commit/54547d87bef080fe3ce03672509ba179bf7feafb)) - Remove unused import ([#396](https://github.com/Python-roborock/python-roborock/pull/396), [`54547d8`](https://github.com/Python-roborock/python-roborock/commit/54547d87bef080fe3ce03672509ba179bf7feafb)) - Share mqtt url parsing code with original client ([#396](https://github.com/Python-roborock/python-roborock/pull/396), [`54547d8`](https://github.com/Python-roborock/python-roborock/commit/54547d87bef080fe3ce03672509ba179bf7feafb)) - Update bytes dump ([#396](https://github.com/Python-roborock/python-roborock/pull/396), [`54547d8`](https://github.com/Python-roborock/python-roborock/commit/54547d87bef080fe3ce03672509ba179bf7feafb)) ## v2.21.0 (2025-07-01) ### Chores - Minor refactoring creating functions for transforming bytes ([#397](https://github.com/Python-roborock/python-roborock/pull/397), [`b19dbaa`](https://github.com/Python-roborock/python-roborock/commit/b19dbaac894a9fec8953e782cfb51433f19b2b90)) - Refactor authorization header ([#398](https://github.com/Python-roborock/python-roborock/pull/398), [`9e0ddf8`](https://github.com/Python-roborock/python-roborock/commit/9e0ddf89dfb18774b757ad07270de0be3af14561)) - **deps**: Bump vacuum-map-parser-roborock from 0.1.2 to 0.1.4 ([#373](https://github.com/Python-roborock/python-roborock/pull/373), [`05966aa`](https://github.com/Python-roborock/python-roborock/commit/05966aa474227bbb1d58192d68b44f3003f97e86)) ### Features - Add a DeviceManager to perform discovery ([#399](https://github.com/Python-roborock/python-roborock/pull/399), [`e04a215`](https://github.com/Python-roborock/python-roborock/commit/e04a215bcadce6e582d92dce81f58e902391ec57)) - Fix lint error ([#399](https://github.com/Python-roborock/python-roborock/pull/399), [`e04a215`](https://github.com/Python-roborock/python-roborock/commit/e04a215bcadce6e582d92dce81f58e902391ec57)) - Update CLI to allow logging in with a code ([#395](https://github.com/Python-roborock/python-roborock/pull/395), [`e1a9e69`](https://github.com/Python-roborock/python-roborock/commit/e1a9e695362677d82abf1693bb8790537e38d2d1)) - Update review feedback ([#399](https://github.com/Python-roborock/python-roborock/pull/399), [`e04a215`](https://github.com/Python-roborock/python-roborock/commit/e04a215bcadce6e582d92dce81f58e902391ec57)) - Update tests with additional feedback ([#399](https://github.com/Python-roborock/python-roborock/pull/399), [`e04a215`](https://github.com/Python-roborock/python-roborock/commit/e04a215bcadce6e582d92dce81f58e902391ec57)) ## v2.20.0 (2025-06-30) ### Bug Fixes - Correct keepalive log message ([#385](https://github.com/Python-roborock/python-roborock/pull/385), [`8d4902b`](https://github.com/Python-roborock/python-roborock/commit/8d4902b408cba89daad7eb46d85ef7bdb4b8c8c7)) - Correct typos in log messages ([#385](https://github.com/Python-roborock/python-roborock/pull/385), [`8d4902b`](https://github.com/Python-roborock/python-roborock/commit/8d4902b408cba89daad7eb46d85ef7bdb4b8c8c7)) ### Chores - **deps**: Bump aiohttp from 3.11.16 to 3.12.13 ([#390](https://github.com/Python-roborock/python-roborock/pull/390), [`e10b464`](https://github.com/Python-roborock/python-roborock/commit/e10b464b895fcbb8340fcf11ea7b5f2a2f33b676)) - **deps**: Bump python-semantic-release/python-semantic-release ([#391](https://github.com/Python-roborock/python-roborock/pull/391), [`6536700`](https://github.com/Python-roborock/python-roborock/commit/653670031bb366ed0e08d3daadb63d511795929c)) - **deps-dev**: Bump pre-commit from 4.1.0 to 4.2.0 ([#358](https://github.com/Python-roborock/python-roborock/pull/358), [`9653abc`](https://github.com/Python-roborock/python-roborock/commit/9653abc2451d8b2d2f8c68232777d1419a194efb)) - **deps-dev**: Bump pytest-timeout from 2.3.1 to 2.4.0 ([#379](https://github.com/Python-roborock/python-roborock/pull/379), [`150de05`](https://github.com/Python-roborock/python-roborock/commit/150de05390ce7e31862a202e99017932da3529a5)) - **deps-dev**: Bump ruff from 0.11.4 to 0.12.0 ([#394](https://github.com/Python-roborock/python-roborock/pull/394), [`6ce7af8`](https://github.com/Python-roborock/python-roborock/commit/6ce7af82c847f7cdfa7107bae3505088437a9e66)) ### Features - Add Qrevo MaxV code mappings ([#385](https://github.com/Python-roborock/python-roborock/pull/385), [`8d4902b`](https://github.com/Python-roborock/python-roborock/commit/8d4902b408cba89daad7eb46d85ef7bdb4b8c8c7)) - Add support for roborock qrevo maxv code mappings ([#385](https://github.com/Python-roborock/python-roborock/pull/385), [`8d4902b`](https://github.com/Python-roborock/python-roborock/commit/8d4902b408cba89daad7eb46d85ef7bdb4b8c8c7)) ## v2.19.0 (2025-05-13) ### Bug Fixes - Add Saros 10 dock type code ([#362](https://github.com/Python-roborock/python-roborock/pull/362), [`240bf59`](https://github.com/Python-roborock/python-roborock/commit/240bf59df1873e85e05356496e5be01f1a000199)) ### Chores - **deps**: Bump aiomqtt from 2.3.2 to 2.4.0 ([#375](https://github.com/Python-roborock/python-roborock/pull/375), [`b243a25`](https://github.com/Python-roborock/python-roborock/commit/b243a25569c2cb6b54e6c0e1eed6dadecb9ad84c)) Bumps [aiomqtt](https://github.com/empicano/aiomqtt) from 2.3.2 to 2.4.0. - [Release notes](https://github.com/empicano/aiomqtt/releases) - [Changelog](https://github.com/empicano/aiomqtt/blob/main/CHANGELOG.md) - [Commits](https://github.com/empicano/aiomqtt/compare/v2.3.2...v2.4.0) --- updated-dependencies: - dependency-name: aiomqtt dependency-version: 2.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ### Features - Add some logging for the web api ([#377](https://github.com/Python-roborock/python-roborock/pull/377), [`74c1b5f`](https://github.com/Python-roborock/python-roborock/commit/74c1b5f6e88ce410f95676de802bd04d304963b1)) ## v2.18.2 (2025-05-04) ### Bug Fixes - Add session to home_data_v3 ([#372](https://github.com/Python-roborock/python-roborock/pull/372), [`77061fe`](https://github.com/Python-roborock/python-roborock/commit/77061fe1545a3d2f9e874a3f7e4a94eedfd17706)) ## v2.18.1 (2025-05-04) ### Bug Fixes - Get home_data_v3 working ([#371](https://github.com/Python-roborock/python-roborock/pull/371), [`f9e6c54`](https://github.com/Python-roborock/python-roborock/commit/f9e6c546e68a71a321dafabd5d502abef3e89b31)) ## v2.18.0 (2025-04-06) ### Features - Rate limits for login and home data ([#361](https://github.com/Python-roborock/python-roborock/pull/361), [`93ef8ad`](https://github.com/Python-roborock/python-roborock/commit/93ef8addfd2faa6264606c9d710c46772cd52150)) * feat: rate limits for login and home data * fix: comments * fix: testing and comments ## v2.17.0 (2025-04-05) ### Features - Add support for g20s ultra ([#359](https://github.com/Python-roborock/python-roborock/pull/359), [`593c368`](https://github.com/Python-roborock/python-roborock/commit/593c3687064779ee6790e17f40411cd8129b756e)) ## v2.16.1 (2025-03-22) ### Bug Fixes - Close the session if we created it ([#356](https://github.com/Python-roborock/python-roborock/pull/356), [`96cc718`](https://github.com/Python-roborock/python-roborock/commit/96cc718dbd4106fa344172e2dbf0c3779344ba04)) ## v2.16.0 (2025-03-22) ### Features - Allow forcing of updating cache variables ([#355](https://github.com/Python-roborock/python-roborock/pull/355), [`eae7803`](https://github.com/Python-roborock/python-roborock/commit/eae7803db8973870c396ce45341e5d38cbfaf321)) ## v2.15.0 (2025-03-18) ### Chores - Fix documentation links ([#348](https://github.com/Python-roborock/python-roborock/pull/348), [`404a47c`](https://github.com/Python-roborock/python-roborock/commit/404a47c8c51891ed90093869e567d56386cdc4a2)) ### Features - Allow passing in clientsession ([#354](https://github.com/Python-roborock/python-roborock/pull/354), [`1d31cf6`](https://github.com/Python-roborock/python-roborock/commit/1d31cf619ef38dfdd2891cd42c0acf4550b88c29)) * feat: allow passing in clientsession * fix: test ## v2.14.0 (2025-03-16) ### Features - Add load_multi_map function ([#349](https://github.com/Python-roborock/python-roborock/pull/349), [`23bae12`](https://github.com/Python-roborock/python-roborock/commit/23bae1225389b6ec88bad868b8c6d4a28f458e61)) ## v2.13.0 (2025-03-16) ### Features - Add home_data_v3 ([#347](https://github.com/Python-roborock/python-roborock/pull/347), [`1325fda`](https://github.com/Python-roborock/python-roborock/commit/1325fdaef0f9d920ab499a0550da51cdb8efc0c4)) * feat: add home_data_v3 * fix: address comments ## v2.12.2 (2025-03-11) ### Bug Fixes - Bad dock summary logic ([#345](https://github.com/Python-roborock/python-roborock/pull/345), [`eda1e98`](https://github.com/Python-roborock/python-roborock/commit/eda1e98e5ea177e2eb2390d877b383780f938fd8)) ### Chores - **deps-dev**: Bump pytest from 8.3.4 to 8.3.5 ([#342](https://github.com/Python-roborock/python-roborock/pull/342), [`53635ed`](https://github.com/Python-roborock/python-roborock/commit/53635eda2a2415fc5744f9ebdf8e80fb2df96ef0)) Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.3.4 to 8.3.5. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/8.3.4...8.3.5) --- updated-dependencies: - dependency-name: pytest dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> - **deps-dev**: Bump ruff from 0.9.9 to 0.9.10 ([#344](https://github.com/Python-roborock/python-roborock/pull/344), [`94b281d`](https://github.com/Python-roborock/python-roborock/commit/94b281daf5906ec572fa679869eb78fab030db59)) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.9.9 to 0.9.10. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.9.9...0.9.10) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ## v2.12.1 (2025-03-04) ### Bug Fixes - Add error for web calls and saros dock ([#343](https://github.com/Python-roborock/python-roborock/pull/343), [`49fb137`](https://github.com/Python-roborock/python-roborock/commit/49fb1372aead96ad5b03222699ab150bf83b31f9)) ### Chores - **deps**: Bump aiohttp from 3.11.11 to 3.11.12 ([#328](https://github.com/Python-roborock/python-roborock/pull/328), [`f2d0c39`](https://github.com/Python-roborock/python-roborock/commit/f2d0c39353aff0d2f63ba5402cbfd1fd5c9f70c3)) --- updated-dependencies: - dependency-name: aiohttp dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> - **deps**: Bump aiohttp from 3.11.12 to 3.11.13 ([#340](https://github.com/Python-roborock/python-roborock/pull/340), [`7c6bb54`](https://github.com/Python-roborock/python-roborock/commit/7c6bb544fe14b0512eb4cc73f3d92f19fc56f4f7)) --- updated-dependencies: - dependency-name: aiohttp dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> - **deps**: Bump python-semantic-release/python-semantic-release ([#338](https://github.com/Python-roborock/python-roborock/pull/338), [`15f7705`](https://github.com/Python-roborock/python-roborock/commit/15f77056b8f2c4dcd2772812c6c2f9647f808bcd)) Bumps [python-semantic-release/python-semantic-release](https://github.com/python-semantic-release/python-semantic-release) from 9.17.0 to 9.21.0. - [Release notes](https://github.com/python-semantic-release/python-semantic-release/releases) - [Changelog](https://github.com/python-semantic-release/python-semantic-release/blob/master/CHANGELOG.rst) - [Commits](https://github.com/python-semantic-release/python-semantic-release/compare/v9.17.0...v9.21.0) --- updated-dependencies: - dependency-name: python-semantic-release/python-semantic-release dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> - **deps-dev**: Bump mypy from 1.14.1 to 1.15.0 ([#329](https://github.com/Python-roborock/python-roborock/pull/329), [`2105cdf`](https://github.com/Python-roborock/python-roborock/commit/2105cdf2a29a1ad1c1c9117e3dff4c4548466d4f)) Bumps [mypy](https://github.com/python/mypy) from 1.14.1 to 1.15.0. - [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md) - [Commits](https://github.com/python/mypy/compare/v1.14.1...v1.15.0) --- updated-dependencies: - dependency-name: mypy dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> - **deps-dev**: Bump ruff from 0.9.4 to 0.9.9 ([#341](https://github.com/Python-roborock/python-roborock/pull/341), [`4e80f7a`](https://github.com/Python-roborock/python-roborock/commit/4e80f7a86764240729982de3336173231fac6a08)) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.9.4 to 0.9.9. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.9.4...0.9.9) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ## v2.12.0 (2025-02-21) ### Features - Add cli status ([#333](https://github.com/Python-roborock/python-roborock/pull/333), [`64e77d7`](https://github.com/Python-roborock/python-roborock/commit/64e77d7150babcc78ce3698fe98594891dcb7bd4)) ## v2.11.3 (2025-02-19) ### Bug Fixes - Q revo curv mappings ([#332](https://github.com/Python-roborock/python-roborock/pull/332), [`83d010a`](https://github.com/Python-roborock/python-roborock/commit/83d010acbc100f06ae322adde1eedcfd0f78efc8)) ## v2.11.2 (2025-02-13) ### Bug Fixes - Add some extra data protocol checking ([#331](https://github.com/Python-roborock/python-roborock/pull/331), [`4af1490`](https://github.com/Python-roborock/python-roborock/commit/4af1490ea4db0dbeb5d5666019d9433af4f3d273)) ## v2.11.1 (2025-02-03) ### Bug Fixes - Typing of scene api call ([#324](https://github.com/Python-roborock/python-roborock/pull/324), [`61e27ae`](https://github.com/Python-roborock/python-roborock/commit/61e27aedfbb363913f80ace3932fa4adf61f9792)) ## v2.11.0 (2025-02-03) ### Chores - **deps**: Bump pypa/gh-action-pypi-publish from 1.12.3 to 1.12.4 ([#311](https://github.com/Python-roborock/python-roborock/pull/311), [`cb40279`](https://github.com/Python-roborock/python-roborock/commit/cb4027994e4ee0b72f25d9f51f46f8b3f9522bc5)) - **deps**: Bump python-semantic-release/python-semantic-release ([#312](https://github.com/Python-roborock/python-roborock/pull/312), [`7827af5`](https://github.com/Python-roborock/python-roborock/commit/7827af5ef7e6fb2dedd6eef0cb8c0c8439d2a8ef)) - **deps**: Bump python-semantic-release/upload-to-gh-release ([#290](https://github.com/Python-roborock/python-roborock/pull/290), [`87038e3`](https://github.com/Python-roborock/python-roborock/commit/87038e3a556a359d552775195d7640b6cdbeb1fe)) - **deps**: Bump wagoid/commitlint-github-action from 6.2.0 to 6.2.1 ([#296](https://github.com/Python-roborock/python-roborock/pull/296), [`037e28c`](https://github.com/Python-roborock/python-roborock/commit/037e28c38df282dac09bd4ff9596dc0b3a09c78f)) - **deps-dev**: Bump codespell from 2.3.0 to 2.4.1 ([#321](https://github.com/Python-roborock/python-roborock/pull/321), [`c36d46f`](https://github.com/Python-roborock/python-roborock/commit/c36d46f90780db50f2c5c2e947ada78b6ee4967c)) - **deps-dev**: Bump pytest-asyncio from 0.25.2 to 0.25.3 ([#322](https://github.com/Python-roborock/python-roborock/pull/322), [`9e40fe7`](https://github.com/Python-roborock/python-roborock/commit/9e40fe780224903c8e81c4d210ab61212582948d)) - **deps-dev**: Bump ruff from 0.9.2 to 0.9.4 ([#323](https://github.com/Python-roborock/python-roborock/pull/323), [`25d15a7`](https://github.com/Python-roborock/python-roborock/commit/25d15a78d1f5ffb069159aa652c2ef3f88d3eb03)) ### Features - Add scenes/routines support ([#317](https://github.com/Python-roborock/python-roborock/pull/317), [`090d912`](https://github.com/Python-roborock/python-roborock/commit/090d912872712e16b24597826a0b85d22b37acb3)) * add scenes support --------- Co-authored-by: Luke Lashley ## v2.10.1 (2025-02-03) ### Bug Fixes - Delete in cli ([#320](https://github.com/Python-roborock/python-roborock/pull/320), [`6704f55`](https://github.com/Python-roborock/python-roborock/commit/6704f55915005d771d698e58dcbac5ec46a385e5)) ## v2.10.0 (2025-01-31) ### Features - Add commands to add a new device ([#307](https://github.com/Python-roborock/python-roborock/pull/307), [`430c248`](https://github.com/Python-roborock/python-roborock/commit/430c24806fa06a5cec6c7fb3945a9b9cbfbc2f7a)) * feat: add commands to add a new device * chore: mr comments ## v2.9.8 (2025-01-30) ### Bug Fixes - Ignore ping id during id check ([#316](https://github.com/Python-roborock/python-roborock/pull/316), [`b3d74b4`](https://github.com/Python-roborock/python-roborock/commit/b3d74b4bc9fa581da0485cf68a46c23f53fdbf50)) ## v2.9.7 (2025-01-28) ### Bug Fixes - Never create a new asyncio loop ([#310](https://github.com/Python-roborock/python-roborock/pull/310), [`ed7db1f`](https://github.com/Python-roborock/python-roborock/commit/ed7db1f09f379f509a38a61a445fb2c41b384f25)) ## v2.9.6 (2025-01-26) ### Bug Fixes - Remove the __del__ warning for disconnected clients ([#308](https://github.com/Python-roborock/python-roborock/pull/308), [`235752b`](https://github.com/Python-roborock/python-roborock/commit/235752bd77e4617323366b56439bf8981b071430)) ### Refactoring - Breaking change to remove sync APIs ([#306](https://github.com/Python-roborock/python-roborock/pull/306), [`3c30d93`](https://github.com/Python-roborock/python-roborock/commit/3c30d933f680cc567b10ad6566b02289eade5b3f)) * refactor: breaking change to remove sync APIs * chore: downgrade log to a debug message ## v2.9.5 (2025-01-21) ### Bug Fixes - Fix queue timeout variable and set default in tests of 10 seconds ([#302](https://github.com/Python-roborock/python-roborock/pull/302), [`9c75e3a`](https://github.com/Python-roborock/python-roborock/commit/9c75e3a67fc8f411c5496b5864a9a0e90a573c8a)) * test: set queue timeout of 10 * test: cleanup lint errors * fix: set queue_timeout in the client leaf base classes * chore: fix test fixture after merging - Log an explicit message when intentionally resetting the connection ([#304](https://github.com/Python-roborock/python-roborock/pull/304), [`a20d2ac`](https://github.com/Python-roborock/python-roborock/commit/a20d2ac46c7553c7b7c7dffbbc86ee0da370418d)) ## v2.9.4 (2025-01-21) ### Bug Fixes - Bump paho-mqtt from 1.6.1 to 2.1.0 ([#288](https://github.com/Python-roborock/python-roborock/pull/288), [`777b736`](https://github.com/Python-roborock/python-roborock/commit/777b736440a3633c089bf09ab9d7240e54e0fb0e)) Bumps [paho-mqtt](https://github.com/eclipse/paho.mqtt.python) from 1.6.1 to 2.1.0. - [Release notes](https://github.com/eclipse/paho.mqtt.python/releases) - [Changelog](https://github.com/eclipse-paho/paho.mqtt.python/blob/master/ChangeLog.txt) - [Commits](https://github.com/eclipse/paho.mqtt.python/compare/v1.6.1...v2.1.0) --- updated-dependencies: - dependency-name: paho-mqtt dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> - Set unique sequence numbers on outgoing messages ([#300](https://github.com/Python-roborock/python-roborock/pull/300), [`14f03c7`](https://github.com/Python-roborock/python-roborock/commit/14f03c7df1c574ab87ea056227bb95f9150f4832)) ### Chores - Fix flaky tests by cleaning up threads ([#303](https://github.com/Python-roborock/python-roborock/pull/303), [`6e29e74`](https://github.com/Python-roborock/python-roborock/commit/6e29e7440f61ddde9a67b25c87864ed0cbf1a097)) * chore: set log level to debug to aid in tracking down flaky tests * test: update log format to include timestamps and dates test: update logmessage with package name chore: fix tests to use valid zeo codes * test: fix zeo test assertion * test: add logging when updating future * test: make the client read socket always available for reading to avoid getting blocked * test: revert socket changes * test: set function loop scope * test: add pytest-timeout with a 20 second hard timeout * test: explicitly disconnect threads * test: fix formatting * test: fix lint errors * fix: stop the mqtt loop on disconnect * fix: release the mqtt thread on release * test: revert log changes * chore: cleanup/revert changes * chore: revert mqtt client check * fix: always stop the event loop when disconnecting ## v2.9.3 (2025-01-21) ### Bug Fixes - Remove methods no longer available in paho-mqtt ([#298](https://github.com/Python-roborock/python-roborock/pull/298), [`685edc8`](https://github.com/Python-roborock/python-roborock/commit/685edc825fbf2062d61c3294ea82c4566442dd64)) ### Chores - Remove test that creates abstract base class ([#299](https://github.com/Python-roborock/python-roborock/pull/299), [`a55b804`](https://github.com/Python-roborock/python-roborock/commit/a55b804fddff318d704cc04e6c4190514e3e3375)) - **deps-dev**: Bump aioresponses from 0.7.7 to 0.7.8 ([#295](https://github.com/Python-roborock/python-roborock/pull/295), [`ab7ffb3`](https://github.com/Python-roborock/python-roborock/commit/ab7ffb36190090e6d5b39150da4ebe2f2e22fbd4)) Bumps [aioresponses](https://github.com/pnuckowski/aioresponses) from 0.7.7 to 0.7.8. - [Release notes](https://github.com/pnuckowski/aioresponses/releases) - [Commits](https://github.com/pnuckowski/aioresponses/compare/0.7.7...0.7.8) --- updated-dependencies: - dependency-name: aioresponses dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ## v2.9.2 (2025-01-19) ### Bug Fixes - Update local API protocol broken during refactoring and add additional tests for API calls ([#293](https://github.com/Python-roborock/python-roborock/pull/293), [`ea8e55a`](https://github.com/Python-roborock/python-roborock/commit/ea8e55a0b9c54e7c7d6235ad0e73f7b75ec4de7b)) * test: add an additional local API test and fix bug in test fixture * test: fix formatting * fix: Update local API ### Chores - Remove dacite and update readme ([#294](https://github.com/Python-roborock/python-roborock/pull/294), [`699a2c5`](https://github.com/Python-roborock/python-roborock/commit/699a2c5ed5362ee4004d2888037baf929869e98c)) - Update CI to run on one platform, but multiple python versions ([#292](https://github.com/Python-roborock/python-roborock/pull/292), [`16ab4ff`](https://github.com/Python-roborock/python-roborock/commit/16ab4ff433d25df9daa4bf102569c39bbd686420)) ## v2.9.1 (2025-01-13) ### Bug Fixes - Bump commitlint and allow caps ([#283](https://github.com/Python-roborock/python-roborock/pull/283), [`6211a81`](https://github.com/Python-roborock/python-roborock/commit/6211a8163d130c41594daf65e36be2d87788a5c6)) * fix: bump commitlint and allow caps * fix: error ### Chores - Add end-to-end tests for the MQTT client ([#278](https://github.com/Python-roborock/python-roborock/pull/278), [`0872691`](https://github.com/Python-roborock/python-roborock/commit/0872691c9eeb6e564a1ee47b8ba2bec73eb81a63)) * test: add end-to-end tests for the MQTT client * test: extract connected client to a fixture style: fix formatting of tests refactor: extract variables for mock data used in mqtt tests style: fix lint errors in tests - Add local api test coverage ([#284](https://github.com/Python-roborock/python-roborock/pull/284), [`c8dcd34`](https://github.com/Python-roborock/python-roborock/commit/c8dcd34c8197b9d47ec3c96567313d658e0f36b3)) - Allow type checking in roborock/cloud_api.py ([#280](https://github.com/Python-roborock/python-roborock/pull/280), [`9100bbf`](https://github.com/Python-roborock/python-roborock/commit/9100bbff1390a706a74dc0ec15c1bb1d7dc83d9f)) - Inheritance fixes and simplifications ([#282](https://github.com/Python-roborock/python-roborock/pull/282), [`1013cb5`](https://github.com/Python-roborock/python-roborock/commit/1013cb5f35ec6feb71e58a437395b0cdaa593937)) - Remove level of inheritance in mqtt client ([#286](https://github.com/Python-roborock/python-roborock/pull/286), [`5add0da`](https://github.com/Python-roborock/python-roborock/commit/5add0dac8d1e1e86b184ebad709034ea2a2686a3)) - Remove one level of local client inheritence ([#285](https://github.com/Python-roborock/python-roborock/pull/285), [`1f5a9ec`](https://github.com/Python-roborock/python-roborock/commit/1f5a9ecd907c0314cc156a59156b03151e9c26a8)) - Use asyncio mode in tests ([#272](https://github.com/Python-roborock/python-roborock/pull/272), [`8f779c3`](https://github.com/Python-roborock/python-roborock/commit/8f779c39b21ab429335fc5d179fe3bacc0b5d274)) - **deps**: Bump pre-commit/action from 3.0.0 to 3.0.1 ([#276](https://github.com/Python-roborock/python-roborock/pull/276), [`3f61bcc`](https://github.com/Python-roborock/python-roborock/commit/3f61bccde418c9e9e04ef059ca8a6a2dfcba8312)) - **deps**: Bump pypa/gh-action-pypi-publish from 1.12.2 to 1.12.3 ([#291](https://github.com/Python-roborock/python-roborock/pull/291), [`be52b3d`](https://github.com/Python-roborock/python-roborock/commit/be52b3d48dc7edeb164a006db10b7efe91a18b71)) - **deps-dev**: Bump pre-commit from 3.8.0 to 4.0.1 ([#287](https://github.com/Python-roborock/python-roborock/pull/287), [`f2f0c4c`](https://github.com/Python-roborock/python-roborock/commit/f2f0c4c8fa9f8fe85fd208daf28e5f7dfe02aba3)) - **deps-dev**: Bump pytest-asyncio from 0.25.1 to 0.25.2 ([#275](https://github.com/Python-roborock/python-roborock/pull/275), [`b0611f0`](https://github.com/Python-roborock/python-roborock/commit/b0611f0eb72b0078c10a5c03ae8415d21cc19c03)) - **deps-dev**: Bump ruff from 0.8.6 to 0.9.1 ([#277](https://github.com/Python-roborock/python-roborock/pull/277), [`eb8bbe3`](https://github.com/Python-roborock/python-roborock/commit/eb8bbe317b8d4f98e9c72151d6f9ca105e3c0db0)) ### Refactoring - Simplify future usage within the api clients ([#263](https://github.com/Python-roborock/python-roborock/pull/263), [`39a8661`](https://github.com/Python-roborock/python-roborock/commit/39a8661d4c5ade657cfc655a3ac78a66628bb755)) ## v2.9.0 (2025-01-09) ### Chores - Add example ([#269](https://github.com/Python-roborock/python-roborock/pull/269), [`d7a3af2`](https://github.com/Python-roborock/python-roborock/commit/d7a3af29c91bf2066f88a941789c0dc725eb7431)) - Add some testing and mocks for the web api ([#270](https://github.com/Python-roborock/python-roborock/pull/270), [`2356c16`](https://github.com/Python-roborock/python-roborock/commit/2356c16cd08cdf7210f605f9c890eb1c5631a792)) ### Features - Add dust collection mode name for typing ease ([#271](https://github.com/Python-roborock/python-roborock/pull/271), [`c85232a`](https://github.com/Python-roborock/python-roborock/commit/c85232a00b997dbc84a4b9b99b18ae1c714b7df7)) - Add product v4 and downloading code ([#267](https://github.com/Python-roborock/python-roborock/pull/267), [`b669117`](https://github.com/Python-roborock/python-roborock/commit/b6691174607a66959f4d9046dffb4cd4e782695d)) * feat: add product v4 and downloading code * fix: remove got message - Add support for qrevo curv ([#253](https://github.com/Python-roborock/python-roborock/pull/253), [`e42729a`](https://github.com/Python-roborock/python-roborock/commit/e42729aa5aedd2c77f68230825d6ce832a146f33)) * add support for qrevo curv * add dock support * revert unnecessary changes * fix: lint --------- Co-authored-by: Luke Lashley ## v2.8.5 (2025-01-06) ### Bug Fixes - Add additional log messages to track down concurrency errors ([#266](https://github.com/Python-roborock/python-roborock/pull/266), [`d750234`](https://github.com/Python-roborock/python-roborock/commit/d75023482e58689009c4df96cfc69b6080f5ada9)) - Update log message to include existing request id ([#264](https://github.com/Python-roborock/python-roborock/pull/264), [`ac8d23a`](https://github.com/Python-roborock/python-roborock/commit/ac8d23aa59342d9ae9f7c5d7c857de353e288ffa)) * fix: Update log message to include existing request id * fix: Add protocol to log message ### Chores - Always use time.monotonic ([#265](https://github.com/Python-roborock/python-roborock/pull/265), [`e14802c`](https://github.com/Python-roborock/python-roborock/commit/e14802cadde404d548cdff0c6b5906740a7e8c00)) ## v2.8.4 (2024-12-20) ### Bug Fixes - Update mop intensity, fan speed, and dock mappings for the QRevo Master ([#260](https://github.com/Python-roborock/python-roborock/pull/260), [`77f6d6f`](https://github.com/Python-roborock/python-roborock/commit/77f6d6fc917831f1966d2138bc7355292fa1e5e2)) * fix: update mop intensity, fan speed, and dock mappings for QRevo Master * Fix sorting of imports * Rerun precommit ## v2.8.3 (2024-12-19) ### Bug Fixes - Add support for QRevo Master mop mode ([#259](https://github.com/Python-roborock/python-roborock/pull/259), [`db11c0f`](https://github.com/Python-roborock/python-roborock/commit/db11c0f8ca7c08d2f795f77f7a652db4bfaa91ae)) ## v2.8.2 (2024-12-19) ### Bug Fixes - Add a mop mode to QRevoMaster ([#258](https://github.com/Python-roborock/python-roborock/pull/258), [`bf0feb7`](https://github.com/Python-roborock/python-roborock/commit/bf0feb7ee8bc9933232e8235e6efa92a451ee19e)) ## v2.8.1 (2024-12-18) ### Bug Fixes - Add config github actions ([#247](https://github.com/Python-roborock/python-roborock/pull/247), [`35f888c`](https://github.com/Python-roborock/python-roborock/commit/35f888c653ad3d41ca40d27a5ea7041df47b6bbe)) * fix: add config github actions * fix: remove placeholders - Add gh_token to checkout ([#245](https://github.com/Python-roborock/python-roborock/pull/245), [`ab9fcfe`](https://github.com/Python-roborock/python-roborock/commit/ab9fcfe4526314b09c8fd382527c5b9d9b011315)) - Bad indentation ([#248](https://github.com/Python-roborock/python-roborock/pull/248), [`190f66e`](https://github.com/Python-roborock/python-roborock/commit/190f66e53fca6938b927fd587ebcdb249c908505)) - Bump semantic release ([#236](https://github.com/Python-roborock/python-roborock/pull/236), [`cf067d4`](https://github.com/Python-roborock/python-roborock/commit/cf067d4e4fa4680e766719dc22295afb2a526323)) * fix: bump semantic release * fix: bump versioning and add environment * fix: move if check * fix: some other version bumps - Change to deploy_key ([#254](https://github.com/Python-roborock/python-roborock/pull/254), [`de0a0c7`](https://github.com/Python-roborock/python-roborock/commit/de0a0c73f1f9b415f67412170a754d6685f0c969)) - Change to persist credentials ([#246](https://github.com/Python-roborock/python-roborock/pull/246), [`5b4b769`](https://github.com/Python-roborock/python-roborock/commit/5b4b7694743d96ca7acb57ed28271220791f9802)) - Container issue from api change and ci update ([#257](https://github.com/Python-roborock/python-roborock/pull/257), [`b1e645d`](https://github.com/Python-roborock/python-roborock/commit/b1e645d6acb8de776f5361e2a5a2be59c730237b)) - Give ci more permissions ([#240](https://github.com/Python-roborock/python-roborock/pull/240), [`641a40c`](https://github.com/Python-roborock/python-roborock/commit/641a40c12f38f3dcdca36aa61f17663440f0ba8e)) - Hopefully finalize semantic release ([#244](https://github.com/Python-roborock/python-roborock/pull/244), [`481f01d`](https://github.com/Python-roborock/python-roborock/commit/481f01dc039f27037e269a7234c97006dae91969)) - Move github token to env for semantic release ([#241](https://github.com/Python-roborock/python-roborock/pull/241), [`c61d8de`](https://github.com/Python-roborock/python-roborock/commit/c61d8de1bbf0705d0d7a2699822e6bfef49c3db4)) - Repair semantic release ([#251](https://github.com/Python-roborock/python-roborock/pull/251), [`431bc20`](https://github.com/Python-roborock/python-roborock/commit/431bc2033340267340f4740cef14ec0e4c5e7331)) - Semantic release versioning tag ([#237](https://github.com/Python-roborock/python-roborock/pull/237), [`fcc58ee`](https://github.com/Python-roborock/python-roborock/commit/fcc58ee6de75a61642e73c63cf614d8953318c29)) - Semantic release versioning tag ([#238](https://github.com/Python-roborock/python-roborock/pull/238), [`33a1e72`](https://github.com/Python-roborock/python-roborock/commit/33a1e72d97881aac867119eddca39c4366a549e3)) * fix: semantic release versioning tag * fix: set version back - Set python version in ci ([#239](https://github.com/Python-roborock/python-roborock/pull/239), [`dcad510`](https://github.com/Python-roborock/python-roborock/commit/dcad510ec232380f5bed7646c4455f656b7ca6ae)) - Specify x-access-token ([#249](https://github.com/Python-roborock/python-roborock/pull/249), [`e9f319b`](https://github.com/Python-roborock/python-roborock/commit/e9f319b0ee22cd90e9437d20f279a24228ee62c1)) - Update_gh_token ([#242](https://github.com/Python-roborock/python-roborock/pull/242), [`8a9866c`](https://github.com/Python-roborock/python-roborock/commit/8a9866cce2f6d868ab5f87b13a6b0151034d7a22)) - Update_gh_token ([#243](https://github.com/Python-roborock/python-roborock/pull/243), [`e100ab3`](https://github.com/Python-roborock/python-roborock/commit/e100ab3e8557ed97a5917cadb40968bbf7686b76)) ### Chores - Update README.md ([`5a982b7`](https://github.com/Python-roborock/python-roborock/commit/5a982b723528e67c6d8d664dd8b3eee64436a0c8)) ## v2.8.0 (2024-11-12) ### Chores - Call to super in docs ([#235](https://github.com/Python-roborock/python-roborock/pull/235), [`df331ea`](https://github.com/Python-roborock/python-roborock/commit/df331ea0165d05b093f170fb9107918aaaac03e6)) ### Features - Add some new roborock codes and add custom command ([#234](https://github.com/Python-roborock/python-roborock/pull/234), [`c8507ef`](https://github.com/Python-roborock/python-roborock/commit/c8507eff9cdc24654034fbe4fd63ac89b6de6f99)) * fix: add some new roborock codes and add custom command * fix: lint ## v2.7.2 (2024-11-08) ### Bug Fixes - Add some new roborock codes ([#233](https://github.com/Python-roborock/python-roborock/pull/233), [`59546dd`](https://github.com/Python-roborock/python-roborock/commit/59546dd68f7b40ad368d58fd502680ff9c03c81b)) ## v2.7.1 (2024-10-28) ### Bug Fixes - Check that clean area is not a str ([#230](https://github.com/Python-roborock/python-roborock/pull/230), [`e66a91e`](https://github.com/Python-roborock/python-roborock/commit/e66a91edaf6fedf5d4b2ab9117b7759295add492)) ### Chores - Add some async improvements ([#229](https://github.com/Python-roborock/python-roborock/pull/229), [`e987c17`](https://github.com/Python-roborock/python-roborock/commit/e987c17ee65982c7179f4d94a84e1863aa4830da)) * chore: add some async improvements * chore: improve get_rand_int ## v2.7.0 (2024-10-28) ### Features - Remove dacite ([#227](https://github.com/Python-roborock/python-roborock/pull/227), [`86878a7`](https://github.com/Python-roborock/python-roborock/commit/86878a71d82c2cc707daa16dec109fc07360e3f6)) ## v2.6.1 (2024-10-22) ### Bug Fixes - Add a warning for wrong type of clean area and add new dock ([#224](https://github.com/Python-roborock/python-roborock/pull/224), [`c334eb2`](https://github.com/Python-roborock/python-roborock/commit/c334eb2193091dccd23db0d3ee4863e838733e30)) ## v2.6.0 (2024-06-29) ### Features - Add q revo pro/p10 pro support ([#220](https://github.com/Python-roborock/python-roborock/pull/220), [`5e6a2d6`](https://github.com/Python-roborock/python-roborock/commit/5e6a2d6a7171da146efb3e59ddb3215c2a573507)) ## v2.5.0 (2024-06-25) ### Features - Add some typing ([#219](https://github.com/Python-roborock/python-roborock/pull/219), [`35d0900`](https://github.com/Python-roborock/python-roborock/commit/35d09000b8d144cbaf935069952ea135950d0e78)) ## v2.4.0 (2024-06-25) ### Features - Add some missing codes and make warnings only message once ([#218](https://github.com/Python-roborock/python-roborock/pull/218), [`12361b5`](https://github.com/Python-roborock/python-roborock/commit/12361b58e7a4d368281c4ffd9ac3d8e9d8155e62)) ## v2.3.0 (2024-06-07) ### Features - Add warning in web requests if it fails to decode ([#215](https://github.com/Python-roborock/python-roborock/pull/215), [`6ae69e9`](https://github.com/Python-roborock/python-roborock/commit/6ae69e9bcba6a98736f2f480114922186f6ca458)) ## v2.2.3 (2024-06-04) ### Bug Fixes - S8 maxv has a wash and fill dock ([#213](https://github.com/Python-roborock/python-roborock/pull/213), [`018fd05`](https://github.com/Python-roborock/python-roborock/commit/018fd052360dffd238919e336943809720457c4e)) ### Chores - Add load multi map parameter to docs(#209) ([`2cee5d7`](https://github.com/Python-roborock/python-roborock/commit/2cee5d7e065473232caacf1531c38e83506f0c5b)) - Update documentation for reset_consumable ([#207](https://github.com/Python-roborock/python-roborock/pull/207), [`4071538`](https://github.com/Python-roborock/python-roborock/commit/40715387f5eac6788d198ffefad0c1d25b7c7138)) Document parameter for API function reset_consumable ## v2.2.2 (2024-05-16) ### Bug Fixes - Handle weird clean record response ([#206](https://github.com/Python-roborock/python-roborock/pull/206), [`07ce71a`](https://github.com/Python-roborock/python-roborock/commit/07ce71a2cd8085136952bd7639f6f4a2e273faf9)) ## v2.2.1 (2024-05-11) ### Bug Fixes - Add missing value "high = 203" to RoborockMopIntensityS8MaxVUltra ([#205](https://github.com/Python-roborock/python-roborock/pull/205), [`886b0e6`](https://github.com/Python-roborock/python-roborock/commit/886b0e6a8a4b98ff74964d59f4c8c0fbbf569688)) ## v2.2.0 (2024-05-09) ### Features - Improve some typing ([#204](https://github.com/Python-roborock/python-roborock/pull/204), [`7752db9`](https://github.com/Python-roborock/python-roborock/commit/7752db9066fa49bb93a6268a491e2a0baa608cfc)) ## v2.1.1 (2024-05-08) ### Bug Fixes - Set roommapping when it is only one room ([#203](https://github.com/Python-roborock/python-roborock/pull/203), [`26af66b`](https://github.com/Python-roborock/python-roborock/commit/26af66bd5d8dbfa4c94a9add317ccc9ca9161510)) * fix: set roommapping when it is only one room * fix: add len check ## v2.1.0 (2024-05-08) ### Features - Add s8_maxv_ultra info ([#202](https://github.com/Python-roborock/python-roborock/pull/202), [`aaaf0f0`](https://github.com/Python-roborock/python-roborock/commit/aaaf0f0c381924524a079f600de14db1cd61ed45)) ## v2.0.0 (2024-04-11) ### Features - Add zeo support and fix some a01 weirdness ([#200](https://github.com/Python-roborock/python-roborock/pull/200), [`e825ff5`](https://github.com/Python-roborock/python-roborock/commit/e825ff5811516b4034e9b41769e5912c99cf0166)) * major: add A01 * chore: add init * chore: fix commitlint? * chore: fix commitlint * chore: change refactor to be major tag * refactor: add A01 * feat: add a01 BREAKING CHANGE: You must now specify what version api you want to use with clients. * feat: add initial zeo support * fix: fix A01 support * fix: allow messages to fail * fix: lint * feat: add more zeo things ### Breaking Changes - You must now specify what version api you want to use with clients. ## v1.0.0 (2024-04-09) ### Chores - Move more things around in version 1 api ([#198](https://github.com/Python-roborock/python-roborock/pull/198), [`30d2577`](https://github.com/Python-roborock/python-roborock/commit/30d257756f35b9fc71d64d0479b872661b9176a6)) * chore: move more things around in version 1 api * fix: tests ### Refactoring - Add A01 ([#199](https://github.com/Python-roborock/python-roborock/pull/199), [`16b9e3e`](https://github.com/Python-roborock/python-roborock/commit/16b9e3e8261db3ec38d6bc24661ecf40c6bb0870)) * major: add A01 * chore: add init * chore: fix commitlint? * chore: fix commitlint * chore: change refactor to be major tag * refactor: add A01 * feat: add a01 BREAKING CHANGE: You must now specify what version api you want to use with clients. ### Breaking Changes - You must now specify what version api you want to use with clients. ## v0.41.0 (2024-03-06) ### Features - Add v1 api ([#194](https://github.com/Python-roborock/python-roborock/pull/194), [`9fb124e`](https://github.com/Python-roborock/python-roborock/commit/9fb124ecdd0a979ff8f2c742eb4dd625b7e9292f)) * feat: add v1 api * fix: change some imports * fix: bug and versioning * chore: move location of v1 * fix: random exception ## v0.40.0 (2024-03-03) ### Features - Add nonce to diagnostic data ([#195](https://github.com/Python-roborock/python-roborock/pull/195), [`ceafcb6`](https://github.com/Python-roborock/python-roborock/commit/ceafcb6e30c60f6f6ad3833ab73861c18413b806)) ## v0.39.2 (2024-02-26) ### Bug Fixes - Bump construct and add wm category ([#192](https://github.com/Python-roborock/python-roborock/pull/192), [`2f18b35`](https://github.com/Python-roborock/python-roborock/commit/2f18b35755776844e266c893b126a830622afd43)) ## v0.39.1 (2024-01-24) ### Bug Fixes - Remove problematic code ([#189](https://github.com/Python-roborock/python-roborock/pull/189), [`a9e12ca`](https://github.com/Python-roborock/python-roborock/commit/a9e12ca122b467d74e9cd29dc031802cf0f551bc)) ## v0.39.0 (2024-01-03) ### Chores - Added code from decompiled react and refactoring web api ([#176](https://github.com/Python-roborock/python-roborock/pull/176), [`dab105c`](https://github.com/Python-roborock/python-roborock/commit/dab105c58d11f7789b5f11dd962dd916d5436ced)) * chore: added code from decompiled react and refactoring web api * fix: patches * fix: patch * chore: add info from new_feature_info - Update api_commands.rst app_goto_target ([#163](https://github.com/Python-roborock/python-roborock/pull/163), [`9c83c77`](https://github.com/Python-roborock/python-roborock/commit/9c83c77c732943b2cb9481442afddc3b1ba241c3)) ### Features - Add async_release ([#179](https://github.com/Python-roborock/python-roborock/pull/179), [`ae58627`](https://github.com/Python-roborock/python-roborock/commit/ae58627bda324c29090b7c4ab78776288a30a64d)) ## v0.38.0 (2023-12-11) ### Features - Add information from product api ([#158](https://github.com/Python-roborock/python-roborock/pull/158), [`22720ae`](https://github.com/Python-roborock/python-roborock/commit/22720aee79e582328ae642e61d57dc2e3a92ec1c)) * fix: add information from product api * feat: add dyad protocol ## v0.37.0 (2023-12-10) ### Features - House keeping, version bumping, doc fixes, doc improvements, v2 home data api ([#157](https://github.com/Python-roborock/python-roborock/pull/157), [`f3ca9b4`](https://github.com/Python-roborock/python-roborock/commit/f3ca9b45d3de3a15c57e134421d3abc11095bc22)) * feat: version bumping, docs improvements, mypy fixes, doc fixes * fix: ci steps * feat: convert to v2 of the api * chore: linting, include docs, poetry lock * fix: tests * fix: add ability to remove listener ## v0.36.2 (2023-11-22) ### Bug Fixes - Typing and error checking ([#149](https://github.com/Python-roborock/python-roborock/pull/149), [`d94aa48`](https://github.com/Python-roborock/python-roborock/commit/d94aa48c1e594f7f6cd1cff16da66169368fb86c)) * fix: typing and error checking * chore: lint * fix: merge weirdness ## v0.36.1 (2023-11-08) ### Bug Fixes - Typing for map ([#141](https://github.com/Python-roborock/python-roborock/pull/141), [`64121ee`](https://github.com/Python-roborock/python-roborock/commit/64121eee14e4f0ca24db664b0664aaac5c7332af)) ## v0.36.0 (2023-11-07) ### Features - Update listeners ([#140](https://github.com/Python-roborock/python-roborock/pull/140), [`5498596`](https://github.com/Python-roborock/python-roborock/commit/549859669941e71c8d7ee09a0d4eea9564b4a12f)) * fix: change some typing * fix: include poetry lock * fix: linting * fix: add typing * fix: bugs * fix: none typing * fix: weird merge things * fix: rework listeners and cache a bit more * chore: linting * chore: typo * chore: self listener model * fix: override missing for data protocol ## v0.35.4 (2023-11-03) ### Bug Fixes - Mypy complaints ([#137](https://github.com/Python-roborock/python-roborock/pull/137), [`752e320`](https://github.com/Python-roborock/python-roborock/commit/752e320644449a83a724590628c4011b9d8bacb2)) * fix: change some typing * fix: include poetry lock * fix: linting * fix: add typing * fix: bugs * fix: none typing * Update api.py ## v0.35.3 (2023-10-29) ### Bug Fixes - Typing and versioning ([#134](https://github.com/Python-roborock/python-roborock/pull/134), [`e1dc545`](https://github.com/Python-roborock/python-roborock/commit/e1dc545f20f2a163240eb72d831025cb2ff3ec7c)) * fix: change some typing * fix: include poetry lock * fix: linting ### Chores - **deps**: Bump snok/install-poetry from 1.3.3 to 1.3.4 ([#106](https://github.com/Python-roborock/python-roborock/pull/106), [`1fc0265`](https://github.com/Python-roborock/python-roborock/commit/1fc02658e9d5934c5b5a2e173d7bcba8d8c55c2f)) Bumps [snok/install-poetry](https://github.com/snok/install-poetry) from 1.3.3 to 1.3.4. - [Release notes](https://github.com/snok/install-poetry/releases) - [Commits](https://github.com/snok/install-poetry/compare/v1.3.3...v1.3.4) --- updated-dependencies: - dependency-name: snok/install-poetry dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ## v0.35.2 (2023-10-29) ### Bug Fixes - Error catch and typing ([#133](https://github.com/Python-roborock/python-roborock/pull/133), [`171c302`](https://github.com/Python-roborock/python-roborock/commit/171c30265664b0161db75695d2d30d8b45bbf5b3)) ### Chores - Add some initial documentation ([#94](https://github.com/Python-roborock/python-roborock/pull/94), [`316fc0d`](https://github.com/Python-roborock/python-roborock/commit/316fc0d95f83948da25df0515622913173117ee0)) ## v0.35.1 (2023-10-28) ### Bug Fixes - Add s5 max mop code 207 ([#132](https://github.com/Python-roborock/python-roborock/pull/132), [`adc7ae0`](https://github.com/Python-roborock/python-roborock/commit/adc7ae0bbb75eb5be452efb62ca93de6a5211eef)) ## v0.35.0 (2023-10-18) ### Features - **code_mappings**: Add error n53 cleaning tank full or blocked ([#130](https://github.com/Python-roborock/python-roborock/pull/130), [`ebd57a0`](https://github.com/Python-roborock/python-roborock/commit/ebd57a0b559c0dee605e30eaead58b8433347a84)) Co-authored-by: jalcaras ## v0.34.6 (2023-10-02) ### Bug Fixes - Add missing 207 code ([#127](https://github.com/Python-roborock/python-roborock/pull/127), [`87431a1`](https://github.com/Python-roborock/python-roborock/commit/87431a1f155059a51b1b3e2c8867fe18cc476e16)) ## v0.34.5 (2023-09-29) ### Bug Fixes - Remove alexapy ([#126](https://github.com/Python-roborock/python-roborock/pull/126), [`38ff4eb`](https://github.com/Python-roborock/python-roborock/commit/38ff4eb90a1805ad599f61322d7c3547f465868b)) ## v0.34.4 (2023-09-28) ### Bug Fixes - Parsing potential list of clean record ([#125](https://github.com/Python-roborock/python-roborock/pull/125), [`df7a920`](https://github.com/Python-roborock/python-roborock/commit/df7a920a94a632d9653637e0111b3a955db49356)) ## v0.34.3 (2023-09-24) ### Bug Fixes - Add custom code for p10 ([#123](https://github.com/Python-roborock/python-roborock/pull/123), [`8b57d50`](https://github.com/Python-roborock/python-roborock/commit/8b57d50b0c898ca7d3df7cbdfe3682fd03cf649e)) ## v0.34.2 (2023-09-21) ### Bug Fixes - Make cache not global ([#122](https://github.com/Python-roborock/python-roborock/pull/122), [`e119201`](https://github.com/Python-roborock/python-roborock/commit/e119201f1c700d98e3322653440097c91ef4e14c)) * feat: add datetime parsing in cleanrecord * chore: lint * fix: timezone for non-3.11 * feat: add is_available for ha and here in future * fix: add timeout as a variable and set a longer default timeout for cloud * fix: is_available true by default * fix: status type as class variable * fix: don't update status when it was none before listener * fix: reduce info logs * fix: don't cache device cache * fix: double keepalive * fix: don't continue calling unsupported functions * fix: revert keepalive for now ## v0.34.1 (2023-09-19) ### Bug Fixes - Status reworking ([#121](https://github.com/Python-roborock/python-roborock/pull/121), [`8f4b7d3`](https://github.com/Python-roborock/python-roborock/commit/8f4b7d376d5a475798782496ea52ac9674cb9ae7)) * fix: is_available true by default * fix: status type as class variable * fix: don't update status when it was none before listener * fix: reduce info logs ## v0.34.0 (2023-09-12) ### Chores - Add pyupgrade to ruff ([#118](https://github.com/Python-roborock/python-roborock/pull/118), [`360b240`](https://github.com/Python-roborock/python-roborock/commit/360b240ab89862f8003ece11833e50846b279259)) * chore: add pyupgrade to ruff * chore: make ruff and isort play nice ### Features - Add datetime parsing in cleanrecord ([#119](https://github.com/Python-roborock/python-roborock/pull/119), [`5e67fa6`](https://github.com/Python-roborock/python-roborock/commit/5e67fa648478e573239c2f1dfc4b58c01cae1797)) * feat: add datetime parsing in cleanrecord * fix: timezone for non-3.11 * feat: add is_available for ha and here in future * fix: add timeout as a variable and set a longer default timeout for cloud ## v0.33.2 (2023-09-06) ### Bug Fixes - Add missing s5 codes ([#116](https://github.com/Python-roborock/python-roborock/pull/116), [`4d56021`](https://github.com/Python-roborock/python-roborock/commit/4d560216354fab4ab8b1d452dd6b29008b20d50a)) * fix: add missing codes for s5 max * chore: lint ## v0.33.1 (2023-09-06) ### Bug Fixes - Unknow values on HA component ([#117](https://github.com/Python-roborock/python-roborock/pull/117), [`1323618`](https://github.com/Python-roborock/python-roborock/commit/1323618c6c58bb6dcef5c7f5f2ca12e32969ba0f)) * feat add Q REVO support (RoborockFanSpeedP10 + RoborockMopModeP10) * feat add Q REVO support (model ROBOROCK_P10/roborock.vacuum.a75) * feat add Q REVO support (P10Status) * feat add Q REVO support (status data) * fix(P10Status): Change RoborockMopModeP10 by RoborockMopModeS8ProUltra * fix(RoborockMopModeP10): Remove * fix: change ordering of imports * fix: change q_revo->p10 to be consistent with entire code * fix: for HA component(items: dock_mop_wash_mode_interval, dock_washing_mode) stuck at "unknow" value when using P10 --------- Co-authored-by: jalcaras Co-authored-by: jalcaras Co-authored-by: Luke ## v0.33.0 (2023-09-04) ### Features - Add q revo/p10 support ([#114](https://github.com/Python-roborock/python-roborock/pull/114), [`b2237d9`](https://github.com/Python-roborock/python-roborock/commit/b2237d97384d819cbcc62902bbcbb2c7dbe0072e)) * feat add Q REVO support (RoborockFanSpeedP10 + RoborockMopModeP10) * feat add Q REVO support (model ROBOROCK_P10/roborock.vacuum.a75) * feat add Q REVO support (P10Status) * feat add Q REVO support (status data) * fix(P10Status): Change RoborockMopModeP10 by RoborockMopModeS8ProUltra * fix(RoborockMopModeP10): Remove * fix: change ordering of imports --------- Co-authored-by: jalcaras Co-authored-by: jalcaras Co-authored-by: Luke ## v0.32.4 (2023-08-30) ### Bug Fixes - Refactor cache and call get_status after changing mop mode ([#105](https://github.com/Python-roborock/python-roborock/pull/105), [`8bf70f4`](https://github.com/Python-roborock/python-roborock/commit/8bf70f4f8b3cabe846bffdc3dd3300f9f621ae97)) ### Chores - **deps**: Bump wagoid/commitlint-github-action from 5.4.1 to 5.4.3 ([#96](https://github.com/Python-roborock/python-roborock/pull/96), [`2da7b38`](https://github.com/Python-roborock/python-roborock/commit/2da7b3865bb1693b7ce655bf0d44090753aa5a52)) ## v0.32.3 (2023-08-05) ### Bug Fixes - Resolve unawaited task errors on connect/disconnect ([#103](https://github.com/Python-roborock/python-roborock/pull/103), [`1ad03be`](https://github.com/Python-roborock/python-roborock/commit/1ad03befa84f9b729a0cc7553b794fe5344a22ce)) * fix: resolve unawaited task errors on connect/disconnect * chore: make lint happy ## v0.32.2 (2023-08-04) ### Bug Fixes - Waiting queue ([`ff5376b`](https://github.com/Python-roborock/python-roborock/commit/ff5376be3a4ff4eb90e33118db89214ef699dc6f)) ## v0.32.1 (2023-08-04) ### Bug Fixes - Remove coroutine warning ([`da83078`](https://github.com/Python-roborock/python-roborock/commit/da83078f7ef8f333fa46b75603ce8a88bb97914d)) ## v0.32.0 (2023-08-03) ### Chores - Lint ([`d158dcc`](https://github.com/Python-roborock/python-roborock/commit/d158dcc2c44d2d529e762d95815dc854b5ed674e)) ### Features - Adding device_id to listeners and fixing race condition on connection, disconnection and messages ([`2bee8a1`](https://github.com/Python-roborock/python-roborock/commit/2bee8a11ad30cd4a3c186a4c0a619838adc83a53)) ## v0.31.1 (2023-08-02) ### Bug Fixes - Add error code for invalid credentials ([#101](https://github.com/Python-roborock/python-roborock/pull/101), [`703f48b`](https://github.com/Python-roborock/python-roborock/commit/703f48b66cfd32d20e74eaa959a66cd736ca38c8)) ## v0.31.0 (2023-07-31) ### Features - Add device name to logs ([#100](https://github.com/Python-roborock/python-roborock/pull/100), [`7690d56`](https://github.com/Python-roborock/python-roborock/commit/7690d5644181abb5fb7681d6c1764e2f8750c4b5)) ## v0.30.3 (2023-07-31) ### Bug Fixes - Adding no dustbin to docker errors ([`0e28628`](https://github.com/Python-roborock/python-roborock/commit/0e286280edda21a3b95c656d5bc358cd4229d075)) ## v0.30.2 (2023-07-21) ### Bug Fixes - Possible solution for future invalid state ([`8ac4e72`](https://github.com/Python-roborock/python-roborock/commit/8ac4e72372f26105423213bb85d4c33d7951af4d)) ## v0.30.1 (2023-07-18) ### Bug Fixes - Add missing s8 pro mop code and q revo dock ([#92](https://github.com/Python-roborock/python-roborock/pull/92), [`5d75c3b`](https://github.com/Python-roborock/python-roborock/commit/5d75c3b794db231e07f8b6693f2a96b132f737ce)) ### Chores - **deps**: Bump relekang/python-semantic-release from 7.34.6 to 8.0.0 ([#89](https://github.com/Python-roborock/python-roborock/pull/89), [`9677018`](https://github.com/Python-roborock/python-roborock/commit/96770184e953598e6232dbed4e6d39466f7d7465)) ## v0.30.0 (2023-07-10) ### Bug Fixes - Add missing dock for s7 max ultra ([#88](https://github.com/Python-roborock/python-roborock/pull/88), [`10aff22`](https://github.com/Python-roborock/python-roborock/commit/10aff22bc1e6d17b1b6c2587ebefcfd1d9fb7be7)) - Listeners getting protocol data before it exists. ([#87](https://github.com/Python-roborock/python-roborock/pull/87), [`3d68ea4`](https://github.com/Python-roborock/python-roborock/commit/3d68ea4326da827f17a32b2b5645f1e1e43f3eca)) * fix: listeners getting protocol data before it exists * fix: optimize code ### Features - Created strong foundation for docs ([#86](https://github.com/Python-roborock/python-roborock/pull/86), [`ef88edd`](https://github.com/Python-roborock/python-roborock/commit/ef88eddb8b582f5ad958d8135964e39ba6a05c91)) ## v0.29.2 (2023-06-28) ### Bug Fixes - Downgrade construct ([#84](https://github.com/Python-roborock/python-roborock/pull/84), [`920f59f`](https://github.com/Python-roborock/python-roborock/commit/920f59f1fad2790084ee001225bbaff2e21b3f91)) ## v0.29.1 (2023-06-27) ### Bug Fixes - Adding scene commands ([`fddbe50`](https://github.com/Python-roborock/python-roborock/commit/fddbe508f177dc6bc336223007018f501709c995)) ## v0.29.0 (2023-06-26) ### Features - Adding server timer and retry command compatibility ([`1a1565b`](https://github.com/Python-roborock/python-roborock/commit/1a1565b1f2eb57fa373c9298dd2501a13914bb0a)) ## v0.28.0 (2023-06-26) ### Features - Adding status and consumable listeners ([#83](https://github.com/Python-roborock/python-roborock/pull/83), [`ebdbc90`](https://github.com/Python-roborock/python-roborock/commit/ebdbc907f1f1a2a91ad10953ca6e70b91b9664dd)) * feat: adding status and consumable listeners * fix: api tests * chore: linting ## v0.27.2 (2023-06-22) ### Bug Fixes - Cache concurrency ([`7dd3aa4`](https://github.com/Python-roborock/python-roborock/commit/7dd3aa4933248ede6230a82e6d14e30e8009e27c)) ## v0.27.1 (2023-06-22) ### Bug Fixes - Improving cache and refactoring ([`e88854d`](https://github.com/Python-roborock/python-roborock/commit/e88854d3c6c9109e9fbb4e8ecd3d0ee4ad5d53ff)) ## v0.27.0 (2023-06-22) ### Features - Improving cache and refactoring ([#82](https://github.com/Python-roborock/python-roborock/pull/82), [`e6d48af`](https://github.com/Python-roborock/python-roborock/commit/e6d48af4e1c83fe79104d368918613ac0b332cbb)) ## v0.26.2 (2023-06-21) ### Bug Fixes - #81 - cli raising exception for diagnostic data ([`690b316`](https://github.com/Python-roborock/python-roborock/commit/690b316de35c970454a45418682c82d752b81201)) ## v0.26.1 (2023-06-20) ### Bug Fixes - Changelog ([#80](https://github.com/Python-roborock/python-roborock/pull/80), [`5c4928b`](https://github.com/Python-roborock/python-roborock/commit/5c4928b2d414b9decc1a454348e38d29aeb505fa)) ## v0.26.0 (2023-06-20) ### Chores - Update pyproject ([#79](https://github.com/Python-roborock/python-roborock/pull/79), [`cad97da`](https://github.com/Python-roborock/python-roborock/commit/cad97da7924288524993b32f2d2cd7d71abccee6)) - **deps**: Bump relekang/python-semantic-release from 7.34.4 to 7.34.6 ([#78](https://github.com/Python-roborock/python-roborock/pull/78), [`cebc9d2`](https://github.com/Python-roborock/python-roborock/commit/cebc9d28aa5222e78670bab5e19e162774a9a73f)) ### Features - Adding command cache ([#77](https://github.com/Python-roborock/python-roborock/pull/77), [`505f5e4`](https://github.com/Python-roborock/python-roborock/commit/505f5e45a56e98c248a38236ae3f02908583de12)) * feat: adding command cache * chore: typo * fix: dependencies * feat: adding cache evict time ## v0.25.2 (2023-06-17) ### Bug Fixes - Downgrading construct version ([`d5148ce`](https://github.com/Python-roborock/python-roborock/commit/d5148ce8fc553f73819a9f03c7688d53100bdcd9)) - Moving back to python 3.10 due to python-semantic-release incompatibility ([`8ab9352`](https://github.com/Python-roborock/python-roborock/commit/8ab9352adb2cb82c24057bef3107b28d3a157087)) - Removing python 10 tests ([`46e258b`](https://github.com/Python-roborock/python-roborock/commit/46e258bc495123c8e8325a731e353f3bc5ce3e0c)) ## v0.25.1 (2023-06-16) ### Bug Fixes - Python-semantic-release python version ([`845da45`](https://github.com/Python-roborock/python-roborock/commit/845da456a0d59765d08962fee007b63c8d0c50eb)) ## v0.25.0 (2023-06-16) ### Bug Fixes - Remove dnd timer and valley electricity from props ([#75](https://github.com/Python-roborock/python-roborock/pull/75), [`2035af5`](https://github.com/Python-roborock/python-roborock/commit/2035af5d524605fcbd0b87e20f256c1c61ca9c68)) * fix: remove dnd timer and valley electricity from props * fix: linting * fix: clear out old keep alive before adding new one * chore: remove keep_alive_task * fix: add storing of dnd and valley in api - Remove python 10 from tests ([`31fc34c`](https://github.com/Python-roborock/python-roborock/commit/31fc34c22ad9e5f06b588e6b283412902bd2959d)) - Semantic release ([#76](https://github.com/Python-roborock/python-roborock/pull/76), [`224a566`](https://github.com/Python-roborock/python-roborock/commit/224a5662d2dbdf47d5141554733a9b4aeaf8d4f2)) * fix: remove dnd timer and valley electricity from props * fix: linting * fix: clear out old keep alive before adding new one * chore: remove keep_alive_task * fix: add storing of dnd and valley in api * 0.24.2 Automatically generated by python-semantic-release * fix: add dirty tank latch error ### Chores - Add dependabot ([#70](https://github.com/Python-roborock/python-roborock/pull/70), [`cff6871`](https://github.com/Python-roborock/python-roborock/commit/cff6871012370bc8c1aaeefbea32f08c3a8d21f6)) * add dependabot * chore: update dependabot ignore - Manually releasing 0.24.1 ([`0ab69b3`](https://github.com/Python-roborock/python-roborock/commit/0ab69b3cdfb1697fdd7edb9a644f296f1dfa10a2)) - Updating ci.yml ([`d4c2714`](https://github.com/Python-roborock/python-roborock/commit/d4c2714a5800c38333d292f1bef0c17a38326e40)) - **deps**: Bump wagoid/commitlint-github-action from 5.3.0 to 5.4.1 ([#71](https://github.com/Python-roborock/python-roborock/pull/71), [`951dd5c`](https://github.com/Python-roborock/python-roborock/commit/951dd5c13030e0bc15256d414ed8e11235ff192b)) Bumps [wagoid/commitlint-github-action](https://github.com/wagoid/commitlint-github-action) from 5.3.0 to 5.4.1. - [Changelog](https://github.com/wagoid/commitlint-github-action/blob/master/CHANGELOG.md) - [Commits](https://github.com/wagoid/commitlint-github-action/compare/v5.3.0...v5.4.1) --- updated-dependencies: - dependency-name: wagoid/commitlint-github-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> - **deps**: Update pycryptodome requirement ([#73](https://github.com/Python-roborock/python-roborock/pull/73), [`52dd451`](https://github.com/Python-roborock/python-roborock/commit/52dd451b57e7d292c6f8f01f1777f7a5cb88918b)) Updates the requirements on [pycryptodome](https://github.com/Legrandin/pycryptodome) to permit the latest version. - [Release notes](https://github.com/Legrandin/pycryptodome/releases) - [Changelog](https://github.com/Legrandin/pycryptodome/blob/master/Changelog.rst) - [Commits](https://github.com/Legrandin/pycryptodome/compare/v3.17.0...v3.18.0) --- updated-dependencies: - dependency-name: pycryptodome dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ### Features - Bump python version ([`aae48b1`](https://github.com/Python-roborock/python-roborock/commit/aae48b1395698136ca90b7fe7386a1b6ea8aaa9c)) ## v0.24.1 (2023-06-14) ### Bug Fixes - Device_prop update ([`b6d1ccc`](https://github.com/Python-roborock/python-roborock/commit/b6d1ccc913cff1a7e25745867435146e9f748df7)) - Python-semantic-release ([`80e9c24`](https://github.com/Python-roborock/python-roborock/commit/80e9c24a39f3147b0fbc0a5437631777ab52b027)) ### Chores - Manually releasing 0.24.0 ([`0a08c97`](https://github.com/Python-roborock/python-roborock/commit/0a08c972dae32a8d5670fd049b8220a4af1d3307)) ## v0.24.0 (2023-06-14) ### Features - Adding valley_electricity_timer to props ([`0844067`](https://github.com/Python-roborock/python-roborock/commit/08440670a7fb098f5f3954e2ad09f9a32e64a54e)) ## v0.23.6 (2023-06-08) ### Bug Fixes - Add datetime_time back ([#68](https://github.com/Python-roborock/python-roborock/pull/68), [`a3461dd`](https://github.com/Python-roborock/python-roborock/commit/a3461dd0a08702add2625df8616ba20d239805ce)) ### Chores - Linting ([`90f905d`](https://github.com/Python-roborock/python-roborock/commit/90f905d331125c8536ab1db29444685fcf8bf196)) ## v0.23.5 (2023-06-08) ### Bug Fixes - Issue building roborock message ([`89e1f28`](https://github.com/Python-roborock/python-roborock/commit/89e1f28461baaf03029679aed5f91200bb7dac4e)) ## v0.23.4 (2023-06-06) ### Bug Fixes - Adding method parse_datetime_to_roborock_datetime ([`64c8159`](https://github.com/Python-roborock/python-roborock/commit/64c8159a9695374a4b0599a317418949bdd8f3fe)) ### Chores - Fix mypy ([`c0e7997`](https://github.com/Python-roborock/python-roborock/commit/c0e7997c61f9878436ae65aa8530b1c08b503ed9)) ## v0.23.3 (2023-06-05) ### Bug Fixes - Parse_time_to_datetime method ([`d0fc149`](https://github.com/Python-roborock/python-roborock/commit/d0fc1498e20217d28703455937f760ba45053c61)) ## v0.23.2 (2023-06-05) ### Bug Fixes - Parse_time_to_datetime method ([`bcbc211`](https://github.com/Python-roborock/python-roborock/commit/bcbc2117dd306c21495c1f3364aa3205b3c5cfce)) ## v0.23.1 (2023-06-05) ### Bug Fixes - Parse_time_to_datetime method ([`1c39216`](https://github.com/Python-roborock/python-roborock/commit/1c39216c0ee6a29d350d08adc5d662d8669f85cf)) ## v0.23.0 (2023-06-05) ### Bug Fixes - Merging timer entities ([`22ff7f4`](https://github.com/Python-roborock/python-roborock/commit/22ff7f451166bcfda360552e92d661d0520886ae)) ### Chores - Linting ([`9e2a3c5`](https://github.com/Python-roborock/python-roborock/commit/9e2a3c5f2908c3e69e14bda239112cc6d8bbca15)) ### Features - Add diagnostic data and extra containers ([#67](https://github.com/Python-roborock/python-roborock/pull/67), [`59ef6f4`](https://github.com/Python-roborock/python-roborock/commit/59ef6f4d5366859ba5d02ba66ec1aa2288564179)) * feat: add diagnostic data and extra containers * fix: lint * fix: dock summary as roborockbase * fix: make deviceprop RoborockBase * merge in changes ## v0.22.0 (2023-06-05) ### Features - Adding type cast for send_command ([`4a0b709`](https://github.com/Python-roborock/python-roborock/commit/4a0b70997080012e3059150da2b12fb47f6ef43a)) ## v0.21.1 (2023-06-05) ### Bug Fixes - Cli json serializing ([#66](https://github.com/Python-roborock/python-roborock/pull/66), [`ab13b53`](https://github.com/Python-roborock/python-roborock/commit/ab13b53a15822067112edda285c6feddf389a8b8)) ## v0.21.0 (2023-06-04) ### Features - Add time datetime for valley ([#65](https://github.com/Python-roborock/python-roborock/pull/65), [`c965862`](https://github.com/Python-roborock/python-roborock/commit/c965862f5b8b1f4dfbc83738cdebc1e11122c387)) ## v0.20.2 (2023-06-02) ### Bug Fixes - S6maxvstatus and minor changes ([`01f84ae`](https://github.com/Python-roborock/python-roborock/commit/01f84ae741dd3c9fa3bc5932b718abebcc8e3f0f)) ## v0.20.1 (2023-06-01) ### Bug Fixes - S8 model name and adding api methods get_child_lock_status and get_sound_volume ([`a3b7cee`](https://github.com/Python-roborock/python-roborock/commit/a3b7cee63a70746ac3db5e5cee37c5b507b99478)) ## v0.20.0 (2023-05-31) ### Features - Adds code for duct blockage ([#64](https://github.com/Python-roborock/python-roborock/pull/64), [`84dd5fb`](https://github.com/Python-roborock/python-roborock/commit/84dd5fbdefebe4b33c6bae6879137847522b1bfb)) ## v0.19.0 (2023-05-31) ### Features - Moving clean area to api ([#63](https://github.com/Python-roborock/python-roborock/pull/63), [`7ade218`](https://github.com/Python-roborock/python-roborock/commit/7ade218e3efd44159c6ad40cd88933385bbd1496)) ## v0.18.10 (2023-05-30) ### Bug Fixes - Dict with enum instead of value ([`9653c50`](https://github.com/Python-roborock/python-roborock/commit/9653c50f31b03ce2d3d21e2042d5c194924f4aca)) ## v0.18.9 (2023-05-28) ### Bug Fixes - Mqtt reconnections ([`462d4e4`](https://github.com/Python-roborock/python-roborock/commit/462d4e4a30372c143c9198c7008808ca11800af5)) ### Chores - Linting ([`f850cd1`](https://github.com/Python-roborock/python-roborock/commit/f850cd1f7d10b774516e76f3dac1ba2fec254ad7)) ## v0.18.8 (2023-05-28) ### Bug Fixes - Improve device ping ([`56e4469`](https://github.com/Python-roborock/python-roborock/commit/56e4469c95ac9255604025df99f0d6ac1940dd19)) ## v0.18.7 (2023-05-27) ### Bug Fixes - Change e2 fan codes ([#62](https://github.com/Python-roborock/python-roborock/pull/62), [`7231f1e`](https://github.com/Python-roborock/python-roborock/commit/7231f1efc412f93bfb5719091337536bcb6185d6)) * fix: change e2 fan codes * fix: linting * fix: incorrect balanced code ## v0.18.6 (2023-05-19) ### Bug Fixes - Consumables with time equals 0 ([`ccab5f0`](https://github.com/Python-roborock/python-roborock/commit/ccab5f0724854ae27bbc51b9ee33f2a96ce709f1)) ## v0.18.5 (2023-05-16) ### Bug Fixes - Connection_lost ([`c2ba673`](https://github.com/Python-roborock/python-roborock/commit/c2ba673f2c198bc78e75e1cf6fc9844e385e85bb)) ## v0.18.4 (2023-05-16) ### Bug Fixes - Minor fixes ([`e4a291d`](https://github.com/Python-roborock/python-roborock/commit/e4a291dd2b011e5852c992dbb23068ef5dde0e52)) ## v0.18.3 (2023-05-15) ### Bug Fixes - Keep_alive_func ([`e4aeebc`](https://github.com/Python-roborock/python-roborock/commit/e4aeebc16317a5c9fe3ffcd3bff89be1f2070dbb)) ### Chores - Linting ([`dbffaab`](https://github.com/Python-roborock/python-roborock/commit/dbffaaba59214015a9b721347331b37ff38fb941)) ## v0.18.2 (2023-05-15) ### Bug Fixes - Adding hello command ([`dfa44ff`](https://github.com/Python-roborock/python-roborock/commit/dfa44ff56a794f30e7c93d0a9a270f2a02da7e65)) - Improving new protocols ([`08c6f95`](https://github.com/Python-roborock/python-roborock/commit/08c6f9530b202d17ef80047c2d60836f9f9b8422)) ## v0.18.1 (2023-05-15) ### Bug Fixes - Type checks ([`58b3322`](https://github.com/Python-roborock/python-roborock/commit/58b33225b50a221a5f3100055fe28461f5cff884)) ## v0.18.0 (2023-05-15) ### Features - Keep connection alive ([`691b04b`](https://github.com/Python-roborock/python-roborock/commit/691b04b0135a38cc6b150e284d96e217f18f7f46)) ## v0.17.8 (2023-05-15) ### Bug Fixes - Trying to fix connection leaks ([`a66482a`](https://github.com/Python-roborock/python-roborock/commit/a66482a22cba9a6e7cc449c3f35acc1f230cd211)) ## v0.17.7 (2023-05-15) ### Bug Fixes - Ignoring get_room_mapping for int list response ([`c71d3b5`](https://github.com/Python-roborock/python-roborock/commit/c71d3b549a8dd09d08d1d27cde6882298875269c)) ## v0.17.6 (2023-05-13) ### Bug Fixes - Using cache only a single time ([`1ebfb35`](https://github.com/Python-roborock/python-roborock/commit/1ebfb35b9fe9ec50d4abeb60c695d33a37818768)) ## v0.17.5 (2023-05-12) ### Bug Fixes - Adding log for local disconnection ([`3001798`](https://github.com/Python-roborock/python-roborock/commit/300179839ec6a25e4ab8172f2c11e8beb0ff17ce)) ## v0.17.4 (2023-05-12) ### Bug Fixes - Pycharm typing ([`12d7c0b`](https://github.com/Python-roborock/python-roborock/commit/12d7c0b71bdeae90e9abbc6a16de3e07ebaa82da)) ## v0.17.3 (2023-05-12) ### Bug Fixes - Trigger new release ([`270a65c`](https://github.com/Python-roborock/python-roborock/commit/270a65c24a847cdc58a630e6d6c8e296910de8ea)) ## v0.17.2 (2023-05-11) ### Bug Fixes - Adding fallback cache (to be tested) ([`0e214cd`](https://github.com/Python-roborock/python-roborock/commit/0e214cd0633e9b9baca3323cc505a4f787aa08fb)) - Fallback_cache func ([`8048d84`](https://github.com/Python-roborock/python-roborock/commit/8048d843f669b06960967918570201498e4ae051)) ### Chores - Linting ([`2263190`](https://github.com/Python-roborock/python-roborock/commit/226319078162796c186bcd0bef46b961153e0435)) ## v0.17.1 (2023-05-11) ### Bug Fixes - Improving logs ([`cdd0ea7`](https://github.com/Python-roborock/python-roborock/commit/cdd0ea75d4e336c8f918a79574fd7b642eaffeec)) ## v0.17.0 (2023-05-11) ### Features - Dynamic calculated prefixes ([`d57a0a7`](https://github.com/Python-roborock/python-roborock/commit/d57a0a7d31f851b6bf4381233a84187d19e5782f)) ## v0.16.1 (2023-05-10) ### Bug Fixes - Connection timeouts ([`36a7295`](https://github.com/Python-roborock/python-roborock/commit/36a7295ce878dd0649505dd4a5b5ad662f0655fd)) ## v0.16.0 (2023-05-10) ### Chores - Adding package_parser.py ([`c6cc29b`](https://github.com/Python-roborock/python-roborock/commit/c6cc29b86418c7ed62f30a5684f5a95a6a712834)) - Fix readthedocs ([#59](https://github.com/Python-roborock/python-roborock/pull/59), [`b747ad8`](https://github.com/Python-roborock/python-roborock/commit/b747ad89ec1180ceffc4130d1be1ce9dee203f98)) - Linting ([`3eaed1d`](https://github.com/Python-roborock/python-roborock/commit/3eaed1d48293f474e65914c17c93ea54b7c0a9a5)) ### Features - Adding pcap file parser to cli ([`798287a`](https://github.com/Python-roborock/python-roborock/commit/798287a5100a3e973524aae6dd9404c0af354c11)) ## v0.15.0 (2023-05-09) ### Bug Fixes - Add int for clean summary ([#57](https://github.com/Python-roborock/python-roborock/pull/57), [`4257aa7`](https://github.com/Python-roborock/python-roborock/commit/4257aa7888178703d1b38ed00c12ef932ca1e862)) ### Features - Add docs ([#58](https://github.com/Python-roborock/python-roborock/pull/58), [`959abe1`](https://github.com/Python-roborock/python-roborock/commit/959abe1f3b2be0bfb8705d1bc1f9cbe966577540)) ## v0.14.1 (2023-05-09) ### Bug Fixes - Add types for S8 ([#56](https://github.com/Python-roborock/python-roborock/pull/56), [`125b6e7`](https://github.com/Python-roborock/python-roborock/commit/125b6e728145fde39f49fa6b80168bb985f2cc43)) * fix: add types for S8 * fix: lint ## v0.14.0 (2023-05-08) ### Features - Add more codes for status ([#55](https://github.com/Python-roborock/python-roborock/pull/55), [`cddd765`](https://github.com/Python-roborock/python-roborock/commit/cddd765aa15e31ae50db5a6b29ff6988050aa5cc)) ## v0.13.4 (2023-05-05) ### Bug Fixes - Command prefixes ([`65c5db8`](https://github.com/Python-roborock/python-roborock/commit/65c5db834baadc4c1a61704bd2279c48dd0f6074)) ## v0.13.3 (2023-05-05) ### Bug Fixes - Roborock enum ([`ae0b93e`](https://github.com/Python-roborock/python-roborock/commit/ae0b93ee0f0fc9c62c3f40b436ece209938e9e6c)) ### Chores - Linting ([`250d5fc`](https://github.com/Python-roborock/python-roborock/commit/250d5fcc0a320604ee25519764bd7ac1872dbd0b)) - Linting ([`fea34d6`](https://github.com/Python-roborock/python-roborock/commit/fea34d63400a94447834ab355d0a023b53e77d7d)) ## v0.13.2 (2023-05-05) ### Bug Fixes - Minor changes ([`522734a`](https://github.com/Python-roborock/python-roborock/commit/522734a4bdcf6555feede24e3e97c6a3a98fa760)) ## v0.13.1 (2023-05-05) ### Bug Fixes - Adding app_start_collect_dust prefix ([`3124d7e`](https://github.com/Python-roborock/python-roborock/commit/3124d7ea6277ec08d8e592448b2a4f8cb60fb7db)) ## v0.13.0 (2023-05-05) ### Features - Add s4_max ([#54](https://github.com/Python-roborock/python-roborock/pull/54), [`e7cfd15`](https://github.com/Python-roborock/python-roborock/commit/e7cfd153b3c41215fd1c85d4968a14d1862c91b5)) ## v0.12.1 (2023-05-05) ### Bug Fixes - Changed incorrect s8 pro ultra string ([`c6a37a9`](https://github.com/Python-roborock/python-roborock/commit/c6a37a97da9279af3a6a24dc0fd01770cdd9b3b1)) fixes #52 ## v0.12.0 (2023-05-05) ### Features - Extending device status by device model ([#51](https://github.com/Python-roborock/python-roborock/pull/51), [`8092b67`](https://github.com/Python-roborock/python-roborock/commit/8092b67b8c9a380cca5178217fde3a61746fcf75)) * feat: extending device status by device model * chore: linting ## v0.11.0 (2023-05-04) ### Features - Add error check for invalid user agreement ([#49](https://github.com/Python-roborock/python-roborock/pull/49), [`0374449`](https://github.com/Python-roborock/python-roborock/commit/0374449d7280c93ceb772b7fbe009c6d19d0c462)) * minor: add error check for invalid user agreement * fix: lint * feat: add no user agreement error * fix: version issue * fix: added account to str ## v0.10.3 (2023-05-04) ### Bug Fixes - Port already in use ([`e5d71d8`](https://github.com/Python-roborock/python-roborock/commit/e5d71d88f5144c172482cd6ee71d9a5b01dbbe3f)) ## v0.10.2 (2023-05-03) ### Bug Fixes - Change devices fan speed enum to lower case ([`c559d40`](https://github.com/Python-roborock/python-roborock/commit/c559d40183e47ef8698651281ae8946a99cb897e)) - Test errors ([`6a46515`](https://github.com/Python-roborock/python-roborock/commit/6a465157bbf6fa15bc578a1c4b1dffa17a694a92)) ## v0.10.1 (2023-05-03) ### Bug Fixes - Allow discovering multiple devices ([`ada9e07`](https://github.com/Python-roborock/python-roborock/commit/ada9e0723728b1d7e3ccd6dc37cbbe06a3c6a2cc)) ### Chores - Using python construct for data parsing ([#48](https://github.com/Python-roborock/python-roborock/pull/48), [`71f7f22`](https://github.com/Python-roborock/python-roborock/commit/71f7f2207986cb22c2990ae6d67fd38c2d04b472)) * chore: using python construct for data parsing * chore: linting * fix: roborock message protocol * fix: change local api constructor ## v0.10.0 (2023-05-03) ### Chores - Linting ([`e3f2541`](https://github.com/Python-roborock/python-roborock/commit/e3f25419fcfe00f18e0cca9214c4d50cd5254c80)) ### Features - Add specific device functionality ([#46](https://github.com/Python-roborock/python-roborock/pull/46), [`32abce5`](https://github.com/Python-roborock/python-roborock/commit/32abce5d51d14aab9adef5b9560ceee534186b1a)) * feat: add support for old mop and vacuum codes * fix: linting * feat: using api for single device and adding new commands * fix: using single device api (cherry picked from commit e689e8d141acff998fd524ace923621fc0f91d0c) * chore: linting (cherry picked from commit 2ed367cba5e9b4199fdea935305fb47f85a8c1e7) (cherry picked from commit 58b46835d609794210f8c49daddbc7d25cee011d) * chore: init work * feat: added more device specific * fix: merge issues * feat: finalize specific device work * feat: finished specific device with current info * fix: add fast for S8 * fix: add s8 dock --------- Co-authored-by: humbertogontijo ## v0.9.0 (2023-05-01) ### Chores - Linting ([`a6a55ac`](https://github.com/Python-roborock/python-roborock/commit/a6a55ac4d11d230a0599aeec3d5254895fbaa684)) ### Features - Single device api and discovery method ([`5fef26d`](https://github.com/Python-roborock/python-roborock/commit/5fef26d257433c12d38f6b19731018e54884a150)) ## v0.8.3 (2023-04-28) ### Bug Fixes - Add functionality for missing enum values ([#43](https://github.com/Python-roborock/python-roborock/pull/43), [`49d77f8`](https://github.com/Python-roborock/python-roborock/commit/49d77f8208a65cb0fb86ab7948138df0bf447e45)) * fix: add functionality for missing enum values * fix: temp removed 207 * Revert "chore: linting" This reverts commit 58b46835d609794210f8c49daddbc7d25cee011d. This reverts commit 2ed367cba5e9b4199fdea935305fb47f85a8c1e7. * Revert "fix: using single device api" This reverts commit e689e8d141acff998fd524ace923621fc0f91d0c. ### Chores - Linting ([`58b4683`](https://github.com/Python-roborock/python-roborock/commit/58b46835d609794210f8c49daddbc7d25cee011d)) - Linting ([`2ed367c`](https://github.com/Python-roborock/python-roborock/commit/2ed367cba5e9b4199fdea935305fb47f85a8c1e7)) ## v0.8.2 (2023-04-27) ### Bug Fixes - Using single device api ([`e689e8d`](https://github.com/Python-roborock/python-roborock/commit/e689e8d141acff998fd524ace923621fc0f91d0c)) ### Chores - Linting ([`2e8e307`](https://github.com/Python-roborock/python-roborock/commit/2e8e307e6d82e045856d2a4ae731feba25005fe4)) ## v0.8.1 (2023-04-27) ### Bug Fixes - Adding keepalive to local connection ([`8ff8d2f`](https://github.com/Python-roborock/python-roborock/commit/8ff8d2f13fd85df96b3b334456799244ac878fbe)) ## v0.8.0 (2023-04-27) ### Features - Added error check and deviceprop functionality for core ([#42](https://github.com/Python-roborock/python-roborock/pull/42), [`746eec9`](https://github.com/Python-roborock/python-roborock/commit/746eec99ae0b6115fea6277f51b546036f7b3f18)) * feat: added update to deviceprop * feat: added time remaining to consumable * feat: added more exception checking * fix: linting * feat: add consumable const ## v0.7.8 (2023-04-26) ### Bug Fixes - Local api failing to send message ([`4cc38fe`](https://github.com/Python-roborock/python-roborock/commit/4cc38fe13df487296efda2a1e962c238e3d69168)) ### Chores - Linting ([`c378036`](https://github.com/Python-roborock/python-roborock/commit/c3780369a2ea237f7ed6f5114d68d55fff6b1386)) ## v0.7.7 (2023-04-26) ### Bug Fixes - Local api recover after command fail ([`cb11f14`](https://github.com/Python-roborock/python-roborock/commit/cb11f14d7b771b31c77dafe6435bcd52527c16a8)) ## v0.7.6 (2023-04-26) ### Bug Fixes - Reset_consumable command prefix ([`a1a8c06`](https://github.com/Python-roborock/python-roborock/commit/a1a8c06d369e33e4ebd42cf6f563b9727d0ce24e)) ### Chores - Linting ([`ac7e15a`](https://github.com/Python-roborock/python-roborock/commit/ac7e15a349aa7a6f438339109189d9d715dfa71d)) - Linting ([`4907044`](https://github.com/Python-roborock/python-roborock/commit/4907044e1933ab8afc30f2289df0ca1130cadb28)) ## v0.7.5 (2023-04-25) ### Bug Fixes - Adding missing prefixes ([`66b1833`](https://github.com/Python-roborock/python-roborock/commit/66b183385c96dd7ee395bff143f2d64ef8fb927a)) ### Chores - Linting ([`41af0e2`](https://github.com/Python-roborock/python-roborock/commit/41af0e2469cb2d9786ceab8fbcfdb4701714db69)) - Linting ([`6d6dff5`](https://github.com/Python-roborock/python-roborock/commit/6d6dff5a0131b9a6735023ce0ac47bc9a0622bc9)) ## v0.7.4 (2023-04-25) ### Bug Fixes - Get_room_mapping ([`459119b`](https://github.com/Python-roborock/python-roborock/commit/459119bee90513451bf10a1abeeccb75f3daa539)) ## v0.7.3 (2023-04-25) ### Bug Fixes - Added missing docks ([#40](https://github.com/Python-roborock/python-roborock/pull/40), [`65a6cc4`](https://github.com/Python-roborock/python-roborock/commit/65a6cc4fd19a30bc78f2c34b407d3d88e3aac2b1)) ## v0.7.2 (2023-04-25) ### Bug Fixes - Command prefixes ([`e792728`](https://github.com/Python-roborock/python-roborock/commit/e7927288cc3059a1eced1a65b31f84190718aaf2)) ## v0.7.1 (2023-04-25) ### Bug Fixes - Command prefixes ([`156ac51`](https://github.com/Python-roborock/python-roborock/commit/156ac5182d1a97c93ab16696099c8c099a19155d)) ## v0.7.0 (2023-04-25) ### Features - Add room mapping ([#41](https://github.com/Python-roborock/python-roborock/pull/41), [`aa3e6e4`](https://github.com/Python-roborock/python-roborock/commit/aa3e6e442fbbb679c4eca68840c4d19f9c659fde)) * feat: add room mapping * fix: lint * chore: move room mapping to super class client * chore: linting * Update roborock/api.py Co-authored-by: Humberto Gontijo --------- ## v0.6.17 (2023-04-25) ### Bug Fixes - Adding multi_maps_list to device props ([`7ac0485`](https://github.com/Python-roborock/python-roborock/commit/7ac0485c4a5bb43350c51331323c6773ff1c54fc)) - Removing non-needed classes ([`6ceedad`](https://github.com/Python-roborock/python-roborock/commit/6ceedadf09c20c743c994b07489887e344cd3061)) ## v0.6.16 (2023-04-22) ### Bug Fixes - Improving local integration ([`7657617`](https://github.com/Python-roborock/python-roborock/commit/7657617901d807908e5fd5c364700851b5108ab4)) ## v0.6.15 (2023-04-21) ### Bug Fixes - Get_clean_summary ([`ee81538`](https://github.com/Python-roborock/python-roborock/commit/ee815380a8b70efbac65627fdd69fdf0bb75420e)) ### Chores - Linting ([`0d3b000`](https://github.com/Python-roborock/python-roborock/commit/0d3b00093395a706ec202c5a55639ed9ece54281)) - Linting ([`124fa11`](https://github.com/Python-roborock/python-roborock/commit/124fa115b14430b2a9680d4b1da36f1b70ae85b5)) ## v0.6.14 (2023-04-21) ### Bug Fixes - Get_multi_map_list ([`cfaeb41`](https://github.com/Python-roborock/python-roborock/commit/cfaeb419e188510ade5bc1506214c9b3d2afeb18)) - Linting ([`fdb4484`](https://github.com/Python-roborock/python-roborock/commit/fdb44840741cd6872f7defea70e8f118a9803099)) ## v0.6.13 (2023-04-20) ### Bug Fixes - Check dock_type is not none ([#38](https://github.com/Python-roborock/python-roborock/pull/38), [`84c95e3`](https://github.com/Python-roborock/python-roborock/commit/84c95e3b3bebd940b9cc6cc06b73c1770605c765)) ## v0.6.12 (2023-04-19) ### Bug Fixes - Removed enum type check ([#37](https://github.com/Python-roborock/python-roborock/pull/37), [`585238e`](https://github.com/Python-roborock/python-roborock/commit/585238e505e685e14d867b19819815e7c3e19634)) ## v0.6.11 (2023-04-18) ### Bug Fixes - Lint ([`b0d8996`](https://github.com/Python-roborock/python-roborock/commit/b0d8996d46c2a52f87a8c01eb50fd6aa7bd98ed8)) ## v0.6.10 (2023-04-18) ### Bug Fixes - Lint ([`5ae44e2`](https://github.com/Python-roborock/python-roborock/commit/5ae44e247efca5e9b7958b887f6049f09ae2ced8)) ## v0.6.9 (2023-04-18) ### Bug Fixes - Lint ([`8499522`](https://github.com/Python-roborock/python-roborock/commit/8499522e5fb44abad20af1cfb7a677ca4e03639f)) ## v0.6.8 (2023-04-18) ### Bug Fixes - Lint ([`20bf54b`](https://github.com/Python-roborock/python-roborock/commit/20bf54b0a1834065584bdcb469a3123700c68f1d)) ## v0.6.7 (2023-04-18) ## v0.6.6 (2023-04-17) ### Bug Fixes - Using asyncio future instead of queue ([`1ea5430`](https://github.com/Python-roborock/python-roborock/commit/1ea5430197620dbd2dc87949e4326f24601f4ba8)) ## v0.6.5 (2023-04-13) ### Bug Fixes - Clean_summary for older devices ([`0a0c9e7`](https://github.com/Python-roborock/python-roborock/commit/0a0c9e7c965c183df971e11bd597319c68c8f646)) - Exclude changelog.md from pre-commit ([#36](https://github.com/Python-roborock/python-roborock/pull/36), [`b12c7a2`](https://github.com/Python-roborock/python-roborock/commit/b12c7a229dfdbe0af182d6a120548100b0ca4140)) ### Chores - Fix mypy errors ([#34](https://github.com/Python-roborock/python-roborock/pull/34), [`16bd2d1`](https://github.com/Python-roborock/python-roborock/commit/16bd2d1fab65760670252120fafa4b8e87e968be)) * chore: fix mypy errors * fix: run mypy through pre-commit * fix: spacing for ci * fix: tests changes * fix: cli exclusion * fix: add typing for roborockenum * fix: ignore warnings with mqtt.client * fix: more mypy changes * fix: limit cli mypy * fix: ignore type for containers * fix: add pre-commit information to dev poetry dependencies - New styling ([#35](https://github.com/Python-roborock/python-roborock/pull/35), [`55e6426`](https://github.com/Python-roborock/python-roborock/commit/55e6426129ec70f41a019fd9408b227fb8a03b5a)) ## v0.6.4 (2023-04-11) ### Bug Fixes - Disconnect on timeout so next command can work ([`5ad397b`](https://github.com/Python-roborock/python-roborock/commit/5ad397b3bbb4bc600888baba6c0cc15be9d17ef7)) ## v0.6.3 (2023-04-11) ### Bug Fixes - Semantic_release ([`63b249d`](https://github.com/Python-roborock/python-roborock/commit/63b249d65d3fc40b048320e6596aedc40f588bf9)) ## v0.6.2 (2023-04-11) ### Bug Fixes - Error code nogo_zone_detected ([`722e4b5`](https://github.com/Python-roborock/python-roborock/commit/722e4b5cfd0c4891adc506e9fe99740860027670)) ## v0.6.1 (2023-04-10) ### Bug Fixes - Lowercase true ([`774c3cc`](https://github.com/Python-roborock/python-roborock/commit/774c3cc9765ee76a3a553ca6911751124ae7164c)) - Semantic release not updating changelong ([`eaf6e90`](https://github.com/Python-roborock/python-roborock/commit/eaf6e90264b6ab69549da0e5bc3d17c4c0a2c07c)) - Trigger release ([`f1ce0ed`](https://github.com/Python-roborock/python-roborock/commit/f1ce0ed55a254bccd8567b48974ff74dd9ec8b25)) - Trigger release ([`9a4462c`](https://github.com/Python-roborock/python-roborock/commit/9a4462c800762393cc047085156acbe119cd0fe4)) - Trigger release ([`b7a664b`](https://github.com/Python-roborock/python-roborock/commit/b7a664b15b7c5180d816de325537693f47c24860)) - Trigger release ([`9256849`](https://github.com/Python-roborock/python-roborock/commit/9256849252f019f4fea2f59384bc0ea7c57adb5c)) ### Chores - Update gh token ([`f13690d`](https://github.com/Python-roborock/python-roborock/commit/f13690de8c4b5eb3d72809dff66a0caf275476dc)) ## v0.6.0 (2023-04-08) ### Bug Fixes - Changed prefixes for debugged commands ([`0db6b6d`](https://github.com/Python-roborock/python-roborock/commit/0db6b6dc3b7ef1b7721b8a9536affdd08380d916)) ### Features - Add more commands and prefixes ([`fe85dea`](https://github.com/Python-roborock/python-roborock/commit/fe85deaa1acc053c9c18f2b313ff5b812ba0e2c3)) ## v0.5.9 (2023-04-07) ### Bug Fixes - Assume device prop attr can be none ([`573db33`](https://github.com/Python-roborock/python-roborock/commit/573db337664be1f768254e384e3eef6c957955ba)) - Change to dataclass ([`111d762`](https://github.com/Python-roborock/python-roborock/commit/111d7627aa5999fc82cde650326857e51c4dc4a2)) ## v0.5.8 (2023-04-07) ### Bug Fixes - Changed prefix for set_custom_mode ([`d187eb4`](https://github.com/Python-roborock/python-roborock/commit/d187eb467e6c5c969fcaa48dcc7881d75784663d)) ## v0.5.7 (2023-04-07) ## v0.5.6 (2023-04-06) ### Bug Fixes - Create function for creating roborock code ([`2cf00fe`](https://github.com/Python-roborock/python-roborock/commit/2cf00fe607c7b5b544ea9671dabf87454cdb2322)) - Roborockbase.as_dict ([`bf52b44`](https://github.com/Python-roborock/python-roborock/commit/bf52b44b01e93000268c9fa274a3449ac3f82e36)) ## v0.5.5 (2023-04-06) ### Bug Fixes - Fix cloud_api ([`6159412`](https://github.com/Python-roborock/python-roborock/commit/6159412b577efa3544add18982d6a9859ad8225d)) ## v0.5.4 (2023-04-06) ### Bug Fixes - Minor fixes ([`7579ad5`](https://github.com/Python-roborock/python-roborock/commit/7579ad5266f46102b90be0a7676e5c116f5daefa)) ## v0.5.3 (2023-04-06) ### Bug Fixes - Roborock enum ([`df1262e`](https://github.com/Python-roborock/python-roborock/commit/df1262ef41b2b1cb4fd866cda1527b82723d38cd)) ## v0.5.2 (2023-04-06) ### Bug Fixes - Changing code mappings ([`493ed4b`](https://github.com/Python-roborock/python-roborock/commit/493ed4b9a1fb8f62918ecc4899b9ce716801b4be)) - Code mappings ([`115dad2`](https://github.com/Python-roborock/python-roborock/commit/115dad22c0280edf1853de43ae86ff1169707f5b)) - Roborockdeviceinfo ([`1ced9e9`](https://github.com/Python-roborock/python-roborock/commit/1ced9e95a6d2effb359008c2c5ef340db3243d6e)) - Using dataclass for containers ([`ad25a44`](https://github.com/Python-roborock/python-roborock/commit/ad25a443fb697f90b10a9c42c93bccbf4204c383)) ## v0.5.1 (2023-04-05) ## v0.5.0 (2023-04-05) ### Bug Fixes - Change device info class to dataclass ([`158766f`](https://github.com/Python-roborock/python-roborock/commit/158766fcb70b92aba87e8b7fe2255528fa72f123)) ### Features - Add networking function ([`19746aa`](https://github.com/Python-roborock/python-roborock/commit/19746aa7739da295c4e7c7316596af9f8ff6b0a0)) ## v0.4.16 (2023-04-05) ### Bug Fixes - Mapping prefix for all known commands ([`ad3afc0`](https://github.com/Python-roborock/python-roborock/commit/ad3afc04dfec31a20a4a2635b4c6b52cf236ce17)) ## v0.4.15 (2023-04-04) ### Bug Fixes - Test_get_washing_mode ([`17e72c3`](https://github.com/Python-roborock/python-roborock/commit/17e72c34c6ac133025450eab68f4be7025ab138b)) - **local_api**: Receiving multiple messages ([`e3c419c`](https://github.com/Python-roborock/python-roborock/commit/e3c419c98f64bc3adada4cc78ce4de366b5267cb)) ## v0.4.14 (2023-04-03) ### Bug Fixes - Adding is_valid function to RoborockBase ([`7575aee`](https://github.com/Python-roborock/python-roborock/commit/7575aeea3b1ca4cfe4a1fb0cb3cea29e964f52b7)) ## v0.4.13 (2023-04-03) ### Bug Fixes - Adiing broken pipe exception log ([`7e73eb2`](https://github.com/Python-roborock/python-roborock/commit/7e73eb2ac7b93f6d0d7331515cf9db5da2c92dc5)) ## v0.4.12 (2023-04-03) ### Bug Fixes - Add containers for dock information ([`77dc414`](https://github.com/Python-roborock/python-roborock/commit/77dc4146b16906807d8a5fbc5025c4a8344c62f0)) ### Chores - Add changelog ([`cc3f378`](https://github.com/Python-roborock/python-roborock/commit/cc3f378d9427c95a66ecdd5c1277a7415e322850)) - Pypi cleanup ([`1878e8e`](https://github.com/Python-roborock/python-roborock/commit/1878e8e42692a2f56679fbdd667da29dfcf759e3)) ## v0.4.11 (2023-04-01) ### Bug Fixes - Changing RoborockDeviceInfo to serializable ([`6dd8ff8`](https://github.com/Python-roborock/python-roborock/commit/6dd8ff8e622d5021e20caf19d36812e34e6c435f)) ## v0.4.10 (2023-04-01) ### Bug Fixes - Using entire object for roborock device info ([`599d461`](https://github.com/Python-roborock/python-roborock/commit/599d461af69c7d6b220973c5d905decc5657ce0f)) ## v0.4.9 (2023-04-01) ### Bug Fixes - Cloud_api.py ([`39fd964`](https://github.com/Python-roborock/python-roborock/commit/39fd964a9ccd0a33310747d6f7d764db1b7c3c23)) ## v0.4.8 (2023-04-01) ### Bug Fixes - Refactor roborock device info ([`291a6b2`](https://github.com/Python-roborock/python-roborock/commit/291a6b295943d6635116e79f7f56c97a553a7c62)) ## v0.4.7 (2023-04-01) ### Bug Fixes - Local_api should receive ip for each device ([`b2f2f15`](https://github.com/Python-roborock/python-roborock/commit/b2f2f1566a27505ebf456aef360b76d001a1351c)) ## v0.4.6 (2023-04-01) ### Bug Fixes - Adding local_api disconnection ([`a010304`](https://github.com/Python-roborock/python-roborock/commit/a01030480353b8d6524c71e463455802082f4066)) - Move add_status_listener from cloud_api to base_api ([`dcad915`](https://github.com/Python-roborock/python-roborock/commit/dcad91545ba18e163ba4ceca887065817b0a4e0c)) ## v0.4.5 (2023-04-01) ### Bug Fixes - Close socket on broken pipe ([`bf8c8d5`](https://github.com/Python-roborock/python-roborock/commit/bf8c8d52b390b27b442a3b7dd046f8ece483bc2e)) ### Chores - Fix cloud_api.py ([`b954c9c`](https://github.com/Python-roborock/python-roborock/commit/b954c9c22977b8239b034e346292a23afe5acbfb)) ## v0.4.4 (2023-04-01) ### Bug Fixes - Removing local_api.py nonworking commands from api.py ([`12bf756`](https://github.com/Python-roborock/python-roborock/commit/12bf756d8d5193bd4cfd9b59d85f11ec3ad4f6e0)) ### Chores - Add new commands ([`e0869cf`](https://github.com/Python-roborock/python-roborock/commit/e0869cf83e87d4c35986acdddf25f650acbd92ee)) - Removing local_api.py nonworking commands from api.py ([`70c04a3`](https://github.com/Python-roborock/python-roborock/commit/70c04a32878cb98c1e009860f2b6d8ede83a6e47)) ## v0.4.3 (2023-04-01) ### Bug Fixes - Minor fixes ([`29bdb45`](https://github.com/Python-roborock/python-roborock/commit/29bdb4542e1c32b956ea8b739f9a610b92e27259)) ## v0.4.2 (2023-04-01) ### Bug Fixes - Refactoring api ([`aa66e1d`](https://github.com/Python-roborock/python-roborock/commit/aa66e1d31ed635690104f9b30b62421e8a2ba663)) ## v0.4.1 (2023-03-31) ### Bug Fixes - Code cleaning ([`d6e3b34`](https://github.com/Python-roborock/python-roborock/commit/d6e3b34bfa5e1803b5e5e494711e56b7d909f1ea)) ## v0.4.0 (2023-03-31) ### Features - Sppliting clients into local and cloud ([`8019313`](https://github.com/Python-roborock/python-roborock/commit/8019313ccb50233610b74d2626ae87e79f55204e)) ## v0.3.1 (2023-03-30) ### Bug Fixes - Minor fixes to offline integration ([`1b4926e`](https://github.com/Python-roborock/python-roborock/commit/1b4926e1d79401f21bee68e4676235426e253191)) ## v0.3.0 (2023-03-30) ### Features - Adding offline.py for others to test local api ([`22680bf`](https://github.com/Python-roborock/python-roborock/commit/22680bfd7929d77b12c27c270478c3253d0cfada)) ## v0.2.3 (2023-03-29) ### Bug Fixes - Bug with dock commands ([`2f2cfb6`](https://github.com/Python-roborock/python-roborock/commit/2f2cfb6b702b6a6f9500e3b272761962ed15ed09)) ## v0.2.2 (2023-03-28) ### Bug Fixes - Change semantic_release from tag_only to tag ([`cad8973`](https://github.com/Python-roborock/python-roborock/commit/cad897381515530ba221b2f92a75ebb3fde876bd)) ## v0.2.1 (2023-03-28) ### Bug Fixes - Repository variable for python-semantic-release ([`b9e21a3`](https://github.com/Python-roborock/python-roborock/commit/b9e21a3d2f5db0a426b96031e154a2a001bc3242)) ## v0.2.0 (2023-03-28) ### Bug Fixes - Add version source ([`c46e503`](https://github.com/Python-roborock/python-roborock/commit/c46e503b91159468e7cf4afb9549c720c1d3dee0)) - Change github token from user defined secret to default secret ([`5886535`](https://github.com/Python-roborock/python-roborock/commit/58865350d583ffa1c4e00a2c22c12b8cf60d3c5f)) - Change to timeout from wait_for ([`eaa4dee`](https://github.com/Python-roborock/python-roborock/commit/eaa4dee1dca696a5817205cd4387b92ce93df0bf)) wait_for creates a task, async_timeout does the same work and avoids the task creation - Removed unneeded line ([`f2b4c89`](https://github.com/Python-roborock/python-roborock/commit/f2b4c89500ac169e9dc021de6e250474f6f75b15)) - Rename github_token to gh_token ([`012cd9d`](https://github.com/Python-roborock/python-roborock/commit/012cd9d0ec065d78063472dc66e60e9545547e24)) - Version source from pyproject.toml ([`20d3c59`](https://github.com/Python-roborock/python-roborock/commit/20d3c59bab6fee2093b892cdc062f929a2b83304)) ### Chores - Add typing to user_data property ([`16f1d5d`](https://github.com/Python-roborock/python-roborock/commit/16f1d5dc10123987ee480bc4696a9a80a5bbe376)) - Added some typing ([`3a72b58`](https://github.com/Python-roborock/python-roborock/commit/3a72b58273d80f0a5d8d8da473e2b0e16aeea722)) - Added typing for containers ([`be20ae1`](https://github.com/Python-roborock/python-roborock/commit/be20ae1fb8c3055b54de083b542cee86874ba9f7)) - Bump pycryptodome to 3.17 ([`1931073`](https://github.com/Python-roborock/python-roborock/commit/193107361f81706e2a67b9558b9e0ad56607166b)) - Bump version ([`33ab4d1`](https://github.com/Python-roborock/python-roborock/commit/33ab4d1523aa21dc692685cd109f878888ee4d78)) - Fix tests with new code mapping ([`4dac8f5`](https://github.com/Python-roborock/python-roborock/commit/4dac8f5ced0dbe0c948a8e8ca335d05f39b27634)) - Moved code mappings to api ([`81bf2e2`](https://github.com/Python-roborock/python-roborock/commit/81bf2e24342dd0b5c1fee3d0c32c38cf4791f7d0)) ### Features - Add dock error mapping ([`4694c66`](https://github.com/Python-roborock/python-roborock/commit/4694c661edaa09a2f637a4ad2191a3b587613ffb)) - Added semantic release ([`2bb2279`](https://github.com/Python-roborock/python-roborock/commit/2bb2279187609a7a7cf4c1a854ede54e8a671860)) - Adding more options to commands ([`9b20345`](https://github.com/Python-roborock/python-roborock/commit/9b203456c3bd5e075e2945be24e1aa65620af12f)) Python-roborock-python-roborock-d6da2db/CONTRIBUTING.md000066400000000000000000000060741513363643200227640ustar00rootroot00000000000000# Contributing to python-roborock Thank you for your interest in contributing to `python-roborock`! We welcome contributions from the community. ## Getting Started 1. **Fork the repository** on GitHub. 2. **Clone your fork** locally: ```bash git clone https://github.com/your-username/python-roborock.git cd python-roborock ``` 3. **Set up your environment**. This project typically tries to stay on the most recent python versions (e.g. latest 2 or 3 versions). We use `uv` for dependency management: ```bash # Create virtual environment and install dependencies uv venv uv sync ``` 4. **Activate the virtual environment**. This is required for running `pre-commit` hooks and `pytest`: ```bash source .venv/bin/activate ``` 5. **Install pre-commit hooks**. This ensures your code meets our quality standards. Once installed, these hooks run automatically on staged files when you commit: ```bash pre-commit install ``` ## Development Workflow ### Code Style We use several tools to enforce code quality and consistency. These are configured via `pre-commit` and generally run automatically. * **Ruff**: Used for linting and formatting. * **Mypy**: Used for static type checking. * **Codespell**: Checks for common misspellings. You can verify your changes manually before committing (checks all files): ```bash # Run all pre-commit hooks pre-commit run --all-files ``` ### Testing We use `pytest` for testing. Please ensure all tests pass and add new tests for your changes. ```bash # Run tests pytest ``` ## Pull Requests 1. **Create a branch** for your changes. 2. **Make your changes**. Keep your changes focused and atomic. 3. **Commit your changes**. * **Important**: We use [Conventional Commits](https://www.conventionalcommits.org/). Please format your commit messages accordingly (e.g., `feat: add new vacuum model`, `fix: handle connection timeout`). This is required for our automated release process. * Allowed types: `chore`, `docs`, `feat`, `fix`, `refactor`. 4. **Push to your fork** and submit a **Pull Request**. ## Adding New Devices or Features If you are adding support for a new device or feature, please follow these steps: 1. **Update Device Info**: Use the CLI to discover and fetch device features. ```bash roborock get-device-info ``` Arguments and output will be printed to the console. **Manually copy the YAML output** from this command into the `device_info.yaml` file. 2. **Add Test Data**: * **Home API Data**: Capture device information from Home API responses and save as `tests/testdata/home_data_.json`. This helps test device discovery and initialization. * **Protocol/Feature Data**: Capture actual device responses or protocol data. You can often see these messages in the DEBUG logs when interacting with the device. Create JSON files in `tests/protocols/testdata/` that reflect these responses. This ensures protocol parsing works correctly against real-world data. ## Code of Conduct Please be respectful and considerate in your interactions. Python-roborock-python-roborock-d6da2db/LICENSE000066400000000000000000001045151513363643200215370ustar00rootroot00000000000000 GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . Python-roborock-python-roborock-d6da2db/README.md000066400000000000000000000073231513363643200220100ustar00rootroot00000000000000# Roborock

PyPI Version Supported Python versions License Code Coverage

Roborock library for online and offline control of your vacuums. ## Installation Install this via pip (or your favourite package manager): `pip install python-roborock` ## Example Usage See [examples/example.py](examples/example.py) for a more full featured example, or the [API documentation](https://python-roborock.github.io/python-roborock/) for more details. Here is a basic example: ```python import asyncio from roborock.web_api import RoborockApiClient from roborock.devices.device_manager import create_device_manager, UserParams async def main(): email_address = "youremailhere@example.com" web_api = RoborockApiClient(username=email_address) # Send a login code to the above email address await web_api.request_code() # Prompt the user to enter the code code = input("What is the code?") user_data = await web_api.code_login(code) # Create a device manager that can discover devices. user_params = UserParams(username=email_address, user_data=user_data) device_manager = await create_device_manager(user_params) devices = await device_manager.get_devices() # Get all vacuum devices. Each device generation has different capabilities # and APIs available so to find vacuums we filter by the v1 PropertiesApi. for device in devices: if not device.v1_properties: continue # The PropertiesAPI has traits different device commands such as getting # status, sending clean commands, etc. For this example we send a # command to refresh the current device status. status_trait = device.v1_properties.status await status_trait.refresh() print(status_trait) asyncio.run(main()) ``` ## Functionality The library interacts with devices through specific API properties based on the device protocol: * **Standard Vacuums (V1 Protocol)**: Most robot vacuums use this. Interaction is done through `device.v1_properties`, which contains traits like `status`, `consumables`, and `maps`. Use the `command` trait for actions like starting or stopping cleaning. * **Wet/Dry Vacuums & Washing Machines (A01 Protocol)**: Devices like the Dyad and Zeo use this. Interaction is done through `device.a01_properties` using `query_values()` and `set_value()`. You can find detailed documentation for [Devices](https://python-roborock.github.io/python-roborock/roborock/devices/device.html) and [Traits](https://python-roborock.github.io/python-roborock/roborock/devices/traits.html). ## Supported devices You can find what devices are supported [here](https://python-roborock.readthedocs.io/en/latest/supported_devices.html). Please note this may not immediately contain the latest devices. ## Acknowledgements * Thanks to [@rovo89](https://github.com/rovo89) for [Login APIs gist](https://gist.github.com/rovo89/dff47ed19fca0dfdda77503e66c2b7c7). * Thanks to [@PiotrMachowski](https://github.com/PiotrMachowski) for [Home-Assistant-custom-components-Xiaomi-Cloud-Map-Extractor](https://github.com/PiotrMachowski/Home-Assistant-custom-components-Xiaomi-Cloud-Map-Extractor). Python-roborock-python-roborock-d6da2db/SUPPORTED_FEATURES.md000066400000000000000000000207531513363643200237400ustar00rootroot00000000000000| Feature | roborock.vacuum.a15 | roborock.vacuum.a87 | roborock.vacuum.s5e | |---|---|---|---| | Product Nickname | TANOSS | PEARLPLUS | RUBYSLITE | | Protocol Version | 1.0 | 1.0 | 1.0 | | New Feature Info | 636084721975295 | 4499197267967999 | 633887780925447 | | New Feature Info Str | 0000000000002000 | 508A977F7EFEFFFF | 0000000000002000 | | `111` | X | X | X | | `112` | X | X | X | | `113` | X | X | X | | `114` | X | X | X | | `115` | X | X | X | | `116` | X | X | X | | `117` | X | X | X | | `118` | X | X | X | | `119` | X | X | X | | `120` | X | X | X | | `121` | | X | | | `122` | X | X | X | | `123` | X | X | X | | `124` | X | X | X | | `125` | X | X | X | | `is_activate_video_charging_and_standby_supported` | | | | | `is_analysis_supported` | X | X | X | | `is_any_state_transit_goto_supported` | X | X | | | `is_auto_collection_2_supported` | | | | | `is_auto_delivery_field_in_global_status_supported` | | X | | | `is_auto_tear_down_mop_supported` | | | | | `is_avoid_collision_mode_supported` | | X | | | `is_avoid_collision_supported` | | X | | | `is_back_charge_auto_wash_supported` | | X | | | `is_back_wash_new_smart_supported` | | | | | `is_careful_slow_mop_supported` | X | X | | | `is_carpet_custom_clean_supported` | | X | | | `is_carpet_deep_clean_supported` | | X | | | `is_carpet_long_haired_ex_supported` | | | | | `is_carpet_long_haired_supported` | | | | | `is_carpet_pressure_use_origin_paras_supported` | | | | | `is_carpet_shape_type_supported` | | | | | `is_carpet_show_on_map` | | X | | | `is_carpet_supported` | X | X | | | `is_ces_2022_supported` | | | | | `is_clean_count_setting_supported` | | X | | | `is_clean_direct_status_supported` | | | | | `is_clean_efficiency_supported` | | | | | `is_clean_fluid_delivery_supported` | | | | | `is_clean_history_time_line_supported` | | | | | `is_clean_route_deep_slow_plus_supported` | | | | | `is_clean_route_fast_mode_supported` | | X | | | `is_clean_route_setting_supported` | | | | | `is_clean_then_mop_mode_supported` | | | | | `is_clean_time_line_supported` | | | | | `is_collect_dust_count_show_supported` | | | | | `is_collect_dust_mode_supported` | X | X | X | | `is_corner_clean_mode_supported` | | | | | `is_corner_mop_stretch_supported` | | X | | | `is_ctm_with_repeat_supported` | | | | | `is_current_map_restore_enabled` | X | X | X | | `is_custom_clean_mode_count_supported` | | X | | | `is_custom_mode_supported` | X | X | X | | `is_custom_water_box_distance_supported` | | X | X | | `is_customized_clean_supported` | | | | | `is_detect_wire_carpet_supported` | | | | | `is_dirty_object_detect_supported` | | | | | `is_dirty_replenish_clean_supported` | | X | | | `is_dry_interval_timer_supported` | | | | | `is_dss_believable` | | X | | | `is_dual_band_wi_fi_supported` | | | | | `is_dust_collection_setting_supported` | X | X | | | `is_dynamically_add_clean_zones_supported` | | X | | | `is_dynamically_skip_clean_zone_supported` | | X | | | `is_egg_dance_mode_supported` | | | | | `is_egg_mode_supported_from_new_features` | | | | | `is_exact_custom_mode_supported` | | X | | | `is_exhibition_function_supported` | | | | | `is_floor_dir_clean_any_time_supported` | | X | | | `is_flow_led_setting_supported` | X | | | | `is_follow_low_obs_supported` | | | | | `is_full_duples_switch_supported` | | | | | `is_fw_filter_obstacle_supported` | X | X | | | `is_gap_deep_clean_supported` | | | | | `is_goto_pure_clean_path_supported` | | X | | | `is_hot_wash_towel_supported` | | X | | | `is_identify_room_supported` | | | | | `is_ignore_unknown_map_object_supported` | X | X | | | `is_lds_lifting_supported` | | | | | `is_led_status_switch_supported` | X | X | X | | `is_left_water_drain_supported` | | X | | | `is_low_area_access_supported` | | | | | `is_main_brush_up_down_supported_from_str` | | X | | | `is_map_beautify_internal_debug_supported` | X | X | | | `is_map_carpet_add_support` | | X | | | `is_map_eraser_supported` | | | | | `is_matter_supported` | | | | | `is_max_plus_mode_supported` | | | | | `is_max_zone_opened_supported` | | | | | `is_mechanical_arm_mode_supported` | | | | | `is_midway_back_to_dock_supported` | | | | | `is_min_battery_15_to_clean_task_supported` | | X | | | `is_mop_forbidden_supported` | | | | | `is_mop_path_supported` | X | X | | | `is_mop_shake_module_supported` | | | | | `is_mop_shake_water_max_supported` | | | | | `is_multi_floor_supported` | X | X | X | | `is_multi_map_segment_timer_supported` | X | X | X | | `is_new_ai_recognition_supported` | | | | | `is_new_data_for_clean_history` | X | X | | | `is_new_data_for_clean_history_detail` | X | X | | | `is_new_endpoint_supported` | | X | | | `is_new_remote_view_supported` | | | | | `is_no_need_carpet_press_set_supported` | | | | | `is_none_pure_clean_mop_with_max_plus` | | | | | `is_object_detect_check_supported` | | | | | `is_offline_map_supported` | | X | | | `is_optimize_battery_supported` | | | | | `is_order_clean_supported` | X | X | X | | `is_over_sea_ctm_supported` | | | | | `is_pet_snapshot_supported` | | | | | `is_pet_supplies_deep_clean_supported` | | | | | `is_program_mode_supported` | | | | | `is_pumping_water_supported` | | | | | `is_pure_clean_mop_supported` | | | | | `is_re_segment_supported` | X | X | X | | `is_record_allowed` | X | X | | | `is_remote_supported` | X | X | X | | `is_right_brush_stretch_supported` | | | | | `is_room_name_supported` | X | X | X | | `is_rpc_retry_supported` | | X | | | `is_rubber_brush_carpet_supported` | | | | | `is_set_child_supported` | X | X | | | `is_setting_carpet_first_supported` | | X | | | `is_shake_mop_set_supported` | X | X | | | `is_should_show_arm_over_load_supported` | | | | | `is_show_clean_finish_reason_supported` | X | X | X | | `is_show_general_obstacle_supported` | | | | | `is_show_obstacle_photo_supported` | | | | | `is_side_brush_lift_carpet_supported` | | | | | `is_small_side_mop_supported` | | | | | `is_smart_clean_mode_set_supported` | | X | | | `is_soak_and_wash_supported` | | | | | `is_soft_clean_mode_supported` | | | | | `is_sr_map_supported` | | | | | `is_super_deep_wash_supported` | | X | | | `is_support_api_app_stop_grasp_supported` | | | | | `is_support_backup_map` | X | X | X | | `is_support_clean_estimate` | | X | | | `is_support_cliff_zone` | | X | | | `is_support_custom_carpet` | | | | | `is_support_custom_dnd` | | X | | | `is_support_custom_door_sill` | | X | | | `is_support_custom_mode_in_cleaning` | | X | | | `is_support_fetch_timer_summary` | X | X | X | | `is_support_floor_direction` | | X | | | `is_support_floor_edit` | | X | | | `is_support_furniture` | | X | | | `is_support_get_particular_status_supported` | | | | | `is_support_incremental_map` | | X | | | `is_support_main_brush_up_down_supported` | | | | | `is_support_mop_back_pwm_set` | | | | | `is_support_quick_map_builder` | X | X | X | | `is_support_remote_control_in_call` | | X | | | `is_support_room_tag` | | X | | | `is_support_set_switch_map_mode` | | X | | | `is_support_set_volume_in_call` | | X | | | `is_support_side_brush_up_down_supported` | | | | | `is_support_smart_door_sill` | | X | | | `is_support_smart_global_clean_with_custom_mode` | | X | | | `is_support_smart_scene` | | X | | | `is_support_stuck_zone` | | X | | | `is_support_voice_control_debug` | | | | | `is_support_water_mode` | | | | | `is_supported_download_test_voice` | | X | | | `is_supported_drying` | | X | | | `is_supported_valley_electricity` | | X | | | `is_sync_server_name_supported` | | | | | `is_three_d_mapping_inner_test_supported` | | | | | `is_tidyup_zones_supported` | | | | | `is_two_gears_no_collision_supported` | | | | | `is_two_key_real_time_video_supported` | | X | | | `is_two_key_rtv_in_charging_supported` | | X | | | `is_type_identify_supported` | | | | | `is_unsave_map_reason_supported` | X | X | X | | `is_uvc_sterilize_supported` | | | | | `is_video_monitor_supported` | X | X | | | `is_video_patrol_supported` | | | | | `is_video_setting_supported` | X | X | | | `is_voice_control_led_supported` | | | | | `is_voice_control_supported` | | X | | | `is_wash_then_charge_cmd_supported` | | X | | | `is_water_leak_check_supported` | | X | | | `is_water_slide_mode_supported` | | | | | `is_water_up_down_drain_supported` | | X | | | `is_wifi_manage_supported` | | X | | | `is_workday_holiday_supported` | | | | Python-roborock-python-roborock-d6da2db/commitlint.config.mjs000066400000000000000000000007411513363643200246640ustar00rootroot00000000000000export default { extends: ["@commitlint/config-conventional"], ignores: [ (msg) => /Signed-off-by: dependabot\[bot]/m.test(msg), (msg) => /Co-authored-by:.*Copilot/m.test(msg) ], rules: { // Disable the rule that enforces lowercase in subject "subject-case": [0], // 0 = disable, 1 = warn, 2 = error // Disable the rule that enforces a maximum line length in the body "body-max-line-length": [0, "always"] }, }; Python-roborock-python-roborock-d6da2db/device_info.yaml000066400000000000000000000303241513363643200236640ustar00rootroot00000000000000roborock.vacuum.a117: protocol_version: '1.0' product_nickname: PEARLPLUSS new_feature_info: 4499197267967999 new_feature_info_str: 000000000000000BC2FF8F7F7EFEFFFF feature_info: - 111 - 112 - 113 - 114 - 115 - 116 - 117 - 118 - 119 - 120 - 121 - 122 - 123 - 124 - 125 product: id: 3hVxBJoGbDP2kv93Pcc1pb name: Roborock Qrevo Master model: roborock.vacuum.a117 category: robot.vacuum.cleaner capability: 0 schema: - id: 101 name: rpc_request code: rpc_request mode: rw type: RAW - id: 102 name: rpc_response code: rpc_response mode: rw type: RAW - id: 120 name: "\u9519\u8BEF\u4EE3\u7801" code: error_code mode: ro type: ENUM property: '{"range": [""]}' - id: 121 name: "\u8BBE\u5907\u72B6\u6001" code: state mode: ro type: ENUM property: '{"range": [""]}' - id: 122 name: "\u8BBE\u5907\u7535\u91CF" code: battery mode: ro type: ENUM property: '{"range": [""]}' - id: 123 name: "\u6E05\u626B\u6A21\u5F0F" code: fan_power mode: rw type: ENUM property: '{"range": [""]}' - id: 124 name: "\u62D6\u5730\u6A21\u5F0F" code: water_box_mode mode: rw type: ENUM property: '{"range": [""]}' - id: 125 name: "\u4E3B\u5237\u5BFF\u547D" code: main_brush_life mode: rw type: VALUE property: '{"max": 100, "min": 0, "step": 1, "unit": "null", "scale": 1}' - id: 126 name: "\u8FB9\u5237\u5BFF\u547D" code: side_brush_life mode: rw type: VALUE property: '{"max": 100, "min": 0, "step": 1, "unit": "null", "scale": 1}' - id: 127 name: "\u6EE4\u7F51\u5BFF\u547D" code: filter_life mode: rw type: VALUE property: '{"max": 100, "min": 0, "step": 1, "unit": "null", "scale": 1}' - id: 128 name: "\u989D\u5916\u72B6\u6001" code: additional_props mode: ro type: RAW - id: 130 name: "\u5B8C\u6210\u4E8B\u4EF6" code: task_complete mode: ro type: RAW - id: 131 name: "\u7535\u91CF\u4E0D\u8DB3\u4EFB\u52A1\u53D6\u6D88" code: task_cancel_low_power mode: ro type: RAW - id: 132 name: "\u8FD0\u52A8\u4E2D\u4EFB\u52A1\u53D6\u6D88" code: task_cancel_in_motion mode: ro type: RAW - id: 133 name: "\u5145\u7535\u72B6\u6001" code: charge_status mode: ro type: RAW - id: 134 name: "\u70D8\u5E72\u72B6\u6001" code: drying_status mode: ro type: RAW - id: 135 name: "\u79BB\u7EBF\u539F\u56E0\u7EC6\u5206" code: offline_status mode: ro type: RAW roborock.vacuum.a15: protocol_version: '1.0' product_nickname: TANOSS new_feature_info: 636084721975295 new_feature_info_str: '0000000000002000' feature_info: - 111 - 112 - 113 - 114 - 115 - 116 - 117 - 118 - 119 - 120 - 122 - 123 - 124 - 125 product: id: 1YYW18rpgyAJTISwb1NM91 name: S7 model: roborock.vacuum.a15 category: robot.vacuum.cleaner capability: 0 schema: - id: 101 name: rpc_request code: rpc_request mode: rw type: RAW - id: 102 name: rpc_response code: rpc_response mode: rw type: RAW - id: 120 name: "\u9519\u8BEF\u4EE3\u7801" code: error_code mode: ro type: ENUM property: '{"range": []}' - id: 121 name: "\u8BBE\u5907\u72B6\u6001" code: state mode: ro type: ENUM property: '{"range": []}' - id: 122 name: "\u8BBE\u5907\u7535\u91CF" code: battery mode: ro type: ENUM property: '{"range": []}' - id: 123 name: "\u6E05\u626B\u6A21\u5F0F" code: fan_power mode: rw type: ENUM property: '{"range": []}' - id: 124 name: "\u62D6\u5730\u6A21\u5F0F" code: water_box_mode mode: rw type: ENUM property: '{"range": []}' - id: 125 name: "\u4E3B\u5237\u5BFF\u547D" code: main_brush_life mode: rw type: VALUE property: '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}' - id: 126 name: "\u8FB9\u5237\u5BFF\u547D" code: side_brush_life mode: rw type: VALUE property: '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}' - id: 127 name: "\u6EE4\u7F51\u5BFF\u547D" code: filter_life mode: rw type: VALUE property: '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}' - id: 128 name: "\u989D\u5916\u72B6\u6001" code: additional_props mode: ro type: RAW - id: 130 name: "\u5B8C\u6210\u4E8B\u4EF6" code: task_complete mode: ro type: RAW - id: 131 name: "\u7535\u91CF\u4E0D\u8DB3\u4EFB\u52A1\u53D6\u6D88" code: task_cancel_low_power mode: ro type: RAW - id: 132 name: "\u8FD0\u52A8\u4E2D\u4EFB\u52A1\u53D6\u6D88" code: task_cancel_in_motion mode: ro type: RAW - id: 133 name: "\u5145\u7535\u72B6\u6001" code: charge_status mode: ro type: RAW - id: 134 name: "\u70D8\u5E72\u72B6\u6001" code: drying_status mode: ro type: RAW - id: 135 name: "\u79BB\u7EBF\u539F\u56E0\u7EC6\u5206" code: offline_status mode: ro type: RAW roborock.vacuum.a87: protocol_version: '1.0' product_nickname: PEARLPLUS new_feature_info: 4499197267967999 new_feature_info_str: 508A977F7EFEFFFF feature_info: - 111 - 112 - 113 - 114 - 115 - 116 - 117 - 118 - 119 - 120 - 121 - 122 - 123 - 124 - 125 product: id: 5gUei3OIJIXVD3eD85Balg name: Roborock Qrevo MaxV model: roborock.vacuum.a87 category: robot.vacuum.cleaner capability: 0 schema: - id: 101 name: rpc_request code: rpc_request mode: rw type: RAW - id: 102 name: rpc_response code: rpc_response mode: rw type: RAW - id: 120 name: "\u9519\u8BEF\u4EE3\u7801" code: error_code mode: ro type: ENUM property: '{"range": [""]}' - id: 121 name: "\u8BBE\u5907\u72B6\u6001" code: state mode: ro type: ENUM property: '{"range": [""]}' - id: 122 name: "\u8BBE\u5907\u7535\u91CF" code: battery mode: ro type: ENUM property: '{"range": [""]}' - id: 123 name: "\u6E05\u626B\u6A21\u5F0F" code: fan_power mode: rw type: ENUM property: '{"range": [""]}' - id: 124 name: "\u62D6\u5730\u6A21\u5F0F" code: water_box_mode mode: rw type: ENUM property: '{"range": [""]}' - id: 125 name: "\u4E3B\u5237\u5BFF\u547D" code: main_brush_life mode: rw type: VALUE property: '{"max": 100, "min": 0, "step": 1, "unit": "null", "scale": 1}' - id: 126 name: "\u8FB9\u5237\u5BFF\u547D" code: side_brush_life mode: rw type: VALUE property: '{"max": 100, "min": 0, "step": 1, "unit": "null", "scale": 1}' - id: 127 name: "\u6EE4\u7F51\u5BFF\u547D" code: filter_life mode: rw type: VALUE property: '{"max": 100, "min": 0, "step": 1, "unit": "null", "scale": 1}' - id: 128 name: "\u989D\u5916\u72B6\u6001" code: additional_props mode: ro type: RAW - id: 130 name: "\u5B8C\u6210\u4E8B\u4EF6" code: task_complete mode: ro type: RAW - id: 131 name: "\u7535\u91CF\u4E0D\u8DB3\u4EFB\u52A1\u53D6\u6D88" code: task_cancel_low_power mode: ro type: RAW - id: 132 name: "\u8FD0\u52A8\u4E2D\u4EFB\u52A1\u53D6\u6D88" code: task_cancel_in_motion mode: ro type: RAW - id: 133 name: "\u5145\u7535\u72B6\u6001" code: charge_status mode: ro type: RAW - id: 134 name: "\u70D8\u5E72\u72B6\u6001" code: drying_status mode: ro type: RAW - id: 135 name: "\u79BB\u7EBF\u539F\u56E0\u7EC6\u5206" code: offline_status mode: ro type: RAW roborock.vacuum.s5e: protocol_version: '1.0' product_nickname: RUBYSLITE new_feature_info: 633887780925447 new_feature_info_str: '0000000000002000' feature_info: - 111 - 112 - 113 - 114 - 115 - 116 - 117 - 118 - 119 - 120 - 122 - 123 - 124 - 125 roborock.vacuum.sc05: protocol_version: B01 product_nickname: PEARLPLUS product: id: 5ayEx3aKgStqZZ0v5IpMBP name: Roborock Q7 Series model: roborock.vacuum.sc05 category: robot.vacuum.cleaner capability: 0 schema: - id: 101 name: RPC Request code: rpc_request mode: rw type: RAW property: 'null' - id: 102 name: RPC Response code: rpc_response mode: rw type: RAW property: 'null' - id: 120 name: "\u9519\u8BEF\u4EE3\u7801" code: error_code mode: ro type: ENUM property: 'null' - id: 121 name: "\u8BBE\u5907\u72B6\u6001" code: state mode: ro type: VALUE property: 'null' - id: 122 name: "\u8BBE\u5907\u7535\u91CF" code: battery mode: ro type: ENUM property: 'null' - id: 123 name: "\u5438\u529B\u6863\u4F4D" code: fan_power mode: rw type: ENUM property: 'null' - id: 124 name: "\u62D6\u5730\u6863\u4F4D" code: water_box_mode mode: rw type: RAW property: 'null' - id: 125 name: "\u4E3B\u5237\u5BFF\u547D" code: main_brush_life mode: ro type: ENUM property: 'null' - id: 126 name: "\u8FB9\u5237\u5BFF\u547D" code: side_brush_life mode: ro type: ENUM property: 'null' - id: 127 name: "\u6EE4\u7F51\u5BFF\u547D" code: filter_life mode: ro type: ENUM property: 'null' - id: 135 name: "\u79BB\u7EBF\u539F\u56E0" code: offline_status mode: ro type: ENUM property: 'null' - id: 136 name: "\u6E05\u6D01\u6B21\u6570" code: clean_times mode: rw type: ENUM property: 'null' - id: 137 name: "\u626B\u62D6\u6A21\u5F0F" code: cleaning_preference mode: rw type: ENUM property: 'null' - id: 138 name: "\u6E05\u6D01\u4EFB\u52A1\u7C7B\u578B" code: clean_task_type mode: ro type: ENUM property: 'null' - id: 139 name: "\u8FD4\u56DE\u57FA\u7AD9\u7C7B\u578B" code: back_type mode: ro type: ENUM property: 'null' - id: 141 name: "\u6E05\u6D01\u8FDB\u5EA6" code: cleaning_progress mode: ro type: ENUM property: 'null' - id: 142 name: "\u7A9C\u8D27\u4FE1\u606F" code: fc_state mode: ro type: RAW property: 'null' - id: 201 name: "\u542F\u52A8\u6E05\u6D01\u4EFB\u52A1" code: start_clean_task mode: wo type: ENUM property: 'null' - id: 202 name: "\u8FD4\u56DE\u57FA\u7AD9\u4EFB\u52A1" code: start_back_dock_task mode: wo type: ENUM property: 'null' - id: 203 name: "\u542F\u52A8\u57FA\u7AD9\u4EFB\u52A1" code: start_dock_task mode: wo type: ENUM property: 'null' - id: 204 name: "\u6682\u505C\u4EFB\u52A1" code: pause mode: wo type: RAW property: 'null' - id: 205 name: "\u7EE7\u7EED\u4EFB\u52A1" code: resume mode: wo type: RAW property: 'null' - id: 206 name: "\u7ED3\u675F\u4EFB\u52A1" code: stop mode: wo type: RAW property: 'null' - id: 10000 name: request_cmd code: request_cmd mode: wo type: RAW property: 'null' - id: 10001 name: response_cmd code: response_cmd mode: ro type: RAW property: 'null' - id: 10002 name: request_map code: request_map mode: ro type: RAW property: 'null' - id: 10003 name: response_map code: response_map mode: ro type: RAW property: 'null' - id: 10004 name: event_report code: event_report mode: rw type: RAW property: 'null' Python-roborock-python-roborock-d6da2db/docs/000077500000000000000000000000001513363643200214545ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/docs/DEVICES.md000066400000000000000000000611771513363643200230740ustar00rootroot00000000000000# Roborock Devices & Discovery The devices module provides functionality to discover Roborock devices on the network. This section documents the full lifecycle of device discovery across Cloud and Network. ## Usage TL;DR * **Discovery**: Use `roborock.devices.device_manager.DeviceManager` to get device instances. * Call `create_device_manager(user_params)` then `await device_manager.get_devices()`. * **Control**: * **Vacuums (V1)**: Use `device.v1_properties` to access traits like `status` or `consumables`. * Call `await trait.refresh()` to update state. * Use `device.v1_properties.command.send()` for raw commands (start/stop). * **Washers (A01)**: Use `device.a01_properties` for Dyad/Zeo devices. * Use `await device.a01_properties.query_values([...])` to get state. * Use `await device.a01_properties.set_value(protocol, value)` to control. ## Background: Understanding Device Protocols **The library supports three device protocol versions, each with different capabilities:** ### Protocol Summary | Protocol | Device Examples | MQTT | Local TCP | Channel Type | Notes | |----------|----------------|------|-----------|--------------|-------| | **V1** (`pv=1.0`) | Most vacuum robots (S7, S8, Q5, Q7, etc.) | ✅ | ✅ | `V1Channel` with `RpcChannel` | Prefers local, falls back to MQTT | | **A01** (`pv=A01`) | Dyad, Zeo washers | ✅ | ❌ | `MqttChannel` + helpers | MQTT only, DPS protocol | | **B01** (`pv=B01`) | Some newer models | ✅ | ❌ | `MqttChannel` + helpers | MQTT only, DPS protocol | **Key Point:** The `DeviceManager` automatically detects the protocol version and creates the appropriate channel type. You don't need to handle this manually. ## Internal Architecture The library is organized into distinct layers, each with a specific responsibility. **Different device protocols use different channel implementations:** ```mermaid graph TB subgraph "Application Layer" User[Application Code] end subgraph "Device Management Layer" DM[DeviceManager
Detects protocol version] end subgraph "Device Types by Protocol" V1Dev[V1 Devices
pv=1.0
Most vacuums] A01Dev[A01 Devices
pv=A01
Dyad, Zeo] B01Dev[B01 Devices
pv=B01
Some models] end subgraph "Traits Layer" V1Traits[V1 Traits
Clean, Map, etc.] A01Traits[A01 Traits
DPS-based] B01Traits[B01 Traits
DPS-based] end subgraph "Channel Layer" V1C[V1Channel
MQTT + Local] A01C[A01 send_decoded_command
MQTT only] B01C[B01 send_decoded_command
MQTT only] RPC[RpcChannel
Multi-strategy] MC[MqttChannel
Per-device wrapper] LC[LocalChannel
TCP :58867] end subgraph "Session Layer" MS[MqttSession
SHARED by all devices
Idle timeout] LS[LocalSession
Factory] end subgraph "Protocol Layer" V1P[V1 Protocol
JSON RPC + AES] A01P[A01 Protocol
DPS format] B01P[B01 Protocol
DPS format] end subgraph "Transport Layer" MQTT[MQTT Broker
Roborock Cloud] TCP[TCP Socket
Direct to device] end User --> DM DM -->|pv=1.0| V1Dev DM -->|pv=A01| A01Dev DM -->|pv=B01| B01Dev V1Dev --> V1Traits A01Dev --> A01Traits B01Dev --> B01Traits V1Traits --> V1C A01Traits --> A01C B01Traits --> B01C V1C --> RPC RPC -->|Strategy 1| LC RPC -->|Strategy 2| MC A01C --> MC B01C --> MC MC --> MS LC --> LS MC --> V1P MC --> A01P MC --> B01P LC --> V1P MS --> MQTT LC --> TCP MQTT <--> TCP style User fill:#e1f5ff style DM fill:#fff4e1 style V1C fill:#ffe1e1 style RPC fill:#ffe1e1 style MS fill:#e1ffe1 style V1P fill:#f0e1ff style A01P fill:#f0e1ff style B01P fill:#f0e1ff ``` ### Layer Responsibilities 1. **Device Management Layer**: Detects protocol version (`pv` field) and creates appropriate channels 2. **Device Types**: Different devices based on protocol version (V1, A01, B01) 3. **Traits Layer**: Protocol-specific device capabilities and commands 4. **Channel Layer**: Protocol-specific communication patterns - **V1**: Full RPC channel with local + MQTT fallback - **A01/B01**: Helper functions wrapping MqttChannel (MQTT only) - **MqttChannel**: Per-device wrapper that uses shared `MqttSession` 5. **Session Layer**: Connection pooling and subscription management - **MqttSession**: **Shared single connection** for all devices - **LocalSession**: Factory for creating device-specific local connections 6. **Protocol Layer**: Message encoding/decoding for different device versions 7. **Transport Layer**: Low-level MQTT and TCP communication **Important:** All `MqttChannel` instances share the same `MqttSession`, which maintains a single MQTT connection to the broker. This means: - Only one TCP connection to the MQTT broker regardless of device count - Subscription management is centralized with idle timeout optimization - All devices communicate through device-specific MQTT topics on the shared connection ### Protocol-Specific Architecture | Protocol | Channel Type | Local Support | RPC Strategy | Use Case | |----------|-------------|---------------|--------------|----------| | **V1** (`pv=1.0`) | `V1Channel` with `RpcChannel` | ✅ Yes | Multi-strategy (Local → MQTT) | Most vacuum robots | | **A01** (`pv=A01`) | `MqttChannel` + helpers | ❌ No | Direct MQTT | Dyad, Zeo washers | | **B01** (`pv=B01`) | `MqttChannel` + helpers | ❌ No | Direct MQTT | Some newer models | ## Account Setup Internals ### Login - Login can happen with either email and password or email and sending a code. We currently prefer email with sending a code -- however the roborock no longer supports this method of login. In the future we may want to migrate to password if this login method is no longer supported. - The Login API provides a `userData` object with information on connecting to the cloud APIs - This `rriot` data contains per-session information, unique each time you login. - This contains information used to connect to MQTT - You get an `-eu` suffix in the API URLs if you are in the eu and `-us` if you are in the us ## Home Data Internals The `HomeData` includes information about the various devices in the home. We use `v3` and it is notable that if devices don't show up in the `home_data` response it is likely that a newer version of the API should be used. - `products`: This is a list of all of the products you have on your account. These objects are always the same (i.e. a s7 maxv is always the exact same.) - It only shows the products for devices available on your account - `devices` and `received_devices`: - These both share the same objects, but one is for devices that have been shared with you and one is those that are on your account. - The big things here are (MOST are static): - `duid`: A unique identifier for your device (this is always the same i think) - `name`: The name of the device in your app - `local_key`: The local key that is needed for encoding and decoding messages for the device. This stays the same unless someone sets their vacuum back up. - `pv`: the protocol version (i.e. 1.0 or A1 or B1) - `product_id`: The id of the product from the above products list. - `device_status`: An initial status for some of the data we care about, though this changes on each update. - `rooms`: The rooms in the home. - This changes if the user adds a new room or changes its name. - We have to combine this with the room numbers from `GET_ROOM_MAPPING` on the device - There is another REST request `get_rooms` that will do the same thing. - Note: If we cache home_data, we likely need to use `get_rooms` to get rooms fresh ## Connection Implementation ### Connection Flow by Protocol The connection flow differs based on the device protocol version: #### V1 Devices (Most Vacuums) - MQTT + Local ```mermaid sequenceDiagram participant App as Application participant DM as DeviceManager participant V1C as V1Channel participant RPC as RpcChannel participant MC as MqttChannel participant LC as LocalChannel participant MS as MqttSession participant Broker as MQTT Broker participant Device as V1 Vacuum App->>DM: create_device_manager() DM->>MS: Create MQTT Session MS->>Broker: Connect Broker-->>MS: Connected App->>DM: get_devices() Note over DM: Detect pv=1.0 DM->>V1C: Create V1Channel V1C->>MC: Create MqttChannel V1C->>LC: Create LocalChannel (deferred) Note over V1C: Subscribe to device topics V1C->>MC: subscribe() MC->>MS: subscribe(topic, callback) MS->>Broker: SUBSCRIBE Note over V1C: Fetch network info via MQTT V1C->>RPC: send_command(GET_NETWORK_INFO) RPC->>MC: publish(request) MC->>MS: publish(topic, message) MS->>Broker: PUBLISH Broker->>Device: Command Device->>Broker: Response Broker->>MS: Message MS->>MC: callback(message) MC->>RPC: decoded message RPC-->>V1C: NetworkInfo Note over V1C: Connect locally using IP V1C->>LC: connect() LC->>Device: TCP Connect :58867 Device-->>LC: Connected Note over App: Commands prefer local App->>V1C: send_command(GET_STATUS) V1C->>RPC: send_command() RPC->>LC: publish(request) [Try local first] LC->>Device: Command via TCP Device->>LC: Response LC->>RPC: decoded message RPC-->>App: Status ``` #### A01/B01 Devices (Dyad, Zeo) - MQTT Only ```mermaid sequenceDiagram participant App as Application participant DM as DeviceManager participant A01 as A01 Traits participant Helper as send_decoded_command participant MC as MqttChannel participant MS as MqttSession participant Broker as MQTT Broker participant Device as A01 Device App->>DM: create_device_manager() DM->>MS: Create MQTT Session MS->>Broker: Connect Broker-->>MS: Connected App->>DM: get_devices() Note over DM: Detect pv=A01 DM->>MC: Create MqttChannel DM->>A01: Create A01 Traits Note over A01: Subscribe to device topics A01->>MC: subscribe() MC->>MS: subscribe(topic, callback) MS->>Broker: SUBSCRIBE Note over App: All commands via MQTT App->>A01: set_power(True) A01->>Helper: send_decoded_command() Helper->>MC: subscribe(find_response) Helper->>MC: publish(request) MC->>MS: publish(topic, message) MS->>Broker: PUBLISH Broker->>Device: Command Device->>Broker: Response Broker->>MS: Message MS->>MC: callback(message) MC->>Helper: decoded message Helper-->>App: Result ``` ### Key Differences | Aspect | V1 Devices | A01/B01 Devices | |--------|------------|-----------------| | **Protocols** | V1 Protocol (JSON RPC) | DPS Protocol | | **Transports** | MQTT + Local TCP | MQTT only | | **Channel Type** | `V1Channel` with `RpcChannel` | `MqttChannel` with helpers | | **Local Support** | ✅ Yes, preferred | ❌ No | | **Fallback** | Local → MQTT | N/A | | **Connection** | Requires network info fetch | Direct MQTT | | **Examples** | Most vacuum robots | Dyad washers, Zeo models | ### MQTT Connection (All Devices) - Initial device information must be obtained from MQTT - For V1 devices, we set up the MQTT device connection before the local device connection - The `NetworkingInfo` needs to be fetched to get additional information about connecting to the device (e.g., Local IP Address) - This networking info can be cached to reduce network calls - MQTT is also the only way to get the device Map - Incoming and outgoing messages are decoded/encoded using the device `local_key` - For A01/B01 devices, MQTT is the only transport ### Local Connection (V1 Devices Only) - We use the `ip` from the `NetworkingInfo` to find the device - The local connection is preferred for improved latency and reducing load on the cloud servers to avoid rate limiting - Connections are made using a normal TCP socket on port `58867` - Incoming and outgoing messages are decoded/encoded using the device `local_key` - Messages received on the stream may be partially received, so we keep a running buffer as messages are partially decoded - **Not available for A01/B01 devices** ### RPC Pattern (V1 Devices) V1 devices use a publish/subscribe model for both MQTT and local connections, with an RPC abstraction on top: ```mermaid graph LR subgraph "RPC Layer" A[send_command] -->|1. Create request| B[Encoder] B -->|2. Subscribe for response| C[Channel.subscribe] B -->|3. Publish request| D[Channel.publish] C -->|4. Wait for match| E[find_response callback] E -->|5. Match request_id| F[Future.set_result] F -->|6. Return| G[Command Result] end subgraph "Channel Layer" C --> H[Subscription Map] D --> I[Transport] I --> J[Device] J --> K[Incoming Messages] K --> H H --> E end ``` **Key Design Points:** 1. **Temporary Subscriptions**: Each RPC creates a temporary subscription that matches the request ID 2. **Subscription Reuse**: `MqttSession` keeps subscriptions alive for 60 seconds (or idle timeout) to enable reuse during command bursts 3. **Timeout Handling**: Commands timeout after 10 seconds if no response is received 4. **Multiple Strategies**: `V1Channel` tries local first, then falls back to MQTT if local fails ## Class Design & Components ### Current Architecture The current design separates concerns into distinct layers: ```mermaid classDiagram class Channel { <> +subscribe(callback) Callable +publish(message) +is_connected() bool } class MqttChannel { -MqttSession session -duid: str -local_key: str +subscribe(callback) +publish(message) } class LocalChannel { -host: str -transport: Transport -local_key: str +connect() +subscribe(callback) +publish(message) +close() } class V1Channel { -MqttChannel mqtt_channel -LocalChannel local_channel -RpcChannel rpc_channel +send_command(method, params) +subscribe(callback) } class RpcChannel { -List~RpcStrategy~ strategies +send_command(method, params) } class RpcStrategy { +name: str +channel: Channel +encoder: Callable +decoder: Callable +health_manager: HealthManager } class MqttSession { -Client client -dict listeners -dict idle_timers +subscribe(topic, callback) +publish(topic, payload) +close() } Channel <|-- MqttChannel Channel <|-- LocalChannel Channel <|-- V1Channel MqttChannel --> MqttSession V1Channel --> MqttChannel V1Channel --> LocalChannel V1Channel --> RpcChannel RpcChannel --> RpcStrategy RpcStrategy --> Channel ``` ### Key Components #### Channel Interface The `Channel` abstraction provides a uniform interface for both MQTT and local connections: - **`subscribe(callback)`**: Register a callback for incoming messages - **`publish(message)`**: Send a message to the device - **`is_connected`**: Check connection status This abstraction allows the RPC layer to work identically over both transports. #### MqttSession (Shared Across All Devices) The `MqttSession` manages a **single shared MQTT connection** for all devices: - **Single Connection**: Only one TCP connection to the MQTT broker, regardless of device count - **Per-Device Topics**: Each device communicates via its own MQTT topics (e.g., `rr/m/i/{user}/{username}/{duid}`) - **Subscription Pooling**: Multiple callbacks can subscribe to the same topic - **Idle Timeout**: Keeps subscriptions alive for 10 seconds after the last callback unsubscribes (enables reuse during command bursts) - **Reconnection**: Automatically reconnects and re-establishes all subscriptions on connection loss - **Thread-Safe**: Uses asyncio primitives for safe concurrent access **Efficiency**: Creating 5 devices means 5 `MqttChannel` instances but only 1 `MqttSession` and 1 MQTT broker connection. #### MqttChannel (Per-Device Wrapper) Each device gets its own `MqttChannel` instance that: - Wraps the shared `MqttSession` - Manages device-specific topics (publish to `rr/m/i/.../duid`, subscribe to `rr/m/o/.../duid`) - Handles protocol-specific encoding/decoding with the device's `local_key` - Provides the same `Channel` interface as `LocalChannel` #### RpcChannel with Multiple Strategies (V1 Only) The `RpcChannel` implements the request/response pattern over pub/sub channels and is **only used by V1 devices**: ```python # Example: V1Channel tries local first, then MQTT strategies = [ RpcStrategy(name="local", channel=local_channel, ...), RpcStrategy(name="mqtt", channel=mqtt_channel, ...), ] rpc_channel = RpcChannel(strategies) ``` For each V1 command: 1. Try the first strategy (local) 2. If it fails, try the next strategy (MQTT) 3. Return the first successful result **A01/B01 devices** don't use `RpcChannel`. Instead, they use helper functions (`send_decoded_command`) that directly wrap `MqttChannel`. #### Protocol-Specific Channel Architecture | Component | V1 Devices | A01/B01 Devices | |-----------|------------|-----------------| | **Channel Class** | `V1Channel` | `MqttChannel` directly | | **RPC Abstraction** | `RpcChannel` with strategies | Helper functions | | **Strategy Pattern** | ✅ Multi-strategy (Local → MQTT) | ❌ Direct MQTT only | | **Health Manager** | ✅ Tracks local/MQTT health | ❌ Not needed | | **Code Location** | `v1_channel.py` | `a01_channel.py`, `b01_q7_channel.py` | #### Health Management (V1 Only) Each V1 RPC strategy can have a `HealthManager` that tracks success/failure: - **Exponential Backoff**: After failures, wait before retrying - **Automatic Recovery**: Periodically attempt to restore failed connections - **Network Info Refresh**: Refresh local IP addresses after extended periods A01/B01 devices don't need health management since they only use MQTT (no fallback). ### Protocol Versions Different device models use different protocol versions: | Protocol | Devices | Encoding | |----------|---------|----------| | V1 | Most vacuum robots | JSON RPC with AES encryption | | A01 | Dyad, Zeo | DPS-based protocol | | B01 | Some newer models | DPS-based protocol | | L01 | Local protocol variant | Binary protocol negotiation | The protocol layer handles encoding/decoding transparently based on the device's `pv` field. ### Prior API Issues - Complex Inheritance Hierarchy: Multiple inheritance with classes like RoborockMqttClientV1 inheriting from both RoborockMqttClient and RoborockClientV1 - Callback-Heavy Design: Heavy reliance on callbacks and listeners in RoborockClientV1.on_message_received and the ListenerModel system - Version Fragmentation: Separate v1 and A01 APIs with different patterns and abstractions - Mixed Concerns: Classes handle both communication protocols (MQTT/local) and device-specific logic - Complex Caching: The AttributeCache system with RepeatableTask adds complexity - Manual Connection Management: Users need to manually set up both MQTT and local clients as shown in the README example ### Design Goals - Prefer a single unified client that handles both MQTT and local connections internally. - Home and device discovery (fetching home data and device setup) will be behind a single API. - Asyncio First: Everything should be asyncio as much as possible, with fewer callbacks. - The clients should be working in terms of devices. We need to detect capabilities for each device and not expose details about API versions. - Reliability issues: The current Home Assistant integration has issues with reliability and needs to be simplified. It may be that there are bugs with the exception handling and it's too heavy on the cloud APIs and could benefit from more seamless caching. ### Migration from Legacy APIs The library previously had: - Separate `RoborockMqttClientV1` and `RoborockLocalClientV1` classes - Manual connection management - Callback-heavy design with `on_message_received` - Complex inheritance hierarchies The new design: - `DeviceManager` handles all connection management - `V1Channel` automatically manages both MQTT and local - Asyncio-first with minimal callbacks - Clear separation of concerns through layers - Users work with devices, not raw clients ## Implementation Details ### Code Organization ``` roborock/ ├── devices/ # Device management and channels │ ├── device_manager.py # High-level device lifecycle │ ├── transport/ # Module for network connections to devices │ | ├── channel.py # Base Channel interface │ | ├── mqtt_channel.py # MQTT channel implementation │ | ├── local_channel.py # Local TCP channel implementation │ | └── ... │ ├── rpc/ # Application-level protocol/device-specific glue │ | ├── v1_channel.py # V1 protocol channel with RPC strategies │ | ├── a01_channel.py # A01 protocol helpers │ | ├── b01_q7_channel.py # B01 Q7 protocol helpers │ | ├── b01_q10_channel.py # B01 Q10 protocol helpers │ | └── ... │ └── traits/ # High-level device-specific command traits │ └── v1/ # V1 device traits │ ├── __init__.py # Trait initialization │ ├── clean.py # Cleaning commands │ ├── map.py # Map management │ └── ... ├── mqtt/ # MQTT session management │ ├── session.py # Base session interface │ └── roborock_session.py # MQTT session with idle timeout ├── protocols/ # Low level protocol encoders/decoders │ ├── v1_protocol.py # V1 JSON RPC protocol │ ├── a01_protocol.py # A01 protocol │ ├── b01_q7_protocol.py # B01 Q7 protocol │ └── ... └── data/ # Data containers and mappings ├── containers.py # Status, HomeData, etc. ├── v1/ # V1-specific data structures ├── dyad/ # Dyad-specific data structures ├── zeo/ # Zeo-specific data structures ├── b01_q7/ # B01 Q7-specific data structures ├── b01_q10/ # B01 Q10-specific data structures └── ... ``` ### Threading Model The library is **asyncio-only** with no threads: - All I/O is non-blocking using `asyncio` - No thread synchronization needed (single event loop) - Callbacks are executed in the event loop - Use `asyncio.create_task()` for background work ### Error Handling ```mermaid graph TD A[send_command] --> B{Local Available?} B -->|Yes| C[Try Local] B -->|No| D[Try MQTT] C --> E{Success?} E -->|Yes| F[Return Result] E -->|No| G{Timeout?} G -->|Yes| H[Update Health Manager] H --> D G -->|No| I{Connection Error?} I -->|Yes| J[Mark Connection Failed] J --> D I -->|No| D D --> K{Success?} K -->|Yes| F K -->|No| L[Raise RoborockException] ``` **Exception Types:** - `RoborockException`: Base exception for all library errors - `RoborockConnectionException`: Connection-related failures - `RoborockTimeout`: Command timeout (10 seconds) ### Caching Strategy To reduce API calls and improve reliability: 1. **Home Data**: Cached on disk, refreshed periodically 2. **Network Info**: Cached for 12 hours 3. **Device Capabilities**: Detected once and cached 4. **MQTT Subscriptions**: Kept alive for 60 seconds (idle timeout) ### Testing Test structure mirrors the python module structure. For example, the module `roborock.devices.traits.v1.maps` is tested in the file `tests/devices/traits/v1/test_maps.py`. Each test file corresponds to a python module. The test suite uses mocking extensively to avoid real devices: - `Mock` and `AsyncMock` for channels and sessions - Fake message generators (`mqtt_packet.gen_publish()`) - Snapshot testing for complex data structures - Time-based tests use small timeouts (10-50ms) for speed Example test structure: ```python @pytest.fixture def mock_mqtt_channel(): """Mock MQTT channel that simulates responses.""" channel = AsyncMock(spec=MqttChannel) channel.response_queue = [] async def publish_side_effect(message): # Simulate device response if channel.response_queue: response = channel.response_queue.pop(0) await callback(response) channel.publish.side_effect = publish_side_effect return channel ``` Python-roborock-python-roborock-d6da2db/docs/README.md000066400000000000000000000006321513363643200227340ustar00rootroot00000000000000# Roborock Documentation This directory contains lower level documentation for developers interested in: - Details about the lower level transport protocols used by the library - Undocumented or unsupported device commands or RPCs - The internal design of components of the library For details about using the library see the higher level documentation at https://python-roborock.github.io/python-roborock/ Python-roborock-python-roborock-d6da2db/docs/V1_API_COMMANDS.md000066400000000000000000000670561513363643200242540ustar00rootroot00000000000000# Api commands This page is still under construction. All of the following are the commands we have reverse engineered. It is not an exhaustive list of all the possible commands. Commands do not immediately make it to this page. You can find more commands [here](https://github.com/humbertogontijo/python-roborock/blob/main/roborock/roborock_typing.py#L18) Commands can have multiple parameters that can change from one model to another. * [app_charge](#app_charge) * [app_get_dryer_setting](#app_get_dryer_setting) * [app_get_init_status](#app_get_init_status) * [app_pause](#app_pause) * [app_rc_end](#app_rc_end) * [app_rc_move](#app_rc_move) * [app_rc_start](#app_rc_start) * [app_rc_stop](#app_rc_stop) * [app_segment_clean](#app_segment_clean) * [app_set_dryer_setting](#app_set_dryer_setting) * [app_start_collect_dust](#app_start_collect_dust) * [app_start_wash](#app_start_wash) * [app_start](#app_start) * [app_stop_collect_dust](#app_stop_collect_dust) * [app_stop_wash](#app_stop_wash) * [app_stop](#app_stop) * [change_sound_volume](#change_sound_volume) * [close_dnd_timer](#close_dnd_timer) * [del_server_timer](#del_server_timer) * [dnld_install_sound](#dnld_install_sound) * [get_clean_sequence](#get_clean_sequence) * [get_consumable](#get_consumable) * [get_custom_mode](#get_custom_mode) * [get_customize_clean_mode](#get_customize_clean_mode) * [get_dnd_timer](#get_dnd_timer) * [get_dust_collection_mode](#get_dust_collection_mode) * [get_clean_follow_ground_material_status](#get_clean_follow_ground_material_status) * [get_identify_furniture_status](#get_identify_furniture_status) * [get_identify_ground_material_status](#get_identify_ground_material_status) * [get_led_status](#get_led_status) * [get_map_v1](#get_map_v1) * [get_multi_map](#get_multi_map) * [get_multi_maps_list](#get_multi_maps_list) * [get_network_info](#get_network_info) * [get_prop](#get_prop) * [get_room_mapping](#get_room_mapping) * [get_scenes_valid_tids](#get_scenes_valid_tids) * [get_serial_number](#get_serial_number) * [get_smart_wash_params](#get_smart_wash_params) * [get_sound_progress](#get_sound_progress) * [get_status](#get_status) * [get_timezone](#get_timezone) * [get_turn_server](#get_turn_server) * [get_valley_electricity_timer](#get_valley_electricity_timer) * [get_wash_towel_mode](#get_wash_towel_mode) * [load_multi_map](#load_multi_map) * [name_segment](#name_segment) * [reset_consumable](#reset_consumable) * [resume_segment_clean](#resume_segment_clean) * [resume_zoned_clean](#resume_zoned_clean) * [retry_request](#retry_request) * [reunion_scenes](#reunion_scenes) * [save_map](#save_map) * [send_ice_to_robot](#send_ice_to_robot) * [send_sdp_to_robot](#send_sdp_to_robot) * [set_server_timer](#set_server_timer) * [set_clean_motor_mode](#set_clean_motor_mode) * [set_customize_clean_mode](#set_customize_clean_mode) * [set_dnd_timer](#set_dnd_timer) * [set_dust_collection_mode](#set_dust_collection_mode) * [set_fds_endpoint](#set_fds_endpoint) * [set_identify_furniture_status](#set_identify_furniture_status) * [set_identify_ground_material_status](#set_identify_ground_material_status) * [set_led_status](#set_led_status) * [set_mop_mode](#set_mop_mode) * [set_scenes_segments](#set_scenes_segments) * [set_scenes_zones](#set_scenes_zones) * [set_segment_ground_material](#set_segment_ground_material) * [set_smart_wash_params](#set_smart_wash_params) * [set_timezone](#set_timezone) * [set_valley_electricity_timer](#set_valley_electricity_timer) * [set_wash_towel_mode](#set_wash_towel_mode) * [set_water_box_custom_mode](#set_water_box_custom_mode) * [start_camera_preview](#start_camera_preview) * [start_edit_map](#start_edit_map) * [start_voice_chat](#start_voice_chat) * [start_wash_then_charge](#start_wash_then_charge) * [stop_camera_preview](#stop_camera_preview) * [stop_segment_clean](#stop_segment_clean) * [test_sound_volume](#test_sound_volume) * [upd_server_timer](#upd_server_timer) ## Robot status ### get_status Description: Returns the current status of the vacuum Parameters: None Returns: msg_ver: msg_seq: state: battery: Battery level of your device. clean_time: Total clean time in hours. clean_area: Total clean area in meters. error_code: map_reset: in_cleaning: in_returning: in_fresh_state: lab_status: water_box_status: back_type: wash_phase: wash_ready: fan_power: dnd_enabled: map_status: is_locating: lock_status: water_box_mode: water_box_carriage_status: mop_forbidden_enable: camera_status: is_exploring: home_sec_status: home_sec_enable_password: adbumper_status: water_shortage_status: dock_type: dust_collection_status: auto_dust_collection: avoid_count: mop_mode: debug_mode: collision_avoid_status: switch_map_mode: dock_error_status: charge_status: unsave_map_reason: unsave_map_flag: **Supported devices:** * Roborock S7 MaxV Ultra: ✅ * Roborock S8 Pro Ultra: ✅ ## App vacuum control ### app_start Description: Parameters: ### app_pause Description: This pauses the vacuum's current task Parameters: None Returns ok or error **Supported devices:** * Roborock S8 Pro Ultra: ✅ ### app_stop Description: Parameters: ### app_start_collect_dust Description: This empties the bin while docked Parameters: None **Supported devices:** * Roborock S8 Pro Ultra: ✅ ### app_stop_collect_dust Description: This stops the emptying of the dust bin while docked Parameters: None **Supported devices:** * Roborock S8 Pro Ultra: ✅ ### app_start_wash Description: This washes the mop while docked Parameters: None **Supported devices:** * Roborock S8 Pro Ultra: ✅ ### app_stop_wash Description: This stops washing the mop whiloe docked Parameters: None **Supported devices:** * Roborock S8 Pro Ultra: ✅ ### app_goto_target Description: Got to target Parameters: - X coordinate as integer (e.g.: 23450) - Y coordinate as integer (e.g.: 16450) Returns ok or error **Supported devices:** * Roborock S8 Pro Ultra: ✅ ### app_charge Description: This tells your vacuum to go back to the dock and charge. Parameters: None Returns : ok or error **Supported devices:** * Roborock S7 MaxV Ultra: ✅ * Roborock S8 Pro Ultra: ✅ ## App status ### app_get_init_status Description: Returns details on the app being used to interact with Roborock servers ?? In this case the app is backend supporting the HA integration ? Parameters: None Returns: local_info: name: Name of the app bom: Version of the app location: Location of the app language: Language of the app wifiplan: Wifi plan of the app timezone: Timezone of the app logserver: Log server of the app featureset: Featureset of the app feature_info: List of features new_feature_info: New feature info Return example: ```json {'local_info': {'name': 'custom_A.03.0342_CE', 'bom': 'A.03.0342', 'location': 'de', 'language': 'en', 'wifiplan': '', 'timezone': 'Europe/Berlin', 'logserver': 'awsde0.fds.api.xiaomi.com', 'featureset': 3}, 'feature_info': [111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125], 'new_feature_info': 2247395306799103, 'new_feature_info_str': '00000008009EFFFE'} ``` **Supported devices:** * Roborock S8 Pro Ultra: ✅ ## App dryer settings ### app_get_dryer_setting Description: Get dock dryer settings. Parameters: None Returns: status: on: cliff_on: cliff_off count: dry_time: Duration dryer remains on in seconds. off: cliff_on: cliff_off: count: Return example: ```json {'status': 1, 'on': {'cliff_on': 1, 'cliff_off': 1, 'count': 10, 'dry_time': 7200}, 'off': {'cliff_on': 2, 'cliff_off': 1, 'count': 10}} ``` Source: Roborock S7 MaxV Ultra **Supported devices:** * Roborock S7 MaxV Ultra: ✅ * Roborock S8 Pro Ultra: ✅ ### app_set_dryer_setting Description: Set the time for the dryer to run Parameters: '{"status":1,"on":{"dry_time":14400}}' dry_time is the time in seconds the dryer will run for Returns ok or error **Supported devices:** * Roborock S8 Pro Ultra: ✅ ## App remote control ### app_rc_start Description: Starts remote control. Parameters: None Returns ok or error **Supported devices:** * Roborock S8 Pro Ultra: ✅ ### app_rc_move Description: Moves the robot in the direction specified Parameters: To be documented Returns ok or error ### app_rc_stop Description: Stops the remote control Parameters: None Returns ok or error ### app_rc_end Description: Ends the remote control task Parameters: Returns ok or error **Supported devices:** * Roborock S8 Pro Ultra: ✅ ## App other ### app_set_smart_cliff_forbidden Description: Parameters: ### app_spot Description: Parameters: ### app_stat Description: This returns the current status of the vacuum Parameters: None Returns: ok or error **Supported devices:** * Roborock S8 Pro Ultra: ✅ ### app_wakeup_robot Description: Parameters: ### app_zoned_clean Description: Starts a zone clean Parameters: Returns: ok or error **Supported devices:** * Roborock S8 Pro Ultra: ✅ ## Segments and Zones ### app_segment_clean Description: This starts a segment clean and repeats it the number of times specified. Parameters: An array of segments to clean. Each segment is an integer with the segment id and the number of times to clean it. For example, to clean segment 18 twice, the parameter would be `[{'segments': [18], 'repeat': 2}]` Command: `roborock -d command --device_id deviceIdRedacted --cmd app_segment_clean --params '[{"segments": [17,19], "repeat": 2}]'` Returns ok or error **Supported devices:** * Roborock S8 Pro Ultra: ✅ ### set_segment_ground_material Description: Sets the groud material for the segment Parameters: "{'data':[[22,3,0]]}" Returns ok or error ### name_segment Description: Parameters: To be determined ### resume_segment_clean Description: Parameters: **Supported devices:** * Roborock S8 Pro Ultra: ✅ ### stop_segment_clean Description: Parameters: **Supported devices:** * Roborock S8 Pro Ultra: ✅ ### set_scenes_zones Description: Parameters: ### set_scenes_segments Description: Parameters: ### get_scenes_valid_tids Description: To be confirmed Parameters: None Returns: ```json [{'tid': '1699679077347', 'map_flag': 0, 'segs': [{'sid': 24}, {'sid': 20}, {'sid': 22}, {'sid': 18}]}, {'tid': '1699679236553', 'map_flag': 0, 'segs': [{'sid': 24}, {'sid': 20}, {'sid': 22}]}, {'tid': '1699679386045', 'map_flag': 0, 'segs': [{'sid': 16}, {'sid': 19}, {'sid': 17}]}, {'tid': '1699679335823', 'map_flag': 0, 'segs': [{'sid': 19}, {'sid': 16}, {'sid': 17}]}] ``` **Supported devices:** * Roborock S8 Pro Ultra: ✅ ### resume_zoned_clean Description: Parameters: ### reunion_scenes Description: Parameters: ## Camera ### start_camera_preview Description: Parameters: **Supported devices:** * Roborock S8 Pro Ultra: ❌ ### stop_camera_preview Description: Parameters: **Supported devices:** * Roborock S8 Pro Ultra: ❌ ### get_camera_status Description: Get camera status. Parameters: None Returns: 3457  387 Roborock S8 Pro Ultra Source: Roborock S7 MaxV Ultra ### set_camera_status Description: Parameters: ### start_voice_chat Description: Parameters: **Supported devices:** * Roborock S8 Pro Ultra: ❌ ## Clean modes ### get_carpet_clean_mode Description: Get carpet clean mode. Parameters: Returns: carpet_clean_mode: Enumeration for carpet clean mode. Return example: ```json {'carpet_clean_mode': 3} ``` Source: Roborock S7 MaxV Ultra ### set_carpet_clean_mode Description: Parameters: ### get_carpet_mode Description: Parameters: None Returns: enable: current_integral: current_high: current_low: stall_time: Return example: ```json {'enable': 1, 'current_integral': 450, 'current_high': 500, 'current_low': 400, 'stall_time': 10} ``` **Supported devices:** * Roborock S7 MaxV Ultra: ✅ * Roborock S8 Pro Ultra: ✅ ### set_carpet_mode Description: Parameters: ### get_smart_wash_params Description: Returns the smartwash parameters Parameters: None Returns: smart_wash: 0 is off, 1 is on wash_interval: The interval in seconds between washes Example: ```json {'smart_wash': 0, 'wash_interval': 1200} ``` **Supported devices:** * Roborock S8 Pro Ultra: ✅ ### set_smart_wash_params Description: Sets the smartwash parameters Parameters: smart_wash: 0 is off, 1 is on wash_interval: The interval in seconds between washes `{'smart_wash': 0, 'wash_interval': 1200}` **Supported devices:** * Roborock S8 Pro Ultra: ✅ ## Cleaning history ### get_clean_record Description: Parameters: To be determined ### get_clean_record_map Description: Parameters: ### get_clean_sequence Description: Parameters: ### get_clean_summary Description: Get a summary of cleaning history. Parameters: None Returns: clean_time: clean_area: clean_count: dust_collection_count: records: Return example: ```json {'clean_time': 568146, 'clean_area': 8816865000, 'clean_count': 178, 'dust_collection_count': 172, 'records': [1689740211, 1689555788, 1689259450, 1688999113, 1688852350, 1688693213, 1688692357, 1688614354, 1688613280, 1688606676, 1688325265, 1688174717, 1688149381, 1688092832, 1688001593, 1687921414, 1687890618, 1687743256, 1687655018, 1687631444]} ``` Source: Roborock S7 MaxV Ultra **Supported devices:** * Roborock S7 MaxV Ultra: ✅ * Roborock S8 Pro Ultra: ✅ ### get_mop_template_params_summary Description: Parameters: **Supported devices:** * Roborock S8 Pro Ultra: ❌ ## Child lock ### get_child_lock_status Description: This gets the child lock status of the device. 0 is off, 1 is on. Parameters: None Returns: lock_status: Return example: ```json {'lock_status': 0} ``` **Supported devices:** * Roborock S8 Pro Ultra: ✅ ### set_child_lock_status Description: This sets the child lock status of the device. Parameters: '{"lock_status" :0}' Returns: ok **Supported devices:** * Roborock S8 Pro Ultra: ✅ ## Consumables ### get_consumable Description: This gets the status of all of the consumables for your device. Parameters: None Returns: main_brush_work_time: This is the amount of time the main brush has been used in seconds since it was last replaced side_brush_work_time: This is the amount of time the side brush has been used in seconds since it was last replaced filter_work_time: This is the amount of time the air filter inside the vacuum has been used in seconds since it was last replaced filter_element_work_time: sensor_dirty_time: This is the amount of time since you have cleaned the sensors on the bottom of your vacuum. strainer_work_times: dust_collection_work_times: cleaning_brush_work_times: Return examples: ```json {'main_brush_work_time': 14151, 'side_brush_work_time': 41638, 'filter_work_time': 14151, 'filter_element_work_time': 0, 'sensor_dirty_time': 41522, 'strainer_work_times': 44, 'dust_collection_work_times': 19, 'cleaning_brush_work_times': 44} ``` ### reset_consumable Description: Parameters: List of consumables to reset. For example, to reset consumables 'strainer_work_times' and 'sensor_dirty_time' the parameter would be `['strainer_work_times', 'sensor_dirty_time']` **Supported devices:** * Roborock S7 MaxV Ultra: ✅ * Roborock S8 Pro Ultra: ✅ ## Custom modes ### get_custom_mode Description: It returns the current custom mode. Parameters: None Returns: integer value of the current custom mode Return example: ``` 102 ``` **Supported devices:** * Roborock S7 MaxV Ultra: ✅ * Roborock S8 Pro Ultra: ✅ ### set_custom_mode Description: Parameters: ### get_customize_clean_mode Description: Parameters: ### set_customize_clean_mode Description: Parameters: ## Furniture and ground material ### get_identify_furniture_status Description: Parameters: ### set_identify_furniture_status Description: Parameters: ### get_identify_ground_material_status Description: Parameters: ### set_identify_ground_material_status Description: Parameters: ## LEDs ### get_flow_led_status Description: Parameters: ### set_flow_led_status Description: Parameters: ### get_led_status Description: Returns the LED status. If disabled the indicator light will turn off 1 minute after fully charged Parameters: Returns: led_status: 0 is off, 1 is on **Supported devices:** * Roborock S8 Pro Ultra: ✅ ### set_led_status Description: Sets the LED status. If disabled the indicator light will turn off 1 minute after fully charged Parameters: ???? ## Maps ### get_multi_map Description: Parameters: Comment: Response timed out for S8 Pro Ultra ### get_multi_maps_list Description: Returns a list of map information stored on the device. Parameters: None required Returns: max_multi_map: max_bak_map: multi_map_count: map_info: mapFlag: add_time: length: name: bak_maps: mapFlag: add_time: Return example: ```json {'max_multi_map': 4, 'max_bak_map': 1, 'multi_map_count': 2, 'map_info': [{'mapFlag': 0, 'add_time': 1699919699, 'length': 4, 'name': 'Home', 'bak_maps': [{'mapFlag': 4, 'add_time': 1699823921}]}, {'mapFlag': 1, 'add_time': 1699828035, 'length': 13, 'name': 'Boys bathroom', 'bak_maps': [{'mapFlag': 5, 'add_time': 1699828035}]}]} ``` Source: S8 Pro Ultra **Supported devices:** * Roborock S8 Pro Ultra: ✅ ### get_map_v1 Description: Returns the map Parameters: Unknown Comment: Returns a map in a format that is not yet understood by me ### start_edit_map Description: Parameters: ### get_room_mapping Description: Returns a list of rooms, ids as discovered by Parameters: None Returns: room_id Return example: ```json [[16, '14731399', 12], [17, '2220009', 2], [18, '2219688', 12], [19, '2219685', 9], [20, '2219691', 12], [21, '2431758', 12], [22, '2219677', 13], [23, '2312548', 12], [24, '2219678', 14], [25, '2219686', 15], [26, '2219772', 12], [27, '14768755', 12]] ``` **Supported devices:** * Roborock S7 MaxV Ultra: ✅ * Roborock S8 Pro Ultra: ✅ ### load_multi_map Description: Parameters: number (the floor/map index) ### save_map Description: Parameters: ## Operating modes ### get_mop_mode Description: Get mop mode. Parameters: None Returns: Enumeration for mop mode. 300 Example for S8 Pro Ultra: standard = 300 deep = 301 deep_plus = 303 fast = 304 custom = 302 **Supported devices:** * Roborock S8 Pro Ultra: ✅ ### set_mop_mode Description: Set mop mode. Parameters: mop_mode 300 **Supported devices:** * Roborock S8 Pro Ultra: ✅ ### set_clean_motor_mode Description: Parameters: ### get_dust_collection_mode Description: Parameters: None Returns: mode: Return example: ```json {'mode': 0} ``` Source: Roborock S7 MaxV Ultra **Supported devices:** * Roborock S7 MaxV Ultra: ✅ * Roborock S8 Pro Ultra: ✅ ### set_dust_collection_mode Description: Parameters: ### get_wash_towel_mode Description: Parameters: None Returns: wash_mode: Return example: ```json {'wash_mode': 1} ``` Source: Roborock S7 MaxV Ultra unknown = -9999 light = 0 balanced = 1 deep = 2 **Supported devices:** * Roborock S7 MaxV Ultra: ✅ * Roborock S8 Pro Ultra: ✅ ### set_wash_towel_mode Description: Sets the wash wash_towel_mode Parameters: {'wash_mode': 2} Returns: ok or error Source: S8 Pro Ultra **Supported devices:** * Roborock S7 MaxV Ultra: ✅ * Roborock S8 Pro Ultra: ✅ ### get_collision_avoid_status Description: Parameters: None Returns: status: Return example: ```json {'status': 1} ``` **Supported devices:** * Roborock S7 MaxV Ultra: ✅ * Roborock S8 Pro Ultra: ✅ ### set_collision_avoid_status Description: Update collision avoid status. Parameters: '{"status" :1}' Returns: ok **Supported devices:** * Roborock S7 MaxV Ultra: ✅ * Roborock S8 Pro Ultra: ✅ ### start_wash_then_charge Description: Parameters: ### switch_water_mark Description: Parameters: **Supported devices:** * Roborock S8 Pro Ultra: ❌ ## System information ### get_network_info Description: Get the device's network information. Parameters: None Returns: ssid: SSID of the wirelness network the device is connected to. ip: IP address of the device. mac: MAC address of the device. bssid: BSSID of the device. rssi: RSSI of the device. Return example: ```json {'ssid': 'My WiFi Network', 'ip': '192.168.1.29', 'mac': 'a0:2b:47:3d:24:51', 'bssid': '18:3b:1a:23:41:3c', 'rssi': -32} ``` Source: Roborock S7 MaxV Ultra **Supported devices:** * Roborock S7 MaxV Ultra: ✅ * Roborock S8 Pro Ultra: ✅ ### get_serial_number Description: Get serial number of the vacuum. Parameters: None Returns: serial_number: Serial number of the vacuum. Return example: ```json {'serial_number': 'B16EVD12345678'} ``` Source: Roborock S7 MaxV Ultra **Supported devices:** * Roborock S7 MaxV Ultra: ✅ * Roborock S8 Pro Ultra: ✅ ### get_prop Description: Generic get property command Parameters: The property to get Example: roborock -d command --device_id aHiddenDeviceId --cmd get_prop --params '["battery"]' Comment : This example returns the same as get_status. Initial testing has shown that not all get commands are supported by this method ### get_turn_server Description: Parameters: **Supported devices:** * Roborock S8 Pro Ultra: ❌ ### enable_log_upload Description: Parameters: ### find_me Description: This makes your vacuum speak so you can find it. Parameters: None ### upd_server_timer Description: Parameters: ### get_homesec_connect_status Description: Parameters: **Supported devices:** * Roborock S8 Pro Ultra: ❌ ### set_fds_endpoint Description: Parameters: ### send_ice_to_robot Description: Parameters: ### send_sdp_to_robot Description: Parameters: ### get_device_ice Description: Parameters: **Supported devices:** * Roborock S8 Pro Ultra: ❌ ### get_device_sdp Description: Parameters: **Supported devices:** * Roborock S8 Pro Ultra: ❌ ### retry_request Description: Parameters: ## Timers ### del_server_timer Description: Parameters: ### dnd_timer ### get_dnd_timer Description: Gets the do not disturb timer start_hour: The hour you want dnd to start start_minute: The minute you want dnd to start end_hour: The hour you want dnd to be turned off end_minute: The minute you want dnd to be turned off enabled: If the switch is currently turned on in the app for DnD Parameters: None ### set_dnd_timer Description: Parameters: ### close_dnd_timer Description: This disables the dnd timer Parameters: None ### get_server_timer Description: Parameters: ### set_server_timer Description: Parameters: ### get_timezone Description: Get the device's time zone. Parameters: None Returns: Time zone by the TZ identifier (e.g., America/Los_Angeles) **Supported devices:** * Roborock S7 MaxV Ultra: ✅ * Roborock S8 Pro Ultra: ✅ ### set_timezone Description: Sets the device's time zone Parameters: ## Sound ### get_sound_volume Description: Returns the volume of the sound played by the vacuum Parameters: None Returns: volume: The volume of the sound played by the vacuum Example: 72 **Supported devices:** * Roborock S8 Pro Ultra: ✅ ### change_sound_volume Description: Sets the volume of the sound played by the vacuum Parameters: volume Returns: ok or error `roborock -d command --device_id aHiddenDeviceId --cmd change_sound_volume --params 72` **Supported devices:** * Roborock S8 Pro Ultra: ✅ ### test_sound_volume Description: Plays a sound on the vacumm to identity volume Parameters: None **Supported devices:** * Roborock S8 Pro Ultra: ✅ ### get_sound_progress Description: Parameters: Returns: ```json {'sid_in_progress': 0, 'progress': 0, 'state': 0, 'error': 0} ``` ### get_current_sound Description: Parameters: Return example: ```json {'sid_in_use': 122, 'sid_version': 1, 'sid_in_progress': 0, 'location': 'de', 'bom': 'A.03.0342', 'language': 'en', 'msg_ver': 2} ``` **Supported devices:** * Roborock S7 MaxV Ultra: ✅ * Roborock S8 Pro Ultra: ✅ ### dnld_install_sound Description: Parameters: ## Off peak charging ### get_valley_electricity_timer Description: Get valley electricity timer. Parameters: None Returns: start_hour: The hour you want valley electricity to start start_minute: The minute you want valley electricity to start end_hour: The hour you want valley electricity to be turned off end_minute: The minute you want valley electricity to be turned off enabled: If the switch is currently turned on in the app for valley electricity ```json {'start_hour': 0, 'start_minute': 0, 'end_hour': 0, 'end_minute': 0, 'enabled': 0} ``` **Supported devices:** * Roborock S8 Pro Ultra: ✅ ### set_valley_electricity_timer Description: Sets the valley electricity timer Parameters: start_hour: The hour you want valley electricity to start start_minute: The minute you want valley electricity to start end_hour: The hour you want valley electricity to be turned off end_minute: The minute you want valley electricity to be turned off enabled: If the switch is currently turned on in the app for valley electricity Example: ```json {'start_hour': 0, 'start_minute': 0, 'end_hour': 0, 'end_minute': 0, 'enabled': 0} ``` **Supported devices:** * Roborock S8 Pro Ultra: ❓ ## Water box mode ### get_water_box_custom_mode Description: Get water box mode. Parameters: None Returns: Enumeration for water box mode. 203 ### get_clean_follow_ground_material_status Description: Parameters: None **Supported devices:** * Roborock S8 Pro Ultra: ✅ ### set_water_box_custom_mode Description: Set the water box mode. Parameters: {'water_box_mode': 203} Returns: ok or error **Supported devices:** * Roborock S8 Pro Ultra: ✅ Python-roborock-python-roborock-d6da2db/docs/V1_DEVICE_FEATURES.md000066400000000000000000001254571513363643200246170ustar00rootroot00000000000000# Roborock Device Features Documentation This document provides comprehensive documentation for each `DeviceFeature` in the python-roborock library, explaining what each feature supports in the Roborock app and vacuum functionality. ## Table of Contents - [Overview](#overview) - [Feature Detection Systems](#feature-detection-systems) - [Robot New Features (Lower 32 bits)](#robot-new-features-lower-32-bits) - [Robot New Features (Upper 32 bits)](#robot-new-features-upper-32-bits) - [New Feature String Mask Features](#new-feature-string-mask-features) - [New Feature String Bit Features](#new-feature-string-bit-features) - [Robot Features (Array-based)](#robot-features-array-based) - [Model-Specific Features](#model-specific-features) - [Product Features](#product-features) --- ## Overview The Roborock ecosystem uses multiple feature flag systems to determine device capabilities: 1. **robotNewFeatures** - A 64-bit integer split into lower/upper 32 bits for bit-masked features 2. **new_feature_info_str** - A hexadecimal string where each bit/nibble represents a feature 3. **feature_info** (robotFeatures) - An array of integer feature IDs 4. **Model whitelists/blacklists** - Specific features tied to device models 5. **Product features** - Hardware capability flags (cameras, mop modules, etc.) --- ## Feature Detection Systems ### System 1: robotNewFeatures (Lower 32 bits) Features checked via bitwise AND against the lower 32 bits of `new_feature_info`. ### System 2: robotNewFeatures (Upper 32 bits) Features checked by shifting right 32 bits and checking individual bit positions. ### System 3: new_feature_info_str (Hex String) A hexadecimal string where features are encoded as bits. The string is parsed from right to left. ### System 4: robotFeatures (Array) An array of integer feature IDs. Feature is supported if ID is present in the array. --- ## Robot New Features (Lower 32 bits) ### is_show_clean_finish_reason_supported **Bit Mask:** `1` (bit 0) **Feature:** Show Clean Finish Reason **Description:** Enables the app to display the reason why a cleaning session ended (e.g., completed, battery low, stuck, user canceled). **Impact:** Provides better user feedback on cleaning outcomes. --- ### is_re_segment_supported **Bit Mask:** `4` (bit 2) **Feature:** Re-Segment Map **Description:** Allows the vacuum to re-segment and re-divide rooms on the map after initial mapping. **Impact:** Users can trigger automatic room re-detection if the initial segmentation was incorrect. --- ### is_video_monitor_supported **Bit Mask:** `8` (bit 3) **Feature:** Video Monitor **Description:** Enables video monitoring capabilities through the vacuum's camera system. **Impact:** Users can view live video feed from the vacuum's camera in the app. --- ### is_any_state_transit_goto_supported **Bit Mask:** `16` (bit 4) **Feature:** Any State Transit Goto **Description:** Allows the vacuum to accept "go to" commands in any operational state, not just idle. **Impact:** More flexible navigation commands during cleaning or other operations. --- ### is_fw_filter_obstacle_supported **Bit Mask:** `32` (bit 5) **Feature:** Firmware Filter Obstacle **Description:** Firmware-level obstacle filtering to distinguish between real obstacles and false positives. **Impact:** Reduces unnecessary obstacle avoidance for minor or temporary objects. --- ### is_video_setting_supported **Bit Mask:** `64` (bit 6) **Feature:** Video Settings **Description:** Enables configuration options for camera video quality, resolution, and streaming settings. **Impact:** Users can adjust video quality to balance bandwidth and clarity. --- ### is_ignore_unknown_map_object_supported **Bit Mask:** `128` (bit 7) **Feature:** Ignore Unknown Map Objects **Description:** Allows the vacuum to ignore unrecognized map objects during navigation. **Impact:** Prevents navigation issues from outdated or corrupted map data. --- ### is_set_child_supported **Bit Mask:** `256` (bit 8) **Feature:** Set Child Lock **Description:** Enables child lock functionality to prevent unauthorized use of the vacuum. **Impact:** Prevents children from starting or controlling the vacuum. --- ### is_carpet_supported **Bit Mask:** `512` (bit 9) **Feature:** Carpet Detection **Description:** Enables carpet detection and special carpet cleaning modes (increased suction, mop lifting). **Impact:** Automatic suction boost on carpets and prevents mopping on carpets. --- ### is_record_allowed **Bit Mask:** `1024` (bit 10) **Feature:** Video Recording Allowed **Description:** Permits video recording from the vacuum's camera (privacy setting). **Impact:** Users can record video clips from the camera feed. --- ### is_mop_path_supported **Bit Mask:** `2048` (bit 11) **Feature:** Mop Path Display **Description:** Shows the mopping path separately from the vacuum path on the map. **Impact:** Users can see exactly which areas were mopped vs. just vacuumed. --- ### is_multi_map_segment_timer_supported **Bit Mask:** `4096` (bit 12) **Feature:** Multi-Map Segment Timer **Description:** Enables scheduled cleaning for specific rooms across multiple floor maps. **Impact:** Create schedules that work across different floors/maps. --- ### is_current_map_restore_enabled **Bit Mask:** `8192` (bit 13) **Feature:** Current Map Restore **Description:** Ability to restore and reload the current map if it becomes corrupted or lost. **Impact:** Prevents need to remap after map corruption or vacuum reset. --- ### is_room_name_supported **Bit Mask:** `16384` (bit 14) **Feature:** Room Naming **Description:** Allows users to assign custom names to rooms on the map. **Impact:** More intuitive room selection (e.g., "Kitchen" instead of "Room 1"). --- ### is_shake_mop_set_supported **Bit Mask:** `262144` (bit 18) **Feature:** Shake/Vibrating Mop Settings **Description:** Configuration options for mop vibration/shaking intensity and frequency. **Impact:** Users can adjust mopping aggressiveness for different floor types. --- ### is_map_beautify_internal_debug_supported **Bit Mask:** `2097152` (bit 21) **Feature:** Map Beautify Debug Mode **Description:** Internal debugging mode for map rendering and beautification algorithms. **Impact:** Developer/debug feature, not typically exposed to end users. --- ### is_new_data_for_clean_history **Bit Mask:** `4194304` (bit 22) **Feature:** Enhanced Clean History Data **Description:** New data format for cleaning history with additional metrics and details. **Impact:** More detailed cleaning history including area coverage, time per room, etc. --- ### is_new_data_for_clean_history_detail **Bit Mask:** `8388608` (bit 23) **Feature:** Enhanced Clean History Detail View **Description:** Detailed view for individual cleaning sessions with comprehensive statistics. **Impact:** Users can see granular details about each cleaning session. --- ### is_flow_led_setting_supported **Bit Mask:** `16777216` (bit 24) **Feature:** Flow LED Settings **Description:** Configuration for LED light indicators on the vacuum. **Impact:** Users can customize LED brightness or disable LEDs. --- ### is_dust_collection_setting_supported **Bit Mask:** `33554432` (bit 25) **Feature:** Dust Collection Settings **Description:** Options for auto-empty dock dust collection frequency and duration. **Impact:** Configure when and how aggressively the dock empties the dustbin. --- ### is_rpc_retry_supported **Bit Mask:** `67108864` (bit 26) **Feature:** RPC Retry Mechanism **Description:** Automatic retry of failed remote procedure calls to the vacuum. **Impact:** Improved command reliability over unreliable networks. --- ### is_avoid_collision_supported **Bit Mask:** `134217728` (bit 27) **Feature:** Collision Avoidance **Description:** Advanced collision avoidance using sensors and camera. **Impact:** Reduces bumping into furniture and obstacles. --- ### is_support_set_switch_map_mode **Bit Mask:** `268435456` (bit 28) **Feature:** Switch Map Mode **Description:** Ability to switch between different mapping modes (2D/3D). **Impact:** Users can toggle between different map visualization modes. --- ### is_map_carpet_add_support **Bit Mask:** `1073741824` (bit 30) **Feature:** Manual Carpet Area Addition **Description:** Manually mark carpet areas on the map for special treatment. **Impact:** Users can define carpet zones even if auto-detection fails. --- ### is_custom_water_box_distance_supported **Bit Mask:** `2147483648` (bit 31) **Feature:** Custom Water Box Distance **Description:** Adjustable water tank capacity/distance settings. **Impact:** Optimize water usage for different floor sizes. --- ## Robot New Features (Upper 32 bits) ### is_support_smart_scene **Bit Position:** 1 (upper 32 bits) **Feature:** Smart Scene **Description:** Intelligent cleaning scenes that adapt to room type and detected objects. **Impact:** Automatic cleaning parameter adjustment based on room identification. --- ### is_support_floor_edit **Bit Position:** 3 (upper 32 bits) **Feature:** Floor Map Editing **Description:** Advanced map editing capabilities including floor type assignment. **Impact:** Users can mark different floor types (hardwood, tile, carpet) for optimized cleaning. --- ### is_support_furniture **Bit Position:** 4 (upper 32 bits) **Feature:** Furniture Detection/Placement **Description:** Detection and placement of furniture icons on the map. **Impact:** Visual representation of furniture locations on the map. --- ### is_wash_then_charge_cmd_supported **Bit Position:** 5 (upper 32 bits) **Feature:** Wash Then Charge Command **Description:** Command to wash the mop before returning to charging (for docks with mop washing). **Impact:** Ensures mop is clean before drying/charging cycle. --- ### is_support_room_tag **Bit Position:** 6 (upper 32 bits) **Feature:** Room Tagging **Description:** Ability to tag rooms with properties (e.g., high traffic, pet area). **Impact:** Allows for room-specific cleaning strategies. --- ### is_support_quick_map_builder **Bit Position:** 7 (upper 32 bits) **Feature:** Quick Map Builder **Description:** Rapid mapping mode that creates a basic map faster than full mapping. **Impact:** Get a usable map quickly before running detailed mapping later. --- ### is_support_smart_global_clean_with_custom_mode **Bit Position:** 8 (upper 32 bits) **Feature:** Smart Global Clean with Custom Mode **Description:** Apply custom cleaning modes during smart global cleaning. **Impact:** Combine intelligent cleaning with user-defined preferences. --- ### is_careful_slow_mop_supported **Bit Position:** 9 (upper 32 bits) **Feature:** Careful/Slow Mopping Mode **Description:** Extra slow and careful mopping for delicate floors. **Impact:** Better cleaning for sensitive floor types that need gentle treatment. --- ### is_egg_mode_supported_from_new_features **Bit Position:** 10 (upper 32 bits) **Feature:** Egg Mode **Description:** Special cleaning pattern that moves in egg-shaped paths. **Impact:** Alternative cleaning pattern for thorough coverage. --- ### is_carpet_show_on_map **Bit Position:** 12 (upper 32 bits) **Feature:** Display Carpets on Map **Description:** Visual indication of detected carpet areas on the map. **Impact:** Users can see which areas the vacuum has identified as carpet. --- ### is_supported_valley_electricity **Bit Position:** 13 (upper 32 bits) **Feature:** Off-Peak Electricity Scheduling **Description:** Schedule charging and intensive tasks during off-peak electricity hours. **Impact:** Reduce electricity costs by charging during cheaper rate periods. --- ### is_unsave_map_reason_supported **Bit Position:** 14 (upper 32 bits) **Feature:** Unsaved Map Reason **Description:** Displays reason why a map wasn't saved after a cleaning session. **Impact:** Better understanding of why maps fail to save. --- ### is_supported_drying **Bit Position:** 15 (upper 32 bits) **Feature:** Mop Drying **Description:** Active mop drying function in compatible docks. **Impact:** Mop pads are actively dried to prevent odors and mildew. --- ### is_supported_download_test_voice **Bit Position:** 16 (upper 32 bits) **Feature:** Download Test Voice Packs **Description:** Ability to download and test voice packs before installation. **Impact:** Preview voice packs before committing to download. --- ### is_support_backup_map **Bit Position:** 17 (upper 32 bits) **Feature:** Map Backup **Description:** Cloud backup of maps to prevent data loss. **Impact:** Maps can be restored after device reset or replacement. --- ### is_support_custom_mode_in_cleaning **Bit Position:** 18 (upper 32 bits) **Feature:** Custom Mode During Cleaning **Description:** Change custom cleaning modes while a cleaning session is in progress. **Impact:** Adjust cleaning intensity on-the-fly without stopping. --- ### is_support_remote_control_in_call **Bit Position:** 19 (upper 32 bits) **Feature:** Remote Control During Video Call **Description:** Manual remote control available during video call sessions. **Impact:** Navigate the vacuum manually while viewing the camera feed. --- ## New Feature String Mask Features These features are encoded in the last 8 characters of the `new_feature_info_str` hex string. ### is_support_set_volume_in_call **Mask:** `1` (position 8) **Feature:** Volume Control in Call **Description:** Adjust audio volume during video call sessions. **Impact:** Better audio experience during two-way communication. --- ### is_support_clean_estimate **Mask:** `2` (position 8) **Feature:** Clean Time Estimation **Description:** Provides estimated cleaning time before starting a session. **Impact:** Users know approximately how long cleaning will take. --- ### is_support_custom_dnd **Mask:** `4` (position 8) **Feature:** Custom Do Not Disturb **Description:** Customizable DND schedules with fine-grained time control. **Impact:** More flexible quiet hours configuration. --- ### is_carpet_deep_clean_supported **Mask:** `8` (position 8) **Feature:** Carpet Deep Clean Mode **Description:** Extra-intense carpet cleaning with multiple passes. **Impact:** Deeper cleaning for heavily soiled carpets. --- ### is_support_stuck_zone **Mask:** `16` (position 8) **Feature:** Stuck Zone Detection **Description:** Automatically marks areas where the vacuum frequently gets stuck. **Impact:** Can create automatic no-go zones for problematic areas. --- ### is_support_custom_door_sill **Mask:** `32` (position 8) **Feature:** Custom Door Sill Height **Description:** Manually set door sill/threshold heights for better navigation. **Impact:** Helps vacuum navigate over thresholds it might otherwise avoid. --- ### is_wifi_manage_supported **Mask:** `128` (position 8) **Feature:** WiFi Management **Description:** Advanced WiFi settings including network switching and diagnostics. **Impact:** Better control over network connectivity. --- ### is_clean_route_fast_mode_supported **Mask:** `256` (position 8) **Feature:** Fast Clean Route Mode **Description:** Optimized routing for faster cleaning at slight coverage cost. **Impact:** Quicker cleaning when thoroughness isn't critical. --- ### is_support_cliff_zone **Mask:** `512` (position 8) **Feature:** Cliff/Drop-off Zone Marking **Description:** Mark areas with cliffs or drop-offs that sensors might miss. **Impact:** Prevents falls in areas where cliff sensors may be unreliable. --- ### is_support_smart_door_sill **Mask:** `1024` (position 8) **Feature:** Smart Door Sill Detection **Description:** Automatic detection and learning of door sill heights. **Impact:** Vacuum learns which thresholds it can cross over time. --- ### is_support_floor_direction **Mask:** `2048` (position 8) **Feature:** Floor Direction/Grain **Description:** Set floor grain direction for optimized mopping along wood grain. **Impact:** Better mopping results on hardwood floors. --- ### is_back_charge_auto_wash_supported **Mask:** `4096` (position 8) **Feature:** Auto-Wash Before Charging **Description:** Automatically wash mop when returning to charge. **Impact:** Mop is always clean and ready for next session. --- ### is_support_incremental_map **Mask:** `4194304` (position 8) **Feature:** Incremental Mapping **Description:** Continuously update and refine maps with each cleaning. **Impact:** Maps improve over time with more cleaning sessions. --- ### is_offline_map_supported **Mask:** `16384` (position 8) **Feature:** Offline Map Access **Description:** View and edit maps without internet connection. **Impact:** Full functionality even when cloud services are unavailable. --- ### is_super_deep_wash_supported **Mask:** `32768` (position 8) **Feature:** Super Deep Mop Wash **Description:** Extended and intensive mop washing cycle in dock. **Impact:** Thoroughly clean heavily soiled mop pads. --- ### is_ces_2022_supported **Mask:** `65536` (position 8) **Feature:** CES 2022 Features **Description:** Features demonstrated at CES 2022 trade show. **Impact:** Early access or beta features from product announcements. --- ### is_dss_believable **Mask:** `131072` (position 8) **Feature:** DSS (Dirt Detect System) Believable **Description:** Improved dirt detection system reliability. **Impact:** More accurate dirty area detection and focused cleaning. --- ### is_main_brush_up_down_supported_from_str **Mask:** `262144` (position 8) **Feature:** Main Brush Lift **Description:** Main brush can be raised/lowered automatically (for carpets/hard floors). **Impact:** Optimized brush engagement for different floor types. --- ### is_goto_pure_clean_path_supported **Mask:** `524288` (position 8) **Feature:** Pure Clean Path Navigation **Description:** Navigate to cleaning areas using pure/clean paths. **Impact:** Avoids dirty areas when navigating to next cleaning zone. --- ### is_water_up_down_drain_supported **Mask:** `1048576` (position 8) **Feature:** Water Tank Auto Drain **Description:** Automatic water tank drainage system. **Impact:** Prevents stagnant water and simplifies maintenance. --- ### is_setting_carpet_first_supported **Mask:** `8388608` (position 8) **Feature:** Carpet-First Cleaning **Description:** Option to clean all carpeted areas first before hard floors. **Impact:** Vacuum-only areas completed before mopping begins. --- ### is_clean_route_deep_slow_plus_supported **Mask:** `16777216` (position 8) **Feature:** Deep Slow Plus Route **Description:** Extra slow and thorough cleaning route for maximum coverage. **Impact:** Most thorough cleaning at expense of time. --- ### is_dynamically_skip_clean_zone_supported **Mask:** `33554432` (position 8) **Feature:** Dynamic Zone Skipping **Description:** Automatically skip certain zones based on conditions (e.g., doors closed). **Impact:** More intelligent cleaning that adapts to real-time conditions. --- ### is_dynamically_add_clean_zones_supported **Mask:** `67108864` (position 8) **Feature:** Dynamic Zone Addition **Description:** Automatically add new cleaning zones during a session. **Impact:** Expand cleaning area on-the-fly when needed. --- ### is_left_water_drain_supported **Mask:** `134217728` (position 8) **Feature:** Residual Water Drainage **Description:** Drain all remaining water from tank and system. **Impact:** Completely empty water system for storage or travel. --- ### is_clean_count_setting_supported **Mask:** `1073741824` (position 8) **Feature:** Multi-Pass Clean Count **Description:** Configure number of cleaning passes per area. **Impact:** Set how many times vacuum should clean each area (1-3 passes). --- ### is_corner_clean_mode_supported **Mask:** `2147483648` (position 8) **Feature:** Corner Cleaning Mode **Description:** Special mode for intensive corner and edge cleaning. **Impact:** Better cleaning along walls and in corners. --- ## New Feature String Bit Features These features are encoded as individual bits in the `new_feature_info_str` hex string. ### is_two_key_real_time_video_supported **Bit Index:** 32 **Feature:** Two-Key Real-Time Video **Description:** Real-time video requires two-step activation for privacy. **Impact:** Additional privacy protection for camera access. --- ### is_two_key_rtv_in_charging_supported **Bit Index:** 33 **Feature:** Two-Key RTV While Charging **Description:** Two-step activation required for video while charging. **Impact:** Privacy protection even when vacuum is docked. --- ### is_dirty_replenish_clean_supported **Bit Index:** 34 **Feature:** Dirty Area Replenishment Clean **Description:** Automatic return to re-clean areas detected as still dirty. **Impact:** Ensures heavily soiled areas get adequate cleaning. --- ### is_auto_delivery_field_in_global_status_supported **Bit Index:** 35 **Feature:** Auto-Delivery Status Field **Description:** Status field for automatic detergent/cleaner delivery systems. **Impact:** Monitor and control automatic cleaning solution dispensing. --- ### is_avoid_collision_mode_supported **Bit Index:** 36 **Feature:** Collision Avoidance Mode **Description:** Enhanced mode for avoiding collisions with obstacles. **Impact:** Gentler navigation around furniture and objects. --- ### is_voice_control_supported **Bit Index:** 37 **Feature:** Voice Control **Description:** Voice command support (Alexa, Google Assistant, etc.). **Impact:** Hands-free vacuum control via voice assistants. --- ### is_new_endpoint_supported **Bit Index:** 38 **Feature:** New API Endpoint **Description:** Support for new/updated API endpoints for app communication. **Impact:** Access to latest features and improved communication protocol. --- ### is_pumping_water_supported **Bit Index:** 39 **Feature:** Water Pumping System **Description:** Active water pumping for precise water delivery control. **Impact:** More consistent and controllable water flow for mopping. --- ### is_corner_mop_stretch_supported **Bit Index:** 40 **Feature:** Corner Mop Stretch/Extension **Description:** Mop pad extends or stretches to reach corners and edges. **Impact:** Better mopping coverage in corners and along baseboards. --- ### is_hot_wash_towel_supported **Bit Index:** 41 **Feature:** Hot Water Mop Washing **Description:** Dock uses hot water to wash mop pads. **Impact:** Better mop cleaning and sanitization. --- ### is_floor_dir_clean_any_time_supported **Bit Index:** 42 **Feature:** Floor Direction Clean Anytime **Description:** Apply floor direction settings at any time, not just during setup. **Impact:** Adjust floor grain direction after initial mapping. --- ### is_pet_supplies_deep_clean_supported **Bit Index:** 43 **Feature:** Pet Supplies Deep Clean **Description:** Special deep cleaning mode for areas with pet supplies (bowls, toys, beds). **Impact:** More thorough cleaning around pet areas. --- ### is_mop_shake_water_max_supported **Bit Index:** 45 **Feature:** Maximum Mop Shake Water Mode **Description:** Highest water flow setting for mop vibration mode. **Impact:** Most aggressive mopping for stubborn stains. --- ### is_exact_custom_mode_supported **Bit Index:** 47 **Feature:** Exact Custom Mode **Description:** Precise custom mode configuration with fine-grained control. **Impact:** More granular control over cleaning parameters. --- ### is_video_patrol_supported **Bit Index:** 48 **Feature:** Video Patrol Mode **Description:** Autonomous patrol mode with video recording for home monitoring. **Impact:** Use vacuum as mobile security camera. --- ### is_carpet_custom_clean_supported **Bit Index:** 49 **Feature:** Carpet Custom Clean **Description:** Custom cleaning modes specifically for carpet areas. **Impact:** Different cleaning strategies for different carpet types. --- ### is_pet_snapshot_supported **Bit Index:** 50 **Feature:** Pet Snapshot **Description:** Automatically capture photos of detected pets during cleaning. **Impact:** Get photos of pets throughout the day. --- ### is_custom_clean_mode_count_supported **Bit Index:** 51 **Feature:** Custom Clean Mode Count **Description:** Support for multiple custom cleaning modes (more than default). **Impact:** Create and save more than the standard 3 custom modes. --- ### is_new_ai_recognition_supported **Bit Index:** 52 **Feature:** New AI Recognition **Description:** Updated AI recognition algorithms for obstacles and objects. **Impact:** Better object detection and classification. --- ### is_auto_collection_2_supported **Bit Index:** 53 **Feature:** Auto-Empty 2.0 **Description:** Second generation auto-empty dock system. **Impact:** Improved dustbin emptying with better suction and reliability. --- ### is_right_brush_stretch_supported **Bit Index:** 54 **Feature:** Right Side Brush Extension **Description:** Right side brush extends outward for better edge cleaning. **Impact:** Better cleaning along walls and edges. --- ### is_smart_clean_mode_set_supported **Bit Index:** 55 **Feature:** Smart Clean Mode Settings **Description:** AI-powered automatic cleaning mode selection. **Impact:** Vacuum automatically chooses optimal cleaning mode per room. --- ### is_dirty_object_detect_supported **Bit Index:** 56 **Feature:** Dirty Object Detection **Description:** Detect and focus on dirty objects/areas during cleaning. **Impact:** More attention to visibly dirty spots. --- ### is_no_need_carpet_press_set_supported **Bit Index:** 57 **Feature:** No Carpet Pressure Setting Needed **Description:** Automatic carpet pressure adjustment without manual configuration. **Impact:** Simplified setup with automatic carpet handling. --- ### is_voice_control_led_supported **Bit Index:** 58 **Feature:** Voice Control LED Indicator **Description:** LED indicator for voice control status. **Impact:** Visual feedback when voice assistant is listening. --- ### is_water_leak_check_supported **Bit Index:** 60 **Feature:** Water Leak Detection **Description:** Sensors to detect water leaks from tank or mop system. **Impact:** Alerts for water system issues before damage occurs. --- ### is_min_battery_15_to_clean_task_supported **Bit Index:** 62 **Feature:** 15% Minimum Battery for Cleaning **Description:** Requires at least 15% battery to start cleaning task. **Impact:** Prevents starting cleaning with insufficient battery. --- ### is_gap_deep_clean_supported **Bit Index:** 63 **Feature:** Gap Deep Cleaning **Description:** Special mode for cleaning gaps and crevices. **Impact:** Better cleaning in tight spaces between furniture. --- ### is_object_detect_check_supported **Bit Index:** 64 **Feature:** Object Detection Check **Description:** Verification system for object detection accuracy. **Impact:** Improved reliability of obstacle avoidance. --- ### is_identify_room_supported **Bit Index:** 66 **Feature:** Room Identification **Description:** AI-based room type identification (bedroom, kitchen, etc.). **Impact:** Automatic cleaning mode selection based on room type. --- ### is_matter_supported **Bit Index:** 67 **Feature:** Matter Protocol Support **Description:** Support for Matter smart home standard. **Impact:** Integration with Matter-compatible smart home systems. --- ### is_workday_holiday_supported **Bit Index:** 69 **Feature:** Workday/Holiday Scheduling **Description:** Different schedules for workdays vs. holidays/weekends. **Impact:** Flexible scheduling that adapts to your routine. --- ### is_clean_direct_status_supported **Bit Index:** 70 **Feature:** Cleaning Direction Status **Description:** Display current cleaning direction and path on map. **Impact:** See which direction vacuum is moving in real-time. --- ### is_map_eraser_supported **Bit Index:** 71 **Feature:** Map Eraser Tool **Description:** Tool to erase portions of map for re-mapping. **Impact:** Selectively re-map problem areas without full re-scan. --- ### is_optimize_battery_supported **Bit Index:** 72 **Feature:** Battery Optimization **Description:** Smart charging and battery management for longevity. **Impact:** Extended battery lifespan through optimized charging. --- ### is_activate_video_charging_and_standby_supported **Bit Index:** 73 **Feature:** Video in Charging/Standby **Description:** Enable video camera when docked or in standby. **Impact:** Use vacuum as stationary camera when not cleaning. --- ### is_carpet_long_haired_supported **Bit Index:** 75 **Feature:** Long-Haired Carpet Mode **Description:** Special mode for high-pile or shag carpets. **Impact:** Better cleaning on thick, plush carpets. --- ### is_clean_history_time_line_supported **Bit Index:** 76 **Feature:** Timeline Clean History **Description:** Timeline view of cleaning history with visual map playback. **Impact:** See cleaning progression over time with animated replay. --- ### is_max_zone_opened_supported **Bit Index:** 77 **Feature:** Maximum Zone Expansion **Description:** Support for larger number of zones than default. **Impact:** Create more cleaning zones and no-go areas. --- ### is_exhibition_function_supported **Bit Index:** 78 **Feature:** Exhibition/Demo Mode **Description:** Special mode for retail display and demonstrations. **Impact:** Retail demo functionality for stores. --- ### is_lds_lifting_supported **Bit Index:** 79 **Feature:** LDS Sensor Lifting **Description:** LDS (laser) sensor can retract/lower for low clearance areas. **Impact:** Clean under low furniture that would block fixed LDS. --- ### is_auto_tear_down_mop_supported **Bit Index:** 80 **Feature:** Auto Mop Removal **Description:** Automatically detach mop when returning to dock. **Impact:** Simplified mop maintenance and drying. --- ### is_small_side_mop_supported **Bit Index:** 81 **Feature:** Small Side Mop **Description:** Additional small mop on side for edge mopping. **Impact:** Better mopping along baseboards and edges. --- ### is_support_side_brush_up_down_supported **Bit Index:** 82 **Feature:** Side Brush Lift **Description:** Side brush can lift up/down for carpet vs. hard floor. **Impact:** Optimal brush position for different floor types. --- ### is_dry_interval_timer_supported **Bit Index:** 83 **Feature:** Mop Drying Interval Timer **Description:** Scheduled mop pad drying at set intervals. **Impact:** Prevent mildew with regular drying cycles. --- ### is_uvc_sterilize_supported **Bit Index:** 84 **Feature:** UVC Sterilization **Description:** UVC light for sanitizing mop pads or dustbin. **Impact:** Kills bacteria and germs on mop or in dust collection. --- ### is_midway_back_to_dock_supported **Bit Index:** 85 **Feature:** Midway Return to Dock **Description:** Return to dock during cleaning for mop wash or dust emptying. **Impact:** Maintain clean mop and empty dustbin during long cleaning sessions. --- ### is_support_main_brush_up_down_supported **Bit Index:** 86 **Feature:** Main Brush Lift (duplicate) **Description:** Main brush height adjustment capability. **Impact:** Same as is_main_brush_up_down_supported_from_str. --- ### is_egg_dance_mode_supported **Bit Index:** 87 **Feature:** Egg Dance Cleaning Mode **Description:** Egg-shaped cleaning dance pattern. **Impact:** Alternative thorough cleaning pattern. --- ### is_mechanical_arm_mode_supported / is_tidyup_zones_supported **Bit Index:** 89 **Feature:** Mechanical Arm / Tidy-Up Zones **Description:** Robotic arm for object manipulation or designated tidy-up zones. **Impact:** Can move small objects or perform tidy-up tasks in specific zones. --- ### is_clean_time_line_supported **Bit Index:** 91 **Feature:** Clean Timeline View **Description:** Visual timeline of cleaning sessions. **Impact:** See cleaning history in chronological timeline format. --- ### is_clean_then_mop_mode_supported **Bit Index:** 93 **Feature:** Vacuum-Then-Mop Mode **Description:** Separate vacuum and mop passes (vacuum entire area first, then mop). **Impact:** More thorough cleaning with dedicated vacuum and mop phases. --- ### is_type_identify_supported **Bit Index:** 94 **Feature:** Object Type Identification **Description:** Identify types of objects encountered (shoe, cable, pet waste, etc.). **Impact:** Better obstacle handling based on object type. --- ### is_support_get_particular_status_supported **Bit Index:** 96 **Feature:** Get Particular Status **Description:** API support for querying specific status fields. **Impact:** More efficient status updates with targeted queries. --- ### is_three_d_mapping_inner_test_supported **Bit Index:** 97 **Feature:** 3D Mapping Internal Test **Description:** Beta/test mode for 3D mapping features. **Impact:** Early access to 3D mapping capabilities. --- ### is_sync_server_name_supported **Bit Index:** 98 **Feature:** Sync Server Name **Description:** Synchronize device name with cloud servers. **Impact:** Consistent device naming across all platforms. --- ### is_should_show_arm_over_load_supported **Bit Index:** 99 **Feature:** Arm Overload Warning **Description:** Display warning when mechanical arm is overloaded. **Impact:** Prevent damage to robotic arm from excessive weight. --- ### is_collect_dust_count_show_supported **Bit Index:** 100 **Feature:** Dust Collection Count Display **Description:** Show number of times dustbin has been auto-emptied. **Impact:** Track dock usage and maintenance needs. --- ### is_support_api_app_stop_grasp_supported **Bit Index:** 101 **Feature:** App Stop Grasp Command **Description:** API command to stop robotic arm grasping action. **Impact:** Emergency stop for arm operations. --- ### is_ctm_with_repeat_supported **Bit Index:** 102 **Feature:** Custom Time Mode with Repeat **Description:** Custom scheduled cleaning with repeat patterns. **Impact:** More flexible scheduling options. --- ### is_side_brush_lift_carpet_supported **Bit Index:** 104 **Feature:** Side Brush Lift on Carpet **Description:** Automatically lift side brush when on carpet. **Impact:** Prevents brush from scattering debris on carpet. --- ### is_detect_wire_carpet_supported **Bit Index:** 105 **Feature:** Wire/Carpet Detection **Description:** Detect wires or cables on carpet surfaces. **Impact:** Avoid tangling in cables on carpeted areas. --- ### is_water_slide_mode_supported **Bit Index:** 106 **Feature:** Water Slide Mode **Description:** Gradual water flow adjustment during mopping. **Impact:** Optimize water usage throughout cleaning session. --- ### is_soak_and_wash_supported **Bit Index:** 107 **Feature:** Soak and Wash **Description:** Pre-soak mop pads before washing in dock. **Impact:** Better mop cleaning for dried or stubborn dirt. --- ### is_clean_efficiency_supported **Bit Index:** 108 **Feature:** Clean Efficiency Mode **Description:** Optimized cleaning for maximum efficiency vs. thoroughness balance. **Impact:** Faster cleaning with acceptable thoroughness trade-off. --- ### is_back_wash_new_smart_supported **Bit Index:** 109 **Feature:** Smart Back Wash **Description:** Intelligent mop back-washing with dirt detection. **Impact:** Wash mop more when it's dirtier, less when cleaner. --- ### is_dual_band_wi_fi_supported **Bit Index:** 110 **Feature:** Dual-Band WiFi (2.4GHz + 5GHz) **Description:** Support for both 2.4GHz and 5GHz WiFi networks. **Impact:** Better WiFi connectivity options and performance. --- ### is_program_mode_supported **Bit Index:** 111 **Feature:** Program Mode **Description:** Programmable cleaning sequences and routines. **Impact:** Create complex cleaning programs with multiple steps. --- ### is_clean_fluid_delivery_supported **Bit Index:** 112 **Feature:** Cleaning Fluid Delivery **Description:** Automatic delivery of cleaning solution/detergent. **Impact:** Enhanced mopping with cleaning agents. --- ### is_carpet_long_haired_ex_supported **Bit Index:** 113 **Feature:** Long-Haired Carpet Extended Mode **Description:** Extended/enhanced mode for extra-thick carpets. **Impact:** Even better performance on very thick pile carpets. --- ### is_over_sea_ctm_supported **Bit Index:** 114 **Feature:** Overseas Custom Time Mode **Description:** Custom scheduling for international/overseas regions. **Impact:** Support for different time zones and regional calendars. --- ### is_full_duples_switch_supported **Bit Index:** 115 **Feature:** Full Duplex Communication **Description:** Two-way simultaneous audio communication during video calls. **Impact:** Real-time conversation through vacuum's speaker/microphone. --- ### is_low_area_access_supported **Bit Index:** 116 **Feature:** Low Area Access **Description:** Special mode for accessing very low clearance areas. **Impact:** Clean under furniture with minimal clearance. --- ### is_follow_low_obs_supported **Bit Index:** 117 **Feature:** Follow Low Obstacles **Description:** Navigate closely along low obstacles. **Impact:** Clean more effectively around low furniture. --- ### is_two_gears_no_collision_supported **Bit Index:** 118 **Feature:** Two-Gear No Collision Mode **Description:** Two-level collision avoidance sensitivity. **Impact:** Adjustable collision avoidance aggressiveness. --- ### is_carpet_shape_type_supported **Bit Index:** 119 **Feature:** Carpet Shape Type Detection **Description:** Detect and classify carpet shapes (rectangular, round, runner, etc.). **Impact:** Better carpet handling based on shape and placement. --- ### is_sr_map_supported **Bit Index:** 120 **Feature:** SR (Super Resolution) Map **Description:** High-resolution map rendering and display. **Impact:** More detailed and accurate map visualization. --- ## Robot Features (Array-based) These features are checked by verifying if the feature ID exists in the `feature_info` array. ### is_led_status_switch_supported **Feature ID:** 119 **Feature:** LED Status Switch **Description:** Control and toggle LED indicators on/off. **Impact:** Turn off LEDs for bedrooms or light-sensitive areas. --- ### is_multi_floor_supported **Feature ID:** 120 **Feature:** Multi-Floor Mapping **Description:** Save and manage maps for multiple floors/levels. **Impact:** Use same vacuum on multiple floors with different maps. --- ### is_support_fetch_timer_summary **Feature ID:** 122 **Feature:** Fetch Timer Summary **Description:** Retrieve summary of all scheduled cleaning timers. **Impact:** View all schedules at a glance. --- ### is_order_clean_supported **Feature ID:** 123 **Feature:** Room Order Cleaning **Description:** Clean rooms in specified order. **Impact:** Control cleaning sequence (e.g., clean bedroom last). --- ### is_analysis_supported **Feature ID:** 124 **Feature:** Cleaning Analysis **Description:** Detailed analysis and statistics of cleaning performance. **Impact:** Insights into cleaning effectiveness and coverage. --- ### is_remote_supported **Feature ID:** 125 **Feature:** Remote Control **Description:** Manual remote control mode via app. **Impact:** Drive vacuum manually like an RC car. --- ### is_support_voice_control_debug **Feature ID:** 130 **Feature:** Voice Control Debug Mode **Description:** Debug mode for voice control features. **Impact:** Troubleshoot voice command issues. --- ## Model-Specific Features These features are enabled/disabled based on specific device model whitelists or blacklists. ### is_mop_forbidden_supported **Model Whitelist:** TANOSV, TOPAZSV, TANOS, TANOSE, TANOSSLITE, TANOSS, TANOSSPLUS, TANOSSMAX, ULTRON, ULTRONLITE, PEARL, RUBYSLITE **Feature:** Mop-Forbidden Zones **Description:** Mark areas where mopping is forbidden (carpet protection). **Impact:** Mop automatically lifts or avoids designated no-mop zones. --- ### is_soft_clean_mode_supported **Model Whitelist:** TANOSV, TANOSE, TANOS **Feature:** Soft Clean Mode **Description:** Gentle cleaning mode for delicate floors. **Impact:** Reduced suction and brush speed for sensitive surfaces. --- ### is_custom_mode_supported **Model Blacklist:** TANOS (all except TANOS) **Feature:** Custom Cleaning Mode **Description:** User-defined custom cleaning modes. **Impact:** Create personalized cleaning modes with specific parameters. --- ### is_support_custom_carpet **Model Whitelist:** ULTRONLITE **Feature:** Custom Carpet Settings **Description:** Advanced carpet-specific customization options. **Impact:** Fine-tune carpet cleaning behavior. --- ### is_show_general_obstacle_supported **Model Whitelist:** TANOSSPLUS **Feature:** Show General Obstacles **Description:** Display generic obstacle markers on map. **Impact:** See where obstacles were encountered during cleaning. --- ### is_show_obstacle_photo_supported **Model Whitelist:** TANOSSPLUS, TANOSSMAX, ULTRON **Feature:** Obstacle Photos **Description:** Capture and display photos of detected obstacles. **Impact:** Visual verification of what vacuum avoided. --- ### is_rubber_brush_carpet_supported **Model Whitelist:** ULTRONLITE **Feature:** Rubber Brush Carpet Mode **Description:** Special mode for rubber brush on carpets. **Impact:** Optimized rubber brush performance on carpets. --- ### is_carpet_pressure_use_origin_paras_supported **Model Whitelist:** ULTRONLITE **Feature:** Original Carpet Pressure Parameters **Description:** Use original firmware carpet pressure settings. **Impact:** Factory-calibrated carpet cleaning pressure. --- ### is_support_mop_back_pwm_set **Model Whitelist:** PEARL **Feature:** Mop Back PWM Settings **Description:** PWM (Pulse Width Modulation) control for mop motor. **Impact:** Fine-grained mop vibration control. --- ### is_collect_dust_mode_supported **Model Blacklist:** PEARL (all except PEARL) **Feature:** Dust Collection Mode **Description:** Auto-empty dock dust collection. **Impact:** Automatic dustbin emptying at dock. --- ## Product Features These features are determined by hardware capabilities and product variant. ### is_support_water_mode **Product Features:** MOP_ELECTRONIC_MODULE, MOP_SHAKE_MODULE, MOP_SPIN_MODULE **Feature:** Water Mode Control **Description:** Electronic control over water flow for mopping. **Impact:** Adjustable water flow rates for different floor wetness. --- ### is_pure_clean_mop_supported **Product Features:** CLEANMODE_PURECLEANMOP **Feature:** Pure Clean Mop Mode **Description:** Ultra-clean mopping mode for pristine floors. **Impact:** Maximum mopping effectiveness for deep cleaning. --- ### is_new_remote_view_supported **Product Features:** REMOTE_BACK **Feature:** New Remote View Interface **Description:** Updated UI for remote control view. **Impact:** Improved user experience for manual control. --- ### is_max_plus_mode_supported **Product Features:** CLEANMODE_MAXPLUS **Feature:** Max+ Suction Mode **Description:** Maximum suction power mode. **Impact:** Highest cleaning power for heavily soiled areas. --- ### is_none_pure_clean_mop_with_max_plus **Product Features:** CLEANMODE_NONE_PURECLEANMOP_WITH_MAXPLUS **Feature:** Max+ Without Pure Clean Mop **Description:** Max+ suction available but not pure clean mop mode. **Impact:** High suction models without advanced mopping. --- ### is_clean_route_setting_supported **Product Features:** MOP_SHAKE_MODULE, MOP_SPIN_MODULE **Feature:** Clean Route Settings **Description:** Configure cleaning route patterns. **Impact:** Choose between different cleaning patterns (zigzag, edge-first, etc.). --- ### is_mop_shake_module_supported **Product Features:** MOP_SHAKE_MODULE **Feature:** Vibrating/Shaking Mop Module **Description:** Hardware vibrating mop for better cleaning. **Impact:** Enhanced mopping effectiveness through vibration. --- ### is_customized_clean_supported **Product Features:** MOP_SHAKE_MODULE, MOP_SPIN_MODULE **Feature:** Customized Clean Settings **Description:** Advanced customization for mop-equipped models. **Impact:** Detailed control over mopping parameters. Python-roborock-python-roborock-d6da2db/examples/000077500000000000000000000000001513363643200223425ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/examples/example.py000066400000000000000000000054071513363643200243550ustar00rootroot00000000000000"""Example script demonstrating how to connect to Roborock devices and print their status.""" import asyncio import dataclasses import json import pathlib from typing import Any from roborock.devices.device_manager import UserParams, create_device_manager from roborock.devices.file_cache import FileCache, load_value, store_value from roborock.web_api import RoborockApiClient # We typically store the login credentials/information separately from other cached data. USER_PARAMS_PATH = pathlib.Path.home() / ".cache" / "roborock-user-params.pkl" # Device connection information is cached to speed up future connections. CACHE_PATH = pathlib.Path.home() / ".cache" / "roborock-cache-data.pkl" async def login_flow() -> UserParams: """Perform the login flow to obtain UserData from the web API.""" username = input("Email: ") web_api = RoborockApiClient(username=username) print("Requesting login code sent to email...") await web_api.request_code() code = input("Code: ") user_data = await web_api.code_login(code) # We store the base_url to avoid future discovery calls. base_url = await web_api.base_url return UserParams( username=username, user_data=user_data, base_url=base_url, ) async def get_or_create_session() -> UserParams: """Initialize the session by logging in if necessary.""" user_params = await load_value(USER_PARAMS_PATH) if user_params is None: print("No cached login data found, please login.") user_params = await login_flow() print("Login successful, caching login data...") await store_value(USER_PARAMS_PATH, user_params) print(f"Cached login data to {USER_PARAMS_PATH}.") return user_params def remove_none_values(data: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in data.items() if v is not None} async def main(): user_params = await get_or_create_session() cache = FileCache(CACHE_PATH) # Create a device manager that can discover devices. device_manager = await create_device_manager(user_params, cache=cache) devices = await device_manager.get_devices() # Get all vacuum devices that support the v1 PropertiesApi device_results = [] for device in devices: if not device.v1_properties: continue # Refresh the current device status status_trait = device.v1_properties.status await status_trait.refresh() # Print the device status as JSON device_results.append( { "device": device.name, "status": remove_none_values(dataclasses.asdict(status_trait)), } ) print(json.dumps(device_results, indent=2)) await cache.flush() if __name__ == "__main__": asyncio.run(main()) Python-roborock-python-roborock-d6da2db/mypy.ini000066400000000000000000000001211513363643200222150ustar00rootroot00000000000000[mypy] check_untyped_defs = True [mypy-construct] ignore_missing_imports = True Python-roborock-python-roborock-d6da2db/pyproject.toml000066400000000000000000000051021513363643200234360ustar00rootroot00000000000000[project] name = "python-roborock" version = "4.7.2" description = "A package to control Roborock vacuums." authors = [{ name = "humbertogontijo", email = "humbertogontijo@users.noreply.github.com" }, {name="Lash-L"}, {name="allenporter"}] requires-python = ">=3.11, <4" readme = "README.md" license = "GPL-3.0-only" keywords = [ "roborock", "vacuum", "homeassistant", ] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Natural Language :: English", "Operating System :: OS Independent", "Topic :: Software Development :: Libraries", ] dependencies = [ "click>=8", "aiohttp>=3.8.2,<4", "pycryptodome~=3.18", "pycryptodomex~=3.18 ; sys_platform == 'darwin'", "paho-mqtt>=1.6.1,<3.0.0", "construct>=2.10.57,<3", "vacuum-map-parser-roborock", "pyrate-limiter>=3.7.0,<4", "aiomqtt>=2.5.0,<3", "click-shell~=2.1", ] [project.urls] Repository = "https://github.com/humbertogontijo/python-roborock" Documentation = "https://python-roborock.readthedocs.io/" [project.scripts] roborock = "roborock.cli:main" [dependency-groups] dev = [ "pytest-asyncio>=1.1.0", "pytest", "pre-commit>=3.5,<5.0", "mypy", "ruff==0.14.11", "codespell", "pyshark>=0.6,<0.7", "aioresponses>=0.7.7,<0.8", "freezegun>=1.5.1,<2", "pytest-timeout>=2.3.1,<3", "syrupy>=4.9.1,<6", "pdoc>=15.0.4,<17", "pyyaml>=6.0.3", "pyshark>=0.6", "pytest-cov>=7.0.0", ] [tool.hatch.build.targets.sdist] include = ["roborock"] [tool.hatch.build.targets.wheel] include = ["roborock"] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.semantic_release] branch = "main" version_toml = ["pyproject.toml:project.version"] build_command = "pip install uv && uv lock --upgrade-package python-roborock && git add uv.lock && uv build" changelog_file = 'CHANGELOG.md' commit = true [tool.semantic_release.branches.main] match = "main" prerelease = false [tool.semantic_release.branches.temp-main-branch] match = "temp-main-branch" prerelease = false [tool.semantic_release.commit_parser_options] allowed_tags = [ "chore", "docs", "feat", "fix", "refactor" ] major_tags= ["refactor"] [tool.ruff] lint.ignore = ["F403", "E741"] lint.select=["E", "F", "UP", "I"] line-length = 120 [tool.ruff.lint.per-file-ignores] "*/__init__.py" = ["F401"] [tool.pytest.ini_options] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" timeout = 30 log_format = "%(asctime)s.%(msecs)03d %(levelname)s (%(threadName)s) [%(name)s] %(message)s" Python-roborock-python-roborock-d6da2db/roborock/000077500000000000000000000000001513363643200223445ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/roborock/__init__.py000066400000000000000000000005671513363643200244650ustar00rootroot00000000000000"""Roborock API. .. include:: ../README.md """ from roborock.data import * from roborock.exceptions import * from roborock.roborock_typing import * from . import ( const, data, devices, exceptions, roborock_typing, web_api, ) __all__ = [ "devices", "data", "map", "web_api", "roborock_typing", "exceptions", "const", ] Python-roborock-python-roborock-d6da2db/roborock/broadcast_protocol.py000066400000000000000000000077231513363643200266120ustar00rootroot00000000000000from __future__ import annotations import asyncio import hashlib import json import logging from asyncio import BaseTransport, Lock from construct import ( # type: ignore Bytes, Checksum, GreedyBytes, Int16ub, Int32ub, Prefixed, RawCopy, Struct, ) from Crypto.Cipher import AES from roborock import RoborockException from roborock.data import BroadcastMessage from roborock.protocol import EncryptionAdapter, Utils, _Parser _LOGGER = logging.getLogger(__name__) BROADCAST_TOKEN = b"qWKYcdQWrbm9hPqe" class RoborockProtocol(asyncio.DatagramProtocol): def __init__(self, timeout: int = 5): self.timeout = timeout self.transport: BaseTransport | None = None self.devices_found: list[BroadcastMessage] = [] self._mutex = Lock() def datagram_received(self, data: bytes, _): """Handle incoming broadcast datagrams.""" try: version = data[:3] if version == b"L01": [parsed_msg], _ = L01Parser.parse(data) encrypted_payload = parsed_msg.payload if encrypted_payload is None: raise RoborockException("No encrypted payload found in broadcast message") ciphertext = encrypted_payload[:-16] tag = encrypted_payload[-16:] key = hashlib.sha256(BROADCAST_TOKEN).digest() iv_digest_input = data[:9] digest = hashlib.sha256(iv_digest_input).digest() iv = digest[:12] cipher = AES.new(key, AES.MODE_GCM, nonce=iv) decrypted_payload_bytes = cipher.decrypt_and_verify(ciphertext, tag) json_payload = json.loads(decrypted_payload_bytes) parsed_message = BroadcastMessage(duid=json_payload["duid"], ip=json_payload["ip"], version=version) _LOGGER.debug(f"Received L01 broadcast: {parsed_message}") self.devices_found.append(parsed_message) else: # Fallback to the original protocol parser for other versions [broadcast_message], _ = BroadcastParser.parse(data) if broadcast_message.payload: json_payload = json.loads(broadcast_message.payload) parsed_message = BroadcastMessage(duid=json_payload["duid"], ip=json_payload["ip"], version=version) _LOGGER.debug(f"Received broadcast: {parsed_message}") self.devices_found.append(parsed_message) except Exception as e: _LOGGER.warning(f"Failed to decode message: {data!r}. Error: {e}") async def discover(self) -> list[BroadcastMessage]: async with self._mutex: try: loop = asyncio.get_event_loop() self.transport, _ = await loop.create_datagram_endpoint(lambda: self, local_addr=("0.0.0.0", 58866)) await asyncio.sleep(self.timeout) return self.devices_found finally: self.close() self.devices_found = [] def close(self): self.transport.close() if self.transport else None _BroadcastMessage = Struct( "message" / RawCopy( Struct( "version" / Bytes(3), "seq" / Int32ub, "protocol" / Int16ub, "payload" / EncryptionAdapter(lambda ctx: BROADCAST_TOKEN), ) ), "checksum" / Checksum(Int32ub, Utils.crc, lambda ctx: ctx.message.data), ) _L01BroadcastMessage = Struct( "message" / RawCopy( Struct( "version" / Bytes(3), "field1" / Bytes(4), # Unknown field "field2" / Bytes(2), # Unknown field "payload" / Prefixed(Int16ub, GreedyBytes), # Encrypted payload with length prefix ) ), "checksum" / Checksum(Int32ub, Utils.crc, lambda ctx: ctx.message.data), ) BroadcastParser: _Parser = _Parser(_BroadcastMessage, False) L01Parser: _Parser = _Parser(_L01BroadcastMessage, False) Python-roborock-python-roborock-d6da2db/roborock/callbacks.py000066400000000000000000000102651513363643200246410ustar00rootroot00000000000000"""Module for managing callback utility functions.""" import logging from collections.abc import Callable from typing import Generic, TypeVar _LOGGER = logging.getLogger(__name__) K = TypeVar("K") V = TypeVar("V") def safe_callback( callback: Callable[[V], None], logger: logging.Logger | logging.LoggerAdapter | None = None ) -> Callable[[V], None]: """Wrap a callback to catch and log exceptions. This is useful for ensuring that errors in callbacks do not propagate and cause unexpected behavior. Any failures during callback execution will be logged. """ if logger is None: logger = _LOGGER def wrapper(value: V) -> None: try: callback(value) except Exception as ex: # noqa: BLE001 logger.error("Uncaught error in callback '%s': %s", callback.__name__, ex) return wrapper class CallbackMap(Generic[K, V]): """A mapping of callbacks for specific keys. This allows for registering multiple callbacks for different keys and invoking them when a value is received for a specific key. """ def __init__(self, logger: logging.Logger | logging.LoggerAdapter | None = None) -> None: self._callbacks: dict[K, list[Callable[[V], None]]] = {} self._logger = logger or _LOGGER def keys(self) -> list[K]: """Get all keys in the callback map.""" return list(self._callbacks.keys()) def add_callback(self, key: K, callback: Callable[[V], None]) -> Callable[[], None]: """Add a callback for a specific key. Any failures during callback execution will be logged. Returns a callable that can be used to remove the callback. """ self._callbacks.setdefault(key, []).append(callback) def remove_callback() -> None: """Remove the callback for the specific key.""" if cb_list := self._callbacks.get(key): cb_list.remove(callback) if not cb_list: del self._callbacks[key] return remove_callback def get_callbacks(self, key: K) -> list[Callable[[V], None]]: """Get all callbacks for a specific key.""" return self._callbacks.get(key, []) def __call__(self, key: K, value: V) -> None: """Invoke all callbacks for a specific key.""" for callback in self.get_callbacks(key): safe_callback(callback, self._logger)(value) class CallbackList(Generic[V]): """A list of callbacks that can be invoked. This combines a list of callbacks into a single callable. Callers can add additional callbacks to the list at any time. """ def __init__(self, logger: logging.Logger | logging.LoggerAdapter | None = None) -> None: self._callbacks: list[Callable[[V], None]] = [] self._logger = logger or _LOGGER def add_callback(self, callback: Callable[[V], None]) -> Callable[[], None]: """Add a callback to the list. Any failures during callback execution will be logged. Returns a callable that can be used to remove the callback. """ self._callbacks.append(callback) return lambda: self._callbacks.remove(callback) def __call__(self, value: V) -> None: """Invoke all callbacks in the list.""" for callback in self._callbacks: safe_callback(callback, self._logger)(value) def decoder_callback( decoder: Callable[[K], list[V]], callback: Callable[[V], None], logger: logging.Logger | logging.LoggerAdapter | None = None, ) -> Callable[[K], None]: """Create a callback that decodes messages using a decoder and invokes a callback. The decoder converts a value into a list of values. The callback is then invoked for each value in the list. Any failures during decoding or invoking the callbacks will be logged. """ if logger is None: logger = _LOGGER safe_cb = safe_callback(callback, logger) def wrapper(data: K) -> None: if not (messages := decoder(data)): logger.debug("Failed to decode message: %s", data) return for message in messages: logger.debug("Decoded message: %s", message) safe_cb(message) return wrapper Python-roborock-python-roborock-d6da2db/roborock/cli.py000066400000000000000000001242011513363643200234650ustar00rootroot00000000000000"""Command line interface for python-roborock. The CLI supports both one-off commands and an interactive session mode. In session mode, an asyncio event loop is created in a separate thread, allowing users to interactively run commands that require async operations. Typical CLI usage: ``` $ roborock login --email [--password ] $ roborock discover $ roborock list-devices $ roborock status --device_id ``` ... Session mode usage: ``` $ roborock session roborock> list-devices ... roborock> status --device_id ``` """ import asyncio import datetime import functools import json import logging import sys import threading from collections.abc import Callable from dataclasses import asdict, dataclass from pathlib import Path from typing import Any, cast import click import click_shell import yaml from pyshark import FileCapture # type: ignore from pyshark.capture.live_capture import LiveCapture, UnknownInterfaceException # type: ignore from pyshark.packet.packet import Packet # type: ignore from roborock import RoborockCommand from roborock.data import RoborockBase, UserData from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP from roborock.data.code_mappings import SHORT_MODEL_TO_ENUM from roborock.device_features import DeviceFeatures from roborock.devices.cache import Cache, CacheData from roborock.devices.device import RoborockDevice from roborock.devices.device_manager import DeviceManager, UserParams, create_device_manager from roborock.devices.traits import Trait from roborock.devices.traits.v1 import V1TraitMixin from roborock.devices.traits.v1.consumeable import ConsumableAttribute from roborock.devices.traits.v1.map_content import MapContentTrait from roborock.exceptions import RoborockException, RoborockUnsupportedFeature from roborock.protocol import MessageParser from roborock.web_api import RoborockApiClient _LOGGER = logging.getLogger(__name__) if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) def dump_json(obj: Any) -> Any: """Dump an object as JSON.""" def custom_json_serializer(obj): if isinstance(obj, datetime.time): return obj.isoformat() raise TypeError(f"Object of type {obj.__class__.__name__} is not JSON serializable") return json.dumps(obj, default=custom_json_serializer) def async_command(func): """Decorator for async commands that work in both CLI and session modes. The CLI supports two execution modes: 1. CLI mode: One-off commands that create their own event loop 2. Session mode: Interactive shell with a persistent background event loop This decorator ensures async commands work correctly in both modes: - CLI mode: Uses asyncio.run() to create a new event loop - Session mode: Uses the existing session event loop via run_in_session() """ @functools.wraps(func) def wrapper(*args, **kwargs): ctx = args[0] context: RoborockContext = ctx.obj async def run(): try: await func(*args, **kwargs) except Exception as err: _LOGGER.exception("Uncaught exception in command") click.echo(f"Error: {err}", err=True) finally: if not context.is_session_mode(): await context.cleanup() if context.is_session_mode(): # Session mode - run in the persistent loop return context.run_in_session(run()) else: # CLI mode - just run normally (asyncio.run handles loop creation) return asyncio.run(run()) return wrapper @dataclass class ConnectionCache(RoborockBase): """Cache for Roborock data. This is used to store data retrieved from the Roborock API, such as user data and home data to avoid repeated API calls. This cache is superset of `LoginData` since we used to directly store that dataclass, but now we also store additional data. """ user_data: UserData email: str # TODO: Used new APIs for cache file storage cache_data: CacheData | None = None class DeviceConnectionManager: """Manages device connections for both CLI and session modes.""" def __init__(self, context: "RoborockContext", loop: asyncio.AbstractEventLoop | None = None): self.context = context self.loop = loop self.device_manager: DeviceManager | None = None self._devices: dict[str, RoborockDevice] = {} async def ensure_device_manager(self) -> DeviceManager: """Ensure device manager is initialized.""" if self.device_manager is None: connection_cache = self.context.connection_cache() user_params = UserParams( username=connection_cache.email, user_data=connection_cache.user_data, ) self.device_manager = await create_device_manager(user_params, cache=self.context) # Cache devices for quick lookup devices = await self.device_manager.get_devices() self._devices = {device.duid: device for device in devices} return self.device_manager async def get_device(self, device_id: str) -> RoborockDevice: """Get a device by ID, creating connections if needed.""" await self.ensure_device_manager() if device_id not in self._devices: raise RoborockException(f"Device {device_id} not found") return self._devices[device_id] async def close(self): """Close device manager connections.""" if self.device_manager: await self.device_manager.close() self.device_manager = None self._devices = {} class RoborockContext(Cache): """Context that handles both CLI and session modes internally.""" roborock_file = Path("~/.roborock").expanduser() roborock_cache_file = Path("~/.roborock.cache").expanduser() _connection_cache: ConnectionCache | None = None def __init__(self): self.reload() self._session_loop: asyncio.AbstractEventLoop | None = None self._session_thread: threading.Thread | None = None self._device_manager: DeviceConnectionManager | None = None def reload(self): if self.roborock_file.is_file(): with open(self.roborock_file) as f: data = json.load(f) if data: self._connection_cache = ConnectionCache.from_dict(data) def update(self, connection_cache: ConnectionCache): data = json.dumps(connection_cache.as_dict(), default=vars, indent=4) with open(self.roborock_file, "w") as f: f.write(data) self.reload() def validate(self): if self._connection_cache is None: raise RoborockException("You must login first") def connection_cache(self) -> ConnectionCache: """Get the cache data.""" self.validate() return cast(ConnectionCache, self._connection_cache) def start_session_mode(self): """Start session mode with a background event loop.""" if self._session_loop is not None: return # Already started self._session_loop = asyncio.new_event_loop() self._session_thread = threading.Thread(target=self._run_session_loop) self._session_thread.daemon = True self._session_thread.start() def _run_session_loop(self): """Run the session event loop in a background thread.""" assert self._session_loop is not None # guaranteed by start_session_mode asyncio.set_event_loop(self._session_loop) self._session_loop.run_forever() def is_session_mode(self) -> bool: return self._session_loop is not None def run_in_session(self, coro): """Run a coroutine in the session loop (session mode only).""" if not self._session_loop: raise RoborockException("Not in session mode") future = asyncio.run_coroutine_threadsafe(coro, self._session_loop) return future.result() async def get_device_manager(self) -> DeviceConnectionManager: """Get device manager, creating if needed.""" await self.get_devices() if self._device_manager is None: self._device_manager = DeviceConnectionManager(self, self._session_loop) return self._device_manager async def refresh_devices(self) -> ConnectionCache: """Refresh device data from server (always fetches fresh data).""" connection_cache = self.connection_cache() client = RoborockApiClient(connection_cache.email) home_data = await client.get_home_data_v3(connection_cache.user_data) if connection_cache.cache_data is None: connection_cache.cache_data = CacheData() connection_cache.cache_data.home_data = home_data self.update(connection_cache) return connection_cache async def get_devices(self) -> ConnectionCache: """Get device data (uses cache if available, fetches if needed).""" connection_cache = self.connection_cache() if (connection_cache.cache_data is None) or (connection_cache.cache_data.home_data is None): connection_cache = await self.refresh_devices() return connection_cache async def cleanup(self): """Clean up resources (mainly for session mode).""" if self._device_manager: await self._device_manager.close() self._device_manager = None # Stop session loop if running if self._session_loop: self._session_loop.call_soon_threadsafe(self._session_loop.stop) if self._session_thread: self._session_thread.join(timeout=5.0) self._session_loop = None self._session_thread = None def finish_session(self) -> None: """Finish the session and clean up resources.""" if self._session_loop: future = asyncio.run_coroutine_threadsafe(self.cleanup(), self._session_loop) future.result(timeout=5.0) async def get(self) -> CacheData: """Get cached value.""" _LOGGER.debug("Getting cache data") connection_cache = self.connection_cache() if connection_cache.cache_data is not None: return connection_cache.cache_data return CacheData() async def set(self, value: CacheData) -> None: """Set value in the cache.""" _LOGGER.debug("Setting cache data") connection_cache = self.connection_cache() connection_cache.cache_data = value self.update(connection_cache) @click.option("-d", "--debug", default=False, count=True) @click.version_option(package_name="python-roborock") @click.group() @click.pass_context def cli(ctx, debug: int): logging_config: dict[str, Any] = {"level": logging.DEBUG if debug > 0 else logging.INFO} logging.basicConfig(**logging_config) # type: ignore ctx.obj = RoborockContext() @click.command() @click.option("--email", required=True) @click.option( "--reauth", is_flag=True, default=False, help="Re-authenticate even if cached credentials exist.", ) @click.option( "--password", required=False, help="Password for the Roborock account. If not provided, an email code will be requested.", ) @click.pass_context @async_command async def login(ctx, email, password, reauth): """Login to Roborock account.""" context: RoborockContext = ctx.obj if not reauth: try: context.validate() _LOGGER.info("Already logged in") return except RoborockException: pass client = RoborockApiClient(email) if password is not None: user_data = await client.pass_login(password) else: print(f"Requesting code for {email}") await client.request_code() code = click.prompt("A code has been sent to your email, please enter the code", type=str) user_data = await client.code_login(code) print("Login successful") context.update(ConnectionCache(user_data=user_data, email=email)) def _shell_session_finished(ctx): """Callback for when shell session finishes.""" context: RoborockContext = ctx.obj try: context.finish_session() except Exception as e: click.echo(f"Error during cleanup: {e}", err=True) click.echo("Session finished") @click_shell.shell( prompt="roborock> ", on_finished=_shell_session_finished, ) @click.pass_context def session(ctx): """Start an interactive session.""" context: RoborockContext = ctx.obj # Start session mode with background loop context.start_session_mode() context.run_in_session(context.get_device_manager()) click.echo("OK") @session.command() @click.pass_context @async_command async def discover(ctx): """Discover devices.""" context: RoborockContext = ctx.obj # Use the explicit refresh method for the discover command connection_cache = await context.refresh_devices() home_data = connection_cache.cache_data.home_data click.echo(f"Discovered devices {', '.join([device.name for device in home_data.get_all_devices()])}") @session.command() @click.pass_context @async_command async def list_devices(ctx): context: RoborockContext = ctx.obj connection_cache = await context.get_devices() home_data = connection_cache.cache_data.home_data device_name_id = {device.name: device.duid for device in home_data.get_all_devices()} click.echo(json.dumps(device_name_id, indent=4)) @click.command() @click.option("--device_id", required=True) @click.pass_context @async_command async def list_scenes(ctx, device_id): context: RoborockContext = ctx.obj connection_cache = await context.get_devices() client = RoborockApiClient(connection_cache.email) scenes = await client.get_scenes(connection_cache.user_data, device_id) output_list = [] for scene in scenes: output_list.append(scene.as_dict()) click.echo(json.dumps(output_list, indent=4)) @click.command() @click.option("--scene_id", required=True) @click.pass_context @async_command async def execute_scene(ctx, scene_id): context: RoborockContext = ctx.obj connection_cache = await context.get_devices() client = RoborockApiClient(connection_cache.email) await client.execute_scene(connection_cache.user_data, scene_id) async def _v1_trait(context: RoborockContext, device_id: str, display_func: Callable[[], V1TraitMixin]) -> Trait: device_manager = await context.get_device_manager() device = await device_manager.get_device(device_id) if device.v1_properties is None: raise RoborockUnsupportedFeature(f"Device {device.name} does not support V1 protocol") await device.v1_properties.discover_features() trait = display_func(device.v1_properties) if trait is None: raise RoborockUnsupportedFeature("Trait not supported by device") await trait.refresh() return trait async def _display_v1_trait(context: RoborockContext, device_id: str, display_func: Callable[[], Trait]) -> None: try: trait = await _v1_trait(context, device_id, display_func) except RoborockUnsupportedFeature: click.echo("Feature not supported by device") return except RoborockException as e: click.echo(f"Error: {e}") return click.echo(dump_json(trait.as_dict())) @session.command() @click.option("--device_id", required=True) @click.pass_context @async_command async def status(ctx, device_id: str): """Get device status.""" context: RoborockContext = ctx.obj await _display_v1_trait(context, device_id, lambda v1: v1.status) @session.command() @click.option("--device_id", required=True) @click.pass_context @async_command async def clean_summary(ctx, device_id: str): """Get device clean summary.""" context: RoborockContext = ctx.obj await _display_v1_trait(context, device_id, lambda v1: v1.clean_summary) @session.command() @click.option("--device_id", required=True) @click.pass_context @async_command async def clean_record(ctx, device_id: str): """Get device last clean record.""" context: RoborockContext = ctx.obj await _display_v1_trait(context, device_id, lambda v1: v1.clean_record) @session.command() @click.option("--device_id", required=True) @click.pass_context @async_command async def dock_summary(ctx, device_id: str): """Get device dock summary.""" context: RoborockContext = ctx.obj await _display_v1_trait(context, device_id, lambda v1: v1.dock_summary) @session.command() @click.option("--device_id", required=True) @click.pass_context @async_command async def volume(ctx, device_id: str): """Get device volume.""" context: RoborockContext = ctx.obj await _display_v1_trait(context, device_id, lambda v1: v1.sound_volume) @session.command() @click.option("--device_id", required=True) @click.option("--volume", required=True, type=int) @click.pass_context @async_command async def set_volume(ctx, device_id: str, volume: int): """Set the devicevolume.""" context: RoborockContext = ctx.obj volume_trait = await _v1_trait(context, device_id, lambda v1: v1.sound_volume) await volume_trait.set_volume(volume) click.echo(f"Set Device {device_id} volume to {volume}") @session.command() @click.option("--device_id", required=True) @click.pass_context @async_command async def maps(ctx, device_id: str): """Get device maps info.""" context: RoborockContext = ctx.obj await _display_v1_trait(context, device_id, lambda v1: v1.maps) @session.command() @click.option("--device_id", required=True) @click.option("--output-file", required=True, help="Path to save the map image.") @click.pass_context @async_command async def map_image(ctx, device_id: str, output_file: str): """Get device map image and save it to a file.""" context: RoborockContext = ctx.obj trait: MapContentTrait = await _v1_trait(context, device_id, lambda v1: v1.map_content) if trait.image_content: with open(output_file, "wb") as f: f.write(trait.image_content) click.echo(f"Map image saved to {output_file}") else: click.echo("No map image content available.") @session.command() @click.option("--device_id", required=True) @click.option("--include_path", is_flag=True, default=False, help="Include path data in the output.") @click.pass_context @async_command async def map_data(ctx, device_id: str, include_path: bool): """Get parsed map data as JSON.""" context: RoborockContext = ctx.obj trait: MapContentTrait = await _v1_trait(context, device_id, lambda v1: v1.map_content) if not trait.map_data: click.echo("No parsed map data available.") return # Pick some parts of the map data to display. data_summary = { "charger": trait.map_data.charger.as_dict() if trait.map_data.charger else None, "image_size": trait.map_data.image.data.size if trait.map_data.image else None, "vacuum_position": trait.map_data.vacuum_position.as_dict() if trait.map_data.vacuum_position else None, "calibration": trait.map_data.calibration(), "zones": [z.as_dict() for z in trait.map_data.zones or ()], } if include_path and trait.map_data.path: data_summary["path"] = trait.map_data.path.as_dict() click.echo(dump_json(data_summary)) @session.command() @click.option("--device_id", required=True) @click.pass_context @async_command async def consumables(ctx, device_id: str): """Get device consumables.""" context: RoborockContext = ctx.obj await _display_v1_trait(context, device_id, lambda v1: v1.consumables) @session.command() @click.option("--device_id", required=True) @click.option("--consumable", required=True, type=click.Choice([e.value for e in ConsumableAttribute])) @click.pass_context @async_command async def reset_consumable(ctx, device_id: str, consumable: str): """Reset a specific consumable attribute.""" context: RoborockContext = ctx.obj trait = await _v1_trait(context, device_id, lambda v1: v1.consumables) attribute = ConsumableAttribute.from_str(consumable) await trait.reset_consumable(attribute) click.echo(f"Reset {consumable} for device {device_id}") @session.command() @click.option("--device_id", required=True) @click.option("--enabled", type=bool, help="Enable (True) or disable (False) the child lock.") @click.pass_context @async_command async def child_lock(ctx, device_id: str, enabled: bool | None): """Get device child lock status.""" context: RoborockContext = ctx.obj try: trait = await _v1_trait(context, device_id, lambda v1: v1.child_lock) except RoborockUnsupportedFeature: click.echo("Feature not supported by device") return if enabled is not None: if enabled: await trait.enable() else: await trait.disable() click.echo(f"Set child lock to {enabled} for device {device_id}") await trait.refresh() click.echo(dump_json(trait.as_dict())) @session.command() @click.option("--device_id", required=True) @click.option("--enabled", type=bool, help="Enable (True) or disable (False) the DND status.") @click.pass_context @async_command async def dnd(ctx, device_id: str, enabled: bool | None): """Get Do Not Disturb Timer status.""" context: RoborockContext = ctx.obj try: trait = await _v1_trait(context, device_id, lambda v1: v1.dnd) except RoborockUnsupportedFeature: click.echo("Feature not supported by device") return if enabled is not None: if enabled: await trait.enable() else: await trait.disable() click.echo(f"Set DND to {enabled} for device {device_id}") await trait.refresh() click.echo(dump_json(trait.as_dict())) @session.command() @click.option("--device_id", required=True) @click.option("--enabled", required=False, type=bool, help="Enable (True) or disable (False) the Flow LED.") @click.pass_context @async_command async def flow_led_status(ctx, device_id: str, enabled: bool | None): """Get device Flow LED status.""" context: RoborockContext = ctx.obj try: trait = await _v1_trait(context, device_id, lambda v1: v1.flow_led_status) except RoborockUnsupportedFeature: click.echo("Feature not supported by device") return if enabled is not None: if enabled: await trait.enable() else: await trait.disable() click.echo(f"Set Flow LED to {enabled} for device {device_id}") await trait.refresh() click.echo(dump_json(trait.as_dict())) @session.command() @click.option("--device_id", required=True) @click.option("--enabled", required=False, type=bool, help="Enable (True) or disable (False) the LED.") @click.pass_context @async_command async def led_status(ctx, device_id: str, enabled: bool | None): """Get device LED status.""" context: RoborockContext = ctx.obj try: trait = await _v1_trait(context, device_id, lambda v1: v1.led_status) except RoborockUnsupportedFeature: click.echo("Feature not supported by device") return if enabled is not None: if enabled: await trait.enable() else: await trait.disable() click.echo(f"Set LED Status to {enabled} for device {device_id}") await trait.refresh() click.echo(dump_json(trait.as_dict())) @session.command() @click.option("--device_id", required=True) @click.option("--enabled", required=True, type=bool, help="Enable (True) or disable (False) the child lock.") @click.pass_context @async_command async def set_child_lock(ctx, device_id: str, enabled: bool): """Set the child lock status.""" context: RoborockContext = ctx.obj trait = await _v1_trait(context, device_id, lambda v1: v1.child_lock) await trait.set_child_lock(enabled) status = "enabled" if enabled else "disabled" click.echo(f"Child lock {status} for device {device_id}") @session.command() @click.option("--device_id", required=True) @click.pass_context @async_command async def rooms(ctx, device_id: str): """Get device room mapping info.""" context: RoborockContext = ctx.obj await _display_v1_trait(context, device_id, lambda v1: v1.rooms) @session.command() @click.option("--device_id", required=True) @click.pass_context @async_command async def features(ctx, device_id: str): """Get device room mapping info.""" context: RoborockContext = ctx.obj await _display_v1_trait(context, device_id, lambda v1: v1.device_features) @session.command() @click.option("--device_id", required=True) @click.option("--refresh", is_flag=True, default=False, help="Refresh status before discovery.") @click.pass_context @async_command async def home(ctx, device_id: str, refresh: bool): """Discover and cache home layout (maps and rooms).""" context: RoborockContext = ctx.obj device_manager = await context.get_device_manager() device = await device_manager.get_device(device_id) if device.v1_properties is None: raise RoborockException(f"Device {device.name} does not support V1 protocol") # Ensure we have the latest status before discovery await device.v1_properties.status.refresh() home_trait = device.v1_properties.home await home_trait.discover_home() if refresh: await home_trait.refresh() # Display the discovered home cache if home_trait.home_map_info: cache_summary = { map_flag: { "name": map_data.name, "room_count": len(map_data.rooms), "rooms": [{"segment_id": room.segment_id, "name": room.name} for room in map_data.rooms], } for map_flag, map_data in home_trait.home_map_info.items() } click.echo(dump_json(cache_summary)) else: click.echo("No maps discovered") @session.command() @click.option("--device_id", required=True) @click.pass_context @async_command async def network_info(ctx, device_id: str): """Get device network information.""" context: RoborockContext = ctx.obj await _display_v1_trait(context, device_id, lambda v1: v1.network_info) def _parse_b01_q10_command(cmd: str) -> B01_Q10_DP: """Parse B01_Q10 command from either enum name or value.""" try: return B01_Q10_DP(int(cmd)) except ValueError: try: return B01_Q10_DP.from_name(cmd) except ValueError: try: return B01_Q10_DP.from_value(cmd) except ValueError: pass raise RoborockException(f"Invalid command {cmd} for B01_Q10 device") @click.command() @click.option("--device_id", required=True) @click.option("--cmd", required=True) @click.option("--params", required=False) @click.pass_context @async_command async def command(ctx, cmd, device_id, params): context: RoborockContext = ctx.obj device_manager = await context.get_device_manager() device = await device_manager.get_device(device_id) if device.v1_properties is not None: command_trait: Trait = device.v1_properties.command result = await command_trait.send(cmd, json.loads(params) if params is not None else None) if result: click.echo(dump_json(result)) elif device.b01_q10_properties is not None: cmd_value = _parse_b01_q10_command(cmd) command_trait: Trait = device.b01_q10_properties.command await command_trait.send(cmd_value, json.loads(params) if params is not None else None) click.echo("Command sent successfully; Enable debug logging (-d) to see responses.") # Q10 commands don't have a specific time to respond, so wait a bit and log await asyncio.sleep(5) @click.command() @click.option("--local_key", required=True) @click.option("--device_ip", required=True) @click.option("--file", required=False) @click.pass_context @async_command async def parser(_, local_key, device_ip, file): file_provided = file is not None if file_provided: capture = FileCapture(file) else: _LOGGER.info("Listen for interface rvi0 since no file was provided") capture = LiveCapture(interface="rvi0") buffer = {"data": b""} def on_package(packet: Packet): if hasattr(packet, "ip"): if packet.transport_layer == "TCP" and (packet.ip.dst == device_ip or packet.ip.src == device_ip): if hasattr(packet, "DATA"): if hasattr(packet.DATA, "data"): if packet.ip.dst == device_ip: try: f, buffer["data"] = MessageParser.parse( buffer["data"] + bytes.fromhex(packet.DATA.data), local_key, ) print(f"Received request: {f}") except BaseException as e: print(e) pass elif packet.ip.src == device_ip: try: f, buffer["data"] = MessageParser.parse( buffer["data"] + bytes.fromhex(packet.DATA.data), local_key, ) print(f"Received response: {f}") except BaseException as e: print(e) pass try: await capture.packets_from_tshark(on_package, close_tshark=not file_provided) except UnknownInterfaceException: raise RoborockException( "You need to run 'rvictl -s XXXXXXXX-XXXXXXXXXXXXXXXX' first, with an iPhone connected to usb port" ) def _parse_diagnostic_file(diagnostic_path: Path) -> dict[str, dict[str, Any]]: """Parse device info from a Home Assistant diagnostic file. Args: diagnostic_path: Path to the diagnostic JSON file. Returns: A dictionary mapping model names to device info dictionaries. """ with open(diagnostic_path, encoding="utf-8") as f: diagnostic_data = json.load(f) all_products_data: dict[str, dict[str, Any]] = {} # Navigate to coordinators in the diagnostic data coordinators = diagnostic_data.get("data", {}).get("coordinators", {}) if not coordinators: return all_products_data for coordinator_data in coordinators.values(): device_data = coordinator_data.get("device", {}) product_data = coordinator_data.get("product", {}) model = product_data.get("model") if not model or model in all_products_data: continue # Derive product nickname from model short_model = model.split(".")[-1] product_nickname = SHORT_MODEL_TO_ENUM.get(short_model) current_product_data: dict[str, Any] = { "protocol_version": device_data.get("pv"), "product_nickname": product_nickname.name if product_nickname else "Unknown", } # Get feature info from the device_features trait (preferred location) traits_data = coordinator_data.get("traits", {}) device_features = traits_data.get("device_features", {}) # newFeatureInfo is the integer new_feature_info = device_features.get("newFeatureInfo") if new_feature_info is not None: current_product_data["new_feature_info"] = new_feature_info # newFeatureInfoStr is the hex string new_feature_info_str = device_features.get("newFeatureInfoStr") if new_feature_info_str: current_product_data["new_feature_info_str"] = new_feature_info_str # featureInfo is the list of feature codes feature_info = device_features.get("featureInfo") if feature_info: current_product_data["feature_info"] = feature_info # Build product dict from diagnostic product data if product_data: # Convert to the format expected by device_info.yaml product_dict: dict[str, Any] = {} for key in ["id", "name", "model", "category", "capability", "schema"]: if key in product_data: product_dict[key] = product_data[key] if product_dict: current_product_data["product"] = product_dict all_products_data[model] = current_product_data return all_products_data @click.command() @click.option( "--record", is_flag=True, default=False, help="Save new device info entries to the YAML file.", ) @click.option( "--device-info-file", default="device_info.yaml", help="Path to the YAML file with device and product data.", ) @click.option( "--diagnostic-file", default=None, help="Path to a Home Assistant diagnostic JSON file to parse instead of connecting to devices.", ) @click.pass_context @async_command async def get_device_info(ctx: click.Context, record: bool, device_info_file: str, diagnostic_file: str | None): """ Connects to devices and prints their feature information in YAML format. Can also parse device info from a Home Assistant diagnostic file using --diagnostic-file. """ context: RoborockContext = ctx.obj device_info_path = Path(device_info_file) existing_device_info: dict[str, Any] = {} # Load existing device info if recording if record: click.echo(f"Using device info file: {device_info_path.resolve()}") if device_info_path.exists(): with open(device_info_path, encoding="utf-8") as f: data = yaml.safe_load(f) if isinstance(data, dict): existing_device_info = data # Parse from diagnostic file if provided if diagnostic_file: diagnostic_path = Path(diagnostic_file) if not diagnostic_path.exists(): click.echo(f"Diagnostic file not found: {diagnostic_path}", err=True) return click.echo(f"Parsing diagnostic file: {diagnostic_path.resolve()}") all_products_data = _parse_diagnostic_file(diagnostic_path) if not all_products_data: click.echo("No device data found in diagnostic file.") return click.echo(f"Found {len(all_products_data)} device(s) in diagnostic file.") else: click.echo("Discovering devices...") if record: connection_cache = await context.get_devices() home_data = connection_cache.cache_data.home_data if connection_cache.cache_data else None if home_data is None: click.echo("Home data not available.", err=True) return device_connection_manager = await context.get_device_manager() device_manager = await device_connection_manager.ensure_device_manager() devices = await device_manager.get_devices() if not devices: click.echo("No devices found.") return click.echo(f"Found {len(devices)} devices. Fetching data...") all_products_data = {} for device in devices: click.echo(f" - Processing {device.name} ({device.duid})") model = device.product.model if model in all_products_data: click.echo(f" - Skipping duplicate model {model}") continue current_product_data = { "protocol_version": device.device_info.pv, "product_nickname": device.product.product_nickname.name if device.product.product_nickname else "Unknown", } if device.v1_properties is not None: try: result: list[dict[str, Any]] = await device.v1_properties.command.send( RoborockCommand.APP_GET_INIT_STATUS ) except Exception as e: click.echo(f" - Error processing device {device.name}: {e}", err=True) continue init_status_result = result[0] if result else {} current_product_data.update( { "new_feature_info": init_status_result.get("new_feature_info"), "new_feature_info_str": init_status_result.get("new_feature_info_str"), "feature_info": init_status_result.get("feature_info"), } ) product_data = device.product.as_dict() if product_data: current_product_data["product"] = product_data all_products_data[model] = current_product_data if record: if not all_products_data: click.echo("No device info updates needed.") return updated_device_info = {**existing_device_info, **all_products_data} device_info_path.parent.mkdir(parents=True, exist_ok=True) ordered_data = dict(sorted(updated_device_info.items(), key=lambda item: item[0])) with open(device_info_path, "w", encoding="utf-8") as f: yaml.safe_dump(ordered_data, f, sort_keys=False) click.echo(f"Updated {device_info_path}.") click.echo("\n--- Device Info Updates ---\n") click.echo(yaml.safe_dump(all_products_data, sort_keys=False)) return if all_products_data: click.echo("\n--- Device Information (copy to your YAML file) ---\n") click.echo(yaml.dump(all_products_data, sort_keys=False)) @click.command() @click.option("--data-file", default="../device_info.yaml", help="Path to the YAML file with device feature data.") @click.option("--output-file", default="../SUPPORTED_FEATURES.md", help="Path to the output markdown file.") def update_docs(data_file: str, output_file: str): """ Generates a markdown file by processing raw feature data from a YAML file. """ data_path = Path(data_file) output_path = Path(output_file) if not data_path.exists(): click.echo(f"Error: Data file not found at '{data_path}'", err=True) return click.echo(f"Loading data from {data_path}...") with open(data_path, encoding="utf-8") as f: product_data_from_yaml = yaml.safe_load(f) if not product_data_from_yaml: click.echo("No data found in YAML file. Exiting.", err=True) return product_features_map = {} all_feature_names = set() # Process the raw data from YAML to build the feature map for model, data in product_data_from_yaml.items(): # Reconstruct the DeviceFeatures object from the raw data in the YAML file device_features = DeviceFeatures.from_feature_flags( new_feature_info=data.get("new_feature_info"), new_feature_info_str=data.get("new_feature_info_str"), feature_info=data.get("feature_info"), product_nickname=data.get("product_nickname"), ) features_dict = asdict(device_features) # This dictionary will hold the final data for the markdown table row current_product_data = { "product_nickname": data.get("product_nickname", ""), "protocol_version": data.get("protocol_version", ""), "new_feature_info": data.get("new_feature_info", ""), "new_feature_info_str": data.get("new_feature_info_str", ""), } # Populate features from the calculated DeviceFeatures object for feature, is_supported in features_dict.items(): all_feature_names.add(feature) if is_supported: current_product_data[feature] = "X" supported_codes = data.get("feature_info", []) if isinstance(supported_codes, list): for code in supported_codes: feature_name = str(code) all_feature_names.add(feature_name) current_product_data[feature_name] = "X" product_features_map[model] = current_product_data # --- Helper function to write the markdown table --- def write_markdown_table(product_features: dict[str, dict[str, any]], all_features: set[str]): """Writes the data into a markdown table (products as columns).""" sorted_products = sorted(product_features.keys()) special_rows = [ "product_nickname", "protocol_version", "new_feature_info", "new_feature_info_str", ] # Regular features are the remaining keys, sorted alphabetically # We filter out the special rows to avoid duplicating them. sorted_features = sorted(list(all_features - set(special_rows))) header = ["Feature"] + sorted_products click.echo(f"Writing documentation to {output_path}...") with open(output_path, "w", encoding="utf-8") as f: f.write("| " + " | ".join(header) + " |\n") f.write("|" + "---|" * len(header) + "\n") # Write the special metadata rows first for row_name in special_rows: row_values = [str(product_features[p].get(row_name, "")) for p in sorted_products] f.write("| " + " | ".join([row_name] + row_values) + " |\n") # Write the feature rows for feature in sorted_features: # Use backticks for feature names that are just numbers (from the list) display_feature = f"`{feature}`" feature_row = [display_feature] for product in sorted_products: # Use .get() to place an 'X' or an empty string feature_row.append(product_features[product].get(feature, "")) f.write("| " + " | ".join(feature_row) + " |\n") write_markdown_table(product_features_map, all_feature_names) click.echo("Done.") cli.add_command(login) cli.add_command(discover) cli.add_command(list_devices) cli.add_command(list_scenes) cli.add_command(execute_scene) cli.add_command(status) cli.add_command(command) cli.add_command(parser) cli.add_command(session) cli.add_command(get_device_info) cli.add_command(update_docs) cli.add_command(clean_summary) cli.add_command(clean_record) cli.add_command(dock_summary) cli.add_command(volume) cli.add_command(set_volume) cli.add_command(maps) cli.add_command(map_image) cli.add_command(map_data) cli.add_command(consumables) cli.add_command(reset_consumable) cli.add_command(rooms) cli.add_command(home) cli.add_command(features) cli.add_command(child_lock) cli.add_command(dnd) cli.add_command(flow_led_status) cli.add_command(led_status) cli.add_command(network_info) def main(): return cli() if __name__ == "__main__": main() Python-roborock-python-roborock-d6da2db/roborock/const.py000066400000000000000000000053641513363643200240540ustar00rootroot00000000000000# Total time in seconds consumables have before Roborock recommends replacing MAIN_BRUSH_REPLACE_TIME = 1080000 SIDE_BRUSH_REPLACE_TIME = 720000 FILTER_REPLACE_TIME = 540000 SENSOR_DIRTY_REPLACE_TIME = 108000 MOP_ROLLER_REPLACE_TIME = 1080000 STRAINER_REPLACE_TIME = 150 CLEANING_BRUSH_REPLACE_TIME = 300 DUST_COLLECTION_REPLACE_TIME = 90 FLOOR_CLEANER_REPLACE_TIME = 300 ROBOROCK_V1 = "ROBOROCK.vacuum.v1" ROBOROCK_S4 = "roborock.vacuum.s4" ROBOROCK_S4_MAX = "roborock.vacuum.a19" ROBOROCK_S5 = "roborock.vacuum.s5" ROBOROCK_S5_MAX = "roborock.vacuum.s5e" ROBOROCK_S6 = "roborock.vacuum.s6" ROBOROCK_T6 = "roborock.vacuum.t6" # cn s6 ROBOROCK_E4 = "roborock.vacuum.a01" ROBOROCK_S6_PURE = "roborock.vacuum.a08" ROBOROCK_T7 = "roborock.vacuum.a11" # cn s7 ROBOROCK_T7S = "roborock.vacuum.a14" ROBOROCK_T7SPLUS = "roborock.vacuum.a23" ROBOROCK_S7_MAXV = "roborock.vacuum.a27" ROBOROCK_S7_MAXV_ULTRA = "roborock.vacuum.a65" ROBOROCK_S7_PRO_ULTRA = "roborock.vacuum.a62" ROBOROCK_Q5 = "roborock.vacuum.a34" ROBOROCK_Q5_PRO = "roborock.vacuum.a72" ROBOROCK_Q7 = "roborock.vacuum.a40" ROBOROCK_Q7_MAX = "roborock.vacuum.a38" ROBOROCK_Q7PLUS = "roborock.vacuum.a40" ROBOROCK_QREVO_MASTER = "roborock.vacuum.a117" ROBOROCK_QREVO_CURV = "roborock.vacuum.a135" ROBOROCK_Q8_MAX = "roborock.vacuum.a73" ROBOROCK_G10S_PRO = "roborock.vacuum.a26" ROBOROCK_G20S_Ultra = "roborock.vacuum.a143" # cn saros_r10 ROBOROCK_G10S = "roborock.vacuum.a46" ROBOROCK_G10 = "roborock.vacuum.a29" ROCKROBO_G10_SG = "roborock.vacuum.a30" # Variant of the G10, has similar features as S7 ROBOROCK_S7 = "roborock.vacuum.a15" ROBOROCK_S6_MAXV = "roborock.vacuum.a10" ROBOROCK_E2 = "roborock.vacuum.e2" ROBOROCK_1S = "roborock.vacuum.m1s" ROBOROCK_C1 = "roborock.vacuum.c1" ROBOROCK_S8_PRO_ULTRA = "roborock.vacuum.a70" ROBOROCK_S8 = "roborock.vacuum.a51" ROBOROCK_P10 = "roborock.vacuum.a75" # also known as q_revo ROBOROCK_S8_MAXV_ULTRA = "roborock.vacuum.a97" ROBOROCK_QREVO_S = "roborock.vacuum.a104" ROBOROCK_QREVO_PRO = "roborock.vacuum.a101" ROBOROCK_QREVO_MAXV = "roborock.vacuum.a87" ROBOROCK_SAROS_10R = "roborock.vacuum.a144" ROBOROCK_SAROS_10 = "roborock.vacuum.a147" ROBOROCK_DYAD_AIR = "roborock.wetdryvac.a107" ROBOROCK_DYAD_PRO_COMBO = "roborock.wetdryvac.a83" ROBOROCK_DYAD_PRO = "roborock.wetdryvac.a56" # These are the devices that show up when you add a device - more could be supported and just not show up SUPPORTED_VACUUMS = [ ROBOROCK_G10, ROBOROCK_G10S_PRO, ROBOROCK_G20S_Ultra, ROBOROCK_Q5, ROBOROCK_Q7, ROBOROCK_Q7_MAX, ROBOROCK_S4, ROBOROCK_S5_MAX, ROBOROCK_S6, ROBOROCK_S6_MAXV, ROBOROCK_S6_PURE, ROBOROCK_S7_MAXV, ROBOROCK_S8_PRO_ULTRA, ROBOROCK_S8, ROBOROCK_S4_MAX, ROBOROCK_S7, ROBOROCK_P10, ROCKROBO_G10_SG, ] NO_MAP = 63 Python-roborock-python-roborock-d6da2db/roborock/data/000077500000000000000000000000001513363643200232555ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/roborock/data/__init__.py000066400000000000000000000004011513363643200253610ustar00rootroot00000000000000"""This module is meant to hold dataclasses and codemappings for various devices and protocols.""" from .b01_q7 import * from .b01_q10 import * from .code_mappings import * from .containers import * from .dyad import * from .v1 import * from .zeo import * Python-roborock-python-roborock-d6da2db/roborock/data/b01_q10/000077500000000000000000000000001513363643200243205ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/roborock/data/b01_q10/__init__.py000066400000000000000000000001071513363643200264270ustar00rootroot00000000000000from .b01_q10_code_mappings import * from .b01_q10_containers import * Python-roborock-python-roborock-d6da2db/roborock/data/b01_q10/b01_q10_code_mappings.py000066400000000000000000000165531513363643200306370ustar00rootroot00000000000000from ..code_mappings import RoborockModeEnum class B01_Q10_DP(RoborockModeEnum): CLEAN_TIME = ("dpCleanTime", 6) CLEAN_AREA = ("dpCleanArea", 7) SEEK = ("dpSeek", 11) REMOTE = ("dpRemote", 12) MAP_RESET = ("dpMapReset", 13) REQUEST = ("dpRequest", 16) RESET_SIDE_BRUSH = ("dpResetSideBrush", 18) RESET_MAIN_BRUSH = ("dpResetMainBrush", 20) RESET_FILTER = ("dpResetFilter", 22) RAG_LIFE = ("dpRagLife", 23) RESET_RAG_LIFE = ("dpResetRagLife", 24) NOT_DISTURB = ("dpNotDisturb", 25) VOLUME = ("dpVolume", 26) BEAK_CLEAN = ("dpBeakClean", 27) TOTAL_CLEAN_AREA = ("dpTotalCleanArea", 29) TOTAL_CLEAN_COUNT = ("dpTotalCleanCount", 30) TOTAL_CLEAN_TIME = ("dpTotalCleanTime", 31) TIMER = ("dpTimer", 32) NOT_DISTURB_DATA = ("dpNotDisturbData", 33) DEVICE_INFO = ("dpDeviceInfo", 34) VOICE_PACKAGE = ("dpVoicePackage", 35) VOICE_LANGUAGE = ("dpVoiceLanguage", 36) DUST_SWITCH = ("dpDustSwitch", 37) CUSTOM_MODE = ("dpCustomMode", 39) MOP_STATE = ("dpMopState", 40) UNIT = ("dpUnit", 42) CARPET_CLEAN_PREFER = ("dpCarpetCleanPrefer", 44) AUTO_BOOST = ("dpAutoBoost", 45) CHILD_LOCK = ("dpChildLock", 47) DUST_SETTING = ("dpDustSetting", 50) MAP_SAVE_SWITCH = ("dpMapSaveSwitch", 51) CLEAN_RECORD = ("dpCleanRecord", 52) RECEND_CLEAN_RECORD = ("dpRecendCleanRecord", 53) RESTRICTED_ZONE = ("dpRestrictedZone", 54) RESTRICTED_ZONE_UP = ("dpRestrictedZoneUp", 55) VIRTUAL_WALL = ("dpVirtualWall", 56) VIRTUAL_WALL_UP = ("dpVirtualWallUp", 57) ZONED = ("dpZoned", 58) ZONED_UP = ("dpZonedUp", 59) MULTI_MAP_SWITCH = ("dpMultiMapSwitch", 60) MULTI_MAP = ("dpMultiMap", 61) CUSTOMER_CLEAN = ("dpCustomerClean", 62) CUSTOMER_CLEAN_REQUEST = ("dpCustomerCleanRequest", 63) GET_CARPET = ("dpGetCarpet", 64) CARPET_UP = ("dpCarpetUp", 65) SELF_IDENTIFYING_CARPET = ("dpSelfIdentifyingCarpet", 66) SENSOR_LIFE = ("dpSensorLife", 67) RESET_SENSOR = ("dpResetSensor", 68) REQUEST_TIMER = ("dpRequestTimer", 69) REMOVE_ZONED = ("dpRemoveZoned", 70) REMOVE_ZONED_UP = ("dpRemoveZonedUp", 71) ROOM_MERGE = ("dpRoomMerge", 72) ROOM_SPLIT = ("dpRoomSplit", 73) RESET_ROOM_NAME = ("dpResetRoomName", 74) REQUSET_NOT_DISTURB_DATA = ("dpRequsetNotDisturbData", 75) CARPET_CLEAN_TYPE = ("dpCarpetCleanType", 76) BUTTON_LIGHT_SWITCH = ("dpButtonLightSwitch", 77) CLEAN_LINE = ("dpCleanLine", 78) TIME_ZONE = ("dpTimeZone", 79) AREA_UNIT = ("dpAreaUnit", 80) NET_INFO = ("dpNetInfo", 81) CLEAN_ORDER = ("dpCleanOrder", 82) ROBOT_TYPE = ("dpRobotType", 83) LOG_SWITCH = ("dpLogSwitch", 84) FLOOR_MATERIAL = ("dpFloorMaterial", 85) LINE_LASER_OBSTACLE_AVOIDANCE = ("dpLineLaserObstacleAvoidance", 86) CLEAN_PROGESS = ("dpCleanProgess", 87) GROUND_CLEAN = ("dpGroundClean", 88) IGNORE_OBSTACLE = ("dpIgnoreObstacle", 89) FAULT = ("dpFault", 90) CLEAN_EXPAND = ("dpCleanExpand", 91) NOT_DISTURB_EXPAND = ("dpNotDisturbExpand", 92) TIMER_TYPE = ("dpTimerType", 93) CREATE_MAP_FINISHED = ("dpCreateMapFinished", 94) ADD_CLEAN_AREA = ("dpAddCleanArea", 95) ADD_CLEAN_STATE = ("dpAddCleanState", 96) RESTRICTED_AREA = ("dpRestrictedArea", 97) RESTRICTED_AREA_UP = ("dpRestrictedAreaUp", 98) SUSPECTED_THRESHOLD = ("dpSuspectedThreshold", 99) SUSPECTED_THRESHOLD_UP = ("dpSuspectedThresholdUp", 100) COMMON = ("dpCommon", 101) JUMP_SCAN = ("dpJumpScan", 101) REQUETDPS = ("dpRequetdps", 102) # NOTE: THIS TYPO IS FOUND IN SOURCE CODE CLIFF_RESTRICTED_AREA = ("dpCliffRestrictedArea", 102) CLIFF_RESTRICTED_AREA_UP = ("dpCliffRestrictedAreaUp", 103) BREAKPOINT_CLEAN = ("dpBreakpointClean", 104) VALLEY_POINT_CHARGING = ("dpValleyPointCharging", 105) VALLEY_POINT_CHARGING_DATA_UP = ("dpValleyPointChargingDataUp", 106) VALLEY_POINT_CHARGING_DATA = ("dpValleyPointChargingData", 107) VOICE_VERSION = ("dpVoiceVersion", 108) ROBOT_COUNTRY_CODE = ("dpRobotCountryCode", 109) HEARTBEAT = ("dpHeartbeat", 110) STATUS = ("dpStatus", 121) BATTERY = ("dpBattery", 122) FUN_LEVEL = ("dpfunLevel", 123) WATER_LEVEL = ("dpWaterLevel", 124) MAIN_BRUSH_LIFE = ("dpMainBrushLife", 125) SIDE_BRUSH_LIFE = ("dpSideBrushLife", 126) FILTER_LIFE = ("dpFilterLife", 127) TASK_CANCEL_IN_MOTION = ("dpTaskCancelInMotion", 132) OFFLINE = ("dpOffline", 135) CLEAN_COUNT = ("dpCleanCount", 136) CLEAN_MODE = ("dpCleanMode", 137) CLEAN_TASK_TYPE = ("dpCleanTaskType", 138) BACK_TYPE = ("dpBackType", 139) CLEANING_PROGRESS = ("dpCleaningProgress", 141) FLEEING_GOODS = ("dpFleeingGoods", 142) START_CLEAN = ("dpStartClean", 201) START_BACK = ("dpStartBack", 202) START_DOCK_TASK = ("dpStartDockTask", 203) PAUSE = ("dpPause", 204) RESUME = ("dpResume", 205) STOP = ("dpStop", 206) USER_PLAN = ("dpUserPlan", 207) class YXFanLevel(RoborockModeEnum): UNKNOWN = "unknown", -1 CLOSE = "close", 0 QUITE = "quite", 1 NORMAL = "normal", 2 STRONG = "strong", 3 MAX = "max", 4 SUPER = "super", 5 class YXWaterLevel(RoborockModeEnum): UNKNOWN = "unknown", -1 CLOSE = "close", 0 LOW = "low", 1 MIDDLE = "middle", 2 HIGH = "high", 3 class YXCleanLine(RoborockModeEnum): FAST = "fast", 0 DAILY = "daily", 1 FINE = "fine", 2 class YXRoomMaterial(RoborockModeEnum): HORIZONTAL_FLOOR_BOARD = "horizontalfloorboard", 0 VERTICAL_FLOOR_BOARD = "verticalfloorboard", 1 CERAMIC_TILE = "ceramictile", 2 OTHER = "other", 255 class YXCleanType(RoborockModeEnum): UNKNOWN = "unknown", -1 BOTH_WORK = "bothwork", 1 ONLY_SWEEP = "onlysweep", 2 ONLY_MOP = "onlymop", 3 class YXDeviceState(RoborockModeEnum): UNKNOWN = "unknown", -1 SLEEP_STATE = "sleepstate", 2 STANDBY_STATE = "standbystate", 3 CLEANING_STATE = "cleaningstate", 5 TO_CHARGE_STATE = "tochargestate", 6 REMOTEING_STATE = "remoteingstate", 7 CHARGING_STATE = "chargingstate", 8 PAUSE_STATE = "pausestate", 10 FAULT_STATE = "faultstate", 12 UPGRADE_STATE = "upgradestate", 14 DUSTING = "dusting", 22 CREATING_MAP_STATE = "creatingmapstate", 29 MAP_SAVE_STATE = "mapsavestate", 99 RE_LOCATION_STATE = "relocationstate", 101 ROBOT_SWEEPING = "robotsweeping", 102 ROBOT_MOPING = "robotmoping", 103 ROBOT_SWEEP_AND_MOPING = "robotsweepandmoping", 104 ROBOT_TRANSITIONING = "robottransitioning", 105 ROBOT_WAIT_CHARGE = "robotwaitcharge", 108 class YXBackType(RoborockModeEnum): UNKNOWN = "unknown", -1 IDLE = "idle", 0 BACK_DUSTING = "backdusting", 4 BACK_CHARGING = "backcharging", 5 class YXDeviceWorkMode(RoborockModeEnum): UNKNOWN = "unknown", -1 BOTH_WORK = "bothwork", 1 ONLY_SWEEP = "onlysweep", 2 ONLY_MOP = "onlymop", 3 CUSTOMIZED = "customized", 4 SAVE_WORRY = "saveworry", 5 SWEEP_MOP = "sweepmop", 6 class YXDeviceCleanTask(RoborockModeEnum): UNKNOWN = "unknown", -1 IDLE = "idle", 0 SMART = "smart", 1 ELECTORAL = "electoral", 2 DIVIDE_AREAS = "divideareas", 3 CREATING_MAP = "creatingmap", 4 PART = "part", 5 class YXDeviceDustCollectionFrequency(RoborockModeEnum): DAILY = "daily", 0 INTERVAL_15 = "interval_15", 15 INTERVAL_30 = "interval_30", 30 INTERVAL_45 = "interval_45", 45 INTERVAL_60 = "interval_60", 60 Python-roborock-python-roborock-d6da2db/roborock/data/b01_q10/b01_q10_containers.py000066400000000000000000000014621513363643200301650ustar00rootroot00000000000000from ..containers import RoborockBase class dpCleanRecord(RoborockBase): op: str result: int id: str data: list class dpMultiMap(RoborockBase): op: str result: int data: list class dpGetCarpet(RoborockBase): op: str result: int data: str class dpSelfIdentifyingCarpet(RoborockBase): op: str result: int data: str class dpNetInfo(RoborockBase): wifiName: str ipAdress: str mac: str signal: int class dpNotDisturbExpand(RoborockBase): disturb_dust_enable: int disturb_light: int disturb_resume_clean: int disturb_voice: int class dpCurrentCleanRoomIds(RoborockBase): room_id_list: list class dpVoiceVersion(RoborockBase): version: int class dpTimeZone(RoborockBase): timeZoneCity: str timeZoneSec: int Python-roborock-python-roborock-d6da2db/roborock/data/b01_q7/000077500000000000000000000000001513363643200242465ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/roborock/data/b01_q7/__init__.py000066400000000000000000000001051513363643200263530ustar00rootroot00000000000000from .b01_q7_code_mappings import * from .b01_q7_containers import * Python-roborock-python-roborock-d6da2db/roborock/data/b01_q7/b01_q7_code_mappings.py000066400000000000000000000244541513363643200305120ustar00rootroot00000000000000from ..code_mappings import RoborockModeEnum class WorkStatusMapping(RoborockModeEnum): """Maps the general status of the robot.""" SLEEPING = ("sleeping", 0) WAITING_FOR_ORDERS = ("waiting_for_orders", 1) PAUSED = ("paused", 2) DOCKING = ("docking", 3) CHARGING = ("charging", 4) SWEEP_MOPING = ("sweep_moping", 5) SWEEP_MOPING_2 = ("sweep_moping_2", 6) MOPING = ("moping", 7) UPDATING = ("updating", 8) MOP_CLEANING = ("mop_cleaning", 9) MOP_AIRDRYING = ("mop_airdrying", 10) class SCWindMapping(RoborockModeEnum): """Maps suction power levels.""" SILENCE = ("quiet", 1) STANDARD = ("balanced", 2) STRONG = ("turbo", 3) SUPER_STRONG = ("max", 4) SUPER_STRONG_PLUS = ("max_plus", 5) class WaterLevelMapping(RoborockModeEnum): """Maps water flow levels.""" LOW = ("low", 1) MEDIUM = ("medium", 2) HIGH = ("high", 3) class CleanTypeMapping(RoborockModeEnum): """Maps the type of cleaning (Vacuum, Mop, or both).""" VACUUM = ("vacuum", 0) VAC_AND_MOP = ("vac_and_mop", 1) MOP = ("mop", 2) class CleanRepeatMapping(RoborockModeEnum): """Maps the cleaning repeat parameter.""" ONCE = ("once", 0) TWICE = ("twice", 1) class SCDeviceCleanParam(RoborockModeEnum): """Maps the control values for cleaning tasks.""" STOP = ("stop", 0) START = ("start", 1) PAUSE = ("pause", 2) class WorkModeMapping(RoborockModeEnum): """Maps the detailed work modes of the robot.""" IDLE = ("idle", 0) AUTO = ("auto", 1) MANUAL = ("manual", 2) AREA = ("area", 3) AUTO_PAUSE = ("auto_pause", 4) BACK_CHARGE = ("back_charge", 5) POINT = ("point", 6) NAVI = ("navi", 7) AREA_PAUSE = ("area_pause", 8) NAVI_PAUSE = ("navi_pause", 9) GLOBAL_GO_HOME = ("global_go_home", 10) GLOBAL_BROKEN = ("global_broken", 11) NAVI_GO_HOME = ("navi_go_home", 12) POINT_GO_HOME = ("point_go_home", 13) NAVI_IDLE = ("navi_idle", 14) SCREW = ("screw", 20) SCREW_GO_HOME = ("screw_go_home", 21) POINT_IDLE = ("point_idle", 22) SCREW_IDLE = ("screw_idle", 23) BORDER = ("border", 25) BORDER_GO_HOME = ("border_go_home", 26) BORDER_PAUSE = ("border_pause", 27) BORDER_BROKEN = ("border_broken", 28) BORDER_IDLE = ("border_idle", 29) PLAN_AREA = ("plan_area", 30) PLAN_AREA_PAUSE = ("plan_area_pause", 31) PLAN_AREA_GO_HOME = ("plan_area_go_home", 32) PLAN_AREA_BROKEN = ("plan_area_broken", 33) PLAN_AREA_IDLE = ("plan_area_idle", 35) MOPPING = ("mopping", 36) MOPPING_PAUSE = ("mopping_pause", 37) MOPPING_GO_HOME = ("mopping_go_home", 38) MOPPING_BROKEN = ("mopping_broken", 39) MOPPING_IDLE = ("mopping_idle", 40) EXPLORING = ("exploring", 45) EXPLORE_PAUSE = ("explore_pause", 46) EXPLORE_GO_HOME = ("explore_go_home", 47) EXPLORE_BROKEN = ("explore_broken", 48) EXPLORE_IDLE = ("explore_idle", 49) class StationActionMapping(RoborockModeEnum): """Maps actions for the cleaning/drying station.""" STOP_CLEAN_OR_AIRDRY = ("stop_clean_or_airdry", 0) MOP_CLEAN = ("mop_clean", 1) MOP_AIRDRY = ("mop_airdry", 2) class CleanTaskTypeMapping(RoborockModeEnum): """Maps the high-level type of cleaning task selected.""" ALL = ("full", 0) ROOM = ("room", 1) AREA = ("zones", 4) ROOM_NORMAL = ("room_normal", 5) CUSTOM_MODE = ("customize", 6) ALL_CUSTOM = ("all_custom", 11) AREA_CUSTOM = ("area_custom", 99) class CarpetModeMapping(RoborockModeEnum): """Maps carpet handling parameters.""" FOLLOW_GLOBAL = ("follow_global", 0) ON = ("on", 1) OFF = ("off", 2) class B01Fault(RoborockModeEnum): """B01 fault codes and their descriptions.""" F_0 = ("fault_0", 0) F_407 = ("cleaning_in_progress", 407) # Cleaning in progress. Scheduled cleanup ignored. F_500 = ( "lidar_blocked", 500, ) # LiDAR turret or laser blocked. Check for obstruction and retry. LiDAR sensor obstructed or stuck. # Remove foreign objects if any. If the problem persists, move the robot away and restart. F_501 = ( "robot_suspended", 501, ) # Robot suspended. Move the robot away and restart. Cliff sensors dirty. Wipe them clean. F_502 = ( "low_battery", 502, ) # Low battery. Recharge now. Battery low. Put the robot on the dock to charge it to 20% before starting. F_503 = ( "dustbin_not_installed", 503, ) # Check that the dustbin and filter are installed properly. Reinstall the dustbin and filter in place. # If the problem persists, replace the filter. F_504 = ("fault_504", 504) F_505 = ("fault_505", 505) F_506 = ("fault_506", 506) F_507 = ("fault_507", 507) F_508 = ("fault_508", 508) F_509 = ("cliff_sensor_error", 509) # Cliff sensors error. Clean them, move the robot away from drops, and restart. F_510 = ( "bumper_stuck", 510, ) # Bumper stuck. Clean it and lightly tap to release it. Tap it repeatedly to release it. If no foreign object # exists, move the robot away and restart. F_511 = ( "docking_error", 511, ) # Docking error. Put the robot on the dock. Clear obstacles around the dock, clean charging contacts, and put # the robot on the dock. F_512 = ( "docking_error", 512, ) # Docking error. Put the robot on the dock. Clear obstacles around the dock, clean charging contacts, and put # the robot on the dock. F_513 = ( "robot_trapped", 513, ) # Robot trapped. Move the robot away and restart. Clear obstacles around robot or move robot away and restart. F_514 = ( "robot_trapped", 514, ) # Robot trapped. Move the robot away and restart. Clear obstacles around robot or move robot away and restart. F_515 = ("fault_515", 515) F_517 = ("fault_517", 517) F_518 = ( "low_battery", 518, ) # Low battery. Recharge now. Battery low. Put the robot on the dock to charge it to 20% before starting. F_519 = ("fault_519", 519) F_520 = ("fault_520", 520) F_521 = ("fault_521", 521) F_522 = ("mop_not_installed", 522) # Check that the mop is properly installed. Mop not installed. Reinstall it. F_523 = ("fault_523", 523) F_525 = ("fault_525", 525) F_526 = ("fault_526", 526) F_527 = ("fault_527", 527) F_528 = ("fault_528", 528) F_529 = ("fault_529", 529) F_530 = ("fault_530", 530) F_531 = ("fault_531", 531) F_532 = ("fault_532", 532) F_533 = ("long_sleep", 533) # About to shut down after a long time of sleep. Charge the robot. F_534 = ( "low_battery_shutdown", 534, ) # Low battery. Turning off. About to shut down due to low battery. Charge the robot. F_535 = ("fault_535", 535) F_536 = ("fault_536", 536) F_540 = ("fault_540", 540) F_541 = ("fault_541", 541) F_542 = ("fault_542", 542) F_550 = ("fault_550", 550) F_551 = ("fault_551", 551) F_559 = ("fault_559", 559) F_560 = ("side_brush_entangled", 560) # Side brush entangled. Remove and clean it. F_561 = ("fault_561", 561) F_562 = ("fault_562", 562) F_563 = ("fault_563", 563) F_564 = ("fault_564", 564) F_565 = ("fault_565", 565) F_566 = ("fault_566", 566) F_567 = ("fault_567", 567) F_568 = ("main_wheels_entangled", 568) # Clean main wheels, move the robot away and restart. F_569 = ("main_wheels_entangled", 569) # Clean main wheels, move the robot away and restart. F_570 = ("main_brush_entangled", 570) # Main brush entangled. Remove and clean it and its bearing. F_571 = ("fault_571", 571) F_572 = ("main_brush_entangled", 572) # Main brush entangled. Remove and clean it and its bearing. F_573 = ("fault_573", 573) F_574 = ("fault_574", 574) F_580 = ("fault_580", 580) F_581 = ("fault_581", 581) F_582 = ("fault_582", 582) F_583 = ("fault_583", 583) F_584 = ("fault_584", 584) F_585 = ("fault_585", 585) F_586 = ("fault_586", 586) F_587 = ("fault_587", 587) F_588 = ("fault_588", 588) F_589 = ("fault_589", 589) F_590 = ("fault_590", 590) F_591 = ("fault_591", 591) F_592 = ("fault_592", 592) F_593 = ("fault_593", 593) F_594 = ( "dust_bag_not_installed", 594, ) # Make sure the dust bag is properly installed. Dust bag not installed. Check that it is installed properly. F_601 = ("fault_601", 601) F_602 = ("fault_602", 602) F_603 = ("fault_603", 603) F_604 = ("fault_604", 604) F_605 = ("fault_605", 605) F_611 = ("positioning_failed", 611) # Positioning failed. Move the robot back to the dock and remap. F_612 = ( "map_changed", 612, ) # Map changed. Positioning failed. Try again. New environment detected. Map changed. Positioning failed. # Try again after remapping. F_629 = ("mop_mount_fell_off", 629) # Mop cloth mount fell off. Reinstall it to resume working. F_668 = ( "system_error", 668, ) # Robot error. Reset the system. Fan error. Reset the system. If the problem persists, contact customer service. F_2000 = ("fault_2000", 2000) F_2003 = ("low_battery_schedule_canceled", 2003) # Battery level below 20%. Scheduled task canceled. F_2007 = ( "cannot_reach_target", 2007, ) # Unable to reach the target. Cleaning ended. Ensure the door to the target area is open or unobstructed. F_2012 = ( "cannot_reach_target", 2012, ) # Unable to reach the target. Cleaning ended. Ensure the door to the target area is open or unobstructed. F_2013 = ("fault_2013", 2013) F_2015 = ("fault_2015", 2015) F_2017 = ("fault_2017", 2017) F_2100 = ( "low_battery_resume_later", 2100, ) # Low battery. Resume cleaning after recharging. Low battery. Starting to recharge. Resume cleaning after # charging. F_2101 = ("fault_2101", 2101) F_2102 = ("cleaning_complete", 2102) # Cleaning completed. Returning to the dock. F_2103 = ("fault_2103", 2103) F_2104 = ("fault_2104", 2104) F_2105 = ("fault_2105", 2105) F_2108 = ("fault_2108", 2108) F_2109 = ("fault_2109", 2109) F_2110 = ("fault_2110", 2110) F_2111 = ("fault_2111", 2111) F_2112 = ("fault_2112", 2112) F_2113 = ("fault_2113", 2113) F_2114 = ("fault_2114", 2114) F_2115 = ("fault_2115", 2115) Python-roborock-python-roborock-d6da2db/roborock/data/b01_q7/b01_q7_containers.py000066400000000000000000000133471513363643200300460ustar00rootroot00000000000000from dataclasses import dataclass, field from ..containers import RoborockBase from .b01_q7_code_mappings import ( B01Fault, SCWindMapping, WorkModeMapping, WorkStatusMapping, ) @dataclass class NetStatus(RoborockBase): """Represents the network status of the device.""" rssi: str loss: int ping: int ip: str mac: str ssid: str frequency: int bssid: str @dataclass class OrderTotal(RoborockBase): """Represents the order total information.""" total: int enable: int @dataclass class Privacy(RoborockBase): """Represents the privacy settings of the device.""" ai_recognize: int dirt_recognize: int pet_recognize: int carpet_turbo: int carpet_avoid: int carpet_show: int map_uploads: int ai_agent: int ai_avoidance: int record_uploads: int along_floor: int auto_upgrade: int @dataclass class PvCharging(RoborockBase): """Represents the photovoltaic charging status.""" status: int begin_time: int end_time: int @dataclass class Recommend(RoborockBase): """Represents cleaning recommendations.""" sill: int wall: int room_id: list[int] = field(default_factory=list) @dataclass class B01Props(RoborockBase): """ Represents the complete properties and status for a Roborock B01 model. This dataclass is generated based on the device's status JSON object. """ status: WorkStatusMapping | None = None fault: B01Fault | None = None wind: SCWindMapping | None = None water: int | None = None mode: int | None = None quantity: int | None = None alarm: int | None = None volume: int | None = None hypa: int | None = None main_brush: int | None = None side_brush: int | None = None mop_life: int | None = None main_sensor: int | None = None net_status: NetStatus | None = None repeat_state: int | None = None tank_state: int | None = None sweep_type: int | None = None clean_path_preference: int | None = None cloth_state: int | None = None time_zone: int | None = None time_zone_info: str | None = None language: int | None = None cleaning_time: int | None = None real_clean_time: int | None = None cleaning_area: int | None = None custom_type: int | None = None sound: int | None = None work_mode: WorkModeMapping | None = None station_act: int | None = None charge_state: int | None = None current_map_id: int | None = None map_num: int | None = None dust_action: int | None = None quiet_is_open: int | None = None quiet_begin_time: int | None = None quiet_end_time: int | None = None clean_finish: int | None = None voice_type: int | None = None voice_type_version: int | None = None order_total: OrderTotal | None = None build_map: int | None = None privacy: Privacy | None = None dust_auto_state: int | None = None dust_frequency: int | None = None child_lock: int | None = None multi_floor: int | None = None map_save: int | None = None light_mode: int | None = None green_laser: int | None = None dust_bag_used: int | None = None order_save_mode: int | None = None manufacturer: str | None = None back_to_wash: int | None = None charge_station_type: int | None = None pv_cut_charge: int | None = None pv_charging: PvCharging | None = None serial_number: str | None = None recommend: Recommend | None = None add_sweep_status: int | None = None @property def main_brush_time_left(self) -> int | None: """ Returns estimated remaining life of the main brush in minutes. Total life is 300 hours (18000 minutes). """ if self.main_brush is None: return None return max(0, 18000 - self.main_brush) @property def side_brush_time_left(self) -> int | None: """ Returns estimated remaining life of the side brush in minutes. Total life is 200 hours (12000 minutes). """ if self.side_brush is None: return None return max(0, 12000 - self.side_brush) @property def filter_time_left(self) -> int | None: """ Returns estimated remaining life of the filter (hypa) in minutes. Total life is 150 hours (9000 minutes). """ if self.hypa is None: return None return max(0, 9000 - self.hypa) @property def mop_life_time_left(self) -> int | None: """ Returns estimated remaining life of the mop in minutes. Total life is 180 hours (10800 minutes). """ if self.mop_life is None: return None return max(0, 10800 - self.mop_life) @property def sensor_dirty_time_left(self) -> int | None: """ Returns estimated time until sensors need cleaning in minutes. Maintenance interval is typically 30 hours (1800 minutes). """ if self.main_sensor is None: return None return max(0, 1800 - self.main_sensor) @property def status_name(self) -> str | None: """Returns the name of the current status.""" return self.status.value if self.status is not None else None @property def fault_name(self) -> str | None: """Returns the name of the current fault.""" return self.fault.value if self.fault is not None else None @property def wind_name(self) -> str | None: """Returns the name of the current fan speed (wind).""" return self.wind.value if self.wind is not None else None @property def work_mode_name(self) -> str | None: """Returns the name of the current work mode.""" return self.work_mode.value if self.work_mode is not None else None Python-roborock-python-roborock-d6da2db/roborock/data/code_mappings.py000066400000000000000000000157031513363643200264450ustar00rootroot00000000000000from __future__ import annotations import logging from collections import namedtuple from enum import Enum, IntEnum, StrEnum from typing import Self _LOGGER = logging.getLogger(__name__) completed_warnings = set() class RoborockEnum(IntEnum): """Roborock Enum for codes with int values""" @property def name(self) -> str: return super().name.lower() @classmethod def _missing_(cls: type[RoborockEnum], key) -> RoborockEnum: if hasattr(cls, "unknown"): warning = f"Missing {cls.__name__} code: {key} - defaulting to 'unknown'" if warning not in completed_warnings: completed_warnings.add(warning) _LOGGER.warning(warning) return cls.unknown # type: ignore default_value = next(item for item in cls) warning = f"Missing {cls.__name__} code: {key} - defaulting to {default_value}" if warning not in completed_warnings: completed_warnings.add(warning) _LOGGER.warning(warning) return default_value @classmethod def as_dict(cls: type[RoborockEnum]): return {i.name: i.value for i in cls if i.name != "missing"} @classmethod def as_enum_dict(cls: type[RoborockEnum]): return {i.value: i for i in cls if i.name != "missing"} @classmethod def values(cls: type[RoborockEnum]) -> list[int]: return list(cls.as_dict().values()) @classmethod def keys(cls: type[RoborockEnum]) -> list[str]: return list(cls.as_dict().keys()) @classmethod def items(cls: type[RoborockEnum]): return cls.as_dict().items() class RoborockModeEnum(StrEnum): """A custom StrEnum that also stores an integer code for each member.""" code: int """The integer code associated with the enum member.""" def __new__(cls, value: str, code: int) -> Self: """Creates a new enum member.""" member = str.__new__(cls, value) member._value_ = value member.code = code return member @classmethod def from_code(cls, code: int) -> Self: for member in cls: if member.code == code: return member message = f"{code} is not a valid code for {cls.__name__}" if message not in completed_warnings: completed_warnings.add(message) _LOGGER.warning(message) raise ValueError(message) @classmethod def from_code_optional(cls, code: int) -> RoborockModeEnum | None: try: return cls.from_code(code) except ValueError: return None @classmethod def from_value(cls, value: str) -> Self: """Find enum member by string value (case-insensitive).""" for member in cls: if member.value.lower() == value.lower(): return member raise ValueError(f"{value} is not a valid value for {cls.__name__}") @classmethod def from_name(cls, name: str) -> Self: """Find enum member by name (case-insensitive).""" for member in cls: if member.name.lower() == name.lower(): return member raise ValueError(f"{name} is not a valid name for {cls.__name__}") @classmethod def keys(cls) -> list[str]: """Returns a list of all member values.""" return [member.value for member in cls] ProductInfo = namedtuple("ProductInfo", ["nickname", "short_models"]) class RoborockProductNickname(Enum): # Coral Series CORAL = ProductInfo(nickname="Coral", short_models=("a20", "a21")) CORALPRO = ProductInfo(nickname="CoralPro", short_models=("a143", "a144")) # Pearl Series PEARL = ProductInfo(nickname="Pearl", short_models=("a74", "a75")) PEARLC = ProductInfo(nickname="PearlC", short_models=("a103", "a104")) PEARLE = ProductInfo(nickname="PearlE", short_models=("a167", "a168")) PEARLELITE = ProductInfo(nickname="PearlELite", short_models=("a169", "a170")) PEARLPLUS = ProductInfo(nickname="PearlPlus", short_models=("a86", "a87")) PEARLPLUSS = ProductInfo(nickname="PearlPlusS", short_models=("a116", "a117", "a136")) PEARLS = ProductInfo(nickname="PearlS", short_models=("a100", "a101")) PEARLSLITE = ProductInfo(nickname="PearlSLite", short_models=("a122", "a123")) # Ruby Series RUBYPLUS = ProductInfo(nickname="RubyPlus", short_models=("t4", "s4")) RUBYSC = ProductInfo(nickname="RubySC", short_models=("p5", "a08")) RUBYSE = ProductInfo(nickname="RubySE", short_models=("a19",)) RUBYSLITE = ProductInfo(nickname="RubySLite", short_models=("p6", "s5e", "a05")) # Tanos Series TANOS = ProductInfo(nickname="Tanos", short_models=("t6", "s6")) TANOSE = ProductInfo(nickname="TanosE", short_models=("t7", "a11")) TANOSS = ProductInfo(nickname="TanosS", short_models=("a14", "a15")) TANOSSC = ProductInfo(nickname="TanosSC", short_models=("a39", "a40")) TANOSSE = ProductInfo(nickname="TanosSE", short_models=("a33", "a34")) TANOSSMAX = ProductInfo(nickname="TanosSMax", short_models=("a52",)) TANOSSLITE = ProductInfo(nickname="TanosSLite", short_models=("a37", "a38")) TANOSSPLUS = ProductInfo(nickname="TanosSPlus", short_models=("a23", "a24")) TANOSV = ProductInfo(nickname="TanosV", short_models=("t7p", "a09", "a10")) # Topaz Series TOPAZS = ProductInfo(nickname="TopazS", short_models=("a29", "a30", "a76")) TOPAZSC = ProductInfo(nickname="TopazSC", short_models=("a64", "a65")) TOPAZSPLUS = ProductInfo(nickname="TopazSPlus", short_models=("a46", "a47", "a66")) TOPAZSPOWER = ProductInfo(nickname="TopazSPower", short_models=("a62",)) TOPAZSV = ProductInfo(nickname="TopazSV", short_models=("a26", "a27")) # Ultron Series ULTRON = ProductInfo(nickname="Ultron", short_models=("a50", "a51")) ULTRONE = ProductInfo(nickname="UltronE", short_models=("a72", "a84")) ULTRONLITE = ProductInfo(nickname="UltronLite", short_models=("a73", "a85")) ULTRONSC = ProductInfo(nickname="UltronSC", short_models=("a94", "a95")) ULTRONSE = ProductInfo(nickname="UltronSE", short_models=("a124", "a125", "a139", "a140")) ULTRONSPLUS = ProductInfo(nickname="UltronSPlus", short_models=("a68", "a69", "a70")) ULTRONSV = ProductInfo(nickname="UltronSV", short_models=("a96", "a97")) # Verdelite Series VERDELITE = ProductInfo(nickname="Verdelite", short_models=("a146", "a147")) # Vivian Series VIVIAN = ProductInfo(nickname="Vivian", short_models=("a134", "a135", "a155", "a156")) VIVIANC = ProductInfo(nickname="VivianC", short_models=("a158", "a159")) SHORT_MODEL_TO_ENUM = {model: product for product in RoborockProductNickname for model in product.value.short_models} class RoborockCategory(Enum): """Describes the category of the device.""" WET_DRY_VAC = "roborock.wetdryvac" VACUUM = "robot.vacuum.cleaner" WASHING_MACHINE = "roborock.wm" UNKNOWN = "UNKNOWN" def __missing__(self, key): _LOGGER.warning("Missing key %s from category", key) return RoborockCategory.UNKNOWN Python-roborock-python-roborock-d6da2db/roborock/data/containers.py000066400000000000000000000336611513363643200260050ustar00rootroot00000000000000import dataclasses import datetime import inspect import json import logging import re import types from dataclasses import asdict, dataclass, field from enum import Enum from functools import cached_property from typing import Any, ClassVar, NamedTuple, get_args, get_origin from .code_mappings import ( SHORT_MODEL_TO_ENUM, RoborockCategory, RoborockModeEnum, RoborockProductNickname, ) _LOGGER = logging.getLogger(__name__) def _camelize(s: str): first, *others = s.split("_") if len(others) == 0: return s return "".join([first.lower(), *map(str.title, others)]) def _decamelize(s: str): # Split before uppercase letters not at the start, and before numbers s = re.sub(r"(?<=[a-z0-9])([A-Z])", r"_\1", s) s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s) # Split acronyms followed by normal camelCase s = re.sub(r"([a-zA-Z])([0-9]+)", r"\1_\2", s) s = s.lower() # Temporary fix to avoid breaking any serialization. s = s.replace("base_64", "base64") return s def _attr_repr(obj: Any) -> str: """Return a string representation of the object including specified attributes. This reproduces the default repr behavior of dataclasses, but also includes properties. This must be called by the child class's __repr__ method since the parent RoborockBase class does not know about the child class's attributes. """ # Reproduce default repr behavior parts = [] for k in dir(obj): if k.startswith("_"): continue try: v = getattr(obj, k) except (RuntimeError, Exception): continue if callable(v): continue parts.append(f"{k}={v!r}") return f"{type(obj).__name__}({', '.join(parts)})" @dataclass(repr=False) class RoborockBase: """Base class for all Roborock data classes.""" _missing_logged: ClassVar[set[str]] = set() @staticmethod def _convert_to_class_obj(class_type: type, value): if get_origin(class_type) is list: sub_type = get_args(class_type)[0] return [RoborockBase._convert_to_class_obj(sub_type, obj) for obj in value] if get_origin(class_type) is dict: key_type, value_type = get_args(class_type) if key_type is not None: return {key_type(k): RoborockBase._convert_to_class_obj(value_type, v) for k, v in value.items()} return {k: RoborockBase._convert_to_class_obj(value_type, v) for k, v in value.items()} if inspect.isclass(class_type): if issubclass(class_type, RoborockBase): return class_type.from_dict(value) if issubclass(class_type, RoborockModeEnum): return class_type.from_code(value) if class_type is Any or type(class_type) is str: return value return class_type(value) # type: ignore[call-arg] @classmethod def from_dict(cls, data: dict[str, Any]): """Create an instance of the class from a dictionary.""" if not isinstance(data, dict): return None field_types = {field.name: field.type for field in dataclasses.fields(cls)} result: dict[str, Any] = {} for orig_key, value in data.items(): key = _decamelize(orig_key) if (field_type := field_types.get(key)) is None: if (log_key := f"{cls.__name__}.{key}") not in RoborockBase._missing_logged: _LOGGER.debug( "Key '%s' (decamelized: '%s') not found in %s fields, skipping", orig_key, key, cls.__name__, ) RoborockBase._missing_logged.add(log_key) continue if value == "None" or value is None: result[key] = None continue if isinstance(field_type, types.UnionType): for subtype in get_args(field_type): if subtype is types.NoneType: continue try: result[key] = RoborockBase._convert_to_class_obj(subtype, value) break except Exception: _LOGGER.exception(f"Failed to convert {key} with value {value} to type {subtype}") continue else: try: result[key] = RoborockBase._convert_to_class_obj(field_type, value) except Exception: _LOGGER.exception(f"Failed to convert {key} with value {value} to type {field_type}") continue return cls(**result) def as_dict(self) -> dict: return asdict( self, dict_factory=lambda _fields: { _camelize(key): value.value if isinstance(value, Enum) else value for (key, value) in _fields if value is not None }, ) @dataclass class RoborockBaseTimer(RoborockBase): start_hour: int | None = None start_minute: int | None = None end_hour: int | None = None end_minute: int | None = None enabled: int | None = None @property def start_time(self) -> datetime.time | None: return ( datetime.time(hour=self.start_hour, minute=self.start_minute) if self.start_hour is not None and self.start_minute is not None else None ) @property def end_time(self) -> datetime.time | None: return ( datetime.time(hour=self.end_hour, minute=self.end_minute) if self.end_hour is not None and self.end_minute is not None else None ) def as_list(self) -> list: return [self.start_hour, self.start_minute, self.end_hour, self.end_minute] def __repr__(self) -> str: return _attr_repr(self) @dataclass class Reference(RoborockBase): r: str | None = None a: str | None = None m: str | None = None l: str | None = None @dataclass class RRiot(RoborockBase): u: str s: str h: str k: str r: Reference @dataclass class UserData(RoborockBase): rriot: RRiot uid: int | None = None tokentype: str | None = None token: str | None = None rruid: str | None = None region: str | None = None countrycode: str | None = None country: str | None = None nickname: str | None = None tuya_device_state: int | None = None avatarurl: str | None = None @dataclass class HomeDataProductSchema(RoborockBase): id: Any | None = None name: Any | None = None code: Any | None = None mode: Any | None = None type: Any | None = None product_property: Any | None = None property: Any | None = None desc: Any | None = None @dataclass class HomeDataProduct(RoborockBase): id: str name: str model: str category: RoborockCategory code: str | None = None icon_url: str | None = None attribute: Any | None = None capability: int | None = None schema: list[HomeDataProductSchema] | None = None @property def product_nickname(self) -> RoborockProductNickname: return SHORT_MODEL_TO_ENUM.get(self.model.split(".")[-1], RoborockProductNickname.PEARLPLUS) def summary_info(self) -> str: """Return a string with key product information for logging purposes.""" return f"{self.name} (model={self.model}, category={self.category})" @cached_property def supported_schema_codes(self) -> set[str]: """Return a set of fields that are supported by the device.""" if self.schema is None: return set() return {schema.code for schema in self.schema if schema.code is not None} @dataclass class HomeDataDevice(RoborockBase): duid: str name: str local_key: str product_id: str fv: str | None = None attribute: Any | None = None active_time: int | None = None runtime_env: Any | None = None time_zone_id: str | None = None icon_url: str | None = None lon: Any | None = None lat: Any | None = None share: Any | None = None share_time: Any | None = None online: bool | None = None pv: str | None = None room_id: Any | None = None tuya_uuid: Any | None = None tuya_migrated: bool | None = None extra: Any | None = None sn: str | None = None feature_set: str | None = None new_feature_set: str | None = None device_status: dict | None = None silent_ota_switch: bool | None = None setting: Any | None = None f: bool | None = None create_time: int | None = None cid: str | None = None share_type: Any | None = None share_expired_time: int | None = None def summary_info(self) -> str: """Return a string with key device information for logging purposes.""" return f"{self.name} (pv={self.pv}, fv={self.fv}, online={self.online})" @dataclass class HomeDataRoom(RoborockBase): id: int name: str @dataclass class HomeDataScene(RoborockBase): id: int name: str @dataclass class HomeDataSchedule(RoborockBase): id: int cron: str repeated: bool enabled: bool param: dict | None = None @dataclass class HomeData(RoborockBase): id: int name: str products: list[HomeDataProduct] = field(default_factory=lambda: []) devices: list[HomeDataDevice] = field(default_factory=lambda: []) received_devices: list[HomeDataDevice] = field(default_factory=lambda: []) lon: Any | None = None lat: Any | None = None geo_name: Any | None = None rooms: list[HomeDataRoom] = field(default_factory=list) def get_all_devices(self) -> list[HomeDataDevice]: devices = [] if self.devices is not None: devices += self.devices if self.received_devices is not None: devices += self.received_devices return devices @cached_property def product_map(self) -> dict[str, HomeDataProduct]: """Returns a dictionary of product IDs to HomeDataProduct objects.""" return {product.id: product for product in self.products} @cached_property def device_products(self) -> dict[str, tuple[HomeDataDevice, HomeDataProduct]]: """Returns a dictionary of device DUIDs to HomeDataDeviceProduct objects.""" product_map = self.product_map return { device.duid: (device, product) for device in self.get_all_devices() if (product := product_map.get(device.product_id)) is not None } @dataclass class LoginData(RoborockBase): user_data: UserData email: str home_data: HomeData | None = None @dataclass class DeviceData(RoborockBase): device: HomeDataDevice model: str host: str | None = None @property def product_nickname(self) -> RoborockProductNickname: return SHORT_MODEL_TO_ENUM.get(self.model.split(".")[-1], RoborockProductNickname.PEARLPLUS) def __repr__(self) -> str: return _attr_repr(self) @dataclass class RoomMapping(RoborockBase): segment_id: int iot_id: str @dataclass class NamedRoomMapping(RoomMapping): """Dataclass representing a mapping of a room segment to a name. The name information is not provided by the device directly, but is provided from the HomeData based on the iot_id from the room. """ name: str """The human-readable name of the room, if available.""" @dataclass class CombinedMapInfo(RoborockBase): """Data structure for caching home information. This is not provided directly by the API, but is a combination of map data and room data to provide a more useful structure. """ map_flag: int """The map identifier.""" name: str """The name of the map from MultiMapsListMapInfo.""" rooms: list[NamedRoomMapping] """The list of rooms in the map.""" @dataclass class BroadcastMessage(RoborockBase): duid: str ip: str version: bytes class ServerTimer(NamedTuple): id: str status: str dontknow: int @dataclass class RoborockProductStateValue(RoborockBase): value: list desc: dict @dataclass class RoborockProductState(RoborockBase): dps: int desc: dict value: list[RoborockProductStateValue] @dataclass class RoborockProductSpec(RoborockBase): state: RoborockProductState battery: dict | None = None dry_countdown: dict | None = None extra: dict | None = None offpeak: dict | None = None countdown: dict | None = None mode: dict | None = None ota_nfo: dict | None = None pause: dict | None = None program: dict | None = None shutdown: dict | None = None washing_left: dict | None = None @dataclass class RoborockProduct(RoborockBase): id: int | None = None name: str | None = None model: str | None = None packagename: str | None = None ssid: str | None = None picurl: str | None = None cardpicurl: str | None = None mediumCardpicurl: str | None = None resetwifipicurl: str | None = None configPicUrl: str | None = None pluginPicUrl: str | None = None resetwifitext: dict | None = None tuyaid: str | None = None status: int | None = None rriotid: str | None = None pictures: list | None = None ncMode: str | None = None scope: str | None = None product_tags: list | None = None agreements: list | None = None cardspec: str | None = None plugin_pic_url: str | None = None @property def product_nickname(self) -> RoborockProductNickname | None: if self.cardspec: return RoborockProductSpec.from_dict(json.loads(self.cardspec).get("data")) return None def __repr__(self) -> str: return _attr_repr(self) @dataclass class RoborockProductCategory(RoborockBase): id: int display_name: str icon_url: str @dataclass class RoborockCategoryDetail(RoborockBase): category: RoborockProductCategory product_list: list[RoborockProduct] @dataclass class ProductResponse(RoborockBase): category_detail_list: list[RoborockCategoryDetail] Python-roborock-python-roborock-d6da2db/roborock/data/dyad/000077500000000000000000000000001513363643200241765ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/roborock/data/dyad/__init__.py000066400000000000000000000001011513363643200262770ustar00rootroot00000000000000from .dyad_code_mappings import * from .dyad_containers import * Python-roborock-python-roborock-d6da2db/roborock/data/dyad/dyad_code_mappings.py000066400000000000000000000054241513363643200303660ustar00rootroot00000000000000from ..code_mappings import RoborockEnum class RoborockDyadStateCode(RoborockEnum): unknown = -999 fetching = -998 # Obtaining Status fetch_failed = -997 # Failed to obtain device status. Try again later. updating = -996 washing = 1 ready = 2 charging = 3 mop_washing = 4 self_clean_cleaning = 5 self_clean_deep_cleaning = 6 self_clean_rinsing = 7 self_clean_dehydrating = 8 drying = 9 ventilating = 10 # drying reserving = 12 mop_washing_paused = 13 dusting_mode = 14 class DyadSelfCleanMode(RoborockEnum): self_clean = 1 self_clean_and_dry = 2 dry = 3 ventilation = 4 class DyadSelfCleanLevel(RoborockEnum): normal = 1 deep = 2 class DyadWarmLevel(RoborockEnum): normal = 1 deep = 2 class DyadMode(RoborockEnum): wash = 1 wash_and_dry = 2 dry = 3 class DyadCleanMode(RoborockEnum): auto = 1 max = 2 dehydration = 3 power_saving = 4 class DyadSuction(RoborockEnum): l1 = 1 l2 = 2 l3 = 3 l4 = 4 l5 = 5 l6 = 6 class DyadWaterLevel(RoborockEnum): l1 = 1 l2 = 2 l3 = 3 l4 = 4 class DyadBrushSpeed(RoborockEnum): l1 = 1 l2 = 2 class DyadCleanser(RoborockEnum): none = 0 normal = 1 deep = 2 max = 3 class DyadError(RoborockEnum): none = 0 dirty_tank_full = 20000 # Dirty tank full. Empty it water_level_sensor_stuck = 20001 # Water level sensor is stuck. Clean it. clean_tank_empty = 20002 # Clean tank empty. Refill now clean_head_entangled = 20003 # Check if the cleaning head is entangled with foreign objects. clean_head_too_hot = 20004 # Cleaning head temperature protection. Wait for the temperature to return to normal. fan_protection_e5 = 10005 # Fan protection (E5). Restart the vacuum cleaner. cleaning_head_blocked = 20005 # Remove blockages from the cleaning head and pipes. temperature_protection = 20006 # Temperature protection. Wait for the temperature to return to normal fan_protection_e4 = 10004 # Fan protection (E4). Restart the vacuum cleaner. fan_protection_e9 = 10009 # Fan protection (E9). Restart the vacuum cleaner. battery_temperature_protection_e0 = 10000 battery_temperature_protection = ( 20007 # Battery temperature protection. Wait for the temperature to return to a normal range. ) battery_temperature_protection_2 = 20008 power_adapter_error = 20009 # Check if the power adapter is working properly. dirty_charging_contacts = 10007 # Disconnection between the device and dock. Wipe charging contacts. low_battery = 20017 # Low battery level. Charge before starting self-cleaning. battery_under_10 = 20018 # Charge until the battery level exceeds 10% before manually starting self-cleaning. Python-roborock-python-roborock-d6da2db/roborock/data/dyad/dyad_containers.py000066400000000000000000000006621513363643200277220ustar00rootroot00000000000000from dataclasses import dataclass from ..containers import RoborockBase @dataclass class DyadProductInfo(RoborockBase): sn: str ssid: str timezone: str posix_timezone: str ip: str mac: str oba: dict @dataclass class DyadSndState(RoborockBase): sid_in_use: int sid_version: int location: str bom: str language: str @dataclass class DyadOtaNfo(RoborockBase): mqttOtaData: dict Python-roborock-python-roborock-d6da2db/roborock/data/v1/000077500000000000000000000000001513363643200236035ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/roborock/data/v1/__init__.py000066400000000000000000000001331513363643200257110ustar00rootroot00000000000000from .v1_clean_modes import * from .v1_code_mappings import * from .v1_containers import * Python-roborock-python-roborock-d6da2db/roborock/data/v1/v1_clean_modes.py000066400000000000000000000144351513363643200270430ustar00rootroot00000000000000from __future__ import annotations import typing from ..code_mappings import RoborockModeEnum if typing.TYPE_CHECKING: from roborock.device_features import DeviceFeatures class VacuumModes(RoborockModeEnum): GENTLE = ("gentle", 105) OFF = ("off", 105) QUIET = ("quiet", 101) BALANCED = ("balanced", 102) TURBO = ("turbo", 103) MAX = ("max", 104) MAX_PLUS = ("max_plus", 108) CUSTOMIZED = ("custom", 106) SMART_MODE = ("smart_mode", 110) class CleanRoutes(RoborockModeEnum): STANDARD = ("standard", 300) DEEP = ("deep", 301) DEEP_PLUS = ("deep_plus", 303) FAST = ("fast", 304) DEEP_PLUS_CN = ("deep_plus", 305) SMART_MODE = ("smart_mode", 306) CUSTOMIZED = ("custom", 302) class VacuumModesOld(RoborockModeEnum): QUIET = ("quiet", 38) BALANCED = ("balanced", 60) TURBO = ("turbo", 75) MAX = ("max", 100) class WaterModes(RoborockModeEnum): OFF = ("off", 200) LOW = ("low", 201) MILD = ("mild", 201) MEDIUM = ("medium", 202) STANDARD = ("standard", 202) HIGH = ("high", 203) INTENSE = ("intense", 203) CUSTOMIZED = ("custom", 204) CUSTOM = ("custom_water_flow", 207) EXTREME = ("extreme", 208) SMART_MODE = ("smart_mode", 209) PURE_WATER_FLOW_START = ("slight", 221) PURE_WATER_FLOW_SMALL = ("low", 225) PURE_WATER_FLOW_MIDDLE = ("medium", 235) PURE_WATER_FLOW_LARGE = ("moderate", 245) PURE_WATER_SUPER_BEGIN = ("high", 248) PURE_WATER_FLOW_END = ("extreme", 250) class WashTowelModes(RoborockModeEnum): SMART = ("smart", 10) LIGHT = ("light", 0) BALANCED = ("balanced", 1) DEEP = ("deep", 2) SUPER_DEEP = ("super_deep", 8) def get_wash_towel_modes(features: DeviceFeatures) -> list[WashTowelModes]: """Get the valid wash towel modes for the device""" modes = [WashTowelModes.LIGHT, WashTowelModes.BALANCED, WashTowelModes.DEEP] if features.is_super_deep_wash_supported and not features.is_dirty_replenish_clean_supported: modes.append(WashTowelModes.SUPER_DEEP) elif features.is_dirty_replenish_clean_supported: modes.append(WashTowelModes.SMART) return modes def get_clean_modes(features: DeviceFeatures) -> list[VacuumModes]: """Get the valid clean modes for the device - also known as 'fan power' or 'suction mode'""" modes = [VacuumModes.QUIET, VacuumModes.BALANCED, VacuumModes.TURBO, VacuumModes.MAX] if features.is_max_plus_mode_supported or features.is_none_pure_clean_mop_with_max_plus: # If the vacuum has max plus mode supported modes.append(VacuumModes.MAX_PLUS) if features.is_pure_clean_mop_supported: # If the vacuum is capable of 'pure mop clean' aka no vacuum modes.append(VacuumModes.OFF) else: # If not, we can add gentle modes.append(VacuumModes.GENTLE) if features.is_smart_clean_mode_set_supported: modes.append(VacuumModes.SMART_MODE) if features.is_customized_clean_supported: modes.append(VacuumModes.CUSTOMIZED) return modes def get_clean_routes(features: DeviceFeatures, region: str) -> list[CleanRoutes]: """The routes that the vacuum will take while mopping""" if features.is_none_pure_clean_mop_with_max_plus: return [CleanRoutes.FAST, CleanRoutes.STANDARD] supported = [CleanRoutes.STANDARD, CleanRoutes.DEEP] if features.is_careful_slow_mop_supported: if not ( features.is_corner_clean_mode_supported and features.is_clean_route_deep_slow_plus_supported and region == "cn" ): # for some reason there is a china specific deep plus mode supported.append(CleanRoutes.DEEP_PLUS_CN) else: supported.append(CleanRoutes.DEEP_PLUS) if features.is_clean_route_fast_mode_supported: supported.append(CleanRoutes.FAST) if features.is_smart_clean_mode_set_supported: supported.append(CleanRoutes.SMART_MODE) if features.is_customized_clean_supported: supported.append(CleanRoutes.CUSTOMIZED) return supported def get_water_modes(features: DeviceFeatures) -> list[WaterModes]: """Get the valid water modes for the device - also known as 'water flow' or 'water level'""" # If the device supports water slide mode, it uses a completely different set of modes. Technically, it can even # support values in between. But for now we will just support the main values. if features.is_water_slide_mode_supported: return [ WaterModes.PURE_WATER_FLOW_START, WaterModes.PURE_WATER_FLOW_SMALL, WaterModes.PURE_WATER_FLOW_MIDDLE, WaterModes.PURE_WATER_FLOW_LARGE, WaterModes.PURE_WATER_SUPER_BEGIN, WaterModes.PURE_WATER_FLOW_END, ] supported_modes = [WaterModes.OFF] if features.is_mop_shake_module_supported: # For mops that have the vibrating mop pad, they do mild standard intense supported_modes.extend([WaterModes.MILD, WaterModes.STANDARD, WaterModes.INTENSE]) else: supported_modes.extend([WaterModes.LOW, WaterModes.MEDIUM, WaterModes.HIGH]) if features.is_custom_water_box_distance_supported: # This is for devices that allow you to set a custom water flow from 0-100 supported_modes.append(WaterModes.CUSTOM) if features.is_mop_shake_module_supported and features.is_mop_shake_water_max_supported: supported_modes.append(WaterModes.EXTREME) if features.is_smart_clean_mode_set_supported: supported_modes.append(WaterModes.SMART_MODE) if features.is_customized_clean_supported: supported_modes.append(WaterModes.CUSTOMIZED) return supported_modes def is_mode_customized(clean_mode: VacuumModes, water_mode: WaterModes, mop_mode: CleanRoutes) -> bool: """Check if any of the cleaning modes are set to a custom value.""" return ( clean_mode == VacuumModes.CUSTOMIZED or water_mode == WaterModes.CUSTOMIZED or mop_mode == CleanRoutes.CUSTOMIZED ) def is_smart_mode_set(water_mode: WaterModes, clean_mode: VacuumModes, mop_mode: CleanRoutes) -> bool: """Check if the smart mode is set for the given water mode and clean mode""" return ( water_mode == WaterModes.SMART_MODE or clean_mode == VacuumModes.SMART_MODE or mop_mode == CleanRoutes.SMART_MODE ) Python-roborock-python-roborock-d6da2db/roborock/data/v1/v1_code_mappings.py000066400000000000000000000341061513363643200273770ustar00rootroot00000000000000from ..code_mappings import RoborockEnum class RoborockFinishReason(RoborockEnum): manual_interrupt = 21 # Cleaning interrupted by user cleanup_interrupted = 24 # Cleanup interrupted manual_interrupt_2 = 21 manual_interrupt_12 = 29 breakpoint = 32 # Could not continue cleaning breakpoint_2 = 33 cleanup_interrupted_2 = 34 manual_interrupt_3 = 35 manual_interrupt_4 = 36 manual_interrupt_5 = 37 manual_interrupt_6 = 43 locate_fail = 45 # Positioning Failed cleanup_interrupted_3 = 64 locate_fail_2 = 65 manual_interrupt_7 = 48 manual_interrupt_8 = 49 manual_interrupt_9 = 50 cleanup_interrupted_4 = 51 finished_cleaning = 52 # Finished cleaning finished_cleaning_2 = 54 finished_cleaning_3 = 55 finished_cleaning_4 = 56 finished_clenaing_5 = 57 manual_interrupt_10 = 60 area_unreachable = 61 # Area unreachable area_unreachable_2 = 62 washing_error = 67 # Washing error back_to_wash_failure = 68 # Failed to return to the dock cleanup_interrupted_5 = 101 breakpoint_4 = 102 manual_interrupt_11 = 103 cleanup_interrupted_6 = 104 cleanup_interrupted_7 = 105 cleanup_interrupted_8 = 106 cleanup_interrupted_9 = 107 cleanup_interrupted_10 = 109 cleanup_interrupted_11 = 110 patrol_success = 114 # Cruise completed patrol_fail = 115 # Cruise failed pet_patrol_success = 116 # Pet found pet_patrol_fail = 117 # Pet found failed class RoborockInCleaning(RoborockEnum): complete = 0 global_clean_not_complete = 1 zone_clean_not_complete = 2 segment_clean_not_complete = 3 class RoborockCleanType(RoborockEnum): all_zone = 1 draw_zone = 2 select_zone = 3 quick_build = 4 video_patrol = 5 pet_patrol = 6 class RoborockStartType(RoborockEnum): button = 1 app = 2 schedule = 3 mi_home = 4 quick_start = 5 voice_control = 13 routines = 101 alexa = 801 google = 802 ifttt = 803 yandex = 804 homekit = 805 xiaoai = 806 tmall_genie = 807 duer = 808 dingdong = 809 siri = 810 clova = 811 wechat = 901 alipay = 902 aqara = 903 hisense = 904 huawei = 905 widget_launch = 820 smart_watch = 821 class RoborockDssCodes(RoborockEnum): @classmethod def _missing_(cls: type[RoborockEnum], key) -> RoborockEnum: # If the calculated value is not provided, then it should be viewed as okay. # As the math will sometimes result in you getting numbers that don't matter. return cls.okay # type: ignore class ClearWaterBoxStatus(RoborockDssCodes): """Status of the clear water box.""" okay = 0 out_of_water = 1 out_of_water_2 = 38 refill_error = 48 class DirtyWaterBoxStatus(RoborockDssCodes): """Status of the dirty water box.""" okay = 0 full_not_installed = 1 full_not_installed_2 = 39 drain_error = 49 class DustBagStatus(RoborockDssCodes): """Status of the dust bag.""" okay = 0 not_installed = 1 full = 34 class CleanFluidStatus(RoborockDssCodes): """Status of the cleaning fluid container.""" empty_not_installed = 1 okay = 2 class RoborockErrorCode(RoborockEnum): none = 0 lidar_blocked = 1 bumper_stuck = 2 wheels_suspended = 3 cliff_sensor_error = 4 main_brush_jammed = 5 side_brush_jammed = 6 wheels_jammed = 7 robot_trapped = 8 no_dustbin = 9 strainer_error = 10 # Filter is wet or blocked compass_error = 11 # Strong magnetic field detected low_battery = 12 charging_error = 13 battery_error = 14 wall_sensor_dirty = 15 robot_tilted = 16 side_brush_error = 17 fan_error = 18 dock = 19 # Dock not connected to power optical_flow_sensor_dirt = 20 vertical_bumper_pressed = 21 dock_locator_error = 22 return_to_dock_fail = 23 nogo_zone_detected = 24 visual_sensor = 25 # Camera error light_touch = 26 # Wall sensor error vibrarise_jammed = 27 robot_on_carpet = 28 filter_blocked = 29 invisible_wall_detected = 30 cannot_cross_carpet = 31 internal_error = 32 collect_dust_error_3 = 34 # Clean auto-empty dock collect_dust_error_4 = 35 # Auto empty dock voltage error mopping_roller_1 = 36 # Wash roller may be jammed mopping_roller_error_2 = 37 # wash roller not lowered properly clear_water_box_hoare = 38 # Check the clean water tank dirty_water_box_hoare = 39 # Check the dirty water tank sink_strainer_hoare = 40 # Reinstall the water filter clear_water_box_exception = 41 # Clean water tank empty clear_brush_exception = 42 # Check that the water filter has been correctly installed clear_brush_exception_2 = 43 # Positioning button error filter_screen_exception = 44 # Clean the dock water filter mopping_roller_2 = 45 # Wash roller may be jammed up_water_exception = 48 drain_water_exception = 49 temperature_protection = 51 # Unit temperature protection clean_carousel_exception = 52 clean_carousel_water_full = 53 water_carriage_drop = 54 check_clean_carouse = 55 audio_error = 56 class RoborockFanPowerCode(RoborockEnum): """Describes the fan power of the vacuum cleaner.""" # Fan speeds should have the first letter capitalized - as there is no way to change the name in translations as # far as I am aware class RoborockFanSpeedV1(RoborockFanPowerCode): silent = 38 standard = 60 medium = 77 turbo = 90 class RoborockFanSpeedV2(RoborockFanPowerCode): silent = 101 balanced = 102 turbo = 103 max = 104 gentle = 105 auto = 106 class RoborockFanSpeedV3(RoborockFanPowerCode): silent = 38 standard = 60 medium = 75 turbo = 100 class RoborockFanSpeedE2(RoborockFanPowerCode): gentle = 41 silent = 50 standard = 68 medium = 79 turbo = 100 class RoborockFanSpeedS7(RoborockFanPowerCode): off = 105 quiet = 101 balanced = 102 turbo = 103 max = 104 custom = 106 class RoborockFanSpeedS7MaxV(RoborockFanPowerCode): off = 105 quiet = 101 balanced = 102 turbo = 103 max = 104 custom = 106 max_plus = 108 class RoborockFanSpeedS6Pure(RoborockFanPowerCode): gentle = 105 quiet = 101 balanced = 102 turbo = 103 max = 104 custom = 106 class RoborockFanSpeedQ7Max(RoborockFanPowerCode): quiet = 101 balanced = 102 turbo = 103 max = 104 class RoborockFanSpeedQRevoMaster(RoborockFanPowerCode): off = 105 quiet = 101 balanced = 102 turbo = 103 max = 104 custom = 106 max_plus = 108 smart_mode = 110 class RoborockFanSpeedQRevoCurv(RoborockFanPowerCode): quiet = 101 balanced = 102 turbo = 103 max = 104 off = 105 custom = 106 max_plus = 108 smart_mode = 110 class RoborockFanSpeedQRevoMaxV(RoborockFanPowerCode): off = 105 quiet = 101 balanced = 102 turbo = 103 max = 104 custom = 106 max_plus = 108 smart_mode = 110 class RoborockFanSpeedP10(RoborockFanPowerCode): off = 105 quiet = 101 balanced = 102 turbo = 103 max = 104 custom = 106 max_plus = 108 smart_mode = 110 class RoborockFanSpeedS8MaxVUltra(RoborockFanPowerCode): off = 105 quiet = 101 balanced = 102 turbo = 103 max = 104 custom = 106 max_plus = 108 smart_mode = 110 class RoborockFanSpeedSaros10(RoborockFanPowerCode): off = 105 quiet = 101 balanced = 102 turbo = 103 max = 104 custom = 106 max_plus = 108 smart_mode = 110 class RoborockFanSpeedSaros10R(RoborockFanPowerCode): off = 105 quiet = 101 balanced = 102 turbo = 103 max = 104 custom = 106 max_plus = 108 smart_mode = 110 class RoborockMopModeCode(RoborockEnum): """Describes the mop mode of the vacuum cleaner.""" class RoborockMopModeQRevoCurv(RoborockMopModeCode): standard = 300 deep = 301 custom = 302 deep_plus = 303 fast = 304 smart_mode = 306 class RoborockMopModeS7(RoborockMopModeCode): """Describes the mop mode of the vacuum cleaner.""" standard = 300 deep = 301 custom = 302 deep_plus = 303 class RoborockMopModeS8ProUltra(RoborockMopModeCode): standard = 300 deep = 301 deep_plus = 303 fast = 304 custom = 302 smart_mode = 306 class RoborockMopModeS8MaxVUltra(RoborockMopModeCode): standard = 300 deep = 301 custom = 302 deep_plus = 303 fast = 304 deep_plus_pearl = 305 smart_mode = 306 class RoborockMopModeSaros10R(RoborockMopModeCode): standard = 300 deep = 301 custom = 302 deep_plus = 303 fast = 304 smart_mode = 306 class RoborockMopModeQRevoMaster(RoborockMopModeCode): standard = 300 deep = 301 custom = 302 deep_plus = 303 fast = 304 smart_mode = 306 class RoborockMopModeQRevoMaxV(RoborockMopModeCode): standard = 300 deep = 301 custom = 302 deep_plus = 303 fast = 304 smart_mode = 306 class RoborockMopModeSaros10(RoborockMopModeCode): standard = 300 deep = 301 custom = 302 deep_plus = 303 fast = 304 smart_mode = 306 class RoborockMopIntensityCode(RoborockEnum): """Describes the mop intensity of the vacuum cleaner.""" class RoborockMopIntensityS7(RoborockMopIntensityCode): """Describes the mop intensity of the vacuum cleaner.""" off = 200 mild = 201 moderate = 202 intense = 203 custom = 204 class RoborockMopIntensityV2(RoborockMopIntensityCode): """Describes the mop intensity of the vacuum cleaner.""" off = 200 low = 201 medium = 202 high = 203 custom = 207 class RoborockMopIntensityQRevoMaster(RoborockMopIntensityCode): """Describes the mop intensity of the vacuum cleaner.""" off = 200 low = 201 medium = 202 high = 203 custom = 204 custom_water_flow = 207 smart_mode = 209 class RoborockMopIntensityQRevoCurv(RoborockMopIntensityCode): off = 200 low = 201 medium = 202 high = 203 custom = 204 custom_water_flow = 207 smart_mode = 209 class RoborockMopIntensityQRevoMaxV(RoborockMopIntensityCode): off = 200 low = 201 medium = 202 high = 203 custom = 204 custom_water_flow = 207 smart_mode = 209 class RoborockMopIntensityP10(RoborockMopIntensityCode): """Describes the mop intensity of the vacuum cleaner.""" off = 200 low = 201 medium = 202 high = 203 custom = 204 custom_water_flow = 207 smart_mode = 209 class RoborockMopIntensityS8MaxVUltra(RoborockMopIntensityCode): off = 200 low = 201 medium = 202 high = 203 custom = 204 max = 208 smart_mode = 209 custom_water_flow = 207 class RoborockMopIntensitySaros10(RoborockMopIntensityCode): off = 200 mild = 201 standard = 202 intense = 203 extreme = 208 custom = 204 smart_mode = 209 class RoborockMopIntensitySaros10R(RoborockMopIntensityCode): off = 200 low = 201 medium = 202 high = 203 custom = 204 extreme = 250 vac_followed_by_mop = 235 smart_mode = 209 class RoborockMopIntensityS5Max(RoborockMopIntensityCode): """Describes the mop intensity of the vacuum cleaner.""" off = 200 low = 201 medium = 202 high = 203 custom = 204 custom_water_flow = 207 class RoborockMopIntensityS6MaxV(RoborockMopIntensityCode): """Describes the mop intensity of the vacuum cleaner.""" off = 200 low = 201 medium = 202 high = 203 custom = 204 custom_water_flow = 207 class RoborockMopIntensityQ7Max(RoborockMopIntensityCode): """Describes the mop intensity of the vacuum cleaner.""" off = 200 low = 201 medium = 202 high = 203 custom_water_flow = 207 class RoborockDockErrorCode(RoborockEnum): """Describes the error code of the dock.""" ok = 0 duct_blockage = 34 water_empty = 38 waste_water_tank_full = 39 maintenance_brush_jammed = 42 dirty_tank_latch_open = 44 no_dustbin = 46 cleaning_tank_full_or_blocked = 53 class RoborockDockTypeCode(RoborockEnum): unknown = -9999 no_dock = 0 auto_empty_dock = 1 empty_wash_fill_dock = 3 auto_empty_dock_pure = 5 s7_max_ultra_dock = 6 s8_dock = 7 p10_dock = 8 p10_pro_dock = 9 s8_maxv_ultra_dock = 10 qrevo_master_dock = 14 qrevo_s_dock = 15 saros_r10_dock = 16 qrevo_curv_dock = 17 saros_10_dock = 18 class RoborockDockDustCollectionModeCode(RoborockEnum): """Describes the dust collection mode of the vacuum cleaner.""" # TODO: Get the correct values for various different docks unknown = -9999 smart = 0 light = 1 balanced = 2 max = 4 class RoborockDockWashTowelModeCode(RoborockEnum): """Describes the wash towel mode of the vacuum cleaner.""" # TODO: Get the correct values for various different docks unknown = -9999 light = 0 balanced = 1 deep = 2 smart = 10 class RoborockStateCode(RoborockEnum): unknown = 0 starting = 1 charger_disconnected = 2 idle = 3 remote_control_active = 4 cleaning = 5 returning_home = 6 manual_mode = 7 charging = 8 charging_problem = 9 paused = 10 spot_cleaning = 11 error = 12 shutting_down = 13 updating = 14 docking = 15 going_to_target = 16 zoned_cleaning = 17 segment_cleaning = 18 emptying_the_bin = 22 # on s7+ washing_the_mop = 23 # on a46 washing_the_mop_2 = 25 going_to_wash_the_mop = 26 # on a46 in_call = 28 mapping = 29 egg_attack = 30 patrol = 32 attaching_the_mop = 33 # on g20s ultra detaching_the_mop = 34 # on g20s ultra charging_complete = 100 device_offline = 101 locked = 103 air_drying_stopping = 202 robot_status_mopping = 6301 clean_mop_cleaning = 6302 clean_mop_mopping = 6303 segment_mopping = 6304 segment_clean_mop_cleaning = 6305 segment_clean_mop_mopping = 6306 zoned_mopping = 6307 zoned_clean_mop_cleaning = 6308 zoned_clean_mop_mopping = 6309 back_to_dock_washing_duster = 6310 Python-roborock-python-roborock-d6da2db/roborock/data/v1/v1_containers.py000066400000000000000000000466251513363643200267450ustar00rootroot00000000000000import datetime import logging from dataclasses import dataclass, field from enum import StrEnum from typing import Any from roborock.const import ( CLEANING_BRUSH_REPLACE_TIME, DUST_COLLECTION_REPLACE_TIME, FILTER_REPLACE_TIME, MAIN_BRUSH_REPLACE_TIME, MOP_ROLLER_REPLACE_TIME, NO_MAP, ROBOROCK_G10S_PRO, ROBOROCK_P10, ROBOROCK_Q7_MAX, ROBOROCK_QREVO_CURV, ROBOROCK_QREVO_MASTER, ROBOROCK_QREVO_MAXV, ROBOROCK_QREVO_PRO, ROBOROCK_QREVO_S, ROBOROCK_S4_MAX, ROBOROCK_S5_MAX, ROBOROCK_S6, ROBOROCK_S6_MAXV, ROBOROCK_S6_PURE, ROBOROCK_S7, ROBOROCK_S7_MAXV, ROBOROCK_S8, ROBOROCK_S8_MAXV_ULTRA, ROBOROCK_S8_PRO_ULTRA, ROBOROCK_SAROS_10, ROBOROCK_SAROS_10R, SENSOR_DIRTY_REPLACE_TIME, SIDE_BRUSH_REPLACE_TIME, STRAINER_REPLACE_TIME, ROBOROCK_G20S_Ultra, ) from roborock.exceptions import RoborockException from ..containers import RoborockBase, RoborockBaseTimer, _attr_repr from .v1_code_mappings import ( CleanFluidStatus, ClearWaterBoxStatus, DirtyWaterBoxStatus, DustBagStatus, RoborockCleanType, RoborockDockDustCollectionModeCode, RoborockDockErrorCode, RoborockDockTypeCode, RoborockDockWashTowelModeCode, RoborockErrorCode, RoborockFanPowerCode, RoborockFanSpeedP10, RoborockFanSpeedQ7Max, RoborockFanSpeedQRevoCurv, RoborockFanSpeedQRevoMaster, RoborockFanSpeedQRevoMaxV, RoborockFanSpeedS6Pure, RoborockFanSpeedS7, RoborockFanSpeedS7MaxV, RoborockFanSpeedS8MaxVUltra, RoborockFanSpeedSaros10, RoborockFanSpeedSaros10R, RoborockFinishReason, RoborockInCleaning, RoborockMopIntensityCode, RoborockMopIntensityP10, RoborockMopIntensityQ7Max, RoborockMopIntensityQRevoCurv, RoborockMopIntensityQRevoMaster, RoborockMopIntensityQRevoMaxV, RoborockMopIntensityS5Max, RoborockMopIntensityS6MaxV, RoborockMopIntensityS7, RoborockMopIntensityS8MaxVUltra, RoborockMopIntensitySaros10, RoborockMopIntensitySaros10R, RoborockMopModeCode, RoborockMopModeQRevoCurv, RoborockMopModeQRevoMaster, RoborockMopModeQRevoMaxV, RoborockMopModeS7, RoborockMopModeS8MaxVUltra, RoborockMopModeS8ProUltra, RoborockMopModeSaros10, RoborockMopModeSaros10R, RoborockStartType, RoborockStateCode, ) _LOGGER = logging.getLogger(__name__) class FieldNameBase(StrEnum): """A base enum class that represents a field name in a RoborockBase dataclass.""" class StatusField(FieldNameBase): """An enum that represents a field in the `Status` class. This is used with `roborock.devices.traits.v1.status.DeviceFeaturesTrait` to understand if a feature is supported by the device using `is_field_supported`. The enum values are names of fields in the `Status` class. Each field is annotated with `requires_schema_code` metadata to map the field to a schema code in the product schema, which may have a different name than the field/attribute name. """ STATE = "state" BATTERY = "battery" FAN_POWER = "fan_power" WATER_BOX_MODE = "water_box_mode" CHARGE_STATUS = "charge_status" DRY_STATUS = "dry_status" def _requires_schema_code(requires_schema_code: str, default=None) -> Any: return field(metadata={"requires_schema_code": requires_schema_code}, default=default) @dataclass class Status(RoborockBase): msg_ver: int | None = None msg_seq: int | None = None state: RoborockStateCode | None = _requires_schema_code("state", default=None) battery: int | None = _requires_schema_code("battery", default=None) clean_time: int | None = None clean_area: int | None = None error_code: RoborockErrorCode | None = None map_present: int | None = None in_cleaning: RoborockInCleaning | None = None in_returning: int | None = None in_fresh_state: int | None = None lab_status: int | None = None water_box_status: int | None = None back_type: int | None = None wash_phase: int | None = None wash_ready: int | None = None fan_power: RoborockFanPowerCode | None = _requires_schema_code("fan_power", default=None) dnd_enabled: int | None = None map_status: int | None = None is_locating: int | None = None lock_status: int | None = None water_box_mode: RoborockMopIntensityCode | None = _requires_schema_code("water_box_mode", default=None) water_box_carriage_status: int | None = None mop_forbidden_enable: int | None = None camera_status: int | None = None is_exploring: int | None = None home_sec_status: int | None = None home_sec_enable_password: int | None = None adbumper_status: list[int] | None = None water_shortage_status: int | None = None dock_type: RoborockDockTypeCode | None = None dust_collection_status: int | None = None auto_dust_collection: int | None = None avoid_count: int | None = None mop_mode: RoborockMopModeCode | None = None debug_mode: int | None = None collision_avoid_status: int | None = None switch_map_mode: int | None = None dock_error_status: RoborockDockErrorCode | None = None charge_status: int | None = _requires_schema_code("charge_status", default=None) unsave_map_reason: int | None = None unsave_map_flag: int | None = None wash_status: int | None = None distance_off: int | None = None in_warmup: int | None = None dry_status: int | None = _requires_schema_code("drying_status", default=None) rdt: int | None = None clean_percent: int | None = None rss: int | None = None dss: int | None = None common_status: int | None = None corner_clean_mode: int | None = None last_clean_t: int | None = None replenish_mode: int | None = None repeat: int | None = None kct: int | None = None subdivision_sets: int | None = None @property def square_meter_clean_area(self) -> float | None: return round(self.clean_area / 1000000, 1) if self.clean_area is not None else None @property def error_code_name(self) -> str | None: return self.error_code.name if self.error_code is not None else None @property def state_name(self) -> str | None: return self.state.name if self.state is not None else None @property def water_box_mode_name(self) -> str | None: return self.water_box_mode.name if self.water_box_mode is not None else None @property def fan_power_options(self) -> list[str]: if self.fan_power is None: return [] return list(self.fan_power.keys()) @property def fan_power_name(self) -> str | None: return self.fan_power.name if self.fan_power is not None else None @property def mop_mode_name(self) -> str | None: return self.mop_mode.name if self.mop_mode is not None else None def get_fan_speed_code(self, fan_speed: str) -> int: if self.fan_power is None: raise RoborockException("Attempted to get fan speed before status has been updated.") return self.fan_power.as_dict().get(fan_speed) def get_mop_intensity_code(self, mop_intensity: str) -> int: if self.water_box_mode is None: raise RoborockException("Attempted to get mop_intensity before status has been updated.") return self.water_box_mode.as_dict().get(mop_intensity) def get_mop_mode_code(self, mop_mode: str) -> int: if self.mop_mode is None: raise RoborockException("Attempted to get mop_mode before status has been updated.") return self.mop_mode.as_dict().get(mop_mode) @property def current_map(self) -> int | None: """Returns the current map ID if the map is present.""" if self.map_status is not None: map_flag = self.map_status >> 2 if map_flag != NO_MAP: return map_flag return None @property def clear_water_box_status(self) -> ClearWaterBoxStatus | None: if self.dss: return ClearWaterBoxStatus((self.dss >> 2) & 3) return None @property def dirty_water_box_status(self) -> DirtyWaterBoxStatus | None: if self.dss: return DirtyWaterBoxStatus((self.dss >> 4) & 3) return None @property def dust_bag_status(self) -> DustBagStatus | None: if self.dss: return DustBagStatus((self.dss >> 6) & 3) return None @property def water_box_filter_status(self) -> int | None: if self.dss: return (self.dss >> 8) & 3 return None @property def clean_fluid_status(self) -> CleanFluidStatus | None: if self.dss: value = (self.dss >> 10) & 3 if value == 0: return None # Feature not supported by this device return CleanFluidStatus(value) return None @property def hatch_door_status(self) -> int | None: if self.dss: return (self.dss >> 12) & 7 return None @property def dock_cool_fan_status(self) -> int | None: if self.dss: return (self.dss >> 15) & 3 return None def __repr__(self) -> str: return _attr_repr(self) @dataclass class S4MaxStatus(Status): fan_power: RoborockFanSpeedS6Pure | None = None water_box_mode: RoborockMopIntensityS7 | None = None mop_mode: RoborockMopModeS7 | None = None @dataclass class S5MaxStatus(Status): fan_power: RoborockFanSpeedS6Pure | None = None water_box_mode: RoborockMopIntensityS5Max | None = None @dataclass class Q7MaxStatus(Status): fan_power: RoborockFanSpeedQ7Max | None = None water_box_mode: RoborockMopIntensityQ7Max | None = None @dataclass class QRevoMasterStatus(Status): fan_power: RoborockFanSpeedQRevoMaster | None = None water_box_mode: RoborockMopIntensityQRevoMaster | None = None mop_mode: RoborockMopModeQRevoMaster | None = None @dataclass class QRevoCurvStatus(Status): fan_power: RoborockFanSpeedQRevoCurv | None = None water_box_mode: RoborockMopIntensityQRevoCurv | None = None mop_mode: RoborockMopModeQRevoCurv | None = None @dataclass class QRevoMaxVStatus(Status): fan_power: RoborockFanSpeedQRevoMaxV | None = None water_box_mode: RoborockMopIntensityQRevoMaxV | None = None mop_mode: RoborockMopModeQRevoMaxV | None = None @dataclass class S6MaxVStatus(Status): fan_power: RoborockFanSpeedS7MaxV | None = None water_box_mode: RoborockMopIntensityS6MaxV | None = None @dataclass class S6PureStatus(Status): fan_power: RoborockFanSpeedS6Pure | None = None @dataclass class S7MaxVStatus(Status): fan_power: RoborockFanSpeedS7MaxV | None = None water_box_mode: RoborockMopIntensityS7 | None = None mop_mode: RoborockMopModeS7 | None = None @dataclass class S7Status(Status): fan_power: RoborockFanSpeedS7 | None = None water_box_mode: RoborockMopIntensityS7 | None = None mop_mode: RoborockMopModeS7 | None = None @dataclass class S8ProUltraStatus(Status): fan_power: RoborockFanSpeedS7MaxV | None = None water_box_mode: RoborockMopIntensityS7 | None = None mop_mode: RoborockMopModeS8ProUltra | None = None @dataclass class S8Status(Status): fan_power: RoborockFanSpeedS7MaxV | None = None water_box_mode: RoborockMopIntensityS7 | None = None mop_mode: RoborockMopModeS8ProUltra | None = None @dataclass class P10Status(Status): fan_power: RoborockFanSpeedP10 | None = None water_box_mode: RoborockMopIntensityP10 | None = None mop_mode: RoborockMopModeS8ProUltra | None = None @dataclass class S8MaxvUltraStatus(Status): fan_power: RoborockFanSpeedS8MaxVUltra | None = None water_box_mode: RoborockMopIntensityS8MaxVUltra | None = None mop_mode: RoborockMopModeS8MaxVUltra | None = None @dataclass class Saros10RStatus(Status): fan_power: RoborockFanSpeedSaros10R | None = None water_box_mode: RoborockMopIntensitySaros10R | None = None mop_mode: RoborockMopModeSaros10R | None = None @dataclass class Saros10Status(Status): fan_power: RoborockFanSpeedSaros10 | None = None water_box_mode: RoborockMopIntensitySaros10 | None = None mop_mode: RoborockMopModeSaros10 | None = None ModelStatus: dict[str, type[Status]] = { ROBOROCK_S4_MAX: S4MaxStatus, ROBOROCK_S5_MAX: S5MaxStatus, ROBOROCK_Q7_MAX: Q7MaxStatus, ROBOROCK_QREVO_MASTER: QRevoMasterStatus, ROBOROCK_QREVO_CURV: QRevoCurvStatus, ROBOROCK_S6: S6PureStatus, ROBOROCK_S6_MAXV: S6MaxVStatus, ROBOROCK_S6_PURE: S6PureStatus, ROBOROCK_S7_MAXV: S7MaxVStatus, ROBOROCK_S7: S7Status, ROBOROCK_S8: S8Status, ROBOROCK_S8_PRO_ULTRA: S8ProUltraStatus, ROBOROCK_G10S_PRO: S7MaxVStatus, ROBOROCK_G20S_Ultra: QRevoMasterStatus, ROBOROCK_P10: P10Status, # These likely are not correct, # but i am currently unable to do my typical reverse engineering/ get any data from users on this, # so this will be here in the mean time. ROBOROCK_QREVO_S: P10Status, ROBOROCK_QREVO_MAXV: QRevoMaxVStatus, ROBOROCK_QREVO_PRO: P10Status, ROBOROCK_S8_MAXV_ULTRA: S8MaxvUltraStatus, ROBOROCK_SAROS_10R: Saros10RStatus, ROBOROCK_SAROS_10: Saros10Status, } @dataclass class DnDTimer(RoborockBaseTimer): """DnDTimer""" @dataclass class ValleyElectricityTimer(RoborockBaseTimer): """ValleyElectricityTimer""" @dataclass class CleanSummary(RoborockBase): clean_time: int | None = None clean_area: int | None = None clean_count: int | None = None dust_collection_count: int | None = None records: list[int] | None = None last_clean_t: int | None = None @property def square_meter_clean_area(self) -> float | None: """Returns the clean area in square meters.""" if isinstance(self.clean_area, list | str): _LOGGER.warning(f"Clean area is a unexpected type! Please give the following in a issue: {self.clean_area}") return None return round(self.clean_area / 1000000, 1) if self.clean_area is not None else None def __repr__(self) -> str: """Return a string representation of the object including all attributes.""" return _attr_repr(self) @dataclass class CleanRecord(RoborockBase): begin: int | None = None end: int | None = None duration: int | None = None area: int | None = None error: int | None = None complete: int | None = None start_type: RoborockStartType | None = None clean_type: RoborockCleanType | None = None finish_reason: RoborockFinishReason | None = None dust_collection_status: int | None = None avoid_count: int | None = None wash_count: int | None = None map_flag: int | None = None @property def square_meter_area(self) -> float | None: return round(self.area / 1000000, 1) if self.area is not None else None @property def begin_datetime(self) -> datetime.datetime | None: return datetime.datetime.fromtimestamp(self.begin).astimezone(datetime.UTC) if self.begin else None @property def end_datetime(self) -> datetime.datetime | None: return datetime.datetime.fromtimestamp(self.end).astimezone(datetime.UTC) if self.end else None def __repr__(self) -> str: return _attr_repr(self) class CleanSummaryWithDetail(CleanSummary): """CleanSummary with the last CleanRecord included.""" last_clean_record: CleanRecord | None = None @dataclass class Consumable(RoborockBase): main_brush_work_time: int | None = None side_brush_work_time: int | None = None filter_work_time: int | None = None filter_element_work_time: int | None = None sensor_dirty_time: int | None = None strainer_work_times: int | None = None dust_collection_work_times: int | None = None cleaning_brush_work_times: int | None = None moproller_work_time: int | None = None @property def main_brush_time_left(self) -> int | None: return MAIN_BRUSH_REPLACE_TIME - self.main_brush_work_time if self.main_brush_work_time is not None else None @property def side_brush_time_left(self) -> int | None: return SIDE_BRUSH_REPLACE_TIME - self.side_brush_work_time if self.side_brush_work_time is not None else None @property def filter_time_left(self) -> int | None: return FILTER_REPLACE_TIME - self.filter_work_time if self.filter_work_time is not None else None @property def sensor_time_left(self) -> int | None: return SENSOR_DIRTY_REPLACE_TIME - self.sensor_dirty_time if self.sensor_dirty_time is not None else None @property def strainer_time_left(self) -> int | None: return STRAINER_REPLACE_TIME - self.strainer_work_times if self.strainer_work_times is not None else None @property def dust_collection_time_left(self) -> int | None: return ( DUST_COLLECTION_REPLACE_TIME - self.dust_collection_work_times if self.dust_collection_work_times is not None else None ) @property def cleaning_brush_time_left(self) -> int | None: return ( CLEANING_BRUSH_REPLACE_TIME - self.cleaning_brush_work_times if self.cleaning_brush_work_times is not None else None ) @property def mop_roller_time_left(self) -> int | None: return MOP_ROLLER_REPLACE_TIME - self.moproller_work_time if self.moproller_work_time is not None else None def __repr__(self) -> str: return _attr_repr(self) @dataclass class MultiMapsListRoom(RoborockBase): id: int | None = None tag: int | None = None iot_name_id: str | None = None iot_name: str | None = None @dataclass class MultiMapsListMapInfoBakMaps(RoborockBase): mapflag: Any | None = None add_time: Any | None = None @dataclass class MultiMapsListMapInfo(RoborockBase): map_flag: int name: str add_time: Any | None = None length: Any | None = None bak_maps: list[MultiMapsListMapInfoBakMaps] | None = None rooms: list[MultiMapsListRoom] | None = None @property def mapFlag(self) -> int: """Alias for map_flag, returns the map flag as an integer.""" return self.map_flag @dataclass class MultiMapsList(RoborockBase): max_multi_map: int | None = None max_bak_map: int | None = None multi_map_count: int | None = None map_info: list[MultiMapsListMapInfo] | None = None @dataclass class SmartWashParams(RoborockBase): smart_wash: int | None = None wash_interval: int | None = None @dataclass class DustCollectionMode(RoborockBase): mode: RoborockDockDustCollectionModeCode | None = None @dataclass class WashTowelMode(RoborockBase): wash_mode: RoborockDockWashTowelModeCode | None = None @dataclass class NetworkInfo(RoborockBase): ip: str ssid: str | None = None mac: str | None = None bssid: str | None = None rssi: int | None = None @dataclass class AppInitStatusLocalInfo(RoborockBase): location: str bom: str | None = None featureset: int | None = None language: str | None = None logserver: str | None = None wifiplan: str | None = None timezone: str | None = None name: str | None = None @dataclass class AppInitStatus(RoborockBase): local_info: AppInitStatusLocalInfo feature_info: list[int] new_feature_info: int new_feature_info_str: str = "" new_feature_info_2: int | None = None carriage_type: int | None = None dsp_version: str | None = None @dataclass class ChildLockStatus(RoborockBase): lock_status: int = 0 @dataclass class FlowLedStatus(RoborockBase): status: int = 0 @dataclass class LedStatus(RoborockBase): status: int = 0 Python-roborock-python-roborock-d6da2db/roborock/data/zeo/000077500000000000000000000000001513363643200240525ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/roborock/data/zeo/__init__.py000066400000000000000000000000771513363643200261670ustar00rootroot00000000000000from .zeo_code_mappings import * from .zeo_containers import * Python-roborock-python-roborock-d6da2db/roborock/data/zeo/zeo_code_mappings.py000066400000000000000000000044171513363643200301170ustar00rootroot00000000000000from ..code_mappings import RoborockEnum class ZeoMode(RoborockEnum): wash = 1 wash_and_dry = 2 dry = 3 class ZeoState(RoborockEnum): standby = 1 weighing = 2 soaking = 3 washing = 4 rinsing = 5 spinning = 6 drying = 7 cooling = 8 under_delay_start = 9 done = 10 aftercare = 12 waiting_for_aftercare = 13 class ZeoProgram(RoborockEnum): standard = 1 quick = 2 sanitize = 3 wool = 4 air_refresh = 5 custom = 6 bedding = 7 down = 8 silk = 9 rinse_and_spin = 10 spin = 11 down_clean = 12 baby_care = 13 anti_allergen = 14 sportswear = 15 night = 16 new_clothes = 17 shirts = 18 synthetics = 19 underwear = 20 gentle = 21 intensive = 22 cotton_linen = 23 season = 24 warming = 25 bra = 26 panties = 27 boiling_wash = 28 socks = 30 towels = 31 anti_mite = 32 exo_40_60 = 33 twenty_c = 34 t_shirts = 35 stain_removal = 36 class ZeoSoak(RoborockEnum): normal = 0 low = 1 medium = 2 high = 3 max = 4 class ZeoTemperature(RoborockEnum): normal = 1 low = 2 medium = 3 high = 4 max = 5 twenty_c = 6 class ZeoRinse(RoborockEnum): none = 0 min = 1 low = 2 mid = 3 high = 4 max = 5 class ZeoSpin(RoborockEnum): none = 1 very_low = 2 low = 3 mid = 4 high = 5 very_high = 6 max = 7 class ZeoDryingMode(RoborockEnum): none = 0 quick = 1 iron = 2 store = 3 class ZeoDetergentType(RoborockEnum): empty = 0 low = 1 medium = 2 high = 3 class ZeoSoftenerType(RoborockEnum): empty = 0 low = 1 medium = 2 high = 3 class ZeoError(RoborockEnum): none = 0 refill_error = 1 drain_error = 2 door_lock_error = 3 water_level_error = 4 inverter_error = 5 heating_error = 6 temperature_error = 7 communication_error = 10 drying_error = 11 drying_error_e_12 = 12 drying_error_e_13 = 13 drying_error_e_14 = 14 drying_error_e_15 = 15 drying_error_e_16 = 16 drying_error_water_flow = 17 # Check for normal water flow drying_error_restart = 18 # Restart the washer and try again spin_error = 19 # re-arrange clothes Python-roborock-python-roborock-d6da2db/roborock/data/zeo/zeo_containers.py000066400000000000000000000000001513363643200274340ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/roborock/device_features.py000066400000000000000000001061171513363643200260610ustar00rootroot00000000000000from __future__ import annotations from dataclasses import dataclass, field, fields from enum import IntEnum, StrEnum from typing import Any from roborock.data.code_mappings import RoborockProductNickname from roborock.data.containers import RoborockBase from roborock.data.v1 import RoborockDockTypeCode class NewFeatureStrBit(IntEnum): TWO_KEY_REAL_TIME_VIDEO = 32 TWO_KEY_RTV_IN_CHARGING = 33 DIRTY_REPLENISH_CLEAN = 34 AUTO_DELIVERY_FIELD_IN_GLOBAL_STATUS = 35 AVOID_COLLISION_MODE = 36 VOICE_CONTROL = 37 NEW_ENDPOINT = 38 PUMPING_WATER = 39 CORNER_MOP_STRETCH = 40 HOT_WASH_TOWEL = 41 FLOOR_DIR_CLEAN_ANY_TIME = 42 PET_SUPPLIES_DEEP_CLEAN = 43 MOP_SHAKE_WATER_MAX = 45 EXACT_CUSTOM_MODE = 47 VIDEO_PATROL = 48 CARPET_CUSTOM_CLEAN = 49 PET_SNAPSHOT = 50 CUSTOM_CLEAN_MODE_COUNT = 51 NEW_AI_RECOGNITION = 52 AUTO_COLLECTION_2 = 53 RIGHT_BRUSH_STRETCH = 54 SMART_CLEAN_MODE_SET = 55 DIRTY_OBJECT_DETECT = 56 NO_NEED_CARPET_PRESS_SET = 57 VOICE_CONTROL_LED = 58 WATER_LEAK_CHECK = 60 MIN_BATTERY_15_TO_CLEAN_TASK = 62 GAP_DEEP_CLEAN = 63 OBJECT_DETECT_CHECK = 64 IDENTIFY_ROOM = 66 MATTER = 67 WORKDAY_HOLIDAY = 69 CLEAN_DIRECT_STATUS = 70 MAP_ERASER = 71 OPTIMIZE_BATTERY = 72 ACTIVATE_VIDEO_CHARGING_AND_STANDBY = 73 CARPET_LONG_HAIRED = 75 CLEAN_HISTORY_TIME_LINE = 76 MAX_ZONE_OPENED = 77 EXHIBITION_FUNCTION = 78 LDS_LIFTING = 79 AUTO_TEAR_DOWN_MOP = 80 SMALL_SIDE_MOP = 81 SUPPORT_SIDE_BRUSH_UP_DOWN = 82 DRY_INTERVAL_TIMER = 83 UVC_STERILIZE = 84 MIDWAY_BACK_TO_DOCK = 85 SUPPORT_MAIN_BRUSH_UP_DOWN = 86 EGG_DANCE_MODE = 87 MECHANICAL_ARM_MODE = 89 TIDYUP_ZONES = MECHANICAL_ARM_MODE CLEAN_TIME_LINE = 91 CLEAN_THEN_MOP_MODE = 93 TYPE_IDENTIFY = 94 SUPPORT_GET_PARTICULAR_STATUS = 96 THREE_D_MAPPING_INNER_TEST = 97 SYNC_SERVER_NAME = 98 SHOULD_SHOW_ARM_OVER_LOAD = 99 COLLECT_DUST_COUNT_SHOW = 100 SUPPORT_API_APP_STOP_GRASP = 101 CTM_WITH_REPEAT = 102 SIDE_BRUSH_LIFT_CARPET = 104 DETECT_WIRE_CARPET = 105 WATER_SLIDE_MODE = 106 SOAK_AND_WASH = 107 CLEAN_EFFICIENCY = 108 BACK_WASH_NEW_SMART = 109 DUAL_BAND_WI_FI = 110 PROGRAM_MODE = 111 CLEAN_FLUID_DELIVERY = 112 CARPET_LONG_HAIRED_EX = 113 OVER_SEA_CTM = 114 FULL_DUPLES_SWITCH = 115 LOW_AREA_ACCESS = 116 FOLLOW_LOW_OBS = 117 TWO_GEARS_NO_COLLISION = 118 CARPET_SHAPE_TYPE = 119 SR_MAP = 120 class ProductFeatures(StrEnum): REMOTE_BACK = "remote_back" CLEANMODE_MAXPLUS = "cleanmode_maxplus" CLEANMODE_PURECLEANMOP = "cleanmode_purecleanmop" CLEANMODE_NONE_PURECLEANMOP_WITH_MAXPLUS = "cleanmode_none_purecleanmop_with_maxplus" MOP_ELECTRONIC_MODULE = "mop_electronic_module" MOP_SHAKE_MODULE = "mop_shake_module" MOP_SPIN_MODULE = "mop_spin_module" DEFAULT_MAP3D = "map3d" DEFAULT_CLEANMODECUSTOM = "custom_cleanmode" REALTIMEVIDEO = "realtimevideo" REALTIMEVIDEO_LIVECALL = "realtimevideo_livecall" REALTIMEVIDEO_RECORDANDSHORTCUT = "realtimevideo_livecall" CAMERA_SINGLELINE = "camera_singleline" CAMERA_DUALLINE = "camera_dualline" CAMERA_RGB = "camera_rgb" CAMERA_DOUBLERGB = "camera_doublergb" AIRECOGNITION_SETTING = "airecognition_setting" AIRECOGNITION_SCENE = "airecognition_scene" AIRECOGNITION_PET = "airecognition_pet" AIRECOGNITION_OBSTACLE = "airecognition_obstacle" # The following combinations are pulled directly from decompiled source code. AIRECOGNITION_OBSTACLE = [ProductFeatures.AIRECOGNITION_OBSTACLE] RGB_CAMERA_FEATURES = [ ProductFeatures.CAMERA_RGB, ProductFeatures.AIRECOGNITION_SETTING, ProductFeatures.AIRECOGNITION_SCENE, ProductFeatures.AIRECOGNITION_PET, ProductFeatures.AIRECOGNITION_OBSTACLE, ProductFeatures.REALTIMEVIDEO, ProductFeatures.REALTIMEVIDEO_LIVECALL, ProductFeatures.REALTIMEVIDEO_RECORDANDSHORTCUT, ] DOUBLE_RGB_CAMERA_FEATURES = [ ProductFeatures.CAMERA_DOUBLERGB, ProductFeatures.AIRECOGNITION_SETTING, ProductFeatures.AIRECOGNITION_PET, ProductFeatures.AIRECOGNITION_OBSTACLE, ProductFeatures.REALTIMEVIDEO, ] SINGLE_LINE_CAMERA_FEATURES = [ ProductFeatures.CAMERA_SINGLELINE, ProductFeatures.AIRECOGNITION_SETTING, ProductFeatures.AIRECOGNITION_OBSTACLE, ] DUAL_LINE_CAMERA_FEATURES = [ ProductFeatures.CAMERA_DUALLINE, ProductFeatures.AIRECOGNITION_SETTING, ProductFeatures.AIRECOGNITION_OBSTACLE, ProductFeatures.AIRECOGNITION_PET, ] NEW_DEFAULT_FEATURES = [ProductFeatures.REMOTE_BACK, ProductFeatures.CLEANMODE_MAXPLUS] PEARL_FEATURES = SINGLE_LINE_CAMERA_FEATURES + [ProductFeatures.CLEANMODE_MAXPLUS, ProductFeatures.MOP_SPIN_MODULE] PEARL_PLUS_FEATURES = NEW_DEFAULT_FEATURES + RGB_CAMERA_FEATURES + [ProductFeatures.MOP_SPIN_MODULE] ULTRON_FEATURES = NEW_DEFAULT_FEATURES + DUAL_LINE_CAMERA_FEATURES + [ProductFeatures.MOP_SHAKE_MODULE] ULTRONSV_FEATURES = NEW_DEFAULT_FEATURES + RGB_CAMERA_FEATURES + [ProductFeatures.MOP_SHAKE_MODULE] TANOSS_FEATURES = [ProductFeatures.REMOTE_BACK, ProductFeatures.MOP_SHAKE_MODULE] TOPAZSPOWER_FEATURES = [ProductFeatures.CLEANMODE_MAXPLUS, ProductFeatures.MOP_SHAKE_MODULE] PRODUCTS_WITHOUT_CUSTOM_CLEAN: set[RoborockProductNickname] = { RoborockProductNickname.TANOS, RoborockProductNickname.RUBYPLUS, RoborockProductNickname.RUBYSC, RoborockProductNickname.RUBYSE, } PRODUCTS_WITHOUT_DEFAULT_3D_MAP: set[RoborockProductNickname] = { RoborockProductNickname.TANOS, RoborockProductNickname.TANOSSPLUS, RoborockProductNickname.TANOSE, RoborockProductNickname.TANOSV, RoborockProductNickname.RUBYPLUS, RoborockProductNickname.RUBYSC, RoborockProductNickname.RUBYSE, } PRODUCTS_WITHOUT_PURE_CLEAN_MOP: set[RoborockProductNickname] = { RoborockProductNickname.TANOS, RoborockProductNickname.TANOSE, RoborockProductNickname.TANOSV, RoborockProductNickname.TANOSSLITE, RoborockProductNickname.TANOSSE, RoborockProductNickname.TANOSSC, RoborockProductNickname.ULTRONLITE, RoborockProductNickname.ULTRONE, RoborockProductNickname.RUBYPLUS, RoborockProductNickname.RUBYSLITE, RoborockProductNickname.RUBYSC, RoborockProductNickname.RUBYSE, } # Base map containing the initial, unconditional features for each product. _BASE_PRODUCT_FEATURE_MAP: dict[RoborockProductNickname, list[ProductFeatures]] = { RoborockProductNickname.PEARL: PEARL_FEATURES, RoborockProductNickname.PEARLS: PEARL_FEATURES, RoborockProductNickname.PEARLPLUS: PEARL_PLUS_FEATURES, RoborockProductNickname.VIVIAN: PEARL_PLUS_FEATURES, RoborockProductNickname.CORAL: PEARL_PLUS_FEATURES, RoborockProductNickname.ULTRON: ULTRON_FEATURES, RoborockProductNickname.ULTRONE: [ProductFeatures.CLEANMODE_NONE_PURECLEANMOP_WITH_MAXPLUS], RoborockProductNickname.ULTRONSV: ULTRONSV_FEATURES, RoborockProductNickname.TOPAZSPOWER: TOPAZSPOWER_FEATURES, RoborockProductNickname.TANOSS: TANOSS_FEATURES, RoborockProductNickname.PEARLC: PEARL_FEATURES, RoborockProductNickname.PEARLPLUSS: PEARL_PLUS_FEATURES, RoborockProductNickname.PEARLSLITE: PEARL_FEATURES, RoborockProductNickname.PEARLE: PEARL_FEATURES, RoborockProductNickname.PEARLELITE: PEARL_FEATURES, RoborockProductNickname.VIVIANC: [ProductFeatures.CLEANMODE_MAXPLUS, ProductFeatures.MOP_SPIN_MODULE] + SINGLE_LINE_CAMERA_FEATURES, RoborockProductNickname.CORALPRO: PEARL_PLUS_FEATURES, RoborockProductNickname.ULTRONLITE: SINGLE_LINE_CAMERA_FEATURES + [ProductFeatures.CLEANMODE_NONE_PURECLEANMOP_WITH_MAXPLUS, ProductFeatures.MOP_ELECTRONIC_MODULE], RoborockProductNickname.ULTRONSC: ULTRON_FEATURES, RoborockProductNickname.ULTRONSE: [ ProductFeatures.CLEANMODE_NONE_PURECLEANMOP_WITH_MAXPLUS, ProductFeatures.MOP_ELECTRONIC_MODULE, ], RoborockProductNickname.ULTRONSPLUS: ULTRON_FEATURES, RoborockProductNickname.VERDELITE: ULTRONSV_FEATURES, RoborockProductNickname.TOPAZS: [ProductFeatures.REMOTE_BACK, ProductFeatures.MOP_SHAKE_MODULE], RoborockProductNickname.TOPAZSPLUS: NEW_DEFAULT_FEATURES + DUAL_LINE_CAMERA_FEATURES + [ProductFeatures.MOP_SHAKE_MODULE], RoborockProductNickname.TOPAZSC: TOPAZSPOWER_FEATURES + SINGLE_LINE_CAMERA_FEATURES, RoborockProductNickname.TOPAZSV: NEW_DEFAULT_FEATURES + RGB_CAMERA_FEATURES + [ProductFeatures.MOP_SHAKE_MODULE], RoborockProductNickname.TANOSSPLUS: TANOSS_FEATURES + DUAL_LINE_CAMERA_FEATURES, RoborockProductNickname.TANOSSLITE: [ProductFeatures.MOP_ELECTRONIC_MODULE], RoborockProductNickname.TANOSSC: [], RoborockProductNickname.TANOSSE: [], RoborockProductNickname.TANOSSMAX: NEW_DEFAULT_FEATURES + DUAL_LINE_CAMERA_FEATURES + [ProductFeatures.MOP_SHAKE_MODULE], RoborockProductNickname.TANOS: [ProductFeatures.REMOTE_BACK], RoborockProductNickname.TANOSE: [ProductFeatures.MOP_ELECTRONIC_MODULE, ProductFeatures.REMOTE_BACK], RoborockProductNickname.TANOSV: DOUBLE_RGB_CAMERA_FEATURES + [ProductFeatures.REMOTE_BACK, ProductFeatures.MOP_ELECTRONIC_MODULE], RoborockProductNickname.RUBYPLUS: [], RoborockProductNickname.RUBYSC: [], RoborockProductNickname.RUBYSE: [], RoborockProductNickname.RUBYSLITE: [ProductFeatures.MOP_ELECTRONIC_MODULE], } PRODUCT_FEATURE_MAP: dict[RoborockProductNickname, list[ProductFeatures]] = { product: ( features + ([ProductFeatures.DEFAULT_CLEANMODECUSTOM] if product not in PRODUCTS_WITHOUT_CUSTOM_CLEAN else []) + ([ProductFeatures.DEFAULT_MAP3D] if product not in PRODUCTS_WITHOUT_DEFAULT_3D_MAP else []) + ([ProductFeatures.CLEANMODE_PURECLEANMOP] if product not in PRODUCTS_WITHOUT_PURE_CLEAN_MOP else []) ) for product, features in _BASE_PRODUCT_FEATURE_MAP.items() } @dataclass class DeviceFeatures(RoborockBase): """Represents the features supported by a Roborock device.""" # Features from robot_new_features (lower 32 bits) is_show_clean_finish_reason_supported: bool = field(metadata={"robot_new_features": 1}) is_re_segment_supported: bool = field(metadata={"robot_new_features": 4}) is_video_monitor_supported: bool = field(metadata={"robot_new_features": 8}) is_any_state_transit_goto_supported: bool = field(metadata={"robot_new_features": 16}) is_fw_filter_obstacle_supported: bool = field(metadata={"robot_new_features": 32}) is_video_setting_supported: bool = field(metadata={"robot_new_features": 64}) is_ignore_unknown_map_object_supported: bool = field(metadata={"robot_new_features": 128}) is_set_child_supported: bool = field(metadata={"robot_new_features": 256}) is_carpet_supported: bool = field(metadata={"robot_new_features": 512}) is_record_allowed: bool = field(metadata={"robot_new_features": 1024}) is_mop_path_supported: bool = field(metadata={"robot_new_features": 2048}) is_multi_map_segment_timer_supported: bool = field(metadata={"robot_new_features": 4096}) is_current_map_restore_enabled: bool = field(metadata={"robot_new_features": 8192}) is_room_name_supported: bool = field(metadata={"robot_new_features": 16384}) is_shake_mop_set_supported: bool = field(metadata={"robot_new_features": 262144}) is_map_beautify_internal_debug_supported: bool = field(metadata={"robot_new_features": 2097152}) is_new_data_for_clean_history: bool = field(metadata={"robot_new_features": 4194304}) is_new_data_for_clean_history_detail: bool = field(metadata={"robot_new_features": 8388608}) is_flow_led_setting_supported: bool = field(metadata={"robot_new_features": 16777216}) is_dust_collection_setting_supported: bool = field(metadata={"robot_new_features": 33554432}) is_rpc_retry_supported: bool = field(metadata={"robot_new_features": 67108864}) is_avoid_collision_supported: bool = field(metadata={"robot_new_features": 134217728}) is_support_set_switch_map_mode: bool = field(metadata={"robot_new_features": 268435456}) is_map_carpet_add_support: bool = field(metadata={"robot_new_features": 1073741824}) is_custom_water_box_distance_supported: bool = field(metadata={"robot_new_features": 2147483648}) # Features from robot_new_features (upper 32 bits) is_support_smart_scene: bool = field(metadata={"upper_32_bits": 1}) is_support_floor_edit: bool = field(metadata={"upper_32_bits": 3}) is_support_furniture: bool = field(metadata={"upper_32_bits": 4}) is_wash_then_charge_cmd_supported: bool = field(metadata={"upper_32_bits": 5}) is_support_room_tag: bool = field(metadata={"upper_32_bits": 6}) is_support_quick_map_builder: bool = field(metadata={"upper_32_bits": 7}) is_support_smart_global_clean_with_custom_mode: bool = field(metadata={"upper_32_bits": 8}) is_careful_slow_mop_supported: bool = field(metadata={"upper_32_bits": 9}) is_egg_mode_supported_from_new_features: bool = field(metadata={"upper_32_bits": 10}) is_carpet_show_on_map: bool = field(metadata={"upper_32_bits": 12}) is_supported_valley_electricity: bool = field(metadata={"upper_32_bits": 13}) is_unsave_map_reason_supported: bool = field(metadata={"upper_32_bits": 14}) is_supported_drying: bool = field(metadata={"upper_32_bits": 15}) is_supported_download_test_voice: bool = field(metadata={"upper_32_bits": 16}) is_support_backup_map: bool = field(metadata={"upper_32_bits": 17}) is_support_custom_mode_in_cleaning: bool = field(metadata={"upper_32_bits": 18}) is_support_remote_control_in_call: bool = field(metadata={"upper_32_bits": 19}) # Features from new_feature_info_str (masking last 8 chars / 32 bits) is_support_set_volume_in_call: bool = field(metadata={"new_feature_str_mask": (1, 8)}) is_support_clean_estimate: bool = field(metadata={"new_feature_str_mask": (2, 8)}) is_support_custom_dnd: bool = field(metadata={"new_feature_str_mask": (4, 8)}) is_carpet_deep_clean_supported: bool = field(metadata={"new_feature_str_mask": (8, 8)}) is_support_stuck_zone: bool = field(metadata={"new_feature_str_mask": (16, 8)}) is_support_custom_door_sill: bool = field(metadata={"new_feature_str_mask": (32, 8)}) is_wifi_manage_supported: bool = field(metadata={"new_feature_str_mask": (128, 8)}) is_clean_route_fast_mode_supported: bool = field(metadata={"new_feature_str_mask": (256, 8)}) is_support_cliff_zone: bool = field(metadata={"new_feature_str_mask": (512, 8)}) is_support_smart_door_sill: bool = field(metadata={"new_feature_str_mask": (1024, 8)}) is_support_floor_direction: bool = field(metadata={"new_feature_str_mask": (2048, 8)}) is_back_charge_auto_wash_supported: bool = field(metadata={"new_feature_str_mask": (4096, 8)}) is_support_incremental_map: bool = field(metadata={"new_feature_str_mask": (4194304, 8)}) is_offline_map_supported: bool = field(metadata={"new_feature_str_mask": (16384, 8)}) is_super_deep_wash_supported: bool = field(metadata={"new_feature_str_mask": (32768, 8)}) is_ces_2022_supported: bool = field(metadata={"new_feature_str_mask": (65536, 8)}) is_dss_believable: bool = field(metadata={"new_feature_str_mask": (131072, 8)}) is_main_brush_up_down_supported_from_str: bool = field(metadata={"new_feature_str_mask": (262144, 8)}) is_goto_pure_clean_path_supported: bool = field(metadata={"new_feature_str_mask": (524288, 8)}) is_water_up_down_drain_supported: bool = field(metadata={"new_feature_str_mask": (1048576, 8)}) is_setting_carpet_first_supported: bool = field(metadata={"new_feature_str_mask": (8388608, 8)}) is_clean_route_deep_slow_plus_supported: bool = field(metadata={"new_feature_str_mask": (16777216, 8)}) is_dynamically_skip_clean_zone_supported: bool = field(metadata={"new_feature_str_mask": (33554432, 8)}) is_dynamically_add_clean_zones_supported: bool = field(metadata={"new_feature_str_mask": (67108864, 8)}) is_left_water_drain_supported: bool = field(metadata={"new_feature_str_mask": (134217728, 8)}) is_clean_count_setting_supported: bool = field(metadata={"new_feature_str_mask": (1073741824, 8)}) is_corner_clean_mode_supported: bool = field(metadata={"new_feature_str_mask": (2147483648, 8)}) # Features from new_feature_info_str (by bit index) is_two_key_real_time_video_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.TWO_KEY_REAL_TIME_VIDEO} ) is_two_key_rtv_in_charging_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.TWO_KEY_RTV_IN_CHARGING} ) is_dirty_replenish_clean_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.DIRTY_REPLENISH_CLEAN} ) is_auto_delivery_field_in_global_status_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.AUTO_DELIVERY_FIELD_IN_GLOBAL_STATUS} ) is_avoid_collision_mode_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.AVOID_COLLISION_MODE} ) is_voice_control_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.VOICE_CONTROL}) is_new_endpoint_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.NEW_ENDPOINT}) is_pumping_water_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.PUMPING_WATER}) is_corner_mop_stretch_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.CORNER_MOP_STRETCH}) is_hot_wash_towel_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.HOT_WASH_TOWEL}) is_floor_dir_clean_any_time_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.FLOOR_DIR_CLEAN_ANY_TIME} ) is_pet_supplies_deep_clean_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.PET_SUPPLIES_DEEP_CLEAN} ) is_mop_shake_water_max_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.MOP_SHAKE_WATER_MAX} ) is_exact_custom_mode_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.EXACT_CUSTOM_MODE}) is_video_patrol_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.VIDEO_PATROL}) is_carpet_custom_clean_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.CARPET_CUSTOM_CLEAN} ) is_pet_snapshot_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.PET_SNAPSHOT}) is_custom_clean_mode_count_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.CUSTOM_CLEAN_MODE_COUNT} ) is_new_ai_recognition_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.NEW_AI_RECOGNITION}) is_auto_collection_2_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.AUTO_COLLECTION_2}) is_right_brush_stretch_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.RIGHT_BRUSH_STRETCH} ) is_smart_clean_mode_set_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.SMART_CLEAN_MODE_SET} ) is_dirty_object_detect_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.DIRTY_OBJECT_DETECT} ) is_no_need_carpet_press_set_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.NO_NEED_CARPET_PRESS_SET} ) is_voice_control_led_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.VOICE_CONTROL_LED}) is_water_leak_check_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.WATER_LEAK_CHECK}) is_min_battery_15_to_clean_task_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.MIN_BATTERY_15_TO_CLEAN_TASK} ) is_gap_deep_clean_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.GAP_DEEP_CLEAN}) is_object_detect_check_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.OBJECT_DETECT_CHECK} ) is_identify_room_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.IDENTIFY_ROOM}) is_matter_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.MATTER}) is_workday_holiday_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.WORKDAY_HOLIDAY}) is_clean_direct_status_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.CLEAN_DIRECT_STATUS} ) is_map_eraser_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.MAP_ERASER}) is_optimize_battery_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.OPTIMIZE_BATTERY}) is_activate_video_charging_and_standby_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.ACTIVATE_VIDEO_CHARGING_AND_STANDBY} ) is_carpet_long_haired_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.CARPET_LONG_HAIRED}) is_clean_history_time_line_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.CLEAN_HISTORY_TIME_LINE} ) is_max_zone_opened_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.MAX_ZONE_OPENED}) is_exhibition_function_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.EXHIBITION_FUNCTION} ) is_lds_lifting_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.LDS_LIFTING}) is_auto_tear_down_mop_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.AUTO_TEAR_DOWN_MOP}) is_small_side_mop_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.SMALL_SIDE_MOP}) is_support_side_brush_up_down_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.SUPPORT_SIDE_BRUSH_UP_DOWN} ) is_dry_interval_timer_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.DRY_INTERVAL_TIMER}) is_uvc_sterilize_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.UVC_STERILIZE}) is_midway_back_to_dock_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.MIDWAY_BACK_TO_DOCK} ) is_support_main_brush_up_down_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.SUPPORT_MAIN_BRUSH_UP_DOWN} ) is_egg_dance_mode_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.EGG_DANCE_MODE}) is_mechanical_arm_mode_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.MECHANICAL_ARM_MODE} ) is_tidyup_zones_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.TIDYUP_ZONES}) is_clean_time_line_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.CLEAN_TIME_LINE}) is_clean_then_mop_mode_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.CLEAN_THEN_MOP_MODE} ) is_type_identify_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.TYPE_IDENTIFY}) is_support_get_particular_status_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.SUPPORT_GET_PARTICULAR_STATUS} ) is_three_d_mapping_inner_test_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.THREE_D_MAPPING_INNER_TEST} ) is_sync_server_name_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.SYNC_SERVER_NAME}) is_should_show_arm_over_load_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.SHOULD_SHOW_ARM_OVER_LOAD} ) is_collect_dust_count_show_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.COLLECT_DUST_COUNT_SHOW} ) is_support_api_app_stop_grasp_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.SUPPORT_API_APP_STOP_GRASP} ) is_ctm_with_repeat_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.CTM_WITH_REPEAT}) is_side_brush_lift_carpet_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.SIDE_BRUSH_LIFT_CARPET} ) is_detect_wire_carpet_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.DETECT_WIRE_CARPET}) is_water_slide_mode_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.WATER_SLIDE_MODE}) is_soak_and_wash_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.SOAK_AND_WASH}) is_clean_efficiency_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.CLEAN_EFFICIENCY}) is_back_wash_new_smart_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.BACK_WASH_NEW_SMART} ) is_dual_band_wi_fi_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.DUAL_BAND_WI_FI}) is_program_mode_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.PROGRAM_MODE}) is_clean_fluid_delivery_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.CLEAN_FLUID_DELIVERY} ) is_carpet_long_haired_ex_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.CARPET_LONG_HAIRED_EX} ) is_over_sea_ctm_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.OVER_SEA_CTM}) is_full_duples_switch_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.FULL_DUPLES_SWITCH}) is_low_area_access_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.LOW_AREA_ACCESS}) is_follow_low_obs_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.FOLLOW_LOW_OBS}) is_two_gears_no_collision_supported: bool = field( metadata={"new_feature_str_bit": NewFeatureStrBit.TWO_GEARS_NO_COLLISION} ) is_carpet_shape_type_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.CARPET_SHAPE_TYPE}) is_sr_map_supported: bool = field(metadata={"new_feature_str_bit": NewFeatureStrBit.SR_MAP}) # Features from feature_info list is_led_status_switch_supported: bool = field(metadata={"robot_features": 119}) is_multi_floor_supported: bool = field(metadata={"robot_features": 120}) is_support_fetch_timer_summary: bool = field(metadata={"robot_features": 122}) is_order_clean_supported: bool = field(metadata={"robot_features": 123}) is_analysis_supported: bool = field(metadata={"robot_features": 124}) is_remote_supported: bool = field(metadata={"robot_features": 125}) is_support_voice_control_debug: bool = field(metadata={"robot_features": 130}) # Features from model whitelists/blacklists or other flags is_mop_forbidden_supported: bool = field( metadata={ "model_whitelist": [ RoborockProductNickname.TANOSV, RoborockProductNickname.TOPAZSV, RoborockProductNickname.TANOS, RoborockProductNickname.TANOSE, RoborockProductNickname.TANOSSLITE, RoborockProductNickname.TANOSS, RoborockProductNickname.TANOSSPLUS, RoborockProductNickname.TANOSSMAX, RoborockProductNickname.ULTRON, RoborockProductNickname.ULTRONLITE, RoborockProductNickname.PEARL, RoborockProductNickname.RUBYSLITE, ] } ) is_soft_clean_mode_supported: bool = field( metadata={ "model_whitelist": [ RoborockProductNickname.TANOSV, RoborockProductNickname.TANOSE, RoborockProductNickname.TANOS, ] } ) is_custom_mode_supported: bool = field(metadata={"model_blacklist": [RoborockProductNickname.TANOS]}) is_support_custom_carpet: bool = field(metadata={"model_whitelist": [RoborockProductNickname.ULTRONLITE]}) is_show_general_obstacle_supported: bool = field(metadata={"model_whitelist": [RoborockProductNickname.TANOSSPLUS]}) is_show_obstacle_photo_supported: bool = field( metadata={ "model_whitelist": [ RoborockProductNickname.TANOSSPLUS, RoborockProductNickname.TANOSSMAX, RoborockProductNickname.ULTRON, ] } ) is_rubber_brush_carpet_supported: bool = field(metadata={"model_whitelist": [RoborockProductNickname.ULTRONLITE]}) is_carpet_pressure_use_origin_paras_supported: bool = field( metadata={"model_whitelist": [RoborockProductNickname.ULTRONLITE]} ) is_support_mop_back_pwm_set: bool = field(metadata={"model_whitelist": [RoborockProductNickname.PEARL]}) is_collect_dust_mode_supported: bool = field(metadata={"model_blacklist": [RoborockProductNickname.PEARL]}) is_support_water_mode: bool = field( metadata={ "product_features": [ ProductFeatures.MOP_ELECTRONIC_MODULE, ProductFeatures.MOP_SHAKE_MODULE, ProductFeatures.MOP_SPIN_MODULE, ] } ) is_pure_clean_mop_supported: bool = field(metadata={"product_features": [ProductFeatures.CLEANMODE_PURECLEANMOP]}) is_new_remote_view_supported: bool = field(metadata={"product_features": [ProductFeatures.REMOTE_BACK]}) is_max_plus_mode_supported: bool = field(metadata={"product_features": [ProductFeatures.CLEANMODE_MAXPLUS]}) is_none_pure_clean_mop_with_max_plus: bool = field( metadata={"product_features": [ProductFeatures.CLEANMODE_NONE_PURECLEANMOP_WITH_MAXPLUS]} ) is_clean_route_setting_supported: bool = field( metadata={"product_features": [ProductFeatures.MOP_SHAKE_MODULE, ProductFeatures.MOP_SPIN_MODULE]} ) is_mop_shake_module_supported: bool = field(metadata={"product_features": [ProductFeatures.MOP_SHAKE_MODULE]}) is_customized_clean_supported: bool = field( metadata={"product_features": [ProductFeatures.MOP_SHAKE_MODULE, ProductFeatures.MOP_SPIN_MODULE]} ) # Raw feature info values from get_init_status for diagnostics new_feature_info: int = field(default=0, repr=False) new_feature_info_str: str = field(default="", repr=False) feature_info: list[int] = field(default_factory=list, repr=False) @classmethod def from_feature_flags( cls, new_feature_info: int, new_feature_info_str: str, feature_info: list[int], product_nickname: RoborockProductNickname | None, ) -> DeviceFeatures: """Creates a DeviceFeatures instance from raw feature flags. :param new_feature_info: A int from get_init_status (sometimes can be found in homedata, but it is not always) :param new_feature_info_str: A hex string from get_init_status or home_data. :param feature_info: A list of ints from get_init_status :param product_nickname: The product nickname of the device.""" # For any future reverse engineerining: # RobotNewFeatures = new_feature_info # newFeatureInfoStr = new_feature_info_str # feature_info =robotFeatures kwargs: dict[str, Any] = { # Store raw feature info for diagnostics "new_feature_info": new_feature_info, "new_feature_info_str": new_feature_info_str, "feature_info": feature_info, } for f in fields(cls): # Skip raw feature info fields (already set above) if f.name in ("new_feature_info", "new_feature_info_str", "feature_info"): continue # Default all features to False. kwargs[f.name] = False if not f.metadata: continue if (mask := f.metadata.get("robot_new_features")) is not None: kwargs[f.name] = bool(mask & new_feature_info) elif (bit_index := f.metadata.get("upper_32_bits")) is not None: # Check bits in the upper 32-bit integer of new_feature_info if new_feature_info: kwargs[f.name] = bool(((new_feature_info >> 32) >> bit_index) & 1) elif (mask_info := f.metadata.get("new_feature_str_mask")) is not None: # Check bitmask against a slice of the hex string if new_feature_info_str: try: mask, slice_count = mask_info if len(new_feature_info_str) >= slice_count: last_chars = new_feature_info_str[-slice_count:] value = int(last_chars, 16) kwargs[f.name] = bool(mask & value) except (ValueError, IndexError): pass # Keep it False elif (bit := f.metadata.get("new_feature_str_bit")) is not None: # Check a specific bit in the hex string using its index if new_feature_info_str: try: # Bit index defines which character and which bit inside it to check char_index_from_end = 1 + bit.value // 4 if char_index_from_end <= len(new_feature_info_str): char_hex = new_feature_info_str[-char_index_from_end] nibble = int(char_hex, 16) bit_in_nibble = bit.value % 4 kwargs[f.name] = bool((nibble >> bit_in_nibble) & 1) except (ValueError, IndexError): pass # Keep it False elif (feature_id := f.metadata.get("robot_features")) is not None: kwargs[f.name] = feature_id in feature_info elif (whitelist := f.metadata.get("model_whitelist")) is not None: # If product_nickname is None, assume it is not in the whitelist kwargs[f.name] = product_nickname in whitelist or product_nickname is None elif (blacklist := f.metadata.get("model_blacklist")) is not None: # If product_nickname is None, assume it is not in the blacklist. if product_nickname is None: kwargs[f.name] = True else: kwargs[f.name] = product_nickname not in blacklist elif (product_features := f.metadata.get("product_features")) is not None: if product_nickname is not None: available_features = PRODUCT_FEATURE_MAP.get(product_nickname, []) if any(feat in available_features for feat in product_features): # type: ignore kwargs[f.name] = True return cls(**kwargs) def get_supported_features(self) -> list[str]: """Returns a list of supported features (Primarily used for logging purposes).""" return [k for k, v in vars(self).items() if v] WASH_N_FILL_DOCK_TYPES = [ RoborockDockTypeCode.empty_wash_fill_dock, RoborockDockTypeCode.s8_dock, RoborockDockTypeCode.p10_dock, RoborockDockTypeCode.p10_pro_dock, RoborockDockTypeCode.s8_maxv_ultra_dock, RoborockDockTypeCode.qrevo_s_dock, RoborockDockTypeCode.saros_r10_dock, RoborockDockTypeCode.qrevo_curv_dock, ] def is_wash_n_fill_dock(dock_type: RoborockDockTypeCode) -> bool: """Check if the dock type is a wash and fill dock.""" return dock_type in WASH_N_FILL_DOCK_TYPES def is_valid_dock(dock_type: RoborockDockTypeCode) -> bool: """Check if device supports a dock.""" return dock_type != RoborockDockTypeCode.no_dock Python-roborock-python-roborock-d6da2db/roborock/devices/000077500000000000000000000000001513363643200237665ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/roborock/devices/README.md000066400000000000000000000042121513363643200252440ustar00rootroot00000000000000# Roborock Device Manager This library provides a high-level interface for discovering and controlling Roborock devices. It abstracts the underlying communication protocols (MQTT, Local TCP) and provides a unified `DeviceManager` for interacting with your devices. For internal architecture details, protocol specifications, and design documentation, please refer to [docs/DEVICES.md](https://github.com/python-roborock/python-roborock/docs/DEVICES.md). ## Getting Started ### Credentials To connect to your devices, you first need to obtain your user data (including the `rriot` token) from the Roborock Cloud. This is handled via the `RoborockApiClient`. ## Usage Guide The core entry point for the library is the `DeviceManager`. It handles: 1. **Device Discovery**: Fetching the list of devices associated with your account. 2. **Connection Management**: Automatically determining the best connection method (Local vs MQTT) and protocol version (V1 vs A01/B01). 3. **Command Execution**: Sending commands and query status. ### Example See [examples/example.py](https://github.com/python-roborock/python-roborock/examples/example.py) for a complete example of how to login, create a device manager, and list the status of your vacuums. ### Device Properties Different devices support different property sets: * **`v1_properties`**: Primarily for Vacuum Robots (S7, S8, Q5, etc.). Supports traits like `status`, `consumables`, `fan_power`, `water_box`. * **`a01_properties`**: For Washer/Dryers and handheld Wet/Dry Vacuums (Dyad, Zeo) that use another newer protocol. * **`b01_q7_properties`** and **`b01_q10_properties`**: For newer Vacuum/Mop devices using newer protocol instead of v1. You can check if a property set is available by checking if the property on the device object is not `None` (e.g. `if device.v1_properties:`). ### Caching Use `FileCache` or your own `Cache` implementation to persist: - `HomeData`: The list of your home's rooms and devices. - `NetworkingInfo`: Device IP addresses and tokens. - `Device Capabilities`: What features your specific model supports. This speeds up startup time and reduces load on the Roborock cloud APIs. Python-roborock-python-roborock-d6da2db/roborock/devices/__init__.py000066400000000000000000000002011513363643200260700ustar00rootroot00000000000000""" .. include:: ./README.md """ __all__ = [ "device", "device_manager", "cache", "file_cache", "traits", ] Python-roborock-python-roborock-d6da2db/roborock/devices/cache.py000066400000000000000000000110311513363643200253770ustar00rootroot00000000000000"""This module provides caching functionality for the Roborock device management system. This module defines a cache interface that you may use to cache device information to avoid unnecessary API calls. Callers may implement this interface to provide their own caching mechanism. """ from dataclasses import dataclass, field from typing import Any, Protocol from roborock.data import CombinedMapInfo, HomeData, NetworkInfo, RoborockBase from roborock.device_features import DeviceFeatures @dataclass class DeviceCacheData(RoborockBase): """Data structure for caching device information.""" network_info: NetworkInfo | None = None """Network information for the device""" home_map_info: dict[int, CombinedMapInfo] | None = None """Home map information for the device by map_flag.""" home_map_content_base64: dict[int, str] | None = None """Home cache content for the device (encoded base64) by map_flag.""" device_features: DeviceFeatures | None = None """Device features information.""" trait_data: dict[str, Any] | None = None """Trait-specific cached data used internally for caching device features.""" @dataclass class CacheData(RoborockBase): """Data structure for caching device information.""" home_data: HomeData | None = None """Home data containing device and product information.""" device_info: dict[str, DeviceCacheData] = field(default_factory=dict) """Per-device cached information indexed by device DUID.""" network_info: dict[str, NetworkInfo] = field(default_factory=dict) """Network information indexed by device DUID. This is deprecated. Use the per-device `network_info` field instead. """ home_map_info: dict[int, CombinedMapInfo] = field(default_factory=dict) """Home map information indexed by map_flag. This is deprecated. Use the per-device `home_map_info` field instead. """ home_map_content: dict[int, bytes] = field(default_factory=dict) """Home cache content for each map data indexed by map_flag. This is deprecated. Use the per-device `home_map_content_base64` field instead. """ home_map_content_base64: dict[int, str] = field(default_factory=dict) """Home cache content for each map data (encoded base64) indexed by map_flag. This is deprecated. Use the per-device `home_map_content_base64` field instead. """ device_features: DeviceFeatures | None = None """Device features information. This is deprecated. Use the per-device `device_features` field instead. """ trait_data: dict[str, Any] | None = None """Trait-specific cached data used internally for caching device features. This is deprecated. Use the per-device `trait_data` field instead. """ class Cache(Protocol): """Protocol for a cache that can store and retrieve values.""" async def get(self) -> CacheData: """Get cached value.""" ... async def set(self, value: CacheData) -> None: """Set value in the cache.""" ... @dataclass class DeviceCache(RoborockBase): """Provides a cache interface for a specific device. This is a convenience wrapper around a general Cache implementation to provide device-specific caching functionality. """ def __init__(self, duid: str, cache: Cache) -> None: """Initialize the device cache with the given cache implementation.""" self._duid = duid self._cache = cache async def get(self) -> DeviceCacheData: """Get cached device-specific information.""" cache_data = await self._cache.get() if self._duid not in cache_data.device_info: cache_data.device_info[self._duid] = DeviceCacheData() await self._cache.set(cache_data) return cache_data.device_info[self._duid] async def set(self, device_cache_data: DeviceCacheData) -> None: """Set cached device-specific information.""" cache_data = await self._cache.get() cache_data.device_info[self._duid] = device_cache_data await self._cache.set(cache_data) class InMemoryCache(Cache): """In-memory cache implementation.""" def __init__(self) -> None: """Initialize the in-memory cache.""" self._data = CacheData() async def get(self) -> CacheData: return self._data async def set(self, value: CacheData) -> None: self._data = value class NoCache(Cache): """No-op cache implementation.""" async def get(self) -> CacheData: return CacheData() async def set(self, value: CacheData) -> None: pass Python-roborock-python-roborock-d6da2db/roborock/devices/device.py000066400000000000000000000211061513363643200255770ustar00rootroot00000000000000"""Module for Roborock devices. This interface is experimental and subject to breaking changes without notice until the API is stable. """ import asyncio import datetime import logging from abc import ABC from collections.abc import Callable from typing import Any from roborock.callbacks import CallbackList from roborock.data import HomeDataDevice, HomeDataProduct from roborock.diagnostics import redact_device_data from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockMessage from roborock.util import RoborockLoggerAdapter from .traits import Trait from .traits.traits_mixin import TraitsMixin from .transport.channel import Channel _LOGGER = logging.getLogger(__name__) __all__ = [ "DeviceReadyCallback", "RoborockDevice", ] # Exponential backoff parameters MIN_BACKOFF_INTERVAL = datetime.timedelta(seconds=10) MAX_BACKOFF_INTERVAL = datetime.timedelta(minutes=30) BACKOFF_MULTIPLIER = 1.5 # Give time for the NETWORK_INFO fetch and V1 hello attempt # and potential fallback to L01. START_ATTEMPT_TIMEOUT = datetime.timedelta(seconds=15) DeviceReadyCallback = Callable[["RoborockDevice"], None] class RoborockDevice(ABC, TraitsMixin): """A generic channel for establishing a connection with a Roborock device. Individual channel implementations have their own methods for speaking to the device that hide some of the protocol specific complexity, but they are still specialized for the device type and protocol. Attributes of the device are exposed through traits, which are mixed in through the TraitsMixin class. Traits are optional and may not be present on all devices. """ def __init__( self, device_info: HomeDataDevice, product: HomeDataProduct, channel: Channel, trait: Trait, ) -> None: """Initialize the RoborockDevice. The device takes ownership of the channel for communication with the device. Use `connect()` to establish the connection, which will set up the appropriate protocol channel. Use `close()` to clean up all connections. """ TraitsMixin.__init__(self, trait) self._duid = device_info.duid self._logger = RoborockLoggerAdapter(duid=self._duid, logger=_LOGGER) self._name = device_info.name self._device_info = device_info self._product = product self._channel = channel self._connect_task: asyncio.Task[None] | None = None self._unsub: Callable[[], None] | None = None self._ready_callbacks = CallbackList["RoborockDevice"]() self._has_connected = False @property def duid(self) -> str: """Return the device unique identifier (DUID).""" return self._duid @property def name(self) -> str: """Return the device name.""" return self._name @property def device_info(self) -> HomeDataDevice: """Return the device information. This includes information specific to the device like its identifier or firmware version. """ return self._device_info @property def product(self) -> HomeDataProduct: """Return the device product name. This returns product level information such as the model name. """ return self._product @property def is_connected(self) -> bool: """Return whether the device is connected.""" return self._channel.is_connected @property def is_local_connected(self) -> bool: """Return whether the device is connected locally. This can be used to determine if the device is reachable over a local network connection, as opposed to a cloud connection. This is useful for adjusting behavior like polling frequency. """ return self._channel.is_local_connected def add_ready_callback(self, callback: DeviceReadyCallback) -> Callable[[], None]: """Add a callback to be notified when the device is ready. A device is considered ready when it has successfully connected. It may go offline later, but this callback will only be called once when the device first connects. The callback will be called immediately if the device has already previously connected. """ remove = self._ready_callbacks.add_callback(callback) if self._has_connected: callback(self) return remove async def start_connect(self) -> None: """Start a background task to connect to the device. This will give a moment for the first connection attempt to start so that the device will have connections established -- however, this will never directly fail. If the connection fails, it will retry in the background with exponential backoff. Once connected, the device will remain connected until `close()` is called. The device will automatically attempt to reconnect if the connection is lost. """ # The future will be set to True if the first attempt succeeds, False if # it fails, or an exception if an unexpected error occurs. # We use this to wait a short time for the first attempt to complete. We # don't actually care about the result, just that we waited long enough. start_attempt: asyncio.Future[bool] = asyncio.Future() async def connect_loop() -> None: try: backoff = MIN_BACKOFF_INTERVAL while True: try: await self.connect() if not start_attempt.done(): start_attempt.set_result(True) self._has_connected = True self._ready_callbacks(self) return except RoborockException as e: if not start_attempt.done(): start_attempt.set_result(False) self._logger.info("Failed to connect (retry %s): %s", backoff.total_seconds(), e) await asyncio.sleep(backoff.total_seconds()) backoff = min(backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF_INTERVAL) except Exception as e: # pylint: disable=broad-except if not start_attempt.done(): start_attempt.set_exception(e) self._logger.exception("Uncaught error during connect: %s", e) return except asyncio.CancelledError: self._logger.debug("connect_loop was cancelled for device %s", self.duid) finally: if not start_attempt.done(): start_attempt.set_result(False) self._connect_task = asyncio.create_task(connect_loop()) try: async with asyncio.timeout(START_ATTEMPT_TIMEOUT.total_seconds()): await start_attempt except TimeoutError: self._logger.debug("Initial connection attempt took longer than expected, will keep trying in background") async def connect(self) -> None: """Connect to the device using the appropriate protocol channel.""" if self._unsub: raise ValueError("Already connected to the device") unsub = await self._channel.subscribe(self._on_message) if self.v1_properties is not None: try: await self.v1_properties.discover_features() except RoborockException: unsub() raise self._logger.info("Connected to device") self._unsub = unsub async def close(self) -> None: """Close all connections to the device.""" if self._connect_task: self._connect_task.cancel() try: await self._connect_task except asyncio.CancelledError: pass if self._unsub: self._unsub() self._unsub = None def _on_message(self, message: RoborockMessage) -> None: """Handle incoming messages from the device.""" self._logger.debug("Received message from device: %s", message) def diagnostic_data(self) -> dict[str, Any]: """Return diagnostics information about the device.""" extra: dict[str, Any] = {} if self.v1_properties: extra["traits"] = self.v1_properties.as_dict() return redact_device_data( { "device": self.device_info.as_dict(), "product": self.product.as_dict(), **extra, } ) Python-roborock-python-roborock-d6da2db/roborock/devices/device_manager.py000066400000000000000000000245521513363643200273010ustar00rootroot00000000000000"""Module for discovering Roborock devices.""" import asyncio import enum import logging from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import Any import aiohttp from roborock.data import ( HomeData, HomeDataDevice, HomeDataProduct, UserData, ) from roborock.devices.device import DeviceReadyCallback, RoborockDevice from roborock.diagnostics import Diagnostics, redact_device_data from roborock.exceptions import RoborockException from roborock.map.map_parser import MapParserConfig from roborock.mqtt.roborock_session import create_lazy_mqtt_session from roborock.mqtt.session import MqttSession, SessionUnauthorizedHook from roborock.protocol import create_mqtt_params from roborock.web_api import RoborockApiClient, UserWebApiClient from .cache import Cache, DeviceCache, NoCache from .rpc.v1_channel import create_v1_channel from .traits import Trait, a01, b01, v1 from .transport.channel import Channel from .transport.mqtt_channel import create_mqtt_channel _LOGGER = logging.getLogger(__name__) __all__ = [ "create_device_manager", "UserParams", "DeviceManager", ] DeviceCreator = Callable[[HomeData, HomeDataDevice, HomeDataProduct], RoborockDevice] class DeviceVersion(enum.StrEnum): """Enum for device versions.""" V1 = "1.0" A01 = "A01" B01 = "B01" UNKNOWN = "unknown" class UnsupportedDeviceError(RoborockException): """Exception raised when a device is unsupported.""" class DeviceManager: """Central manager for Roborock device discovery and connections.""" def __init__( self, web_api: UserWebApiClient, device_creator: DeviceCreator, mqtt_session: MqttSession, cache: Cache, diagnostics: Diagnostics, ) -> None: """Initialize the DeviceManager with user data and optional cache storage. This takes ownership of the MQTT session and will close it when the manager is closed. """ self._web_api = web_api self._cache = cache self._device_creator = device_creator self._devices: dict[str, RoborockDevice] = {} self._mqtt_session = mqtt_session self._diagnostics = diagnostics self._home_data: HomeData | None = None async def discover_devices(self, prefer_cache: bool = True) -> list[RoborockDevice]: """Discover all devices for the logged-in user.""" self._diagnostics.increment("discover_devices") cache_data = await self._cache.get() if not cache_data.home_data or not prefer_cache: _LOGGER.debug("Fetching home data (prefer_cache=%s)", prefer_cache) self._diagnostics.increment("fetch_home_data") try: cache_data.home_data = await self._web_api.get_home_data() except RoborockException as ex: if not cache_data.home_data: raise _LOGGER.debug("Failed to fetch home data, using cached data: %s", ex) await self._cache.set(cache_data) self._home_data = cache_data.home_data device_products = self._home_data.device_products _LOGGER.debug("Discovered %d devices", len(device_products)) # These are connected serially to avoid overwhelming the MQTT broker new_devices = {} start_tasks = [] supported_devices_counter = self._diagnostics.subkey("supported_devices") unsupported_devices_counter = self._diagnostics.subkey("unsupported_devices") for duid, (device, product) in device_products.items(): _LOGGER.debug("[%s] Discovered device %s %s", duid, product.summary_info(), device.summary_info()) if duid in self._devices: continue try: new_device = self._device_creator(self._home_data, device, product) except UnsupportedDeviceError: _LOGGER.info("Skipping unsupported device %s %s", product.summary_info(), device.summary_info()) unsupported_devices_counter.increment(device.pv or "unknown") continue supported_devices_counter.increment(device.pv or "unknown") start_tasks.append(new_device.start_connect()) new_devices[duid] = new_device self._devices.update(new_devices) await asyncio.gather(*start_tasks) return list(self._devices.values()) async def get_device(self, duid: str) -> RoborockDevice | None: """Get a specific device by DUID.""" return self._devices.get(duid) async def get_devices(self) -> list[RoborockDevice]: """Get all discovered devices.""" return list(self._devices.values()) async def close(self) -> None: """Close all MQTT connections and clean up resources.""" tasks = [device.close() for device in self._devices.values()] self._devices.clear() tasks.append(self._mqtt_session.close()) await asyncio.gather(*tasks) def diagnostic_data(self) -> Mapping[str, Any]: """Return diagnostics information about the device manager.""" return { "home_data": redact_device_data(self._home_data.as_dict()) if self._home_data else None, "devices": [device.diagnostic_data() for device in self._devices.values()], "diagnostics": self._diagnostics.as_dict(), } @dataclass class UserParams: """Parameters for creating a new session with Roborock devices. These parameters include the username, user data for authentication, and an optional base URL for the Roborock API. The `user_data` and `base_url` parameters are obtained from `RoborockApiClient` during the login process. """ username: str """The username (email) used for logging in.""" user_data: UserData """This is the user data containing authentication information.""" base_url: str | None = None """Optional base URL for the Roborock API. This is used to speed up connection times by avoiding the need to discover the API base URL each time. If not provided, the API client will attempt to discover it automatically which may take multiple requests. """ def create_web_api_wrapper( user_params: UserParams, *, cache: Cache | None = None, session: aiohttp.ClientSession | None = None, ) -> UserWebApiClient: """Create a home data API wrapper from an existing API client.""" # Note: This will auto discover the API base URL. This can be improved # by caching this next to `UserData` if needed to avoid unnecessary API calls. client = RoborockApiClient(username=user_params.username, base_url=user_params.base_url, session=session) return UserWebApiClient(client, user_params.user_data) async def create_device_manager( user_params: UserParams, *, cache: Cache | None = None, map_parser_config: MapParserConfig | None = None, session: aiohttp.ClientSession | None = None, ready_callback: DeviceReadyCallback | None = None, mqtt_session_unauthorized_hook: SessionUnauthorizedHook | None = None, prefer_cache: bool = True, ) -> DeviceManager: """Convenience function to create and initialize a DeviceManager. Args: user_params: Parameters for creating the user session. cache: Optional cache implementation to use for caching device data. map_parser_config: Optional configuration for parsing maps. session: Optional aiohttp ClientSession to use for HTTP requests. ready_callback: Optional callback to be notified when a device is ready. mqtt_session_unauthorized_hook: Optional hook for MQTT session unauthorized events which may indicate rate limiting or revoked credentials. The caller may use this to refresh authentication tokens as needed. prefer_cache: Whether to prefer cached device data over always fetching it from the API. Returns: An initialized DeviceManager with discovered devices. """ if cache is None: cache = NoCache() web_api = create_web_api_wrapper(user_params, session=session, cache=cache) user_data = user_params.user_data diagnostics = Diagnostics() mqtt_params = create_mqtt_params(user_data.rriot) mqtt_params.diagnostics = diagnostics.subkey("mqtt_session") mqtt_params.unauthorized_hook = mqtt_session_unauthorized_hook mqtt_session = await create_lazy_mqtt_session(mqtt_params) def device_creator(home_data: HomeData, device: HomeDataDevice, product: HomeDataProduct) -> RoborockDevice: channel: Channel trait: Trait device_cache: DeviceCache = DeviceCache(device.duid, cache) match device.pv: case DeviceVersion.V1: channel = create_v1_channel(user_data, mqtt_params, mqtt_session, device, device_cache) trait = v1.create( device.duid, product, home_data, channel.rpc_channel, channel.mqtt_rpc_channel, channel.map_rpc_channel, web_api, device_cache=device_cache, map_parser_config=map_parser_config, ) case DeviceVersion.A01: channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device) trait = a01.create(product, channel) case DeviceVersion.B01: channel = create_mqtt_channel(user_data, mqtt_params, mqtt_session, device) model_part = product.model.split(".")[-1] if "ss" in model_part: trait = b01.q10.create(channel) elif "sc" in model_part: # Q7 devices start with 'sc' in their model naming. trait = b01.q7.create(channel) else: raise UnsupportedDeviceError(f"Device {device.name} has unsupported B01 model: {product.model}") case _: raise UnsupportedDeviceError( f"Device {device.name} has unsupported version {device.pv} {product.model}" ) dev = RoborockDevice(device, product, channel, trait) if ready_callback: dev.add_ready_callback(ready_callback) return dev manager = DeviceManager(web_api, device_creator, mqtt_session=mqtt_session, cache=cache, diagnostics=diagnostics) await manager.discover_devices(prefer_cache) return manager Python-roborock-python-roborock-d6da2db/roborock/devices/file_cache.py000066400000000000000000000052441513363643200264070ustar00rootroot00000000000000"""This module implements a file-backed cache for device information. This module provides a `FileCache` class that implements the `Cache` protocol to store and retrieve cached device information from a file on disk. This allows persistent caching of device data across application restarts. """ import asyncio import pathlib import pickle from collections.abc import Callable from typing import Any from .cache import Cache, CacheData class FileCache(Cache): """File backed cache implementation.""" def __init__( self, file_path: pathlib.Path, init_fn: Callable[[], CacheData] = CacheData, serialize_fn: Callable[[Any], bytes] = pickle.dumps, deserialize_fn: Callable[[bytes], Any] = pickle.loads, ) -> None: """Initialize the file cache with the given file path.""" self._init_fn = init_fn self._file_path = file_path self._cache_data: CacheData | None = None self._serialize_fn = serialize_fn self._deserialize_fn = deserialize_fn async def get(self) -> CacheData: """Get cached value.""" if self._cache_data is not None: return self._cache_data data = await load_value(self._file_path, self._deserialize_fn) if data is not None and not isinstance(data, CacheData): raise TypeError(f"Invalid cache data loaded from {self._file_path}") self._cache_data = data or self._init_fn() return self._cache_data async def set(self, value: CacheData) -> None: # type: ignore[override] """Set value in the cache.""" self._cache_data = value async def flush(self) -> None: """Flush the cache to disk.""" if self._cache_data is None: return await store_value(self._file_path, self._cache_data, self._serialize_fn) async def store_value(file_path: pathlib.Path, value: Any, serialize_fn: Callable[[Any], bytes] = pickle.dumps) -> None: """Store a value to the given file path.""" def _store_to_disk(file_path: pathlib.Path, value: Any) -> None: with open(file_path, "wb") as f: data = serialize_fn(value) f.write(data) await asyncio.to_thread(_store_to_disk, file_path, value) async def load_value(file_path: pathlib.Path, deserialize_fn: Callable[[bytes], Any] = pickle.loads) -> Any | None: """Load a value from the given file path.""" def _load_from_disk(file_path: pathlib.Path) -> Any | None: if not file_path.exists(): return None with open(file_path, "rb") as f: data = f.read() return deserialize_fn(data) return await asyncio.to_thread(_load_from_disk, file_path) Python-roborock-python-roborock-d6da2db/roborock/devices/rpc/000077500000000000000000000000001513363643200245525ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/roborock/devices/rpc/__init__.py000066400000000000000000000010611513363643200266610ustar00rootroot00000000000000"""Module for sending device specific commands to Roborock devices. This module provides a application-level interface for sending commands to Roborock devices. These modules can be used by traits (higher level APIs) to send commands. Each module may contain details that are common across all traits, and may depend on the transport level modules (e.g. MQTT, Local device) for issuing the commands. The lowest level protocol encoding is handled in `roborock.protocols` which have no dependencies on the transport level modules. """ __all__: list[str] = [] Python-roborock-python-roborock-d6da2db/roborock/devices/rpc/a01_channel.py000066400000000000000000000064261513363643200272050ustar00rootroot00000000000000"""Thin wrapper around the MQTT channel for Roborock A01 devices.""" import asyncio import logging from collections.abc import Callable from typing import Any, overload from roborock.devices.transport.mqtt_channel import MqttChannel from roborock.exceptions import RoborockException from roborock.protocols.a01_protocol import ( decode_rpc_response, encode_mqtt_payload, ) from roborock.roborock_message import ( RoborockDyadDataProtocol, RoborockMessage, RoborockZeoProtocol, ) _LOGGER = logging.getLogger(__name__) _TIMEOUT = 10.0 # Both RoborockDyadDataProtocol and RoborockZeoProtocol have the same # value for ID_QUERY _ID_QUERY = int(RoborockDyadDataProtocol.ID_QUERY) @overload async def send_decoded_command( mqtt_channel: MqttChannel, params: dict[RoborockDyadDataProtocol, Any], value_encoder: Callable[[Any], Any] | None = None, ) -> dict[RoborockDyadDataProtocol, Any]: ... @overload async def send_decoded_command( mqtt_channel: MqttChannel, params: dict[RoborockZeoProtocol, Any], value_encoder: Callable[[Any], Any] | None = None, ) -> dict[RoborockZeoProtocol, Any]: ... async def send_decoded_command( mqtt_channel: MqttChannel, params: dict[RoborockDyadDataProtocol, Any] | dict[RoborockZeoProtocol, Any], value_encoder: Callable[[Any], Any] | None = None, ) -> dict[RoborockDyadDataProtocol, Any] | dict[RoborockZeoProtocol, Any]: """Send a command on the MQTT channel and get a decoded response.""" _LOGGER.debug("Sending MQTT command: %s", params) roborock_message = encode_mqtt_payload(params, value_encoder) # For commands that set values: send the command and do not # block waiting for a response. Queries are handled below. param_values = {int(k): v for k, v in params.items()} if not (query_values := param_values.get(_ID_QUERY)): await mqtt_channel.publish(roborock_message) return {} # Merge any results together than contain the requested data. This # does not use a future since it needs to merge results across responses. # This could be simplified if we can assume there is a single response. finished = asyncio.Event() result: dict[int, Any] = {} def find_response(response_message: RoborockMessage) -> None: """Handle incoming messages and resolve the future.""" try: decoded = decode_rpc_response(response_message) except RoborockException as ex: _LOGGER.info("Failed to decode a01 message: %s: %s", response_message, ex) return for key, value in decoded.items(): if key in query_values: result[key] = value if len(result) != len(query_values): _LOGGER.debug("Incomplete query response: %s != %s", result, query_values) return _LOGGER.debug("Received query response: %s", result) if not finished.is_set(): finished.set() unsub = await mqtt_channel.subscribe(find_response) try: await mqtt_channel.publish(roborock_message) try: await asyncio.wait_for(finished.wait(), timeout=_TIMEOUT) except TimeoutError as ex: raise RoborockException(f"Command timed out after {_TIMEOUT}s") from ex finally: unsub() return result # type: ignore[return-value] Python-roborock-python-roborock-d6da2db/roborock/devices/rpc/b01_q10_channel.py000066400000000000000000000021331513363643200276560ustar00rootroot00000000000000"""Thin wrapper around the MQTT channel for Roborock B01 Q10 devices.""" from __future__ import annotations import logging from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP from roborock.devices.transport.mqtt_channel import MqttChannel from roborock.exceptions import RoborockException from roborock.protocols.b01_q10_protocol import ( ParamsType, encode_mqtt_payload, ) _LOGGER = logging.getLogger(__name__) async def send_command( mqtt_channel: MqttChannel, command: B01_Q10_DP, params: ParamsType, ) -> None: """Send a command on the MQTT channel, without waiting for a response""" _LOGGER.debug("Sending B01 MQTT command: cmd=%s params=%s", command, params) roborock_message = encode_mqtt_payload(command, params) _LOGGER.debug("Sending MQTT message: %s", roborock_message) try: await mqtt_channel.publish(roborock_message) except RoborockException as ex: _LOGGER.debug( "Error sending B01 decoded command (method=%s params=%s): %s", command, params, ex, ) raise Python-roborock-python-roborock-d6da2db/roborock/devices/rpc/b01_q7_channel.py000066400000000000000000000074351513363643200276160ustar00rootroot00000000000000"""Thin wrapper around the MQTT channel for Roborock B01 Q7 devices.""" from __future__ import annotations import asyncio import json import logging from typing import Any from roborock.devices.transport.mqtt_channel import MqttChannel from roborock.exceptions import RoborockException from roborock.protocols.b01_q7_protocol import ( Q7RequestMessage, decode_rpc_response, encode_mqtt_payload, ) from roborock.roborock_message import RoborockMessage _LOGGER = logging.getLogger(__name__) _TIMEOUT = 10.0 async def send_decoded_command( mqtt_channel: MqttChannel, request_message: Q7RequestMessage, ) -> dict[str, Any] | None: """Send a command on the MQTT channel and get a decoded response.""" _LOGGER.debug("Sending B01 MQTT command: %s", request_message) roborock_message = encode_mqtt_payload(request_message) future: asyncio.Future[Any] = asyncio.get_running_loop().create_future() def find_response(response_message: RoborockMessage) -> None: """Handle incoming messages and resolve the future.""" try: decoded_dps = decode_rpc_response(response_message) except RoborockException as ex: _LOGGER.debug( "Failed to decode B01 RPC response (expecting method=%s msg_id=%s): %s: %s", request_message.command, request_message.msg_id, response_message, ex, ) return for dps_value in decoded_dps.values(): # valid responses are JSON strings wrapped in the dps value if not isinstance(dps_value, str): _LOGGER.debug("Received unexpected response: %s", dps_value) continue try: inner = json.loads(dps_value) except (json.JSONDecodeError, TypeError): _LOGGER.debug("Received unexpected response: %s", dps_value) continue if isinstance(inner, dict) and inner.get("msgId") == str(request_message.msg_id): _LOGGER.debug("Received query response: %s", inner) # Check for error code (0 = success, non-zero = error) code = inner.get("code", 0) if code != 0: error_msg = f"B01 command failed with code {code} ({request_message})" _LOGGER.debug("B01 error response: %s", error_msg) if not future.done(): future.set_exception(RoborockException(error_msg)) return data = inner.get("data") # All get commands should be dicts if request_message.command.endswith(".get") and not isinstance(data, dict): if not future.done(): future.set_exception( RoborockException(f"Unexpected data type for response {data} ({request_message})") ) return if not future.done(): future.set_result(data) unsub = await mqtt_channel.subscribe(find_response) _LOGGER.debug("Sending MQTT message: %s", roborock_message) try: await mqtt_channel.publish(roborock_message) return await asyncio.wait_for(future, timeout=_TIMEOUT) except TimeoutError as ex: raise RoborockException(f"B01 command timed out after {_TIMEOUT}s ({request_message})") from ex except RoborockException as ex: _LOGGER.warning( "Error sending B01 decoded command (%ss): %s", request_message, ex, ) raise except Exception as ex: _LOGGER.exception( "Error sending B01 decoded command (%ss): %s", request_message, ex, ) raise finally: unsub() Python-roborock-python-roborock-d6da2db/roborock/devices/rpc/v1_channel.py000066400000000000000000000453001513363643200271440ustar00rootroot00000000000000"""V1 Channel for Roborock devices. This module provides a unified channel interface for V1 protocol devices, handling both MQTT and local connections with automatic fallback. """ import asyncio import datetime import logging from collections.abc import Callable from dataclasses import dataclass from typing import Any, TypeVar from roborock.data import HomeDataDevice, NetworkInfo, RoborockBase, UserData from roborock.devices.cache import DeviceCache from roborock.devices.transport.channel import Channel from roborock.devices.transport.local_channel import LocalChannel, LocalSession, create_local_session from roborock.devices.transport.mqtt_channel import MqttChannel from roborock.exceptions import RoborockException from roborock.mqtt.health_manager import HealthManager from roborock.mqtt.session import MqttParams, MqttSession from roborock.protocols.v1_protocol import ( CommandType, MapResponse, ParamsType, RequestMessage, ResponseData, ResponseMessage, SecurityData, V1RpcChannel, create_map_response_decoder, create_security_data, decode_rpc_response, ) from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol from roborock.roborock_typing import RoborockCommand from roborock.util import RoborockLoggerAdapter _LOGGER = logging.getLogger(__name__) __all__ = [ "create_v1_channel", ] _T = TypeVar("_T", bound=RoborockBase) _TIMEOUT = 10.0 # Exponential backoff parameters for reconnecting to local MIN_RECONNECT_INTERVAL = datetime.timedelta(minutes=1) MAX_RECONNECT_INTERVAL = datetime.timedelta(minutes=10) RECONNECT_MULTIPLIER = 1.5 # After this many hours, the network info is refreshed NETWORK_INFO_REFRESH_INTERVAL = datetime.timedelta(hours=12) # Interval to check that the local connection is healthy LOCAL_CONNECTION_CHECK_INTERVAL = datetime.timedelta(seconds=15) @dataclass(frozen=True) class RpcStrategy: """Strategy for encoding/sending/decoding RPC commands.""" name: str # For debug logging channel: LocalChannel | MqttChannel encoder: Callable[[RequestMessage], RoborockMessage] decoder: Callable[[RoborockMessage], ResponseMessage | MapResponse | None] health_manager: HealthManager | None = None class RpcChannel(V1RpcChannel): """Provides an RPC interface around a pub/sub transport channel.""" def __init__(self, rpc_strategies_cb: Callable[[], list[RpcStrategy]], logger: RoborockLoggerAdapter) -> None: """Initialize the RpcChannel with an ordered list of strategies.""" self._rpc_strategies_cb = rpc_strategies_cb self._logger = logger async def send_command( self, method: CommandType, *, response_type: type[_T] | None = None, params: ParamsType = None, ) -> _T | Any: """Send a command and return either a decoded or parsed response.""" request = RequestMessage(method, params=params) # Try each channel in order until one succeeds last_exception = None for strategy in self._rpc_strategies_cb(): try: decoded_response = await self._send_rpc(strategy, request, self._logger) except RoborockException as e: self._logger.debug("Command %s failed on %s channel: %s", method, strategy.name, e) last_exception = e except Exception as e: self._logger.exception("Unexpected error sending command %s on %s channel", method, strategy.name) last_exception = RoborockException(f"Unexpected error: {e}") else: if response_type is not None: if not isinstance(decoded_response, dict): raise RoborockException( f"Expected dict response to parse {response_type.__name__}, got {type(decoded_response)}" ) return response_type.from_dict(decoded_response) return decoded_response raise last_exception or RoborockException("No available connection to send command") @staticmethod async def _send_rpc( strategy: RpcStrategy, request: RequestMessage, logger: RoborockLoggerAdapter ) -> ResponseData | bytes: """Send a command and return a decoded response type. This provides an RPC interface over a given channel strategy. The device channel only supports publish and subscribe, so this function handles associating requests with their corresponding responses. """ future: asyncio.Future[ResponseData | bytes] = asyncio.Future() logger.debug( "Sending command (%s, request_id=%s): %s, params=%s", strategy.name, request.request_id, request.method, request.params, ) message = strategy.encoder(request) def find_response(response_message: RoborockMessage) -> None: try: decoded = strategy.decoder(response_message) except RoborockException as ex: logger.debug("Exception while decoding message (%s): %s", response_message, ex) return if decoded is None: return logger.debug("Received response (%s, request_id=%s)", strategy.name, decoded.request_id) if decoded.request_id == request.request_id: if isinstance(decoded, ResponseMessage) and decoded.api_error: future.set_exception(decoded.api_error) else: future.set_result(decoded.data) unsub = await strategy.channel.subscribe(find_response) try: await strategy.channel.publish(message) result = await asyncio.wait_for(future, timeout=_TIMEOUT) except TimeoutError as ex: if strategy.health_manager: await strategy.health_manager.on_timeout() future.cancel() raise RoborockException(f"Command timed out after {_TIMEOUT}s") from ex finally: unsub() if strategy.health_manager: await strategy.health_manager.on_success() return result class V1Channel(Channel): """Unified V1 protocol channel with automatic MQTT/local connection handling. This channel abstracts away the complexity of choosing between MQTT and local connections, and provides high-level V1 protocol methods. It automatically handles connection setup, fallback logic, and protocol encoding/decoding. """ def __init__( self, device_uid: str, security_data: SecurityData, mqtt_channel: MqttChannel, local_session: LocalSession, device_cache: DeviceCache, ) -> None: """Initialize the V1Channel.""" self._device_uid = device_uid self._logger = RoborockLoggerAdapter(duid=device_uid, logger=_LOGGER) self._security_data = security_data self._mqtt_channel = mqtt_channel self._local_session = local_session self._local_channel: LocalChannel | None = None self._mqtt_unsub: Callable[[], None] | None = None self._local_unsub: Callable[[], None] | None = None self._callback: Callable[[RoborockMessage], None] | None = None self._device_cache = device_cache self._reconnect_task: asyncio.Task[None] | None = None self._last_network_info_refresh: datetime.datetime | None = None @property def is_connected(self) -> bool: """Return whether any connection is available.""" return self.is_mqtt_connected or self.is_local_connected @property def is_local_connected(self) -> bool: """Return whether local connection is available.""" return self._local_channel is not None and self._local_channel.is_connected @property def is_mqtt_connected(self) -> bool: """Return whether MQTT connection is available.""" return self._mqtt_channel.is_connected @property def rpc_channel(self) -> V1RpcChannel: """Return the combined RPC channel that prefers local with a fallback to MQTT. The returned V1RpcChannel may be long lived and will respect the current connection state of the underlying channels. """ def rpc_strategies_cb() -> list[RpcStrategy]: strategies = [] if local_rpc_strategy := self._create_local_rpc_strategy(): strategies.append(local_rpc_strategy) strategies.append(self._create_mqtt_rpc_strategy()) return strategies return RpcChannel(rpc_strategies_cb, self._logger) @property def mqtt_rpc_channel(self) -> V1RpcChannel: """Return the MQTT-only RPC channel. The returned V1RpcChannel may be long lived and will respect the current connection state of the underlying channels. """ return RpcChannel(lambda: [self._create_mqtt_rpc_strategy()], self._logger) @property def map_rpc_channel(self) -> V1RpcChannel: """Return the map RPC channel used for fetching map content.""" decoder = create_map_response_decoder(security_data=self._security_data) return RpcChannel(lambda: [self._create_mqtt_rpc_strategy(decoder)], self._logger) def _create_local_rpc_strategy(self) -> RpcStrategy | None: """Create the RPC strategy for local transport.""" if self._local_channel is None or not self.is_local_connected: return None return RpcStrategy( name="local", channel=self._local_channel, encoder=self._local_encoder, decoder=decode_rpc_response, ) def _local_encoder(self, x: RequestMessage) -> RoborockMessage: """Encode a request message for local transport. This will read the current local channel's protocol version which changes as the protocol version is discovered. """ if self._local_channel is None: raise ValueError("Local channel unavailable for encoding") return x.encode_message( RoborockMessageProtocol.GENERAL_REQUEST, version=self._local_channel.protocol_version, ) def _create_mqtt_rpc_strategy(self, decoder: Callable[[RoborockMessage], Any] = decode_rpc_response) -> RpcStrategy: """Create the RPC strategy for MQTT transport with optional custom decoder.""" return RpcStrategy( name="mqtt", channel=self._mqtt_channel, encoder=lambda x: x.encode_message( RoborockMessageProtocol.RPC_REQUEST, security_data=self._security_data, ), decoder=decoder, health_manager=self._mqtt_channel.health_manager, ) async def subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]: """Subscribe to all messages from the device. This will first attempt to establish a local connection to the device using cached network information if available. If that fails, it will fall back to using the MQTT connection. A background task will be started to monitor and maintain the local connection, attempting to reconnect as needed. Args: callback: Callback to invoke for each received message. Returns: Unsubscribe function to stop receiving messages and clean up resources. """ if self._callback is not None: raise ValueError("Only one subscription allowed at a time") # Make an initial, optimistic attempt to connect to local with the # cache. The cache information will be refreshed by the background task. try: await self._local_connect(prefer_cache=True) except RoborockException as err: self._logger.debug("First local connection attempt failed, will retry: %s", err) # Start a background task to manage the local connection health. This # happens independent of whether we were able to connect locally now. if self._reconnect_task is None: loop = asyncio.get_running_loop() self._reconnect_task = loop.create_task(self._background_reconnect()) if not self.is_local_connected: # We were not able to connect locally, so fallback to MQTT and at least # establish that connection explicitly. If this fails then raise an # error and let the caller know we failed to subscribe. self._mqtt_unsub = await self._mqtt_channel.subscribe(self._on_mqtt_message) self._logger.debug("V1Channel connected to device via MQTT") def unsub() -> None: """Unsubscribe from all messages.""" if self._reconnect_task: self._reconnect_task.cancel() self._reconnect_task = None if self._mqtt_unsub: self._mqtt_unsub() self._mqtt_unsub = None if self._local_unsub: self._local_unsub() self._local_unsub = None self._logger.debug("Unsubscribed from device") self._callback = callback return unsub async def _get_networking_info(self, *, prefer_cache: bool = True) -> NetworkInfo: """Retrieve networking information for the device. This is a cloud only command used to get the local device's IP address. """ device_cache_data = await self._device_cache.get() if prefer_cache and device_cache_data.network_info: self._logger.debug("Using cached network info") return device_cache_data.network_info try: network_info = await self.mqtt_rpc_channel.send_command( RoborockCommand.GET_NETWORK_INFO, response_type=NetworkInfo ) except RoborockException as e: self._logger.debug("Error fetching network info for device") if device_cache_data.network_info: self._logger.debug("Falling back to cached network info after error") return device_cache_data.network_info raise RoborockException(f"Network info failed for device {self._device_uid}") from e self._logger.debug("Network info for device: %s", network_info) self._last_network_info_refresh = datetime.datetime.now(datetime.UTC) device_cache_data = await self._device_cache.get() device_cache_data.network_info = network_info await self._device_cache.set(device_cache_data) return network_info async def _local_connect(self, *, prefer_cache: bool = True) -> None: """Set up local connection if possible.""" self._logger.debug("Attempting to connect to local channel (prefer_cache=%s)", prefer_cache) networking_info = await self._get_networking_info(prefer_cache=prefer_cache) host = networking_info.ip self._logger.debug("Connecting to local channel at %s", host) # Create a new local channel and connect local_channel = self._local_session(host) try: await local_channel.connect() except RoborockException as e: raise RoborockException(f"Error connecting to local device {self._device_uid}: {e}") from e # Wire up the new channel self._local_channel = local_channel self._local_unsub = await self._local_channel.subscribe(self._on_local_message) self._logger.info("Connected to local channel successfully") async def _background_reconnect(self) -> None: """Task to run in the background to manage the local connection.""" self._logger.debug("Starting background task to manage local connection") reconnect_backoff = MIN_RECONNECT_INTERVAL local_connect_failures = 0 while True: try: if self.is_local_connected: await asyncio.sleep(LOCAL_CONNECTION_CHECK_INTERVAL.total_seconds()) continue # Not connected, so wait with backoff before trying to connect. # The first time through, we don't sleep, we just try to connect. local_connect_failures += 1 if local_connect_failures > 1: await asyncio.sleep(reconnect_backoff.total_seconds()) reconnect_backoff = min(reconnect_backoff * RECONNECT_MULTIPLIER, MAX_RECONNECT_INTERVAL) use_cache = self._should_use_cache(local_connect_failures) await self._local_connect(prefer_cache=use_cache) # Reset backoff and failures on success reconnect_backoff = MIN_RECONNECT_INTERVAL local_connect_failures = 0 except asyncio.CancelledError: self._logger.debug("Background reconnect task cancelled") if self._local_channel: self._local_channel.close() return except RoborockException as err: self._logger.debug("Background reconnect failed: %s", err) except Exception: self._logger.exception("Unhandled exception in background reconnect task") def _should_use_cache(self, local_connect_failures: int) -> bool: """Determine whether to use cached network info on retries. On the first retry we'll avoid the cache to handle the case where the network ip may have recently changed. Otherwise, use the cache if available then expire at some point. """ if local_connect_failures == 1: return False elif self._last_network_info_refresh and ( datetime.datetime.now(datetime.UTC) - self._last_network_info_refresh > NETWORK_INFO_REFRESH_INTERVAL ): return False return True def _on_mqtt_message(self, message: RoborockMessage) -> None: """Handle incoming MQTT messages.""" self._logger.debug("V1Channel received MQTT message: %s", message) if self._callback: self._callback(message) def _on_local_message(self, message: RoborockMessage) -> None: """Handle incoming local messages.""" self._logger.debug("V1Channel received local message: %s", message) if self._callback: self._callback(message) def create_v1_channel( user_data: UserData, mqtt_params: MqttParams, mqtt_session: MqttSession, device: HomeDataDevice, device_cache: DeviceCache, ) -> V1Channel: """Create a V1Channel for the given device.""" security_data = create_security_data(user_data.rriot) mqtt_channel = MqttChannel(mqtt_session, device.duid, device.local_key, user_data.rriot, mqtt_params) local_session = create_local_session(device.local_key, device.duid) return V1Channel( device.duid, security_data, mqtt_channel, local_session=local_session, device_cache=device_cache, ) Python-roborock-python-roborock-d6da2db/roborock/devices/traits/000077500000000000000000000000001513363643200252745ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/roborock/devices/traits/__init__.py000066400000000000000000000013751513363643200274130ustar00rootroot00000000000000"""Module for device traits. This package contains the trait definitions for different device protocols supported by Roborock devices. Submodules ---------- * `v1`: Contains traits for standard Roborock vacuums (e.g., S-series, Q-series). These devices use the V1 protocol and have rich feature sets split into granular traits (e.g., `StatusTrait`, `ConsumableTrait`). * `a01`: Contains APIs for A01 protocol devices, such as the Dyad (wet/dry vacuum) and Zeo (washing machine). These devices use a different communication structure. * `b01`: Contains APIs for B01 protocol devices. """ from abc import ABC __all__ = [ "Trait", "traits_mixin", "v1", "a01", "b01", ] class Trait(ABC): """Base class for all traits.""" Python-roborock-python-roborock-d6da2db/roborock/devices/traits/a01/000077500000000000000000000000001513363643200256555ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/roborock/devices/traits/a01/__init__.py000066400000000000000000000176501513363643200277770ustar00rootroot00000000000000"""Create traits for A01 devices. This module provides the API implementations for A01 protocol devices, which include Dyad (Wet/Dry Vacuums) and Zeo (Washing Machines). Using A01 APIs -------------- A01 devices expose a single API object that handles all device interactions. This API is available on the device instance (typically via `device.a01_properties`). The API provides two main methods: 1. **query_values(protocols)**: Fetches current state for specific data points. You must pass a list of protocol enums (e.g. `RoborockDyadDataProtocol` or `RoborockZeoProtocol`) to request specific data. 2. **set_value(protocol, value)**: Sends a command to the device to change a setting or perform an action. Note that these APIs fetch data directly from the device upon request and do not cache state internally. """ import json from collections.abc import Callable from datetime import time from typing import Any from roborock.data import DyadProductInfo, DyadSndState, HomeDataProduct, RoborockCategory from roborock.data.dyad.dyad_code_mappings import ( DyadBrushSpeed, DyadCleanMode, DyadError, DyadSelfCleanLevel, DyadSelfCleanMode, DyadSuction, DyadWarmLevel, DyadWaterLevel, RoborockDyadStateCode, ) from roborock.data.zeo.zeo_code_mappings import ( ZeoDetergentType, ZeoDryingMode, ZeoError, ZeoMode, ZeoProgram, ZeoRinse, ZeoSoftenerType, ZeoSpin, ZeoState, ZeoTemperature, ) from roborock.devices.rpc.a01_channel import send_decoded_command from roborock.devices.traits import Trait from roborock.devices.transport.mqtt_channel import MqttChannel from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol __init__ = [ "DyadApi", "ZeoApi", ] DYAD_PROTOCOL_ENTRIES: dict[RoborockDyadDataProtocol, Callable] = { RoborockDyadDataProtocol.STATUS: lambda val: RoborockDyadStateCode(val).name, RoborockDyadDataProtocol.SELF_CLEAN_MODE: lambda val: DyadSelfCleanMode(val).name, RoborockDyadDataProtocol.SELF_CLEAN_LEVEL: lambda val: DyadSelfCleanLevel(val).name, RoborockDyadDataProtocol.WARM_LEVEL: lambda val: DyadWarmLevel(val).name, RoborockDyadDataProtocol.CLEAN_MODE: lambda val: DyadCleanMode(val).name, RoborockDyadDataProtocol.SUCTION: lambda val: DyadSuction(val).name, RoborockDyadDataProtocol.WATER_LEVEL: lambda val: DyadWaterLevel(val).name, RoborockDyadDataProtocol.BRUSH_SPEED: lambda val: DyadBrushSpeed(val).name, RoborockDyadDataProtocol.POWER: lambda val: int(val), RoborockDyadDataProtocol.AUTO_DRY: lambda val: bool(val), RoborockDyadDataProtocol.MESH_LEFT: lambda val: int(360000 - val * 60), RoborockDyadDataProtocol.BRUSH_LEFT: lambda val: int(360000 - val * 60), RoborockDyadDataProtocol.ERROR: lambda val: DyadError(val).name, RoborockDyadDataProtocol.VOLUME_SET: lambda val: int(val), RoborockDyadDataProtocol.STAND_LOCK_AUTO_RUN: lambda val: bool(val), RoborockDyadDataProtocol.AUTO_DRY_MODE: lambda val: bool(val), RoborockDyadDataProtocol.SILENT_DRY_DURATION: lambda val: int(val), # in minutes RoborockDyadDataProtocol.SILENT_MODE: lambda val: bool(val), RoborockDyadDataProtocol.SILENT_MODE_START_TIME: lambda val: time( hour=int(val / 60), minute=val % 60 ), # in minutes since 00:00 RoborockDyadDataProtocol.SILENT_MODE_END_TIME: lambda val: time( hour=int(val / 60), minute=val % 60 ), # in minutes since 00:00 RoborockDyadDataProtocol.RECENT_RUN_TIME: lambda val: [ int(v) for v in val.split(",") ], # minutes of cleaning in past few days. RoborockDyadDataProtocol.TOTAL_RUN_TIME: lambda val: int(val), RoborockDyadDataProtocol.SND_STATE: lambda val: DyadSndState.from_dict(val), RoborockDyadDataProtocol.PRODUCT_INFO: lambda val: DyadProductInfo.from_dict(val), } ZEO_PROTOCOL_ENTRIES: dict[RoborockZeoProtocol, Callable] = { # read-only RoborockZeoProtocol.STATE: lambda val: ZeoState(val).name, RoborockZeoProtocol.COUNTDOWN: lambda val: int(val), RoborockZeoProtocol.WASHING_LEFT: lambda val: int(val), RoborockZeoProtocol.ERROR: lambda val: ZeoError(val).name, RoborockZeoProtocol.TIMES_AFTER_CLEAN: lambda val: int(val), RoborockZeoProtocol.DETERGENT_EMPTY: lambda val: bool(val), RoborockZeoProtocol.SOFTENER_EMPTY: lambda val: bool(val), # read-write RoborockZeoProtocol.MODE: lambda val: ZeoMode(val).name, RoborockZeoProtocol.PROGRAM: lambda val: ZeoProgram(val).name, RoborockZeoProtocol.TEMP: lambda val: ZeoTemperature(val).name, RoborockZeoProtocol.RINSE_TIMES: lambda val: ZeoRinse(val).name, RoborockZeoProtocol.SPIN_LEVEL: lambda val: ZeoSpin(val).name, RoborockZeoProtocol.DRYING_MODE: lambda val: ZeoDryingMode(val).name, RoborockZeoProtocol.DETERGENT_TYPE: lambda val: ZeoDetergentType(val).name, RoborockZeoProtocol.SOFTENER_TYPE: lambda val: ZeoSoftenerType(val).name, RoborockZeoProtocol.SOUND_SET: lambda val: bool(val), } def convert_dyad_value(protocol_value: RoborockDyadDataProtocol, value: Any) -> Any: """Convert a dyad protocol value to its corresponding type.""" if (converter := DYAD_PROTOCOL_ENTRIES.get(protocol_value)) is not None: try: return converter(value) except (ValueError, TypeError): return None return None def convert_zeo_value(protocol_value: RoborockZeoProtocol, value: Any) -> Any: """Convert a zeo protocol value to its corresponding type.""" if (converter := ZEO_PROTOCOL_ENTRIES.get(protocol_value)) is not None: try: return converter(value) except (ValueError, TypeError): return None return None class DyadApi(Trait): """API for interacting with Dyad devices.""" def __init__(self, channel: MqttChannel) -> None: """Initialize the Dyad API.""" self._channel = channel async def query_values(self, protocols: list[RoborockDyadDataProtocol]) -> dict[RoborockDyadDataProtocol, Any]: """Query the device for the values of the given Dyad protocols.""" response = await send_decoded_command( self._channel, {RoborockDyadDataProtocol.ID_QUERY: protocols}, value_encoder=json.dumps, ) return {protocol: convert_dyad_value(protocol, response.get(protocol)) for protocol in protocols} async def set_value(self, protocol: RoborockDyadDataProtocol, value: Any) -> dict[RoborockDyadDataProtocol, Any]: """Set a value for a specific protocol on the device.""" params = {protocol: value} return await send_decoded_command(self._channel, params) class ZeoApi(Trait): """API for interacting with Zeo devices.""" name = "zeo" def __init__(self, channel: MqttChannel) -> None: """Initialize the Zeo API.""" self._channel = channel async def query_values(self, protocols: list[RoborockZeoProtocol]) -> dict[RoborockZeoProtocol, Any]: """Query the device for the values of the given protocols.""" response = await send_decoded_command( self._channel, {RoborockZeoProtocol.ID_QUERY: protocols}, value_encoder=json.dumps, ) return {protocol: convert_zeo_value(protocol, response.get(protocol)) for protocol in protocols} async def set_value(self, protocol: RoborockZeoProtocol, value: Any) -> dict[RoborockZeoProtocol, Any]: """Set a value for a specific protocol on the device.""" params = {protocol: value} return await send_decoded_command(self._channel, params, value_encoder=lambda x: x) def create(product: HomeDataProduct, mqtt_channel: MqttChannel) -> DyadApi | ZeoApi: """Create traits for A01 devices.""" match product.category: case RoborockCategory.WET_DRY_VAC: return DyadApi(mqtt_channel) case RoborockCategory.WASHING_MACHINE: return ZeoApi(mqtt_channel) case _: raise NotImplementedError(f"Unsupported category {product.category}") Python-roborock-python-roborock-d6da2db/roborock/devices/traits/b01/000077500000000000000000000000001513363643200256565ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/roborock/devices/traits/b01/__init__.py000066400000000000000000000003121513363643200277630ustar00rootroot00000000000000"""Traits for B01 devices.""" from . import q7, q10 from .q7 import Q7PropertiesApi from .q10 import Q10PropertiesApi __all__ = [ "Q7PropertiesApi", "Q10PropertiesApi", "q7", "q10", ] Python-roborock-python-roborock-d6da2db/roborock/devices/traits/b01/q10/000077500000000000000000000000001513363643200262575ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/roborock/devices/traits/b01/q10/__init__.py000066400000000000000000000013521513363643200303710ustar00rootroot00000000000000"""Traits for Q10 B01 devices.""" from typing import Any from roborock.devices.rpc.b01_q7_channel import send_decoded_command from roborock.devices.traits import Trait from roborock.devices.transport.mqtt_channel import MqttChannel from .command import CommandTrait __all__ = [ "Q10PropertiesApi", ] class Q10PropertiesApi(Trait): """API for interacting with B01 devices.""" command: CommandTrait """Trait for sending commands to Q10 devices.""" def __init__(self, channel: MqttChannel) -> None: """Initialize the B01Props API.""" self.command = CommandTrait(channel) def create(channel: MqttChannel) -> Q10PropertiesApi: """Create traits for B01 devices.""" return Q10PropertiesApi(channel) Python-roborock-python-roborock-d6da2db/roborock/devices/traits/b01/q10/command.py000066400000000000000000000024321513363643200302500ustar00rootroot00000000000000from typing import Any from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP from roborock.devices.rpc.b01_q10_channel import send_command from roborock.devices.transport.mqtt_channel import MqttChannel from roborock.protocols.b01_q10_protocol import ParamsType class CommandTrait: """Trait for sending commands to Q10 Roborock devices. This trait allows sending raw commands directly to the device. It is particularly useful for accessing features that do not have their own traits. Generally it is preferred to use specific traits for device functionality when available. """ def __init__(self, channel: MqttChannel) -> None: """Initialize the CommandTrait.""" self._channel = channel async def send(self, command: B01_Q10_DP, params: ParamsType = None) -> Any: """Send a command to the device. Sending a raw command to the device using this method does not update the internal state of any other traits. It is the responsibility of the caller to ensure that any traits affected by the command are refreshed as needed. """ if not self._channel: raise ValueError("Device trait in invalid state") return await send_command(self._channel, command, params=params) Python-roborock-python-roborock-d6da2db/roborock/devices/traits/b01/q7/000077500000000000000000000000001513363643200262055ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/roborock/devices/traits/b01/q7/__init__.py000066400000000000000000000074041513363643200303230ustar00rootroot00000000000000"""Traits for Q7 B01 devices. Potentially other devices may fall into this category in the future.""" from typing import Any from roborock import B01Props from roborock.data.b01_q7.b01_q7_code_mappings import ( CleanTaskTypeMapping, SCDeviceCleanParam, SCWindMapping, WaterLevelMapping, ) from roborock.devices.rpc.b01_q7_channel import send_decoded_command from roborock.devices.traits import Trait from roborock.devices.transport.mqtt_channel import MqttChannel from roborock.protocols.b01_q7_protocol import CommandType, ParamsType, Q7RequestMessage from roborock.roborock_message import RoborockB01Props from roborock.roborock_typing import RoborockB01Q7Methods __all__ = [ "Q7PropertiesApi", ] class Q7PropertiesApi(Trait): """API for interacting with B01 devices.""" def __init__(self, channel: MqttChannel) -> None: """Initialize the B01Props API.""" self._channel = channel async def query_values(self, props: list[RoborockB01Props]) -> B01Props | None: """Query the device for the values of the given Q7 properties.""" result = await self.send( RoborockB01Q7Methods.GET_PROP, {"property": props}, ) if not isinstance(result, dict): raise TypeError(f"Unexpected response type for GET_PROP: {type(result).__name__}: {result!r}") return B01Props.from_dict(result) async def set_prop(self, prop: RoborockB01Props, value: Any) -> None: """Set a property on the device.""" await self.send( command=RoborockB01Q7Methods.SET_PROP, params={prop: value}, ) async def set_fan_speed(self, fan_speed: SCWindMapping) -> None: """Set the fan speed (wind).""" await self.set_prop(RoborockB01Props.WIND, fan_speed.code) async def set_water_level(self, water_level: WaterLevelMapping) -> None: """Set the water level (water).""" await self.set_prop(RoborockB01Props.WATER, water_level.code) async def start_clean(self) -> None: """Start cleaning.""" await self.send( command=RoborockB01Q7Methods.SET_ROOM_CLEAN, params={ "clean_type": CleanTaskTypeMapping.ALL.code, "ctrl_value": SCDeviceCleanParam.START.code, "room_ids": [], }, ) async def pause_clean(self) -> None: """Pause cleaning.""" await self.send( command=RoborockB01Q7Methods.SET_ROOM_CLEAN, params={ "clean_type": CleanTaskTypeMapping.ALL.code, "ctrl_value": SCDeviceCleanParam.PAUSE.code, "room_ids": [], }, ) async def stop_clean(self) -> None: """Stop cleaning.""" await self.send( command=RoborockB01Q7Methods.SET_ROOM_CLEAN, params={ "clean_type": CleanTaskTypeMapping.ALL.code, "ctrl_value": SCDeviceCleanParam.STOP.code, "room_ids": [], }, ) async def return_to_dock(self) -> None: """Return to dock.""" await self.send( command=RoborockB01Q7Methods.START_RECHARGE, params={}, ) async def find_me(self) -> None: """Locate the robot.""" await self.send( command=RoborockB01Q7Methods.FIND_DEVICE, params={}, ) async def send(self, command: CommandType, params: ParamsType) -> Any: """Send a command to the device.""" return await send_decoded_command( self._channel, Q7RequestMessage(dps=10000, command=command, params=params), ) def create(channel: MqttChannel) -> Q7PropertiesApi: """Create traits for B01 devices.""" return Q7PropertiesApi(channel) Python-roborock-python-roborock-d6da2db/roborock/devices/traits/traits_mixin.py000066400000000000000000000040541513363643200303630ustar00rootroot00000000000000"""Holds device traits mixin and related code. This holds the TraitsMixin class, which is used to provide accessors for various device traits. Each trait is a class that encapsulates a specific set of functionality for a device, such as controlling a vacuum or a mop. The TraitsMixin holds traits across all protocol types. A trait is supported if it is non-None. """ from dataclasses import dataclass, fields from typing import get_args, get_origin from . import Trait, a01, b01, v1 __all__ = [ "TraitsMixin", ] @dataclass(init=False) class TraitsMixin: """Mixin to provide trait accessors.""" v1_properties: v1.PropertiesApi | None = None """V1 properties trait, if supported.""" dyad: a01.DyadApi | None = None """Dyad API, if supported.""" zeo: a01.ZeoApi | None = None """Zeo API, if supported.""" b01_q7_properties: b01.Q7PropertiesApi | None = None """B01 Q7 properties trait, if supported.""" b01_q10_properties: b01.Q10PropertiesApi | None = None """B01 Q10 properties trait, if supported.""" def __init__(self, trait: Trait) -> None: """Initialize the TraitsMixin with the given trait. This will populate the appropriate trait attributes based on the types of the traits provided. """ for item in fields(self): trait_type = _get_trait_type(item) if trait_type is type(trait): setattr(self, item.name, trait) break def _get_trait_type(item) -> type[Trait]: """Get the trait type from a dataclass field.""" if get_origin(item.type) is None: raise ValueError(f"Trait {item.name} is not an optional type") if (args := get_args(item.type)) is None: raise ValueError(f"Trait {item.name} is not an optional type") if len(args) != 2 or args[1] is not type(None): raise ValueError(f"Trait {item.name} is not an optional type") trait_type = args[0] if not issubclass(trait_type, Trait): raise ValueError(f"Trait {item.name} is not a Trait subclass") return trait_type Python-roborock-python-roborock-d6da2db/roborock/devices/traits/v1/000077500000000000000000000000001513363643200256225ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/roborock/devices/traits/v1/__init__.py000066400000000000000000000324331513363643200277400ustar00rootroot00000000000000"""Create traits for V1 devices. Traits are modular components that encapsulate specific features of a Roborock device. This module provides a factory function to create and initialize the appropriate traits for V1 devices based on their capabilities. Using Traits ------------ Traits are accessed via the `v1_properties` attribute on a device. Each trait represents a specific capability, such as `status`, `consumables`, or `rooms`. Traits serve two main purposes: 1. **State**: Traits are dataclasses that hold the current state of the device feature. You can access attributes directly (e.g., `device.v1_properties.status.battery`). 2. **Commands**: Traits provide methods to control the device. For example, `device.v1_properties.volume.set_volume()`. Additionally, the `command` trait provides a generic way to send any command to the device (e.g. `device.v1_properties.command.send("app_start")`). This is often used for basic cleaning operations like starting, stopping, or docking the vacuum. Most traits have a `refresh()` method that must be called to update their state from the device. The state is not updated automatically in real-time unless specifically implemented by the trait or via polling. Adding New Traits ----------------- When adding a new trait, the most common pattern is to subclass `V1TraitMixin` and a `RoborockBase` dataclass. You must define a `command` class variable that specifies the `RoborockCommand` used to fetch the trait data from the device. See `common.py` for more details on common patterns used across traits. There are some additional decorators in `common.py` that can be used to specify which RPC channel to use for the trait (standard, MQTT/cloud, or map-specific). - `@common.mqtt_rpc_channel` - Use the MQTT RPC channel for this trait. - `@common.map_rpc_channel` - Use the map RPC channel for this trait. There are also some attributes that specify device feature dependencies for optional traits: - `requires_feature` - The string name of the device feature that must be supported for this trait to be enabled. See `DeviceFeaturesTrait` for a list of available features. - `requires_dock_type` - If set, this is a function that accepts a `RoborockDockTypeCode` and returns a boolean indicating whether the trait is supported for that dock type. Additionally, DeviceFeaturesTrait has a method `is_field_supported` that is used to check individual trait field values. This is a more fine grained version to allow optional fields in a dataclass, vs the above feature checks that apply to an entire trait. The `requires_schema_code` field metadata attribute is a string of the schema code in HomeDataProduct Schema that is required for the field to be supported. """ import logging from dataclasses import dataclass, field, fields from functools import cache from typing import Any, get_args from roborock.data.containers import HomeData, HomeDataProduct, RoborockBase from roborock.data.v1.v1_code_mappings import RoborockDockTypeCode from roborock.devices.cache import DeviceCache from roborock.devices.traits import Trait from roborock.map.map_parser import MapParserConfig from roborock.protocols.v1_protocol import V1RpcChannel from roborock.web_api import UserWebApiClient from . import ( child_lock, clean_summary, command, common, consumeable, device_features, do_not_disturb, dust_collection_mode, flow_led_status, home, led_status, map_content, maps, network_info, rooms, routines, smart_wash_params, status, valley_electricity_timer, volume, wash_towel_mode, ) from .child_lock import ChildLockTrait from .clean_summary import CleanSummaryTrait from .command import CommandTrait from .common import V1TraitMixin from .consumeable import ConsumableTrait from .device_features import DeviceFeaturesTrait from .do_not_disturb import DoNotDisturbTrait from .dust_collection_mode import DustCollectionModeTrait from .flow_led_status import FlowLedStatusTrait from .home import HomeTrait from .led_status import LedStatusTrait from .map_content import MapContentTrait from .maps import MapsTrait from .network_info import NetworkInfoTrait from .rooms import RoomsTrait from .routines import RoutinesTrait from .smart_wash_params import SmartWashParamsTrait from .status import StatusTrait from .valley_electricity_timer import ValleyElectricityTimerTrait from .volume import SoundVolumeTrait from .wash_towel_mode import WashTowelModeTrait _LOGGER = logging.getLogger(__name__) __all__ = [ "PropertiesApi", "child_lock", "clean_summary", "command", "common", "consumeable", "device_features", "do_not_disturb", "dust_collection_mode", "flow_led_status", "home", "led_status", "map_content", "maps", "network_info", "rooms", "routines", "smart_wash_params", "status", "valley_electricity_timer", "volume", "wash_towel_mode", ] @dataclass class PropertiesApi(Trait): """Common properties for V1 devices. This class holds all the traits that are common across all V1 devices. """ # All v1 devices have these traits status: StatusTrait command: CommandTrait dnd: DoNotDisturbTrait clean_summary: CleanSummaryTrait sound_volume: SoundVolumeTrait rooms: RoomsTrait maps: MapsTrait map_content: MapContentTrait consumables: ConsumableTrait home: HomeTrait device_features: DeviceFeaturesTrait network_info: NetworkInfoTrait routines: RoutinesTrait # Optional features that may not be supported on all devices child_lock: ChildLockTrait | None = None led_status: LedStatusTrait | None = None flow_led_status: FlowLedStatusTrait | None = None valley_electricity_timer: ValleyElectricityTimerTrait | None = None dust_collection_mode: DustCollectionModeTrait | None = None wash_towel_mode: WashTowelModeTrait | None = None smart_wash_params: SmartWashParamsTrait | None = None def __init__( self, device_uid: str, product: HomeDataProduct, home_data: HomeData, rpc_channel: V1RpcChannel, mqtt_rpc_channel: V1RpcChannel, map_rpc_channel: V1RpcChannel, web_api: UserWebApiClient, device_cache: DeviceCache, map_parser_config: MapParserConfig | None = None, ) -> None: """Initialize the V1TraitProps.""" self._device_uid = device_uid self._rpc_channel = rpc_channel self._mqtt_rpc_channel = mqtt_rpc_channel self._map_rpc_channel = map_rpc_channel self._web_api = web_api self._device_cache = device_cache self.status = StatusTrait(product) self.consumables = ConsumableTrait() self.rooms = RoomsTrait(home_data) self.maps = MapsTrait(self.status) self.map_content = MapContentTrait(map_parser_config) self.home = HomeTrait(self.status, self.maps, self.map_content, self.rooms, self._device_cache) self.device_features = DeviceFeaturesTrait(product, self._device_cache) self.network_info = NetworkInfoTrait(device_uid, self._device_cache) self.routines = RoutinesTrait(device_uid, web_api) # Dynamically create any traits that need to be populated for item in fields(self): if (trait := getattr(self, item.name, None)) is None: # We exclude optional features and them via discover_features if (union_args := get_args(item.type)) is None or len(union_args) > 0: continue _LOGGER.debug("Trait '%s' is supported, initializing", item.name) trait = item.type() setattr(self, item.name, trait) # This is a hack to allow setting the rpc_channel on all traits. This is # used so we can preserve the dataclass behavior when the values in the # traits are updated, but still want to allow them to have a reference # to the rpc channel for sending commands. trait._rpc_channel = self._get_rpc_channel(trait) def _get_rpc_channel(self, trait: V1TraitMixin) -> V1RpcChannel: # The decorator `@common.mqtt_rpc_channel` means that the trait needs # to use the mqtt_rpc_channel (cloud only) instead of the rpc_channel (adaptive) if hasattr(trait, "mqtt_rpc_channel"): return self._mqtt_rpc_channel elif hasattr(trait, "map_rpc_channel"): return self._map_rpc_channel else: return self._rpc_channel async def discover_features(self) -> None: """Populate any supported traits that were not initialized in __init__.""" _LOGGER.debug("Starting optional trait discovery") await self.device_features.refresh() # Dock type also acts like a device feature for some traits. dock_type = await self._dock_type() # Dynamically create any traits that need to be populated for item in fields(self): if (trait := getattr(self, item.name, None)) is not None: continue if (union_args := get_args(item.type)) is None: raise ValueError(f"Unexpected non-union type for trait {item.name}: {item.type}") if len(union_args) != 2 or type(None) not in union_args: raise ValueError(f"Unexpected non-optional type for trait {item.name}: {item.type}") # Union args may not be in declared order item_type = union_args[0] if union_args[1] is type(None) else union_args[1] if not self._is_supported(item_type, item.name, dock_type): _LOGGER.debug("Trait '%s' not supported, skipping", item.name) continue _LOGGER.debug("Trait '%s' is supported, initializing", item.name) trait = item_type() setattr(self, item.name, trait) trait._rpc_channel = self._get_rpc_channel(trait) def _is_supported(self, trait_type: type[V1TraitMixin], name: str, dock_type: RoborockDockTypeCode) -> bool: """Check if a trait is supported by the device.""" if (requires_dock_type := getattr(trait_type, "requires_dock_type", None)) is not None: return requires_dock_type(dock_type) if (feature_name := getattr(trait_type, "requires_feature", None)) is None: _LOGGER.debug("Optional trait missing 'requires_feature' attribute %s, skipping", name) return False if (is_supported := getattr(self.device_features, feature_name)) is None: raise ValueError(f"Device feature '{feature_name}' on trait '{name}' is unknown") return is_supported async def _dock_type(self) -> RoborockDockTypeCode: """Get the dock type from the status trait or cache.""" dock_type = await self._get_cached_trait_data("dock_type") if dock_type is not None: _LOGGER.debug("Using cached dock type: %s", dock_type) try: return RoborockDockTypeCode(dock_type) except ValueError: _LOGGER.debug("Cached dock type %s is invalid, refreshing", dock_type) _LOGGER.debug("Starting dock type discovery") await self.status.refresh() _LOGGER.debug("Fetched dock type: %s", self.status.dock_type) if self.status.dock_type is None: # Explicitly set so we reuse cached value next type dock_type = RoborockDockTypeCode.no_dock else: dock_type = self.status.dock_type await self._set_cached_trait_data("dock_type", dock_type) return dock_type async def _get_cached_trait_data(self, name: str) -> Any: """Get the dock type from the status trait or cache.""" cache_data = await self._device_cache.get() if cache_data.trait_data is None: cache_data.trait_data = {} _LOGGER.debug("Cached trait data: %s", cache_data.trait_data) return cache_data.trait_data.get(name) async def _set_cached_trait_data(self, name: str, value: Any) -> None: """Set trait-specific cached data.""" cache_data = await self._device_cache.get() if cache_data.trait_data is None: cache_data.trait_data = {} cache_data.trait_data[name] = value _LOGGER.debug("Updating cached trait data: %s", cache_data.trait_data) await self._device_cache.set(cache_data) def as_dict(self) -> dict[str, Any]: """Return the trait data as a dictionary.""" result: dict[str, Any] = {} for item in fields(self): trait = getattr(self, item.name, None) if trait is None or not isinstance(trait, RoborockBase): continue data = trait.as_dict() if data: # Don't omit unset traits result[item.name] = data return result def create( device_uid: str, product: HomeDataProduct, home_data: HomeData, rpc_channel: V1RpcChannel, mqtt_rpc_channel: V1RpcChannel, map_rpc_channel: V1RpcChannel, web_api: UserWebApiClient, device_cache: DeviceCache, map_parser_config: MapParserConfig | None = None, ) -> PropertiesApi: """Create traits for V1 devices.""" return PropertiesApi( device_uid, product, home_data, rpc_channel, mqtt_rpc_channel, map_rpc_channel, web_api, device_cache, map_parser_config, ) Python-roborock-python-roborock-d6da2db/roborock/devices/traits/v1/child_lock.py000066400000000000000000000021201513363643200302620ustar00rootroot00000000000000from roborock.data import ChildLockStatus from roborock.devices.traits.v1 import common from roborock.roborock_typing import RoborockCommand _STATUS_PARAM = "lock_status" class ChildLockTrait(ChildLockStatus, common.V1TraitMixin, common.RoborockSwitchBase): """Trait for controlling the child lock of a Roborock device.""" command = RoborockCommand.GET_CHILD_LOCK_STATUS requires_feature = "is_set_child_supported" @property def is_on(self) -> bool: """Return whether the child lock is enabled.""" return self.lock_status == 1 async def enable(self) -> None: """Enable the child lock.""" await self.rpc_channel.send_command(RoborockCommand.SET_CHILD_LOCK_STATUS, params={_STATUS_PARAM: 1}) # Optimistic update to avoid an extra refresh self.lock_status = 1 async def disable(self) -> None: """Disable the child lock.""" await self.rpc_channel.send_command(RoborockCommand.SET_CHILD_LOCK_STATUS, params={_STATUS_PARAM: 0}) # Optimistic update to avoid an extra refresh self.lock_status = 0 Python-roborock-python-roborock-d6da2db/roborock/devices/traits/v1/clean_summary.py000066400000000000000000000075611513363643200310440ustar00rootroot00000000000000import logging from typing import Self from roborock.data import CleanRecord, CleanSummaryWithDetail from roborock.devices.traits.v1 import common from roborock.roborock_typing import RoborockCommand from roborock.util import unpack_list _LOGGER = logging.getLogger(__name__) class CleanSummaryTrait(CleanSummaryWithDetail, common.V1TraitMixin): """Trait for managing the clean summary of Roborock devices.""" command = RoborockCommand.GET_CLEAN_SUMMARY async def refresh(self) -> None: """Refresh the clean summary data and last clean record. Assumes that the clean summary has already been fetched. """ await super().refresh() if not self.records: _LOGGER.debug("No clean records available in clean summary.") self.last_clean_record = None return last_record_id = self.records[0] self.last_clean_record = await self.get_clean_record(last_record_id) @classmethod def _parse_type_response(cls, response: common.V1ResponseData) -> Self: """Parse the response from the device into a CleanSummary.""" if isinstance(response, dict): return cls.from_dict(response) elif isinstance(response, list): clean_time, clean_area, clean_count, records = unpack_list(response, 4) return cls( clean_time=clean_time, clean_area=clean_area, clean_count=clean_count, records=records, ) elif isinstance(response, int): return cls(clean_time=response) raise ValueError(f"Unexpected clean summary format: {response!r}") async def get_clean_record(self, record_id: int) -> CleanRecord: """Load a specific clean record by ID.""" response = await self.rpc_channel.send_command(RoborockCommand.GET_CLEAN_RECORD, params=[record_id]) return self._parse_clean_record_response(response) @classmethod def _parse_clean_record_response(cls, response: common.V1ResponseData) -> CleanRecord: """Parse the response from the device into a CleanRecord.""" if isinstance(response, list) and len(response) == 1: response = response[0] if isinstance(response, dict): return CleanRecord.from_dict(response) if isinstance(response, list): if isinstance(response[-1], dict): records = [CleanRecord.from_dict(rec) for rec in response] final_record = records[-1] try: # This code is semi-presumptuous - so it is put in a try finally to be safe. final_record.begin = records[0].begin final_record.begin_datetime = records[0].begin_datetime final_record.start_type = records[0].start_type for rec in records[0:-1]: final_record.duration = (final_record.duration or 0) + (rec.duration or 0) final_record.area = (final_record.area or 0) + (rec.area or 0) final_record.avoid_count = (final_record.avoid_count or 0) + (rec.avoid_count or 0) final_record.wash_count = (final_record.wash_count or 0) + (rec.wash_count or 0) final_record.square_meter_area = (final_record.square_meter_area or 0) + ( rec.square_meter_area or 0 ) return final_record except Exception: # Return final record when an exception occurred return final_record # There are still a few unknown variables in this. begin, end, duration, area = unpack_list(response, 4) return CleanRecord(begin=begin, end=end, duration=duration, area=area) raise ValueError(f"Unexpected clean record format: {response!r}") Python-roborock-python-roborock-d6da2db/roborock/devices/traits/v1/command.py000066400000000000000000000027261513363643200276210ustar00rootroot00000000000000from typing import Any from roborock import RoborockCommand from roborock.protocols.v1_protocol import ParamsType class CommandTrait: """Trait for sending commands to Roborock devices. This trait allows sending raw commands directly to the device. It is particularly useful for: 1. **Cleaning Control**: Sending commands like `app_start`, `app_stop`, `app_pause`, or `app_charge` which don't belong to a specific state trait. 2. **Unsupported Features**: Accessing device functionality that hasn't been mapped to a specific trait yet. See `roborock.roborock_typing.RoborockCommand` for a list of available commands. """ def __post_init__(self) -> None: """Post-initialization to set up the RPC channel. This is called automatically after the dataclass is initialized by the device setup code. """ self._rpc_channel = None async def send(self, command: RoborockCommand | str, params: ParamsType = None) -> Any: """Send a command to the device. Sending a raw command to the device using this method does not update the internal state of any other traits. It is the responsibility of the caller to ensure that any traits affected by the command are refreshed as needed. """ if not self._rpc_channel: raise ValueError("Device trait in invalid state") return await self._rpc_channel.send_command(command, params=params) Python-roborock-python-roborock-d6da2db/roborock/devices/traits/v1/common.py000066400000000000000000000144701513363643200274720ustar00rootroot00000000000000"""Module for Roborock V1 devices common trait commands. This is an internal library and should not be used directly by consumers. """ import logging from abc import ABC, abstractmethod from dataclasses import dataclass, fields from typing import ClassVar, Self from roborock.data import RoborockBase from roborock.protocols.v1_protocol import V1RpcChannel from roborock.roborock_typing import RoborockCommand _LOGGER = logging.getLogger(__name__) V1ResponseData = dict | list | int | str @dataclass class V1TraitMixin(ABC): """Base model that supports v1 traits. This class provides functioanlity for parsing responses from V1 devices into dataclass instances. It also provides a reference to the V1RpcChannel used to communicate with the device to execute commands. Each trait subclass must define a class variable `command` that specifies the RoborockCommand used to fetch the trait data from the device. The `refresh()` method can be called to update the contents of the trait data from the device. A trait can also support additional commands for updating state associated with the trait. It is expected that a trait will update its own internal state either reflecting the change optimistically or by refreshing the trait state from the device. In cases where one trait caches data that is also represented in another trait, it is the responsibility of the caller to ensure that both traits are refreshed as needed to keep them in sync. The traits typically subclass RoborockBase to provide serialization and deserialization functionality, but this is not strictly required. """ command: ClassVar[RoborockCommand] @classmethod def _parse_type_response(cls, response: V1ResponseData) -> RoborockBase: """Parse the response from the device into a a RoborockBase. Subclasses should override this method to implement custom parsing logic as needed. """ if not issubclass(cls, RoborockBase): raise NotImplementedError(f"Trait {cls} does not implement RoborockBase") # Subclasses can override to implement custom parsing logic if isinstance(response, list): response = response[0] if not isinstance(response, dict): raise ValueError(f"Unexpected {cls} response format: {response!r}") return cls.from_dict(response) def _parse_response(self, response: V1ResponseData) -> RoborockBase: """Parse the response from the device into a a RoborockBase. This is used by subclasses that want to override the class behavior with instance-specific data. """ return self._parse_type_response(response) def __post_init__(self) -> None: """Post-initialization to set up the RPC channel. This is called automatically after the dataclass is initialized by the device setup code. """ self._rpc_channel = None @property def rpc_channel(self) -> V1RpcChannel: """Helper for executing commands, used internally by the trait""" if not self._rpc_channel: raise ValueError("Device trait in invalid state") return self._rpc_channel async def refresh(self) -> None: """Refresh the contents of this trait.""" response = await self.rpc_channel.send_command(self.command) new_data = self._parse_response(response) if not isinstance(new_data, RoborockBase): raise ValueError(f"Internal error, unexpected response type: {new_data!r}") _LOGGER.debug("Refreshed %s: %s", self.__class__.__name__, new_data) self._update_trait_values(new_data) def _update_trait_values(self, new_data: RoborockBase) -> None: """Update the values of this trait from another instance.""" for field in fields(new_data): new_value = getattr(new_data, field.name, None) setattr(self, field.name, new_value) def _get_value_field(clazz: type[V1TraitMixin]) -> str: """Get the name of the field marked as the main value of the RoborockValueBase.""" value_fields = [field.name for field in fields(clazz) if field.metadata.get("roborock_value", False)] if len(value_fields) != 1: raise ValueError( f"RoborockValueBase subclass {clazz} must have exactly one field marked as roborock_value, " f" but found: {value_fields}" ) return value_fields[0] @dataclass(init=False, kw_only=True) class RoborockValueBase(V1TraitMixin, RoborockBase): """Base class for traits that represent a single value. This class is intended to be subclassed by traits that represent a single value, such as volume or brightness. The subclass should define a single field with the metadata `roborock_value=True` to indicate which field represents the main value of the trait. """ @classmethod def _parse_response(cls, response: V1ResponseData) -> Self: """Parse the response from the device into a RoborockValueBase.""" if isinstance(response, list): response = response[0] if not isinstance(response, int): raise ValueError(f"Unexpected response format: {response!r}") value_field = _get_value_field(cls) return cls(**{value_field: response}) class RoborockSwitchBase(ABC): """Base class for traits that represent a boolean switch.""" @property @abstractmethod def is_on(self) -> bool: """Return whether the switch is on.""" @abstractmethod async def enable(self) -> None: """Enable the switch.""" @abstractmethod async def disable(self) -> None: """Disable the switch.""" def mqtt_rpc_channel(cls): """Decorator to mark a function as cloud only. Normally a trait uses an adaptive rpc channel that can use either local or cloud communication depending on what is available. This will force the trait to always use the cloud rpc channel. """ def wrapper(*args, **kwargs): return cls(*args, **kwargs) cls.mqtt_rpc_channel = True # type: ignore[attr-defined] return wrapper def map_rpc_channel(cls): """Decorator to mark a function as cloud only using the map rpc format.""" def wrapper(*args, **kwargs): return cls(*args, **kwargs) cls.map_rpc_channel = True # type: ignore[attr-defined] return wrapper Python-roborock-python-roborock-d6da2db/roborock/devices/traits/v1/consumeable.py000066400000000000000000000027721513363643200305010ustar00rootroot00000000000000"""Trait for managing consumable attributes. A consumable attribute is one that is expected to be replaced or refilled periodically, such as filters, brushes, etc. """ from enum import StrEnum from typing import Self from roborock.data import Consumable from roborock.devices.traits.v1 import common from roborock.roborock_typing import RoborockCommand __all__ = [ "ConsumableTrait", ] class ConsumableAttribute(StrEnum): """Enum for consumable attributes.""" SENSOR_DIRTY_TIME = "sensor_dirty_time" FILTER_WORK_TIME = "filter_work_time" SIDE_BRUSH_WORK_TIME = "side_brush_work_time" MAIN_BRUSH_WORK_TIME = "main_brush_work_time" @classmethod def from_str(cls, value: str) -> Self: """Create a ConsumableAttribute from a string value.""" for member in cls: if member.value == value: return member raise ValueError(f"Unknown ConsumableAttribute: {value}") class ConsumableTrait(Consumable, common.V1TraitMixin): """Trait for managing consumable attributes on Roborock devices. After the first refresh, you can tell what consumables are supported by checking which attributes are not None. """ command = RoborockCommand.GET_CONSUMABLE async def reset_consumable(self, consumable: ConsumableAttribute) -> None: """Reset a specific consumable attribute on the device.""" await self.rpc_channel.send_command(RoborockCommand.RESET_CONSUMABLE, params=[consumable.value]) await self.refresh() Python-roborock-python-roborock-d6da2db/roborock/devices/traits/v1/device_features.py000066400000000000000000000065551513363643200313440ustar00rootroot00000000000000from dataclasses import Field, fields from roborock.data import AppInitStatus, HomeDataProduct, RoborockBase from roborock.data.v1.v1_containers import FieldNameBase from roborock.device_features import DeviceFeatures from roborock.devices.cache import DeviceCache from roborock.devices.traits.v1 import common from roborock.roborock_typing import RoborockCommand class DeviceFeaturesTrait(DeviceFeatures, common.V1TraitMixin): """Trait for managing supported features on Roborock devices.""" command = RoborockCommand.APP_GET_INIT_STATUS def __init__(self, product: HomeDataProduct, device_cache: DeviceCache) -> None: # pylint: disable=super-init-not-called """Initialize DeviceFeaturesTrait.""" self._product = product self._nickname = product.product_nickname self._device_cache = device_cache # All fields of DeviceFeatures are required. Initialize them to False # so we have some known state. for field in fields(self): setattr(self, field.name, False) def is_field_supported(self, cls: type[RoborockBase], field_name: FieldNameBase) -> bool: """Determines if the specified field is supported by this device. We use dataclass attributes on the field to specify the schema code that is required for the field to be supported and it is compared against the list of supported schema codes for the device returned in the product information. """ dataclass_field: Field | None = None for field in fields(cls): if field.name == field_name: dataclass_field = field break if dataclass_field is None: raise ValueError(f"Field {field_name} not found in {cls}") requires_schema_code = dataclass_field.metadata.get("requires_schema_code", None) if requires_schema_code is None: # We assume the field is supported return True # If the field requires a protocol that is not supported, we return False return requires_schema_code in self._product.supported_schema_codes async def refresh(self) -> None: """Refresh the contents of this trait. This will use cached device features if available since they do not change often and this avoids unnecessary RPC calls. This would only ever change with a firmware update, so caching is appropriate. """ cache_data = await self._device_cache.get() if cache_data.device_features is not None: self._update_trait_values(cache_data.device_features) return # Save cached device features await super().refresh() cache_data.device_features = self await self._device_cache.set(cache_data) def _parse_response(self, response: common.V1ResponseData) -> DeviceFeatures: """Parse the response from the device into a MapContentTrait instance.""" if not isinstance(response, list): raise ValueError(f"Unexpected AppInitStatus response format: {type(response)}") app_status = AppInitStatus.from_dict(response[0]) return DeviceFeatures.from_feature_flags( new_feature_info=app_status.new_feature_info, new_feature_info_str=app_status.new_feature_info_str, feature_info=app_status.feature_info, product_nickname=self._nickname, ) Python-roborock-python-roborock-d6da2db/roborock/devices/traits/v1/do_not_disturb.py000066400000000000000000000031131513363643200312100ustar00rootroot00000000000000from roborock.data import DnDTimer from roborock.devices.traits.v1 import common from roborock.roborock_typing import RoborockCommand _ENABLED_PARAM = "enabled" class DoNotDisturbTrait(DnDTimer, common.V1TraitMixin, common.RoborockSwitchBase): """Trait for managing Do Not Disturb (DND) settings on Roborock devices.""" command = RoborockCommand.GET_DND_TIMER @property def is_on(self) -> bool: """Return whether the Do Not Disturb (DND) timer is enabled.""" return self.enabled == 1 async def set_dnd_timer(self, dnd_timer: DnDTimer) -> None: """Set the Do Not Disturb (DND) timer settings of the device.""" await self.rpc_channel.send_command(RoborockCommand.SET_DND_TIMER, params=dnd_timer.as_list()) await self.refresh() async def clear_dnd_timer(self) -> None: """Clear the Do Not Disturb (DND) timer settings of the device.""" await self.rpc_channel.send_command(RoborockCommand.CLOSE_DND_TIMER) await self.refresh() async def enable(self) -> None: """Set the Do Not Disturb (DND) timer settings of the device.""" await self.rpc_channel.send_command( RoborockCommand.SET_DND_TIMER, params=self.as_list(), ) # Optimistic update to avoid an extra refresh self.enabled = 1 async def disable(self) -> None: """Disable the Do Not Disturb (DND) timer settings of the device.""" await self.rpc_channel.send_command(RoborockCommand.CLOSE_DND_TIMER) # Optimistic update to avoid an extra refresh self.enabled = 0 Python-roborock-python-roborock-d6da2db/roborock/devices/traits/v1/dust_collection_mode.py000066400000000000000000000006751513363643200324020ustar00rootroot00000000000000"""Trait for dust collection mode.""" from roborock.data import DustCollectionMode from roborock.device_features import is_valid_dock from roborock.devices.traits.v1 import common from roborock.roborock_typing import RoborockCommand class DustCollectionModeTrait(DustCollectionMode, common.V1TraitMixin): """Trait for dust collection mode.""" command = RoborockCommand.GET_DUST_COLLECTION_MODE requires_dock_type = is_valid_dock Python-roborock-python-roborock-d6da2db/roborock/devices/traits/v1/flow_led_status.py000066400000000000000000000021211513363643200313660ustar00rootroot00000000000000from roborock.data import FlowLedStatus from roborock.devices.traits.v1 import common from roborock.roborock_typing import RoborockCommand _STATUS_PARAM = "status" class FlowLedStatusTrait(FlowLedStatus, common.V1TraitMixin, common.RoborockSwitchBase): """Trait for controlling the Flow LED status of a Roborock device.""" command = RoborockCommand.GET_FLOW_LED_STATUS requires_feature = "is_flow_led_setting_supported" @property def is_on(self) -> bool: """Return whether the Flow LED status is enabled.""" return self.status == 1 async def enable(self) -> None: """Enable the Flow LED status.""" await self.rpc_channel.send_command(RoborockCommand.SET_FLOW_LED_STATUS, params={_STATUS_PARAM: 1}) # Optimistic update to avoid an extra refresh self.status = 1 async def disable(self) -> None: """Disable the Flow LED status.""" await self.rpc_channel.send_command(RoborockCommand.SET_FLOW_LED_STATUS, params={_STATUS_PARAM: 0}) # Optimistic update to avoid an extra refresh self.status = 0 Python-roborock-python-roborock-d6da2db/roborock/devices/traits/v1/home.py000066400000000000000000000315541513363643200271340ustar00rootroot00000000000000"""Trait that represents a full view of the home layout. This trait combines information about maps and rooms to provide a comprehensive view of the home layout, including room names and their corresponding segment on the map. It also makes it straight forward to fetch the map image and data. This trait depends on the MapsTrait and RoomsTrait to gather the necessary information. It provides properties to access the current map, the list of rooms with names, and the map image and data. Callers may first call `discover_home()` to populate the home layout cache by iterating through all available maps on the device. This will cache the map information and room names for all maps to minimize map switching and improve performance. After the initial discovery, callers can call `refresh()` to update the current map's information and room names as needed. """ import asyncio import base64 import logging from typing import Self from roborock.data import CombinedMapInfo, NamedRoomMapping, RoborockBase from roborock.data.v1.v1_code_mappings import RoborockStateCode from roborock.devices.cache import DeviceCache from roborock.devices.traits.v1 import common from roborock.exceptions import RoborockDeviceBusy, RoborockException, RoborockInvalidStatus from roborock.roborock_typing import RoborockCommand from .map_content import MapContent, MapContentTrait from .maps import MapsTrait from .rooms import RoomsTrait from .status import StatusTrait _LOGGER = logging.getLogger(__name__) MAP_SLEEP = 3 class HomeTrait(RoborockBase, common.V1TraitMixin): """Trait that represents a full view of the home layout.""" command = RoborockCommand.GET_MAP_V1 # This is not used def __init__( self, status_trait: StatusTrait, maps_trait: MapsTrait, map_content: MapContentTrait, rooms_trait: RoomsTrait, device_cache: DeviceCache, ) -> None: """Initialize the HomeTrait. We keep track of the MapsTrait and RoomsTrait to provide a comprehensive view of the home layout. This also depends on the StatusTrait to determine the current map. See comments in MapsTrait for details on that dependency. The cache is used to store discovered home data to minimize map switching and improve performance. The cache should be persisted by the caller to ensure data is retained across restarts. After initial discovery, only information for the current map is refreshed to keep data up to date without excessive map switching. However, as users switch rooms, the current map's data will be updated to ensure accuracy. """ super().__init__() self._status_trait = status_trait self._maps_trait = maps_trait self._map_content = map_content self._rooms_trait = rooms_trait self._device_cache = device_cache self._discovery_completed = False self._home_map_info: dict[int, CombinedMapInfo] | None = None self._home_map_content: dict[int, MapContent] | None = None async def discover_home(self) -> None: """Iterate through all maps to discover rooms and cache them. This will be a no-op if the home cache is already populated. This cannot be called while the device is cleaning, as that would interrupt the cleaning process. This will raise `RoborockDeviceBusy` if the device is currently cleaning. After discovery, the home cache will be populated and can be accessed via the `home_map_info` property. """ device_cache_data = await self._device_cache.get() if device_cache_data and device_cache_data.home_map_info: _LOGGER.debug("Home cache already populated, skipping discovery") self._home_map_info = device_cache_data.home_map_info self._discovery_completed = True try: self._home_map_content = { k: self._map_content.parse_map_content(base64.b64decode(v)) for k, v in (device_cache_data.home_map_content_base64 or {}).items() } except (ValueError, RoborockException) as ex: _LOGGER.warning("Failed to parse cached home map content, will re-discover: %s", ex) self._home_map_content = {} else: return if self._status_trait.state == RoborockStateCode.cleaning: raise RoborockDeviceBusy("Cannot perform home discovery while the device is cleaning") await self._maps_trait.refresh() if self._maps_trait.current_map_info is None: raise RoborockException("Cannot perform home discovery without current map info") home_map_info, home_map_content = await self._build_home_map_info() _LOGGER.debug("Home discovery complete, caching data for %d maps", len(home_map_info)) self._discovery_completed = True await self._update_home_cache(home_map_info, home_map_content) async def _refresh_map_info(self, map_info) -> CombinedMapInfo: """Collect room data for a specific map and return CombinedMapInfo.""" await self._rooms_trait.refresh() rooms: dict[int, NamedRoomMapping] = {} if map_info.rooms: # Not all vacuums resopnd with rooms inside map_info. for room in map_info.rooms: if room.id is not None and room.iot_name_id is not None: rooms[room.id] = NamedRoomMapping( segment_id=room.id, iot_id=room.iot_name_id, name=room.iot_name or "Unknown", ) # Add rooms from rooms_trait. If room already exists and rooms_trait has "Unknown", don't override. if self._rooms_trait.rooms: for room in self._rooms_trait.rooms: if room.segment_id is not None and room.name: if room.segment_id not in rooms or room.name != "Unknown": # Add the room to rooms if the room segment is not already in it # or if the room name isn't unknown. rooms[room.segment_id] = room return CombinedMapInfo( map_flag=map_info.map_flag, name=map_info.name, rooms=list(rooms.values()), ) async def _refresh_map_content(self) -> MapContent: """Refresh the map content trait to get the latest map data.""" await self._map_content.refresh() return MapContent( image_content=self._map_content.image_content, map_data=self._map_content.map_data, raw_api_response=self._map_content.raw_api_response, ) async def _build_home_map_info(self) -> tuple[dict[int, CombinedMapInfo], dict[int, MapContent]]: """Perform the actual discovery and caching of home map info and content.""" home_map_info: dict[int, CombinedMapInfo] = {} home_map_content: dict[int, MapContent] = {} # Sort map_info to process the current map last, reducing map switching. # False (non-original maps) sorts before True (original map). We ensure # we load the original map last. sorted_map_infos = sorted( self._maps_trait.map_info or [], key=lambda mi: mi.map_flag == self._maps_trait.current_map, reverse=False, ) _LOGGER.debug("Building home cache for maps: %s", [mi.map_flag for mi in sorted_map_infos]) for map_info in sorted_map_infos: # We need to load each map to get its room data if len(sorted_map_infos) > 1: _LOGGER.debug("Loading map %s", map_info.map_flag) try: await self._maps_trait.set_current_map(map_info.map_flag) except RoborockInvalidStatus as ex: # Device is in a state that forbids map switching. Translate to # "busy" so callers can fall back to refreshing the current map only. raise RoborockDeviceBusy("Cannot switch maps right now (device action locked)") from ex await asyncio.sleep(MAP_SLEEP) map_content = await self._refresh_map_content() home_map_content[map_info.map_flag] = map_content combined_map_info = await self._refresh_map_info(map_info) home_map_info[map_info.map_flag] = combined_map_info return home_map_info, home_map_content async def refresh(self) -> None: """Refresh current map's underlying map and room data, updating cache as needed. This will only refresh the current map's data and will not populate non active maps or re-discover the home. It is expected that this will keep information up to date for the current map as users switch to that map. """ if not self._discovery_completed: # Running initial discovery also populates all of the same information # as below so we can just call that method. If the device is busy # then we'll fall through below to refresh the current map only. try: await self.discover_home() return except RoborockDeviceBusy: _LOGGER.debug("Cannot refresh home data while device is busy cleaning") # Refresh the list of map names/info await self._maps_trait.refresh() if (current_map_info := self._maps_trait.current_map_info) is None or ( map_flag := self._maps_trait.current_map ) is None: raise RoborockException("Cannot refresh home data without current map info") # Refresh the map content to ensure we have the latest image and object positions new_map_content = await self._refresh_map_content() # Refresh the current map's room data combined_map_info = await self._refresh_map_info(current_map_info) await self._update_current_map( map_flag, combined_map_info, new_map_content, update_cache=self._discovery_completed ) @property def home_map_info(self) -> dict[int, CombinedMapInfo] | None: """Returns the map information for all cached maps.""" return self._home_map_info @property def current_map_data(self) -> CombinedMapInfo | None: """Returns the map data for the current map.""" current_map_flag = self._maps_trait.current_map if current_map_flag is None or self._home_map_info is None: return None return self._home_map_info.get(current_map_flag) @property def home_map_content(self) -> dict[int, MapContent] | None: """Returns the map content for all cached maps.""" return self._home_map_content def _parse_response(self, response: common.V1ResponseData) -> Self: """This trait does not parse responses directly.""" raise NotImplementedError("HomeTrait does not support direct command responses") async def _update_home_cache( self, home_map_info: dict[int, CombinedMapInfo], home_map_content: dict[int, MapContent] ) -> None: """Update the entire home cache with new map info and content.""" device_cache_data = await self._device_cache.get() device_cache_data.home_map_info = home_map_info device_cache_data.home_map_content_base64 = { k: base64.b64encode(v.raw_api_response).decode("utf-8") for k, v in home_map_content.items() if v.raw_api_response } await self._device_cache.set(device_cache_data) self._home_map_info = home_map_info self._home_map_content = home_map_content async def _update_current_map( self, map_flag: int, map_info: CombinedMapInfo, map_content: MapContent, update_cache: bool, ) -> None: """Update the cache for the current map only.""" # Update the persistent cache if requested e.g. home discovery has # completed and we want to keep it fresh. Otherwise just update the # in memory map below. if update_cache: device_cache_data = await self._device_cache.get() if device_cache_data.home_map_info is None: device_cache_data.home_map_info = {} device_cache_data.home_map_info[map_flag] = map_info if map_content.raw_api_response: if device_cache_data.home_map_content_base64 is None: device_cache_data.home_map_content_base64 = {} device_cache_data.home_map_content_base64[map_flag] = base64.b64encode( map_content.raw_api_response ).decode("utf-8") await self._device_cache.set(device_cache_data) if self._home_map_info is None: self._home_map_info = {} self._home_map_info[map_flag] = map_info if self._home_map_content is None: self._home_map_content = {} self._home_map_content[map_flag] = map_content Python-roborock-python-roborock-d6da2db/roborock/devices/traits/v1/led_status.py000066400000000000000000000031501513363643200303420ustar00rootroot00000000000000from roborock.data import LedStatus from roborock.devices.traits.v1 import common from roborock.roborock_typing import RoborockCommand from .common import V1ResponseData class LedStatusTrait(LedStatus, common.V1TraitMixin, common.RoborockSwitchBase): """Trait for controlling the LED status of a Roborock device.""" command = RoborockCommand.GET_LED_STATUS requires_feature = "is_led_status_switch_supported" @property def is_on(self) -> bool: """Return whether the LED status is enabled.""" return self.status == 1 async def enable(self) -> None: """Enable the LED status.""" await self.rpc_channel.send_command(RoborockCommand.SET_LED_STATUS, params=[1]) # Optimistic update to avoid an extra refresh self.status = 1 async def disable(self) -> None: """Disable the LED status.""" await self.rpc_channel.send_command(RoborockCommand.SET_LED_STATUS, params=[0]) # Optimistic update to avoid an extra refresh self.status = 0 @classmethod def _parse_type_response(cls, response: V1ResponseData) -> LedStatus: """Parse the response from the device into a a RoborockBase. Subclasses should override this method to implement custom parsing logic as needed. """ if not isinstance(response, list): raise ValueError(f"Unexpected {cls} response format: {response!r}") response = response[0] if not isinstance(response, int): raise ValueError(f"Unexpected {cls} response format: {response!r}") return cls.from_dict({"status": response}) Python-roborock-python-roborock-d6da2db/roborock/devices/traits/v1/map_content.py000066400000000000000000000055051513363643200305100ustar00rootroot00000000000000"""Trait for fetching the map content from Roborock devices.""" import logging from dataclasses import dataclass from vacuum_map_parser_base.map_data import MapData from roborock.data import RoborockBase from roborock.devices.traits.v1 import common from roborock.map.map_parser import MapParser, MapParserConfig from roborock.roborock_typing import RoborockCommand _LOGGER = logging.getLogger(__name__) _TRUNCATE_LENGTH = 20 @dataclass class MapContent(RoborockBase): """Dataclass representing map content.""" image_content: bytes | None = None """The rendered image of the map in PNG format.""" map_data: MapData | None = None """The parsed map data which contains metadata for points on the map.""" raw_api_response: bytes | None = None """The raw bytes of the map data from the API for caching for future use. This should be treated as an opaque blob used only internally by this library to re-parse the map data when needed. """ def __repr__(self) -> str: """Return a string representation of the MapContent.""" img = self.image_content if self.image_content and len(self.image_content) > _TRUNCATE_LENGTH: img = self.image_content[: _TRUNCATE_LENGTH - 3] + b"..." return f"MapContent(image_content={img!r}, map_data={self.map_data!r})" @common.map_rpc_channel class MapContentTrait(MapContent, common.V1TraitMixin): """Trait for fetching the map content.""" command = RoborockCommand.GET_MAP_V1 def __init__(self, map_parser_config: MapParserConfig | None = None) -> None: """Initialize MapContentTrait.""" super().__init__() self._map_parser = MapParser(map_parser_config or MapParserConfig()) def _parse_response(self, response: common.V1ResponseData) -> MapContent: """Parse the response from the device into a MapContentTrait instance.""" if not isinstance(response, bytes): raise ValueError(f"Unexpected MapContentTrait response format: {type(response)}") return self.parse_map_content(response) def parse_map_content(self, response: bytes) -> MapContent: """Parse the map content from raw bytes. This method is exposed so that cached map data can be parsed without needing to go through the RPC channel. Args: response: The raw bytes of the map data from the API. Returns: MapContent: The parsed map content. Raises: RoborockException: If the map data cannot be parsed. """ parsed_data = self._map_parser.parse(response) if parsed_data is None: raise ValueError("Failed to parse map data") return MapContent( image_content=parsed_data.image_content, map_data=parsed_data.map_data, raw_api_response=response, ) Python-roborock-python-roborock-d6da2db/roborock/devices/traits/v1/maps.py000066400000000000000000000064141513363643200271410ustar00rootroot00000000000000"""Trait for managing maps and room mappings on Roborock devices. New datatypes are introduced here to manage the additional information associated with maps and rooms, such as map names and room names. These override the base container datatypes to add additional fields. """ import logging from typing import Self from roborock.data import MultiMapsList, MultiMapsListMapInfo from roborock.devices.traits.v1 import common from roborock.roborock_typing import RoborockCommand from .status import StatusTrait _LOGGER = logging.getLogger(__name__) @common.mqtt_rpc_channel class MapsTrait(MultiMapsList, common.V1TraitMixin): """Trait for managing the maps of Roborock devices. A device may have multiple maps, each identified by a unique map_flag. Each map can have multiple rooms associated with it, in a `RoomMapping`. The MapsTrait depends on the StatusTrait to determine the currently active map. It is the responsibility of the caller to ensure that the StatusTrait is up to date before using this trait. However, there is a possibility of races if another client changes the current map between the time the StatusTrait is refreshed and when the MapsTrait is used. This is mitigated by the fact that the map list is unlikely to change frequently, and the current map is only changed when the user explicitly switches maps. """ command = RoborockCommand.GET_MULTI_MAPS_LIST def __init__(self, status_trait: StatusTrait) -> None: """Initialize the MapsTrait. We keep track of the StatusTrait to ensure we have the latest status information when dealing with maps. """ super().__init__() self._status_trait = status_trait @property def current_map(self) -> int | None: """Returns the currently active map (map_flag), if available.""" return self._status_trait.current_map @property def current_map_info(self) -> MultiMapsListMapInfo | None: """Returns the currently active map info, if available.""" if (current_map := self.current_map) is None or self.map_info is None: return None for map_info in self.map_info: if map_info.map_flag == current_map: return map_info return None async def set_current_map(self, map_flag: int) -> None: """Update the current map of the device by it's map_flag id.""" await self.rpc_channel.send_command(RoborockCommand.LOAD_MULTI_MAP, params=[map_flag]) # Refresh our status to make sure it reflects the new map await self._status_trait.refresh() def _parse_response(self, response: common.V1ResponseData) -> Self: """Parse the response from the device into a MapsTrait instance. This overrides the base implementation to handle the specific response format for the multi maps list. This is needed because we have a custom constructor that requires the StatusTrait. """ if not isinstance(response, list): raise ValueError(f"Unexpected MapsTrait response format: {response!r}") response = response[0] if not isinstance(response, dict): raise ValueError(f"Unexpected MapsTrait response format: {response!r}") return MultiMapsList.from_dict(response) Python-roborock-python-roborock-d6da2db/roborock/devices/traits/v1/network_info.py000066400000000000000000000041701513363643200307020ustar00rootroot00000000000000"""Trait for device network information.""" from __future__ import annotations import logging from roborock.data import NetworkInfo from roborock.devices.cache import DeviceCache from roborock.devices.traits.v1 import common from roborock.roborock_typing import RoborockCommand _LOGGER = logging.getLogger(__name__) class NetworkInfoTrait(NetworkInfo, common.V1TraitMixin): """Trait for device network information. This trait will always prefer reading from the cache if available. This information is usually already fetched when creating the device local connection, so reading from the cache avoids an unnecessary RPC call. However, we have the fallback to reading from the device if the cache is not populated for some reason. """ command = RoborockCommand.GET_NETWORK_INFO def __init__(self, device_uid: str, device_cache: DeviceCache) -> None: # pylint: disable=super-init-not-called """Initialize the trait.""" self._device_uid = device_uid self._device_cache = device_cache self.ip = "" async def refresh(self) -> None: """Refresh the network info from the cache.""" device_cache_data = await self._device_cache.get() if device_cache_data.network_info: _LOGGER.debug("Using cached network info for device %s", self._device_uid) self._update_trait_values(device_cache_data.network_info) return # Load from device if not in cache _LOGGER.debug("No cached network info for device %s, fetching from device", self._device_uid) await super().refresh() # Update the cache with the new network info device_cache_data = await self._device_cache.get() device_cache_data.network_info = self await self._device_cache.set(device_cache_data) def _parse_response(self, response: common.V1ResponseData) -> NetworkInfo: """Parse the response from the device into a NetworkInfo.""" if not isinstance(response, dict): raise ValueError(f"Unexpected NetworkInfoTrait response format: {response!r}") return NetworkInfo.from_dict(response) Python-roborock-python-roborock-d6da2db/roborock/devices/traits/v1/rooms.py000066400000000000000000000056771513363643200273520ustar00rootroot00000000000000"""Trait for managing room mappings on Roborock devices.""" import logging from dataclasses import dataclass from roborock.data import HomeData, NamedRoomMapping, RoborockBase from roborock.devices.traits.v1 import common from roborock.roborock_typing import RoborockCommand _LOGGER = logging.getLogger(__name__) _DEFAULT_NAME = "Unknown" @dataclass class Rooms(RoborockBase): """Dataclass representing a collection of room mappings.""" rooms: list[NamedRoomMapping] | None = None """List of room mappings.""" @property def room_map(self) -> dict[int, NamedRoomMapping]: """Returns a mapping of segment_id to NamedRoomMapping.""" if self.rooms is None: return {} return {room.segment_id: room for room in self.rooms} class RoomsTrait(Rooms, common.V1TraitMixin): """Trait for managing the room mappings of Roborock devices.""" command = RoborockCommand.GET_ROOM_MAPPING def __init__(self, home_data: HomeData) -> None: """Initialize the RoomsTrait.""" super().__init__() self._home_data = home_data @property def _iot_id_room_name_map(self) -> dict[str, str]: """Returns a dictionary of Room IOT IDs to room names.""" return {str(room.id): room.name for room in self._home_data.rooms or ()} def _parse_response(self, response: common.V1ResponseData) -> Rooms: """Parse the response from the device into a list of NamedRoomMapping.""" if not isinstance(response, list): raise ValueError(f"Unexpected RoomsTrait response format: {response!r}") name_map = self._iot_id_room_name_map segment_pairs = _extract_segment_pairs(response) return Rooms( rooms=[ NamedRoomMapping(segment_id=segment_id, iot_id=iot_id, name=name_map.get(iot_id, _DEFAULT_NAME)) for segment_id, iot_id in segment_pairs ] ) def _extract_segment_pairs(response: list) -> list[tuple[int, str]]: """Extract segment_id and iot_id pairs from the response. The response format can be either a flat list of [segment_id, iot_id] or a list of lists, where each inner list is a pair of [segment_id, iot_id]. This function normalizes the response into a list of (segment_id, iot_id) tuples NOTE: We currently only partial samples of the room mapping formats, so improving test coverage with samples from a real device with this format would be helpful. """ if len(response) == 2 and not isinstance(response[0], list): segment_id, iot_id = response[0], response[1] return [(segment_id, iot_id)] segment_pairs: list[tuple[int, str]] = [] for part in response: if not isinstance(part, list) or len(part) < 2: _LOGGER.warning("Unexpected room mapping entry format: %r", part) continue segment_id, iot_id = part[0], part[1] segment_pairs.append((segment_id, iot_id)) return segment_pairs Python-roborock-python-roborock-d6da2db/roborock/devices/traits/v1/routines.py000066400000000000000000000016431513363643200300500ustar00rootroot00000000000000"""Routines trait for V1 devices.""" from roborock.data.containers import HomeDataScene from roborock.web_api import UserWebApiClient class RoutinesTrait: """Trait for interacting with routines.""" def __init__(self, device_id: str, web_api: UserWebApiClient) -> None: """Initialize the routines trait.""" self._device_id = device_id self._web_api = web_api async def get_routines(self) -> list[HomeDataScene]: """Get available routines.""" return await self._web_api.get_routines(self._device_id) async def execute_routine(self, routine_id: int) -> None: """Execute a routine by its ID. Technically, routines are per-device, but the API does not require the device ID to execute them. This can execute a routine for any device but it is exposed here for convenience. """ await self._web_api.execute_routine(routine_id) Python-roborock-python-roborock-d6da2db/roborock/devices/traits/v1/smart_wash_params.py000066400000000000000000000006771513363643200317210ustar00rootroot00000000000000"""Trait for smart wash parameters.""" from roborock.data import SmartWashParams from roborock.device_features import is_wash_n_fill_dock from roborock.devices.traits.v1 import common from roborock.roborock_typing import RoborockCommand class SmartWashParamsTrait(SmartWashParams, common.V1TraitMixin): """Trait for smart wash parameters.""" command = RoborockCommand.GET_SMART_WASH_PARAMS requires_dock_type = is_wash_n_fill_dock Python-roborock-python-roborock-d6da2db/roborock/devices/traits/v1/status.py000066400000000000000000000017111513363643200275170ustar00rootroot00000000000000from typing import Self from roborock.data import HomeDataProduct, ModelStatus, S7MaxVStatus, Status from roborock.devices.traits.v1 import common from roborock.roborock_typing import RoborockCommand class StatusTrait(Status, common.V1TraitMixin): """Trait for managing the status of Roborock devices.""" command = RoborockCommand.GET_STATUS def __init__(self, product_info: HomeDataProduct) -> None: """Initialize the StatusTrait.""" self._product_info = product_info def _parse_response(self, response: common.V1ResponseData) -> Self: """Parse the response from the device into a CleanSummary.""" status_type: type[Status] = ModelStatus.get(self._product_info.model, S7MaxVStatus) if isinstance(response, list): response = response[0] if isinstance(response, dict): return status_type.from_dict(response) raise ValueError(f"Unexpected status format: {response!r}") Python-roborock-python-roborock-d6da2db/roborock/devices/traits/v1/valley_electricity_timer.py000066400000000000000000000034071513363643200332740ustar00rootroot00000000000000from roborock.data import ValleyElectricityTimer from roborock.devices.traits.v1 import common from roborock.roborock_typing import RoborockCommand _ENABLED_PARAM = "enabled" class ValleyElectricityTimerTrait(ValleyElectricityTimer, common.V1TraitMixin, common.RoborockSwitchBase): """Trait for managing Valley Electricity Timer settings on Roborock devices.""" command = RoborockCommand.GET_VALLEY_ELECTRICITY_TIMER requires_feature = "is_supported_valley_electricity" @property def is_on(self) -> bool: """Return whether the Valley Electricity Timer is enabled.""" return self.enabled == 1 async def set_timer(self, timer: ValleyElectricityTimer) -> None: """Set the Valley Electricity Timer settings of the device.""" await self.rpc_channel.send_command(RoborockCommand.SET_VALLEY_ELECTRICITY_TIMER, params=timer.as_list()) await self.refresh() async def clear_timer(self) -> None: """Clear the Valley Electricity Timer settings of the device.""" await self.rpc_channel.send_command(RoborockCommand.CLOSE_VALLEY_ELECTRICITY_TIMER) await self.refresh() async def enable(self) -> None: """Enable the Valley Electricity Timer settings of the device.""" await self.rpc_channel.send_command( RoborockCommand.SET_VALLEY_ELECTRICITY_TIMER, params=self.as_list(), ) # Optimistic update to avoid an extra refresh self.enabled = 1 async def disable(self) -> None: """Disable the Valley Electricity Timer settings of the device.""" await self.rpc_channel.send_command( RoborockCommand.CLOSE_VALLEY_ELECTRICITY_TIMER, ) # Optimistic update to avoid an extra refresh self.enabled = 0 Python-roborock-python-roborock-d6da2db/roborock/devices/traits/v1/volume.py000066400000000000000000000017101513363643200275020ustar00rootroot00000000000000from dataclasses import dataclass, field from roborock.devices.traits.v1 import common from roborock.roborock_typing import RoborockCommand # TODO: This is currently the pattern for holding all the commands that hold a # single value, but it still seems too verbose. Maybe we can generate these # dynamically or somehow make them less code. @dataclass class SoundVolume(common.RoborockValueBase): """Dataclass for sound volume.""" volume: int | None = field(default=None, metadata={"roborock_value": True}) """Sound volume level (0-100).""" class SoundVolumeTrait(SoundVolume, common.V1TraitMixin): """Trait for controlling the sound volume of a Roborock device.""" command = RoborockCommand.GET_SOUND_VOLUME async def set_volume(self, volume: int) -> None: """Set the sound volume of the device.""" await self.rpc_channel.send_command(RoborockCommand.CHANGE_SOUND_VOLUME, params=[volume]) self.volume = volume Python-roborock-python-roborock-d6da2db/roborock/devices/traits/v1/wash_towel_mode.py000066400000000000000000000006531513363643200313600ustar00rootroot00000000000000"""Trait for wash towel mode.""" from roborock.data import WashTowelMode from roborock.device_features import is_wash_n_fill_dock from roborock.devices.traits.v1 import common from roborock.roborock_typing import RoborockCommand class WashTowelModeTrait(WashTowelMode, common.V1TraitMixin): """Trait for wash towel mode.""" command = RoborockCommand.GET_WASH_TOWEL_MODE requires_dock_type = is_wash_n_fill_dock Python-roborock-python-roborock-d6da2db/roborock/devices/transport/000077500000000000000000000000001513363643200260225ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/roborock/devices/transport/__init__.py000066400000000000000000000004401513363643200301310ustar00rootroot00000000000000"""Module for handling network connections to Roborock devices. This is used internally by the device manager for creating connections to Roborock devices. These modules contain common code, not specific to a particular device or application level protocol. """ __all__: list[str] = [] Python-roborock-python-roborock-d6da2db/roborock/devices/transport/channel.py000066400000000000000000000017251513363643200300110ustar00rootroot00000000000000"""Low-level interface for connections to Roborock devices.""" import logging from collections.abc import Callable from typing import Protocol from roborock.roborock_message import RoborockMessage _LOGGER = logging.getLogger(__name__) class Channel(Protocol): """A generic channel for establishing a connection with a Roborock device. Individual channel implementations have their own methods for speaking to the device that hide some of the protocol specific complexity, but they are still specialized for the device type and protocol. """ @property def is_connected(self) -> bool: """Return true if the channel is connected.""" ... @property def is_local_connected(self) -> bool: """Return true if the channel is connected locally.""" ... async def subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]: """Subscribe to messages from the device.""" ... Python-roborock-python-roborock-d6da2db/roborock/devices/transport/local_channel.py000066400000000000000000000265331513363643200311670ustar00rootroot00000000000000"""Module for communicating with Roborock devices over a local network.""" import asyncio import logging from collections.abc import Callable from dataclasses import dataclass from roborock.callbacks import CallbackList, decoder_callback from roborock.exceptions import RoborockConnectionException, RoborockException from roborock.protocol import create_local_decoder, create_local_encoder from roborock.protocols.v1_protocol import LocalProtocolVersion from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol from roborock.util import RoborockLoggerAdapter, get_next_int from .channel import Channel _LOGGER = logging.getLogger(__name__) _PORT = 58867 _TIMEOUT = 5.0 _PING_INTERVAL = 10 @dataclass class LocalChannelParams: """Parameters for local channel encoder/decoder.""" local_key: str connect_nonce: int ack_nonce: int | None @dataclass class _LocalProtocol(asyncio.Protocol): """Callbacks for the Roborock local client transport.""" messages_cb: Callable[[bytes], None] connection_lost_cb: Callable[[Exception | None], None] def data_received(self, data: bytes) -> None: """Called when data is received from the transport.""" self.messages_cb(data) def connection_lost(self, exc: Exception | None) -> None: """Called when the transport connection is lost.""" self.connection_lost_cb(exc) def get_running_loop() -> asyncio.AbstractEventLoop: """Get the running event loop, extracted for mocking purposes.""" return asyncio.get_running_loop() class LocalChannel(Channel): """Simple RPC-style channel for communicating with a device over a local network. Handles request/response correlation and timeouts, but leaves message format most parsing to higher-level components. """ def __init__(self, host: str, local_key: str, device_uid: str) -> None: self._host = host self._logger = RoborockLoggerAdapter(duid=device_uid, logger=_LOGGER) self._transport: asyncio.Transport | None = None self._protocol: _LocalProtocol | None = None self._subscribers: CallbackList[RoborockMessage] = CallbackList(self._logger) self._is_connected = False self._local_protocol_version: LocalProtocolVersion | None = None self._keep_alive_task: asyncio.Task[None] | None = None self._update_encoder_decoder( LocalChannelParams(local_key=local_key, connect_nonce=get_next_int(10000, 32767), ack_nonce=None) ) def _update_encoder_decoder(self, params: LocalChannelParams) -> None: """Update the encoder and decoder with new parameters. This is invoked once with an initial set of values used for protocol negotiation. Once negotiation completes, it is updated again to set the correct nonces for the follow up communications and updates the encoder and decoder functions accordingly. """ self._params = params self._encoder = create_local_encoder( local_key=params.local_key, connect_nonce=params.connect_nonce, ack_nonce=params.ack_nonce ) self._decoder = create_local_decoder( local_key=params.local_key, connect_nonce=params.connect_nonce, ack_nonce=params.ack_nonce ) # Callback to decode messages and dispatch to subscribers self._dispatch = decoder_callback(self._decoder, self._subscribers, self._logger) async def _do_hello(self, local_protocol_version: LocalProtocolVersion) -> LocalChannelParams | None: """Perform the initial handshaking and return encoder params if successful.""" self._logger.debug( "Attempting to use the %s protocol for client %s...", local_protocol_version, self._host, ) request = RoborockMessage( protocol=RoborockMessageProtocol.HELLO_REQUEST, version=local_protocol_version.encode(), random=self._params.connect_nonce, seq=1, ) try: response = await self._send_message( roborock_message=request, request_id=request.seq, response_protocol=RoborockMessageProtocol.HELLO_RESPONSE, ) self._logger.debug( "Client %s speaks the %s protocol.", self._host, local_protocol_version, ) return LocalChannelParams( local_key=self._params.local_key, connect_nonce=self._params.connect_nonce, ack_nonce=response.random ) except RoborockException as e: self._logger.debug( "Client %s did not respond or does not speak the %s protocol. %s", self._host, local_protocol_version, e, ) return None async def _hello(self): """Send hello to the device to negotiate protocol.""" attempt_versions = [LocalProtocolVersion.V1, LocalProtocolVersion.L01] if self._local_protocol_version: # Sort to try the preferred version first attempt_versions.sort(key=lambda v: v != self._local_protocol_version) for version in attempt_versions: params = await self._do_hello(version) if params is not None: self._local_protocol_version = version self._update_encoder_decoder(params) return raise RoborockException("Failed to connect to device with any known protocol") async def _ping(self) -> None: ping_message = RoborockMessage( protocol=RoborockMessageProtocol.PING_REQUEST, version=self.protocol_version.encode() ) await self._send_message( roborock_message=ping_message, request_id=ping_message.seq, response_protocol=RoborockMessageProtocol.PING_RESPONSE, ) async def _keep_alive_loop(self) -> None: while self._is_connected: try: await asyncio.sleep(_PING_INTERVAL) if self._is_connected: await self._ping() except asyncio.CancelledError: break except Exception: self._logger.debug("Keep-alive ping failed", exc_info=True) # Retry next interval @property def protocol_version(self) -> LocalProtocolVersion: """Return the negotiated local protocol version, or a sensible default.""" if self._local_protocol_version is not None: return self._local_protocol_version return LocalProtocolVersion.V1 @property def is_connected(self) -> bool: """Check if the channel is currently connected.""" return self._is_connected @property def is_local_connected(self) -> bool: """Check if the channel is currently connected locally.""" return self._is_connected async def connect(self) -> None: """Connect to the device and negotiate protocol.""" if self._is_connected: self._logger.debug("Unexpected call to connect when already connected") return loop = get_running_loop() protocol = _LocalProtocol(self._data_received, self._connection_lost) try: self._transport, self._protocol = await loop.create_connection(lambda: protocol, self._host, _PORT) self._is_connected = True except OSError as e: raise RoborockConnectionException(f"Failed to connect to {self._host}:{_PORT}") from e # Perform protocol negotiation try: await self._hello() self._keep_alive_task = asyncio.create_task(self._keep_alive_loop()) except RoborockException: # If protocol negotiation fails, clean up the connection state self.close() raise def _data_received(self, data: bytes) -> None: """Invoked when data is received on the stream.""" self._dispatch(data) def close(self) -> None: """Disconnect from the device.""" if self._keep_alive_task: self._keep_alive_task.cancel() self._keep_alive_task = None if self._transport: self._transport.close() else: self._logger.warning("Close called but transport is already None") self._transport = None self._is_connected = False def _connection_lost(self, exc: Exception | None) -> None: """Handle connection loss.""" self._logger.debug("Connection lost to %s", self._host, exc_info=exc) if self._keep_alive_task: self._keep_alive_task.cancel() self._keep_alive_task = None self._transport = None self._is_connected = False async def subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]: """Subscribe to all messages from the device.""" return self._subscribers.add_callback(callback) async def publish(self, message: RoborockMessage) -> None: """Send a command message. The caller is responsible for associating the message with its response. """ if not self._transport or not self._is_connected: raise RoborockConnectionException("Not connected to device") try: encoded_msg = self._encoder(message) except Exception as err: self._logger.exception("Error encoding MQTT message: %s", err) raise RoborockException(f"Failed to encode MQTT message: {err}") from err try: self._transport.write(encoded_msg) except Exception as err: self._logger.exception("Uncaught error sending command") raise RoborockException(f"Failed to send message: {message}") from err async def _send_message( self, roborock_message: RoborockMessage, request_id: int, response_protocol: int, ) -> RoborockMessage: """Send a raw message and wait for a raw response.""" future: asyncio.Future[RoborockMessage] = asyncio.Future() def find_response(response_message: RoborockMessage) -> None: if response_message.protocol == response_protocol and response_message.seq == request_id: future.set_result(response_message) unsub = await self.subscribe(find_response) try: await self.publish(roborock_message) return await asyncio.wait_for(future, timeout=_TIMEOUT) except TimeoutError as ex: future.cancel() raise RoborockException(f"Command timed out after {_TIMEOUT}s") from ex finally: unsub() # This module provides a factory function to create LocalChannel instances. # # TODO: Make a separate LocalSession and use it to manage retries with the host, # similar to how MqttSession works. For now this is a simple factory function # for creating channels. LocalSession = Callable[[str], LocalChannel] def create_local_session(local_key: str, device_uid: str) -> LocalSession: """Creates a local session which can create local channels. This plays a role similar to the MqttSession but is really just a factory for creating LocalChannel instances with the same local key. """ def create_local_channel(host: str) -> LocalChannel: """Create a LocalChannel instance for the given host.""" return LocalChannel(host, local_key, device_uid) return create_local_channel Python-roborock-python-roborock-d6da2db/roborock/devices/transport/mqtt_channel.py000066400000000000000000000076231513363643200310610ustar00rootroot00000000000000"""Modules for communicating with specific Roborock devices over MQTT.""" import logging from collections.abc import Callable from roborock.callbacks import decoder_callback from roborock.data import HomeDataDevice, RRiot, UserData from roborock.exceptions import RoborockException from roborock.mqtt.health_manager import HealthManager from roborock.mqtt.session import MqttParams, MqttSession, MqttSessionException from roborock.protocol import create_mqtt_decoder, create_mqtt_encoder from roborock.roborock_message import RoborockMessage from roborock.util import RoborockLoggerAdapter from .channel import Channel _LOGGER = logging.getLogger(__name__) class MqttChannel(Channel): """Simple RPC-style channel for communicating with a device over MQTT. Handles request/response correlation and timeouts, but leaves message format most parsing to higher-level components. """ def __init__(self, mqtt_session: MqttSession, duid: str, local_key: str, rriot: RRiot, mqtt_params: MqttParams): self._mqtt_session = mqtt_session self._duid = duid self._logger = RoborockLoggerAdapter(duid=duid, logger=_LOGGER) self._local_key = local_key self._rriot = rriot self._mqtt_params = mqtt_params self._decoder = create_mqtt_decoder(local_key) self._encoder = create_mqtt_encoder(local_key) @property def is_connected(self) -> bool: """Return true if the channel is connected. This passes through the underlying MQTT session's connected state. """ return self._mqtt_session.connected @property def health_manager(self) -> HealthManager: """Return the health manager for the session.""" return self._mqtt_session.health_manager @property def is_local_connected(self) -> bool: """Return true if the channel is connected locally.""" return False @property def _publish_topic(self) -> str: """Topic to send commands to the device.""" return f"rr/m/i/{self._rriot.u}/{self._mqtt_params.username}/{self._duid}" @property def _subscribe_topic(self) -> str: """Topic to receive responses from the device.""" return f"rr/m/o/{self._rriot.u}/{self._mqtt_params.username}/{self._duid}" async def subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]: """Subscribe to the device's response topic. The callback will be called with the message payload when a message is received. Returns a callable that can be used to unsubscribe from the topic. """ dispatch = decoder_callback(self._decoder, callback, _LOGGER) return await self._mqtt_session.subscribe(self._subscribe_topic, dispatch) async def publish(self, message: RoborockMessage) -> None: """Publish a command message. The caller is responsible for handling any responses and associating them with the incoming request. """ try: encoded_msg = self._encoder(message) except Exception as e: self._logger.exception("Error encoding MQTT message: %s", e) raise RoborockException(f"Failed to encode MQTT message: {e}") from e try: return await self._mqtt_session.publish(self._publish_topic, encoded_msg) except MqttSessionException as e: self._logger.debug("Error publishing MQTT message: %s", e) raise RoborockException(f"Failed to publish MQTT message: {e}") from e async def restart(self) -> None: """Restart the underlying MQTT session.""" await self._mqtt_session.restart() def create_mqtt_channel( user_data: UserData, mqtt_params: MqttParams, mqtt_session: MqttSession, device: HomeDataDevice ) -> MqttChannel: """Create a MQTT channel for the given device.""" return MqttChannel(mqtt_session, device.duid, device.local_key, user_data.rriot, mqtt_params) Python-roborock-python-roborock-d6da2db/roborock/diagnostics.py000066400000000000000000000120201513363643200252200ustar00rootroot00000000000000"""Diagnostics for debugging. A Diagnostics object can be used to track counts and latencies of various operations within a module. This can be useful for debugging performance issues or understanding usage patterns. This is an internal facing module and is not intended for public use. Diagnostics data is collected and exposed to clients via higher level APIs like the DeviceManager. """ from __future__ import annotations import time from collections import Counter from collections.abc import Generator, Mapping from contextlib import contextmanager from typing import Any, TypeVar, cast class Diagnostics: """A class that holds diagnostics information for a module. You can use this class to hold counter or for recording timing information that can be exported as a dictionary for debugging purposes. """ def __init__(self) -> None: """Initialize Diagnostics.""" self._counter: Counter = Counter() self._subkeys: dict[str, Diagnostics] = {} def increment(self, key: str, count: int = 1) -> None: """Increment a counter for the specified key/event.""" self._counter.update(Counter({key: count})) def elapsed(self, key_prefix: str, elapsed_ms: int = 1) -> None: """Track a latency event for the specified key/event prefix.""" self.increment(f"{key_prefix}_count", 1) self.increment(f"{key_prefix}_sum", elapsed_ms) def as_dict(self) -> Mapping[str, Any]: """Return diagnostics as a debug dictionary.""" data: dict[str, Any] = {k: self._counter[k] for k in self._counter} for k, d in self._subkeys.items(): v = d.as_dict() if not v: continue data[k] = v return data def subkey(self, key: str) -> Diagnostics: """Return sub-Diagnostics object with the specified subkey. This will create a new Diagnostics object if one does not already exist for the specified subkey. Stats from the sub-Diagnostics will be included in the parent Diagnostics when exported as a dictionary. Args: key: The subkey for the diagnostics. Returns: The Diagnostics object for the specified subkey. """ if key not in self._subkeys: self._subkeys[key] = Diagnostics() return self._subkeys[key] @contextmanager def timer(self, key_prefix: str) -> Generator[None, None, None]: """A context manager that records the timing of operations as a diagnostic.""" start = time.perf_counter() try: yield finally: end = time.perf_counter() ms = int((end - start) * 1000) self.elapsed(key_prefix, ms) def reset(self) -> None: """Clear all diagnostics, for testing.""" self._counter = Counter() for d in self._subkeys.values(): d.reset() T = TypeVar("T") REDACT_KEYS = { # Potential identifiers "localKey", "mac", "bssid", "sn", "ip", "u", "s", "h", "k", # Large binary blobs are entirely omitted "imageContent", "mapData", "rawApiResponse", # Home data "id", # We want to redact home_data.id but keep some other ids, see below "name", "productId", "ipAddress", "wifiName", "lat", "long", } KEEP_KEYS = { # Product information not unique per user "product.id", "product.schema.id", "product.schema.name", # Room ids are likely unique per user, but don't seem too sensitive and are # useful for debugging "rooms.id", } DEVICE_UID = "duid" REDACTED = "**REDACTED**" def redact_device_data(data: T, path: str = "") -> T | dict[str, Any]: """Redact sensitive data in a dict.""" if not isinstance(data, (Mapping, list)): return data if isinstance(data, list): return cast(T, [redact_device_data(item, path) for item in data]) redacted = {**data} for key, value in redacted.items(): curr_path = f"{path}.{key}" if path else key if key in KEEP_KEYS or curr_path in KEEP_KEYS: continue if key in REDACT_KEYS or curr_path in REDACT_KEYS: redacted[key] = REDACTED elif key == DEVICE_UID and isinstance(value, str): redacted[key] = redact_device_uid(value) elif isinstance(value, dict): redacted[key] = redact_device_data(value, curr_path) elif isinstance(value, list): redacted[key] = [redact_device_data(item, curr_path) for item in value] return redacted def redact_topic_name(topic: str) -> str: """Redact potentially identifying information from a topic name.""" parts = topic.split("/") redacted_parts = parts[:4] for part in parts[4:]: if len(part) <= 5: redacted_parts.append("*****") else: redacted_parts.append("*****" + part[-5:]) return "/".join(redacted_parts) def redact_device_uid(duid: str) -> str: """Redact a device UID to hide identifying information.""" return "******" + duid[-5:] Python-roborock-python-roborock-d6da2db/roborock/exceptions.py000066400000000000000000000051241513363643200251010ustar00rootroot00000000000000"""Roborock exceptions.""" from __future__ import annotations class RoborockException(Exception): """Class for Roborock exceptions.""" class RoborockTimeout(RoborockException): """Class for Roborock timeout exceptions.""" class RoborockConnectionException(RoborockException): """Class for Roborock connection exceptions.""" class RoborockBackoffException(RoborockException): """Class for Roborock exceptions when many retries were made.""" class VacuumError(RoborockException): """Class for vacuum errors.""" class CommandVacuumError(RoborockException): """Class for command vacuum errors.""" def __init__(self, command: str | None, vacuum_error: VacuumError): self.message = f"{command or 'unknown'}: {str(vacuum_error)}" super().__init__(self.message) class UnknownMethodError(RoborockException): """Class for an invalid method being sent.""" class RoborockAccountDoesNotExist(RoborockException): """Class for Roborock account does not exist exceptions.""" class RoborockUrlException(RoborockException): """Class for being unable to get the URL for the Roborock account.""" class RoborockInvalidCode(RoborockException): """Class for Roborock invalid code exceptions.""" class RoborockInvalidEmail(RoborockException): """Class for Roborock invalid formatted email exceptions.""" class RoborockInvalidUserAgreement(RoborockException): """Class for Roborock invalid user agreement exceptions.""" class RoborockNoUserAgreement(RoborockException): """Class for Roborock no user agreement exceptions.""" class RoborockInvalidCredentials(RoborockException): """Class for Roborock credentials have expired or changed.""" class RoborockTooFrequentCodeRequests(RoborockException): """Class for Roborock too frequent code requests exceptions.""" class RoborockMissingParameters(RoborockException): """Class for Roborock missing parameters exceptions.""" class RoborockTooManyRequest(RoborockException): """Class for Roborock too many request exceptions.""" class RoborockRateLimit(RoborockException): """Class for our rate limits exceptions.""" class RoborockNoResponseFromBaseURL(RoborockException): """We could not find an url that had a record of the given account.""" class RoborockDeviceBusy(RoborockException): """Class for Roborock device busy exceptions.""" class RoborockInvalidStatus(RoborockException): """Class for Roborock invalid status exceptions (device action locked).""" class RoborockUnsupportedFeature(RoborockException): """Class for Roborock unsupported feature exceptions.""" Python-roborock-python-roborock-d6da2db/roborock/map/000077500000000000000000000000001513363643200231215ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/roborock/map/__init__.py000066400000000000000000000002221513363643200252260ustar00rootroot00000000000000"""Module for Roborock map related data classes.""" from .map_parser import MapParserConfig, ParsedMapData __all__ = [ "MapParserConfig", ] Python-roborock-python-roborock-d6da2db/roborock/map/map_parser.py000066400000000000000000000067371513363643200256410ustar00rootroot00000000000000"""Module for parsing v1 Roborock map content.""" import io import logging from dataclasses import dataclass, field from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor from vacuum_map_parser_base.config.drawable import Drawable from vacuum_map_parser_base.config.image_config import ImageConfig from vacuum_map_parser_base.config.size import Size, Sizes from vacuum_map_parser_base.map_data import MapData from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser from roborock.exceptions import RoborockException _LOGGER = logging.getLogger(__name__) DEFAULT_DRAWABLES = { Drawable.CHARGER: True, Drawable.CLEANED_AREA: False, Drawable.GOTO_PATH: False, Drawable.IGNORED_OBSTACLES: False, Drawable.IGNORED_OBSTACLES_WITH_PHOTO: False, Drawable.MOP_PATH: False, Drawable.NO_CARPET_AREAS: False, Drawable.NO_GO_AREAS: False, Drawable.NO_MOPPING_AREAS: False, Drawable.OBSTACLES: False, Drawable.OBSTACLES_WITH_PHOTO: False, Drawable.PATH: True, Drawable.PREDICTED_PATH: False, Drawable.VACUUM_POSITION: True, Drawable.VIRTUAL_WALLS: False, Drawable.ZONES: False, } DEFAULT_MAP_SCALE = 4 MAP_FILE_FORMAT = "PNG" def _default_drawable_factory() -> list[Drawable]: return [drawable for drawable, default_value in DEFAULT_DRAWABLES.items() if default_value] @dataclass class MapParserConfig: """Configuration for the Roborock map parser.""" drawables: list[Drawable] = field(default_factory=_default_drawable_factory) """List of drawables to include in the map rendering.""" show_background: bool = True """Whether to show the background of the map.""" map_scale: int = DEFAULT_MAP_SCALE """Scale factor for the map.""" @dataclass class ParsedMapData: """Roborock Map Data. This class holds the parsed map data and the rendered image. """ image_content: bytes | None """The rendered image of the map in PNG format.""" map_data: MapData | None """The parsed map data which contains metadata for points on the map.""" class MapParser: """Roborock Map Parser. This class is used to parse the map data from the device and render it into an image. """ def __init__(self, config: MapParserConfig) -> None: """Initialize the MapParser.""" self._map_parser = _create_map_data_parser(config) def parse(self, map_bytes: bytes) -> ParsedMapData | None: """Parse map_bytes and return MapData and the image.""" try: parsed_map = self._map_parser.parse(map_bytes) except (IndexError, ValueError) as err: raise RoborockException("Failed to parse map data") from err if parsed_map.image is None: raise RoborockException("Failed to render map image") img_byte_arr = io.BytesIO() parsed_map.image.data.save(img_byte_arr, format=MAP_FILE_FORMAT) return ParsedMapData(image_content=img_byte_arr.getvalue(), map_data=parsed_map) def _create_map_data_parser(config: MapParserConfig) -> RoborockMapDataParser: """Create a RoborockMapDataParser based on the config entry.""" colors = ColorsPalette() if not config.show_background: colors = ColorsPalette({SupportedColor.MAP_OUTSIDE: (0, 0, 0, 0)}) return RoborockMapDataParser( colors, Sizes({k: v * config.map_scale for k, v in Sizes.SIZES.items() if k != Size.MOP_PATH_WIDTH}), config.drawables, ImageConfig(scale=config.map_scale), [], ) Python-roborock-python-roborock-d6da2db/roborock/mqtt/000077500000000000000000000000001513363643200233315ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/roborock/mqtt/__init__.py000066400000000000000000000006371513363643200254500ustar00rootroot00000000000000"""This module contains the low level MQTT client for the Roborock vacuum cleaner. This is not meant to be used directly, but rather as a base for the higher level modules. """ # This module is part of the Roborock Python library, which provides a way to # interact with Roborock devices using MQTT. It is not intended to be used directly, # but rather as a base for higher level modules. __all__: list[str] = [] Python-roborock-python-roborock-d6da2db/roborock/mqtt/health_manager.py000066400000000000000000000042401513363643200266420ustar00rootroot00000000000000"""A health manager for monitoring MQTT connections to Roborock devices. We observe a problem where sometimes the MQTT connection appears to be alive but no messages are being received. To mitigate this, we track consecutive timeouts and restart the connection if too many timeouts occur in succession. """ import datetime import logging from collections.abc import Awaitable, Callable _LOGGER = logging.getLogger(__name__) # Number of consecutive timeouts before considering the connection unhealthy. TIMEOUT_THRESHOLD = 3 # We won't restart the session more often than this interval. RESTART_COOLDOWN = datetime.timedelta(minutes=30) class HealthManager: """Manager for monitoring the health of MQTT connections. This tracks communication timeouts and can trigger restarts of the MQTT session if too many timeouts occur in succession. """ def __init__(self, restart: Callable[[], Awaitable[None]]) -> None: """Initialize the health manager. Args: restart: A callable to restart the MQTT session. """ self._consecutive_timeouts = 0 self._restart = restart self._last_restart: datetime.datetime | None = None async def on_success(self) -> None: """Record a successful communication event.""" self._consecutive_timeouts = 0 async def on_timeout(self) -> None: """Record a timeout event. This may trigger a restart of the MQTT session if too many timeouts have occurred in succession. """ self._consecutive_timeouts += 1 if self._consecutive_timeouts >= TIMEOUT_THRESHOLD: now = datetime.datetime.now(datetime.UTC) since_last = (now - self._last_restart) if self._last_restart else None if since_last is None or since_last >= RESTART_COOLDOWN: _LOGGER.debug( "Restarting MQTT session after %d consecutive timeouts (duration since last restart %s)", self._consecutive_timeouts, since_last, ) await self._restart() self._last_restart = now self._consecutive_timeouts = 0 Python-roborock-python-roborock-d6da2db/roborock/mqtt/roborock_session.py000066400000000000000000000464511513363643200273000ustar00rootroot00000000000000"""An MQTT session for sending and receiving messages. See create_mqtt_session for a factory function to create an MQTT session. This is a thin wrapper around the async MQTT client that handles dispatching messages from a topic to a callback function, since the async MQTT client does not support this out of the box. It also handles the authentication process and receiving messages from the vacuum cleaner. """ import asyncio import datetime import logging from collections.abc import Callable from contextlib import asynccontextmanager import aiomqtt from aiomqtt import MqttCodeError, MqttError, TLSParameters from roborock.callbacks import CallbackMap from roborock.diagnostics import Diagnostics, redact_topic_name from .health_manager import HealthManager from .session import MqttParams, MqttSession, MqttSessionException, MqttSessionUnauthorized _LOGGER = logging.getLogger(__name__) _MQTT_LOGGER = logging.getLogger(f"{__name__}.aiomqtt") CLIENT_KEEPALIVE = datetime.timedelta(seconds=60) TOPIC_KEEPALIVE = datetime.timedelta(seconds=60) # Exponential backoff parameters MIN_BACKOFF_INTERVAL = datetime.timedelta(seconds=10) MAX_BACKOFF_INTERVAL = datetime.timedelta(hours=6) BACKOFF_MULTIPLIER = 1.5 class MqttReasonCode: """MQTT Reason Codes used by Roborock devices. This is a subset of paho.mqtt.reasoncodes.ReasonCode where we would like different error handling behavior. """ RC_ERROR_UNAUTHORIZED = 135 class RoborockMqttSession(MqttSession): """An MQTT session for sending and receiving messages. You can start a session invoking the start() method which will connect to the MQTT broker. A caller may subscribe to a topic, and the session keeps track of which callbacks to invoke for each topic. The client is run as a background task that will run until shutdown. Once connected, the client will wait for messages to be received in a loop. If the connection is lost, the client will be re-created and reconnected. There is backoff to avoid spamming the broker with connection attempts. The client will automatically re-establish any subscriptions when the connection is re-established. """ def __init__( self, params: MqttParams, topic_idle_timeout: datetime.timedelta = TOPIC_KEEPALIVE, ): self._params = params self._reconnect_task: asyncio.Task[None] | None = None self._healthy = False self._stop = False self._backoff = MIN_BACKOFF_INTERVAL self._client: aiomqtt.Client | None = None self._client_subscribed_topics: set[str] = set() self._client_lock = asyncio.Lock() self._listeners: CallbackMap[str, bytes] = CallbackMap(_LOGGER) self._connection_task: asyncio.Task[None] | None = None self._topic_idle_timeout = topic_idle_timeout self._idle_timers: dict[str, asyncio.Task[None]] = {} self._diagnostics = params.diagnostics self._health_manager = HealthManager(self.restart) self._unauthorized_hook = params.unauthorized_hook @property def connected(self) -> bool: """True if the session is connected to the broker.""" return self._healthy @property def health_manager(self) -> HealthManager: """Return the health manager for the session.""" return self._health_manager async def start(self) -> None: """Start the MQTT session. This has special behavior for the first connection attempt where any failures are raised immediately. This is to allow the caller to handle the failure and retry if desired itself. Once connected, the session will retry connecting in the background. """ self._diagnostics.increment("start_attempt") start_future: asyncio.Future[None] = asyncio.Future() loop = asyncio.get_event_loop() self._reconnect_task = loop.create_task(self._run_reconnect_loop(start_future)) try: await start_future except MqttCodeError as err: self._diagnostics.increment(f"start_failure:{err.rc}") if err.rc == MqttReasonCode.RC_ERROR_UNAUTHORIZED: raise MqttSessionUnauthorized(f"Authorization error starting MQTT session: {err}") from err raise MqttSessionException(f"Error starting MQTT session: {err}") from err except MqttError as err: self._diagnostics.increment("start_failure:unknown") raise MqttSessionException(f"Error starting MQTT session: {err}") from err except Exception as err: self._diagnostics.increment("start_failure:uncaught") raise MqttSessionException(f"Unexpected error starting session: {err}") from err else: self._diagnostics.increment("start_success") _LOGGER.debug("MQTT session started successfully") async def close(self) -> None: """Cancels the MQTT loop and shutdown the client library.""" self._diagnostics.increment("close") self._stop = True tasks = [task for task in [self._connection_task, self._reconnect_task, *self._idle_timers.values()] if task] self._connection_task = None self._reconnect_task = None self._idle_timers.clear() for task in tasks: task.cancel() try: await asyncio.gather(*tasks, return_exceptions=True) except asyncio.CancelledError: pass self._healthy = False async def restart(self) -> None: """Force the session to disconnect and reconnect. The active connection task will be cancelled and restarted in the background, retried by the reconnect loop. This is a no-op if there is no active connection. """ _LOGGER.info("Forcing MQTT session restart") self._diagnostics.increment("restart") if self._connection_task: self._connection_task.cancel() else: _LOGGER.debug("No message loop task to cancel") async def _run_reconnect_loop(self, start_future: asyncio.Future[None] | None) -> None: """Run the MQTT loop.""" _LOGGER.info("Starting MQTT session") self._diagnostics.increment("start_loop") while True: try: self._connection_task = asyncio.create_task(self._run_connection(start_future)) await self._connection_task except asyncio.CancelledError: _LOGGER.debug("MQTT connection task cancelled") except Exception: # Exceptions are logged and handled in _run_connection. # There is a special case for exceptions on startup where we return # immediately. Otherwise, we let the reconnect loop retry with # backoff when the reconnect loop is active. if start_future and start_future.done() and start_future.exception(): return self._healthy = False start_future = None if self._stop: _LOGGER.debug("MQTT session closed, stopping retry loop") return _LOGGER.info("MQTT session disconnected, retrying in %s seconds", self._backoff.total_seconds()) self._diagnostics.increment("reconnect_wait") await asyncio.sleep(self._backoff.total_seconds()) self._backoff = min(self._backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF_INTERVAL) async def _run_connection(self, start_future: asyncio.Future[None] | None) -> None: """Connect to the MQTT broker and listen for messages. This is the primary connection loop for the MQTT session that is long running and processes incoming messages. If the connection is lost, this method will exit. """ try: with self._diagnostics.timer("connection"): async with self._mqtt_client(self._params) as client: self._backoff = MIN_BACKOFF_INTERVAL self._healthy = True _LOGGER.info("MQTT Session connected.") if start_future and not start_future.done(): start_future.set_result(None) _LOGGER.debug("Processing MQTT messages") async for message in client.messages: _LOGGER.debug("Received message: %s", message) with self._diagnostics.timer("dispatch_message"): self._listeners(message.topic.value, message.payload) except MqttCodeError as err: self._diagnostics.increment(f"connect_failure:{err.rc}") if start_future and not start_future.done(): _LOGGER.debug("MQTT error starting session: %s", err) start_future.set_exception(err) else: _LOGGER.debug("MQTT error: %s", err) if err.rc == MqttReasonCode.RC_ERROR_UNAUTHORIZED and self._unauthorized_hook: _LOGGER.info("MQTT unauthorized/rate-limit error received, setting backoff to maximum") self._unauthorized_hook() self._backoff = MAX_BACKOFF_INTERVAL raise except MqttError as err: self._diagnostics.increment("connect_failure:unknown") if start_future and not start_future.done(): _LOGGER.info("MQTT error starting session: %s", err) start_future.set_exception(err) else: _LOGGER.info("MQTT error: %s", err) raise except Exception as err: self._diagnostics.increment("connect_failure:uncaught") # This error is thrown when the MQTT loop is cancelled # and the generator is not stopped. if "generator didn't stop" in str(err) or "generator didn't yield" in str(err): _LOGGER.debug("MQTT loop was cancelled") return if start_future and not start_future.done(): _LOGGER.error("Uncaught error starting MQTT session: %s", err) start_future.set_exception(err) else: _LOGGER.exception("Uncaught error during MQTT session: %s", err) raise @asynccontextmanager async def _mqtt_client(self, params: MqttParams) -> aiomqtt.Client: """Connect to the MQTT broker and listen for messages.""" _LOGGER.debug("Connecting to %s:%s for %s", params.host, params.port, params.username) try: async with aiomqtt.Client( hostname=params.host, port=params.port, username=params.username, password=params.password, keepalive=int(CLIENT_KEEPALIVE.total_seconds()), protocol=aiomqtt.ProtocolVersion.V5, tls_params=TLSParameters() if params.tls else None, timeout=params.timeout, logger=_MQTT_LOGGER, ) as client: _LOGGER.debug("Connected to MQTT broker") # Re-establish any existing subscriptions async with self._client_lock: self._client = client for topic in self._client_subscribed_topics: self._diagnostics.increment("resubscribe") _LOGGER.debug("Re-establishing subscription to topic %s", redact_topic_name(topic)) # TODO: If this fails it will break the whole connection. Make # this retry again in the background with backoff. await client.subscribe(topic) yield client finally: async with self._client_lock: self._client = None async def subscribe(self, topic: str, callback: Callable[[bytes], None]) -> Callable[[], None]: """Subscribe to messages on the specified topic and invoke the callback for new messages. The callback will be called with the message payload as a bytes object. The callback should not block since it runs in the async loop. It should not raise any exceptions. The returned callable unsubscribes from the topic when called, but will delay actual unsubscription for the idle timeout period. If a new subscription comes in during the timeout, the timer is cancelled and the subscription is reused. """ _LOGGER.debug("Subscribing to topic %s", redact_topic_name(topic)) # If there is an idle timer for this topic, cancel it (reuse subscription) if idle_timer := self._idle_timers.pop(topic, None): self._diagnostics.increment("unsubscribe_idle_cancel") idle_timer.cancel() _LOGGER.debug("Cancelled idle timer for topic %s (reused subscription)", redact_topic_name(topic)) unsub = self._listeners.add_callback(topic, callback) async with self._client_lock: if topic not in self._client_subscribed_topics: self._client_subscribed_topics.add(topic) if self._client: _LOGGER.debug("Establishing subscription to topic %s", topic) try: with self._diagnostics.timer("subscribe"): await self._client.subscribe(topic) except MqttError as err: # Clean up the callback if subscription fails unsub() self._client_subscribed_topics.discard(topic) raise MqttSessionException(f"Error subscribing to topic: {err}") from err else: self._diagnostics.increment("subscribe_pending") _LOGGER.debug("Client not connected, will establish subscription later") def schedule_unsubscribe() -> None: async def idle_unsubscribe(): try: await asyncio.sleep(self._topic_idle_timeout.total_seconds()) # Only unsubscribe if there are no callbacks left for this topic if not self._listeners.get_callbacks(topic): async with self._client_lock: # Check again if we have listeners, in case a subscribe happened # while we were waiting for the lock or after we popped the timer. if self._listeners.get_callbacks(topic): _LOGGER.debug("Skipping unsubscribe for %s, new listeners added", topic) return self._idle_timers.pop(topic, None) self._client_subscribed_topics.discard(topic) if self._client: _LOGGER.debug("Idle timeout expired, unsubscribing from topic %s", topic) try: await self._client.unsubscribe(topic) except MqttError as err: _LOGGER.warning("Error unsubscribing from topic %s: %s", topic, err) except asyncio.CancelledError: _LOGGER.debug("Idle unsubscribe for topic %s cancelled", topic) # Start the idle timer task task = asyncio.create_task(idle_unsubscribe()) self._idle_timers[topic] = task def delayed_unsub(): self._diagnostics.increment("unsubscribe") unsub() # Remove the callback from CallbackMap # If no more callbacks for this topic, start idle timer if not self._listeners.get_callbacks(topic): self._diagnostics.increment("unsubscribe_idle_start") schedule_unsubscribe() else: _LOGGER.debug("Unsubscribing topic %s, still have active callbacks", topic) return delayed_unsub async def publish(self, topic: str, message: bytes) -> None: """Publish a message on the topic.""" _LOGGER.debug("Sending message to topic %s: %s", topic, message) client: aiomqtt.Client async with self._client_lock: if self._client is None: raise MqttSessionException("Could not publish message, MQTT client not connected") client = self._client try: with self._diagnostics.timer("publish"): await client.publish(topic, message) except MqttError as err: raise MqttSessionException(f"Error publishing message: {err}") from err class LazyMqttSession(MqttSession): """An MQTT session that is started on first attempt to subscribe. This is a wrapper around an existing MqttSession that will only start the underlying session when the first attempt to subscribe or publish is made. """ def __init__(self, session: RoborockMqttSession, diagnostics: Diagnostics) -> None: """Initialize the lazy session with an existing session.""" self._lock = asyncio.Lock() self._started = False self._session = session self._diagnostics = diagnostics @property def connected(self) -> bool: """True if the session is connected to the broker.""" return self._session.connected @property def health_manager(self) -> HealthManager: """Return the health manager for the session.""" return self._session.health_manager async def _maybe_start(self) -> None: """Start the MQTT session if not already started.""" async with self._lock: if not self._started: self._diagnostics.increment("start") await self._session.start() self._started = True async def subscribe(self, device_id: str, callback: Callable[[bytes], None]) -> Callable[[], None]: """Invoke the callback when messages are received on the topic. The returned callable unsubscribes from the topic when called. """ await self._maybe_start() return await self._session.subscribe(device_id, callback) async def publish(self, topic: str, message: bytes) -> None: """Publish a message on the specified topic. This will raise an exception if the message could not be sent. """ await self._maybe_start() return await self._session.publish(topic, message) async def close(self) -> None: """Cancels the mqtt loop. This will close the underlying session and will not allow it to be restarted again. """ await self._session.close() async def restart(self) -> None: """Force the session to disconnect and reconnect.""" await self._session.restart() async def create_mqtt_session(params: MqttParams) -> MqttSession: """Create an MQTT session. This function is a factory for creating an MQTT session. This will raise an exception if initial attempt to connect fails. Once connected, the session will retry connecting on failure in the background. """ session = RoborockMqttSession(params) await session.start() return session async def create_lazy_mqtt_session(params: MqttParams) -> MqttSession: """Create a lazy MQTT session. This function is a factory for creating an MQTT session that will only connect when the first attempt to subscribe or publish is made. """ return LazyMqttSession(RoborockMqttSession(params), diagnostics=params.diagnostics.subkey("lazy_mqtt")) Python-roborock-python-roborock-d6da2db/roborock/mqtt/session.py000066400000000000000000000063521513363643200253740ustar00rootroot00000000000000"""An MQTT session for sending and receiving messages.""" from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass, field from roborock.diagnostics import Diagnostics from roborock.exceptions import RoborockException from roborock.mqtt.health_manager import HealthManager DEFAULT_TIMEOUT = 30.0 SessionUnauthorizedHook = Callable[[], None] @dataclass class MqttParams: """MQTT parameters for the connection.""" host: str """MQTT host to connect to.""" port: int """MQTT port to connect to.""" tls: bool """Use TLS for the connection.""" username: str """MQTT username to use for authentication.""" password: str """MQTT password to use for authentication.""" timeout: float = DEFAULT_TIMEOUT """Timeout for communications with the broker in seconds.""" diagnostics: Diagnostics = field(default_factory=Diagnostics) """Diagnostics object for tracking MQTT session stats. This defaults to a new Diagnostics object, but the common case is the caller will provide their own (e.g., from a DeviceManager) so that the shared MQTT session diagnostics are included in the overall diagnostics. """ unauthorized_hook: SessionUnauthorizedHook | None = None """Optional hook invoked when an unauthorized error is received. This may be invoked by the background reconnect logic when an unauthorized error is received from the broker. The caller may use this hook to refresh credentials or take other actions as needed. """ class MqttSession(ABC): """An MQTT session for sending and receiving messages.""" @property @abstractmethod def connected(self) -> bool: """True if the session is connected to the broker.""" @property @abstractmethod def health_manager(self) -> HealthManager: """Return the health manager for the session.""" @abstractmethod async def subscribe(self, device_id: str, callback: Callable[[bytes], None]) -> Callable[[], None]: """Invoke the callback when messages are received on the topic. The returned callable unsubscribes from the topic when called. """ @abstractmethod async def publish(self, topic: str, message: bytes) -> None: """Publish a message on the specified topic. This will raise an exception if the message could not be sent. """ @abstractmethod async def restart(self) -> None: """Force the session to disconnect and reconnect.""" @abstractmethod async def close(self) -> None: """Cancels the mqtt loop""" class MqttSessionException(RoborockException): """Raised when there is an error communicating with MQTT.""" class MqttSessionUnauthorized(RoborockException): """Raised when there is an authorization error communicating with MQTT. This error may be raised in multiple scenarios so there is not a well defined behavior for how the caller should behave. The two cases are: - Rate limiting is in effect and the caller should retry after some time. - The credentials are invalid and the caller needs to obtain new credentials However, it is observed that obtaining new credentials may resolve the issue in both cases. """ Python-roborock-python-roborock-d6da2db/roborock/protocol.py000066400000000000000000000474051513363643200245710ustar00rootroot00000000000000from __future__ import annotations import binascii import gzip import hashlib import logging from collections.abc import Callable from urllib.parse import urlparse from construct import ( # type: ignore Bytes, Checksum, ChecksumError, Construct, Container, GreedyBytes, GreedyRange, Int16ub, Int32ub, Optional, Peek, RawCopy, Struct, bytestringtype, stream_seek, stream_tell, ) from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad from roborock.data import RRiot from roborock.exceptions import RoborockException from roborock.mqtt.session import MqttParams from roborock.roborock_message import RoborockMessage _LOGGER = logging.getLogger(__name__) SALT = b"TXdfu$jyZ#TZHsg4" A01_HASH = "726f626f726f636b2d67a6d6da" B01_HASH = "5wwh9ikChRjASpMU8cxg7o1d2E" AP_CONFIG = 1 SOCK_DISCOVERY = 2 def md5hex(message: str) -> str: md5 = hashlib.md5() md5.update(message.encode()) return md5.hexdigest() class Utils: """Util class for protocol manipulation.""" @staticmethod def verify_token(token: bytes): """Checks if the given token is of correct type and length.""" if not isinstance(token, bytes): raise TypeError("Token must be bytes") if len(token) != 16: raise ValueError("Wrong token length") @staticmethod def ensure_bytes(msg: bytes | str) -> bytes: if isinstance(msg, str): return msg.encode() return msg @staticmethod def encode_timestamp(_timestamp: int) -> bytes: hex_value = f"{_timestamp:x}".zfill(8) return "".join(list(map(lambda idx: hex_value[idx], [5, 6, 3, 7, 1, 2, 0, 4]))).encode() @staticmethod def md5(data: bytes) -> bytes: """Calculates a md5 hashsum for the given bytes object.""" checksum = hashlib.md5() # nosec checksum.update(data) return checksum.digest() @staticmethod def encrypt_ecb(plaintext: bytes, token: bytes) -> bytes: """Encrypt plaintext with a given token using ecb mode. :param bytes plaintext: Plaintext (json) to encrypt :param bytes token: Token to use :return: Encrypted bytes """ if not isinstance(plaintext, bytes): raise TypeError("plaintext requires bytes") Utils.verify_token(token) cipher = AES.new(token, AES.MODE_ECB) if plaintext: plaintext = pad(plaintext, AES.block_size) return cipher.encrypt(plaintext) return plaintext @staticmethod def decrypt_ecb(ciphertext: bytes, token: bytes) -> bytes: """Decrypt ciphertext with a given token using ecb mode. :param bytes ciphertext: Ciphertext to decrypt :param bytes token: Token to use :return: Decrypted bytes object """ if not isinstance(ciphertext, bytes): raise TypeError("ciphertext requires bytes") if ciphertext: Utils.verify_token(token) aes_key = token decipher = AES.new(aes_key, AES.MODE_ECB) return unpad(decipher.decrypt(ciphertext), AES.block_size) return ciphertext @staticmethod def encrypt_cbc(plaintext: bytes, token: bytes) -> bytes: """Encrypt plaintext with a given token using cbc mode. This is currently used for testing purposes only. :param bytes plaintext: Plaintext (json) to encrypt :param bytes token: Token to use :return: Encrypted bytes """ if not isinstance(plaintext, bytes): raise TypeError("plaintext requires bytes") Utils.verify_token(token) iv = bytes(AES.block_size) cipher = AES.new(token, AES.MODE_CBC, iv) if plaintext: plaintext = pad(plaintext, AES.block_size) return cipher.encrypt(plaintext) return plaintext @staticmethod def decrypt_cbc(ciphertext: bytes, token: bytes) -> bytes: """Decrypt ciphertext with a given token using cbc mode. :param bytes ciphertext: Ciphertext to decrypt :param bytes token: Token to use :return: Decrypted bytes object """ if not isinstance(ciphertext, bytes): raise TypeError("ciphertext requires bytes") if ciphertext: Utils.verify_token(token) iv = bytes(AES.block_size) decipher = AES.new(token, AES.MODE_CBC, iv) return unpad(decipher.decrypt(ciphertext), AES.block_size) return ciphertext @staticmethod def _l01_key(local_key: str, timestamp: int) -> bytes: """Derive key for L01 protocol.""" hash_input = Utils.encode_timestamp(timestamp) + Utils.ensure_bytes(local_key) + SALT return hashlib.sha256(hash_input).digest() @staticmethod def _l01_iv(timestamp: int, nonce: int, sequence: int) -> bytes: """Derive IV for L01 protocol.""" digest_input = sequence.to_bytes(4, "big") + nonce.to_bytes(4, "big") + timestamp.to_bytes(4, "big") digest = hashlib.sha256(digest_input).digest() return digest[:12] @staticmethod def _l01_aad(timestamp: int, nonce: int, sequence: int, connect_nonce: int, ack_nonce: int | None = None) -> bytes: """Derive AAD for L01 protocol.""" return ( sequence.to_bytes(4, "big") + connect_nonce.to_bytes(4, "big") + (ack_nonce.to_bytes(4, "big") if ack_nonce is not None else b"") + nonce.to_bytes(4, "big") + timestamp.to_bytes(4, "big") ) @staticmethod def encrypt_gcm_l01( plaintext: bytes, local_key: str, timestamp: int, sequence: int, nonce: int, connect_nonce: int, ack_nonce: int | None = None, ) -> bytes: """Encrypt plaintext for L01 protocol using AES-256-GCM.""" if not isinstance(plaintext, bytes): raise TypeError("plaintext requires bytes") key = Utils._l01_key(local_key, timestamp) iv = Utils._l01_iv(timestamp, nonce, sequence) aad = Utils._l01_aad(timestamp, nonce, sequence, connect_nonce, ack_nonce) cipher = AES.new(key, AES.MODE_GCM, nonce=iv) cipher.update(aad) ciphertext, tag = cipher.encrypt_and_digest(plaintext) return ciphertext + tag @staticmethod def decrypt_gcm_l01( payload: bytes, local_key: str, timestamp: int, sequence: int, nonce: int, connect_nonce: int, ack_nonce: int, ) -> bytes: """Decrypt payload for L01 protocol using AES-256-GCM.""" if not isinstance(payload, bytes): raise TypeError("payload requires bytes") key = Utils._l01_key(local_key, timestamp) iv = Utils._l01_iv(timestamp, nonce, sequence) aad = Utils._l01_aad(timestamp, nonce, sequence, connect_nonce, ack_nonce) if len(payload) < 16: raise ValueError("Invalid payload length for GCM decryption") tag = payload[-16:] ciphertext = payload[:-16] cipher = AES.new(key, AES.MODE_GCM, nonce=iv) cipher.update(aad) try: return cipher.decrypt_and_verify(ciphertext, tag) except ValueError as e: raise RoborockException("GCM tag verification failed") from e @staticmethod def crc(data: bytes) -> int: """Gather bytes for checksum calculation.""" return binascii.crc32(data) @staticmethod def decompress(compressed_data: bytes): """Decompress data using gzip.""" return gzip.decompress(compressed_data) class EncryptionAdapter(Construct): """Adapter to handle communication encryption.""" def __init__(self, token_func: Callable): super().__init__() self.token_func = token_func def _parse(self, stream, context, path): subcon1 = Optional(Int16ub) length = subcon1.parse_stream(stream, **context) if not length: if length == 0: subcon1.parse_stream(stream, **context) # seek 2 return None subcon2 = Bytes(length) obj = subcon2.parse_stream(stream, **context) return self._decode(obj, context, path) def _build(self, obj, stream, context, path): if obj is not None: obj2 = self._encode(obj, context, path) subcon1 = Int16ub length = len(obj2) subcon1.build_stream(length, stream, **context) subcon2 = Bytes(length) subcon2.build_stream(obj2, stream, **context) return obj def _encode(self, obj, context, _): """Encrypt the given payload with the token stored in the context. :param obj: JSON object to encrypt """ if context.version == b"A01": iv = md5hex(format(context.random, "08x") + A01_HASH)[8:24] decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8")) return decipher.encrypt(obj) elif context.version == b"B01": iv = md5hex(f"{context.random:08x}" + B01_HASH)[9:25] decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8")) return decipher.encrypt(pad(obj, AES.block_size)) elif context.version == b"L01": return Utils.encrypt_gcm_l01( plaintext=obj, local_key=context.search("local_key"), timestamp=context.timestamp, sequence=context.seq, nonce=context.random, connect_nonce=context.search("connect_nonce"), ack_nonce=context.search("ack_nonce"), ) token = self.token_func(context) encrypted = Utils.encrypt_ecb(obj, token) return encrypted def _decode(self, obj, context, _): """Decrypts the given payload with the token stored in the context.""" if context.version == b"A01": iv = md5hex(format(context.random, "08x") + A01_HASH)[8:24] decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8")) return decipher.decrypt(obj) elif context.version == b"B01": iv = md5hex(f"{context.random:08x}" + B01_HASH)[9:25] decipher = AES.new(bytes(context.search("local_key"), "utf-8"), AES.MODE_CBC, bytes(iv, "utf-8")) return unpad(decipher.decrypt(obj), AES.block_size) elif context.version == b"L01": return Utils.decrypt_gcm_l01( payload=obj, local_key=context.search("local_key"), timestamp=context.timestamp, sequence=context.seq, nonce=context.random, connect_nonce=context.search("connect_nonce"), ack_nonce=context.search("ack_nonce"), ) token = self.token_func(context) decrypted = Utils.decrypt_ecb(obj, token) return decrypted class OptionalChecksum(Checksum): def _parse(self, stream, context, path): if not context.message.value.payload: return hash1 = self.checksumfield.parse_stream(stream, **context) hash2 = self.hashfunc(self.bytesfunc(context)) if hash1 != hash2: raise ChecksumError( f"wrong checksum, read {hash1 if not isinstance(hash1, bytestringtype) else binascii.hexlify(hash1)}, " f"computed {hash2 if not isinstance(hash2, bytestringtype) else binascii.hexlify(hash2)}", path=path, ) return hash1 class PrefixedStruct(Struct): def _parse(self, stream, context, path): subcon1 = Peek(Optional(Bytes(3))) peek_version = subcon1.parse_stream(stream, **context) valid_versions = (b"1.0", b"A01", b"B01", b"L01") if peek_version not in valid_versions: # Current stream position does not start with a valid version. # Scan forward to find one. current_pos = stream_tell(stream, path) # Read remaining data to find a valid header data = stream.read() if not data: # EOF reached, let the parser fail naturally without logging stream_seek(stream, current_pos, 0, path) return super()._parse(stream, context, path) start_index = -1 # Find the earliest occurrence of any valid version in a single pass for i in range(len(data) - 2): if data[i : i + 3] in valid_versions: start_index = i break if start_index != -1: # Found a valid version header at `start_index`. # Seek to that position (original_pos + index). if start_index != 4: # 4 is the typical/expected amount we prune off, # therefore, we only want a debug if we have a different length. _LOGGER.debug("Stripping %d bytes of invalid data from stream", start_index) stream_seek(stream, current_pos + start_index, 0, path) else: _LOGGER.debug("No valid version header found in stream, continuing anyways...") # Seek back to the original position to avoid parsing at EOF stream_seek(stream, current_pos, 0, path) return super()._parse(stream, context, path) def _build(self, obj, stream, context, path): prefixed = context.search("prefixed") if not prefixed: return super()._build(obj, stream, context, path) offset = stream_tell(stream, path) stream_seek(stream, offset + 4, 0, path) super()._build(obj, stream, context, path) new_offset = stream_tell(stream, path) subcon1 = Bytes(4) stream_seek(stream, offset, 0, path) subcon1.build_stream(new_offset - offset - subcon1.sizeof(**context), stream, **context) stream_seek(stream, new_offset + 4, 0, path) return obj _Message = RawCopy( Struct( "version" / Bytes(3), "seq" / Int32ub, "random" / Int32ub, "timestamp" / Int32ub, "protocol" / Int16ub, "payload" / EncryptionAdapter( lambda ctx: Utils.md5( Utils.encode_timestamp(ctx.timestamp) + Utils.ensure_bytes(ctx.search("local_key")) + SALT ), ), ) ) _Messages = Struct( "messages" / GreedyRange( PrefixedStruct( "message" / _Message, "checksum" / OptionalChecksum(Optional(Int32ub), Utils.crc, lambda ctx: ctx.message.data), ) ), "remaining" / Optional(GreedyBytes), ) class _Parser: def __init__(self, con: Construct, required_local_key: bool): self.con = con self.required_local_key = required_local_key def parse( self, data: bytes, local_key: str | None = None, connect_nonce: int | None = None, ack_nonce: int | None = None ) -> tuple[list[RoborockMessage], bytes]: if self.required_local_key and local_key is None: raise RoborockException("Local key is required") parsed = self.con.parse(data, local_key=local_key, connect_nonce=connect_nonce, ack_nonce=ack_nonce) parsed_messages = [Container({"message": parsed.message})] if parsed.get("message") else parsed.messages messages = [] for message in parsed_messages: messages.append( RoborockMessage( version=message.message.value.version, seq=message.message.value.get("seq"), random=message.message.value.get("random"), timestamp=message.message.value.get("timestamp"), protocol=message.message.value.get("protocol"), payload=message.message.value.payload, ) ) remaining = parsed.get("remaining") or b"" return messages, remaining def build( self, roborock_messages: list[RoborockMessage] | RoborockMessage, local_key: str, prefixed: bool = True, connect_nonce: int | None = None, ack_nonce: int | None = None, ) -> bytes: if isinstance(roborock_messages, RoborockMessage): roborock_messages = [roborock_messages] messages = [] for roborock_message in roborock_messages: messages.append( { "message": { "value": { "version": roborock_message.version, "seq": roborock_message.seq, "random": roborock_message.random, "timestamp": roborock_message.timestamp, "protocol": roborock_message.protocol, "payload": roborock_message.payload, } }, } ) return self.con.build( {"messages": [message for message in messages], "remaining": b""}, local_key=local_key, prefixed=prefixed, connect_nonce=connect_nonce, ack_nonce=ack_nonce, ) MessageParser: _Parser = _Parser(_Messages, True) def create_mqtt_params(rriot: RRiot) -> MqttParams: """Return the MQTT parameters for this user.""" url = urlparse(rriot.r.m) if not isinstance(url.hostname, str): raise RoborockException(f"Url parsing '{rriot.r.m}' returned an invalid hostname") if not url.port: raise RoborockException(f"Url parsing '{rriot.r.m}' returned an invalid port") hashed_user = md5hex(rriot.u + ":" + rriot.k)[2:10] hashed_password = md5hex(rriot.s + ":" + rriot.k)[16:] return MqttParams( host=str(url.hostname), port=url.port, tls=(url.scheme == "ssl"), username=hashed_user, password=hashed_password, ) Decoder = Callable[[bytes], list[RoborockMessage]] Encoder = Callable[[RoborockMessage], bytes] def create_mqtt_decoder(local_key: str) -> Decoder: """Create a decoder for MQTT messages.""" def decode(data: bytes) -> list[RoborockMessage]: """Parse the given data into Roborock messages.""" messages, _ = MessageParser.parse(data, local_key) return messages return decode def create_mqtt_encoder(local_key: str) -> Encoder: """Create an encoder for MQTT messages.""" def encode(messages: RoborockMessage) -> bytes: """Build the given Roborock messages into a byte string.""" return MessageParser.build(messages, local_key, prefixed=False) return encode def create_local_decoder(local_key: str, connect_nonce: int | None = None, ack_nonce: int | None = None) -> Decoder: """Create a decoder for local API messages.""" # This buffer is used to accumulate bytes until a complete message can be parsed. # It is defined outside the decode function to maintain state across calls. buffer: bytes = b"" def decode(bytes_data: bytes) -> list[RoborockMessage]: """Parse the given data into Roborock messages.""" nonlocal buffer buffer += bytes_data parsed_messages, remaining = MessageParser.parse( buffer, local_key=local_key, connect_nonce=connect_nonce, ack_nonce=ack_nonce ) if remaining: _LOGGER.debug("Found %d extra bytes: %s", len(remaining), remaining) buffer = remaining return parsed_messages return decode def create_local_encoder(local_key: str, connect_nonce: int | None = None, ack_nonce: int | None = None) -> Encoder: """Create an encoder for local API messages.""" def encode(message: RoborockMessage) -> bytes: """Called when data is sent to the transport.""" return MessageParser.build(message, local_key=local_key, connect_nonce=connect_nonce, ack_nonce=ack_nonce) return encode Python-roborock-python-roborock-d6da2db/roborock/protocols/000077500000000000000000000000001513363643200243705ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/roborock/protocols/__init__.py000066400000000000000000000001221513363643200264740ustar00rootroot00000000000000"""Protocols for communicating with Roborock devices.""" __all__: list[str] = [] Python-roborock-python-roborock-d6da2db/roborock/protocols/a01_protocol.py000066400000000000000000000045341513363643200272520ustar00rootroot00000000000000"""Roborock A01 Protocol encoding and decoding.""" import json import logging from collections.abc import Callable from typing import Any from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad from roborock.exceptions import RoborockException from roborock.roborock_message import ( RoborockDyadDataProtocol, RoborockMessage, RoborockMessageProtocol, RoborockZeoProtocol, ) _LOGGER = logging.getLogger(__name__) A01_VERSION = b"A01" def _no_encode(value: Any) -> Any: return value def encode_mqtt_payload( data: dict[RoborockDyadDataProtocol, Any] | dict[RoborockZeoProtocol, Any] | dict[RoborockDyadDataProtocol | RoborockZeoProtocol, Any], value_encoder: Callable[[Any], Any] | None = None, ) -> RoborockMessage: """Encode payload for A01 commands over MQTT. Args: data: The data to encode. value_encoder: A function to encode the values of the dictionary. Returns: RoborockMessage: The encoded message. """ if value_encoder is None: value_encoder = _no_encode dps_data = {"dps": {key: value_encoder(value) for key, value in data.items()}} payload = pad(json.dumps(dps_data).encode("utf-8"), AES.block_size) return RoborockMessage( protocol=RoborockMessageProtocol.RPC_REQUEST, version=A01_VERSION, payload=payload, ) def decode_rpc_response(message: RoborockMessage) -> dict[int, Any]: """Decode a V1 RPC_RESPONSE message.""" if not message.payload: raise RoborockException("Invalid A01 message format: missing payload") try: unpadded = unpad(message.payload, AES.block_size) except ValueError as err: raise RoborockException(f"Unable to unpad A01 payload: {err}") try: payload = json.loads(unpadded.decode()) except (json.JSONDecodeError, TypeError) as e: raise RoborockException(f"Invalid A01 message payload: {e} for {message.payload!r}") from e datapoints = payload.get("dps", {}) if not isinstance(datapoints, dict): raise RoborockException(f"Invalid A01 message format: 'dps' should be a dictionary for {message.payload!r}") try: return {int(key): value for key, value in datapoints.items()} except ValueError: raise RoborockException(f"Invalid A01 message format: 'dps' key should be an integer for {message.payload!r}") Python-roborock-python-roborock-d6da2db/roborock/protocols/b01_q10_protocol.py000066400000000000000000000067061513363643200277370ustar00rootroot00000000000000"""Roborock B01 Protocol encoding and decoding.""" import json import logging from typing import Any from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP from roborock.exceptions import RoborockException from roborock.roborock_message import ( RoborockMessage, RoborockMessageProtocol, ) _LOGGER = logging.getLogger(__name__) B01_VERSION = b"B01" ParamsType = list | dict | int | None def encode_mqtt_payload(command: B01_Q10_DP, params: ParamsType) -> RoborockMessage: """Encode payload for B01 Q10 commands over MQTT. This does not perform any special encoding for the command parameters and expects them to already be in a request specific format. """ dps_data = { "dps": { # Important: some commands use falsy values so only default to `{}` when params is actually None. command.code: params if params is not None else {}, } } return RoborockMessage( protocol=RoborockMessageProtocol.RPC_REQUEST, version=B01_VERSION, payload=json.dumps(dps_data).encode("utf-8"), ) def _convert_datapoints(datapoints: dict[str, Any], message: RoborockMessage) -> dict[B01_Q10_DP, Any]: """Convert the 'dps' dictionary keys from strings to B01_Q10_DP enums.""" result: dict[B01_Q10_DP, Any] = {} for key, value in datapoints.items(): try: code = int(key) except ValueError as e: raise ValueError(f"dps key is not a valid integer: {e} for {message.payload!r}") from e if (dps := B01_Q10_DP.from_code_optional(code)) is not None: # Update from_code to use `Self` on newer python version to remove this type ignore result[dps] = value # type: ignore[index] return result def decode_rpc_response(message: RoborockMessage) -> dict[B01_Q10_DP, Any]: """Decode a B01 Q10 RPC_RESPONSE message. This does not perform any special decoding for the response body, but does convert the 'dps' keys from strings to B01_Q10_DP enums. """ if not message.payload: raise RoborockException("Invalid B01 message format: missing payload") try: payload = json.loads(message.payload.decode()) except (json.JSONDecodeError, UnicodeDecodeError) as e: raise RoborockException(f"Invalid B01 json payload: {e} for {message.payload!r}") from e if (datapoints := payload.get("dps")) is None: raise RoborockException(f"Invalid B01 json payload: missing 'dps' for {message.payload!r}") if not isinstance(datapoints, dict): raise RoborockException(f"Invalid B01 message format: 'dps' should be a dictionary for {message.payload!r}") try: result = _convert_datapoints(datapoints, message) except ValueError as e: raise RoborockException(f"Invalid B01 message format: {e}") from e # The COMMON response contains nested datapoints need conversion. To simplify # response handling at higher levels we flatten these into the main result. if B01_Q10_DP.COMMON in result: common_result = result.pop(B01_Q10_DP.COMMON) if not isinstance(common_result, dict): raise RoborockException(f"Invalid dpCommon format: expected dict, got {type(common_result).__name__}") try: common_dps_result = _convert_datapoints(common_result, message) except ValueError as e: raise RoborockException(f"Invalid dpCommon format: {e}") from e result.update(common_dps_result) return result Python-roborock-python-roborock-d6da2db/roborock/protocols/b01_q7_protocol.py000066400000000000000000000053611513363643200276610ustar00rootroot00000000000000"""Roborock B01 Protocol encoding and decoding.""" import json import logging from dataclasses import dataclass, field from typing import Any from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad from roborock import RoborockB01Q7Methods from roborock.exceptions import RoborockException from roborock.roborock_message import ( RoborockMessage, RoborockMessageProtocol, ) from roborock.util import get_next_int _LOGGER = logging.getLogger(__name__) B01_VERSION = b"B01" CommandType = RoborockB01Q7Methods | str ParamsType = list | dict | int | None @dataclass class Q7RequestMessage: """Data class for B01 Q7 request message.""" dps: int command: CommandType params: ParamsType msg_id: int = field(default_factory=lambda: get_next_int(100000000000, 999999999999)) def to_dps_value(self) -> dict[int, Any]: """Return the 'dps' payload dictionary.""" return { self.dps: { "method": str(self.command), "msgId": str(self.msg_id), # Important: some B01 methods use an empty object `{}` (not `[]`) for # "no params", and some setters legitimately send `0` which is falsy. # Only default to `[]` when params is actually None. "params": self.params if self.params is not None else [], } } def encode_mqtt_payload(request: Q7RequestMessage) -> RoborockMessage: """Encode payload for B01 commands over MQTT.""" dps_data = {"dps": request.to_dps_value()} payload = pad(json.dumps(dps_data).encode("utf-8"), AES.block_size) return RoborockMessage( protocol=RoborockMessageProtocol.RPC_REQUEST, version=B01_VERSION, payload=payload, ) def decode_rpc_response(message: RoborockMessage) -> dict[int, Any]: """Decode a B01 RPC_RESPONSE message.""" if not message.payload: raise RoborockException("Invalid B01 message format: missing payload") try: unpadded = unpad(message.payload, AES.block_size) except ValueError: # It would be better to fail down the line. unpadded = message.payload try: payload = json.loads(unpadded.decode()) except (json.JSONDecodeError, TypeError) as e: raise RoborockException(f"Invalid B01 message payload: {e} for {message.payload!r}") from e datapoints = payload.get("dps", {}) if not isinstance(datapoints, dict): raise RoborockException(f"Invalid B01 message format: 'dps' should be a dictionary for {message.payload!r}") try: return {int(key): value for key, value in datapoints.items()} except ValueError: raise RoborockException(f"Invalid B01 message format: 'dps' key should be an integer for {message.payload!r}") Python-roborock-python-roborock-d6da2db/roborock/protocols/v1_protocol.py000066400000000000000000000226431513363643200272200ustar00rootroot00000000000000"""Roborock V1 Protocol Encoder.""" from __future__ import annotations import base64 import json import logging import secrets import struct from collections.abc import Callable from dataclasses import dataclass, field from enum import StrEnum from typing import Any, Protocol, TypeVar, overload from roborock.data import RoborockBase, RRiot from roborock.exceptions import RoborockException, RoborockInvalidStatus, RoborockUnsupportedFeature from roborock.protocol import Utils from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol from roborock.roborock_typing import RoborockCommand from roborock.util import get_next_int, get_timestamp _LOGGER = logging.getLogger(__name__) __all__ = [ "SecurityData", "create_security_data", "decode_rpc_response", "V1RpcChannel", ] CommandType = RoborockCommand | str ParamsType = list | dict | int | None class LocalProtocolVersion(StrEnum): """Supported local protocol versions. Different from vacuum protocol versions.""" L01 = "L01" V1 = "1.0" @dataclass(frozen=True, kw_only=True) class SecurityData: """Security data included in the request for some V1 commands.""" endpoint: str nonce: bytes def to_dict(self) -> dict[str, Any]: """Convert security data to a dictionary for sending in the payload.""" return {"security": {"endpoint": self.endpoint, "nonce": self.nonce.hex().lower()}} def to_diagnostic_data(self) -> dict[str, Any]: """Convert security data to a dictionary for debugging purposes.""" return {"nonce": self.nonce.hex().lower()} def create_security_data(rriot: RRiot) -> SecurityData: """Create a SecurityData instance for the given endpoint and nonce.""" nonce = secrets.token_bytes(16) endpoint = base64.b64encode(Utils.md5(rriot.k.encode())[8:14]).decode() return SecurityData(endpoint=endpoint, nonce=nonce) @dataclass class RequestMessage: """Data structure for v1 RoborockMessage payloads.""" method: RoborockCommand | str params: ParamsType timestamp: int = field(default_factory=lambda: get_timestamp()) request_id: int = field(default_factory=lambda: get_next_int(10000, 32767)) def encode_message( self, protocol: RoborockMessageProtocol, security_data: SecurityData | None = None, version: LocalProtocolVersion = LocalProtocolVersion.V1, ) -> RoborockMessage: """Convert the request message to a RoborockMessage.""" return RoborockMessage( timestamp=self.timestamp, protocol=protocol, payload=self._as_payload(security_data=security_data), version=version.value.encode(), ) def _as_payload(self, security_data: SecurityData | None) -> bytes: """Convert the request arguments to a dictionary.""" inner = { "id": self.request_id, "method": self.method, "params": self.params or [], **(security_data.to_dict() if security_data else {}), } return bytes( json.dumps( { "dps": {"101": json.dumps(inner, separators=(",", ":"))}, "t": self.timestamp, }, separators=(",", ":"), ).encode() ) ResponseData = dict[str, Any] | list | int # V1 RPC error code mappings to specific exception types _V1_ERROR_CODE_EXCEPTIONS: dict[int, type[RoborockException]] = { -10007: RoborockInvalidStatus, # "invalid status" - device action locked } def _create_api_error(error: Any) -> RoborockException: """Create an appropriate exception for a V1 RPC error response. Maps known error codes to specific exception types for easier handling at higher levels. """ if isinstance(error, dict): code = error.get("code") if isinstance(code, int) and (exc_type := _V1_ERROR_CODE_EXCEPTIONS.get(code)): return exc_type(error) return RoborockException(error) @dataclass(kw_only=True, frozen=True) class ResponseMessage: """Data structure for v1 RoborockMessage responses.""" request_id: int | None """The request ID of the response.""" data: ResponseData """The data of the response, where the type depends on the command.""" api_error: RoborockException | None = None """The API error message of the response if any.""" def decode_rpc_response(message: RoborockMessage) -> ResponseMessage: """Decode a V1 RPC_RESPONSE message. This will raise a RoborockException if the message cannot be parsed. A response object will be returned even if there is an error in the response, as long as we can extract the request ID. This is so we can associate an API response with a request even if there was an error. """ if not message.payload: return ResponseMessage(request_id=message.seq, data={}) try: payload = json.loads(message.payload.decode()) except (json.JSONDecodeError, TypeError, UnicodeDecodeError) as e: raise RoborockException(f"Invalid V1 message payload: {e} for {message.payload!r}") from e _LOGGER.debug("Decoded V1 message payload: %s", payload) datapoints = payload.get("dps", {}) if not isinstance(datapoints, dict): raise RoborockException(f"Invalid V1 message format: 'dps' should be a dictionary for {message.payload!r}") if not (data_point := datapoints.get(str(RoborockMessageProtocol.RPC_RESPONSE))): raise RoborockException( f"Invalid V1 message format: missing '{RoborockMessageProtocol.RPC_RESPONSE}' data point" ) try: data_point_response = json.loads(data_point) except (json.JSONDecodeError, TypeError) as e: raise RoborockException( f"Invalid V1 message data point '{RoborockMessageProtocol.RPC_RESPONSE}': {e} for {message.payload!r}" ) from e request_id: int | None = data_point_response.get("id") api_error: RoborockException | None = None if error := data_point_response.get("error"): api_error = _create_api_error(error) if (result := data_point_response.get("result")) is None: # Some firmware versions return an error-only response (no "result" key). # Preserve that error instead of overwriting it with a parsing exception. if api_error is None: api_error = RoborockException( f"Invalid V1 message format: missing 'result' in data point for {message.payload!r}" ) result = {} else: _LOGGER.debug("Decoded V1 message result: %s", result) if isinstance(result, str): if result == "unknown_method": api_error = RoborockUnsupportedFeature("The method called is not recognized by the device.") elif result != "ok": api_error = RoborockException(f"Unexpected API Result: {result}") result = {} if not isinstance(result, dict | list | int): # If we already have an API error, prefer returning a response object # rather than failing to decode the message entirely. if api_error is None: raise RoborockException( f"Invalid V1 message format: 'result' was unexpected type {type(result)}. {message.payload!r}" ) result = {} if not request_id and api_error: raise api_error return ResponseMessage(request_id=request_id, data=result, api_error=api_error) @dataclass class MapResponse: """Data structure for the V1 Map response.""" request_id: int """The request ID of the map response.""" data: bytes """The map data, decrypted and decompressed.""" def create_map_response_decoder(security_data: SecurityData) -> Callable[[RoborockMessage], MapResponse | None]: """Create a decoder for V1 map response messages.""" def _decode_map_response(message: RoborockMessage) -> MapResponse | None: """Decode a V1 map response message.""" if not message.payload or len(message.payload) < 24: raise RoborockException("Invalid V1 map response format: missing payload") header, body = message.payload[:24], message.payload[24:] [endpoint, _, request_id, _] = struct.unpack("<8s8sH6s", header) if not endpoint.decode().startswith(security_data.endpoint): _LOGGER.debug("Received map response not requested by this device, ignoring.") return None try: decrypted = Utils.decrypt_cbc(body, security_data.nonce) except ValueError as err: raise RoborockException("Failed to decode map message payload") from err decompressed = Utils.decompress(decrypted) return MapResponse(request_id=request_id, data=decompressed) return _decode_map_response _T = TypeVar("_T", bound=RoborockBase) class V1RpcChannel(Protocol): """Protocol for V1 RPC channels. This is a wrapper around a raw channel that provides a high-level interface for sending commands and receiving responses. """ @overload async def send_command( self, method: CommandType, *, params: ParamsType = None, ) -> Any: """Send a command and return a decoded response.""" ... @overload async def send_command( self, method: CommandType, *, response_type: type[_T], params: ParamsType = None, ) -> _T: """Send a command and return a parsed response RoborockBase type.""" ... Python-roborock-python-roborock-d6da2db/roborock/py.typed000066400000000000000000000000001513363643200240310ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/roborock/roborock_message.py000066400000000000000000000145141513363643200262470ustar00rootroot00000000000000from __future__ import annotations from dataclasses import dataclass, field from enum import StrEnum from roborock import RoborockEnum from roborock.util import get_next_int, get_timestamp class RoborockMessageProtocol(RoborockEnum): HELLO_REQUEST = 0 HELLO_RESPONSE = 1 PING_REQUEST = 2 PING_RESPONSE = 3 GENERAL_REQUEST = 4 GENERAL_RESPONSE = 5 RPC_REQUEST = 101 RPC_RESPONSE = 102 MAP_RESPONSE = 301 class RoborockDataProtocol(RoborockEnum): ERROR_CODE = 120 STATE = 121 BATTERY = 122 FAN_POWER = 123 WATER_BOX_MODE = 124 MAIN_BRUSH_WORK_TIME = 125 SIDE_BRUSH_WORK_TIME = 126 FILTER_WORK_TIME = 127 ADDITIONAL_PROPS = 128 TASK_COMPLETE = 130 TASK_CANCEL_LOW_POWER = 131 TASK_CANCEL_IN_MOTION = 132 CHARGE_STATUS = 133 DRYING_STATUS = 134 OFFLINE_STATUS = 135 @classmethod def _missing_(cls: type[RoborockEnum], key) -> RoborockEnum: raise ValueError("%s not a valid key for Data Protocol", key) class RoborockDyadDataProtocol(RoborockEnum): DRYING_STATUS = 134 START = 200 STATUS = 201 SELF_CLEAN_MODE = 202 SELF_CLEAN_LEVEL = 203 WARM_LEVEL = 204 CLEAN_MODE = 205 SUCTION = 206 WATER_LEVEL = 207 BRUSH_SPEED = 208 POWER = 209 COUNTDOWN_TIME = 210 AUTO_SELF_CLEAN_SET = 212 AUTO_DRY = 213 MESH_LEFT = 214 BRUSH_LEFT = 215 ERROR = 216 MESH_RESET = 218 BRUSH_RESET = 219 VOLUME_SET = 221 STAND_LOCK_AUTO_RUN = 222 AUTO_SELF_CLEAN_SET_MODE = 223 AUTO_DRY_MODE = 224 SILENT_DRY_DURATION = 225 SILENT_MODE = 226 SILENT_MODE_START_TIME = 227 SILENT_MODE_END_TIME = 228 RECENT_RUN_TIME = 229 TOTAL_RUN_TIME = 230 FEATURE_INFO = 235 RECOVER_SETTINGS = 236 DRY_COUNTDOWN = 237 ID_QUERY = 10000 F_C = 10001 SCHEDULE_TASK = 10002 SND_SWITCH = 10003 SND_STATE = 10004 PRODUCT_INFO = 10005 PRIVACY_INFO = 10006 OTA_NFO = 10007 RPC_REQUEST = 10101 RPC_RESPONSE = 10102 class RoborockZeoProtocol(RoborockEnum): START = 200 # rw PAUSE = 201 # rw SHUTDOWN = 202 # rw STATE = 203 # ro MODE = 204 # rw PROGRAM = 205 # rw CHILD_LOCK = 206 # rw TEMP = 207 # rw RINSE_TIMES = 208 # rw SPIN_LEVEL = 209 # rw DRYING_MODE = 210 # rw DETERGENT_SET = 211 # rw SOFTENER_SET = 212 # rw DETERGENT_TYPE = 213 # rw SOFTENER_TYPE = 214 # rw COUNTDOWN = 217 # rw WASHING_LEFT = 218 # ro DOORLOCK_STATE = 219 # ro ERROR = 220 # ro CUSTOM_PARAM_SAVE = 221 # rw CUSTOM_PARAM_GET = 222 # ro SOUND_SET = 223 # rw TIMES_AFTER_CLEAN = 224 # ro DEFAULT_SETTING = 225 # rw DETERGENT_EMPTY = 226 # ro SOFTENER_EMPTY = 227 # ro LIGHT_SETTING = 229 # rw DETERGENT_VOLUME = 230 # rw SOFTENER_VOLUME = 231 # rw APP_AUTHORIZATION = 232 # rw ID_QUERY = 10000 F_C = 10001 SND_STATE = 10004 PRODUCT_INFO = 10005 PRIVACY_INFO = 10006 OTA_NFO = 10007 WASHING_LOG = 10008 RPC_REQ = 10101 RPC_RESp = 10102 class RoborockB01Protocol(RoborockEnum): RPC_REQUEST = 101 RPC_RESPONSE = 102 ERROR_CODE = 120 STATE = 121 BATTERY = 122 FAN_POWER = 123 WATER_BOX_MODE = 124 MAIN_BRUSH_LIFE = 125 SIDE_BRUSH_LIFE = 126 FILTER_LIFE = 127 OFFLINE_STATUS = 135 CLEAN_TIMES = 136 CLEANING_PREFERENCE = 137 CLEAN_TASK_TYPE = 138 BACK_TYPE = 139 DOCK_TASK_TYPE = 140 CLEANING_PROGRESS = 141 FC_STATE = 142 START_CLEAN_TASK = 201 START_BACK_DOCK_TASK = 202 START_DOCK_TASK = 203 PAUSE = 204 RESUME = 205 STOP = 206 CEIP = 207 class RoborockB01Props(StrEnum): """Properties requested by the Roborock B01 model.""" STATUS = "status" FAULT = "fault" WIND = "wind" WATER = "water" MODE = "mode" QUANTITY = "quantity" ALARM = "alarm" VOLUME = "volume" HYPA = "hypa" MAIN_BRUSH = "main_brush" SIDE_BRUSH = "side_brush" MOP_LIFE = "mop_life" MAIN_SENSOR = "main_sensor" NET_STATUS = "net_status" REPEAT_STATE = "repeat_state" TANK_STATE = "tank_state" SWEEP_TYPE = "sweep_type" CLEAN_PATH_PREFERENCE = "clean_path_preference" CLOTH_STATE = "cloth_state" TIME_ZONE = "time_zone" TIME_ZONE_INFO = "time_zone_info" LANGUAGE = "language" CLEANING_TIME = "cleaning_time" REAL_CLEAN_TIME = "real_clean_time" CLEANING_AREA = "cleaning_area" CUSTOM_TYPE = "custom_type" SOUND = "sound" WORK_MODE = "work_mode" STATION_ACT = "station_act" CHARGE_STATE = "charge_state" CURRENT_MAP_ID = "current_map_id" MAP_NUM = "map_num" DUST_ACTION = "dust_action" QUIET_IS_OPEN = "quiet_is_open" QUIET_BEGIN_TIME = "quiet_begin_time" QUIET_END_TIME = "quiet_end_time" CLEAN_FINISH = "clean_finish" VOICE_TYPE = "voice_type" VOICE_TYPE_VERSION = "voice_type_version" ORDER_TOTAL = "order_total" BUILD_MAP = "build_map" PRIVACY = "privacy" DUST_AUTO_STATE = "dust_auto_state" DUST_FREQUENCY = "dust_frequency" CHILD_LOCK = "child_lock" MULTI_FLOOR = "multi_floor" MAP_SAVE = "map_save" LIGHT_MODE = "light_mode" GREEN_LASER = "green_laser" DUST_BAG_USED = "dust_bag_used" ORDER_SAVE_MODE = "order_save_mode" MANUFACTURER = "manufacturer" BACK_TO_WASH = "back_to_wash" CHARGE_STATION_TYPE = "charge_station_type" PV_CUT_CHARGE = "pv_cut_charge" PV_CHARGING = "pv_charging" SERIAL_NUMBER = "serial_number" RECOMMEND = "recommend" ADD_SWEEP_STATUS = "add_sweep_status" ROBOROCK_DATA_STATUS_PROTOCOL = [ RoborockDataProtocol.ERROR_CODE, RoborockDataProtocol.STATE, RoborockDataProtocol.BATTERY, RoborockDataProtocol.FAN_POWER, RoborockDataProtocol.WATER_BOX_MODE, RoborockDataProtocol.CHARGE_STATUS, ] ROBOROCK_DATA_CONSUMABLE_PROTOCOL = [ RoborockDataProtocol.MAIN_BRUSH_WORK_TIME, RoborockDataProtocol.SIDE_BRUSH_WORK_TIME, RoborockDataProtocol.FILTER_WORK_TIME, ] @dataclass class RoborockMessage: protocol: RoborockMessageProtocol payload: bytes | None = None seq: int = field(default_factory=lambda: get_next_int(100000, 999999)) version: bytes = b"1.0" random: int = field(default_factory=lambda: get_next_int(10000, 99999)) timestamp: int = field(default_factory=lambda: get_timestamp()) Python-roborock-python-roborock-d6da2db/roborock/roborock_typing.py000066400000000000000000000414001513363643200261270ustar00rootroot00000000000000from __future__ import annotations from dataclasses import dataclass, field from enum import Enum, StrEnum from .data import ( CleanRecord, CleanSummary, Consumable, DustCollectionMode, RoborockBase, SmartWashParams, Status, WashTowelMode, ) class RoborockCommand(str, Enum): """Enum of all known Roborock V1 protocol commands. These commands can be sent to a device using the `CommandTrait`. For example: `device.v1_properties.command.send(RoborockCommand.APP_START)`. """ ADD_MOP_TEMPLATE_PARAMS = "add_mop_template_params" APP_AMETHYST_SELF_CHECK = "app_amethyst_self_check" APP_CHARGE = "app_charge" APP_DELETE_WIFI = "app_delete_wifi" APP_GET_AMETHYST_STATUS = "app_get_amethyst_status" APP_GET_CARPET_DEEP_CLEAN_STATUS = "app_get_carpet_deep_clean_status" APP_GET_CLEAN_ESTIMATE_INFO = "app_get_clean_estimate_info" APP_GET_DRYER_SETTING = "app_get_dryer_setting" APP_GET_INIT_STATUS = "app_get_init_status" APP_GET_LOCALE = "app_get_locale" APP_GET_WIFI_LIST = "app_get_wifi_list" APP_GOTO_TARGET = "app_goto_target" APP_KEEP_EASTER_EGG = "app_keep_easter_egg" APP_PAUSE = "app_pause" APP_RC_END = "app_rc_end" APP_RC_MOVE = "app_rc_move" APP_RC_START = "app_rc_start" APP_RC_STOP = "app_rc_stop" APP_RESUME_BUILD_MAP = "app_resume_build_map" APP_RESUME_PATROL = "app_resume_patrol" APP_SEGMENT_CLEAN = "app_segment_clean" APP_SET_AMETHYST_STATUS = "app_set_amethyst_status" APP_SET_CARPET_DEEP_CLEAN_STATUS = "app_set_carpet_deep_clean_status" APP_SET_CROSS_CARPET_CLEANING_STATUS = "app_set_cross_carpet_cleaning_status" APP_SET_DOOR_SILL_BLOCKS = "app_set_door_sill_blocks" APP_SET_DIRTY_REPLENISH_CLEAN_STATUS = "app_set_dirty_replenish_clean_status" APP_SET_DRYER_SETTING = "app_set_dryer_setting" APP_SET_DRYER_STATUS = "app_set_dryer_status" APP_SET_DYNAMIC_CONFIG = "app_set_dynamic_config" APP_SET_IGNORE_STUCK_POINT = "app_set_ignore_stuck_point" APP_SET_SMART_CLIFF_FORBIDDEN = "app_set_smart_cliff_forbidden" APP_SET_SMART_DOOR_SILL = "app_set_smart_door_sill" APP_SPOT = "app_spot" APP_START = "app_start" APP_START_BUILD_MAP = "app_start_build_map" APP_START_COLLECT_DUST = "app_start_collect_dust" APP_START_EASTER_EGG = "app_start_easter_egg" APP_START_PATROL = "app_start_patrol" APP_START_PET_PATROL = "app_start_pet_patrol" APP_START_WASH = "app_start_wash" APP_STAT = "app_stat" APP_STOP = "app_stop" APP_STOP_COLLECT_DUST = "app_stop_collect_dust" APP_STOP_WASH = "app_stop_wash" APP_UPDATE_UNSAVE_MAP = "app_update_unsave_map" APP_WAKEUP_ROBOT = "app_wakeup_robot" APP_ZONED_CLEAN = "app_zoned_clean" CHANGE_SOUND_VOLUME = "change_sound_volume" CHECK_HOMESEC_PASSWORD = "check_homesec_password" CLOSE_DND_TIMER = "close_dnd_timer" CLOSE_VALLEY_ELECTRICITY_TIMER = "close_valley_electricity_timer" DEL_CLEAN_RECORD = "del_clean_record" DEL_CLEAN_RECORD_MAP_V2 = "del_clean_record_map_v2" DEL_MAP = "del_map" DEL_MOP_TEMPLATE_PARAMS = "del_mop_template_params" DEL_SERVER_TIMER = "del_server_timer" DEL_TIMER = "del_timer" DNLD_INSTALL_SOUND = "dnld_install_sound" ENABLE_HOMESEC_VOICE = "enable_homesec_voice" ENABLE_LOG_UPLOAD = "enable_log_upload" END_EDIT_MAP = "end_edit_map" FIND_ME = "find_me" GET_AUTO_DELIVERY_CLEANING_FLUID = "get_auto_delivery_cleaning_fluid" GET_CAMERA_STATUS = "get_camera_status" GET_CARPET_CLEAN_MODE = "get_carpet_clean_mode" GET_CARPET_MODE = "get_carpet_mode" GET_CHILD_LOCK_STATUS = "get_child_lock_status" GET_CLEAN_FOLLOW_GROUND_MATERIAL_STATUS = "get_clean_follow_ground_material_status" GET_CLEAN_MOTOR_MODE = "get_clean_motor_mode" GET_CLEAN_RECORD = "get_clean_record" GET_CLEAN_RECORD_MAP = "get_clean_record_map" GET_CLEAN_SEQUENCE = "get_clean_sequence" GET_CLEAN_SUMMARY = "get_clean_summary" GET_COLLISION_AVOID_STATUS = "get_collision_avoid_status" GET_CONSUMABLE = "get_consumable" GET_CURRENT_SOUND = "get_current_sound" GET_CUSTOM_MODE = "get_custom_mode" GET_CUSTOMIZE_CLEAN_MODE = "get_customize_clean_mode" GET_DEVICE_ICE = "get_device_ice" GET_DEVICE_SDP = "get_device_sdp" GET_DND_TIMER = "get_dnd_timer" GET_DOCK_INFO = "get_dock_info" GET_DUST_COLLECTION_MODE = "get_dust_collection_mode" GET_DUST_COLLECTION_SWITCH_STATUS = "get_dust_collection_switch_status" GET_DYNAMIC_DATA = "get_dynamic_data" GET_DYNAMIC_MAP_DIFF = "get_dynamic_map_diff" GET_FAN_MOTOR_WORK_TIMEOUT = "get_fan_motor_work_timeout" GET_FLOW_LED_STATUS = "get_flow_led_status" GET_FRESH_MAP = "get_fresh_map" GET_FW_FEATURES = "get_fw_features" GET_HOMESEC_CONNECT_STATUS = "get_homesec_connect_status" GET_IDENTIFY_FURNITURE_STATUS = "get_identify_furniture_status" GET_IDENTIFY_GROUND_MATERIAL_STATUS = "get_identify_ground_material_status" GET_LED_STATUS = "get_led_status" GET_LOG_UPLOAD_STATUS = "get_log_upload_status" GET_MAP = "get_map" GET_MAP_BEAUTIFICATION_STATUS = "get_map_beautification_status" GET_MAP_STATUS = "get_map_status" GET_MAP_V1 = "get_map_v1" GET_MAP_V2 = "get_map_v2" GET_MAP_CALIBRATION = "get_map_calibration" # Custom command GET_MOP_MOTOR_STATUS = "get_mop_motor_status" GET_MOP_TEMPLATE_PARAMS_BY_ID = "get_mop_template_params_by_id" GET_MOP_TEMPLATE_PARAMS_SUMMARY = "get_mop_template_params_summary" GET_MULTI_MAP = "get_multi_map" GET_MULTI_MAPS_LIST = "get_multi_maps_list" GET_NETWORK_INFO = "get_network_info" GET_OFFLINE_MAP_STATUS = "get_offline_map_status" GET_PERSIST = "get_persist_map" GET_PROP = "get_prop" GET_RANDOM_PKEY = "get_random_pkey" GET_RECOVER_MAP = "get_recover_map" GET_RECOVER_MAPS = "get_recover_maps" GET_ROOM_MAPPING = "get_room_mapping" GET_SCENES_VALID_TIDS = "get_scenes_valid_tids" GET_SEGMENT_STATUS = "get_segment_status" GET_SERIAL_NUMBER = "get_serial_number" GET_SERVER_TIMER = "get_server_timer" GET_SMART_WASH_PARAMS = "get_smart_wash_params" GET_SOUND_PROGRESS = "get_sound_progress" GET_SOUND_VOLUME = "get_sound_volume" GET_STATUS = "get_status" GET_TESTID = "get_testid" GET_TIMER = "get_timer" GET_TIMER_DETAIL = "get_timer_detail" GET_TIMER_SUMMARY = "get_timer_summary" GET_TIMEZONE = "get_timezone" GET_TURN_SERVER = "get_turn_server" GET_VALLEY_ELECTRICITY_TIMER = "get_valley_electricity_timer" GET_WASH_DEBUG_PARAMS = "get_wash_debug_params" GET_WASH_TOWEL_MODE = "get_wash_towel_mode" GET_WASH_TOWEL_PARAMS = "get_wash_towel_params" GET_WATER_BOX_CUSTOM_MODE = "get_water_box_custom_mode" LOAD_MULTI_MAP = "load_multi_map" MANUAL_BAK_MAP = "manual_bak_map" MANUAL_SEGMENT_MAP = "manual_segment_map" MERGE_SEGMENT = "merge_segment" MOP_MODE = "mop_mode" MOP_TEMPLATE_ID = "mop_template_id" NAME_MULTI_MAP = "name_multi_map" NAME_SEGMENT = "name_segment" PLAY_AUDIO = "play_audio" RECOVER_MAP = "recover_map" RECOVER_MULTI_MAP = "recover_multi_map" RESET_CONSUMABLE = "reset_consumable" RESET_HOMESEC_PASSWORD = "reset_homesec_password" RESET_MAP = "reset_map" RESOLVE_ERROR = "resolve_error" RESUME_SEGMENT_CLEAN = "resume_segment_clean" RESUME_ZONED_CLEAN = "resume_zoned_clean" RETRY_REQUEST = "retry_request" REUNION_SCENES = "reunion_scenes" SAVE_FURNITURES = "save_furnitures" SAVE_MAP = "save_map" SEND_ICE_TO_ROBOT = "send_ice_to_robot" SEND_SDP_TO_ROBOT = "send_sdp_to_robot" SET_AIRDRY_HOURS = "set_airdry_hours" SET_APP_TIMEZONE = "set_app_timezone" SET_AUTO_DELIVERY_CLEANING_FLUID = "set_auto_delivery_cleaning_fluid" SET_CAMERA_STATUS = "set_camera_status" SET_CARPET_AREA = "set_carpet_area" SET_CARPET_CLEAN_MODE = "set_carpet_clean_mode" SET_CARPET_MODE = "set_carpet_mode" SET_CHILD_LOCK_STATUS = "set_child_lock_status" SET_CLEAN_FOLLOW_GROUND_MATERIAL_STATUS = "set_clean_follow_ground_material_status" SET_CLEAN_MOTOR_MODE = "set_clean_motor_mode" SET_CLEAN_SEQUENCE = "set_clean_sequence" SET_CLEAN_REPEAT_TIMES = "set_clean_repeat_times" SET_COLLISION_AVOID_STATUS = "set_collision_avoid_status" SET_CUSTOM_MODE = "set_custom_mode" SET_CUSTOMIZE_CLEAN_MODE = "set_customize_clean_mode" SET_DND_TIMER = "set_dnd_timer" SET_DND_TIMER_ACTIONS = "set_dnd_timer_actions" SET_DUST_COLLECTION_MODE = "set_dust_collection_mode" SET_DUST_COLLECTION_SWITCH_STATUS = "set_dust_collection_switch_status" SET_FAN_MOTOR_WORK_TIMEOUT = "set_fan_motor_work_timeout" SET_FDS_ENDPOINT = "set_fds_endpoint" SET_FLOW_LED_STATUS = "set_flow_led_status" SET_HOMESEC_PASSWORD = "set_homesec_password" SET_IDENTIFY_FURNITURE_STATUS = "set_identify_furniture_status" SET_IDENTIFY_GROUND_MATERIAL_STATUS = "set_identify_ground_material_status" SET_IGNORE_CARPET_ZONE = "set_ignore_carpet_zone" SET_IGNORE_IDENTIFY_AREA = "set_ignore_identify_area" SET_LAB_STATUS = "set_lab_status" SET_LED_STATUS = "set_led_status" SET_MAP_BEAUTIFICATION_STATUS = "set_map_beautification_status" SET_MOP_MODE = "set_mop_mode" SET_MOP_MOTOR_STATUS = "set_mop_motor_status" SET_MOP_TEMPLATE_ID = "set_mop_template_id" SET_OFFLINE_MAP_STATUS = "set_offline_map_status" SET_SCENES_SEGMENTS = "set_scenes_segments" SET_SCENES_ZONES = "set_scenes_zones" SET_SEGMENT_GROUND_MATERIAL = "set_segment_ground_material" SET_SERVER_TIMER = "set_server_timer" SET_SMART_WASH_PARAMS = "set_smart_wash_params" SET_SWITCH_MOP_MODE = "set_switch_map_mode" SET_TIMER = "set_timer" SET_TIMEZONE = "set_timezone" SET_VALLEY_ELECTRICITY_TIMER = "set_valley_electricity_timer" SET_VOICE_CHAT_VOLUME = "set_voice_chat_volume" SET_WASH_DEBUG_PARAMS = "set_wash_debug_params" SET_WASH_TOWEL_MODE = "set_wash_towel_mode" SET_WASH_TOWEL_PARAMS = "set_wash_towel_params" SET_WATER_BOX_CUSTOM_MODE = "set_water_box_custom_mode" SET_WATER_BOX_DISTANCE_OFF = "set_water_box_distance_off" SORT_MOP_TEMPLATE_PARAMS = "sort_mop_template_params" SPLIT_SEGMENT = "split_segment" START_CAMERA_PREVIEW = "start_camera_preview" START_CLEAN = "start_clean" START_EDIT_MAP = "start_edit_map" START_VOICE_CHAT = "start_voice_chat" START_WASH_THEN_CHARGE = "start_wash_then_charge" STOP_CAMERA_PREVIEW = "stop_camera_preview" STOP_FAN_MOTOR_WORK = "stop_fan_motor_work" STOP_GOTO_TARGET = "stop_goto_target" STOP_SEGMENT_CLEAN = "stop_segment_clean" STOP_VOICE_CHAT = "stop_voice_chat" STOP_ZONED_CLEAN = "stop_zoned_clean" SWITCH_VIDEO_QUALITY = "switch_video_quality" SWITCH_WATER_MARK = "switch_water_mark" TEST_SOUND_VOLUME = "test_sound_volume" UPD_SERVER_TIMER = "upd_server_timer" UPD_TIMER = "upd_timer" UPDATE_DOCK = "update_dock" UPDATE_MOP_TEMPLATE_PARAMS = "update_mop_template_params" UPLOAD_DATA_FOR_DEBUG_MODE = "upload_data_for_debug_mode" UPLOAD_PHOTO = "upload_photo" USE_NEW_MAP = "use_new_map" USE_OLD_MAP = "use_old_map" USER_UPLOAD_LOG = "user_upload_log" SET_STRETCH_TAG_STATUS = "set_stretch_tag_status" GET_STRETCH_TAG_STATUS = "get_stretch_tag_status" SET_RIGHT_BRUSH_STRETCH_STATUS = "set_right_brush_stretch_status" GET_RIGHT_BRUSH_STRETCH_STATUS = "get_right_brush_stretch_status" SET_DIRTY_OBJECT_DETECT_STATUS = "set_dirty_object_detect_status" GET_DIRTY_OBJECT_DETECT_STATUS = "get_dirty_object_detect_status" SET_WASH_WATER_TEMPERATURE = "set_wash_water_temperature" GET_WASH_WATER_TEMPERATURE = "get_wash_water_temperature" APP_EMPTY_RINSE_TANK_WATER = "app_empty_rinse_tank_water" SET_PET_SUPPLIES_DEEP_CLEAN_STATUS = "set_pet_supplies_deep_clean_status" GET_PET_SUPPLIES_DEEP_CLEAN_STATUS = "get_pet_supplies_deep_clean_status" SET_AP_MIC_LED_STATUS = "set_ap_mic_led_status" GET_AP_MIC_LED_STATUS = "get_ap_mic_led_status" SET_HANDLE_LEAK_WATER_STATUS = "set_handle_leak_water_status" GET_HANDLE_LEAK_WATER_STATUS = "get_handle_leak_water_status" APP_IGNORE_DIRTY_OBJECTS = "app_ignore_dirty_objects" MATTER_GET_STATUS = "matter.get_status" MATTER_DNLD_KEY = "matter.dnld_key" MATTER_RESET = "matter.reset" SET_GAP_DEEP_CLEAN_STATUS = "set_gap_deep_clean_status" GET_GAP_DEEP_CLEAN_STATUS = "get_gap_deep_clean_status" APP_SET_ROBOT_SETTING = "app_set_robot_setting" APP_GET_ROBOT_SETTING = "app_get_robot_setting" class RoborockB01Q7Methods(StrEnum): """Methods used by the Roborock Q7 model.""" # NOTE: In the Q7 Hermes dump these appear as suffixes and are also used # with an "event." prefix at runtime (see `hermes/.../module_524.js`). ADD_CLEAN_FAILED_POST = "add_clean_failed.post" EVENT_ADD_CLEAN_FAILED_POST = "event.add_clean_failed.post" CLEAN_FINISH_POST = "clean_finish.post" EVENT_CLEAN_FINISH_POST = "event.clean_finish.post" EVENT_BUILD_MAP_FINISH_POST = "event.BuildMapFinish.post" EVENT_MAP_CHANGE_POST = "event.map_change.post" EVENT_WORK_APPOINT_CLEAN_FAILED_POST = "event.work_appoint_clean_failed.post" START_CLEAN_POST = "startClean.post" ADD_ORDER = "service.add_order" ADD_SWEEP_CLEAN = "service.add_sweep_clean" ARRANGE_ROOM = "service.arrange_room" DEL_MAP = "service.del_map" DEL_ORDER = "service.del_order" DEL_ORDERS = "service.del_orders" DELETE_RECORD_BY_URL = "service.delete_record_by_url" DOWNLOAD_VOICE_TYPE = "service.download_voice_type" ERASE_PREFERENCE = "service.erase_preference" FIND_DEVICE = "service.find_device" GET_ROOM_ORDER = "service.get_room_order" GET_VOICE_DOWNLOAD = "service.get_voice_download" HELLO_WIKKA = "service.hello_wikka" RENAME_MAP = "service.rename_map" RENAME_ROOM = "service.rename_room" RENAME_ROOMS = "service.rename_rooms" REPLACE_MAP = "service.replace_map" RESET_CONSUMABLE = "service.reset_consumable" SAVE_CARPET = "service.save_carpet" SAVE_RECOMMEND_FB = "service.save_recommend_fb" SAVE_SILL = "service.save_sill" SET_AREA_START = "service.set_area_start" SET_AREAS_START = "service.set_areas_start" SET_CUR_MAP = "service.set_cur_map" SET_DIRECTION = "service.set_direction" SET_GLOBAL_SORT = "service.set_global_sort" SET_MAP_HIDE = "service.set_map_hide" SET_MULTI_ROOM_MATERIAL = "service.set_multi_room_material" SET_POINT_CLEAN = "service.set_point_clean" SET_PREFERENCE = "service.set_preference" SET_PREFERENCE_TYPE = "service.set_preference_type" SET_QUIET_TIME = "service.set_quiet_time" SET_ROOM_CLEAN = "service.set_room_clean" SET_ROOM_ORDER = "service.set_room_order" SET_VIRTUAL_WALL = "service.set_virtual_wall" SET_ZONE_CLEAN = "service.set_zone_clean" SET_ZONE_POINTS = "service.set_zone_points" SPLIT_ROOM = "service.split_room" START_EXPLORE = "service.start_explore" START_POINT_CLEAN = "service.start_point_clean" START_RECHARGE = "service.start_recharge" STOP_RECHARGE = "service.stop_recharge" UPLOAD_BY_MAPID = "service.upload_by_mapid" UPLOAD_RECORD_BY_URL = "service.upload_record_by_url" GET_PROP = "prop.get" GET_MAP_LIST = "service.get_map_list" UPLOAD_BY_MAPTYPE = "service.upload_by_maptype" SET_PROP = "prop.set" GET_PREFERENCE = "service.get_preference" GET_RECORD_LIST = "service.get_record_list" GET_ORDER = "service.get_order" POST_PROP = "prop.post" @dataclass class DockSummary(RoborockBase): dust_collection_mode: DustCollectionMode | None = None wash_towel_mode: WashTowelMode | None = None smart_wash_params: SmartWashParams | None = None @dataclass class DeviceProp(RoborockBase): status: Status = field(default_factory=Status) clean_summary: CleanSummary = field(default_factory=CleanSummary) consumable: Consumable = field(default_factory=Consumable) last_clean_record: CleanRecord | None = None dock_summary: DockSummary | None = None dust_collection_mode_name: str | None = None def __post_init__(self) -> None: if ( self.dock_summary and self.dock_summary.dust_collection_mode is not None and self.dock_summary.dust_collection_mode.mode is not None ): self.dust_collection_mode_name = self.dock_summary.dust_collection_mode.mode.name def update(self, device_prop: DeviceProp) -> None: if device_prop.status: self.status = device_prop.status if device_prop.clean_summary: self.clean_summary = device_prop.clean_summary if device_prop.consumable: self.consumable = device_prop.consumable if device_prop.last_clean_record: self.last_clean_record = device_prop.last_clean_record if device_prop.dock_summary: self.dock_summary = device_prop.dock_summary self.__post_init__() Python-roborock-python-roborock-d6da2db/roborock/util.py000066400000000000000000000032261513363643200236760ustar00rootroot00000000000000from __future__ import annotations import logging import math import time from collections.abc import MutableMapping from typing import Any, TypeVar from roborock.diagnostics import redact_device_uid T = TypeVar("T") def unpack_list(value: list[T], size: int) -> list[T | None]: return (value + [None] * size)[:size] # type: ignore class RoborockLoggerAdapter(logging.LoggerAdapter): def __init__( self, duid: str | None = None, name: str | None = None, logger: logging.Logger | None = None, ) -> None: super().__init__(logger or logging.getLogger(__name__), {}) if name is not None: self.prefix = name elif duid is not None: self.prefix = redact_device_uid(duid) else: raise ValueError("Either duid or name must be provided") def process(self, msg: str, kwargs: MutableMapping[str, Any]) -> tuple[str, MutableMapping[str, Any]]: return f"[{self.prefix}] {msg}", kwargs counter_map: dict[tuple[int, int], int] = {} def get_next_int(min_val: int, max_val: int) -> int: """Gets a random int in the range, precached to help keep it fast.""" if (min_val, max_val) not in counter_map: # If we have never seen this range, or if the cache is getting low, make a bunch of preshuffled values. counter_map[(min_val, max_val)] = min_val counter_map[(min_val, max_val)] += 1 return counter_map[(min_val, max_val)] % max_val + min_val def get_timestamp() -> int: """Get the current timestamp in seconds since epoch. This is separated out to allow for easier mocking in tests. """ return math.floor(time.time()) Python-roborock-python-roborock-d6da2db/roborock/web_api.py000066400000000000000000001022311513363643200243230ustar00rootroot00000000000000from __future__ import annotations import base64 import hashlib import hmac import logging import math import secrets import string import time from dataclasses import dataclass import aiohttp from aiohttp import ContentTypeError, FormData from pyrate_limiter import BucketFullException, Duration, Limiter, Rate from roborock import HomeDataSchedule from roborock.data import HomeData, HomeDataRoom, HomeDataScene, ProductResponse, RRiot, UserData from roborock.exceptions import ( RoborockAccountDoesNotExist, RoborockException, RoborockInvalidCode, RoborockInvalidCredentials, RoborockInvalidEmail, RoborockInvalidUserAgreement, RoborockMissingParameters, RoborockNoResponseFromBaseURL, RoborockNoUserAgreement, RoborockRateLimit, RoborockTooFrequentCodeRequests, ) _LOGGER = logging.getLogger(__name__) BASE_URLS = [ "https://usiot.roborock.com", "https://euiot.roborock.com", "https://cniot.roborock.com", "https://ruiot.roborock.com", ] @dataclass class IotLoginInfo: """Information about the login to the iot server.""" base_url: str country_code: str country: str class RoborockApiClient: _LOGIN_RATES = [ Rate(1, Duration.SECOND), Rate(3, Duration.MINUTE), Rate(10, Duration.HOUR), Rate(20, Duration.DAY), ] _HOME_DATA_RATES = [ Rate(1, Duration.SECOND), Rate(3, Duration.MINUTE), Rate(5, Duration.HOUR), Rate(40, Duration.DAY), ] _login_limiter = Limiter(_LOGIN_RATES, max_delay=1000) _home_data_limiter = Limiter(_HOME_DATA_RATES) def __init__( self, username: str, base_url: str | None = None, session: aiohttp.ClientSession | None = None ) -> None: """Sample API Client.""" self._username = username self._base_url = base_url self._device_identifier = secrets.token_urlsafe(16) self.session = session self._iot_login_info: IotLoginInfo | None = None self._base_urls = BASE_URLS if base_url is None else [base_url] async def _get_iot_login_info(self) -> IotLoginInfo: if self._iot_login_info is None: for iot_url in self._base_urls: url_request = PreparedRequest(iot_url, self.session) response = await url_request.request( "post", "/api/v1/getUrlByEmail", params={"email": self._username, "needtwostepauth": "false"}, ) if response is None: continue response_code = response.get("code") if response_code != 200: if response_code == 2003: raise RoborockInvalidEmail("Your email was incorrectly formatted.") elif response_code == 1001: raise RoborockMissingParameters( "You are missing parameters for this request, are you sure you entered your username?" ) else: raise RoborockException(f"{response.get('msg')} - response code: {response_code}") country_code = response["data"]["countrycode"] country = response["data"]["country"] if country_code is not None or country is not None: self._iot_login_info = IotLoginInfo( base_url=response["data"]["url"], country=country, country_code=country_code, ) _LOGGER.debug("Country determined to be %s and code is %s", country, country_code) return self._iot_login_info raise RoborockNoResponseFromBaseURL( "No account was found for any base url we tried. Either your email is incorrect or we do not have a" " record of the roborock server your device is on." ) return self._iot_login_info @property async def base_url(self): if self._base_url is not None: return self._base_url return (await self._get_iot_login_info()).base_url @property async def country(self): return (await self._get_iot_login_info()).country @property async def country_code(self): return (await self._get_iot_login_info()).country_code def _get_header_client_id(self): md5 = hashlib.md5() md5.update(self._username.encode()) md5.update(self._device_identifier.encode()) return base64.b64encode(md5.digest()).decode() async def nc_prepare(self, user_data: UserData, timezone: str) -> dict: """This gets a few critical parameters for adding a device to your account.""" if ( user_data.rriot is None or user_data.rriot.r is None or user_data.rriot.u is None or user_data.rriot.r.a is None ): raise RoborockException("Your userdata is missing critical attributes.") base_url = user_data.rriot.r.a prepare_request = PreparedRequest(base_url, self.session) hid = await self._get_home_id(user_data) data = FormData() data.add_field("hid", hid) data.add_field("tzid", timezone) prepare_response = await prepare_request.request( "post", "/nc/prepare", headers={ "Authorization": _get_hawk_authentication( user_data.rriot, "/nc/prepare", {"hid": hid, "tzid": timezone} ), }, data=data, ) if prepare_response is None: raise RoborockException("prepare_response is None") if not prepare_response.get("success"): raise RoborockException(f"{prepare_response.get('msg')} - response code: {prepare_response.get('code')}") return prepare_response["result"] async def add_device(self, user_data: UserData, s: str, t: str) -> dict: """This will add a new device to your account it is recommended to only use this during a pairing cycle with a device. Please see here: https://github.com/Python-roborock/Roborockmitmproxy/blob/main/handshake_protocol.md """ if ( user_data.rriot is None or user_data.rriot.r is None or user_data.rriot.u is None or user_data.rriot.r.a is None ): raise RoborockException("Your userdata is missing critical attributes.") base_url = user_data.rriot.r.a add_device_request = PreparedRequest(base_url, self.session) add_device_response = await add_device_request.request( "GET", "/user/devices/newadd", headers={ "Authorization": _get_hawk_authentication( user_data.rriot, "/user/devices/newadd", params={"s": s, "t": t} ), }, params={"s": s, "t": t}, ) if add_device_response is None: raise RoborockException("add_device is None") if not add_device_response.get("success"): raise RoborockException( f"{add_device_response.get('msg')} - response code: {add_device_response.get('code')}" ) return add_device_response["result"] async def request_code(self) -> None: try: await self._login_limiter.try_acquire_async("login") except BucketFullException as ex: _LOGGER.info(ex.meta_info) raise RoborockRateLimit("Reached maximum requests for login. Please try again later.") from ex base_url = await self.base_url header_clientid = self._get_header_client_id() code_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) code_response = await code_request.request( "post", "/api/v1/sendEmailCode", params={ "username": self._username, "type": "auth", }, ) if code_response is None: raise RoborockException("Failed to get a response from send email code") response_code = code_response.get("code") if response_code != 200: _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response) if response_code == 2008: raise RoborockAccountDoesNotExist("Account does not exist - check your login and try again.") elif response_code == 9002: raise RoborockTooFrequentCodeRequests("You have attempted to request too many codes. Try again later") else: raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}") async def request_code_v4(self) -> None: """Request a code using the v4 endpoint.""" if await self.country_code is None or await self.country is None: _LOGGER.info("No country code or country found, trying old version of request code.") return await self.request_code() try: await self._login_limiter.try_acquire_async("login") except BucketFullException as ex: _LOGGER.info(ex.meta_info) raise RoborockRateLimit("Reached maximum requests for login. Please try again later.") from ex base_url = await self.base_url header_clientid = self._get_header_client_id() code_request = PreparedRequest( base_url, self.session, { "header_clientid": header_clientid, "Content-Type": "application/x-www-form-urlencoded", "header_clientlang": "en", }, ) code_response = await code_request.request( "post", "/api/v4/email/code/send", data={"email": self._username, "type": "login", "platform": ""}, ) if code_response is None: raise RoborockException("Failed to get a response from send email code") response_code = code_response.get("code") if response_code != 200: _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response) if response_code == 2008: raise RoborockAccountDoesNotExist("Account does not exist - check your login and try again.") elif response_code == 9002: raise RoborockTooFrequentCodeRequests("You have attempted to request too many codes. Try again later") elif response_code == 3030 and len(self._base_urls) > 1: self._base_urls = self._base_urls[1:] self._iot_login_info = None return await self.request_code_v4() else: raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}") async def _sign_key_v3(self, s: str) -> str: """Sign a randomly generated string.""" base_url = await self.base_url header_clientid = self._get_header_client_id() code_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) code_response = await code_request.request( "post", "/api/v3/key/sign", params={"s": s}, ) if not code_response or "data" not in code_response or "k" not in code_response["data"]: raise RoborockException("Failed to get a response from sign key") response_code = code_response.get("code") if response_code != 200: _LOGGER.info("Request code failed for %s with the following context: %s", self._username, code_response) raise RoborockException(f"{code_response.get('msg')} - response code: {code_response.get('code')}") return code_response["data"]["k"] async def code_login_v4( self, code: int | str, country: str | None = None, country_code: int | None = None ) -> UserData: """ Login via code authentication. :param code: The code from the email. :param country: The two-character representation of the country, i.e. "US" :param country_code: the country phone number code i.e. 1 for US. """ base_url = await self.base_url if country is None: country = await self.country if country_code is None: country_code = await self.country_code if country_code is None or country is None: _LOGGER.info("No country code or country found, trying old version of code login.") return await self.code_login(code) header_clientid = self._get_header_client_id() x_mercy_ks = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16)) x_mercy_k = await self._sign_key_v3(x_mercy_ks) login_request = PreparedRequest( base_url, self.session, { "header_clientid": header_clientid, "x-mercy-ks": x_mercy_ks, "x-mercy-k": x_mercy_k, "Content-Type": "application/x-www-form-urlencoded", "header_clientlang": "en", "header_appversion": "4.54.02", "header_phonesystem": "iOS", "header_phonemodel": "iPhone16,1", }, ) login_response = await login_request.request( "post", "/api/v4/auth/email/login/code", data={ "country": country, "countryCode": country_code, "email": self._username, "code": code, # Major and minor version are the user agreement version, we will need to see if this needs to be # dynamic https://usiot.roborock.com/api/v3/app/agreement/latest?country=US "majorVersion": 14, "minorVersion": 0, }, ) if login_response is None: raise RoborockException("Login request response is None") response_code = login_response.get("code") if response_code != 200: _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response) if response_code == 2018: raise RoborockInvalidCode("Invalid code - check your code and try again.") if response_code == 3009: raise RoborockNoUserAgreement("You must accept the user agreement in the Roborock app to continue.") if response_code == 3006: raise RoborockInvalidUserAgreement( "User agreement must be accepted again - or you are attempting to use the Mi Home app account." ) if response_code == 3039: raise RoborockAccountDoesNotExist( "This account does not exist - please ensure that you selected the right region and email." ) raise RoborockException(f"{login_response.get('msg')} - response code: {response_code}") user_data = login_response.get("data") if not isinstance(user_data, dict): raise RoborockException("Got unexpected data type for user_data") return UserData.from_dict(user_data) async def pass_login(self, password: str) -> UserData: try: await self._login_limiter.try_acquire_async("login") except BucketFullException as ex: _LOGGER.info(ex.meta_info) raise RoborockRateLimit("Reached maximum requests for login. Please try again later.") from ex base_url = await self.base_url header_clientid = self._get_header_client_id() login_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) login_response = await login_request.request( "post", "/api/v1/login", params={ "username": self._username, "password": password, "needtwostepauth": "false", }, ) if login_response is None: raise RoborockException("Login response is none") if login_response.get("code") != 200: _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response) raise RoborockException(f"{login_response.get('msg')} - response code: {login_response.get('code')}") user_data = login_response.get("data") if not isinstance(user_data, dict): raise RoborockException("Got unexpected data type for user_data") return UserData.from_dict(user_data) async def pass_login_v3(self, password: str) -> UserData: """Seemingly it follows the format below, but password is encrypted in some manner. # login_response = await login_request.request( # "post", # "/api/v3/auth/email/login", # params={ # "email": self._username, # "password": password, # "twoStep": 1, # "version": 0 # }, # ) """ raise NotImplementedError("Pass_login_v3 has not yet been implemented") async def code_login(self, code: int | str) -> UserData: base_url = await self.base_url header_clientid = self._get_header_client_id() login_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) login_response = await login_request.request( "post", "/api/v1/loginWithCode", params={ "username": self._username, "verifycode": code, "verifycodetype": "AUTH_EMAIL_CODE", }, ) if login_response is None: raise RoborockException("Login request response is None") response_code = login_response.get("code") if response_code != 200: _LOGGER.info("Login failed for %s with the following context: %s", self._username, login_response) if response_code == 2018: raise RoborockInvalidCode("Invalid code - check your code and try again.") if response_code == 3009: raise RoborockNoUserAgreement("You must accept the user agreement in the Roborock app to continue.") if response_code == 3006: raise RoborockInvalidUserAgreement( "User agreement must be accepted again - or you are attempting to use the Mi Home app account." ) raise RoborockException(f"{login_response.get('msg')} - response code: {response_code}") user_data = login_response.get("data") if not isinstance(user_data, dict): raise RoborockException("Got unexpected data type for user_data") return UserData.from_dict(user_data) async def _get_home_id(self, user_data: UserData): base_url = await self.base_url header_clientid = self._get_header_client_id() home_id_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) home_id_response = await home_id_request.request( "get", "/api/v1/getHomeDetail", headers={"Authorization": user_data.token}, ) if home_id_response is None: raise RoborockException("home_id_response is None") if home_id_response.get("code") != 200: _LOGGER.info("Get Home Id failed with the following context: %s", home_id_response) if home_id_response.get("code") == 2010: raise RoborockInvalidCredentials( f"Invalid credentials ({home_id_response.get('msg')}) - check your login and try again." ) raise RoborockException(f"{home_id_response.get('msg')} - response code: {home_id_response.get('code')}") return home_id_response["data"]["rrHomeId"] async def get_home_data(self, user_data: UserData) -> HomeData: try: self._home_data_limiter.try_acquire("home_data") except BucketFullException as ex: _LOGGER.info(ex.meta_info) raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.") from ex rriot = user_data.rriot if rriot is None: raise RoborockException("rriot is none") home_id = await self._get_home_id(user_data) if rriot.r.a is None: raise RoborockException("Missing field 'a' in rriot reference") home_request = PreparedRequest( rriot.r.a, self.session, { "Authorization": _get_hawk_authentication(rriot, f"/user/homes/{str(home_id)}"), }, ) home_response = await home_request.request("get", "/user/homes/" + str(home_id)) if not home_response.get("success"): raise RoborockException(home_response) home_data = home_response.get("result") if isinstance(home_data, dict): return HomeData.from_dict(home_data) else: raise RoborockException("home_response result was an unexpected type") async def get_home_data_v2(self, user_data: UserData) -> HomeData: """This is the same as get_home_data, but uses a different endpoint and includes non-robotic vacuums.""" try: self._home_data_limiter.try_acquire("home_data") except BucketFullException as ex: _LOGGER.info(ex.meta_info) raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.") from ex rriot = user_data.rriot if rriot is None: raise RoborockException("rriot is none") home_id = await self._get_home_id(user_data) if rriot.r.a is None: raise RoborockException("Missing field 'a' in rriot reference") home_request = PreparedRequest( rriot.r.a, self.session, { "Authorization": _get_hawk_authentication(rriot, "/v2/user/homes/" + str(home_id)), }, ) home_response = await home_request.request("get", "/v2/user/homes/" + str(home_id)) if not home_response.get("success"): raise RoborockException(home_response) home_data = home_response.get("result") if isinstance(home_data, dict): return HomeData.from_dict(home_data) else: raise RoborockException("home_response result was an unexpected type") async def get_home_data_v3(self, user_data: UserData) -> HomeData: """This is the same as get_home_data, but uses a different endpoint and includes non-robotic vacuums.""" try: self._home_data_limiter.try_acquire("home_data") except BucketFullException as ex: _LOGGER.info(ex.meta_info) raise RoborockRateLimit("Reached maximum requests for home data. Please try again later.") from ex rriot = user_data.rriot home_id = await self._get_home_id(user_data) if rriot.r.a is None: raise RoborockException("Missing field 'a' in rriot reference") home_request = PreparedRequest( rriot.r.a, self.session, { "Authorization": _get_hawk_authentication(rriot, "/v3/user/homes/" + str(home_id)), }, ) home_response = await home_request.request("get", "/v3/user/homes/" + str(home_id)) if not home_response.get("success"): raise RoborockException(home_response) home_data = home_response.get("result") if isinstance(home_data, dict): return HomeData.from_dict(home_data) raise RoborockException(f"home_response result was an unexpected type: {home_data}") async def get_rooms(self, user_data: UserData, home_id: int | None = None) -> list[HomeDataRoom]: rriot = user_data.rriot if rriot is None: raise RoborockException("rriot is none") if home_id is None: home_id = await self._get_home_id(user_data) if rriot.r.a is None: raise RoborockException("Missing field 'a' in rriot reference") room_request = PreparedRequest( rriot.r.a, self.session, { "Authorization": _get_hawk_authentication(rriot, "/v2/user/homes/" + str(home_id)), }, ) room_response = await room_request.request("get", f"/user/homes/{str(home_id)}/rooms" + str(home_id)) if not room_response.get("success"): raise RoborockException(room_response) rooms = room_response.get("result") if isinstance(rooms, list): output_list = [] for room in rooms: output_list.append(HomeDataRoom.from_dict(room)) return output_list else: raise RoborockException("home_response result was an unexpected type") async def get_scenes(self, user_data: UserData, device_id: str) -> list[HomeDataScene]: rriot = user_data.rriot if rriot is None: raise RoborockException("rriot is none") if rriot.r.a is None: raise RoborockException("Missing field 'a' in rriot reference") scenes_request = PreparedRequest( rriot.r.a, self.session, { "Authorization": _get_hawk_authentication(rriot, f"/user/scene/device/{str(device_id)}"), }, ) scenes_response = await scenes_request.request("get", f"/user/scene/device/{str(device_id)}") if not scenes_response.get("success"): raise RoborockException(scenes_response) scenes = scenes_response.get("result") if isinstance(scenes, list): return [HomeDataScene.from_dict(scene) for scene in scenes] else: raise RoborockException("scene_response result was an unexpected type") async def execute_scene(self, user_data: UserData, scene_id: int) -> None: rriot = user_data.rriot if rriot is None: raise RoborockException("rriot is none") if rriot.r.a is None: raise RoborockException("Missing field 'a' in rriot reference") execute_scene_request = PreparedRequest( rriot.r.a, self.session, { "Authorization": _get_hawk_authentication(rriot, f"/user/scene/{str(scene_id)}/execute"), }, ) execute_scene_response = await execute_scene_request.request("POST", f"/user/scene/{str(scene_id)}/execute") if not execute_scene_response.get("success"): raise RoborockException(execute_scene_response) async def get_schedules(self, user_data: UserData, device_id: str) -> list[HomeDataSchedule]: rriot = user_data.rriot if rriot is None: raise RoborockException("rriot is none") if rriot.r.a is None: raise RoborockException("Missing field 'a' in rriot reference") schedules_request = PreparedRequest( rriot.r.a, self.session, { "Authorization": _get_hawk_authentication(rriot, f"/user/devices/{device_id}/jobs"), }, ) schedules_response = await schedules_request.request("get", f"/user/devices/{str(device_id)}/jobs") if not schedules_response.get("success"): raise RoborockException(schedules_response) schedules = schedules_response.get("result") if isinstance(schedules, list): return [HomeDataSchedule.from_dict(schedule) for schedule in schedules] else: raise RoborockException(f"schedule_response result was an unexpected type: {schedules}") async def get_products(self, user_data: UserData) -> ProductResponse: """Gets all products and their schemas, good for determining status codes and model numbers.""" base_url = await self.base_url header_clientid = self._get_header_client_id() product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) product_response = await product_request.request( "get", "/api/v4/product", headers={"Authorization": user_data.token}, ) if product_response is None: raise RoborockException("home_id_response is None") if product_response.get("code") != 200: raise RoborockException(f"{product_response.get('msg')} - response code: {product_response.get('code')}") result = product_response.get("data") if isinstance(result, dict): return ProductResponse.from_dict(result) raise RoborockException("product result was an unexpected type") async def download_code(self, user_data: UserData, product_id: int): base_url = await self.base_url header_clientid = self._get_header_client_id() product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) request = {"apilevel": 99999, "productids": [product_id], "type": 2} response = await product_request.request( "post", "/api/v1/appplugin", json=request, headers={"Authorization": user_data.token, "Content-Type": "application/json"}, ) return response["data"][0]["url"] async def download_category_code(self, user_data: UserData): base_url = await self.base_url header_clientid = self._get_header_client_id() product_request = PreparedRequest(base_url, self.session, {"header_clientid": header_clientid}) response = await product_request.request( "get", "api/v1/plugins?apiLevel=99999&type=2", headers={ "Authorization": user_data.token, }, ) return {r["category"]: r["url"] for r in response["data"]["categoryPluginList"]} class PreparedRequest: def __init__( self, base_url: str, session: aiohttp.ClientSession | None = None, base_headers: dict | None = None ) -> None: self.base_url = base_url self.base_headers = base_headers or {} self.session = session async def request(self, method: str, url: str, params=None, data=None, headers=None, json=None) -> dict: _url = "/".join(s.strip("/") for s in [self.base_url, url]) _headers = {**self.base_headers, **(headers or {})} close_session = self.session is None session = self.session if self.session is not None else aiohttp.ClientSession() try: async with session.request(method, _url, params=params, data=data, headers=_headers, json=json) as resp: return await resp.json() except ContentTypeError as err: """If we get an error, lets log everything for debugging.""" try: resp_json = await resp.json(content_type=None) _LOGGER.info("Resp: %s", resp_json) except ContentTypeError as err_2: _LOGGER.info(err_2) resp_raw = await resp.read() _LOGGER.info("Resp raw: %s", resp_raw) # Still raise the err so that it's clear it failed. raise err finally: if close_session: await session.close() def _process_extra_hawk_values(values: dict | None) -> str: if values is None: return "" else: sorted_keys = sorted(values.keys()) result = [] for key in sorted_keys: value = values.get(key) result.append(f"{key}={value}") return hashlib.md5("&".join(result).encode()).hexdigest() def _get_hawk_authentication(rriot: RRiot, url: str, formdata: dict | None = None, params: dict | None = None) -> str: timestamp = math.floor(time.time()) nonce = secrets.token_urlsafe(6) formdata_str = _process_extra_hawk_values(formdata) params_str = _process_extra_hawk_values(params) prestr = ":".join( [ rriot.u, rriot.s, nonce, str(timestamp), hashlib.md5(url.encode()).hexdigest(), params_str, formdata_str, ] ) mac = base64.b64encode(hmac.new(rriot.h.encode(), prestr.encode(), hashlib.sha256).digest()).decode() return f'Hawk id="{rriot.u}",s="{rriot.s}",ts="{timestamp}",nonce="{nonce}",mac="{mac}"' class UserWebApiClient: """Wrapper around RoborockApiClient to provide information for a specific user. This binds a RoborockApiClient to a specific user context with the provided UserData. This allows for easier access to user-specific data, to avoid needing to pass UserData around and mock out the web API. """ def __init__(self, web_api: RoborockApiClient, user_data: UserData) -> None: """Initialize the wrapper with the API client and user data.""" self._web_api = web_api self._user_data = user_data async def get_home_data(self) -> HomeData: """Fetch home data using the API client.""" return await self._web_api.get_home_data_v3(self._user_data) async def get_routines(self, device_id: str) -> list[HomeDataScene]: """Fetch routines (scenes) for a specific device.""" return await self._web_api.get_scenes(self._user_data, device_id) async def execute_routine(self, scene_id: int) -> None: """Execute a specific routine (scene) by its ID.""" await self._web_api.execute_scene(self._user_data, scene_id) Python-roborock-python-roborock-d6da2db/tests/000077500000000000000000000000001513363643200216665ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/__init__.py000066400000000000000000000000001513363643200237650ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/data/000077500000000000000000000000001513363643200225775ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/data/b01_q7/000077500000000000000000000000001513363643200235705ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/data/b01_q7/test_b01_q7_containers.py000066400000000000000000000056211513363643200304230ustar00rootroot00000000000000"""Test cases for the containers module.""" from roborock.data.b01_q7 import ( B01Fault, B01Props, SCWindMapping, WorkStatusMapping, ) def test_b01props_deserialization(): """Test that B01Props can be deserialized after its module is dynamically imported.""" B01_PROPS_MOCK_DATA = { "status": 6, "fault": 510, "wind": 3, "water": 2, "mode": 1, "quantity": 1, "alarm": 0, "volume": 60, "hypa": 90, "mainBrush": 80, "sideBrush": 70, "mopLife": 60, "mainSensor": 50, "netStatus": { "rssi": "-60", "loss": 1, "ping": 20, "ip": "192.168.1.102", "mac": "BB:CC:DD:EE:FF:00", "ssid": "MyOtherWiFi", "frequency": 2.4, "bssid": "00:FF:EE:DD:CC:BB", }, "repeatState": 1, "tankState": 0, "sweepType": 0, "cleanPathPreference": 1, "clothState": 1, "timeZone": -5, "timeZoneInfo": "America/New_York", "language": 2, "cleaningTime": 1500, "realCleanTime": 1400, "cleaningArea": 600000, "customType": 1, "sound": 0, "workMode": 3, "stationAct": 1, "chargeState": 0, "currentMapId": 2, "mapNum": 3, "dustAction": 0, "quietIsOpen": 1, "quietBeginTime": 23, "quietEndTime": 7, "cleanFinish": 0, "voiceType": 2, "voiceTypeVersion": 1, "orderTotal": {"total": 12, "enable": 0}, "buildMap": 0, "privacy": { "aiRecognize": 1, "dirtRecognize": 1, "petRecognize": 1, "carpetTurbo": 1, "carpetAvoid": 1, "carpetShow": 1, "mapUploads": 1, "aiAgent": 1, "aiAvoidance": 1, "recordUploads": 1, "alongFloor": 1, "autoUpgrade": 1, }, "dustAutoState": 0, "dustFrequency": 1, "childLock": 1, "multiFloor": 0, "mapSave": 0, "lightMode": 0, "greenLaser": 0, "dustBagUsed": 1, "orderSaveMode": 0, "manufacturer": "Roborock-Test", "backToWash": 0, "chargeStationType": 2, "pvCutCharge": 1, "pvCharging": {"status": 1, "beginTime": 10, "endTime": 18}, "serialNumber": "987654321", "recommend": {"sill": 0, "wall": 0, "roomId": [4, 5, 6]}, "addSweepStatus": 1, } deserialized = B01Props.from_dict(B01_PROPS_MOCK_DATA) assert isinstance(deserialized, B01Props) assert deserialized.fault == B01Fault.F_510 assert deserialized.status == WorkStatusMapping.SWEEP_MOPING_2 assert deserialized.wind == SCWindMapping.STRONG assert deserialized.net_status is not None assert deserialized.net_status.ip == "192.168.1.102" Python-roborock-python-roborock-d6da2db/tests/data/test_code_mappings.py000066400000000000000000000033411513363643200270210ustar00rootroot00000000000000"""Tests for code mappings. These tests exercise the custom enum methods using arbitrary enum values. """ import pytest from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP def test_from_code() -> None: """Test from_code method.""" assert B01_Q10_DP.START_CLEAN == B01_Q10_DP.from_code(201) assert B01_Q10_DP.PAUSE == B01_Q10_DP.from_code(204) assert B01_Q10_DP.STOP == B01_Q10_DP.from_code(206) def test_invalid_from_code() -> None: """Test invalid from_code method.""" with pytest.raises(ValueError, match="999999 is not a valid code for B01_Q10_DP"): B01_Q10_DP.from_code(999999) def test_invalid_from_code_optional() -> None: """Test invalid from_code_optional method.""" assert B01_Q10_DP.from_code_optional(999999) is None def test_from_name() -> None: """Test from_name method.""" assert B01_Q10_DP.START_CLEAN == B01_Q10_DP.from_name("START_CLEAN") assert B01_Q10_DP.PAUSE == B01_Q10_DP.from_name("pause") assert B01_Q10_DP.STOP == B01_Q10_DP.from_name("Stop") def test_invalid_from_name() -> None: """Test invalid from_name method.""" with pytest.raises(ValueError, match="INVALID_NAME is not a valid name for B01_Q10_DP"): B01_Q10_DP.from_name("INVALID_NAME") def test_from_value() -> None: """Test from_value method.""" assert B01_Q10_DP.START_CLEAN == B01_Q10_DP.from_value("dpStartClean") assert B01_Q10_DP.PAUSE == B01_Q10_DP.from_value("dpPause") assert B01_Q10_DP.STOP == B01_Q10_DP.from_value("dpStop") def test_invalid_from_value() -> None: """Test invalid from_value method.""" with pytest.raises(ValueError, match="invalid_value is not a valid value for B01_Q10_DP"): B01_Q10_DP.from_value("invalid_value") Python-roborock-python-roborock-d6da2db/tests/data/test_containers.py000066400000000000000000000230351513363643200263600ustar00rootroot00000000000000"""Test cases for the containers module.""" import dataclasses from dataclasses import dataclass from typing import Any import pytest from roborock.data.containers import ( HomeData, HomeDataDevice, RoborockBase, RoborockCategory, UserData, _camelize, _decamelize, ) from tests.mock_data import ( HOME_DATA_RAW, K_VALUE, LOCAL_KEY, USER_DATA, ) @dataclass class SimpleObject(RoborockBase): """Simple object for testing serialization.""" name: str | None = None value: int | None = None @dataclass class ComplexObject(RoborockBase): """Complex object for testing serialization.""" simple: SimpleObject | None = None items: list[str] | None = None value: int | None = None nested_dict: dict[str, SimpleObject] | None = None nested_list: list[SimpleObject] | None = None any: Any | None = None nested_int_dict: dict[int, SimpleObject] | None = None @dataclass class BoolFeatures(RoborockBase): """Complex object for testing serialization.""" my_flag_supported: bool | None = None my_flag_2_supported: bool | None = None is_ces_2022_supported: bool | None = None def test_simple_object() -> None: """Test serialization and deserialization of a simple object.""" obj = SimpleObject(name="Test", value=42) serialized = obj.as_dict() assert serialized == {"name": "Test", "value": 42} deserialized = SimpleObject.from_dict(serialized) assert deserialized.name == "Test" assert deserialized.value == 42 def test_complex_object() -> None: """Test serialization and deserialization of a complex object.""" simple = SimpleObject(name="Nested", value=100) obj = ComplexObject( simple=simple, items=["item1", "item2"], value=200, nested_dict={ "nested1": SimpleObject(name="Nested1", value=1), "nested2": SimpleObject(name="Nested2", value=2), }, nested_int_dict={ 10: SimpleObject(name="IntKey1", value=10), }, nested_list=[SimpleObject(name="Nested3", value=3), SimpleObject(name="Nested4", value=4)], any="This can be anything", ) serialized = obj.as_dict() assert serialized == { "simple": {"name": "Nested", "value": 100}, "items": ["item1", "item2"], "value": 200, "nestedDict": { "nested1": {"name": "Nested1", "value": 1}, "nested2": {"name": "Nested2", "value": 2}, }, "nestedIntDict": { 10: {"name": "IntKey1", "value": 10}, }, "nestedList": [ {"name": "Nested3", "value": 3}, {"name": "Nested4", "value": 4}, ], "any": "This can be anything", } deserialized = ComplexObject.from_dict(serialized) assert deserialized.simple.name == "Nested" assert deserialized.simple.value == 100 assert deserialized.items == ["item1", "item2"] assert deserialized.value == 200 assert deserialized.nested_dict == { "nested1": SimpleObject(name="Nested1", value=1), "nested2": SimpleObject(name="Nested2", value=2), } assert deserialized.nested_int_dict == { 10: SimpleObject(name="IntKey1", value=10), } assert deserialized.nested_list == [ SimpleObject(name="Nested3", value=3), SimpleObject(name="Nested4", value=4), ] assert deserialized.any == "This can be anything" @pytest.mark.parametrize( ("data"), [ { "nested_int_dict": {10: {"name": "IntKey1", "value": 10}}, }, { "nested_int_dict": {"10": {"name": "IntKey1", "value": 10}}, }, ], ) def test_from_dict_key_types(data: dict) -> None: """Test serialization and deserialization of a complex object.""" obj = ComplexObject.from_dict(data) assert obj.nested_int_dict == { 10: SimpleObject(name="IntKey1", value=10), } def test_ignore_unknown_keys() -> None: """Test that we don't fail on unknown keys.""" data = { "ignored_key": "This key should be ignored", "name": "named_object", "value": 42, } deserialized = SimpleObject.from_dict(data) assert deserialized.name == "named_object" assert deserialized.value == 42 def test_user_data(): ud = UserData.from_dict(USER_DATA) assert ud.uid == 123456 assert ud.tokentype == "token_type" assert ud.token == "abc123" assert ud.rruid == "abc123" assert ud.region == "us" assert ud.country == "US" assert ud.countrycode == "1" assert ud.nickname == "user_nickname" assert ud.rriot.u == "user123" assert ud.rriot.s == "pass123" assert ud.rriot.h == "unknown123" assert ud.rriot.k == K_VALUE assert ud.rriot.r.r == "US" assert ud.rriot.r.a == "https://api-us.roborock.com" assert ud.rriot.r.m == "tcp://mqtt-us.roborock.com:8883" assert ud.rriot.r.l == "https://wood-us.roborock.com" assert ud.tuya_device_state == 2 assert ud.avatarurl == "https://files.roborock.com/iottest/default_avatar.png" def test_home_data(): hd = HomeData.from_dict(HOME_DATA_RAW) assert hd.id == 123456 assert hd.name == "My Home" assert hd.lon is None assert hd.lat is None assert hd.geo_name is None product = hd.products[0] assert product.id == "product-id-s7-maxv" assert product.name == "Roborock S7 MaxV" assert product.code == "a27" assert product.model == "roborock.vacuum.a27" assert product.icon_url is None assert product.attribute is None assert product.capability == 0 assert product.category == RoborockCategory.VACUUM schema = product.schema assert schema[0].id == "101" assert schema[0].name == "rpc_request" assert schema[0].code == "rpc_request_code" assert schema[0].mode == "rw" assert schema[0].type == "RAW" assert schema[0].product_property is None assert schema[0].desc is None assert product.supported_schema_codes == { "additional_props", "battery", "charge_status", "drying_status", "error_code", "fan_power", "filter_life", "main_brush_life", "rpc_request_code", "rpc_response", "side_brush_life", "state", "task_cancel_in_motion", "task_cancel_low_power", "task_complete", "water_box_mode", } device = hd.devices[0] assert device.duid == "abc123" assert device.name == "Roborock S7 MaxV" assert device.attribute is None assert device.active_time == 1672364449 assert device.local_key == LOCAL_KEY assert device.runtime_env is None assert device.time_zone_id == "America/Los_Angeles" assert device.icon_url == "no_url" assert device.product_id == "product-id-s7-maxv" assert device.lon is None assert device.lat is None assert not device.share assert device.share_time is None assert device.online assert device.fv == "02.56.02" assert device.pv == "1.0" assert device.room_id == 2362003 assert device.tuya_uuid is None assert not device.tuya_migrated assert device.extra == '{"RRPhotoPrivacyVersion": "1"}' assert device.sn == "abc123" assert device.feature_set == "2234201184108543" assert device.new_feature_set == "0000000000002041" # status = device.device_status # assert status.name == assert device.silent_ota_switch assert hd.rooms[0].id == 2362048 assert hd.rooms[0].name == "Example room 1" def test_serialize_and_unserialize(): ud = UserData.from_dict(USER_DATA) ud_dict = ud.as_dict() assert ud_dict == USER_DATA def test_boolean_features() -> None: """Test serialization and deserialization of BoolFeatures.""" obj = BoolFeatures(my_flag_supported=True, my_flag_2_supported=False, is_ces_2022_supported=True) serialized = obj.as_dict() assert serialized == { "myFlagSupported": True, "myFlag2Supported": False, "isCes2022Supported": True, } deserialized = BoolFeatures.from_dict(serialized) assert dataclasses.asdict(deserialized) == { "my_flag_supported": True, "my_flag_2_supported": False, "is_ces_2022_supported": True, } @pytest.mark.parametrize( "input_str,expected", [ ("simpleTest", "simple_test"), ("testValue", "test_value"), ("anotherExampleHere", "another_example_here"), ("isCes2022Supported", "is_ces_2022_supported"), ("isThreeDMappingInnerTestSupported", "is_three_d_mapping_inner_test_supported"), ], ) def test_decamelize_function(input_str: str, expected: str) -> None: """Test the _decamelize function.""" assert _decamelize(input_str) == expected assert _camelize(expected) == input_str def test_offline_device() -> None: """Test that a HomeDataDevice response from an offline device is handled correctly.""" data = { "duid": "xxxxxx", "name": "S6 Pure", "localKey": "yyyyy", "productId": "zzzzz", "activeTime": 1765277892, "timeZoneId": "Europe/Moscow", "iconUrl": "", "share": False, "online": False, "pv": "1.0", "tuyaMigrated": False, "extra": "{}", "deviceStatus": {}, "silentOtaSwitch": False, "f": False, } device = HomeDataDevice.from_dict(data) assert device.duid == "xxxxxx" assert device.name == "S6 Pure" assert device.local_key == "yyyyy" assert device.product_id == "zzzzz" assert device.active_time == 1765277892 assert device.time_zone_id == "Europe/Moscow" assert not device.online assert device.fv is None Python-roborock-python-roborock-d6da2db/tests/data/v1/000077500000000000000000000000001513363643200231255ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/data/v1/__snapshots__/000077500000000000000000000000001513363643200257435ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/data/v1/__snapshots__/test_v1_containers.ambr000066400000000000000000000021571513363643200324250ustar00rootroot00000000000000# serializer version: 1 # name: test_multi_maps_list_info MultiMapsList(max_multi_map=4, max_bak_map=1, multi_map_count=2, map_info=[MultiMapsListMapInfo(map_flag=0, name='Downstairs', add_time=1757636125, length=10, bak_maps=[MultiMapsListMapInfoBakMaps(mapflag=None, add_time=1739205442)], rooms=[MultiMapsListRoom(id=16, tag=12, iot_name_id='6990322', iot_name='Room'), MultiMapsListRoom(id=17, tag=15, iot_name_id='7140977', iot_name='Room'), MultiMapsListRoom(id=18, tag=12, iot_name_id='6985623', iot_name='Room'), MultiMapsListRoom(id=19, tag=14, iot_name_id='6990378', iot_name='Room'), MultiMapsListRoom(id=20, tag=10, iot_name_id='7063728', iot_name='Room'), MultiMapsListRoom(id=22, tag=12, iot_name_id='6995506', iot_name='Room'), MultiMapsListRoom(id=23, tag=15, iot_name_id='7140979', iot_name='Room'), MultiMapsListRoom(id=25, tag=13, iot_name_id='6990383', iot_name='Room'), MultiMapsListRoom(id=24, tag=-1, iot_name_id='-1', iot_name='Room')]), MultiMapsListMapInfo(map_flag=1, name='Foyer', add_time=1734283706, length=5, bak_maps=[MultiMapsListMapInfoBakMaps(mapflag=None, add_time=1728184107)], rooms=[])]) # --- Python-roborock-python-roborock-d6da2db/tests/data/v1/test_v1_containers.py000066400000000000000000000214351513363643200273160ustar00rootroot00000000000000"""Test cases for the containers module.""" from syrupy import SnapshotAssertion from roborock.data.v1 import ( MultiMapsList, RoborockDockErrorCode, RoborockDockTypeCode, RoborockErrorCode, RoborockFanSpeedS7MaxV, RoborockMopIntensityS7, RoborockMopModeS7, RoborockStateCode, ) from roborock.data.v1.v1_containers import AppInitStatus, CleanRecord, CleanSummary, Consumable, DnDTimer, S7MaxVStatus from tests.mock_data import ( CLEAN_RECORD, CLEAN_SUMMARY, CONSUMABLE, DND_TIMER, STATUS, ) def test_consumable(): c = Consumable.from_dict(CONSUMABLE) assert c.main_brush_work_time == 74382 assert c.side_brush_work_time == 74383 assert c.filter_work_time == 74384 assert c.filter_element_work_time == 0 assert c.sensor_dirty_time == 74385 assert c.strainer_work_times == 65 assert c.dust_collection_work_times == 25 assert c.cleaning_brush_work_times == 66 def test_status(): s = S7MaxVStatus.from_dict(STATUS) assert s.msg_ver == 2 assert s.msg_seq == 458 assert s.state == RoborockStateCode.charging assert s.battery == 100 assert s.clean_time == 1176 assert s.clean_area == 20965000 assert s.square_meter_clean_area == 21.0 assert s.error_code == RoborockErrorCode.none assert s.map_present == 1 assert s.in_cleaning == 0 assert s.in_returning == 0 assert s.in_fresh_state == 1 assert s.lab_status == 1 assert s.water_box_status == 1 assert s.back_type == -1 assert s.wash_phase == 0 assert s.wash_ready == 0 assert s.fan_power == 102 assert s.dnd_enabled == 0 assert s.map_status == 3 assert s.current_map == 0 assert s.is_locating == 0 assert s.lock_status == 0 assert s.water_box_mode == 203 assert s.water_box_carriage_status == 1 assert s.mop_forbidden_enable == 1 assert s.camera_status == 3457 assert s.is_exploring == 0 assert s.home_sec_status == 0 assert s.home_sec_enable_password == 0 assert s.adbumper_status == [0, 0, 0] assert s.water_shortage_status == 0 assert s.dock_type == RoborockDockTypeCode.empty_wash_fill_dock assert s.dust_collection_status == 0 assert s.auto_dust_collection == 1 assert s.avoid_count == 19 assert s.mop_mode == 300 assert s.debug_mode == 0 assert s.collision_avoid_status == 1 assert s.switch_map_mode == 0 assert s.dock_error_status == RoborockDockErrorCode.ok assert s.charge_status == 1 assert s.unsave_map_reason == 0 assert s.unsave_map_flag == 0 assert s.fan_power == RoborockFanSpeedS7MaxV.balanced assert s.mop_mode == RoborockMopModeS7.standard assert s.water_box_mode == RoborockMopIntensityS7.intense def test_current_map() -> None: """Test the current map logic based on map status.""" s = S7MaxVStatus.from_dict(STATUS) assert s.map_status == 3 assert s.current_map == 0 s.map_status = 7 assert s.current_map == 1 s.map_status = 11 assert s.current_map == 2 s.map_status = None assert not s.current_map def test_dnd_timer(): dnd = DnDTimer.from_dict(DND_TIMER) assert dnd.start_hour == 22 assert dnd.start_minute == 0 assert dnd.end_hour == 7 assert dnd.end_minute == 0 assert dnd.enabled == 1 def test_clean_summary(): cs = CleanSummary.from_dict(CLEAN_SUMMARY) assert cs.clean_time == 74382 assert cs.clean_area == 1159182500 assert cs.square_meter_clean_area == 1159.2 assert cs.clean_count == 31 assert cs.dust_collection_count == 25 assert cs.records assert len(cs.records) == 2 assert cs.records[1] == 1672458041 def test_clean_record(): cr = CleanRecord.from_dict(CLEAN_RECORD) assert cr.begin == 1672543330 assert cr.end == 1672544638 assert cr.duration == 1176 assert cr.area == 20965000 assert cr.square_meter_area == 21.0 assert cr.error == 0 assert cr.complete == 1 assert cr.start_type == 2 assert cr.clean_type == 3 assert cr.finish_reason == 56 assert cr.dust_collection_status == 1 assert cr.avoid_count == 19 assert cr.wash_count == 2 assert cr.map_flag == 0 def test_no_value(): modified_status = STATUS.copy() modified_status["dock_type"] = 9999 s = S7MaxVStatus.from_dict(modified_status) assert s.dock_type == RoborockDockTypeCode.unknown assert -9999 not in RoborockDockTypeCode.keys() assert "missing" not in RoborockDockTypeCode.values() def test_multi_maps_list_info(snapshot: SnapshotAssertion) -> None: """Test that MultiMapsListInfo can be deserialized correctly.""" data = { "max_multi_map": 4, "max_bak_map": 1, "multi_map_count": 2, "map_info": [ { "mapFlag": 0, "add_time": 1757636125, "length": 10, "name": "Downstairs", "bak_maps": [{"mapFlag": 4, "add_time": 1739205442}], "rooms": [ {"id": 16, "tag": 12, "iot_name_id": "6990322", "iot_name": "Room"}, {"id": 17, "tag": 15, "iot_name_id": "7140977", "iot_name": "Room"}, {"id": 18, "tag": 12, "iot_name_id": "6985623", "iot_name": "Room"}, {"id": 19, "tag": 14, "iot_name_id": "6990378", "iot_name": "Room"}, {"id": 20, "tag": 10, "iot_name_id": "7063728", "iot_name": "Room"}, {"id": 22, "tag": 12, "iot_name_id": "6995506", "iot_name": "Room"}, {"id": 23, "tag": 15, "iot_name_id": "7140979", "iot_name": "Room"}, {"id": 25, "tag": 13, "iot_name_id": "6990383", "iot_name": "Room"}, {"id": 24, "tag": -1, "iot_name_id": "-1", "iot_name": "Room"}, ], "furnitures": [ {"id": 1, "type": 46, "subtype": 2}, {"id": 2, "type": 47, "subtype": 0}, {"id": 3, "type": 56, "subtype": 0}, {"id": 4, "type": 43, "subtype": 0}, {"id": 5, "type": 44, "subtype": 0}, {"id": 6, "type": 44, "subtype": 0}, {"id": 7, "type": 44, "subtype": 0}, {"id": 8, "type": 46, "subtype": 0}, {"id": 9, "type": 46, "subtype": 0}, ], }, { "mapFlag": 1, "add_time": 1734283706, "length": 5, "name": "Foyer", "bak_maps": [{"mapFlag": 5, "add_time": 1728184107}], "rooms": [], "furnitures": [], }, ], } deserialized = MultiMapsList.from_dict(data) assert isinstance(deserialized, MultiMapsList) assert deserialized == snapshot def test_accurate_map_flag() -> None: """Test that we parse the map flag accurately.""" s = S7MaxVStatus.from_dict(STATUS) assert s.current_map == 0 s = S7MaxVStatus.from_dict( { **STATUS, "map_status": 252, # Code for no map } ) assert s.current_map is None def test_partial_app_init_status() -> None: """Test that a partial AppInitStatus response is handled correctly.""" app_init_status = AppInitStatus.from_dict( { "local_info": { "name": "custom_A.03.0096_FCC", "bom": "A.03.0096", "location": "us", "language": "en", "wifiplan": "US", "timezone": "US/Pacific", "logserver": "awsusor0.fds.api.xiaomi.com", "featureset": 1, }, "feature_info": [111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125], "new_feature_info": 10738169343, "status_info": { "state": 8, "battery": 100, "clean_time": 251, "clean_area": 3847500, "error_code": 0, "in_cleaning": 0, "in_returning": 0, "in_fresh_state": 1, "lab_status": 3, "water_box_status": 0, "map_status": 7, "is_locating": 0, "lock_status": 0, "water_box_mode": 203, "distance_off": 0, "water_box_carriage_status": 0, "mop_forbidden_enable": 0, "camera_status": 3495, "is_exploring": 0, "home_sec_status": 0, "home_sec_enable_password": 1, "adbumper_status": [0, 0, 0], }, } ) assert app_init_status.local_info.name == "custom_A.03.0096_FCC" assert app_init_status.feature_info == [111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125] assert app_init_status.new_feature_info_str == "" Python-roborock-python-roborock-d6da2db/tests/devices/000077500000000000000000000000001513363643200233105ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/devices/__init__.py000066400000000000000000000000431513363643200254160ustar00rootroot00000000000000"""Tests for the device module.""" Python-roborock-python-roborock-d6da2db/tests/devices/__snapshots__/000077500000000000000000000000001513363643200261265ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/devices/__snapshots__/test_device_manager.ambr000066400000000000000000000511321513363643200327630ustar00rootroot00000000000000# serializer version: 1 # name: test_diagnostics_collection dict({ 'devices': list([ dict({ 'device': dict({ 'activeTime': 1672364449, 'deviceStatus': dict({ '120': 0, '121': 8, '122': 100, '123': 102, '124': 203, '125': 94, '126': 90, '127': 87, '128': 0, '133': 1, }), 'duid': '******bc123', 'extra': '{"RRPhotoPrivacyVersion": "1"}', 'featureSet': '2234201184108543', 'fv': '02.56.02', 'iconUrl': 'no_url', 'localKey': '**REDACTED**', 'name': '**REDACTED**', 'newFeatureSet': '0000000000002041', 'online': True, 'productId': '**REDACTED**', 'pv': '1.0', 'roomId': 2362003, 'share': False, 'silentOtaSwitch': True, 'sn': '**REDACTED**', 'timeZoneId': 'America/Los_Angeles', 'tuyaMigrated': False, }), 'product': dict({ 'capability': 0, 'category': 'robot.vacuum.cleaner', 'code': 'a27', 'id': 'product-id-s7-maxv', 'model': 'roborock.vacuum.a27', 'name': '**REDACTED**', 'schema': list([ dict({ 'code': 'rpc_request_code', 'id': '101', 'mode': 'rw', 'name': 'rpc_request', 'type': 'RAW', }), dict({ 'code': 'rpc_response', 'id': '102', 'mode': 'rw', 'name': 'rpc_response', 'type': 'RAW', }), dict({ 'code': 'error_code', 'id': '120', 'mode': 'ro', 'name': '错误代码', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'state', 'id': '121', 'mode': 'ro', 'name': '设备状态', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'battery', 'id': '122', 'mode': 'ro', 'name': '设备电量', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'fan_power', 'id': '123', 'mode': 'rw', 'name': '清扫模式', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'water_box_mode', 'id': '124', 'mode': 'rw', 'name': '拖地模式', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'main_brush_life', 'id': '125', 'mode': 'rw', 'name': '主刷寿命', 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ 'code': 'side_brush_life', 'id': '126', 'mode': 'rw', 'name': '边刷寿命', 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ 'code': 'filter_life', 'id': '127', 'mode': 'rw', 'name': '滤网寿命', 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ 'code': 'additional_props', 'id': '128', 'mode': 'ro', 'name': '额外状态', 'type': 'RAW', }), dict({ 'code': 'task_complete', 'id': '130', 'mode': 'ro', 'name': '完成事件', 'type': 'RAW', }), dict({ 'code': 'task_cancel_low_power', 'id': '131', 'mode': 'ro', 'name': '电量不足任务取消', 'type': 'RAW', }), dict({ 'code': 'task_cancel_in_motion', 'id': '132', 'mode': 'ro', 'name': '运动中任务取消', 'type': 'RAW', }), dict({ 'code': 'charge_status', 'id': '133', 'mode': 'ro', 'name': '充电状态', 'type': 'RAW', }), dict({ 'code': 'drying_status', 'id': '134', 'mode': 'ro', 'name': '烘干状态', 'type': 'RAW', }), ]), }), 'traits': dict({ 'device_features': dict({ 'featureInfo': list([ 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 122, 123, 124, 125, ]), 'isActivateVideoChargingAndStandbySupported': False, 'isAnalysisSupported': True, 'isAnyStateTransitGotoSupported': False, 'isAutoCollection2Supported': False, 'isAutoDeliveryFieldInGlobalStatusSupported': False, 'isAutoTearDownMopSupported': False, 'isAvoidCollisionModeSupported': False, 'isAvoidCollisionSupported': False, 'isBackChargeAutoWashSupported': False, 'isBackWashNewSmartSupported': False, 'isCarefulSlowMopSupported': False, 'isCarpetCustomCleanSupported': False, 'isCarpetDeepCleanSupported': False, 'isCarpetLongHairedExSupported': False, 'isCarpetLongHairedSupported': False, 'isCarpetPressureUseOriginParasSupported': False, 'isCarpetShapeTypeSupported': False, 'isCarpetShowOnMap': False, 'isCarpetSupported': False, 'isCes2022Supported': False, 'isCleanCountSettingSupported': False, 'isCleanDirectStatusSupported': False, 'isCleanEfficiencySupported': False, 'isCleanFluidDeliverySupported': False, 'isCleanHistoryTimeLineSupported': False, 'isCleanRouteDeepSlowPlusSupported': False, 'isCleanRouteFastModeSupported': False, 'isCleanRouteSettingSupported': True, 'isCleanThenMopModeSupported': False, 'isCleanTimeLineSupported': False, 'isCollectDustCountShowSupported': False, 'isCollectDustModeSupported': True, 'isCornerCleanModeSupported': False, 'isCornerMopStretchSupported': False, 'isCtmWithRepeatSupported': False, 'isCurrentMapRestoreEnabled': True, 'isCustomCleanModeCountSupported': False, 'isCustomModeSupported': True, 'isCustomWaterBoxDistanceSupported': True, 'isCustomizedCleanSupported': True, 'isDetectWireCarpetSupported': False, 'isDirtyObjectDetectSupported': False, 'isDirtyReplenishCleanSupported': False, 'isDryIntervalTimerSupported': False, 'isDssBelievable': False, 'isDualBandWiFiSupported': False, 'isDustCollectionSettingSupported': False, 'isDynamicallyAddCleanZonesSupported': False, 'isDynamicallySkipCleanZoneSupported': False, 'isEggDanceModeSupported': False, 'isEggModeSupportedFromNewFeatures': False, 'isExactCustomModeSupported': False, 'isExhibitionFunctionSupported': False, 'isFloorDirCleanAnyTimeSupported': False, 'isFlowLedSettingSupported': False, 'isFollowLowObsSupported': False, 'isFullDuplesSwitchSupported': False, 'isFwFilterObstacleSupported': False, 'isGapDeepCleanSupported': False, 'isGotoPureCleanPathSupported': False, 'isHotWashTowelSupported': False, 'isIdentifyRoomSupported': False, 'isIgnoreUnknownMapObjectSupported': False, 'isLdsLiftingSupported': False, 'isLedStatusSwitchSupported': True, 'isLeftWaterDrainSupported': False, 'isLowAreaAccessSupported': False, 'isMainBrushUpDownSupportedFromStr': False, 'isMapBeautifyInternalDebugSupported': False, 'isMapCarpetAddSupport': False, 'isMapEraserSupported': False, 'isMatterSupported': False, 'isMaxPlusModeSupported': True, 'isMaxZoneOpenedSupported': False, 'isMechanicalArmModeSupported': False, 'isMidwayBackToDockSupported': False, 'isMinBattery15ToCleanTaskSupported': False, 'isMopForbiddenSupported': True, 'isMopPathSupported': False, 'isMopShakeModuleSupported': True, 'isMopShakeWaterMaxSupported': False, 'isMultiFloorSupported': True, 'isMultiMapSegmentTimerSupported': True, 'isNewAiRecognitionSupported': False, 'isNewDataForCleanHistory': False, 'isNewDataForCleanHistoryDetail': False, 'isNewEndpointSupported': False, 'isNewRemoteViewSupported': True, 'isNoNeedCarpetPressSetSupported': False, 'isNonePureCleanMopWithMaxPlus': False, 'isObjectDetectCheckSupported': False, 'isOfflineMapSupported': False, 'isOptimizeBatterySupported': False, 'isOrderCleanSupported': True, 'isOverSeaCtmSupported': False, 'isPetSnapshotSupported': False, 'isPetSuppliesDeepCleanSupported': False, 'isProgramModeSupported': False, 'isPumpingWaterSupported': False, 'isPureCleanMopSupported': True, 'isReSegmentSupported': True, 'isRecordAllowed': False, 'isRemoteSupported': True, 'isRightBrushStretchSupported': False, 'isRoomNameSupported': True, 'isRpcRetrySupported': False, 'isRubberBrushCarpetSupported': False, 'isSetChildSupported': False, 'isSettingCarpetFirstSupported': False, 'isShakeMopSetSupported': False, 'isShouldShowArmOverLoadSupported': False, 'isShowCleanFinishReasonSupported': True, 'isShowGeneralObstacleSupported': False, 'isShowObstaclePhotoSupported': False, 'isSideBrushLiftCarpetSupported': False, 'isSmallSideMopSupported': False, 'isSmartCleanModeSetSupported': False, 'isSoakAndWashSupported': False, 'isSoftCleanModeSupported': False, 'isSrMapSupported': False, 'isSuperDeepWashSupported': False, 'isSupportApiAppStopGraspSupported': False, 'isSupportBackupMap': True, 'isSupportCleanEstimate': False, 'isSupportCliffZone': False, 'isSupportCustomCarpet': False, 'isSupportCustomDnd': False, 'isSupportCustomDoorSill': False, 'isSupportCustomModeInCleaning': False, 'isSupportFetchTimerSummary': True, 'isSupportFloorDirection': False, 'isSupportFloorEdit': False, 'isSupportFurniture': False, 'isSupportGetParticularStatusSupported': False, 'isSupportIncrementalMap': False, 'isSupportMainBrushUpDownSupported': False, 'isSupportMopBackPwmSet': False, 'isSupportQuickMapBuilder': True, 'isSupportRemoteControlInCall': False, 'isSupportRoomTag': False, 'isSupportSetSwitchMapMode': False, 'isSupportSetVolumeInCall': False, 'isSupportSideBrushUpDownSupported': False, 'isSupportSmartDoorSill': False, 'isSupportSmartGlobalCleanWithCustomMode': False, 'isSupportSmartScene': False, 'isSupportStuckZone': False, 'isSupportVoiceControlDebug': False, 'isSupportWaterMode': True, 'isSupportedDownloadTestVoice': False, 'isSupportedDrying': False, 'isSupportedValleyElectricity': False, 'isSyncServerNameSupported': False, 'isThreeDMappingInnerTestSupported': False, 'isTidyupZonesSupported': False, 'isTwoGearsNoCollisionSupported': False, 'isTwoKeyRealTimeVideoSupported': False, 'isTwoKeyRtvInChargingSupported': False, 'isTypeIdentifySupported': False, 'isUnsaveMapReasonSupported': True, 'isUvcSterilizeSupported': False, 'isVideoMonitorSupported': False, 'isVideoPatrolSupported': False, 'isVideoSettingSupported': False, 'isVoiceControlLedSupported': False, 'isVoiceControlSupported': False, 'isWashThenChargeCmdSupported': False, 'isWaterLeakCheckSupported': False, 'isWaterSlideModeSupported': False, 'isWaterUpDownDrainSupported': False, 'isWifiManageSupported': False, 'isWorkdayHolidaySupported': False, 'newFeatureInfo': 633887780925447, 'newFeatureInfoStr': '0000000000002000', }), 'led_status': dict({ 'status': 0, }), 'network_info': dict({ 'ip': '**REDACTED**', }), 'status': dict({ 'adbumperStatus': list([ 0, 0, 0, ]), 'autoDustCollection': 1, 'avoidCount': 19, 'backType': -1, 'battery': 100, 'cameraStatus': 3457, 'chargeStatus': 1, 'cleanArea': 20965000, 'cleanTime': 1176, 'collisionAvoidStatus': 1, 'debugMode': 0, 'dndEnabled': 0, 'dockErrorStatus': 0, 'dockType': 3, 'dustCollectionStatus': 0, 'errorCode': 0, 'fanPower': 102, 'homeSecEnablePassword': 0, 'homeSecStatus': 0, 'inCleaning': 0, 'inFreshState': 1, 'inReturning': 0, 'isExploring': 0, 'isLocating': 0, 'labStatus': 1, 'lockStatus': 0, 'mapPresent': 1, 'mapStatus': 3, 'mopForbiddenEnable': 1, 'mopMode': 300, 'msgSeq': 458, 'msgVer': 2, 'state': 8, 'switchMapMode': 0, 'unsaveMapFlag': 0, 'unsaveMapReason': 0, 'washPhase': 0, 'washReady': 0, 'waterBoxCarriageStatus': 1, 'waterBoxMode': 203, 'waterBoxStatus': 1, 'waterShortageStatus': 0, }), }), }), ]), 'diagnostics': dict({ 'discover_devices': 1, 'fetch_home_data': 1, 'supported_devices': dict({ '1.0': 1, }), }), 'home_data': dict({ 'devices': list([ dict({ 'activeTime': 1672364449, 'deviceStatus': dict({ '120': 0, '121': 8, '122': 100, '123': 102, '124': 203, '125': 94, '126': 90, '127': 87, '128': 0, '133': 1, }), 'duid': '******bc123', 'extra': '{"RRPhotoPrivacyVersion": "1"}', 'featureSet': '2234201184108543', 'fv': '02.56.02', 'iconUrl': 'no_url', 'localKey': '**REDACTED**', 'name': '**REDACTED**', 'newFeatureSet': '0000000000002041', 'online': True, 'productId': '**REDACTED**', 'pv': '1.0', 'roomId': 2362003, 'share': False, 'silentOtaSwitch': True, 'sn': '**REDACTED**', 'timeZoneId': 'America/Los_Angeles', 'tuyaMigrated': False, }), ]), 'id': '**REDACTED**', 'name': '**REDACTED**', 'products': list([ dict({ 'capability': 0, 'category': 'robot.vacuum.cleaner', 'code': 'a27', 'id': '**REDACTED**', 'model': 'roborock.vacuum.a27', 'name': '**REDACTED**', 'schema': list([ dict({ 'code': 'rpc_request_code', 'id': '**REDACTED**', 'mode': 'rw', 'name': '**REDACTED**', 'type': 'RAW', }), dict({ 'code': 'rpc_response', 'id': '**REDACTED**', 'mode': 'rw', 'name': '**REDACTED**', 'type': 'RAW', }), dict({ 'code': 'error_code', 'id': '**REDACTED**', 'mode': 'ro', 'name': '**REDACTED**', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'state', 'id': '**REDACTED**', 'mode': 'ro', 'name': '**REDACTED**', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'battery', 'id': '**REDACTED**', 'mode': 'ro', 'name': '**REDACTED**', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'fan_power', 'id': '**REDACTED**', 'mode': 'rw', 'name': '**REDACTED**', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'water_box_mode', 'id': '**REDACTED**', 'mode': 'rw', 'name': '**REDACTED**', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'main_brush_life', 'id': '**REDACTED**', 'mode': 'rw', 'name': '**REDACTED**', 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ 'code': 'side_brush_life', 'id': '**REDACTED**', 'mode': 'rw', 'name': '**REDACTED**', 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ 'code': 'filter_life', 'id': '**REDACTED**', 'mode': 'rw', 'name': '**REDACTED**', 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ 'code': 'additional_props', 'id': '**REDACTED**', 'mode': 'ro', 'name': '**REDACTED**', 'type': 'RAW', }), dict({ 'code': 'task_complete', 'id': '**REDACTED**', 'mode': 'ro', 'name': '**REDACTED**', 'type': 'RAW', }), dict({ 'code': 'task_cancel_low_power', 'id': '**REDACTED**', 'mode': 'ro', 'name': '**REDACTED**', 'type': 'RAW', }), dict({ 'code': 'task_cancel_in_motion', 'id': '**REDACTED**', 'mode': 'ro', 'name': '**REDACTED**', 'type': 'RAW', }), dict({ 'code': 'charge_status', 'id': '**REDACTED**', 'mode': 'ro', 'name': '**REDACTED**', 'type': 'RAW', }), dict({ 'code': 'drying_status', 'id': '**REDACTED**', 'mode': 'ro', 'name': '**REDACTED**', 'type': 'RAW', }), ]), }), ]), 'receivedDevices': list([ ]), 'rooms': list([ dict({ 'id': 2362048, 'name': '**REDACTED**', }), dict({ 'id': 2362044, 'name': '**REDACTED**', }), dict({ 'id': 2362041, 'name': '**REDACTED**', }), ]), }), }) # --- Python-roborock-python-roborock-d6da2db/tests/devices/__snapshots__/test_file_cache.ambr000066400000000000000000001651611513363643200321040ustar00rootroot00000000000000# serializer version: 1 # name: test_set_and_flush_and_get[default-all_fields_cache] CacheData(home_data=HomeData(id=123456, name='My Home', products=[HomeDataProduct(id='product-id-s7-maxv', name='Roborock S7 MaxV', model='roborock.vacuum.a27', category=, code='a27', icon_url=None, attribute=None, capability=0, schema=[HomeDataProductSchema(id='101', name='rpc_request', code='rpc_request_code', mode='rw', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='102', name='rpc_response', code='rpc_response', mode='rw', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='120', name='错误代码', code='error_code', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='121', name='设备状态', code='state', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='122', name='设备电量', code='battery', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='123', name='清扫模式', code='fan_power', mode='rw', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='124', name='拖地模式', code='water_box_mode', mode='rw', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='125', name='主刷寿命', code='main_brush_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='126', name='边刷寿命', code='side_brush_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='127', name='滤网寿命', code='filter_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='128', name='额外状态', code='additional_props', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='130', name='完成事件', code='task_complete', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='131', name='电量不足任务取消', code='task_cancel_low_power', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='132', name='运动中任务取消', code='task_cancel_in_motion', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='133', name='充电状态', code='charge_status', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='134', name='烘干状态', code='drying_status', mode='ro', type='RAW', product_property=None, property=None, desc=None)])], devices=[HomeDataDevice(duid='abc123', name='Roborock S7 MaxV', local_key='key123key123key1', product_id='product-id-s7-maxv', fv='02.56.02', attribute=None, active_time=1672364449, runtime_env=None, time_zone_id='America/Los_Angeles', icon_url='no_url', lon=None, lat=None, share=False, share_time=None, online=True, pv='1.0', room_id=2362003, tuya_uuid=None, tuya_migrated=False, extra='{"RRPhotoPrivacyVersion": "1"}', sn='abc123', feature_set='2234201184108543', new_feature_set='0000000000002041', device_status={'121': 8, '122': 100, '123': 102, '124': 203, '125': 94, '126': 90, '127': 87, '128': 0, '133': 1, '120': 0}, silent_ota_switch=True, setting=None, f=None, create_time=None, cid=None, share_type=None, share_expired_time=None)], received_devices=[], lon=None, lat=None, geo_name=None, rooms=[HomeDataRoom(id=2362048, name='Example room 1'), HomeDataRoom(id=2362044, name='Example room 2'), HomeDataRoom(id=2362041, name='Example room 3')]), device_info={'device1': DeviceCacheData(network_info=NetworkInfo(ip='1.1.1.1', ssid='test_wifi', mac='aa:bb:cc:dd:ee:ff', bssid='aa:bb:cc:dd:ee:ff', rssi=-50), home_map_info={1: CombinedMapInfo(map_flag=1, name='Test Map 1', rooms=[NamedRoomMapping(segment_id=1023, iot_id='4321', name='Living Room'), NamedRoomMapping(segment_id=1024, iot_id='4322', name='Starship')]), 2: CombinedMapInfo(map_flag=2, name='Test Map 2', rooms=[NamedRoomMapping(segment_id=2047, iot_id='5432', name='Bedroom')])}, home_map_content_base64={1: 'ZHVtbXlfbWFwX2NvbnRlbnQ=', 2: 'bW9yZV9kdW1teV9jb250ZW50'}, device_features=DeviceFeatures(is_show_clean_finish_reason_supported=True, is_re_segment_supported=False, is_video_monitor_supported=False, is_any_state_transit_goto_supported=False, is_fw_filter_obstacle_supported=False, is_video_setting_supported=False, is_ignore_unknown_map_object_supported=False, is_set_child_supported=False, is_carpet_supported=False, is_record_allowed=False, is_mop_path_supported=False, is_multi_map_segment_timer_supported=False, is_current_map_restore_enabled=False, is_room_name_supported=False, is_shake_mop_set_supported=False, is_map_beautify_internal_debug_supported=False, is_new_data_for_clean_history=False, is_new_data_for_clean_history_detail=False, is_flow_led_setting_supported=False, is_dust_collection_setting_supported=False, is_rpc_retry_supported=False, is_avoid_collision_supported=False, is_support_set_switch_map_mode=False, is_map_carpet_add_support=False, is_custom_water_box_distance_supported=False, is_support_smart_scene=False, is_support_floor_edit=False, is_support_furniture=False, is_wash_then_charge_cmd_supported=False, is_support_room_tag=False, is_support_quick_map_builder=False, is_support_smart_global_clean_with_custom_mode=False, is_careful_slow_mop_supported=False, is_egg_mode_supported_from_new_features=False, is_carpet_show_on_map=False, is_supported_valley_electricity=False, is_unsave_map_reason_supported=False, is_supported_drying=False, is_supported_download_test_voice=False, is_support_backup_map=False, is_support_custom_mode_in_cleaning=False, is_support_remote_control_in_call=False, is_support_set_volume_in_call=True, is_support_clean_estimate=False, is_support_custom_dnd=False, is_carpet_deep_clean_supported=False, is_support_stuck_zone=False, is_support_custom_door_sill=False, is_wifi_manage_supported=False, is_clean_route_fast_mode_supported=False, is_support_cliff_zone=False, is_support_smart_door_sill=False, is_support_floor_direction=False, is_back_charge_auto_wash_supported=False, is_support_incremental_map=False, is_offline_map_supported=False, is_super_deep_wash_supported=False, is_ces_2022_supported=False, is_dss_believable=False, is_main_brush_up_down_supported_from_str=False, is_goto_pure_clean_path_supported=False, is_water_up_down_drain_supported=False, is_setting_carpet_first_supported=False, is_clean_route_deep_slow_plus_supported=False, is_dynamically_skip_clean_zone_supported=False, is_dynamically_add_clean_zones_supported=False, is_left_water_drain_supported=False, is_clean_count_setting_supported=False, is_corner_clean_mode_supported=False, is_two_key_real_time_video_supported=False, is_two_key_rtv_in_charging_supported=False, is_dirty_replenish_clean_supported=False, is_auto_delivery_field_in_global_status_supported=False, is_avoid_collision_mode_supported=False, is_voice_control_supported=False, is_new_endpoint_supported=False, is_pumping_water_supported=False, is_corner_mop_stretch_supported=False, is_hot_wash_towel_supported=False, is_floor_dir_clean_any_time_supported=False, is_pet_supplies_deep_clean_supported=False, is_mop_shake_water_max_supported=False, is_exact_custom_mode_supported=False, is_video_patrol_supported=False, is_carpet_custom_clean_supported=False, is_pet_snapshot_supported=False, is_custom_clean_mode_count_supported=False, is_new_ai_recognition_supported=False, is_auto_collection_2_supported=False, is_right_brush_stretch_supported=False, is_smart_clean_mode_set_supported=False, is_dirty_object_detect_supported=False, is_no_need_carpet_press_set_supported=False, is_voice_control_led_supported=False, is_water_leak_check_supported=False, is_min_battery_15_to_clean_task_supported=False, is_gap_deep_clean_supported=False, is_object_detect_check_supported=False, is_identify_room_supported=False, is_matter_supported=False, is_workday_holiday_supported=False, is_clean_direct_status_supported=False, is_map_eraser_supported=False, is_optimize_battery_supported=False, is_activate_video_charging_and_standby_supported=False, is_carpet_long_haired_supported=False, is_clean_history_time_line_supported=False, is_max_zone_opened_supported=False, is_exhibition_function_supported=False, is_lds_lifting_supported=False, is_auto_tear_down_mop_supported=False, is_small_side_mop_supported=False, is_support_side_brush_up_down_supported=False, is_dry_interval_timer_supported=False, is_uvc_sterilize_supported=False, is_midway_back_to_dock_supported=False, is_support_main_brush_up_down_supported=False, is_egg_dance_mode_supported=False, is_mechanical_arm_mode_supported=False, is_tidyup_zones_supported=False, is_clean_time_line_supported=False, is_clean_then_mop_mode_supported=False, is_type_identify_supported=False, is_support_get_particular_status_supported=False, is_three_d_mapping_inner_test_supported=False, is_sync_server_name_supported=False, is_should_show_arm_over_load_supported=False, is_collect_dust_count_show_supported=False, is_support_api_app_stop_grasp_supported=False, is_ctm_with_repeat_supported=False, is_side_brush_lift_carpet_supported=False, is_detect_wire_carpet_supported=False, is_water_slide_mode_supported=False, is_soak_and_wash_supported=False, is_clean_efficiency_supported=False, is_back_wash_new_smart_supported=False, is_dual_band_wi_fi_supported=False, is_program_mode_supported=False, is_clean_fluid_delivery_supported=False, is_carpet_long_haired_ex_supported=False, is_over_sea_ctm_supported=False, is_full_duples_switch_supported=False, is_low_area_access_supported=False, is_follow_low_obs_supported=False, is_two_gears_no_collision_supported=False, is_carpet_shape_type_supported=False, is_sr_map_supported=False, is_led_status_switch_supported=False, is_multi_floor_supported=False, is_support_fetch_timer_summary=False, is_order_clean_supported=False, is_analysis_supported=False, is_remote_supported=False, is_support_voice_control_debug=False, is_mop_forbidden_supported=True, is_soft_clean_mode_supported=True, is_custom_mode_supported=True, is_support_custom_carpet=True, is_show_general_obstacle_supported=True, is_show_obstacle_photo_supported=True, is_rubber_brush_carpet_supported=True, is_carpet_pressure_use_origin_paras_supported=True, is_support_mop_back_pwm_set=True, is_collect_dust_mode_supported=True, is_support_water_mode=False, is_pure_clean_mop_supported=False, is_new_remote_view_supported=False, is_max_plus_mode_supported=False, is_none_pure_clean_mop_with_max_plus=False, is_clean_route_setting_supported=False, is_mop_shake_module_supported=False, is_customized_clean_supported=False), trait_data={'test_trait': {'key': 'value', 'number': 42}}), 'device2': DeviceCacheData(network_info=None, home_map_info=None, home_map_content_base64=None, device_features=None, trait_data=None)}, network_info={}, home_map_info={}, home_map_content={}, home_map_content_base64={}, device_features=None, trait_data=None) # --- # name: test_set_and_flush_and_get[default-empty_cache] CacheData(home_data=None, device_info={}, network_info={}, home_map_info={}, home_map_content={}, home_map_content_base64={}, device_features=None, trait_data=None) # --- # name: test_set_and_flush_and_get[default-multiple_fields_cache] CacheData(home_data=HomeData(id=123456, name='My Home', products=[HomeDataProduct(id='product-id-s7-maxv', name='Roborock S7 MaxV', model='roborock.vacuum.a27', category=, code='a27', icon_url=None, attribute=None, capability=0, schema=[HomeDataProductSchema(id='101', name='rpc_request', code='rpc_request_code', mode='rw', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='102', name='rpc_response', code='rpc_response', mode='rw', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='120', name='错误代码', code='error_code', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='121', name='设备状态', code='state', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='122', name='设备电量', code='battery', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='123', name='清扫模式', code='fan_power', mode='rw', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='124', name='拖地模式', code='water_box_mode', mode='rw', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='125', name='主刷寿命', code='main_brush_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='126', name='边刷寿命', code='side_brush_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='127', name='滤网寿命', code='filter_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='128', name='额外状态', code='additional_props', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='130', name='完成事件', code='task_complete', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='131', name='电量不足任务取消', code='task_cancel_low_power', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='132', name='运动中任务取消', code='task_cancel_in_motion', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='133', name='充电状态', code='charge_status', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='134', name='烘干状态', code='drying_status', mode='ro', type='RAW', product_property=None, property=None, desc=None)])], devices=[HomeDataDevice(duid='abc123', name='Roborock S7 MaxV', local_key='key123key123key1', product_id='product-id-s7-maxv', fv='02.56.02', attribute=None, active_time=1672364449, runtime_env=None, time_zone_id='America/Los_Angeles', icon_url='no_url', lon=None, lat=None, share=False, share_time=None, online=True, pv='1.0', room_id=2362003, tuya_uuid=None, tuya_migrated=False, extra='{"RRPhotoPrivacyVersion": "1"}', sn='abc123', feature_set='2234201184108543', new_feature_set='0000000000002041', device_status={'121': 8, '122': 100, '123': 102, '124': 203, '125': 94, '126': 90, '127': 87, '128': 0, '133': 1, '120': 0}, silent_ota_switch=True, setting=None, f=None, create_time=None, cid=None, share_type=None, share_expired_time=None)], received_devices=[], lon=None, lat=None, geo_name=None, rooms=[HomeDataRoom(id=2362048, name='Example room 1'), HomeDataRoom(id=2362044, name='Example room 2'), HomeDataRoom(id=2362041, name='Example room 3')]), device_info={}, network_info={'abc123': NetworkInfo(ip='1.1.1.1', ssid='test_wifi', mac='aa:bb:cc:dd:ee:ff', bssid='aa:bb:cc:dd:ee:ff', rssi=-50)}, home_map_info={1: CombinedMapInfo(map_flag=1, name='Test Map', rooms=[NamedRoomMapping(segment_id=1023, iot_id='4321', name='Living Room')])}, home_map_content={}, home_map_content_base64={}, device_features=None, trait_data=None) # --- # name: test_set_and_flush_and_get[default-populated_cache] CacheData(home_data=HomeData(id=123456, name='My Home', products=[HomeDataProduct(id='product-id-s7-maxv', name='Roborock S7 MaxV', model='roborock.vacuum.a27', category=, code='a27', icon_url=None, attribute=None, capability=0, schema=[HomeDataProductSchema(id='101', name='rpc_request', code='rpc_request_code', mode='rw', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='102', name='rpc_response', code='rpc_response', mode='rw', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='120', name='错误代码', code='error_code', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='121', name='设备状态', code='state', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='122', name='设备电量', code='battery', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='123', name='清扫模式', code='fan_power', mode='rw', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='124', name='拖地模式', code='water_box_mode', mode='rw', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='125', name='主刷寿命', code='main_brush_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='126', name='边刷寿命', code='side_brush_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='127', name='滤网寿命', code='filter_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='128', name='额外状态', code='additional_props', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='130', name='完成事件', code='task_complete', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='131', name='电量不足任务取消', code='task_cancel_low_power', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='132', name='运动中任务取消', code='task_cancel_in_motion', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='133', name='充电状态', code='charge_status', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='134', name='烘干状态', code='drying_status', mode='ro', type='RAW', product_property=None, property=None, desc=None)])], devices=[HomeDataDevice(duid='abc123', name='Roborock S7 MaxV', local_key='key123key123key1', product_id='product-id-s7-maxv', fv='02.56.02', attribute=None, active_time=1672364449, runtime_env=None, time_zone_id='America/Los_Angeles', icon_url='no_url', lon=None, lat=None, share=False, share_time=None, online=True, pv='1.0', room_id=2362003, tuya_uuid=None, tuya_migrated=False, extra='{"RRPhotoPrivacyVersion": "1"}', sn='abc123', feature_set='2234201184108543', new_feature_set='0000000000002041', device_status={'121': 8, '122': 100, '123': 102, '124': 203, '125': 94, '126': 90, '127': 87, '128': 0, '133': 1, '120': 0}, silent_ota_switch=True, setting=None, f=None, create_time=None, cid=None, share_type=None, share_expired_time=None)], received_devices=[], lon=None, lat=None, geo_name=None, rooms=[HomeDataRoom(id=2362048, name='Example room 1'), HomeDataRoom(id=2362044, name='Example room 2'), HomeDataRoom(id=2362041, name='Example room 3')]), device_info={}, network_info={}, home_map_info={}, home_map_content={}, home_map_content_base64={}, device_features=None, trait_data=None) # --- # name: test_set_and_flush_and_get[json-all_fields_cache] CacheData(home_data=HomeData(id=123456, name='My Home', products=[HomeDataProduct(id='product-id-s7-maxv', name='Roborock S7 MaxV', model='roborock.vacuum.a27', category=, code='a27', icon_url=None, attribute=None, capability=0, schema=[HomeDataProductSchema(id='101', name='rpc_request', code='rpc_request_code', mode='rw', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='102', name='rpc_response', code='rpc_response', mode='rw', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='120', name='错误代码', code='error_code', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='121', name='设备状态', code='state', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='122', name='设备电量', code='battery', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='123', name='清扫模式', code='fan_power', mode='rw', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='124', name='拖地模式', code='water_box_mode', mode='rw', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='125', name='主刷寿命', code='main_brush_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='126', name='边刷寿命', code='side_brush_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='127', name='滤网寿命', code='filter_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='128', name='额外状态', code='additional_props', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='130', name='完成事件', code='task_complete', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='131', name='电量不足任务取消', code='task_cancel_low_power', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='132', name='运动中任务取消', code='task_cancel_in_motion', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='133', name='充电状态', code='charge_status', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='134', name='烘干状态', code='drying_status', mode='ro', type='RAW', product_property=None, property=None, desc=None)])], devices=[HomeDataDevice(duid='abc123', name='Roborock S7 MaxV', local_key='key123key123key1', product_id='product-id-s7-maxv', fv='02.56.02', attribute=None, active_time=1672364449, runtime_env=None, time_zone_id='America/Los_Angeles', icon_url='no_url', lon=None, lat=None, share=False, share_time=None, online=True, pv='1.0', room_id=2362003, tuya_uuid=None, tuya_migrated=False, extra='{"RRPhotoPrivacyVersion": "1"}', sn='abc123', feature_set='2234201184108543', new_feature_set='0000000000002041', device_status={'121': 8, '122': 100, '123': 102, '124': 203, '125': 94, '126': 90, '127': 87, '128': 0, '133': 1, '120': 0}, silent_ota_switch=True, setting=None, f=None, create_time=None, cid=None, share_type=None, share_expired_time=None)], received_devices=[], lon=None, lat=None, geo_name=None, rooms=[HomeDataRoom(id=2362048, name='Example room 1'), HomeDataRoom(id=2362044, name='Example room 2'), HomeDataRoom(id=2362041, name='Example room 3')]), device_info={'device1': DeviceCacheData(network_info=NetworkInfo(ip='1.1.1.1', ssid='test_wifi', mac='aa:bb:cc:dd:ee:ff', bssid='aa:bb:cc:dd:ee:ff', rssi=-50), home_map_info={1: CombinedMapInfo(map_flag=1, name='Test Map 1', rooms=[NamedRoomMapping(segment_id=1023, iot_id='4321', name='Living Room'), NamedRoomMapping(segment_id=1024, iot_id='4322', name='Starship')]), 2: CombinedMapInfo(map_flag=2, name='Test Map 2', rooms=[NamedRoomMapping(segment_id=2047, iot_id='5432', name='Bedroom')])}, home_map_content_base64={1: 'ZHVtbXlfbWFwX2NvbnRlbnQ=', 2: 'bW9yZV9kdW1teV9jb250ZW50'}, device_features=DeviceFeatures(is_show_clean_finish_reason_supported=True, is_re_segment_supported=False, is_video_monitor_supported=False, is_any_state_transit_goto_supported=False, is_fw_filter_obstacle_supported=False, is_video_setting_supported=False, is_ignore_unknown_map_object_supported=False, is_set_child_supported=False, is_carpet_supported=False, is_record_allowed=False, is_mop_path_supported=False, is_multi_map_segment_timer_supported=False, is_current_map_restore_enabled=False, is_room_name_supported=False, is_shake_mop_set_supported=False, is_map_beautify_internal_debug_supported=False, is_new_data_for_clean_history=False, is_new_data_for_clean_history_detail=False, is_flow_led_setting_supported=False, is_dust_collection_setting_supported=False, is_rpc_retry_supported=False, is_avoid_collision_supported=False, is_support_set_switch_map_mode=False, is_map_carpet_add_support=False, is_custom_water_box_distance_supported=False, is_support_smart_scene=False, is_support_floor_edit=False, is_support_furniture=False, is_wash_then_charge_cmd_supported=False, is_support_room_tag=False, is_support_quick_map_builder=False, is_support_smart_global_clean_with_custom_mode=False, is_careful_slow_mop_supported=False, is_egg_mode_supported_from_new_features=False, is_carpet_show_on_map=False, is_supported_valley_electricity=False, is_unsave_map_reason_supported=False, is_supported_drying=False, is_supported_download_test_voice=False, is_support_backup_map=False, is_support_custom_mode_in_cleaning=False, is_support_remote_control_in_call=False, is_support_set_volume_in_call=True, is_support_clean_estimate=False, is_support_custom_dnd=False, is_carpet_deep_clean_supported=False, is_support_stuck_zone=False, is_support_custom_door_sill=False, is_wifi_manage_supported=False, is_clean_route_fast_mode_supported=False, is_support_cliff_zone=False, is_support_smart_door_sill=False, is_support_floor_direction=False, is_back_charge_auto_wash_supported=False, is_support_incremental_map=False, is_offline_map_supported=False, is_super_deep_wash_supported=False, is_ces_2022_supported=False, is_dss_believable=False, is_main_brush_up_down_supported_from_str=False, is_goto_pure_clean_path_supported=False, is_water_up_down_drain_supported=False, is_setting_carpet_first_supported=False, is_clean_route_deep_slow_plus_supported=False, is_dynamically_skip_clean_zone_supported=False, is_dynamically_add_clean_zones_supported=False, is_left_water_drain_supported=False, is_clean_count_setting_supported=False, is_corner_clean_mode_supported=False, is_two_key_real_time_video_supported=False, is_two_key_rtv_in_charging_supported=False, is_dirty_replenish_clean_supported=False, is_auto_delivery_field_in_global_status_supported=False, is_avoid_collision_mode_supported=False, is_voice_control_supported=False, is_new_endpoint_supported=False, is_pumping_water_supported=False, is_corner_mop_stretch_supported=False, is_hot_wash_towel_supported=False, is_floor_dir_clean_any_time_supported=False, is_pet_supplies_deep_clean_supported=False, is_mop_shake_water_max_supported=False, is_exact_custom_mode_supported=False, is_video_patrol_supported=False, is_carpet_custom_clean_supported=False, is_pet_snapshot_supported=False, is_custom_clean_mode_count_supported=False, is_new_ai_recognition_supported=False, is_auto_collection_2_supported=False, is_right_brush_stretch_supported=False, is_smart_clean_mode_set_supported=False, is_dirty_object_detect_supported=False, is_no_need_carpet_press_set_supported=False, is_voice_control_led_supported=False, is_water_leak_check_supported=False, is_min_battery_15_to_clean_task_supported=False, is_gap_deep_clean_supported=False, is_object_detect_check_supported=False, is_identify_room_supported=False, is_matter_supported=False, is_workday_holiday_supported=False, is_clean_direct_status_supported=False, is_map_eraser_supported=False, is_optimize_battery_supported=False, is_activate_video_charging_and_standby_supported=False, is_carpet_long_haired_supported=False, is_clean_history_time_line_supported=False, is_max_zone_opened_supported=False, is_exhibition_function_supported=False, is_lds_lifting_supported=False, is_auto_tear_down_mop_supported=False, is_small_side_mop_supported=False, is_support_side_brush_up_down_supported=False, is_dry_interval_timer_supported=False, is_uvc_sterilize_supported=False, is_midway_back_to_dock_supported=False, is_support_main_brush_up_down_supported=False, is_egg_dance_mode_supported=False, is_mechanical_arm_mode_supported=False, is_tidyup_zones_supported=False, is_clean_time_line_supported=False, is_clean_then_mop_mode_supported=False, is_type_identify_supported=False, is_support_get_particular_status_supported=False, is_three_d_mapping_inner_test_supported=False, is_sync_server_name_supported=False, is_should_show_arm_over_load_supported=False, is_collect_dust_count_show_supported=False, is_support_api_app_stop_grasp_supported=False, is_ctm_with_repeat_supported=False, is_side_brush_lift_carpet_supported=False, is_detect_wire_carpet_supported=False, is_water_slide_mode_supported=False, is_soak_and_wash_supported=False, is_clean_efficiency_supported=False, is_back_wash_new_smart_supported=False, is_dual_band_wi_fi_supported=False, is_program_mode_supported=False, is_clean_fluid_delivery_supported=False, is_carpet_long_haired_ex_supported=False, is_over_sea_ctm_supported=False, is_full_duples_switch_supported=False, is_low_area_access_supported=False, is_follow_low_obs_supported=False, is_two_gears_no_collision_supported=False, is_carpet_shape_type_supported=False, is_sr_map_supported=False, is_led_status_switch_supported=False, is_multi_floor_supported=False, is_support_fetch_timer_summary=False, is_order_clean_supported=False, is_analysis_supported=False, is_remote_supported=False, is_support_voice_control_debug=False, is_mop_forbidden_supported=True, is_soft_clean_mode_supported=True, is_custom_mode_supported=True, is_support_custom_carpet=True, is_show_general_obstacle_supported=True, is_show_obstacle_photo_supported=True, is_rubber_brush_carpet_supported=True, is_carpet_pressure_use_origin_paras_supported=True, is_support_mop_back_pwm_set=True, is_collect_dust_mode_supported=True, is_support_water_mode=False, is_pure_clean_mop_supported=False, is_new_remote_view_supported=False, is_max_plus_mode_supported=False, is_none_pure_clean_mop_with_max_plus=False, is_clean_route_setting_supported=False, is_mop_shake_module_supported=False, is_customized_clean_supported=False), trait_data={'test_trait': {'key': 'value', 'number': 42}}), 'device2': DeviceCacheData(network_info=None, home_map_info=None, home_map_content_base64=None, device_features=None, trait_data=None)}, network_info={}, home_map_info={}, home_map_content={}, home_map_content_base64={}, device_features=None, trait_data=None) # --- # name: test_set_and_flush_and_get[json-empty_cache] CacheData(home_data=None, device_info={}, network_info={}, home_map_info={}, home_map_content={}, home_map_content_base64={}, device_features=None, trait_data=None) # --- # name: test_set_and_flush_and_get[json-multiple_fields_cache] CacheData(home_data=HomeData(id=123456, name='My Home', products=[HomeDataProduct(id='product-id-s7-maxv', name='Roborock S7 MaxV', model='roborock.vacuum.a27', category=, code='a27', icon_url=None, attribute=None, capability=0, schema=[HomeDataProductSchema(id='101', name='rpc_request', code='rpc_request_code', mode='rw', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='102', name='rpc_response', code='rpc_response', mode='rw', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='120', name='错误代码', code='error_code', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='121', name='设备状态', code='state', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='122', name='设备电量', code='battery', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='123', name='清扫模式', code='fan_power', mode='rw', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='124', name='拖地模式', code='water_box_mode', mode='rw', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='125', name='主刷寿命', code='main_brush_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='126', name='边刷寿命', code='side_brush_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='127', name='滤网寿命', code='filter_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='128', name='额外状态', code='additional_props', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='130', name='完成事件', code='task_complete', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='131', name='电量不足任务取消', code='task_cancel_low_power', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='132', name='运动中任务取消', code='task_cancel_in_motion', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='133', name='充电状态', code='charge_status', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='134', name='烘干状态', code='drying_status', mode='ro', type='RAW', product_property=None, property=None, desc=None)])], devices=[HomeDataDevice(duid='abc123', name='Roborock S7 MaxV', local_key='key123key123key1', product_id='product-id-s7-maxv', fv='02.56.02', attribute=None, active_time=1672364449, runtime_env=None, time_zone_id='America/Los_Angeles', icon_url='no_url', lon=None, lat=None, share=False, share_time=None, online=True, pv='1.0', room_id=2362003, tuya_uuid=None, tuya_migrated=False, extra='{"RRPhotoPrivacyVersion": "1"}', sn='abc123', feature_set='2234201184108543', new_feature_set='0000000000002041', device_status={'121': 8, '122': 100, '123': 102, '124': 203, '125': 94, '126': 90, '127': 87, '128': 0, '133': 1, '120': 0}, silent_ota_switch=True, setting=None, f=None, create_time=None, cid=None, share_type=None, share_expired_time=None)], received_devices=[], lon=None, lat=None, geo_name=None, rooms=[HomeDataRoom(id=2362048, name='Example room 1'), HomeDataRoom(id=2362044, name='Example room 2'), HomeDataRoom(id=2362041, name='Example room 3')]), device_info={}, network_info={'abc123': NetworkInfo(ip='1.1.1.1', ssid='test_wifi', mac='aa:bb:cc:dd:ee:ff', bssid='aa:bb:cc:dd:ee:ff', rssi=-50)}, home_map_info={1: CombinedMapInfo(map_flag=1, name='Test Map', rooms=[NamedRoomMapping(segment_id=1023, iot_id='4321', name='Living Room')])}, home_map_content={}, home_map_content_base64={}, device_features=None, trait_data=None) # --- # name: test_set_and_flush_and_get[json-populated_cache] CacheData(home_data=HomeData(id=123456, name='My Home', products=[HomeDataProduct(id='product-id-s7-maxv', name='Roborock S7 MaxV', model='roborock.vacuum.a27', category=, code='a27', icon_url=None, attribute=None, capability=0, schema=[HomeDataProductSchema(id='101', name='rpc_request', code='rpc_request_code', mode='rw', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='102', name='rpc_response', code='rpc_response', mode='rw', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='120', name='错误代码', code='error_code', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='121', name='设备状态', code='state', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='122', name='设备电量', code='battery', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='123', name='清扫模式', code='fan_power', mode='rw', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='124', name='拖地模式', code='water_box_mode', mode='rw', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='125', name='主刷寿命', code='main_brush_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='126', name='边刷寿命', code='side_brush_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='127', name='滤网寿命', code='filter_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='128', name='额外状态', code='additional_props', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='130', name='完成事件', code='task_complete', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='131', name='电量不足任务取消', code='task_cancel_low_power', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='132', name='运动中任务取消', code='task_cancel_in_motion', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='133', name='充电状态', code='charge_status', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='134', name='烘干状态', code='drying_status', mode='ro', type='RAW', product_property=None, property=None, desc=None)])], devices=[HomeDataDevice(duid='abc123', name='Roborock S7 MaxV', local_key='key123key123key1', product_id='product-id-s7-maxv', fv='02.56.02', attribute=None, active_time=1672364449, runtime_env=None, time_zone_id='America/Los_Angeles', icon_url='no_url', lon=None, lat=None, share=False, share_time=None, online=True, pv='1.0', room_id=2362003, tuya_uuid=None, tuya_migrated=False, extra='{"RRPhotoPrivacyVersion": "1"}', sn='abc123', feature_set='2234201184108543', new_feature_set='0000000000002041', device_status={'121': 8, '122': 100, '123': 102, '124': 203, '125': 94, '126': 90, '127': 87, '128': 0, '133': 1, '120': 0}, silent_ota_switch=True, setting=None, f=None, create_time=None, cid=None, share_type=None, share_expired_time=None)], received_devices=[], lon=None, lat=None, geo_name=None, rooms=[HomeDataRoom(id=2362048, name='Example room 1'), HomeDataRoom(id=2362044, name='Example room 2'), HomeDataRoom(id=2362041, name='Example room 3')]), device_info={}, network_info={}, home_map_info={}, home_map_content={}, home_map_content_base64={}, device_features=None, trait_data=None) # --- # name: test_set_and_flush_and_get[pickle-all_fields_cache] CacheData(home_data=HomeData(id=123456, name='My Home', products=[HomeDataProduct(id='product-id-s7-maxv', name='Roborock S7 MaxV', model='roborock.vacuum.a27', category=, code='a27', icon_url=None, attribute=None, capability=0, schema=[HomeDataProductSchema(id='101', name='rpc_request', code='rpc_request_code', mode='rw', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='102', name='rpc_response', code='rpc_response', mode='rw', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='120', name='错误代码', code='error_code', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='121', name='设备状态', code='state', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='122', name='设备电量', code='battery', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='123', name='清扫模式', code='fan_power', mode='rw', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='124', name='拖地模式', code='water_box_mode', mode='rw', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='125', name='主刷寿命', code='main_brush_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='126', name='边刷寿命', code='side_brush_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='127', name='滤网寿命', code='filter_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='128', name='额外状态', code='additional_props', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='130', name='完成事件', code='task_complete', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='131', name='电量不足任务取消', code='task_cancel_low_power', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='132', name='运动中任务取消', code='task_cancel_in_motion', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='133', name='充电状态', code='charge_status', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='134', name='烘干状态', code='drying_status', mode='ro', type='RAW', product_property=None, property=None, desc=None)])], devices=[HomeDataDevice(duid='abc123', name='Roborock S7 MaxV', local_key='key123key123key1', product_id='product-id-s7-maxv', fv='02.56.02', attribute=None, active_time=1672364449, runtime_env=None, time_zone_id='America/Los_Angeles', icon_url='no_url', lon=None, lat=None, share=False, share_time=None, online=True, pv='1.0', room_id=2362003, tuya_uuid=None, tuya_migrated=False, extra='{"RRPhotoPrivacyVersion": "1"}', sn='abc123', feature_set='2234201184108543', new_feature_set='0000000000002041', device_status={'121': 8, '122': 100, '123': 102, '124': 203, '125': 94, '126': 90, '127': 87, '128': 0, '133': 1, '120': 0}, silent_ota_switch=True, setting=None, f=None, create_time=None, cid=None, share_type=None, share_expired_time=None)], received_devices=[], lon=None, lat=None, geo_name=None, rooms=[HomeDataRoom(id=2362048, name='Example room 1'), HomeDataRoom(id=2362044, name='Example room 2'), HomeDataRoom(id=2362041, name='Example room 3')]), device_info={'device1': DeviceCacheData(network_info=NetworkInfo(ip='1.1.1.1', ssid='test_wifi', mac='aa:bb:cc:dd:ee:ff', bssid='aa:bb:cc:dd:ee:ff', rssi=-50), home_map_info={1: CombinedMapInfo(map_flag=1, name='Test Map 1', rooms=[NamedRoomMapping(segment_id=1023, iot_id='4321', name='Living Room'), NamedRoomMapping(segment_id=1024, iot_id='4322', name='Starship')]), 2: CombinedMapInfo(map_flag=2, name='Test Map 2', rooms=[NamedRoomMapping(segment_id=2047, iot_id='5432', name='Bedroom')])}, home_map_content_base64={1: 'ZHVtbXlfbWFwX2NvbnRlbnQ=', 2: 'bW9yZV9kdW1teV9jb250ZW50'}, device_features=DeviceFeatures(is_show_clean_finish_reason_supported=True, is_re_segment_supported=False, is_video_monitor_supported=False, is_any_state_transit_goto_supported=False, is_fw_filter_obstacle_supported=False, is_video_setting_supported=False, is_ignore_unknown_map_object_supported=False, is_set_child_supported=False, is_carpet_supported=False, is_record_allowed=False, is_mop_path_supported=False, is_multi_map_segment_timer_supported=False, is_current_map_restore_enabled=False, is_room_name_supported=False, is_shake_mop_set_supported=False, is_map_beautify_internal_debug_supported=False, is_new_data_for_clean_history=False, is_new_data_for_clean_history_detail=False, is_flow_led_setting_supported=False, is_dust_collection_setting_supported=False, is_rpc_retry_supported=False, is_avoid_collision_supported=False, is_support_set_switch_map_mode=False, is_map_carpet_add_support=False, is_custom_water_box_distance_supported=False, is_support_smart_scene=False, is_support_floor_edit=False, is_support_furniture=False, is_wash_then_charge_cmd_supported=False, is_support_room_tag=False, is_support_quick_map_builder=False, is_support_smart_global_clean_with_custom_mode=False, is_careful_slow_mop_supported=False, is_egg_mode_supported_from_new_features=False, is_carpet_show_on_map=False, is_supported_valley_electricity=False, is_unsave_map_reason_supported=False, is_supported_drying=False, is_supported_download_test_voice=False, is_support_backup_map=False, is_support_custom_mode_in_cleaning=False, is_support_remote_control_in_call=False, is_support_set_volume_in_call=True, is_support_clean_estimate=False, is_support_custom_dnd=False, is_carpet_deep_clean_supported=False, is_support_stuck_zone=False, is_support_custom_door_sill=False, is_wifi_manage_supported=False, is_clean_route_fast_mode_supported=False, is_support_cliff_zone=False, is_support_smart_door_sill=False, is_support_floor_direction=False, is_back_charge_auto_wash_supported=False, is_support_incremental_map=False, is_offline_map_supported=False, is_super_deep_wash_supported=False, is_ces_2022_supported=False, is_dss_believable=False, is_main_brush_up_down_supported_from_str=False, is_goto_pure_clean_path_supported=False, is_water_up_down_drain_supported=False, is_setting_carpet_first_supported=False, is_clean_route_deep_slow_plus_supported=False, is_dynamically_skip_clean_zone_supported=False, is_dynamically_add_clean_zones_supported=False, is_left_water_drain_supported=False, is_clean_count_setting_supported=False, is_corner_clean_mode_supported=False, is_two_key_real_time_video_supported=False, is_two_key_rtv_in_charging_supported=False, is_dirty_replenish_clean_supported=False, is_auto_delivery_field_in_global_status_supported=False, is_avoid_collision_mode_supported=False, is_voice_control_supported=False, is_new_endpoint_supported=False, is_pumping_water_supported=False, is_corner_mop_stretch_supported=False, is_hot_wash_towel_supported=False, is_floor_dir_clean_any_time_supported=False, is_pet_supplies_deep_clean_supported=False, is_mop_shake_water_max_supported=False, is_exact_custom_mode_supported=False, is_video_patrol_supported=False, is_carpet_custom_clean_supported=False, is_pet_snapshot_supported=False, is_custom_clean_mode_count_supported=False, is_new_ai_recognition_supported=False, is_auto_collection_2_supported=False, is_right_brush_stretch_supported=False, is_smart_clean_mode_set_supported=False, is_dirty_object_detect_supported=False, is_no_need_carpet_press_set_supported=False, is_voice_control_led_supported=False, is_water_leak_check_supported=False, is_min_battery_15_to_clean_task_supported=False, is_gap_deep_clean_supported=False, is_object_detect_check_supported=False, is_identify_room_supported=False, is_matter_supported=False, is_workday_holiday_supported=False, is_clean_direct_status_supported=False, is_map_eraser_supported=False, is_optimize_battery_supported=False, is_activate_video_charging_and_standby_supported=False, is_carpet_long_haired_supported=False, is_clean_history_time_line_supported=False, is_max_zone_opened_supported=False, is_exhibition_function_supported=False, is_lds_lifting_supported=False, is_auto_tear_down_mop_supported=False, is_small_side_mop_supported=False, is_support_side_brush_up_down_supported=False, is_dry_interval_timer_supported=False, is_uvc_sterilize_supported=False, is_midway_back_to_dock_supported=False, is_support_main_brush_up_down_supported=False, is_egg_dance_mode_supported=False, is_mechanical_arm_mode_supported=False, is_tidyup_zones_supported=False, is_clean_time_line_supported=False, is_clean_then_mop_mode_supported=False, is_type_identify_supported=False, is_support_get_particular_status_supported=False, is_three_d_mapping_inner_test_supported=False, is_sync_server_name_supported=False, is_should_show_arm_over_load_supported=False, is_collect_dust_count_show_supported=False, is_support_api_app_stop_grasp_supported=False, is_ctm_with_repeat_supported=False, is_side_brush_lift_carpet_supported=False, is_detect_wire_carpet_supported=False, is_water_slide_mode_supported=False, is_soak_and_wash_supported=False, is_clean_efficiency_supported=False, is_back_wash_new_smart_supported=False, is_dual_band_wi_fi_supported=False, is_program_mode_supported=False, is_clean_fluid_delivery_supported=False, is_carpet_long_haired_ex_supported=False, is_over_sea_ctm_supported=False, is_full_duples_switch_supported=False, is_low_area_access_supported=False, is_follow_low_obs_supported=False, is_two_gears_no_collision_supported=False, is_carpet_shape_type_supported=False, is_sr_map_supported=False, is_led_status_switch_supported=False, is_multi_floor_supported=False, is_support_fetch_timer_summary=False, is_order_clean_supported=False, is_analysis_supported=False, is_remote_supported=False, is_support_voice_control_debug=False, is_mop_forbidden_supported=True, is_soft_clean_mode_supported=True, is_custom_mode_supported=True, is_support_custom_carpet=True, is_show_general_obstacle_supported=True, is_show_obstacle_photo_supported=True, is_rubber_brush_carpet_supported=True, is_carpet_pressure_use_origin_paras_supported=True, is_support_mop_back_pwm_set=True, is_collect_dust_mode_supported=True, is_support_water_mode=False, is_pure_clean_mop_supported=False, is_new_remote_view_supported=False, is_max_plus_mode_supported=False, is_none_pure_clean_mop_with_max_plus=False, is_clean_route_setting_supported=False, is_mop_shake_module_supported=False, is_customized_clean_supported=False), trait_data={'test_trait': {'key': 'value', 'number': 42}}), 'device2': DeviceCacheData(network_info=None, home_map_info=None, home_map_content_base64=None, device_features=None, trait_data=None)}, network_info={}, home_map_info={}, home_map_content={}, home_map_content_base64={}, device_features=None, trait_data=None) # --- # name: test_set_and_flush_and_get[pickle-empty_cache] CacheData(home_data=None, device_info={}, network_info={}, home_map_info={}, home_map_content={}, home_map_content_base64={}, device_features=None, trait_data=None) # --- # name: test_set_and_flush_and_get[pickle-multiple_fields_cache] CacheData(home_data=HomeData(id=123456, name='My Home', products=[HomeDataProduct(id='product-id-s7-maxv', name='Roborock S7 MaxV', model='roborock.vacuum.a27', category=, code='a27', icon_url=None, attribute=None, capability=0, schema=[HomeDataProductSchema(id='101', name='rpc_request', code='rpc_request_code', mode='rw', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='102', name='rpc_response', code='rpc_response', mode='rw', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='120', name='错误代码', code='error_code', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='121', name='设备状态', code='state', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='122', name='设备电量', code='battery', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='123', name='清扫模式', code='fan_power', mode='rw', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='124', name='拖地模式', code='water_box_mode', mode='rw', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='125', name='主刷寿命', code='main_brush_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='126', name='边刷寿命', code='side_brush_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='127', name='滤网寿命', code='filter_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='128', name='额外状态', code='additional_props', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='130', name='完成事件', code='task_complete', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='131', name='电量不足任务取消', code='task_cancel_low_power', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='132', name='运动中任务取消', code='task_cancel_in_motion', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='133', name='充电状态', code='charge_status', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='134', name='烘干状态', code='drying_status', mode='ro', type='RAW', product_property=None, property=None, desc=None)])], devices=[HomeDataDevice(duid='abc123', name='Roborock S7 MaxV', local_key='key123key123key1', product_id='product-id-s7-maxv', fv='02.56.02', attribute=None, active_time=1672364449, runtime_env=None, time_zone_id='America/Los_Angeles', icon_url='no_url', lon=None, lat=None, share=False, share_time=None, online=True, pv='1.0', room_id=2362003, tuya_uuid=None, tuya_migrated=False, extra='{"RRPhotoPrivacyVersion": "1"}', sn='abc123', feature_set='2234201184108543', new_feature_set='0000000000002041', device_status={'121': 8, '122': 100, '123': 102, '124': 203, '125': 94, '126': 90, '127': 87, '128': 0, '133': 1, '120': 0}, silent_ota_switch=True, setting=None, f=None, create_time=None, cid=None, share_type=None, share_expired_time=None)], received_devices=[], lon=None, lat=None, geo_name=None, rooms=[HomeDataRoom(id=2362048, name='Example room 1'), HomeDataRoom(id=2362044, name='Example room 2'), HomeDataRoom(id=2362041, name='Example room 3')]), device_info={}, network_info={'abc123': NetworkInfo(ip='1.1.1.1', ssid='test_wifi', mac='aa:bb:cc:dd:ee:ff', bssid='aa:bb:cc:dd:ee:ff', rssi=-50)}, home_map_info={1: CombinedMapInfo(map_flag=1, name='Test Map', rooms=[NamedRoomMapping(segment_id=1023, iot_id='4321', name='Living Room')])}, home_map_content={}, home_map_content_base64={}, device_features=None, trait_data=None) # --- # name: test_set_and_flush_and_get[pickle-populated_cache] CacheData(home_data=HomeData(id=123456, name='My Home', products=[HomeDataProduct(id='product-id-s7-maxv', name='Roborock S7 MaxV', model='roborock.vacuum.a27', category=, code='a27', icon_url=None, attribute=None, capability=0, schema=[HomeDataProductSchema(id='101', name='rpc_request', code='rpc_request_code', mode='rw', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='102', name='rpc_response', code='rpc_response', mode='rw', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='120', name='错误代码', code='error_code', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='121', name='设备状态', code='state', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='122', name='设备电量', code='battery', mode='ro', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='123', name='清扫模式', code='fan_power', mode='rw', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='124', name='拖地模式', code='water_box_mode', mode='rw', type='ENUM', product_property=None, property='{"range": []}', desc=None), HomeDataProductSchema(id='125', name='主刷寿命', code='main_brush_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='126', name='边刷寿命', code='side_brush_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='127', name='滤网寿命', code='filter_life', mode='rw', type='VALUE', product_property=None, property='{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', desc=None), HomeDataProductSchema(id='128', name='额外状态', code='additional_props', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='130', name='完成事件', code='task_complete', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='131', name='电量不足任务取消', code='task_cancel_low_power', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='132', name='运动中任务取消', code='task_cancel_in_motion', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='133', name='充电状态', code='charge_status', mode='ro', type='RAW', product_property=None, property=None, desc=None), HomeDataProductSchema(id='134', name='烘干状态', code='drying_status', mode='ro', type='RAW', product_property=None, property=None, desc=None)])], devices=[HomeDataDevice(duid='abc123', name='Roborock S7 MaxV', local_key='key123key123key1', product_id='product-id-s7-maxv', fv='02.56.02', attribute=None, active_time=1672364449, runtime_env=None, time_zone_id='America/Los_Angeles', icon_url='no_url', lon=None, lat=None, share=False, share_time=None, online=True, pv='1.0', room_id=2362003, tuya_uuid=None, tuya_migrated=False, extra='{"RRPhotoPrivacyVersion": "1"}', sn='abc123', feature_set='2234201184108543', new_feature_set='0000000000002041', device_status={'121': 8, '122': 100, '123': 102, '124': 203, '125': 94, '126': 90, '127': 87, '128': 0, '133': 1, '120': 0}, silent_ota_switch=True, setting=None, f=None, create_time=None, cid=None, share_type=None, share_expired_time=None)], received_devices=[], lon=None, lat=None, geo_name=None, rooms=[HomeDataRoom(id=2362048, name='Example room 1'), HomeDataRoom(id=2362044, name='Example room 2'), HomeDataRoom(id=2362041, name='Example room 3')]), device_info={}, network_info={}, home_map_info={}, home_map_content={}, home_map_content_base64={}, device_features=None, trait_data=None) # --- Python-roborock-python-roborock-d6da2db/tests/devices/__snapshots__/test_v1_device.ambr000066400000000000000000001616121513363643200317040ustar00rootroot00000000000000# serializer version: 1 # name: test_device_trait_command_parsing[clean_summary] CleanSummaryTrait(clean_area=24258125000, clean_count=296, clean_time=1442559, command=, dust_collection_count=None, last_clean_record=CleanRecord(area=81122500, avoid_count=None, begin=1738864366, begin_datetime=datetime.datetime(2025, 2, 6, 17, 52, 46, tzinfo=datetime.timezone.utc), clean_type=None, complete=None, duration=4358, dust_collection_status=None, end=1738868964, end_datetime=datetime.datetime(2025, 2, 6, 19, 9, 24, tzinfo=datetime.timezone.utc), error=None, finish_reason=None, map_flag=None, square_meter_area=81.1, start_type=None, wash_count=None), last_clean_t=None, records=[1756848207, 1754930385, 1753203976, 1752183435, 1747427370, 1746204046, 1745601543, 1744387080, 1743528522, 1742489154, 1741022299, 1740433682, 1739902516, 1738875106, 1738864366, 1738620067, 1736873889, 1736197544, 1736121269, 1734458038], square_meter_clean_area=24258.1) # --- # name: test_device_trait_command_parsing[clean_summary].1 dict({ 'device': dict({ 'activeTime': 1672364449, 'deviceStatus': dict({ '120': 0, '121': 8, '122': 100, '123': 102, '124': 203, '125': 94, '126': 90, '127': 87, '128': 0, '133': 1, }), 'duid': '******bc123', 'extra': '{"RRPhotoPrivacyVersion": "1"}', 'featureSet': '2234201184108543', 'fv': '02.56.02', 'iconUrl': 'no_url', 'localKey': '**REDACTED**', 'name': '**REDACTED**', 'newFeatureSet': '0000000000002041', 'online': True, 'productId': '**REDACTED**', 'pv': '1.0', 'roomId': 2362003, 'share': False, 'silentOtaSwitch': True, 'sn': '**REDACTED**', 'timeZoneId': 'America/Los_Angeles', 'tuyaMigrated': False, }), 'product': dict({ 'capability': 0, 'category': 'robot.vacuum.cleaner', 'code': 'a27', 'id': 'product-id-s7-maxv', 'model': 'roborock.vacuum.a27', 'name': '**REDACTED**', 'schema': list([ dict({ 'code': 'rpc_request_code', 'id': '101', 'mode': 'rw', 'name': 'rpc_request', 'type': 'RAW', }), dict({ 'code': 'rpc_response', 'id': '102', 'mode': 'rw', 'name': 'rpc_response', 'type': 'RAW', }), dict({ 'code': 'error_code', 'id': '120', 'mode': 'ro', 'name': '错误代码', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'state', 'id': '121', 'mode': 'ro', 'name': '设备状态', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'battery', 'id': '122', 'mode': 'ro', 'name': '设备电量', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'fan_power', 'id': '123', 'mode': 'rw', 'name': '清扫模式', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'water_box_mode', 'id': '124', 'mode': 'rw', 'name': '拖地模式', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'main_brush_life', 'id': '125', 'mode': 'rw', 'name': '主刷寿命', 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ 'code': 'side_brush_life', 'id': '126', 'mode': 'rw', 'name': '边刷寿命', 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ 'code': 'filter_life', 'id': '127', 'mode': 'rw', 'name': '滤网寿命', 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ 'code': 'additional_props', 'id': '128', 'mode': 'ro', 'name': '额外状态', 'type': 'RAW', }), dict({ 'code': 'task_complete', 'id': '130', 'mode': 'ro', 'name': '完成事件', 'type': 'RAW', }), dict({ 'code': 'task_cancel_low_power', 'id': '131', 'mode': 'ro', 'name': '电量不足任务取消', 'type': 'RAW', }), dict({ 'code': 'task_cancel_in_motion', 'id': '132', 'mode': 'ro', 'name': '运动中任务取消', 'type': 'RAW', }), dict({ 'code': 'charge_status', 'id': '133', 'mode': 'ro', 'name': '充电状态', 'type': 'RAW', }), dict({ 'code': 'drying_status', 'id': '134', 'mode': 'ro', 'name': '烘干状态', 'type': 'RAW', }), ]), }), 'traits': dict({ 'clean_summary': dict({ 'cleanArea': 24258125000, 'cleanCount': 296, 'cleanTime': 1442559, 'records': list([ 1756848207, 1754930385, 1753203976, 1752183435, 1747427370, 1746204046, 1745601543, 1744387080, 1743528522, 1742489154, 1741022299, 1740433682, 1739902516, 1738875106, 1738864366, 1738620067, 1736873889, 1736197544, 1736121269, 1734458038, ]), }), 'device_features': dict({ 'featureInfo': list([ 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 122, 123, 124, 125, ]), 'isActivateVideoChargingAndStandbySupported': False, 'isAnalysisSupported': True, 'isAnyStateTransitGotoSupported': False, 'isAutoCollection2Supported': False, 'isAutoDeliveryFieldInGlobalStatusSupported': False, 'isAutoTearDownMopSupported': False, 'isAvoidCollisionModeSupported': False, 'isAvoidCollisionSupported': False, 'isBackChargeAutoWashSupported': False, 'isBackWashNewSmartSupported': False, 'isCarefulSlowMopSupported': False, 'isCarpetCustomCleanSupported': False, 'isCarpetDeepCleanSupported': False, 'isCarpetLongHairedExSupported': False, 'isCarpetLongHairedSupported': False, 'isCarpetPressureUseOriginParasSupported': False, 'isCarpetShapeTypeSupported': False, 'isCarpetShowOnMap': False, 'isCarpetSupported': False, 'isCes2022Supported': False, 'isCleanCountSettingSupported': False, 'isCleanDirectStatusSupported': False, 'isCleanEfficiencySupported': False, 'isCleanFluidDeliverySupported': False, 'isCleanHistoryTimeLineSupported': False, 'isCleanRouteDeepSlowPlusSupported': False, 'isCleanRouteFastModeSupported': False, 'isCleanRouteSettingSupported': True, 'isCleanThenMopModeSupported': False, 'isCleanTimeLineSupported': False, 'isCollectDustCountShowSupported': False, 'isCollectDustModeSupported': True, 'isCornerCleanModeSupported': False, 'isCornerMopStretchSupported': False, 'isCtmWithRepeatSupported': False, 'isCurrentMapRestoreEnabled': True, 'isCustomCleanModeCountSupported': False, 'isCustomModeSupported': True, 'isCustomWaterBoxDistanceSupported': True, 'isCustomizedCleanSupported': True, 'isDetectWireCarpetSupported': False, 'isDirtyObjectDetectSupported': False, 'isDirtyReplenishCleanSupported': False, 'isDryIntervalTimerSupported': False, 'isDssBelievable': False, 'isDualBandWiFiSupported': False, 'isDustCollectionSettingSupported': False, 'isDynamicallyAddCleanZonesSupported': False, 'isDynamicallySkipCleanZoneSupported': False, 'isEggDanceModeSupported': False, 'isEggModeSupportedFromNewFeatures': False, 'isExactCustomModeSupported': False, 'isExhibitionFunctionSupported': False, 'isFloorDirCleanAnyTimeSupported': False, 'isFlowLedSettingSupported': False, 'isFollowLowObsSupported': False, 'isFullDuplesSwitchSupported': False, 'isFwFilterObstacleSupported': False, 'isGapDeepCleanSupported': False, 'isGotoPureCleanPathSupported': False, 'isHotWashTowelSupported': False, 'isIdentifyRoomSupported': False, 'isIgnoreUnknownMapObjectSupported': False, 'isLdsLiftingSupported': False, 'isLedStatusSwitchSupported': True, 'isLeftWaterDrainSupported': False, 'isLowAreaAccessSupported': False, 'isMainBrushUpDownSupportedFromStr': False, 'isMapBeautifyInternalDebugSupported': False, 'isMapCarpetAddSupport': False, 'isMapEraserSupported': False, 'isMatterSupported': False, 'isMaxPlusModeSupported': True, 'isMaxZoneOpenedSupported': False, 'isMechanicalArmModeSupported': False, 'isMidwayBackToDockSupported': False, 'isMinBattery15ToCleanTaskSupported': False, 'isMopForbiddenSupported': True, 'isMopPathSupported': False, 'isMopShakeModuleSupported': True, 'isMopShakeWaterMaxSupported': False, 'isMultiFloorSupported': True, 'isMultiMapSegmentTimerSupported': True, 'isNewAiRecognitionSupported': False, 'isNewDataForCleanHistory': False, 'isNewDataForCleanHistoryDetail': False, 'isNewEndpointSupported': False, 'isNewRemoteViewSupported': True, 'isNoNeedCarpetPressSetSupported': False, 'isNonePureCleanMopWithMaxPlus': False, 'isObjectDetectCheckSupported': False, 'isOfflineMapSupported': False, 'isOptimizeBatterySupported': False, 'isOrderCleanSupported': True, 'isOverSeaCtmSupported': False, 'isPetSnapshotSupported': False, 'isPetSuppliesDeepCleanSupported': False, 'isProgramModeSupported': False, 'isPumpingWaterSupported': False, 'isPureCleanMopSupported': True, 'isReSegmentSupported': True, 'isRecordAllowed': False, 'isRemoteSupported': True, 'isRightBrushStretchSupported': False, 'isRoomNameSupported': True, 'isRpcRetrySupported': False, 'isRubberBrushCarpetSupported': False, 'isSetChildSupported': False, 'isSettingCarpetFirstSupported': False, 'isShakeMopSetSupported': False, 'isShouldShowArmOverLoadSupported': False, 'isShowCleanFinishReasonSupported': True, 'isShowGeneralObstacleSupported': False, 'isShowObstaclePhotoSupported': False, 'isSideBrushLiftCarpetSupported': False, 'isSmallSideMopSupported': False, 'isSmartCleanModeSetSupported': False, 'isSoakAndWashSupported': False, 'isSoftCleanModeSupported': False, 'isSrMapSupported': False, 'isSuperDeepWashSupported': False, 'isSupportApiAppStopGraspSupported': False, 'isSupportBackupMap': True, 'isSupportCleanEstimate': False, 'isSupportCliffZone': False, 'isSupportCustomCarpet': False, 'isSupportCustomDnd': False, 'isSupportCustomDoorSill': False, 'isSupportCustomModeInCleaning': False, 'isSupportFetchTimerSummary': True, 'isSupportFloorDirection': False, 'isSupportFloorEdit': False, 'isSupportFurniture': False, 'isSupportGetParticularStatusSupported': False, 'isSupportIncrementalMap': False, 'isSupportMainBrushUpDownSupported': False, 'isSupportMopBackPwmSet': False, 'isSupportQuickMapBuilder': True, 'isSupportRemoteControlInCall': False, 'isSupportRoomTag': False, 'isSupportSetSwitchMapMode': False, 'isSupportSetVolumeInCall': False, 'isSupportSideBrushUpDownSupported': False, 'isSupportSmartDoorSill': False, 'isSupportSmartGlobalCleanWithCustomMode': False, 'isSupportSmartScene': False, 'isSupportStuckZone': False, 'isSupportVoiceControlDebug': False, 'isSupportWaterMode': True, 'isSupportedDownloadTestVoice': False, 'isSupportedDrying': False, 'isSupportedValleyElectricity': False, 'isSyncServerNameSupported': False, 'isThreeDMappingInnerTestSupported': False, 'isTidyupZonesSupported': False, 'isTwoGearsNoCollisionSupported': False, 'isTwoKeyRealTimeVideoSupported': False, 'isTwoKeyRtvInChargingSupported': False, 'isTypeIdentifySupported': False, 'isUnsaveMapReasonSupported': True, 'isUvcSterilizeSupported': False, 'isVideoMonitorSupported': False, 'isVideoPatrolSupported': False, 'isVideoSettingSupported': False, 'isVoiceControlLedSupported': False, 'isVoiceControlSupported': False, 'isWashThenChargeCmdSupported': False, 'isWaterLeakCheckSupported': False, 'isWaterSlideModeSupported': False, 'isWaterUpDownDrainSupported': False, 'isWifiManageSupported': False, 'isWorkdayHolidaySupported': False, 'newFeatureInfo': 633887780925447, 'newFeatureInfoStr': '0000000000002000', }), 'led_status': dict({ 'status': 0, }), 'network_info': dict({ 'ip': '**REDACTED**', }), 'status': dict({ 'adbumperStatus': list([ 0, 0, 0, ]), 'autoDustCollection': 1, 'avoidCount': 19, 'backType': -1, 'battery': 100, 'cameraStatus': 3457, 'chargeStatus': 1, 'cleanArea': 20965000, 'cleanTime': 1176, 'collisionAvoidStatus': 1, 'debugMode': 0, 'dndEnabled': 0, 'dockErrorStatus': 0, 'dockType': 3, 'dustCollectionStatus': 0, 'errorCode': 0, 'fanPower': 102, 'homeSecEnablePassword': 0, 'homeSecStatus': 0, 'inCleaning': 0, 'inFreshState': 1, 'inReturning': 0, 'isExploring': 0, 'isLocating': 0, 'labStatus': 1, 'lockStatus': 0, 'mapPresent': 1, 'mapStatus': 3, 'mopForbiddenEnable': 1, 'mopMode': 300, 'msgSeq': 458, 'msgVer': 2, 'state': 8, 'switchMapMode': 0, 'unsaveMapFlag': 0, 'unsaveMapReason': 0, 'washPhase': 0, 'washReady': 0, 'waterBoxCarriageStatus': 1, 'waterBoxMode': 203, 'waterBoxStatus': 1, 'waterShortageStatus': 0, }), }), }) # --- # name: test_device_trait_command_parsing[dnd] DoNotDisturbTrait(start_hour=22, start_minute=0, end_hour=8, end_minute=0, enabled=1) # --- # name: test_device_trait_command_parsing[dnd].1 dict({ 'device': dict({ 'activeTime': 1672364449, 'deviceStatus': dict({ '120': 0, '121': 8, '122': 100, '123': 102, '124': 203, '125': 94, '126': 90, '127': 87, '128': 0, '133': 1, }), 'duid': '******bc123', 'extra': '{"RRPhotoPrivacyVersion": "1"}', 'featureSet': '2234201184108543', 'fv': '02.56.02', 'iconUrl': 'no_url', 'localKey': '**REDACTED**', 'name': '**REDACTED**', 'newFeatureSet': '0000000000002041', 'online': True, 'productId': '**REDACTED**', 'pv': '1.0', 'roomId': 2362003, 'share': False, 'silentOtaSwitch': True, 'sn': '**REDACTED**', 'timeZoneId': 'America/Los_Angeles', 'tuyaMigrated': False, }), 'product': dict({ 'capability': 0, 'category': 'robot.vacuum.cleaner', 'code': 'a27', 'id': 'product-id-s7-maxv', 'model': 'roborock.vacuum.a27', 'name': '**REDACTED**', 'schema': list([ dict({ 'code': 'rpc_request_code', 'id': '101', 'mode': 'rw', 'name': 'rpc_request', 'type': 'RAW', }), dict({ 'code': 'rpc_response', 'id': '102', 'mode': 'rw', 'name': 'rpc_response', 'type': 'RAW', }), dict({ 'code': 'error_code', 'id': '120', 'mode': 'ro', 'name': '错误代码', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'state', 'id': '121', 'mode': 'ro', 'name': '设备状态', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'battery', 'id': '122', 'mode': 'ro', 'name': '设备电量', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'fan_power', 'id': '123', 'mode': 'rw', 'name': '清扫模式', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'water_box_mode', 'id': '124', 'mode': 'rw', 'name': '拖地模式', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'main_brush_life', 'id': '125', 'mode': 'rw', 'name': '主刷寿命', 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ 'code': 'side_brush_life', 'id': '126', 'mode': 'rw', 'name': '边刷寿命', 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ 'code': 'filter_life', 'id': '127', 'mode': 'rw', 'name': '滤网寿命', 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ 'code': 'additional_props', 'id': '128', 'mode': 'ro', 'name': '额外状态', 'type': 'RAW', }), dict({ 'code': 'task_complete', 'id': '130', 'mode': 'ro', 'name': '完成事件', 'type': 'RAW', }), dict({ 'code': 'task_cancel_low_power', 'id': '131', 'mode': 'ro', 'name': '电量不足任务取消', 'type': 'RAW', }), dict({ 'code': 'task_cancel_in_motion', 'id': '132', 'mode': 'ro', 'name': '运动中任务取消', 'type': 'RAW', }), dict({ 'code': 'charge_status', 'id': '133', 'mode': 'ro', 'name': '充电状态', 'type': 'RAW', }), dict({ 'code': 'drying_status', 'id': '134', 'mode': 'ro', 'name': '烘干状态', 'type': 'RAW', }), ]), }), 'traits': dict({ 'device_features': dict({ 'featureInfo': list([ 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 122, 123, 124, 125, ]), 'isActivateVideoChargingAndStandbySupported': False, 'isAnalysisSupported': True, 'isAnyStateTransitGotoSupported': False, 'isAutoCollection2Supported': False, 'isAutoDeliveryFieldInGlobalStatusSupported': False, 'isAutoTearDownMopSupported': False, 'isAvoidCollisionModeSupported': False, 'isAvoidCollisionSupported': False, 'isBackChargeAutoWashSupported': False, 'isBackWashNewSmartSupported': False, 'isCarefulSlowMopSupported': False, 'isCarpetCustomCleanSupported': False, 'isCarpetDeepCleanSupported': False, 'isCarpetLongHairedExSupported': False, 'isCarpetLongHairedSupported': False, 'isCarpetPressureUseOriginParasSupported': False, 'isCarpetShapeTypeSupported': False, 'isCarpetShowOnMap': False, 'isCarpetSupported': False, 'isCes2022Supported': False, 'isCleanCountSettingSupported': False, 'isCleanDirectStatusSupported': False, 'isCleanEfficiencySupported': False, 'isCleanFluidDeliverySupported': False, 'isCleanHistoryTimeLineSupported': False, 'isCleanRouteDeepSlowPlusSupported': False, 'isCleanRouteFastModeSupported': False, 'isCleanRouteSettingSupported': True, 'isCleanThenMopModeSupported': False, 'isCleanTimeLineSupported': False, 'isCollectDustCountShowSupported': False, 'isCollectDustModeSupported': True, 'isCornerCleanModeSupported': False, 'isCornerMopStretchSupported': False, 'isCtmWithRepeatSupported': False, 'isCurrentMapRestoreEnabled': True, 'isCustomCleanModeCountSupported': False, 'isCustomModeSupported': True, 'isCustomWaterBoxDistanceSupported': True, 'isCustomizedCleanSupported': True, 'isDetectWireCarpetSupported': False, 'isDirtyObjectDetectSupported': False, 'isDirtyReplenishCleanSupported': False, 'isDryIntervalTimerSupported': False, 'isDssBelievable': False, 'isDualBandWiFiSupported': False, 'isDustCollectionSettingSupported': False, 'isDynamicallyAddCleanZonesSupported': False, 'isDynamicallySkipCleanZoneSupported': False, 'isEggDanceModeSupported': False, 'isEggModeSupportedFromNewFeatures': False, 'isExactCustomModeSupported': False, 'isExhibitionFunctionSupported': False, 'isFloorDirCleanAnyTimeSupported': False, 'isFlowLedSettingSupported': False, 'isFollowLowObsSupported': False, 'isFullDuplesSwitchSupported': False, 'isFwFilterObstacleSupported': False, 'isGapDeepCleanSupported': False, 'isGotoPureCleanPathSupported': False, 'isHotWashTowelSupported': False, 'isIdentifyRoomSupported': False, 'isIgnoreUnknownMapObjectSupported': False, 'isLdsLiftingSupported': False, 'isLedStatusSwitchSupported': True, 'isLeftWaterDrainSupported': False, 'isLowAreaAccessSupported': False, 'isMainBrushUpDownSupportedFromStr': False, 'isMapBeautifyInternalDebugSupported': False, 'isMapCarpetAddSupport': False, 'isMapEraserSupported': False, 'isMatterSupported': False, 'isMaxPlusModeSupported': True, 'isMaxZoneOpenedSupported': False, 'isMechanicalArmModeSupported': False, 'isMidwayBackToDockSupported': False, 'isMinBattery15ToCleanTaskSupported': False, 'isMopForbiddenSupported': True, 'isMopPathSupported': False, 'isMopShakeModuleSupported': True, 'isMopShakeWaterMaxSupported': False, 'isMultiFloorSupported': True, 'isMultiMapSegmentTimerSupported': True, 'isNewAiRecognitionSupported': False, 'isNewDataForCleanHistory': False, 'isNewDataForCleanHistoryDetail': False, 'isNewEndpointSupported': False, 'isNewRemoteViewSupported': True, 'isNoNeedCarpetPressSetSupported': False, 'isNonePureCleanMopWithMaxPlus': False, 'isObjectDetectCheckSupported': False, 'isOfflineMapSupported': False, 'isOptimizeBatterySupported': False, 'isOrderCleanSupported': True, 'isOverSeaCtmSupported': False, 'isPetSnapshotSupported': False, 'isPetSuppliesDeepCleanSupported': False, 'isProgramModeSupported': False, 'isPumpingWaterSupported': False, 'isPureCleanMopSupported': True, 'isReSegmentSupported': True, 'isRecordAllowed': False, 'isRemoteSupported': True, 'isRightBrushStretchSupported': False, 'isRoomNameSupported': True, 'isRpcRetrySupported': False, 'isRubberBrushCarpetSupported': False, 'isSetChildSupported': False, 'isSettingCarpetFirstSupported': False, 'isShakeMopSetSupported': False, 'isShouldShowArmOverLoadSupported': False, 'isShowCleanFinishReasonSupported': True, 'isShowGeneralObstacleSupported': False, 'isShowObstaclePhotoSupported': False, 'isSideBrushLiftCarpetSupported': False, 'isSmallSideMopSupported': False, 'isSmartCleanModeSetSupported': False, 'isSoakAndWashSupported': False, 'isSoftCleanModeSupported': False, 'isSrMapSupported': False, 'isSuperDeepWashSupported': False, 'isSupportApiAppStopGraspSupported': False, 'isSupportBackupMap': True, 'isSupportCleanEstimate': False, 'isSupportCliffZone': False, 'isSupportCustomCarpet': False, 'isSupportCustomDnd': False, 'isSupportCustomDoorSill': False, 'isSupportCustomModeInCleaning': False, 'isSupportFetchTimerSummary': True, 'isSupportFloorDirection': False, 'isSupportFloorEdit': False, 'isSupportFurniture': False, 'isSupportGetParticularStatusSupported': False, 'isSupportIncrementalMap': False, 'isSupportMainBrushUpDownSupported': False, 'isSupportMopBackPwmSet': False, 'isSupportQuickMapBuilder': True, 'isSupportRemoteControlInCall': False, 'isSupportRoomTag': False, 'isSupportSetSwitchMapMode': False, 'isSupportSetVolumeInCall': False, 'isSupportSideBrushUpDownSupported': False, 'isSupportSmartDoorSill': False, 'isSupportSmartGlobalCleanWithCustomMode': False, 'isSupportSmartScene': False, 'isSupportStuckZone': False, 'isSupportVoiceControlDebug': False, 'isSupportWaterMode': True, 'isSupportedDownloadTestVoice': False, 'isSupportedDrying': False, 'isSupportedValleyElectricity': False, 'isSyncServerNameSupported': False, 'isThreeDMappingInnerTestSupported': False, 'isTidyupZonesSupported': False, 'isTwoGearsNoCollisionSupported': False, 'isTwoKeyRealTimeVideoSupported': False, 'isTwoKeyRtvInChargingSupported': False, 'isTypeIdentifySupported': False, 'isUnsaveMapReasonSupported': True, 'isUvcSterilizeSupported': False, 'isVideoMonitorSupported': False, 'isVideoPatrolSupported': False, 'isVideoSettingSupported': False, 'isVoiceControlLedSupported': False, 'isVoiceControlSupported': False, 'isWashThenChargeCmdSupported': False, 'isWaterLeakCheckSupported': False, 'isWaterSlideModeSupported': False, 'isWaterUpDownDrainSupported': False, 'isWifiManageSupported': False, 'isWorkdayHolidaySupported': False, 'newFeatureInfo': 633887780925447, 'newFeatureInfoStr': '0000000000002000', }), 'dnd': dict({ 'enabled': 1, 'endHour': 8, 'endMinute': 0, 'startHour': 22, 'startMinute': 0, }), 'led_status': dict({ 'status': 0, }), 'network_info': dict({ 'ip': '**REDACTED**', }), 'status': dict({ 'adbumperStatus': list([ 0, 0, 0, ]), 'autoDustCollection': 1, 'avoidCount': 19, 'backType': -1, 'battery': 100, 'cameraStatus': 3457, 'chargeStatus': 1, 'cleanArea': 20965000, 'cleanTime': 1176, 'collisionAvoidStatus': 1, 'debugMode': 0, 'dndEnabled': 0, 'dockErrorStatus': 0, 'dockType': 3, 'dustCollectionStatus': 0, 'errorCode': 0, 'fanPower': 102, 'homeSecEnablePassword': 0, 'homeSecStatus': 0, 'inCleaning': 0, 'inFreshState': 1, 'inReturning': 0, 'isExploring': 0, 'isLocating': 0, 'labStatus': 1, 'lockStatus': 0, 'mapPresent': 1, 'mapStatus': 3, 'mopForbiddenEnable': 1, 'mopMode': 300, 'msgSeq': 458, 'msgVer': 2, 'state': 8, 'switchMapMode': 0, 'unsaveMapFlag': 0, 'unsaveMapReason': 0, 'washPhase': 0, 'washReady': 0, 'waterBoxCarriageStatus': 1, 'waterBoxMode': 203, 'waterBoxStatus': 1, 'waterShortageStatus': 0, }), }), }) # --- # name: test_device_trait_command_parsing[status] StatusTrait(adbumper_status=None, auto_dust_collection=None, avoid_count=None, back_type=None, battery=100, camera_status=None, charge_status=None, clean_area=91287500, clean_fluid_status=None, clean_percent=None, clean_time=5405, clear_water_box_status=None, collision_avoid_status=None, command=, common_status=None, corner_clean_mode=None, current_map=0, debug_mode=None, dirty_water_box_status=None, distance_off=0, dnd_enabled=1, dock_cool_fan_status=None, dock_error_status=None, dock_type=None, dry_status=None, dss=None, dust_bag_status=None, dust_collection_status=None, error_code=, error_code_name='none', fan_power=, fan_power_name='custom', fan_power_options=['off', 'quiet', 'balanced', 'turbo', 'max', 'custom', 'max_plus'], hatch_door_status=None, home_sec_enable_password=None, home_sec_status=None, in_cleaning=, in_fresh_state=1, in_returning=0, in_warmup=None, is_exploring=None, is_locating=0, kct=None, lab_status=1, last_clean_t=None, lock_status=0, map_present=1, map_status=3, mop_forbidden_enable=0, mop_mode=None, mop_mode_name=None, msg_seq=515, msg_ver=2, rdt=None, repeat=None, replenish_mode=None, rss=None, square_meter_clean_area=91.3, state=, state_name='charging', subdivision_sets=None, switch_map_mode=None, unsave_map_flag=0, unsave_map_reason=4, wash_phase=None, wash_ready=None, wash_status=None, water_box_carriage_status=0, water_box_filter_status=None, water_box_mode=, water_box_mode_name='custom', water_box_status=0, water_shortage_status=None) # --- # name: test_device_trait_command_parsing[status].1 dict({ 'device': dict({ 'activeTime': 1672364449, 'deviceStatus': dict({ '120': 0, '121': 8, '122': 100, '123': 102, '124': 203, '125': 94, '126': 90, '127': 87, '128': 0, '133': 1, }), 'duid': '******bc123', 'extra': '{"RRPhotoPrivacyVersion": "1"}', 'featureSet': '2234201184108543', 'fv': '02.56.02', 'iconUrl': 'no_url', 'localKey': '**REDACTED**', 'name': '**REDACTED**', 'newFeatureSet': '0000000000002041', 'online': True, 'productId': '**REDACTED**', 'pv': '1.0', 'roomId': 2362003, 'share': False, 'silentOtaSwitch': True, 'sn': '**REDACTED**', 'timeZoneId': 'America/Los_Angeles', 'tuyaMigrated': False, }), 'product': dict({ 'capability': 0, 'category': 'robot.vacuum.cleaner', 'code': 'a27', 'id': 'product-id-s7-maxv', 'model': 'roborock.vacuum.a27', 'name': '**REDACTED**', 'schema': list([ dict({ 'code': 'rpc_request_code', 'id': '101', 'mode': 'rw', 'name': 'rpc_request', 'type': 'RAW', }), dict({ 'code': 'rpc_response', 'id': '102', 'mode': 'rw', 'name': 'rpc_response', 'type': 'RAW', }), dict({ 'code': 'error_code', 'id': '120', 'mode': 'ro', 'name': '错误代码', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'state', 'id': '121', 'mode': 'ro', 'name': '设备状态', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'battery', 'id': '122', 'mode': 'ro', 'name': '设备电量', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'fan_power', 'id': '123', 'mode': 'rw', 'name': '清扫模式', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'water_box_mode', 'id': '124', 'mode': 'rw', 'name': '拖地模式', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'main_brush_life', 'id': '125', 'mode': 'rw', 'name': '主刷寿命', 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ 'code': 'side_brush_life', 'id': '126', 'mode': 'rw', 'name': '边刷寿命', 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ 'code': 'filter_life', 'id': '127', 'mode': 'rw', 'name': '滤网寿命', 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ 'code': 'additional_props', 'id': '128', 'mode': 'ro', 'name': '额外状态', 'type': 'RAW', }), dict({ 'code': 'task_complete', 'id': '130', 'mode': 'ro', 'name': '完成事件', 'type': 'RAW', }), dict({ 'code': 'task_cancel_low_power', 'id': '131', 'mode': 'ro', 'name': '电量不足任务取消', 'type': 'RAW', }), dict({ 'code': 'task_cancel_in_motion', 'id': '132', 'mode': 'ro', 'name': '运动中任务取消', 'type': 'RAW', }), dict({ 'code': 'charge_status', 'id': '133', 'mode': 'ro', 'name': '充电状态', 'type': 'RAW', }), dict({ 'code': 'drying_status', 'id': '134', 'mode': 'ro', 'name': '烘干状态', 'type': 'RAW', }), ]), }), 'traits': dict({ 'device_features': dict({ 'featureInfo': list([ 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 122, 123, 124, 125, ]), 'isActivateVideoChargingAndStandbySupported': False, 'isAnalysisSupported': True, 'isAnyStateTransitGotoSupported': False, 'isAutoCollection2Supported': False, 'isAutoDeliveryFieldInGlobalStatusSupported': False, 'isAutoTearDownMopSupported': False, 'isAvoidCollisionModeSupported': False, 'isAvoidCollisionSupported': False, 'isBackChargeAutoWashSupported': False, 'isBackWashNewSmartSupported': False, 'isCarefulSlowMopSupported': False, 'isCarpetCustomCleanSupported': False, 'isCarpetDeepCleanSupported': False, 'isCarpetLongHairedExSupported': False, 'isCarpetLongHairedSupported': False, 'isCarpetPressureUseOriginParasSupported': False, 'isCarpetShapeTypeSupported': False, 'isCarpetShowOnMap': False, 'isCarpetSupported': False, 'isCes2022Supported': False, 'isCleanCountSettingSupported': False, 'isCleanDirectStatusSupported': False, 'isCleanEfficiencySupported': False, 'isCleanFluidDeliverySupported': False, 'isCleanHistoryTimeLineSupported': False, 'isCleanRouteDeepSlowPlusSupported': False, 'isCleanRouteFastModeSupported': False, 'isCleanRouteSettingSupported': True, 'isCleanThenMopModeSupported': False, 'isCleanTimeLineSupported': False, 'isCollectDustCountShowSupported': False, 'isCollectDustModeSupported': True, 'isCornerCleanModeSupported': False, 'isCornerMopStretchSupported': False, 'isCtmWithRepeatSupported': False, 'isCurrentMapRestoreEnabled': True, 'isCustomCleanModeCountSupported': False, 'isCustomModeSupported': True, 'isCustomWaterBoxDistanceSupported': True, 'isCustomizedCleanSupported': True, 'isDetectWireCarpetSupported': False, 'isDirtyObjectDetectSupported': False, 'isDirtyReplenishCleanSupported': False, 'isDryIntervalTimerSupported': False, 'isDssBelievable': False, 'isDualBandWiFiSupported': False, 'isDustCollectionSettingSupported': False, 'isDynamicallyAddCleanZonesSupported': False, 'isDynamicallySkipCleanZoneSupported': False, 'isEggDanceModeSupported': False, 'isEggModeSupportedFromNewFeatures': False, 'isExactCustomModeSupported': False, 'isExhibitionFunctionSupported': False, 'isFloorDirCleanAnyTimeSupported': False, 'isFlowLedSettingSupported': False, 'isFollowLowObsSupported': False, 'isFullDuplesSwitchSupported': False, 'isFwFilterObstacleSupported': False, 'isGapDeepCleanSupported': False, 'isGotoPureCleanPathSupported': False, 'isHotWashTowelSupported': False, 'isIdentifyRoomSupported': False, 'isIgnoreUnknownMapObjectSupported': False, 'isLdsLiftingSupported': False, 'isLedStatusSwitchSupported': True, 'isLeftWaterDrainSupported': False, 'isLowAreaAccessSupported': False, 'isMainBrushUpDownSupportedFromStr': False, 'isMapBeautifyInternalDebugSupported': False, 'isMapCarpetAddSupport': False, 'isMapEraserSupported': False, 'isMatterSupported': False, 'isMaxPlusModeSupported': True, 'isMaxZoneOpenedSupported': False, 'isMechanicalArmModeSupported': False, 'isMidwayBackToDockSupported': False, 'isMinBattery15ToCleanTaskSupported': False, 'isMopForbiddenSupported': True, 'isMopPathSupported': False, 'isMopShakeModuleSupported': True, 'isMopShakeWaterMaxSupported': False, 'isMultiFloorSupported': True, 'isMultiMapSegmentTimerSupported': True, 'isNewAiRecognitionSupported': False, 'isNewDataForCleanHistory': False, 'isNewDataForCleanHistoryDetail': False, 'isNewEndpointSupported': False, 'isNewRemoteViewSupported': True, 'isNoNeedCarpetPressSetSupported': False, 'isNonePureCleanMopWithMaxPlus': False, 'isObjectDetectCheckSupported': False, 'isOfflineMapSupported': False, 'isOptimizeBatterySupported': False, 'isOrderCleanSupported': True, 'isOverSeaCtmSupported': False, 'isPetSnapshotSupported': False, 'isPetSuppliesDeepCleanSupported': False, 'isProgramModeSupported': False, 'isPumpingWaterSupported': False, 'isPureCleanMopSupported': True, 'isReSegmentSupported': True, 'isRecordAllowed': False, 'isRemoteSupported': True, 'isRightBrushStretchSupported': False, 'isRoomNameSupported': True, 'isRpcRetrySupported': False, 'isRubberBrushCarpetSupported': False, 'isSetChildSupported': False, 'isSettingCarpetFirstSupported': False, 'isShakeMopSetSupported': False, 'isShouldShowArmOverLoadSupported': False, 'isShowCleanFinishReasonSupported': True, 'isShowGeneralObstacleSupported': False, 'isShowObstaclePhotoSupported': False, 'isSideBrushLiftCarpetSupported': False, 'isSmallSideMopSupported': False, 'isSmartCleanModeSetSupported': False, 'isSoakAndWashSupported': False, 'isSoftCleanModeSupported': False, 'isSrMapSupported': False, 'isSuperDeepWashSupported': False, 'isSupportApiAppStopGraspSupported': False, 'isSupportBackupMap': True, 'isSupportCleanEstimate': False, 'isSupportCliffZone': False, 'isSupportCustomCarpet': False, 'isSupportCustomDnd': False, 'isSupportCustomDoorSill': False, 'isSupportCustomModeInCleaning': False, 'isSupportFetchTimerSummary': True, 'isSupportFloorDirection': False, 'isSupportFloorEdit': False, 'isSupportFurniture': False, 'isSupportGetParticularStatusSupported': False, 'isSupportIncrementalMap': False, 'isSupportMainBrushUpDownSupported': False, 'isSupportMopBackPwmSet': False, 'isSupportQuickMapBuilder': True, 'isSupportRemoteControlInCall': False, 'isSupportRoomTag': False, 'isSupportSetSwitchMapMode': False, 'isSupportSetVolumeInCall': False, 'isSupportSideBrushUpDownSupported': False, 'isSupportSmartDoorSill': False, 'isSupportSmartGlobalCleanWithCustomMode': False, 'isSupportSmartScene': False, 'isSupportStuckZone': False, 'isSupportVoiceControlDebug': False, 'isSupportWaterMode': True, 'isSupportedDownloadTestVoice': False, 'isSupportedDrying': False, 'isSupportedValleyElectricity': False, 'isSyncServerNameSupported': False, 'isThreeDMappingInnerTestSupported': False, 'isTidyupZonesSupported': False, 'isTwoGearsNoCollisionSupported': False, 'isTwoKeyRealTimeVideoSupported': False, 'isTwoKeyRtvInChargingSupported': False, 'isTypeIdentifySupported': False, 'isUnsaveMapReasonSupported': True, 'isUvcSterilizeSupported': False, 'isVideoMonitorSupported': False, 'isVideoPatrolSupported': False, 'isVideoSettingSupported': False, 'isVoiceControlLedSupported': False, 'isVoiceControlSupported': False, 'isWashThenChargeCmdSupported': False, 'isWaterLeakCheckSupported': False, 'isWaterSlideModeSupported': False, 'isWaterUpDownDrainSupported': False, 'isWifiManageSupported': False, 'isWorkdayHolidaySupported': False, 'newFeatureInfo': 633887780925447, 'newFeatureInfoStr': '0000000000002000', }), 'led_status': dict({ 'status': 0, }), 'network_info': dict({ 'ip': '**REDACTED**', }), 'status': dict({ 'battery': 100, 'cleanArea': 91287500, 'cleanTime': 5405, 'distanceOff': 0, 'dndEnabled': 1, 'errorCode': 0, 'fanPower': 106, 'inCleaning': 0, 'inFreshState': 1, 'inReturning': 0, 'isLocating': 0, 'labStatus': 1, 'lockStatus': 0, 'mapPresent': 1, 'mapStatus': 3, 'mopForbiddenEnable': 0, 'msgSeq': 515, 'msgVer': 2, 'state': 8, 'unsaveMapFlag': 0, 'unsaveMapReason': 4, 'waterBoxCarriageStatus': 0, 'waterBoxMode': 204, 'waterBoxStatus': 0, }), }), }) # --- # name: test_device_trait_command_parsing[volume] SoundVolumeTrait(volume=90) # --- # name: test_device_trait_command_parsing[volume].1 dict({ 'device': dict({ 'activeTime': 1672364449, 'deviceStatus': dict({ '120': 0, '121': 8, '122': 100, '123': 102, '124': 203, '125': 94, '126': 90, '127': 87, '128': 0, '133': 1, }), 'duid': '******bc123', 'extra': '{"RRPhotoPrivacyVersion": "1"}', 'featureSet': '2234201184108543', 'fv': '02.56.02', 'iconUrl': 'no_url', 'localKey': '**REDACTED**', 'name': '**REDACTED**', 'newFeatureSet': '0000000000002041', 'online': True, 'productId': '**REDACTED**', 'pv': '1.0', 'roomId': 2362003, 'share': False, 'silentOtaSwitch': True, 'sn': '**REDACTED**', 'timeZoneId': 'America/Los_Angeles', 'tuyaMigrated': False, }), 'product': dict({ 'capability': 0, 'category': 'robot.vacuum.cleaner', 'code': 'a27', 'id': 'product-id-s7-maxv', 'model': 'roborock.vacuum.a27', 'name': '**REDACTED**', 'schema': list([ dict({ 'code': 'rpc_request_code', 'id': '101', 'mode': 'rw', 'name': 'rpc_request', 'type': 'RAW', }), dict({ 'code': 'rpc_response', 'id': '102', 'mode': 'rw', 'name': 'rpc_response', 'type': 'RAW', }), dict({ 'code': 'error_code', 'id': '120', 'mode': 'ro', 'name': '错误代码', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'state', 'id': '121', 'mode': 'ro', 'name': '设备状态', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'battery', 'id': '122', 'mode': 'ro', 'name': '设备电量', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'fan_power', 'id': '123', 'mode': 'rw', 'name': '清扫模式', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'water_box_mode', 'id': '124', 'mode': 'rw', 'name': '拖地模式', 'property': '{"range": []}', 'type': 'ENUM', }), dict({ 'code': 'main_brush_life', 'id': '125', 'mode': 'rw', 'name': '主刷寿命', 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ 'code': 'side_brush_life', 'id': '126', 'mode': 'rw', 'name': '边刷寿命', 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ 'code': 'filter_life', 'id': '127', 'mode': 'rw', 'name': '滤网寿命', 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ 'code': 'additional_props', 'id': '128', 'mode': 'ro', 'name': '额外状态', 'type': 'RAW', }), dict({ 'code': 'task_complete', 'id': '130', 'mode': 'ro', 'name': '完成事件', 'type': 'RAW', }), dict({ 'code': 'task_cancel_low_power', 'id': '131', 'mode': 'ro', 'name': '电量不足任务取消', 'type': 'RAW', }), dict({ 'code': 'task_cancel_in_motion', 'id': '132', 'mode': 'ro', 'name': '运动中任务取消', 'type': 'RAW', }), dict({ 'code': 'charge_status', 'id': '133', 'mode': 'ro', 'name': '充电状态', 'type': 'RAW', }), dict({ 'code': 'drying_status', 'id': '134', 'mode': 'ro', 'name': '烘干状态', 'type': 'RAW', }), ]), }), 'traits': dict({ 'device_features': dict({ 'featureInfo': list([ 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 122, 123, 124, 125, ]), 'isActivateVideoChargingAndStandbySupported': False, 'isAnalysisSupported': True, 'isAnyStateTransitGotoSupported': False, 'isAutoCollection2Supported': False, 'isAutoDeliveryFieldInGlobalStatusSupported': False, 'isAutoTearDownMopSupported': False, 'isAvoidCollisionModeSupported': False, 'isAvoidCollisionSupported': False, 'isBackChargeAutoWashSupported': False, 'isBackWashNewSmartSupported': False, 'isCarefulSlowMopSupported': False, 'isCarpetCustomCleanSupported': False, 'isCarpetDeepCleanSupported': False, 'isCarpetLongHairedExSupported': False, 'isCarpetLongHairedSupported': False, 'isCarpetPressureUseOriginParasSupported': False, 'isCarpetShapeTypeSupported': False, 'isCarpetShowOnMap': False, 'isCarpetSupported': False, 'isCes2022Supported': False, 'isCleanCountSettingSupported': False, 'isCleanDirectStatusSupported': False, 'isCleanEfficiencySupported': False, 'isCleanFluidDeliverySupported': False, 'isCleanHistoryTimeLineSupported': False, 'isCleanRouteDeepSlowPlusSupported': False, 'isCleanRouteFastModeSupported': False, 'isCleanRouteSettingSupported': True, 'isCleanThenMopModeSupported': False, 'isCleanTimeLineSupported': False, 'isCollectDustCountShowSupported': False, 'isCollectDustModeSupported': True, 'isCornerCleanModeSupported': False, 'isCornerMopStretchSupported': False, 'isCtmWithRepeatSupported': False, 'isCurrentMapRestoreEnabled': True, 'isCustomCleanModeCountSupported': False, 'isCustomModeSupported': True, 'isCustomWaterBoxDistanceSupported': True, 'isCustomizedCleanSupported': True, 'isDetectWireCarpetSupported': False, 'isDirtyObjectDetectSupported': False, 'isDirtyReplenishCleanSupported': False, 'isDryIntervalTimerSupported': False, 'isDssBelievable': False, 'isDualBandWiFiSupported': False, 'isDustCollectionSettingSupported': False, 'isDynamicallyAddCleanZonesSupported': False, 'isDynamicallySkipCleanZoneSupported': False, 'isEggDanceModeSupported': False, 'isEggModeSupportedFromNewFeatures': False, 'isExactCustomModeSupported': False, 'isExhibitionFunctionSupported': False, 'isFloorDirCleanAnyTimeSupported': False, 'isFlowLedSettingSupported': False, 'isFollowLowObsSupported': False, 'isFullDuplesSwitchSupported': False, 'isFwFilterObstacleSupported': False, 'isGapDeepCleanSupported': False, 'isGotoPureCleanPathSupported': False, 'isHotWashTowelSupported': False, 'isIdentifyRoomSupported': False, 'isIgnoreUnknownMapObjectSupported': False, 'isLdsLiftingSupported': False, 'isLedStatusSwitchSupported': True, 'isLeftWaterDrainSupported': False, 'isLowAreaAccessSupported': False, 'isMainBrushUpDownSupportedFromStr': False, 'isMapBeautifyInternalDebugSupported': False, 'isMapCarpetAddSupport': False, 'isMapEraserSupported': False, 'isMatterSupported': False, 'isMaxPlusModeSupported': True, 'isMaxZoneOpenedSupported': False, 'isMechanicalArmModeSupported': False, 'isMidwayBackToDockSupported': False, 'isMinBattery15ToCleanTaskSupported': False, 'isMopForbiddenSupported': True, 'isMopPathSupported': False, 'isMopShakeModuleSupported': True, 'isMopShakeWaterMaxSupported': False, 'isMultiFloorSupported': True, 'isMultiMapSegmentTimerSupported': True, 'isNewAiRecognitionSupported': False, 'isNewDataForCleanHistory': False, 'isNewDataForCleanHistoryDetail': False, 'isNewEndpointSupported': False, 'isNewRemoteViewSupported': True, 'isNoNeedCarpetPressSetSupported': False, 'isNonePureCleanMopWithMaxPlus': False, 'isObjectDetectCheckSupported': False, 'isOfflineMapSupported': False, 'isOptimizeBatterySupported': False, 'isOrderCleanSupported': True, 'isOverSeaCtmSupported': False, 'isPetSnapshotSupported': False, 'isPetSuppliesDeepCleanSupported': False, 'isProgramModeSupported': False, 'isPumpingWaterSupported': False, 'isPureCleanMopSupported': True, 'isReSegmentSupported': True, 'isRecordAllowed': False, 'isRemoteSupported': True, 'isRightBrushStretchSupported': False, 'isRoomNameSupported': True, 'isRpcRetrySupported': False, 'isRubberBrushCarpetSupported': False, 'isSetChildSupported': False, 'isSettingCarpetFirstSupported': False, 'isShakeMopSetSupported': False, 'isShouldShowArmOverLoadSupported': False, 'isShowCleanFinishReasonSupported': True, 'isShowGeneralObstacleSupported': False, 'isShowObstaclePhotoSupported': False, 'isSideBrushLiftCarpetSupported': False, 'isSmallSideMopSupported': False, 'isSmartCleanModeSetSupported': False, 'isSoakAndWashSupported': False, 'isSoftCleanModeSupported': False, 'isSrMapSupported': False, 'isSuperDeepWashSupported': False, 'isSupportApiAppStopGraspSupported': False, 'isSupportBackupMap': True, 'isSupportCleanEstimate': False, 'isSupportCliffZone': False, 'isSupportCustomCarpet': False, 'isSupportCustomDnd': False, 'isSupportCustomDoorSill': False, 'isSupportCustomModeInCleaning': False, 'isSupportFetchTimerSummary': True, 'isSupportFloorDirection': False, 'isSupportFloorEdit': False, 'isSupportFurniture': False, 'isSupportGetParticularStatusSupported': False, 'isSupportIncrementalMap': False, 'isSupportMainBrushUpDownSupported': False, 'isSupportMopBackPwmSet': False, 'isSupportQuickMapBuilder': True, 'isSupportRemoteControlInCall': False, 'isSupportRoomTag': False, 'isSupportSetSwitchMapMode': False, 'isSupportSetVolumeInCall': False, 'isSupportSideBrushUpDownSupported': False, 'isSupportSmartDoorSill': False, 'isSupportSmartGlobalCleanWithCustomMode': False, 'isSupportSmartScene': False, 'isSupportStuckZone': False, 'isSupportVoiceControlDebug': False, 'isSupportWaterMode': True, 'isSupportedDownloadTestVoice': False, 'isSupportedDrying': False, 'isSupportedValleyElectricity': False, 'isSyncServerNameSupported': False, 'isThreeDMappingInnerTestSupported': False, 'isTidyupZonesSupported': False, 'isTwoGearsNoCollisionSupported': False, 'isTwoKeyRealTimeVideoSupported': False, 'isTwoKeyRtvInChargingSupported': False, 'isTypeIdentifySupported': False, 'isUnsaveMapReasonSupported': True, 'isUvcSterilizeSupported': False, 'isVideoMonitorSupported': False, 'isVideoPatrolSupported': False, 'isVideoSettingSupported': False, 'isVoiceControlLedSupported': False, 'isVoiceControlSupported': False, 'isWashThenChargeCmdSupported': False, 'isWaterLeakCheckSupported': False, 'isWaterSlideModeSupported': False, 'isWaterUpDownDrainSupported': False, 'isWifiManageSupported': False, 'isWorkdayHolidaySupported': False, 'newFeatureInfo': 633887780925447, 'newFeatureInfoStr': '0000000000002000', }), 'led_status': dict({ 'status': 0, }), 'network_info': dict({ 'ip': '**REDACTED**', }), 'sound_volume': dict({ 'volume': 90, }), 'status': dict({ 'adbumperStatus': list([ 0, 0, 0, ]), 'autoDustCollection': 1, 'avoidCount': 19, 'backType': -1, 'battery': 100, 'cameraStatus': 3457, 'chargeStatus': 1, 'cleanArea': 20965000, 'cleanTime': 1176, 'collisionAvoidStatus': 1, 'debugMode': 0, 'dndEnabled': 0, 'dockErrorStatus': 0, 'dockType': 3, 'dustCollectionStatus': 0, 'errorCode': 0, 'fanPower': 102, 'homeSecEnablePassword': 0, 'homeSecStatus': 0, 'inCleaning': 0, 'inFreshState': 1, 'inReturning': 0, 'isExploring': 0, 'isLocating': 0, 'labStatus': 1, 'lockStatus': 0, 'mapPresent': 1, 'mapStatus': 3, 'mopForbiddenEnable': 1, 'mopMode': 300, 'msgSeq': 458, 'msgVer': 2, 'state': 8, 'switchMapMode': 0, 'unsaveMapFlag': 0, 'unsaveMapReason': 0, 'washPhase': 0, 'washReady': 0, 'waterBoxCarriageStatus': 1, 'waterBoxMode': 203, 'waterBoxStatus': 1, 'waterShortageStatus': 0, }), }), }) # --- Python-roborock-python-roborock-d6da2db/tests/devices/rpc/000077500000000000000000000000001513363643200240745ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/devices/rpc/test_a01_channel.py000066400000000000000000000032001513363643200275510ustar00rootroot00000000000000"""Tests for the a01_channel.""" from typing import Any import pytest from roborock.devices.rpc.a01_channel import send_decoded_command from roborock.protocols.a01_protocol import encode_mqtt_payload from roborock.roborock_message import ( RoborockDyadDataProtocol, RoborockMessage, RoborockMessageProtocol, ) from tests.fixtures.channel_fixtures import FakeChannel @pytest.fixture def mock_mqtt_channel() -> FakeChannel: """Fixture for a fake MQTT channel.""" return FakeChannel() async def test_id_query(mock_mqtt_channel: FakeChannel): """Test successful command sending and response decoding.""" # Command parameters to send params: dict[RoborockDyadDataProtocol, Any] = { RoborockDyadDataProtocol.ID_QUERY: [ RoborockDyadDataProtocol.WARM_LEVEL, RoborockDyadDataProtocol.POWER, ] } encoded = encode_mqtt_payload( { RoborockDyadDataProtocol.WARM_LEVEL: 101, RoborockDyadDataProtocol.POWER: 75, }, value_encoder=lambda x: x, ) response_message = RoborockMessage( protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=encoded.payload, version=encoded.version ) mock_mqtt_channel.response_queue.append(response_message) # Call the function to be tested result = await send_decoded_command(mock_mqtt_channel, params) # type: ignore[call-overload] # Assertions assert result == { RoborockDyadDataProtocol.WARM_LEVEL: 101, RoborockDyadDataProtocol.POWER: 75, } mock_mqtt_channel.publish.assert_awaited_once() mock_mqtt_channel.subscribe.assert_awaited_once() Python-roborock-python-roborock-d6da2db/tests/devices/rpc/test_v1_channel.py000066400000000000000000000476041513363643200275360ustar00rootroot00000000000000"""Tests for the V1Channel class. This test simulates communication across both the MQTT and local connections and failure modes, ensuring the V1Channel behaves correctly in various scenarios. """ import json import logging from collections.abc import Iterator from unittest.mock import AsyncMock, Mock, patch import pytest from roborock.data import NetworkInfo, RoborockStateCode, S5MaxStatus, UserData from roborock.devices.cache import DeviceCache, DeviceCacheData, InMemoryCache from roborock.devices.rpc.v1_channel import V1Channel from roborock.devices.transport.local_channel import LocalSession from roborock.exceptions import RoborockException from roborock.protocol import ( create_local_decoder, create_local_encoder, create_mqtt_decoder, create_mqtt_encoder, ) from roborock.protocols.v1_protocol import MapResponse, SecurityData, V1RpcChannel from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol from roborock.roborock_typing import RoborockCommand from tests import mock_data from tests.fixtures.channel_fixtures import FakeChannel USER_DATA = UserData.from_dict(mock_data.USER_DATA) TEST_DEVICE_UID = "abc123" TEST_LOCAL_KEY = "local_key" TEST_SECURITY_DATA = SecurityData(endpoint="test_endpoint", nonce=b"test_nonce_16byte") TEST_HOST = mock_data.TEST_LOCAL_API_HOST # Test messages for V1 protocol TEST_REQUEST = RoborockMessage( protocol=RoborockMessageProtocol.RPC_REQUEST, payload=json.dumps({"dps": {"101": json.dumps({"id": 12346, "method": "get_status"})}}).encode(), ) TEST_RESPONSE = RoborockMessage( protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=json.dumps( {"dps": {"102": json.dumps({"id": 12346, "result": {"state": RoborockStateCode.cleaning}})}} ).encode(), ) TEST_RESPONSE_2 = RoborockMessage( protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=json.dumps( {"dps": {"102": json.dumps({"id": 12347, "result": {"state": RoborockStateCode.cleaning}})}} ).encode(), ) TEST_NETWORK_INFO_RESPONSE = RoborockMessage( protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=json.dumps({"dps": {"102": json.dumps({"id": 12345, "result": mock_data.NETWORK_INFO})}}).encode(), ) TEST_NETWORKING_INFO = NetworkInfo.from_dict(mock_data.NETWORK_INFO) # Encoders/Decoders MQTT_ENCODER = create_mqtt_encoder(TEST_LOCAL_KEY) MQTT_DECODER = create_mqtt_decoder(TEST_LOCAL_KEY) LOCAL_ENCODER = create_local_encoder(TEST_LOCAL_KEY) LOCAL_DECODER = create_local_decoder(TEST_LOCAL_KEY) @pytest.fixture(name="mock_mqtt_channel") async def setup_mock_mqtt_channel() -> FakeChannel: """Mock MQTT channel for testing.""" channel = FakeChannel() await channel.connect() return channel @pytest.fixture(name="mock_local_channel") async def setup_mock_local_channel() -> FakeChannel: """Mock Local channel for testing.""" return FakeChannel() @pytest.fixture(name="mock_local_session") def setup_mock_local_session(mock_local_channel: Mock) -> Mock: """Mock Local session factory for testing.""" mock_session = Mock(spec=LocalSession) mock_session.return_value = mock_local_channel return mock_session @pytest.fixture(name="mock_request_id", autouse=True) def setup_mock_request_id() -> Iterator[None]: """Assign sequential request ids for testing.""" next_id = 12345 def fake_next_int(*args) -> int: nonlocal next_id id_to_return = next_id next_id += 1 return id_to_return with patch("roborock.protocols.v1_protocol.get_next_int", side_effect=fake_next_int): yield @pytest.fixture(name="mock_create_map_response_decoder") def setup_mock_map_decoder() -> Iterator[Mock]: """Mock the map response decoder to control its behavior in tests.""" with patch("roborock.devices.rpc.v1_channel.create_map_response_decoder") as mock_create_decoder: yield mock_create_decoder @pytest.fixture(name="cache") def cache_fixtures() -> InMemoryCache: """Mock cache for testing.""" return InMemoryCache() @pytest.fixture(name="device_cache") def device_cache_fixtures() -> DeviceCache: """Mock device cache for testing.""" return DeviceCache(TEST_DEVICE_UID, InMemoryCache()) @pytest.fixture(name="v1_channel") def setup_v1_channel( mock_mqtt_channel: Mock, mock_local_session: Mock, mock_create_map_response_decoder: Mock, device_cache: DeviceCache, ) -> V1Channel: """Fixture to set up the V1 channel for tests.""" return V1Channel( device_uid=TEST_DEVICE_UID, security_data=TEST_SECURITY_DATA, mqtt_channel=mock_mqtt_channel, local_session=mock_local_session, device_cache=device_cache, ) @pytest.fixture(name="rpc_channel") def setup_rpc_channel(v1_channel: V1Channel) -> V1RpcChannel: """Fixture to set up the RPC channel for tests. We expect tests to use this to send commands via the V1Channel since we want to exercise the behavior that the V1RpcChannel is long lived and respects the current state of the underlying channels. """ return v1_channel.rpc_channel @pytest.fixture(name="mqtt_rpc_channel") def setup_mqtt_rpc_channel(v1_channel: V1Channel) -> V1RpcChannel: """Fixture to set up the MQTT RPC channel for tests.""" return v1_channel.mqtt_rpc_channel @pytest.fixture(name="map_rpc_channel") def setup_map_rpc_channel(v1_channel: V1Channel) -> V1RpcChannel: """Fixture to set up the Map RPC channel for tests.""" return v1_channel.map_rpc_channel @pytest.fixture(name="warning_caplog") def setup_warning_caplog(caplog: pytest.LogCaptureFixture) -> pytest.LogCaptureFixture: """Fixture to capture warning messages.""" caplog.set_level(logging.WARNING) return caplog async def test_v1_channel_subscribe_mqtt_only_success( v1_channel: V1Channel, mock_mqtt_channel: FakeChannel, mock_local_session: Mock, mock_local_channel: FakeChannel, ) -> None: """Test successful subscription with MQTT only (local connection fails).""" # Setup: MQTT succeeds, local fails mock_mqtt_channel.response_queue.append(TEST_NETWORK_INFO_RESPONSE) mock_local_channel.connect.side_effect = RoborockException("Connection failed") callback = Mock() unsub = await v1_channel.subscribe(callback) # Verify MQTT connection was established assert mock_mqtt_channel.subscribers # Verify local connection was attempted but failed mock_local_session.assert_called_once_with(TEST_HOST) mock_local_channel.connect.assert_called_once() # Verify properties assert v1_channel.is_mqtt_connected assert not v1_channel.is_local_connected # Test unsubscribe unsub() assert not mock_mqtt_channel.subscribers async def test_v1_channel_mqtt_disconnected( v1_channel: V1Channel, mock_mqtt_channel: FakeChannel, mock_local_session: Mock, mock_local_channel: FakeChannel, ) -> None: """Test successful subscription with MQTT only (local connection fails).""" # Setup: MQTT succeeds, local fails mock_mqtt_channel.response_queue.append(TEST_NETWORK_INFO_RESPONSE) mock_local_channel.connect.side_effect = RoborockException("Connection failed") callback = Mock() unsub = await v1_channel.subscribe(callback) # Verify MQTT connection was established assert mock_mqtt_channel.subscribers # Verify local connection was attempted but failed mock_local_session.assert_called_once_with(TEST_HOST) mock_local_channel.connect.assert_called_once() # Simulate an MQTT disconnection where the channel is not healthy mock_mqtt_channel.close() # Verify properties assert not v1_channel.is_mqtt_connected assert not v1_channel.is_local_connected # Test unsubscribe unsub() assert not mock_mqtt_channel.subscribers async def test_v1_channel_subscribe_local_success( v1_channel: V1Channel, mock_mqtt_channel: Mock, mock_local_channel: Mock, mock_local_session: Mock, ) -> None: """Test successful subscription with local connections.""" mock_mqtt_channel.response_queue.append(TEST_NETWORK_INFO_RESPONSE) # Mock network info retrieval callback = Mock() unsub = await v1_channel.subscribe(callback) # Verify local connection was attempted and succeeded mock_local_session.assert_called_once_with(TEST_HOST) mock_local_channel.connect.assert_called_once() # Verify local connection established and not mqtt assert not mock_mqtt_channel.subscribers assert mock_local_channel.subscribers # Verify properties assert v1_channel.is_mqtt_connected assert v1_channel.is_local_connected # Test unsubscribe cleans up both unsub() assert not mock_mqtt_channel.subscribers assert not mock_local_channel.subscribers async def test_v1_channel_subscribe_already_connected_error(v1_channel: V1Channel, mock_mqtt_channel: Mock) -> None: """Test error when trying to subscribe when already connected.""" mock_mqtt_channel.response_queue.append(TEST_NETWORK_INFO_RESPONSE) # First subscription succeeds await v1_channel.subscribe(Mock()) # Second subscription should fail with pytest.raises(ValueError, match="Only one subscription allowed at a time"): await v1_channel.subscribe(Mock()) async def test_v1_channel_send_command_local_preferred( v1_channel: V1Channel, mock_mqtt_channel: Mock, mock_local_channel: Mock, rpc_channel: V1RpcChannel, ) -> None: """Test command sending prefers local connection when available.""" # Establish connections mock_mqtt_channel.response_queue.append(TEST_NETWORK_INFO_RESPONSE) await v1_channel.subscribe(Mock()) # Send command mock_local_channel.response_queue.append(TEST_RESPONSE) result = await rpc_channel.send_command( RoborockCommand.GET_STATUS, response_type=S5MaxStatus, ) # Verify local response was parsed assert result.state == RoborockStateCode.cleaning async def test_v1_channel_send_command_local_fails( v1_channel: V1Channel, mock_mqtt_channel: Mock, mock_local_channel: Mock, rpc_channel: V1RpcChannel, ) -> None: """Test case where sending with local connection fails, falling back to MQTT.""" # Establish connections mock_mqtt_channel.response_queue.append(TEST_NETWORK_INFO_RESPONSE) await v1_channel.subscribe(Mock()) # Local command fails mock_local_channel.publish = Mock() mock_local_channel.publish.side_effect = RoborockException("Local failed") # MQTT command succeeds mock_mqtt_channel.response_queue.append(TEST_RESPONSE) # Send command result = await rpc_channel.send_command( RoborockCommand.GET_STATUS, response_type=S5MaxStatus, ) # Verify result assert result.state == RoborockStateCode.cleaning # Verify local was attempted mock_local_channel.publish.assert_called_once() # Verify MQTT was used assert mock_mqtt_channel.published_messages # The last message should be the command we sent assert mock_mqtt_channel.published_messages[-1].protocol == RoborockMessageProtocol.RPC_REQUEST @pytest.mark.parametrize( ("local_channel_side_effect", "local_channel_responses", "mock_mqtt_channel_responses"), [ (RoborockException("Local failed"), [], [TEST_RESPONSE]), (None, [], [TEST_RESPONSE]), (None, [RoborockMessage(protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=b"invalid")], [TEST_RESPONSE]), ], ids=[ "local-fails-mqtt-succeeds", "local-no-response-mqtt-succeeds", "local-invalid-response-mqtt-succeeds", ], ) async def test_v1_channel_send_pick_first_available( v1_channel: V1Channel, rpc_channel: V1RpcChannel, mock_mqtt_channel: Mock, mock_local_channel: Mock, local_channel_side_effect: Exception | None, local_channel_responses: list[RoborockMessage], mock_mqtt_channel_responses: list[RoborockMessage], ) -> None: """Test command sending works with MQTT only.""" # Setup: only MQTT connection mock_mqtt_channel.response_queue.append(TEST_NETWORK_INFO_RESPONSE) mock_local_channel.connect.side_effect = local_channel_side_effect await v1_channel.subscribe(Mock()) # Send command mock_mqtt_channel.response_queue.extend(mock_mqtt_channel_responses) mock_local_channel.response_queue.extend(local_channel_responses) result = await rpc_channel.send_command( RoborockCommand.GET_STATUS, response_type=S5MaxStatus, ) # Verify only MQTT was used assert result.state == RoborockStateCode.cleaning async def test_v1_channel_send_decoded_command_with_params( v1_channel: V1Channel, rpc_channel: V1RpcChannel, mock_mqtt_channel: Mock, mock_local_channel: Mock, ) -> None: """Test command sending with parameters.""" mock_mqtt_channel.response_queue.append(TEST_NETWORK_INFO_RESPONSE) await v1_channel.subscribe(Mock()) # Send command with params mock_local_channel.response_queue.append(TEST_RESPONSE) test_params = {"volume": 80} await rpc_channel.send_command( RoborockCommand.CHANGE_SOUND_VOLUME, response_type=S5MaxStatus, params=test_params, ) # Verify command was sent with correct params assert mock_local_channel.published_messages sent_message = mock_local_channel.published_messages[0] assert sent_message assert isinstance(sent_message, RoborockMessage) assert sent_message.payload payload = sent_message.payload.decode() json_data = json.loads(payload) assert "dps" in json_data assert "101" in json_data["dps"] decoded_payload = json.loads(json_data["dps"]["101"]) assert decoded_payload["method"] == "change_sound_volume" assert decoded_payload["params"] == {"volume": 80} async def test_v1_channel_networking_info_retrieved_during_connection( v1_channel: V1Channel, mock_mqtt_channel: Mock, mock_local_channel: Mock, mock_local_session: Mock, ) -> None: """Test that networking information is retrieved during local connection setup.""" # Setup: MQTT returns network info when requested mock_mqtt_channel.response_queue.append(TEST_NETWORK_INFO_RESPONSE) # Subscribe - this should trigger network info retrieval for local connection await v1_channel.subscribe(Mock()) # Verify local connection was esablished assert v1_channel.is_local_connected # Verify network info was requested via MQTT assert mock_mqtt_channel.published_messages # Verify local session was created with the correct IP mock_local_session.assert_called_once_with(mock_data.NETWORK_INFO["ip"]) async def test_v1_channel_networking_info_cached_during_connection( mock_mqtt_channel: Mock, mock_local_channel: Mock, mock_local_session: Mock, ) -> None: """Test that networking information is cached and reused on subsequent connections.""" # Create a cache with pre-populated network info device_cache_data = DeviceCacheData() device_cache_data.network_info = TEST_NETWORKING_INFO mock_device_cache = AsyncMock() mock_device_cache.get.return_value = device_cache_data mock_device_cache.set = AsyncMock() # Create V1Channel with the mock cache v1_channel = V1Channel( device_uid=TEST_DEVICE_UID, security_data=TEST_SECURITY_DATA, mqtt_channel=mock_mqtt_channel, local_session=mock_local_session, device_cache=mock_device_cache, ) # Subscribe - should use cached network info await v1_channel.subscribe(Mock()) # Verify local connections are established assert v1_channel.is_local_connected # Verify network info was NOT requested via MQTT (cache hit) assert not mock_mqtt_channel.published_messages assert not mock_local_channel.published_messages # Verify local session was created with the correct IP from cache mock_local_session.assert_called_once_with(mock_data.NETWORK_INFO["ip"]) # Verify cache was accessed but not updated (cache hit) mock_device_cache.get.assert_called_once() mock_device_cache.set.assert_not_called() # V1Channel edge cases tests async def test_v1_channel_local_connect_network_info_failure( v1_channel: V1Channel, mock_mqtt_channel: Mock, ) -> None: """Test local connection when network info retrieval fails.""" mock_mqtt_channel.publish_side_effect = RoborockException("Network info failed") with pytest.raises(RoborockException): await v1_channel._local_connect() async def test_v1_channel_local_connect_network_info_failure_fallback_to_cache( mock_mqtt_channel: FakeChannel, mock_local_session: Mock, v1_channel: V1Channel, device_cache: DeviceCache, ) -> None: """Test local connection falls back to cache when network info retrieval fails.""" # Create a cache with pre-populated network info await device_cache.set(DeviceCacheData(network_info=TEST_NETWORKING_INFO)) # Setup: MQTT fails to publish mock_mqtt_channel.publish_side_effect = RoborockException("Network info failed") # Attempt local connect, forcing a refresh (prefer_cache=False) # This should try MQTT, fail, and then fall back to cache await v1_channel._local_connect(prefer_cache=False) # Verify local connection was established assert v1_channel.is_local_connected # Verify MQTT was attempted (published message) assert mock_mqtt_channel.published_messages # Verify local session was created with the correct IP from cache mock_local_session.assert_called_once_with(TEST_HOST) async def test_v1_channel_command_encoding_validation( v1_channel: V1Channel, mqtt_rpc_channel: V1RpcChannel, rpc_channel: V1RpcChannel, mock_mqtt_channel: Mock, mock_local_channel: Mock, ) -> None: """Test that command encoding works for different protocols.""" mock_mqtt_channel.response_queue.append(TEST_NETWORK_INFO_RESPONSE) await v1_channel.subscribe(Mock()) # Send mqtt command and capture the request mock_mqtt_channel.response_queue.append(TEST_RESPONSE) await mqtt_rpc_channel.send_command(RoborockCommand.CHANGE_SOUND_VOLUME, params={"volume": 50}) assert mock_mqtt_channel.published_messages mqtt_message = mock_mqtt_channel.published_messages[0] # Send local command and capture the request mock_local_channel.response_queue.append(TEST_RESPONSE_2) await rpc_channel.send_command(RoborockCommand.CHANGE_SOUND_VOLUME, params={"volume": 50}) assert mock_local_channel.published_messages local_message = mock_local_channel.published_messages[0] # Verify both are RoborockMessage instances assert isinstance(mqtt_message, RoborockMessage) assert isinstance(local_message, RoborockMessage) # But they should have different protocols assert mqtt_message.protocol == RoborockMessageProtocol.RPC_REQUEST assert local_message.protocol == RoborockMessageProtocol.GENERAL_REQUEST async def test_v1_channel_send_map_command( v1_channel: V1Channel, map_rpc_channel: V1RpcChannel, mock_mqtt_channel: Mock, mock_create_map_response_decoder: Mock, ) -> None: """Test that the map channel can correctly decode a map response.""" # Establish connections mock_mqtt_channel.response_queue.append(TEST_NETWORK_INFO_RESPONSE) await v1_channel.subscribe(Mock()) # Prepare a mock map response decompressed_map_data = b"this is the decompressed map data" request_id = 12346 # from the mock_request_id fixture # Mock the decoder to return a known response map_response = MapResponse(request_id=request_id, data=decompressed_map_data) mock_create_map_response_decoder.return_value.return_value = map_response # The actual message content doesn't matter as much since the decoder is mocked map_response_message = RoborockMessage( protocol=RoborockMessageProtocol.MAP_RESPONSE, payload=b"dummy_payload", ) mock_mqtt_channel.response_queue.append(map_response_message) # Send the command and get the result result = await map_rpc_channel.send_command(RoborockCommand.GET_MAP_V1) # Verify the result is the data from our mocked decoder assert result == decompressed_map_data Python-roborock-python-roborock-d6da2db/tests/devices/test_a01_traits.py000066400000000000000000000034561513363643200267000ustar00rootroot00000000000000from unittest.mock import AsyncMock, Mock, patch import pytest from roborock.devices.traits.a01 import DyadApi, ZeoApi from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol @pytest.fixture def mock_channel(): channel = Mock() channel.send_command = AsyncMock() return channel @pytest.mark.asyncio async def test_dyad_query_values(mock_channel): with patch("roborock.devices.traits.a01.send_decoded_command", new_callable=AsyncMock) as mock_send: api = DyadApi(mock_channel) # Setup mock return value (raw values) mock_send.return_value = { int(RoborockDyadDataProtocol.CLEAN_MODE): 1, int(RoborockDyadDataProtocol.POWER): 100, } protocols = [RoborockDyadDataProtocol.CLEAN_MODE, RoborockDyadDataProtocol.POWER] result = await api.query_values(protocols) # Verify conversion assert RoborockDyadDataProtocol.CLEAN_MODE in result assert RoborockDyadDataProtocol.POWER in result assert isinstance(result[RoborockDyadDataProtocol.CLEAN_MODE], str) assert result[RoborockDyadDataProtocol.POWER] == 100 @pytest.mark.asyncio async def test_zeo_query_values(mock_channel): with patch("roborock.devices.traits.a01.send_decoded_command", new_callable=AsyncMock) as mock_send: api = ZeoApi(mock_channel) mock_send.return_value = { int(RoborockZeoProtocol.STATE): 6, # spinning int(RoborockZeoProtocol.COUNTDOWN): 120, } protocols = [RoborockZeoProtocol.STATE, RoborockZeoProtocol.COUNTDOWN] result = await api.query_values(protocols) assert RoborockZeoProtocol.STATE in result assert result[RoborockZeoProtocol.STATE] == "spinning" assert result[RoborockZeoProtocol.COUNTDOWN] == 120 Python-roborock-python-roborock-d6da2db/tests/devices/test_device_manager.py000066400000000000000000000373421513363643200276630ustar00rootroot00000000000000"""Tests for the DeviceManager class.""" import asyncio import datetime from collections.abc import Generator, Iterator from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest import syrupy from roborock.data import HomeData, UserData from roborock.devices.cache import InMemoryCache from roborock.devices.device import RoborockDevice from roborock.devices.device_manager import UserParams, create_device_manager, create_web_api_wrapper from roborock.exceptions import RoborockException from tests import mock_data USER_DATA = UserData.from_dict(mock_data.USER_DATA) USER_PARAMS = UserParams(username="test_user", user_data=USER_DATA) NETWORK_INFO = mock_data.NETWORK_INFO @pytest.fixture(autouse=True, name="mqtt_session") def setup_mqtt_session() -> Generator[Mock, None, None]: """Fixture to set up the MQTT session for the tests.""" with patch("roborock.devices.device_manager.create_lazy_mqtt_session") as mock_create_session: yield mock_create_session @pytest.fixture(autouse=True, name="mock_rpc_channel") def rpc_channel_fixture() -> AsyncMock: """Fixture to set up the channel for tests.""" return AsyncMock() @pytest.fixture(autouse=True) async def discover_features_fixture( mock_rpc_channel: AsyncMock, ) -> None: """Fixture to handle device feature discovery.""" mock_rpc_channel.send_command.side_effect = [ [mock_data.APP_GET_INIT_STATUS], mock_data.STATUS, ] @pytest.fixture(autouse=True) def channel_fixture(mock_rpc_channel: AsyncMock) -> Generator[Mock, None, None]: """Fixture to set up the local session for the tests.""" with patch("roborock.devices.device_manager.create_v1_channel") as mock_channel: mock_unsub = Mock() mock_channel.return_value.subscribe = AsyncMock() mock_channel.return_value.subscribe.return_value = mock_unsub mock_channel.return_value.rpc_channel = mock_rpc_channel yield mock_channel @pytest.fixture(autouse=True) def mock_sleep() -> Generator[None, None, None]: """Mock sleep logic to speed up tests.""" sleep_time = datetime.timedelta(seconds=0.001) with ( patch("roborock.devices.device.MIN_BACKOFF_INTERVAL", sleep_time), patch("roborock.devices.device.MAX_BACKOFF_INTERVAL", sleep_time), ): yield @pytest.fixture(name="channel_exception") def channel_failure_exception_fixture(mock_rpc_channel: AsyncMock) -> Exception: """Fixture that provides the exception to be raised by the failing channel.""" return RoborockException("Connection failed") @pytest.fixture(name="channel_failure") def channel_failure_fixture(mock_rpc_channel: AsyncMock, channel_exception: Exception) -> Generator[Mock, None, None]: """Fixture that makes channel subscribe fail.""" with patch("roborock.devices.device_manager.create_v1_channel") as mock_channel: mock_channel.return_value.subscribe = AsyncMock(side_effect=channel_exception) mock_channel.return_value.is_connected = False mock_channel.return_value.rpc_channel = mock_rpc_channel yield mock_channel @pytest.fixture(name="home_data_no_devices") def home_data_no_devices_fixture() -> Iterator[HomeData]: """Mock home data API that returns no devices.""" with patch("roborock.devices.device_manager.UserWebApiClient.get_home_data") as mock_home_data: home_data = HomeData( id=1, name="Test Home", devices=[], products=[], ) mock_home_data.return_value = home_data yield home_data @pytest.fixture(name="home_data") def home_data_fixture() -> Iterator[HomeData]: """Mock home data API that returns devices.""" with patch("roborock.devices.device_manager.UserWebApiClient.get_home_data") as mock_home_data: home_data = HomeData.from_dict(mock_data.HOME_DATA_RAW) mock_home_data.return_value = home_data yield home_data async def test_no_devices(home_data_no_devices: HomeData) -> None: """Test the DeviceManager created with no devices returned from the API.""" device_manager = await create_device_manager(USER_PARAMS) devices = await device_manager.get_devices() assert devices == [] async def test_with_device(home_data: HomeData) -> None: """Test the DeviceManager created with devices returned from the API.""" device_manager = await create_device_manager(USER_PARAMS) devices = await device_manager.get_devices() assert len(devices) == 1 assert devices[0].duid == "abc123" assert devices[0].name == "Roborock S7 MaxV" device = await device_manager.get_device("abc123") assert device is not None assert device.duid == "abc123" assert device.name == "Roborock S7 MaxV" await device_manager.close() async def test_get_non_existent_device(home_data: HomeData) -> None: """Test getting a non-existent device.""" device_manager = await create_device_manager(USER_PARAMS) device = await device_manager.get_device("non_existent_duid") assert device is None await device_manager.close() async def test_create_home_data_api_exception() -> None: """Test that exceptions from the home data API are propagated through the wrapper.""" with patch("roborock.devices.device_manager.RoborockApiClient.get_home_data_v3") as mock_get_home_data: mock_get_home_data.side_effect = RoborockException("Test exception") user_params = UserParams(username="test_user", user_data=USER_DATA) api = create_web_api_wrapper(user_params) with pytest.raises(RoborockException, match="Test exception"): await api.get_home_data() @pytest.mark.parametrize(("prefer_cache", "expected_call_count"), [(True, 1), (False, 2)]) async def test_cache_logic(prefer_cache: bool, expected_call_count: int) -> None: """Test that the cache logic works correctly.""" call_count = 0 async def mock_home_data_with_counter(*args, **kwargs) -> HomeData: nonlocal call_count call_count += 1 return HomeData.from_dict(mock_data.HOME_DATA_RAW) # First call happens during create_device_manager initialization with patch( "roborock.devices.device_manager.RoborockApiClient.get_home_data_v3", side_effect=mock_home_data_with_counter, ): device_manager = await create_device_manager(USER_PARAMS, cache=InMemoryCache()) assert call_count == 1 # Second call should use cache, not increment call_count devices2 = await device_manager.discover_devices(prefer_cache=prefer_cache) assert call_count == expected_call_count assert len(devices2) == 1 await device_manager.close() assert len(devices2) == 1 # Ensure closing again works without error await device_manager.close() async def test_home_data_api_fails_with_cache_fallback() -> None: """Test that home data exceptions may still fall back to use the cache when available.""" cache = InMemoryCache() cache_data = await cache.get() cache_data.home_data = HomeData.from_dict(mock_data.HOME_DATA_RAW) await cache.set(cache_data) with patch( "roborock.devices.device_manager.RoborockApiClient.get_home_data_v3", side_effect=RoborockException("Test exception"), ): # This call will skip the API and use the cache device_manager = await create_device_manager(USER_PARAMS, cache=cache) # This call will hit the API since we're not preferring the cache # but will fallback to the cache data on exception devices2 = await device_manager.discover_devices(prefer_cache=False) assert len(devices2) == 1 await device_manager.close() async def test_ready_callback(home_data: HomeData) -> None: """Test that the ready callback is invoked when a device connects.""" ready_devices: list[RoborockDevice] = [] device_manager = await create_device_manager(USER_PARAMS, ready_callback=ready_devices.append) # Callback should be called for the discovered device assert len(ready_devices) == 1 device = ready_devices[0] assert device.duid == "abc123" # Verify that adding a ready callback to an already connected device will # invoke the callback immediately. more_ready_device: list[RoborockDevice] = [] device.add_ready_callback(more_ready_device.append) assert len(more_ready_device) == 1 assert more_ready_device[0].duid == "abc123" await device_manager.close() @pytest.mark.parametrize( ("channel_exception"), [ RoborockException("Connection failed"), ], ) async def test_start_connect_failure(home_data: HomeData, channel_failure: Mock, mock_sleep: Mock) -> None: """Test that start_connect retries when connection fails.""" ready_devices: list[RoborockDevice] = [] device_manager = await create_device_manager(USER_PARAMS, ready_callback=ready_devices.append) devices = await device_manager.get_devices() # The device should attempt to connect in the background at least once # by the time this function returns. subscribe_mock = channel_failure.return_value.subscribe assert subscribe_mock.call_count > 0 # Device should exist but not be connected assert len(devices) == 1 assert not devices[0].is_connected assert not ready_devices # Verify retry attempts assert channel_failure.return_value.subscribe.call_count >= 1 # Reset the mock channel so that it succeeds on the next attempt mock_unsub = Mock() subscribe_mock = AsyncMock() subscribe_mock.return_value = mock_unsub channel_failure.return_value.subscribe = subscribe_mock channel_failure.return_value.is_connected = True # Wait for the device to attempt to connect again attempts = 0 while subscribe_mock.call_count < 1: await asyncio.sleep(0.01) attempts += 1 assert attempts < 10, "Device did not connect after multiple attempts" assert devices[0].is_connected assert ready_devices assert len(ready_devices) == 1 await device_manager.close() assert mock_unsub.call_count == 1 async def test_rediscover_devices(mock_rpc_channel: AsyncMock) -> None: """Test that we can discover devices multiple times and discover new devices.""" raw_devices: list[dict[str, Any]] = mock_data.HOME_DATA_RAW["devices"] assert len(raw_devices) > 0 raw_device_1 = raw_devices[0] home_data_responses = [ HomeData.from_dict(mock_data.HOME_DATA_RAW), # New device added on second call. We make a copy and updated fields to simulate # a new device. HomeData.from_dict( { **mock_data.HOME_DATA_RAW, "devices": [ raw_device_1, { **raw_device_1, "duid": "new_device_duid", "name": "New Device", "model": "roborock.newmodel.v1", "mac": "00:11:22:33:44:55", }, ], } ), ] mock_rpc_channel.send_command.side_effect = [ [mock_data.APP_GET_INIT_STATUS], mock_data.STATUS, # Device #2 [mock_data.APP_GET_INIT_STATUS], mock_data.STATUS, ] async def mock_home_data_with_counter(*args, **kwargs) -> HomeData: nonlocal home_data_responses return home_data_responses.pop(0) # First call happens during create_device_manager initialization with patch( "roborock.devices.device_manager.RoborockApiClient.get_home_data_v3", side_effect=mock_home_data_with_counter, ): device_manager = await create_device_manager(USER_PARAMS, cache=InMemoryCache()) assert len(await device_manager.get_devices()) == 1 # Second call should use cache and does not add new device await device_manager.discover_devices(prefer_cache=True) assert len(await device_manager.get_devices()) == 1 # Third call should fetch new home data and add the new device await device_manager.discover_devices(prefer_cache=False) assert len(await device_manager.get_devices()) == 2 # Verify the two devices exist with correct data device_1 = await device_manager.get_device("abc123") assert device_1 is not None assert device_1.name == "Roborock S7 MaxV" new_device = await device_manager.get_device("new_device_duid") assert new_device assert new_device.name == "New Device" # Ensure closing again works without error await device_manager.close() @pytest.mark.parametrize( ("channel_exception"), [ Exception("Unexpected error"), ], ) async def test_start_connect_unexpected_error(home_data: HomeData, channel_failure: Mock, mock_sleep: Mock) -> None: """Test that some unexpected errors from start_connect are propagated.""" with pytest.raises(Exception, match="Unexpected error"): await create_device_manager(USER_PARAMS) async def test_diagnostics_collection(home_data: HomeData, snapshot: syrupy.SnapshotAssertion) -> None: """Test that diagnostics are collected correctly in the DeviceManager.""" device_manager = await create_device_manager(USER_PARAMS) devices = await device_manager.get_devices() assert len(devices) == 1 diagnostics = device_manager.diagnostic_data() assert diagnostics is not None diagnostics_data = diagnostics.get("diagnostics") assert diagnostics_data assert diagnostics_data.get("discover_devices") == 1 assert diagnostics_data.get("fetch_home_data") == 1 assert snapshot == diagnostics await device_manager.close() async def test_unsupported_protocol_version() -> None: """Test the DeviceManager with some supported and unsupported product IDs.""" with patch("roborock.devices.device_manager.UserWebApiClient.get_home_data") as mock_home_data: home_data = HomeData.from_dict( { "id": 1, "name": "Test Home", "devices": [ { "duid": "device-uid-1", "name": "Device 1", "pv": "1.0", "productId": "product-id-1", "localKey": mock_data.LOCAL_KEY, }, { "duid": "device-uid-2", "name": "Device 2", "pv": "unknown-pv", # Fake new protocol version we've never seen "productId": "product-id-2", "localKey": mock_data.LOCAL_KEY, }, ], "products": [ { "id": "product-id-1", "name": "Roborock S7 MaxV", "model": "roborock.vacuum.a27", "category": "robot.vacuum.cleaner", }, { "id": "product-id-2", "name": "New Roborock Model", "model": "roborock.vacuum.newmodel", "category": "robot.vacuum.cleaner", }, ], } ) mock_home_data.return_value = home_data device_manager = await create_device_manager(USER_PARAMS) # Only the supported device should be created. The other device is ignored devices = await device_manager.get_devices() assert [device.duid for device in devices] == ["device-uid-1"] # Verify diagnostics diagnostics = device_manager.diagnostic_data() diagnostics_data = diagnostics.get("diagnostics") assert diagnostics_data assert diagnostics_data.get("supported_devices") == {"1.0": 1} assert diagnostics_data.get("unsupported_devices") == {"unknown-pv": 1} Python-roborock-python-roborock-d6da2db/tests/devices/test_file_cache.py000066400000000000000000000132731513363643200267710ustar00rootroot00000000000000"""Tests for the FileCache class.""" import json import pathlib import pickle from unittest.mock import AsyncMock, patch import pytest from syrupy import SnapshotAssertion from roborock.data import HomeData from roborock.data.containers import CombinedMapInfo, NamedRoomMapping from roborock.data.v1.v1_containers import NetworkInfo from roborock.device_features import DeviceFeatures from roborock.devices.cache import CacheData, DeviceCacheData from roborock.devices.file_cache import FileCache from tests.mock_data import HOME_DATA_RAW from tests.mock_data import NETWORK_INFO as NETWORK_INFO_RAW HOME_DATA = HomeData.from_dict(HOME_DATA_RAW) NETWORK_INFO = NetworkInfo.from_dict(NETWORK_INFO_RAW) @pytest.fixture(name="cache_file") def cache_file_fixture(tmp_path: pathlib.Path) -> pathlib.Path: """Fixture to provide a temporary cache file path.""" return tmp_path / "test_cache.bin" async def test_get_from_non_existent_cache(cache_file: pathlib.Path) -> None: """Test getting data when the cache file does not exist.""" cache = FileCache(cache_file) data = await cache.get() assert isinstance(data, CacheData) assert data == CacheData() @pytest.mark.parametrize( "initial_data", [ CacheData(), CacheData(home_data=HOME_DATA), CacheData( home_data=HOME_DATA, network_info={"abc123": NETWORK_INFO}, home_map_info={ # Ensure that int keys are serialized and parsed correctly 1: CombinedMapInfo( map_flag=1, name="Test Map", rooms=[NamedRoomMapping(segment_id=1023, iot_id="4321", name="Living Room")], ) }, ), CacheData( home_data=HOME_DATA, device_info={ "device1": DeviceCacheData( network_info=NETWORK_INFO, home_map_info={ 1: CombinedMapInfo( map_flag=1, name="Test Map 1", rooms=[ NamedRoomMapping(segment_id=1023, iot_id="4321", name="Living Room"), NamedRoomMapping(segment_id=1024, iot_id="4322", name="Starship"), ], ), 2: CombinedMapInfo( map_flag=2, name="Test Map 2", rooms=[NamedRoomMapping(segment_id=2047, iot_id="5432", name="Bedroom")], ), }, home_map_content_base64={ 1: "ZHVtbXlfbWFwX2NvbnRlbnQ=", 2: "bW9yZV9kdW1teV9jb250ZW50", }, device_features=DeviceFeatures.from_feature_flags( new_feature_info=1, new_feature_info_str="0000000000000001", feature_info=[], product_nickname=None, ), trait_data={"test_trait": {"key": "value", "number": 42}}, ), "device2": DeviceCacheData(), }, ), ], ids=["empty_cache", "populated_cache", "multiple_fields_cache", "all_fields_cache"], ) @pytest.mark.parametrize( ("init_args"), [ # Default no arguments {}, # Re-specify the default arguments explicitly { "serialize_fn": pickle.dumps, "deserialize_fn": pickle.loads, }, # Use JSON serialization. We don't use this example directly in this library # but we establish it as an approach that can be used by clients with the # RoborockBase methods for serialization/deserialization. { "serialize_fn": lambda x: json.dumps(x.as_dict()).encode("utf-8"), "deserialize_fn": lambda b: CacheData.from_dict(json.loads(b.decode("utf-8"))), }, ], ids=["default", "pickle", "json"], ) async def test_set_and_flush_and_get( cache_file: pathlib.Path, initial_data: CacheData, init_args: dict, snapshot: SnapshotAssertion, ) -> None: """Test setting, flushing, and then getting data from the cache.""" cache = FileCache(cache_file, **init_args) test_data = initial_data await cache.set(test_data) await cache.flush() assert cache_file.exists() # Create a new cache instance to ensure data is loaded from the file new_cache = FileCache(cache_file, **init_args) loaded_data = await new_cache.get() assert loaded_data == test_data assert await cache.get() == snapshot async def test_get_caches_in_memory(cache_file: pathlib.Path) -> None: """Test that get caches the data in memory and avoids re-reading the file.""" cache = FileCache(cache_file) initial_data = await cache.get() with patch("roborock.devices.file_cache.load_value", new_callable=AsyncMock) as mock_load_value: # This call should use the in-memory cache second_get_data = await cache.get() assert second_get_data is initial_data mock_load_value.assert_not_called() async def test_invalid_cache_data(cache_file: pathlib.Path) -> None: """Test that a TypeError is raised for invalid cache data.""" with open(cache_file, "wb") as f: pickle.dump("invalid_data", f) cache = FileCache(cache_file) with pytest.raises(TypeError): await cache.get() async def test_flush_no_data(cache_file: pathlib.Path) -> None: """Test that flush does nothing if there is no data to write.""" cache = FileCache(cache_file) await cache.flush() assert not cache_file.exists() Python-roborock-python-roborock-d6da2db/tests/devices/test_v1_device.py000066400000000000000000000125701513363643200265730ustar00rootroot00000000000000"""Tests for the Device class.""" import pathlib from collections.abc import Callable from typing import Any from unittest.mock import AsyncMock, Mock import pytest from syrupy import SnapshotAssertion from roborock.data import HomeData, S7MaxVStatus, UserData from roborock.devices.cache import DeviceCache, NoCache from roborock.devices.device import RoborockDevice from roborock.devices.traits import v1 from roborock.devices.traits.v1.common import V1TraitMixin from roborock.protocols.v1_protocol import decode_rpc_response from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol from tests import mock_data USER_DATA = UserData.from_dict(mock_data.USER_DATA) HOME_DATA = HomeData.from_dict(mock_data.HOME_DATA_RAW) STATUS = S7MaxVStatus.from_dict(mock_data.STATUS) TESTDATA = pathlib.Path("tests/protocols/testdata/v1_protocol/") @pytest.fixture(autouse=True, name="channel") def device_channel_fixture() -> AsyncMock: """Fixture to set up the channel for tests.""" return AsyncMock() @pytest.fixture(autouse=True, name="rpc_channel") def rpc_channel_fixture() -> AsyncMock: """Fixture to set up the channel for tests.""" return AsyncMock() @pytest.fixture(autouse=True, name="mqtt_rpc_channel") def mqtt_rpc_channel_fixture() -> AsyncMock: """Fixture to set up the channel for tests.""" return AsyncMock() @pytest.fixture(autouse=True, name="map_rpc_channel") def map_rpc_channel_fixture() -> AsyncMock: """Fixture to set up the channel for tests.""" return AsyncMock() @pytest.fixture(autouse=True, name="device") def device_fixture(channel: AsyncMock, rpc_channel: AsyncMock, mqtt_rpc_channel: AsyncMock) -> RoborockDevice: """Fixture to set up the device for tests.""" return RoborockDevice( device_info=HOME_DATA.devices[0], product=HOME_DATA.products[0], channel=channel, trait=v1.create( HOME_DATA.devices[0].duid, HOME_DATA.products[0], HOME_DATA, rpc_channel, mqtt_rpc_channel, AsyncMock(), AsyncMock(), device_cache=DeviceCache(HOME_DATA.devices[0].duid, NoCache()), ), ) async def test_device_connection(device: RoborockDevice, channel: AsyncMock, setup_rpc_channel: AsyncMock) -> None: """Test the Device connection setup.""" unsub = Mock() subscribe = AsyncMock() subscribe.return_value = unsub channel.subscribe = subscribe assert device.duid == "abc123" assert device.name == "Roborock S7 MaxV" assert not subscribe.called await device.connect() assert subscribe.called assert not unsub.called await device.close() assert unsub.called @pytest.mark.parametrize( ("connected", "local_connected"), [ (True, False), (False, False), (True, True), (False, True), ], ) async def test_connection_status( device: RoborockDevice, channel: AsyncMock, connected: bool, local_connected: bool, ) -> None: """Test successful RPC command sending and response handling.""" channel.is_connected = connected channel.is_local_connected = local_connected assert device.is_connected is connected assert device.is_local_connected is local_connected @pytest.fixture(name="payloads") def payloads_fixture() -> list[pathlib.Path]: """Fixture to provide the payload for the tests.""" return [] @pytest.fixture(name="setup_rpc_channel") def setup_rpc_channel_fixture(rpc_channel: AsyncMock, payloads: list[pathlib.Path]) -> AsyncMock: """Fixture to set up the RPC channel for the tests.""" # Device discovery calls side_effects: list[dict[str, Any] | list[Any] | int] = [ [mock_data.APP_GET_INIT_STATUS], mock_data.STATUS, ] # Subsequent calls return the data payloads setup by the test. for payload in payloads: # The values other than the payload are arbitrary message = RoborockMessage( protocol=RoborockMessageProtocol.GENERAL_RESPONSE, payload=payload.read_bytes(), seq=12750, version=b"1.0", random=97431, timestamp=1652547161, ) response_message = decode_rpc_response(message) side_effects.append(response_message.data) rpc_channel.send_command.side_effect = side_effects return rpc_channel @pytest.mark.parametrize( ("payloads", "property_method"), [ ([TESTDATA / "get_status.json"], lambda x: x.status), ([TESTDATA / "get_dnd.json"], lambda x: x.dnd), ([TESTDATA / "get_clean_summary.json", TESTDATA / "get_last_clean_record.json"], lambda x: x.clean_summary), ([TESTDATA / "get_volume.json"], lambda x: x.sound_volume), ], ids=[ "status", "dnd", "clean_summary", "volume", ], ) async def test_device_trait_command_parsing( device: RoborockDevice, setup_rpc_channel: AsyncMock, snapshot: SnapshotAssertion, property_method: Callable[..., V1TraitMixin], ) -> None: """Test the device trait command.""" await device.connect() trait = property_method(device.v1_properties) assert trait assert isinstance(trait, V1TraitMixin) await trait.refresh() assert setup_rpc_channel.send_command.called assert trait == snapshot assert device.v1_properties device_dict = device.diagnostic_data() assert device_dict == snapshot Python-roborock-python-roborock-d6da2db/tests/devices/traits/000077500000000000000000000000001513363643200246165ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/devices/traits/__init__.py000066400000000000000000000000371513363643200267270ustar00rootroot00000000000000"""Tests for device traits.""" Python-roborock-python-roborock-d6da2db/tests/devices/traits/a01/000077500000000000000000000000001513363643200251775ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/devices/traits/a01/__init__.py000066400000000000000000000000001513363643200272760ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/devices/traits/a01/test_init.py000066400000000000000000000215071513363643200275600ustar00rootroot00000000000000import datetime import json from typing import Any import pytest from Crypto.Cipher import AES from Crypto.Util.Padding import unpad from roborock.devices.traits.a01 import DyadApi, ZeoApi from roborock.roborock_message import RoborockDyadDataProtocol, RoborockMessageProtocol, RoborockZeoProtocol from tests.fixtures.channel_fixtures import FakeChannel from tests.protocols.common import build_a01_message @pytest.fixture(name="fake_channel") def fake_channel_fixture() -> FakeChannel: return FakeChannel() @pytest.fixture(name="dyad_api") def dyad_api_fixture(fake_channel: FakeChannel) -> DyadApi: return DyadApi(fake_channel) # type: ignore[arg-type] @pytest.fixture(name="zeo_api") def zeo_api_fixture(fake_channel: FakeChannel) -> ZeoApi: return ZeoApi(fake_channel) # type: ignore[arg-type] async def test_dyad_api_query_values(dyad_api: DyadApi, fake_channel: FakeChannel): """Test that DyadApi currently returns raw values without conversion.""" fake_channel.response_queue.append( build_a01_message( { 209: 1, # POWER 201: 6, # STATUS 207: 3, # WATER_LEVEL 214: 120, # MESH_LEFT 215: 90, # BRUSH_LEFT 227: 85, # SILENT_MODE_START_TIME 229: "3,4,5", # RECENT_RUN_TIME 230: 123456, # TOTAL_RUN_TIME 222: 1, # STAND_LOCK_AUTO_RUN 224: 0, # AUTO_DRY_MODE } ) ) result = await dyad_api.query_values( [ RoborockDyadDataProtocol.POWER, RoborockDyadDataProtocol.STATUS, RoborockDyadDataProtocol.WATER_LEVEL, RoborockDyadDataProtocol.MESH_LEFT, RoborockDyadDataProtocol.BRUSH_LEFT, RoborockDyadDataProtocol.SILENT_MODE_START_TIME, RoborockDyadDataProtocol.RECENT_RUN_TIME, RoborockDyadDataProtocol.TOTAL_RUN_TIME, RoborockDyadDataProtocol.STAND_LOCK_AUTO_RUN, RoborockDyadDataProtocol.AUTO_DRY_MODE, ] ) assert result == { RoborockDyadDataProtocol.POWER: 1, RoborockDyadDataProtocol.STATUS: "self_clean_deep_cleaning", RoborockDyadDataProtocol.WATER_LEVEL: "l3", RoborockDyadDataProtocol.MESH_LEFT: 352800, RoborockDyadDataProtocol.BRUSH_LEFT: 354600, RoborockDyadDataProtocol.SILENT_MODE_START_TIME: datetime.time(1, 25), RoborockDyadDataProtocol.RECENT_RUN_TIME: [3, 4, 5], RoborockDyadDataProtocol.TOTAL_RUN_TIME: 123456, RoborockDyadDataProtocol.STAND_LOCK_AUTO_RUN: True, RoborockDyadDataProtocol.AUTO_DRY_MODE: False, } assert len(fake_channel.published_messages) == 1 message = fake_channel.published_messages[0] assert message.protocol == RoborockMessageProtocol.RPC_REQUEST assert message.version == b"A01" payload_data = json.loads(unpad(message.payload, AES.block_size)) assert payload_data == {"dps": {"10000": "[209, 201, 207, 214, 215, 227, 229, 230, 222, 224]"}} @pytest.mark.parametrize( ("query", "response", "expected_result"), [ ( [RoborockDyadDataProtocol.STATUS], { 7: 1, RoborockDyadDataProtocol.STATUS: 3, 9999: -3, }, { RoborockDyadDataProtocol.STATUS: "charging", }, ), ( [RoborockDyadDataProtocol.SILENT_MODE_START_TIME], { RoborockDyadDataProtocol.SILENT_MODE_START_TIME: "invalid", }, { RoborockDyadDataProtocol.SILENT_MODE_START_TIME: None, }, ), ( [RoborockDyadDataProtocol.SILENT_MODE_START_TIME], { RoborockDyadDataProtocol.SILENT_MODE_START_TIME: 85, RoborockDyadDataProtocol.POWER: 2, 9999: -3, }, { RoborockDyadDataProtocol.SILENT_MODE_START_TIME: datetime.time(1, 25), }, ), ], ids=[ "ignored-unknown-protocol", "invalid-value", "additional-returned-values", ], ) async def test_dyad_invalid_response_value( query: list[RoborockDyadDataProtocol], response: dict[int, Any], expected_result: dict[RoborockDyadDataProtocol, Any], dyad_api: DyadApi, fake_channel: FakeChannel, ): """Test that DyadApi currently returns raw values without conversion.""" fake_channel.response_queue.append(build_a01_message(response)) result = await dyad_api.query_values(query) assert result == expected_result async def test_zeo_api_query_values(zeo_api: ZeoApi, fake_channel: FakeChannel): """Test that ZeoApi currently returns raw values without conversion.""" fake_channel.response_queue.append( build_a01_message( { 203: 6, # spinning 207: 3, # medium 226: 1, 227: 0, 224: 1, # Times after clean. Testing int value 218: 0, # Washing left. Testing zero int value } ) ) result = await zeo_api.query_values( [ RoborockZeoProtocol.STATE, RoborockZeoProtocol.TEMP, RoborockZeoProtocol.DETERGENT_EMPTY, RoborockZeoProtocol.SOFTENER_EMPTY, RoborockZeoProtocol.TIMES_AFTER_CLEAN, RoborockZeoProtocol.WASHING_LEFT, ] ) assert result == { # Note: Bug here, should return enum/bool values RoborockZeoProtocol.STATE: "spinning", RoborockZeoProtocol.TEMP: "medium", RoborockZeoProtocol.DETERGENT_EMPTY: True, RoborockZeoProtocol.SOFTENER_EMPTY: False, RoborockZeoProtocol.TIMES_AFTER_CLEAN: 1, RoborockZeoProtocol.WASHING_LEFT: 0, } assert len(fake_channel.published_messages) == 1 message = fake_channel.published_messages[0] assert message.protocol == RoborockMessageProtocol.RPC_REQUEST assert message.version == b"A01" payload_data = json.loads(unpad(message.payload, AES.block_size)) assert payload_data == {"dps": {"10000": "[203, 207, 226, 227, 224, 218]"}} @pytest.mark.parametrize( ("query", "response", "expected_result"), [ ( [RoborockZeoProtocol.STATE], { 7: 1, RoborockZeoProtocol.STATE: 1, 9999: -3, }, { RoborockZeoProtocol.STATE: "standby", }, ), ( [RoborockZeoProtocol.WASHING_LEFT], { RoborockZeoProtocol.WASHING_LEFT: "invalid", }, { RoborockZeoProtocol.WASHING_LEFT: None, }, ), ( [RoborockZeoProtocol.STATE], { RoborockZeoProtocol.STATE: 1, RoborockZeoProtocol.WASHING_LEFT: 2, 9999: -3, }, { RoborockZeoProtocol.STATE: "standby", }, ), ], ids=[ "ignored-unknown-protocol", "invalid-value", "additional-returned-values", ], ) async def test_zeo_invalid_response_value( query: list[RoborockZeoProtocol], response: dict[int, Any], expected_result: dict[RoborockZeoProtocol, Any], zeo_api: ZeoApi, fake_channel: FakeChannel, ): """Test that ZeoApi currently returns raw values without conversion.""" fake_channel.response_queue.append(build_a01_message(response)) result = await zeo_api.query_values(query) assert result == expected_result async def test_dyad_api_set_value(dyad_api: DyadApi, fake_channel: FakeChannel): """Test DyadApi set_value sends correct command.""" await dyad_api.set_value(RoborockDyadDataProtocol.POWER, 1) assert len(fake_channel.published_messages) == 1 message = fake_channel.published_messages[0] assert message.protocol == RoborockMessageProtocol.RPC_REQUEST assert message.version == b"A01" # decode the payload to verify contents payload_data = json.loads(unpad(message.payload, AES.block_size)) # A01 protocol expects values to be strings in the dps dict assert payload_data == {"dps": {"209": 1}} async def test_zeo_api_set_value(zeo_api: ZeoApi, fake_channel: FakeChannel): """Test ZeoApi set_value sends correct command.""" await zeo_api.set_value(RoborockZeoProtocol.MODE, "standard") assert len(fake_channel.published_messages) == 1 message = fake_channel.published_messages[0] assert message.protocol == RoborockMessageProtocol.RPC_REQUEST assert message.version == b"A01" # decode the payload to verify contents payload_data = json.loads(unpad(message.payload, AES.block_size)) # A01 protocol expects values to be strings in the dps dict assert payload_data == {"dps": {"204": "standard"}} Python-roborock-python-roborock-d6da2db/tests/devices/traits/b01/000077500000000000000000000000001513363643200252005ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/devices/traits/b01/__init__.py000066400000000000000000000000001513363643200272770ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/devices/traits/b01/q7/000077500000000000000000000000001513363643200255275ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/devices/traits/b01/q7/__init__.py000066400000000000000000000000001513363643200276260ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/devices/traits/b01/q7/test_init.py000066400000000000000000000260601513363643200301070ustar00rootroot00000000000000import json import math import time from collections.abc import Generator from typing import Any from unittest.mock import patch import pytest from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad from roborock.data.b01_q7 import ( CleanTaskTypeMapping, SCDeviceCleanParam, SCWindMapping, WaterLevelMapping, WorkStatusMapping, ) from roborock.devices.rpc.b01_q7_channel import send_decoded_command from roborock.devices.traits.b01.q7 import Q7PropertiesApi from roborock.exceptions import RoborockException from roborock.protocols.b01_q7_protocol import B01_VERSION, Q7RequestMessage from roborock.roborock_message import RoborockB01Props, RoborockMessage, RoborockMessageProtocol from tests.fixtures.channel_fixtures import FakeChannel class B01MessageBuilder: """Helper class to build B01 RPC response messages for tests.""" def __init__(self) -> None: self.msg_id = 123456789 self.seq = 2020 def build(self, data: dict[str, Any] | str, code: int | None = None) -> RoborockMessage: """Build an encoded B01 RPC response message.""" message: dict[str, Any] = { "msgId": str(self.msg_id), "data": data, } if code is not None: message["code"] = code return self._build_dps(message) def _build_dps(self, message: dict[str, Any] | str) -> RoborockMessage: """Build an encoded B01 RPC response message.""" dps_payload = {"dps": {"10000": json.dumps(message)}} self.seq += 1 return RoborockMessage( protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=pad( json.dumps(dps_payload).encode(), AES.block_size, ), version=b"B01", seq=self.seq, ) @pytest.fixture(name="fake_channel") def fake_channel_fixture() -> FakeChannel: return FakeChannel() @pytest.fixture(name="q7_api") def q7_api_fixture(fake_channel: FakeChannel) -> Q7PropertiesApi: return Q7PropertiesApi(fake_channel) # type: ignore[arg-type] @pytest.fixture(name="expected_msg_id", autouse=True) def next_message_id_fixture() -> Generator[int, None, None]: """Fixture to patch get_next_int to return the expected message ID. We pick an arbitrary number, but just need it to ensure we can craft a fake response with the message id matched to the outgoing RPC. """ expected_msg_id = math.floor(time.time()) # Patch get_next_int to return our expected msg_id so the channel waits for it with patch("roborock.protocols.b01_q7_protocol.get_next_int", return_value=expected_msg_id): yield expected_msg_id @pytest.fixture(name="message_builder") def message_builder_fixture(expected_msg_id: int) -> B01MessageBuilder: builder = B01MessageBuilder() builder.msg_id = expected_msg_id return builder async def test_q7_api_query_values( q7_api: Q7PropertiesApi, fake_channel: FakeChannel, message_builder: B01MessageBuilder ): """Test that Q7PropertiesApi correctly converts raw values.""" # We need to construct the expected result based on the mappings # status: 1 -> WAITING_FOR_ORDERS # wind: 2 -> STANDARD response_data = { "status": 1, "wind": 2, "battery": 100, } # Queue the response fake_channel.response_queue.append(message_builder.build(response_data)) result = await q7_api.query_values( [ RoborockB01Props.STATUS, RoborockB01Props.WIND, ] ) assert result is not None assert result.status == WorkStatusMapping.WAITING_FOR_ORDERS # wind might be mapped to SCWindMapping.STANDARD (2) # let's verify checking the prop definition in B01Props # wind: SCWindMapping | None = None # SCWindMapping.STANDARD is 2 ('balanced') from roborock.data.b01_q7 import SCWindMapping assert result.wind == SCWindMapping.STANDARD assert len(fake_channel.published_messages) == 1 message = fake_channel.published_messages[0] assert message.protocol == RoborockMessageProtocol.RPC_REQUEST assert message.version == B01_VERSION # Verify request payload assert message.payload is not None payload_data = json.loads(unpad(message.payload, AES.block_size)) # {"dps": {"10000": {"method": "prop.get", "msgId": "123456789", "params": {"property": ["status", "wind"]}}}} assert "dps" in payload_data assert "10000" in payload_data["dps"] inner = payload_data["dps"]["10000"] assert inner["method"] == "prop.get" assert inner["msgId"] == str(message_builder.msg_id) assert inner["params"] == {"property": [RoborockB01Props.STATUS, RoborockB01Props.WIND]} @pytest.mark.parametrize( ("query", "response_data", "expected_status"), [ ( [RoborockB01Props.STATUS], {"status": 2}, WorkStatusMapping.PAUSED, ), ( [RoborockB01Props.STATUS], {"status": 5}, WorkStatusMapping.SWEEP_MOPING, ), ], ) async def test_q7_response_value_mapping( query: list[RoborockB01Props], response_data: dict[str, Any], expected_status: WorkStatusMapping, q7_api: Q7PropertiesApi, fake_channel: FakeChannel, message_builder: B01MessageBuilder, ): """Test Q7PropertiesApi value mapping for different statuses.""" fake_channel.response_queue.append(message_builder.build(response_data)) result = await q7_api.query_values(query) assert result is not None assert result.status == expected_status async def test_send_decoded_command_non_dict_response(fake_channel: FakeChannel, message_builder: B01MessageBuilder): """Test validity of handling non-dict responses (should not timeout).""" message = message_builder.build("some_string_error") fake_channel.response_queue.append(message) # Use a random string for command type to avoid needing import with pytest.raises(RoborockException, match="Unexpected data type for response"): await send_decoded_command(fake_channel, Q7RequestMessage(dps=10000, command="prop.get", params=[])) # type: ignore[arg-type] async def test_send_decoded_command_error_code(fake_channel: FakeChannel, message_builder: B01MessageBuilder): """Test that non-zero error codes from device are properly handled.""" message = message_builder.build({}, code=5001) fake_channel.response_queue.append(message) with pytest.raises(RoborockException, match="B01 command failed with code 5001"): await send_decoded_command(fake_channel, Q7RequestMessage(dps=10000, command="prop.get", params=[])) # type: ignore[arg-type] async def test_q7_api_set_fan_speed( q7_api: Q7PropertiesApi, fake_channel: FakeChannel, message_builder: B01MessageBuilder ): """Test setting fan speed.""" fake_channel.response_queue.append(message_builder.build({"result": "ok"})) await q7_api.set_fan_speed(SCWindMapping.STRONG) assert len(fake_channel.published_messages) == 1 message = fake_channel.published_messages[0] payload_data = json.loads(unpad(message.payload, AES.block_size)) assert payload_data["dps"]["10000"]["method"] == "prop.set" assert payload_data["dps"]["10000"]["params"] == {RoborockB01Props.WIND: SCWindMapping.STRONG.code} async def test_q7_api_set_water_level( q7_api: Q7PropertiesApi, fake_channel: FakeChannel, message_builder: B01MessageBuilder ): """Test setting water level.""" fake_channel.response_queue.append(message_builder.build({"result": "ok"})) await q7_api.set_water_level(WaterLevelMapping.HIGH) assert len(fake_channel.published_messages) == 1 message = fake_channel.published_messages[0] payload_data = json.loads(unpad(message.payload, AES.block_size)) assert payload_data["dps"]["10000"]["method"] == "prop.set" assert payload_data["dps"]["10000"]["params"] == {RoborockB01Props.WATER: WaterLevelMapping.HIGH.code} async def test_q7_api_start_clean( q7_api: Q7PropertiesApi, fake_channel: FakeChannel, message_builder: B01MessageBuilder ): """Test starting cleaning.""" fake_channel.response_queue.append(message_builder.build({"result": "ok"})) await q7_api.start_clean() assert len(fake_channel.published_messages) == 1 message = fake_channel.published_messages[0] payload_data = json.loads(unpad(message.payload, AES.block_size)) assert payload_data["dps"]["10000"]["method"] == "service.set_room_clean" assert payload_data["dps"]["10000"]["params"] == { "clean_type": CleanTaskTypeMapping.ALL.code, "ctrl_value": SCDeviceCleanParam.START.code, "room_ids": [], } async def test_q7_api_pause_clean( q7_api: Q7PropertiesApi, fake_channel: FakeChannel, message_builder: B01MessageBuilder ): """Test pausing cleaning.""" fake_channel.response_queue.append(message_builder.build({"result": "ok"})) await q7_api.pause_clean() assert len(fake_channel.published_messages) == 1 message = fake_channel.published_messages[0] payload_data = json.loads(unpad(message.payload, AES.block_size)) assert payload_data["dps"]["10000"]["method"] == "service.set_room_clean" assert payload_data["dps"]["10000"]["params"] == { "clean_type": CleanTaskTypeMapping.ALL.code, "ctrl_value": SCDeviceCleanParam.PAUSE.code, "room_ids": [], } async def test_q7_api_stop_clean( q7_api: Q7PropertiesApi, fake_channel: FakeChannel, message_builder: B01MessageBuilder ): """Test stopping cleaning.""" fake_channel.response_queue.append(message_builder.build({"result": "ok"})) await q7_api.stop_clean() assert len(fake_channel.published_messages) == 1 message = fake_channel.published_messages[0] payload_data = json.loads(unpad(message.payload, AES.block_size)) assert payload_data["dps"]["10000"]["method"] == "service.set_room_clean" assert payload_data["dps"]["10000"]["params"] == { "clean_type": CleanTaskTypeMapping.ALL.code, "ctrl_value": SCDeviceCleanParam.STOP.code, "room_ids": [], } async def test_q7_api_return_to_dock( q7_api: Q7PropertiesApi, fake_channel: FakeChannel, message_builder: B01MessageBuilder ): """Test returning to dock.""" fake_channel.response_queue.append(message_builder.build({"result": "ok"})) await q7_api.return_to_dock() assert len(fake_channel.published_messages) == 1 message = fake_channel.published_messages[0] payload_data = json.loads(unpad(message.payload, AES.block_size)) assert payload_data["dps"]["10000"]["method"] == "service.start_recharge" assert payload_data["dps"]["10000"]["params"] == {} async def test_q7_api_find_me(q7_api: Q7PropertiesApi, fake_channel: FakeChannel, message_builder: B01MessageBuilder): """Test locating the device.""" fake_channel.response_queue.append(message_builder.build({"result": "ok"})) await q7_api.find_me() assert len(fake_channel.published_messages) == 1 message = fake_channel.published_messages[0] payload_data = json.loads(unpad(message.payload, AES.block_size)) assert payload_data["dps"]["10000"]["method"] == "service.find_device" assert payload_data["dps"]["10000"]["params"] == {} Python-roborock-python-roborock-d6da2db/tests/devices/traits/v1/000077500000000000000000000000001513363643200251445ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/devices/traits/v1/__init__.py000066400000000000000000000000661513363643200272570ustar00rootroot00000000000000pytest_plugins = ["tests.devices.traits.v1.fixtures"] Python-roborock-python-roborock-d6da2db/tests/devices/traits/v1/__snapshots__/000077500000000000000000000000001513363643200277625ustar00rootroot00000000000000test_device_features.ambr000066400000000000000000000013051513363643200347410ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/devices/traits/v1/__snapshots__# serializer version: 1 # name: test_is_attribute_supported[home_data_device_s5e.json] dict({ 'battery': True, 'charge_status': True, 'dry_status': True, 'fan_power': True, 'state': True, 'water_box_mode': True, }) # --- # name: test_is_attribute_supported[home_data_device_s7_maxv.json] dict({ 'battery': True, 'charge_status': True, 'dry_status': True, 'fan_power': True, 'state': True, 'water_box_mode': True, }) # --- # name: test_is_attribute_supported[home_data_device_saros_10r.json] dict({ 'battery': True, 'charge_status': True, 'dry_status': True, 'fan_power': True, 'state': True, 'water_box_mode': True, }) # --- Python-roborock-python-roborock-d6da2db/tests/devices/traits/v1/fixtures.py000066400000000000000000000077411513363643200274000ustar00rootroot00000000000000"""Fixtures for V1 trait tests.""" from unittest.mock import AsyncMock import pytest from roborock.data import HomeData, HomeDataDevice, HomeDataProduct, RoborockDockTypeCode, S7MaxVStatus, UserData from roborock.devices.cache import Cache, DeviceCache, InMemoryCache from roborock.devices.device import RoborockDevice from roborock.devices.traits import v1 from tests import mock_data USER_DATA = UserData.from_dict(mock_data.USER_DATA) HOME_DATA = HomeData.from_dict(mock_data.HOME_DATA_RAW) STATUS = S7MaxVStatus.from_dict(mock_data.STATUS) @pytest.fixture(autouse=True, name="channel") def device_channel_fixture() -> AsyncMock: """Fixture to set up the channel for tests.""" return AsyncMock() @pytest.fixture(autouse=True, name="mock_rpc_channel") def rpc_channel_fixture() -> AsyncMock: """Fixture to set up the channel for tests.""" return AsyncMock() @pytest.fixture(autouse=True, name="mock_mqtt_rpc_channel") def mqtt_rpc_channel_fixture() -> AsyncMock: """Fixture to set up the channel for tests.""" return AsyncMock() @pytest.fixture(autouse=True, name="mock_map_rpc_channel") def map_rpc_channel_fixture() -> AsyncMock: """Fixture to set up the channel for tests.""" return AsyncMock() @pytest.fixture(autouse=True, name="web_api_client") def web_api_client_fixture() -> AsyncMock: """Fixture to set up the web API client for tests.""" return AsyncMock() @pytest.fixture(autouse=True, name="roborock_cache") def roborock_cache_fixture() -> Cache: """Fixture to provide a NoCache instance for tests.""" return InMemoryCache() @pytest.fixture(autouse=True, name="device_cache") def device_cache_fixture(roborock_cache: Cache) -> DeviceCache: """Fixture to provide a DeviceCache instance for tests.""" return DeviceCache(HOME_DATA.devices[0].duid, roborock_cache) @pytest.fixture(name="device_info") def device_info_fixture() -> HomeDataDevice: """Fixture to provide a DeviceInfo instance for tests.""" return HOME_DATA.devices[0] @pytest.fixture(name="products") def products_fixture() -> list[HomeDataProduct]: """Fixture to provide a Product instance for tests.""" return [HomeDataProduct.from_dict(product) for product in mock_data.PRODUCTS.values()] @pytest.fixture(autouse=True, name="device") def device_fixture( channel: AsyncMock, mock_rpc_channel: AsyncMock, mock_mqtt_rpc_channel: AsyncMock, mock_map_rpc_channel: AsyncMock, web_api_client: AsyncMock, device_cache: DeviceCache, device_info: HomeDataDevice, products: list[HomeDataProduct], ) -> RoborockDevice: """Fixture to set up the device for tests.""" product = next(filter(lambda product: product.id == device_info.product_id, products)) return RoborockDevice( device_info=device_info, product=product, channel=channel, trait=v1.create( device_info.duid, product, HOME_DATA, mock_rpc_channel, mock_mqtt_rpc_channel, mock_map_rpc_channel, web_api_client, device_cache=device_cache, ), ) @pytest.fixture(name="dock_type_code", autouse=True) def dock_type_code_fixture(request: pytest.FixtureRequest) -> RoborockDockTypeCode | None: """Fixture to provide the dock type code for parameterized tests.""" return RoborockDockTypeCode.s7_max_ultra_dock @pytest.fixture(autouse=True) async def discover_features_fixture( device: RoborockDevice, mock_rpc_channel: AsyncMock, dock_type_code: RoborockDockTypeCode | None, ) -> None: """Fixture to handle device feature discovery.""" assert device.v1_properties mock_rpc_channel.send_command.side_effect = [ [mock_data.APP_GET_INIT_STATUS], { **mock_data.STATUS, "dock_type": dock_type_code, }, ] # Connecting triggers device discovery await device.connect() assert device.v1_properties.status.dock_type == dock_type_code mock_rpc_channel.send_command.reset_mock() Python-roborock-python-roborock-d6da2db/tests/devices/traits/v1/test_clean_summary.py000066400000000000000000000155371513363643200314270ustar00rootroot00000000000000"""Tests for the CleanSummary class.""" from unittest.mock import AsyncMock, call import pytest from roborock.data import CleanSummary from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.clean_summary import CleanSummaryTrait from roborock.exceptions import RoborockException from roborock.roborock_typing import RoborockCommand from tests.mock_data import CLEAN_RECORD CLEAN_SUMMARY_DATA = [ 1442559, 24258125000, 296, [ 1756848207, 1754930385, 1753203976, 1752183435, 1747427370, 1746204046, 1745601543, 1744387080, 1743528522, 1742489154, 1741022299, 1740433682, 1739902516, 1738875106, 1738864366, 1738620067, 1736873889, 1736197544, 1736121269, 1734458038, ], ] CLEAN_RECORD_DATA = [ [ 1738864366, 1738868964, 4358, 81122500, 0, 0, 1, 1, 21, ] ] @pytest.fixture def clean_summary_trait(device: RoborockDevice) -> CleanSummaryTrait: """Create a DoNotDisturbTrait instance with mocked dependencies.""" assert device.v1_properties return device.v1_properties.clean_summary @pytest.fixture def sample_clean_summary() -> CleanSummary: """Create a sample CleanSummary for testing.""" return CleanSummary( clean_area=100, clean_time=3600, ) async def test_get_clean_summary_success( clean_summary_trait: CleanSummaryTrait, mock_rpc_channel: AsyncMock, sample_clean_summary: CleanSummary ) -> None: """Test successfully getting clean summary.""" # Setup mock to return the sample clean summary mock_rpc_channel.send_command.side_effect = [CLEAN_SUMMARY_DATA, CLEAN_RECORD_DATA] # Call the method await clean_summary_trait.refresh() # Verify the result assert clean_summary_trait.clean_area == 24258125000 assert clean_summary_trait.clean_time == 1442559 assert clean_summary_trait.square_meter_clean_area == 24258.1 assert clean_summary_trait.clean_count == 296 assert clean_summary_trait.records assert len(clean_summary_trait.records) == 20 assert clean_summary_trait.records[0] == 1756848207 assert clean_summary_trait.last_clean_record assert clean_summary_trait.last_clean_record.begin == 1738864366 # Verify the RPC call was made correctly mock_rpc_channel.send_command.assert_has_calls( [call(RoborockCommand.GET_CLEAN_SUMMARY), call(RoborockCommand.GET_CLEAN_RECORD, params=[1756848207])] ) async def test_get_clean_summary_clean_time_only( clean_summary_trait: CleanSummaryTrait, mock_rpc_channel: AsyncMock, sample_clean_summary: CleanSummary ) -> None: """Test successfully getting clean summary where the response only has the clean time.""" mock_rpc_channel.send_command.return_value = [1442559] # Call the method await clean_summary_trait.refresh() # Verify the result assert clean_summary_trait.clean_area is None assert clean_summary_trait.clean_time == 1442559 assert clean_summary_trait.square_meter_clean_area is None assert clean_summary_trait.clean_count is None assert not clean_summary_trait.records # Verify the RPC call was made correctly mock_rpc_channel.send_command.assert_called_once_with(RoborockCommand.GET_CLEAN_SUMMARY) async def test_get_clean_summary_propagates_exception( clean_summary_trait: CleanSummaryTrait, mock_rpc_channel: AsyncMock ) -> None: """Test that exceptions from RPC channel are propagated in get_clean_summary.""" # Setup mock to raise an exception mock_rpc_channel.send_command.side_effect = RoborockException("Communication error") # Verify the exception is propagated with pytest.raises(RoborockException, match="Communication error"): await clean_summary_trait.refresh() async def test_get_clean_record_success( clean_summary_trait: CleanSummaryTrait, mock_rpc_channel: AsyncMock, ) -> None: """Test successfully getting the last clean record.""" # Setup mock to return the sample clean summary and clean record mock_rpc_channel.send_command.side_effect = [ CLEAN_RECORD_DATA, ] # Call the method clean_record = await clean_summary_trait.get_clean_record(1738864366) # Verify the result assert clean_record.begin == 1738864366 assert clean_record.end == 1738868964 assert clean_record.duration == 4358 assert clean_record.area == 81122500 assert clean_record.complete is None assert clean_record.start_type is None assert clean_record.clean_type is None assert clean_record.finish_reason is None # Verify the RPC calls were made correctly mock_rpc_channel.send_command.assert_has_calls( [ call(RoborockCommand.GET_CLEAN_RECORD, params=[1738864366]), ] ) async def test_get_clean_record_dict_response( clean_summary_trait: CleanSummaryTrait, mock_rpc_channel: AsyncMock, ) -> None: """Test successfully getting the last clean record as a dictionary.""" # Setup mock to return the sample clean summary and clean record mock_rpc_channel.send_command.side_effect = [ CLEAN_RECORD, ] # Call the method clean_record = await clean_summary_trait.get_clean_record(1738864366) # Verify the result assert clean_record.begin == 1672543330 assert clean_record.end == 1672544638 assert clean_record.duration == 1176 assert clean_record.area == 20965000 assert clean_record.complete == 1 assert clean_record.start_type == 2 assert clean_record.clean_type == 3 assert clean_record.finish_reason == 56 assert clean_record.dust_collection_status == 1 assert clean_record.avoid_count == 19 assert clean_record.wash_count == 2 assert clean_record.map_flag == 0 # Verify the RPC calls were made correctly mock_rpc_channel.send_command.assert_has_calls( [ call(RoborockCommand.GET_CLEAN_RECORD, params=[1738864366]), ] ) async def test_get_clean_summary_no_records( clean_summary_trait: CleanSummaryTrait, mock_rpc_channel: AsyncMock ) -> None: """Test successfully getting clean summary with no records.""" # Setup mock to return the sample clean summary with no records mock_rpc_channel.send_command.return_value = [ 1442559, 24258125000, 296, [], ] # Call the method await clean_summary_trait.refresh() # Verify the result assert clean_summary_trait.clean_area == 24258125000 assert clean_summary_trait.clean_time == 1442559 assert clean_summary_trait.clean_count == 296 assert not clean_summary_trait.records assert clean_summary_trait.last_clean_record is None # Verify the RPC call was made correctly mock_rpc_channel.send_command.assert_called_once_with(RoborockCommand.GET_CLEAN_SUMMARY) Python-roborock-python-roborock-d6da2db/tests/devices/traits/v1/test_command.py000066400000000000000000000043171513363643200302000ustar00rootroot00000000000000"""Tests for the CommandTrait class.""" from unittest.mock import AsyncMock import pytest from roborock.devices.traits.v1.command import CommandTrait from roborock.exceptions import RoborockException from roborock.roborock_typing import RoborockCommand @pytest.fixture(name="command_trait") def command_trait_fixture() -> CommandTrait: """Create a CommandTrait instance with a mocked RPC channel.""" trait = CommandTrait() trait._rpc_channel = AsyncMock() # type: ignore[assignment] return trait async def test_send_command_success(command_trait: CommandTrait) -> None: """Test successfully sending a command.""" mock_rpc_channel = command_trait._rpc_channel assert mock_rpc_channel is not None mock_rpc_channel.send_command.return_value = {"result": "ok"} # Call the method result = await command_trait.send(RoborockCommand.APP_START) # Verify the result assert result == {"result": "ok"} # Verify the RPC call was made correctly mock_rpc_channel.send_command.assert_called_once_with(RoborockCommand.APP_START, params=None) async def test_send_command_with_params(command_trait: CommandTrait) -> None: """Test successfully sending a command with parameters.""" mock_rpc_channel = command_trait._rpc_channel assert mock_rpc_channel is not None mock_rpc_channel.send_command.return_value = {"result": "ok"} params = {"segments": [1, 2, 3]} # Call the method result = await command_trait.send(RoborockCommand.APP_SEGMENT_CLEAN, params) # Verify the result assert result == {"result": "ok"} # Verify the RPC call was made correctly mock_rpc_channel.send_command.assert_called_once_with(RoborockCommand.APP_SEGMENT_CLEAN, params=params) async def test_send_command_propagates_exception(command_trait: CommandTrait) -> None: """Test that exceptions from RPC channel are propagated.""" mock_rpc_channel = command_trait._rpc_channel assert mock_rpc_channel is not None mock_rpc_channel.send_command.side_effect = RoborockException("Communication error") # Verify the exception is propagated with pytest.raises(RoborockException, match="Communication error"): await command_trait.send(RoborockCommand.APP_START) Python-roborock-python-roborock-d6da2db/tests/devices/traits/v1/test_consumable.py000066400000000000000000000061771513363643200307200ustar00rootroot00000000000000"""Tests for the DoNotDisturbTrait class.""" from unittest.mock import AsyncMock, call import pytest from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.consumeable import ConsumableAttribute, ConsumableTrait from roborock.roborock_typing import RoborockCommand CONSUMABLE_DATA = [ { "main_brush_work_time": 879348, "side_brush_work_time": 707618, "filter_work_time": 738722, "filter_element_work_time": 0, "sensor_dirty_time": 455517, } ] @pytest.fixture def consumable_trait(device: RoborockDevice) -> ConsumableTrait: """Create a ConsumableTrait instance with mocked dependencies.""" assert device.v1_properties return device.v1_properties.consumables async def test_get_consumable_data_success(consumable_trait: ConsumableTrait, mock_rpc_channel: AsyncMock) -> None: """Test successfully getting consumable data.""" # Setup mock to return the sample consumable data mock_rpc_channel.send_command.return_value = CONSUMABLE_DATA # Call the method await consumable_trait.refresh() # Verify the result assert consumable_trait.main_brush_work_time == 879348 assert consumable_trait.side_brush_work_time == 707618 assert consumable_trait.filter_work_time == 738722 assert consumable_trait.filter_element_work_time == 0 assert consumable_trait.sensor_dirty_time == 455517 # Verify the RPC call was made correctly mock_rpc_channel.send_command.assert_called_once_with(RoborockCommand.GET_CONSUMABLE) @pytest.mark.parametrize( ("consumable", "reset_param"), [ (ConsumableAttribute.MAIN_BRUSH_WORK_TIME, "main_brush_work_time"), (ConsumableAttribute.SIDE_BRUSH_WORK_TIME, "side_brush_work_time"), (ConsumableAttribute.FILTER_WORK_TIME, "filter_work_time"), (ConsumableAttribute.SENSOR_DIRTY_TIME, "sensor_dirty_time"), ], ) async def test_reset_consumable_data( consumable_trait: ConsumableTrait, mock_rpc_channel: AsyncMock, consumable: ConsumableAttribute, reset_param: str, ) -> None: """Test successfully resetting consumable data.""" mock_rpc_channel.send_command.side_effect = [ {}, # Response for RESET_CONSUMABLE # Response for GET_CONSUMABLE after reset { "main_brush_work_time": 5555, "side_brush_work_time": 6666, "filter_work_time": 7777, "filter_element_work_time": 8888, "sensor_dirty_time": 9999, }, ] # Call the method await consumable_trait.reset_consumable(consumable) # Verify the RPC call was made correctly with expected parameters assert mock_rpc_channel.send_command.mock_calls == [ call(RoborockCommand.RESET_CONSUMABLE, params=[reset_param]), call(RoborockCommand.GET_CONSUMABLE), ] # Verify the consumable data was refreshed correctly assert consumable_trait.main_brush_work_time == 5555 assert consumable_trait.side_brush_work_time == 6666 assert consumable_trait.filter_work_time == 7777 assert consumable_trait.filter_element_work_time == 8888 assert consumable_trait.sensor_dirty_time == 9999 Python-roborock-python-roborock-d6da2db/tests/devices/traits/v1/test_device_features.py000066400000000000000000000021121513363643200317060ustar00rootroot00000000000000"""Tests for the DeviceFeaturesTrait related functionality.""" import pytest from syrupy import SnapshotAssertion from roborock.data import HomeDataDevice from roborock.data.v1.v1_containers import StatusField from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.status import StatusTrait from tests import mock_data V1_DEVICES = { k: HomeDataDevice.from_dict(device) for k, device in mock_data.DEVICES.items() if device.get("pv") == "1.0" } @pytest.mark.parametrize( ("device_info"), V1_DEVICES.values(), ids=list(V1_DEVICES.keys()), ) async def test_is_attribute_supported( device_info: HomeDataDevice, device: RoborockDevice, snapshot: SnapshotAssertion, ) -> None: """Test if a field is supported.""" assert device.v1_properties is not None assert device.v1_properties.device_features is not None device_features_trait = device.v1_properties.device_features is_supported = {field.value: device_features_trait.is_field_supported(StatusTrait, field) for field in StatusField} assert is_supported == snapshot Python-roborock-python-roborock-d6da2db/tests/devices/traits/v1/test_dnd.py000066400000000000000000000134331513363643200273260ustar00rootroot00000000000000"""Tests for the DoNotDisturbTrait class.""" from unittest.mock import AsyncMock, call import pytest from roborock.data import DnDTimer from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.do_not_disturb import DoNotDisturbTrait from roborock.exceptions import RoborockException from roborock.roborock_typing import RoborockCommand @pytest.fixture async def dnd_trait(device: RoborockDevice) -> DoNotDisturbTrait: """Create a DoNotDisturbTrait instance with mocked dependencies.""" assert device.v1_properties assert device.v1_properties.dnd return device.v1_properties.dnd @pytest.fixture def sample_dnd_timer() -> DnDTimer: """Create a sample DnDTimer for testing.""" return DnDTimer( start_hour=22, start_minute=0, end_hour=8, end_minute=0, enabled=1, ) async def test_get_dnd_timer_success( dnd_trait: DoNotDisturbTrait, mock_rpc_channel: AsyncMock, sample_dnd_timer: DnDTimer ) -> None: """Test successfully getting DnD timer settings.""" # Setup mock to return the sample DnD timer mock_rpc_channel.send_command.return_value = sample_dnd_timer.as_dict() # Call the method await dnd_trait.refresh() # Verify the result assert dnd_trait.start_hour == 22 assert dnd_trait.start_minute == 0 assert dnd_trait.end_hour == 8 assert dnd_trait.end_minute == 0 assert dnd_trait.enabled == 1 assert dnd_trait.is_on # Verify the RPC call was made correctly mock_rpc_channel.send_command.assert_called_once_with(RoborockCommand.GET_DND_TIMER) async def test_get_dnd_timer_disabled(dnd_trait: DoNotDisturbTrait, mock_rpc_channel: AsyncMock) -> None: """Test getting DnD timer when it's disabled.""" disabled_timer = DnDTimer( start_hour=22, start_minute=0, end_hour=8, end_minute=0, enabled=0, ) mock_rpc_channel.send_command.return_value = disabled_timer.as_dict() await dnd_trait.refresh() assert dnd_trait.enabled == 0 assert not dnd_trait.is_on mock_rpc_channel.send_command.assert_called_once_with(RoborockCommand.GET_DND_TIMER) async def test_set_dnd_timer_success( dnd_trait: DoNotDisturbTrait, mock_rpc_channel: AsyncMock, sample_dnd_timer: DnDTimer ) -> None: """Test successfully setting DnD timer settings.""" mock_rpc_channel.send_command.side_effect = [ # Response for SET_DND_TIMER {}, # Response for GET_DND_TIMER after updating { "startHour": 22, "startMinute": 0, "endHour": 8, "endMinute": 0, "enabled": 1, }, ] # Call the method await dnd_trait.set_dnd_timer(sample_dnd_timer) # Verify the RPC call was made correctly with dataclass converted to dict expected_params = [22, 0, 8, 0] assert mock_rpc_channel.send_command.mock_calls == [ call(RoborockCommand.SET_DND_TIMER, params=expected_params), call(RoborockCommand.GET_DND_TIMER), ] # Verify the trait state is updated assert dnd_trait.enabled == 1 assert dnd_trait.is_on assert dnd_trait.start_hour == 22 assert dnd_trait.start_minute == 0 assert dnd_trait.end_hour == 8 assert dnd_trait.end_minute == 0 async def test_clear_dnd_timer_success(dnd_trait: DoNotDisturbTrait, mock_rpc_channel: AsyncMock) -> None: """Test successfully clearing DnD timer settings.""" mock_rpc_channel.send_command.side_effect = [ # Response for CLOSE_DND_TIMER {}, # Response for GET_DND_TIMER after clearing { "startHour": 0, "startMinute": 0, "endHour": 0, "endMinute": 0, "enabled": 0, }, ] # Call the method await dnd_trait.clear_dnd_timer() # Verify the RPC call was made correctly assert mock_rpc_channel.send_command.mock_calls == [ call(RoborockCommand.CLOSE_DND_TIMER), call(RoborockCommand.GET_DND_TIMER), ] # Verify the trait state is updated assert dnd_trait.enabled == 0 assert not dnd_trait.is_on assert dnd_trait.start_hour == 0 assert dnd_trait.start_minute == 0 assert dnd_trait.end_hour == 0 assert dnd_trait.end_minute == 0 async def test_get_dnd_timer_propagates_exception(dnd_trait: DoNotDisturbTrait, mock_rpc_channel: AsyncMock) -> None: """Test that exceptions from RPC channel are propagated in get_dnd_timer.""" # Setup mock to raise an exception mock_rpc_channel.send_command.side_effect = RoborockException("Communication error") # Verify the exception is propagated with pytest.raises(RoborockException, match="Communication error"): await dnd_trait.refresh() async def test_set_dnd_timer_propagates_exception( dnd_trait: DoNotDisturbTrait, mock_rpc_channel: AsyncMock, sample_dnd_timer: DnDTimer ) -> None: """Test that exceptions from RPC channel are propagated in set_dnd_timer.""" from roborock.exceptions import RoborockException # Setup mock to raise an exception mock_rpc_channel.send_command.side_effect = RoborockException("Communication error") # Verify the exception is propagated with pytest.raises(RoborockException, match="Communication error"): await dnd_trait.set_dnd_timer(sample_dnd_timer) async def test_clear_dnd_timer_propagates_exception(dnd_trait: DoNotDisturbTrait, mock_rpc_channel: AsyncMock) -> None: """Test that exceptions from RPC channel are propagated in clear_dnd_timer.""" from roborock.exceptions import RoborockException # Setup mock to raise an exception mock_rpc_channel.send_command.side_effect = RoborockException("Communication error") # Verify the exception is propagated with pytest.raises(RoborockException, match="Communication error"): await dnd_trait.clear_dnd_timer() Python-roborock-python-roborock-d6da2db/tests/devices/traits/v1/test_dust_collection_mode.py000066400000000000000000000037511513363643200327610ustar00rootroot00000000000000"""Tests for the DustCollectionModeTrait class.""" from unittest.mock import AsyncMock, call import pytest from roborock.data import RoborockDockDustCollectionModeCode, RoborockDockTypeCode from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.dust_collection_mode import DustCollectionModeTrait from roborock.roborock_typing import RoborockCommand DUST_COLLECTION_MODE_DATA = [{"mode": 2}] @pytest.fixture(name="dust_collection_mode") def dust_collection_mode_trait( device: RoborockDevice, discover_features_fixture: None, ) -> DustCollectionModeTrait | None: """Create a DustCollectionModeTrait instance with mocked dependencies.""" assert device.v1_properties return device.v1_properties.dust_collection_mode @pytest.mark.parametrize( ("dock_type_code"), [ (RoborockDockTypeCode.s7_max_ultra_dock), (RoborockDockTypeCode.s8_dock), (RoborockDockTypeCode.p10_dock), (RoborockDockTypeCode.qrevo_s_dock), ], ) async def test_dust_collection_mode_available( dust_collection_mode: DustCollectionModeTrait | None, mock_rpc_channel: AsyncMock, dock_type_code: RoborockDockTypeCode, ) -> None: """Test successfully refreshing the dust collection mode.""" assert dust_collection_mode is not None mock_rpc_channel.send_command.side_effect = [ DUST_COLLECTION_MODE_DATA, ] await dust_collection_mode.refresh() mock_rpc_channel.send_command.assert_has_calls( [ call(RoborockCommand.GET_DUST_COLLECTION_MODE), ] ) assert dust_collection_mode.mode == RoborockDockDustCollectionModeCode.balanced @pytest.mark.parametrize(("dock_type_code"), [(RoborockDockTypeCode.no_dock)]) async def test_unsupported_dust_collection_mode( dust_collection_mode: DustCollectionModeTrait | None, dock_type_code: RoborockDockTypeCode, ) -> None: """Test that the trait is not available for unsupported dock types.""" assert dust_collection_mode is None Python-roborock-python-roborock-d6da2db/tests/devices/traits/v1/test_home.py000066400000000000000000000625301513363643200275130ustar00rootroot00000000000000"""Tests for the Home related functionality.""" import base64 from collections.abc import Iterator from unittest.mock import AsyncMock, MagicMock, patch import pytest from roborock.data.containers import CombinedMapInfo, NamedRoomMapping from roborock.data.v1.v1_code_mappings import RoborockStateCode from roborock.data.v1.v1_containers import MultiMapsListMapInfo, MultiMapsListRoom from roborock.devices.cache import DeviceCache, DeviceCacheData, InMemoryCache from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.home import HomeTrait from roborock.devices.traits.v1.map_content import MapContentTrait from roborock.devices.traits.v1.maps import MapsTrait from roborock.devices.traits.v1.rooms import RoomsTrait from roborock.devices.traits.v1.status import StatusTrait from roborock.exceptions import RoborockDeviceBusy, RoborockException, RoborockInvalidStatus from roborock.map.map_parser import ParsedMapData from roborock.roborock_typing import RoborockCommand from tests import mock_data MULTI_MAP_LIST_DATA = [ { "max_multi_map": 2, "max_bak_map": 1, "multi_map_count": 2, "map_info": [ { "mapFlag": 0, "add_time": 1747132930, "length": 0, "name": "Ground Floor", "bak_maps": [{"mapFlag": 4, "add_time": 1747132936}], }, { "mapFlag": 123, "add_time": 1747132940, "length": 0, "name": "Second Floor", "bak_maps": [{"mapFlag": 5, "add_time": 1747132946}], }, ], } ] MULTI_MAP_LIST_SINGLE_MAP_DATA = [ { "max_multi_map": 1, "max_bak_map": 0, "multi_map_count": 1, "map_info": [ { "mapFlag": 0, "add_time": 1747132930, "length": 0, "name": "Only Floor", "bak_maps": [], }, ], } ] ROOM_MAPPING_DATA_MAP_0 = [[16, "2362048"], [17, "2362044"]] ROOM_MAPPING_DATA_MAP_123 = [[18, "2362041"], [19, "2362042"]] UPDATED_STATUS_MAP_0 = { **mock_data.STATUS, "map_status": 0 * 4 + 3, # Set current map to 0 } UPDATED_STATUS_MAP_123 = { **mock_data.STATUS, "map_status": 123 * 4 + 3, # Set current map to 123 } MAP_BYTES_RESPONSE_1 = b"" MAP_BYTES_RESPONSE_2 = b"" TEST_IMAGE_CONTENT_1 = b"" TEST_IMAGE_CONTENT_2 = b"" TEST_PARSER_MAP = { MAP_BYTES_RESPONSE_1: TEST_IMAGE_CONTENT_1, MAP_BYTES_RESPONSE_2: TEST_IMAGE_CONTENT_2, } @pytest.fixture(autouse=True) def no_sleep() -> Iterator[None]: """Patch sleep to avoid delays in tests.""" with patch("roborock.devices.traits.v1.home.asyncio.sleep"): yield @pytest.fixture(name="cache") def cache_fixture(): """Create an in-memory cache for testing.""" return InMemoryCache() @pytest.fixture(name="device_cache") def device_cache_fixture(cache: InMemoryCache) -> DeviceCache: """Create a DeviceCache instance for testing.""" return DeviceCache(duid="abc123", cache=cache) @pytest.fixture(autouse=True) async def status_trait(mock_rpc_channel: AsyncMock, device: RoborockDevice) -> StatusTrait: """Create a StatusTrait instance with mocked dependencies.""" assert device.v1_properties status_trait = device.v1_properties.status # Verify initial state assert status_trait.current_map is None mock_rpc_channel.send_command.side_effect = [UPDATED_STATUS_MAP_0] await status_trait.refresh() assert status_trait.current_map == 0 mock_rpc_channel.reset_mock() return status_trait @pytest.fixture def maps_trait(device: RoborockDevice) -> MapsTrait: """Create a MapsTrait instance with mocked dependencies.""" assert device.v1_properties return device.v1_properties.maps @pytest.fixture def map_content_trait(device: RoborockDevice) -> MapContentTrait: """Create a MapContentTrait instance with mocked dependencies.""" assert device.v1_properties return device.v1_properties.map_content @pytest.fixture def rooms_trait(device: RoborockDevice) -> RoomsTrait: """Create a RoomsTrait instance with mocked dependencies.""" assert device.v1_properties return device.v1_properties.rooms @pytest.fixture def home_trait( status_trait: StatusTrait, maps_trait: MapsTrait, map_content_trait: MapContentTrait, rooms_trait: RoomsTrait, device_cache: DeviceCache, ) -> HomeTrait: """Create a HomeTrait instance with mocked dependencies.""" return HomeTrait(status_trait, maps_trait, map_content_trait, rooms_trait, device_cache) @pytest.fixture(autouse=True) def map_parser_fixture() -> Iterator[None]: """Mock MapParser.parse to return predefined test map data.""" def parse_data(response: bytes) -> ParsedMapData: if image_content := TEST_PARSER_MAP.get(response): return ParsedMapData( image_content=image_content, map_data=MagicMock(), ) raise ValueError(f"Unexpected map bytes {response!r}") with patch("roborock.devices.traits.v1.map_content.MapParser.parse", side_effect=parse_data): yield async def test_discover_home_empty_cache( status_trait: StatusTrait, home_trait: HomeTrait, mock_rpc_channel: AsyncMock, mock_mqtt_rpc_channel: AsyncMock, mock_map_rpc_channel: AsyncMock, device_cache: DeviceCache, ) -> None: """Test discovering home when cache is empty.""" # Setup mocks for the discovery process mock_rpc_channel.send_command.side_effect = [ UPDATED_STATUS_MAP_123, # Status after switching to map 123 ROOM_MAPPING_DATA_MAP_123, # Rooms for map 123 UPDATED_STATUS_MAP_0, # Status after switching back to map 0 ROOM_MAPPING_DATA_MAP_0, # Rooms for map 0 ] mock_mqtt_rpc_channel.send_command.side_effect = [ MULTI_MAP_LIST_DATA, # Multi maps list {}, # LOAD_MULTI_MAP response for map 123 {}, # LOAD_MULTI_MAP response back to map 0 ] mock_map_rpc_channel.send_command.side_effect = [ MAP_BYTES_RESPONSE_2, # Map bytes for 123 MAP_BYTES_RESPONSE_1, # Map bytes for 0 ] # Before discovery, no cache should exist assert home_trait.home_map_info is None assert home_trait.current_map_data is None # Perform home discovery await home_trait.discover_home() # Verify cache is populated assert home_trait.home_map_info is not None assert len(home_trait.home_map_info) == 2 # Check map 0 data map_0_data = home_trait.home_map_info[0] assert map_0_data.map_flag == 0 assert map_0_data.name == "Ground Floor" assert len(map_0_data.rooms) == 2 assert map_0_data.rooms[0].segment_id == 16 assert map_0_data.rooms[0].name == "Example room 1" assert map_0_data.rooms[1].segment_id == 17 assert map_0_data.rooms[1].name == "Example room 2" map_0_content = home_trait.home_map_content[0] assert map_0_content is not None assert map_0_content.image_content == TEST_IMAGE_CONTENT_1 assert map_0_content.map_data is not None # Check map 123 data map_123_data = home_trait.home_map_info[123] assert map_123_data.map_flag == 123 assert map_123_data.name == "Second Floor" assert len(map_123_data.rooms) == 2 assert map_123_data.rooms[0].segment_id == 18 assert map_123_data.rooms[0].name == "Example room 3" assert map_123_data.rooms[1].segment_id == 19 assert map_123_data.rooms[1].name == "Unknown" # Not in mock home data map_123_content = home_trait.home_map_content[123] assert map_123_content is not None assert map_123_content.image_content == TEST_IMAGE_CONTENT_2 assert map_123_content.map_data is not None # Verify current map data is accessible current_map_data = home_trait.current_map_data assert current_map_data is not None assert current_map_data.map_flag == 0 assert current_map_data.name == "Ground Floor" # Verify the persistent cache has been updated device_cache_data = await device_cache.get() assert device_cache_data.home_map_info is not None assert len(device_cache_data.home_map_info) == 2 assert device_cache_data.home_map_content_base64 is not None assert len(device_cache_data.home_map_content_base64) == 2 @pytest.mark.parametrize( "device_cache_data", [ DeviceCacheData( home_map_info={0: CombinedMapInfo(map_flag=0, name="Dummy", rooms=[])}, home_map_content_base64={0: base64.b64encode(MAP_BYTES_RESPONSE_1).decode("utf-8")}, ), ], ) async def test_discover_home_with_existing_cache( home_trait: HomeTrait, mock_rpc_channel: AsyncMock, mock_mqtt_rpc_channel: AsyncMock, device_cache_data: DeviceCacheData, device_cache: DeviceCache, ) -> None: """Test that discovery is skipped when cache already exists.""" # Pre-populate the cache await device_cache.set(device_cache_data) # Call discover_home await home_trait.discover_home() # Verify no RPC calls were made (discovery was skipped) assert mock_rpc_channel.send_command.call_count == 0 assert mock_mqtt_rpc_channel.send_command.call_count == 0 # Verify cache was loaded from storage assert home_trait.home_map_info == {0: CombinedMapInfo(map_flag=0, name="Dummy", rooms=[])} assert home_trait.home_map_content assert home_trait.home_map_content.keys() == {0} map_0_content = home_trait.home_map_content[0] assert map_0_content is not None assert map_0_content.image_content == TEST_IMAGE_CONTENT_1 assert map_0_content.map_data is not None async def test_existing_home_cache_invalid_bytes( home_trait: HomeTrait, device_cache: DeviceCache, mock_rpc_channel: AsyncMock, mock_mqtt_rpc_channel: AsyncMock, mock_map_rpc_channel: AsyncMock, ) -> None: """Test that discovery is skipped when cache already exists.""" # Pre-populate the cache. cache_data = await device_cache.get() cache_data.home_map_info = {0: CombinedMapInfo(map_flag=0, name="Dummy", rooms=[])} # We override the map bytes parser to raise an exception above. cache_data.home_map_content_base64 = {0: base64.b64encode(MAP_BYTES_RESPONSE_1).decode("utf-8")} await device_cache.set(cache_data) # Setup mocks for the discovery process mock_rpc_channel.send_command.side_effect = [ ROOM_MAPPING_DATA_MAP_0, # Rooms for the single map ] mock_mqtt_rpc_channel.send_command.side_effect = [ MULTI_MAP_LIST_SINGLE_MAP_DATA, # Single map list ] mock_map_rpc_channel.send_command.side_effect = [ MAP_BYTES_RESPONSE_1, # Map bytes for the single map ] # Call discover_home. First attempt raises an exception then loading from the server # produes a valid result. with patch( "roborock.devices.traits.v1.map_content.MapParser.parse", side_effect=[ RoborockException("Invalid map bytes"), ParsedMapData( image_content=TEST_IMAGE_CONTENT_2, map_data=MagicMock(), ), ], ): await home_trait.discover_home() # Verify cache was loaded from storage. The map was re-fetched from storage. assert home_trait.home_map_info assert home_trait.home_map_info.keys() == {0} assert home_trait.home_map_info[0].name == "Only Floor" assert home_trait.home_map_content assert home_trait.home_map_content.keys() == {0} map_0_content = home_trait.home_map_content[0] assert map_0_content is not None assert map_0_content.image_content == TEST_IMAGE_CONTENT_2 assert map_0_content.map_data is not None async def test_discover_home_no_maps( home_trait: HomeTrait, mock_rpc_channel: AsyncMock, mock_mqtt_rpc_channel: AsyncMock, ) -> None: """Test discovery when no maps are available.""" # Setup mock to return empty maps list mock_mqtt_rpc_channel.send_command.side_effect = [ [{"max_multi_map": 0, "max_bak_map": 0, "multi_map_count": 0, "map_info": []}] ] with pytest.raises(Exception, match="Cannot perform home discovery without current map info"): await home_trait.discover_home() async def test_refresh_updates_current_map_cache( device_cache: DeviceCache, status_trait: StatusTrait, home_trait: HomeTrait, mock_rpc_channel: AsyncMock, mock_mqtt_rpc_channel: AsyncMock, mock_map_rpc_channel: AsyncMock, ) -> None: """Test that refresh updates the cache for the current map.""" # Pre-populate cache with some data cache_data = await device_cache.get() cache_data.home_map_info = {0: CombinedMapInfo(map_flag=0, name="Old Ground Floor", rooms=[])} cache_data.home_map_content_base64 = { 0: base64.b64encode(MAP_BYTES_RESPONSE_2).decode() } # Pre-existing different map bytes await device_cache.set(cache_data) await home_trait.discover_home() # Load cache into trait # Verify initial cache state assert home_trait.home_map_info assert home_trait.home_map_info.keys() == {0} assert home_trait.home_map_info[0].name == "Old Ground Floor" assert len(home_trait.home_map_info[0].rooms) == 0 assert home_trait.home_map_content assert home_trait.home_map_content.keys() == {0} assert home_trait.home_map_content[0].image_content == TEST_IMAGE_CONTENT_2 # Setup mocks for refresh mock_rpc_channel.send_command.side_effect = [ ROOM_MAPPING_DATA_MAP_0, # Room mapping refresh ] mock_mqtt_rpc_channel.send_command.side_effect = [ MULTI_MAP_LIST_DATA, # Maps refresh ] mock_map_rpc_channel.send_command.side_effect = [ MAP_BYTES_RESPONSE_1, # Map bytes refresh ] # Perform refresh await home_trait.refresh() # Verify cache was updated for current map assert home_trait.home_map_info assert home_trait.home_map_info.keys() == {0} assert home_trait.home_map_info[0].name == "Ground Floor" assert len(home_trait.home_map_info[0].rooms) == 2 # Verify map content assert home_trait.home_map_content assert home_trait.home_map_content.keys() == {0} assert home_trait.home_map_content[0].image_content == TEST_IMAGE_CONTENT_1 async def test_current_map_data_property( home_trait: HomeTrait, mock_rpc_channel: AsyncMock, mock_mqtt_rpc_channel: AsyncMock, mock_map_rpc_channel: AsyncMock, ) -> None: """Test current_map_data property returns correct data.""" # Setup discovery mock_rpc_channel.send_command.side_effect = [ UPDATED_STATUS_MAP_123, # Status after switching to map 123 ROOM_MAPPING_DATA_MAP_123, # Rooms for map 123 UPDATED_STATUS_MAP_0, # Status after switching back to map 0 ROOM_MAPPING_DATA_MAP_0, # Rooms for map 0 ] mock_mqtt_rpc_channel.send_command.side_effect = [ MULTI_MAP_LIST_DATA, # Multi maps list {}, # LOAD_MULTI_MAP response for map 123 {}, # LOAD_MULTI_MAP response back to map 0 ] mock_map_rpc_channel.send_command.side_effect = [ MAP_BYTES_RESPONSE_2, # Map bytes for 123 MAP_BYTES_RESPONSE_1, # Map bytes for 0 ] await home_trait.discover_home() # Test current map data (should be map 0) current_data = home_trait.current_map_data assert current_data is not None assert current_data.map_flag == 0 assert current_data.name == "Ground Floor" # Test when no cache exists home_trait._home_map_info = None assert home_trait.current_map_data is None async def test_discover_home_device_busy_cleaning( status_trait: StatusTrait, home_trait: HomeTrait, mock_rpc_channel: AsyncMock, mock_mqtt_rpc_channel: AsyncMock, mock_map_rpc_channel: AsyncMock, device_cache: DeviceCache, ) -> None: """Test that discovery raises RoborockDeviceBusy when device is cleaning. This tests the initial failure scenario during discovery where the device is busy, then a retry attempt where the device is still busy, then finally a successful attempt to run discovery when the device is idle. """ # Set the status trait state to cleaning status_trait.state = RoborockStateCode.cleaning # Attempt to discover home while cleaning with pytest.raises(RoborockDeviceBusy, match="Cannot perform home discovery while the device is cleaning"): await home_trait.discover_home() # Verify no RPC calls were made (discovery was prevented) assert mock_rpc_channel.send_command.call_count == 0 assert mock_mqtt_rpc_channel.send_command.call_count == 0 # Setup mocks for refresh mock_rpc_channel.send_command.side_effect = [ ROOM_MAPPING_DATA_MAP_0, # Room mapping refresh ] mock_mqtt_rpc_channel.send_command.side_effect = [ MULTI_MAP_LIST_DATA, # Maps refresh ] mock_map_rpc_channel.send_command.side_effect = [ MAP_BYTES_RESPONSE_1, # Map bytes refresh ] # Now attempt to refresh the device while cleaning. This should still fail # home discovery but allow refresh to update the current map. await home_trait.refresh() # Verify the home information is now populated current_data = home_trait.current_map_data assert current_data is not None assert current_data.map_flag == 0 assert current_data.name == "Ground Floor" assert home_trait.home_map_info is not None assert home_trait.home_map_info.keys() == {0} assert home_trait.home_map_content is not None assert home_trait.home_map_content.keys() == {0} map_0_content = home_trait.home_map_content[0] assert map_0_content is not None assert map_0_content.image_content == TEST_IMAGE_CONTENT_1 assert map_0_content.map_data is not None # Verify the persistent cache has not been updated since discovery # has not fully completed. cache_data = await device_cache.get() assert not cache_data.home_map_info assert not cache_data.home_map_content_base64 # Set the status trait state to idle which will mean we can attempt discovery # on the next refresh. This should have the result of updating the # persistent cache which is verified below. status_trait.state = RoborockStateCode.idle # Setup mocks for the discovery process mock_rpc_channel.send_command.side_effect = [ UPDATED_STATUS_MAP_123, # Status after switching to map 123 ROOM_MAPPING_DATA_MAP_123, # Rooms for map 123 UPDATED_STATUS_MAP_0, # Status after switching back to map 0 ROOM_MAPPING_DATA_MAP_0, # Rooms for map 0 ] mock_mqtt_rpc_channel.send_command.side_effect = [ MULTI_MAP_LIST_DATA, # Multi maps list {}, # LOAD_MULTI_MAP response for map 123 {}, # LOAD_MULTI_MAP response back to map 0 ] mock_map_rpc_channel.send_command.side_effect = [ MAP_BYTES_RESPONSE_2, # Map bytes for 123 MAP_BYTES_RESPONSE_1, # Map bytes for 0 ] # Refreshing should now perform discovery successfully await home_trait.refresh() # Verify we now have all of the information populated from discovery assert home_trait.home_map_info is not None assert home_trait.home_map_info.keys() == {0, 123} assert home_trait.home_map_content is not None assert home_trait.home_map_content.keys() == {0, 123} # Verify the persistent cache has been updated device_cache_data = await device_cache.get() assert device_cache_data.home_map_info is not None assert len(device_cache_data.home_map_info) == 2 assert device_cache_data.home_map_content_base64 is not None assert len(device_cache_data.home_map_content_base64) == 2 async def test_refresh_falls_back_when_map_switch_action_locked( status_trait: StatusTrait, home_trait: HomeTrait, mock_rpc_channel: AsyncMock, mock_mqtt_rpc_channel: AsyncMock, mock_map_rpc_channel: AsyncMock, device_cache: DeviceCache, ) -> None: """Test that refresh falls back to current map when map switching is locked.""" # Discovery attempt: we can list maps, but switching maps fails with -10007. mock_mqtt_rpc_channel.send_command.side_effect = [ MULTI_MAP_LIST_DATA, # Maps refresh during discover_home() RoborockInvalidStatus({"code": -10007, "message": "invalid status"}), # LOAD_MULTI_MAP action locked MULTI_MAP_LIST_DATA, # Maps refresh during refresh() fallback ] # Fallback refresh should still be able to refresh the current map. mock_rpc_channel.send_command.side_effect = [ ROOM_MAPPING_DATA_MAP_0, # Rooms for current map ] mock_map_rpc_channel.send_command.side_effect = [ MAP_BYTES_RESPONSE_1, # Map bytes for current map ] await home_trait.refresh() current_data = home_trait.current_map_data assert current_data is not None assert current_data.map_flag == 0 assert current_data.name == "Ground Floor" assert home_trait.home_map_info is not None assert home_trait.home_map_info.keys() == {0} assert home_trait.home_map_content is not None assert home_trait.home_map_content.keys() == {0} map_0_content = home_trait.home_map_content[0] assert map_0_content.image_content == TEST_IMAGE_CONTENT_1 # Discovery did not complete, so the persistent cache should not be updated. cache_data = await device_cache.get() assert not cache_data.home_map_info assert not cache_data.home_map_content_base64 async def test_single_map_no_switching( home_trait: HomeTrait, mock_rpc_channel: AsyncMock, mock_mqtt_rpc_channel: AsyncMock, mock_map_rpc_channel: AsyncMock, ) -> None: """Test that single map discovery doesn't trigger map switching.""" mock_rpc_channel.send_command.side_effect = [ ROOM_MAPPING_DATA_MAP_0, # Rooms for the single map ] mock_mqtt_rpc_channel.send_command.side_effect = [ MULTI_MAP_LIST_SINGLE_MAP_DATA, # Single map list ] mock_map_rpc_channel.send_command.side_effect = [ MAP_BYTES_RESPONSE_1, # Map bytes for the single map ] await home_trait.discover_home() # Verify cache is populated assert home_trait.home_map_info is not None assert home_trait.home_map_info.keys() == {0} assert home_trait.home_map_content is not None assert home_trait.home_map_content.keys() == {0} # Verify no LOAD_MULTI_MAP commands were sent (no map switching) load_map_calls = [ call for call in mock_mqtt_rpc_channel.send_command.call_args_list if call[1].get("command") == RoborockCommand.LOAD_MULTI_MAP ] assert len(load_map_calls) == 0 async def test_refresh_map_info_room_override_and_addition_logic( home_trait: HomeTrait, rooms_trait: RoomsTrait, ) -> None: """Test the room override and addition logic in _refresh_map_info. This test verifies: 1. Room with "Unknown" does not override existing room from map_info 2. Room with valid name overrides existing room from map_info 3. Room with "Unknown" is added if not already in rooms 4. Room with valid name is added if not already in rooms """ map_info = MultiMapsListMapInfo( map_flag=0, name="Test Map", rooms=[ MultiMapsListRoom( id=16, iot_name_id="2362048", iot_name="Kitchen from map_info", ), MultiMapsListRoom( id=19, iot_name_id="2362042", iot_name="Bedroom from map_info", ), ], ) # Mock rooms_trait to return multiple rooms covering all scenarios: # - segment_id 16 with "Unknown": exists in map_info, should NOT override # - segment_id 19 with valid name: exists in map_info, should override # - segment_id 17 with "Unknown": not in map_info, should be added # - segment_id 18 with valid name: not in map_info, should be added rooms_trait.rooms = [ NamedRoomMapping(segment_id=16, iot_id="2362048", name="Unknown"), # Exists in map_info, should not override NamedRoomMapping( segment_id=19, iot_id="2362042", name="Updated Bedroom Name" ), # Exists in map_info, should override NamedRoomMapping(segment_id=17, iot_id="2362044", name="Unknown"), # Not in map_info, should be added NamedRoomMapping(segment_id=18, iot_id="2362041", name="Example room 3"), # Not in map_info, should be added ] # Mock rooms_trait.refresh to prevent actual device calls with patch.object(rooms_trait, "refresh", new_callable=AsyncMock): result = await home_trait._refresh_map_info(map_info) assert result.map_flag == 0 assert result.name == "Test Map" assert len(result.rooms) == 4 # Sort rooms by segment_id for consistent assertions sorted_rooms = sorted(result.rooms, key=lambda r: r.segment_id) # Room 16: from map_info, kept (not overridden by Unknown) assert sorted_rooms[0].segment_id == 16 assert sorted_rooms[0].name == "Kitchen from map_info" assert sorted_rooms[0].iot_id == "2362048" # Room 17: from rooms_trait with "Unknown", added because not in map_info assert sorted_rooms[1].segment_id == 17 assert sorted_rooms[1].name == "Unknown" assert sorted_rooms[1].iot_id == "2362044" # Room 18: from rooms_trait with valid name, added because not in map_info assert sorted_rooms[2].segment_id == 18 assert sorted_rooms[2].name == "Example room 3" assert sorted_rooms[2].iot_id == "2362041" # Room 19: from map_info, overridden by rooms_trait with valid name assert sorted_rooms[3].segment_id == 19 assert sorted_rooms[3].name == "Updated Bedroom Name" assert sorted_rooms[3].iot_id == "2362042" Python-roborock-python-roborock-d6da2db/tests/devices/traits/v1/test_map_content.py000066400000000000000000000025621513363643200310710ustar00rootroot00000000000000"""Tests for the MapContentTrait.""" from unittest.mock import AsyncMock, MagicMock, patch import pytest from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.map_content import MapContentTrait from roborock.map.map_parser import ParsedMapData from roborock.roborock_typing import RoborockCommand @pytest.fixture def map_content_trait(device: RoborockDevice) -> MapContentTrait: """Create a MapContentTrait instance with mocked dependencies.""" assert device.v1_properties return device.v1_properties.map_content async def test_refresh_map_content_trait( map_content_trait: MapContentTrait, mock_map_rpc_channel: AsyncMock, ) -> None: """Test successfully getting and parsing map content.""" map_data = b"dummy_map_bytes" mock_map_rpc_channel.send_command.return_value = map_data mock_parsed_data = ParsedMapData( image_content=b"dummy_image_content", map_data=MagicMock(), ) with patch("roborock.devices.traits.v1.map_content.MapParser.parse", return_value=mock_parsed_data) as mock_parse: await map_content_trait.refresh() mock_parse.assert_called_once_with(map_data) assert map_content_trait.image_content == b"dummy_image_content" assert map_content_trait.map_data is not None mock_map_rpc_channel.send_command.assert_called_once_with(RoborockCommand.GET_MAP_V1) Python-roborock-python-roborock-d6da2db/tests/devices/traits/v1/test_maps.py000066400000000000000000000121621513363643200275170ustar00rootroot00000000000000"""Tests for the Maps related functionality.""" from unittest.mock import AsyncMock import pytest from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.maps import MapsTrait from roborock.devices.traits.v1.status import StatusTrait from roborock.roborock_typing import RoborockCommand from tests import mock_data UPDATED_STATUS = { **mock_data.STATUS, "map_status": 123 * 4 + 3, # Set current map to 123 } MULTI_MAP_LIST_DATA = [ { "max_multi_map": 1, "max_bak_map": 1, "multi_map_count": 1, "map_info": [ { "mapFlag": 0, "add_time": 1747132930, "length": 0, "name": "Map 1", "bak_maps": [{"mapFlag": 4, "add_time": 1747132936}], }, { "mapFlag": 123, "add_time": 1747132930, "length": 0, "name": "Map 2", "bak_maps": [{"mapFlag": 4, "add_time": 1747132936}], }, ], } ] @pytest.fixture def status_trait(device: RoborockDevice) -> StatusTrait: """Create a MapsTrait instance with mocked dependencies.""" assert device.v1_properties return device.v1_properties.status @pytest.fixture def maps_trait(device: RoborockDevice) -> MapsTrait: """Create a MapsTrait instance with mocked dependencies.""" assert device.v1_properties return device.v1_properties.maps async def test_refresh_maps_trait( maps_trait: MapsTrait, mock_rpc_channel: AsyncMock, mock_mqtt_rpc_channel: AsyncMock, status_trait: StatusTrait, ) -> None: """Test successfully getting multi maps list.""" # Setup mock to return the sample multi maps list mock_rpc_channel.send_command.side_effect = [ mock_data.STATUS, # Initial status fetch ] mock_mqtt_rpc_channel.send_command.side_effect = [ MULTI_MAP_LIST_DATA, ] await status_trait.refresh() assert status_trait.current_map == 0 # Populating the status information gives us the current map # flag, but we have not loaded the rest of the information. assert maps_trait.current_map == 0 assert maps_trait.current_map_info is None # Load the maps information await maps_trait.refresh() assert maps_trait.max_multi_map == 1 assert maps_trait.max_bak_map == 1 assert maps_trait.multi_map_count == 1 assert maps_trait.map_info assert len(maps_trait.map_info) == 2 map_infos = maps_trait.map_info assert len(map_infos) == 2 assert map_infos[0].map_flag == 0 assert map_infos[0].name == "Map 1" assert map_infos[0].add_time == 1747132930 assert map_infos[1].map_flag == 123 assert map_infos[1].name == "Map 2" assert map_infos[1].add_time == 1747132930 assert maps_trait.current_map == 0 assert maps_trait.current_map_info is not None assert maps_trait.current_map_info.map_flag == 0 assert maps_trait.current_map_info.name == "Map 1" # Verify the RPC call was made correctly assert mock_rpc_channel.send_command.call_count == 1 mock_rpc_channel.send_command.assert_any_call(RoborockCommand.GET_STATUS) assert mock_mqtt_rpc_channel.send_command.call_count == 1 mock_mqtt_rpc_channel.send_command.assert_any_call(RoborockCommand.GET_MULTI_MAPS_LIST) async def test_set_current_map( status_trait: StatusTrait, maps_trait: MapsTrait, mock_rpc_channel: AsyncMock, mock_mqtt_rpc_channel: AsyncMock, ) -> None: """Test successfully setting the current map.""" assert hasattr(maps_trait, "mqtt_rpc_channel") mock_rpc_channel.send_command.side_effect = [ mock_data.STATUS, # Initial status fetch UPDATED_STATUS, # Response for refreshing status ] mock_mqtt_rpc_channel.send_command.side_effect = [ MULTI_MAP_LIST_DATA, # Response for LOAD_MULTI_MAP {}, # Response for setting the current map ] await status_trait.refresh() # First refresh to populate initial state await maps_trait.refresh() # Verify current map assert maps_trait.current_map == 0 assert maps_trait.current_map_info assert maps_trait.current_map_info.map_flag == 0 assert maps_trait.current_map_info.name == "Map 1" # Call the method to set current map await maps_trait.set_current_map(123) # Verify the current map is updated assert maps_trait.current_map == 123 assert maps_trait.current_map_info assert maps_trait.current_map_info.map_flag == 123 assert maps_trait.current_map_info.name == "Map 2" # Verify the command sent are: # 1. GET_STATUS to get initial status # 2. GET_MULTI_MAPS_LIST to get the map list # 3. LOAD_MULTI_MAP to set the map # 4. GET_STATUS to refresh the current map in status assert mock_rpc_channel.send_command.call_count == 2 mock_rpc_channel.send_command.assert_any_call(RoborockCommand.GET_STATUS) assert mock_mqtt_rpc_channel.send_command.call_count == 2 mock_mqtt_rpc_channel.send_command.assert_any_call(RoborockCommand.GET_MULTI_MAPS_LIST) mock_mqtt_rpc_channel.send_command.assert_any_call(RoborockCommand.LOAD_MULTI_MAP, params=[123]) Python-roborock-python-roborock-d6da2db/tests/devices/traits/v1/test_network_info.py000066400000000000000000000043761513363643200312730ustar00rootroot00000000000000"""Tests for the NetworkInfoTrait class.""" from unittest.mock import AsyncMock import pytest from roborock.data import NetworkInfo from roborock.devices.cache import Cache, DeviceCache from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.network_info import NetworkInfoTrait from roborock.roborock_typing import RoborockCommand from tests.mock_data import NETWORK_INFO DEVICE_UID = "abc123" @pytest.fixture def network_info_trait(device: RoborockDevice) -> NetworkInfoTrait: """Create a NetworkInfoTrait instance with mocked dependencies.""" assert device.v1_properties return device.v1_properties.network_info async def test_network_info_from_cache( network_info_trait: NetworkInfoTrait, roborock_cache: Cache, mock_rpc_channel: AsyncMock ) -> None: """Test that network info is read from the cache.""" device_cache = DeviceCache(DEVICE_UID, roborock_cache) device_cache_data = await device_cache.get() device_cache_data.network_info = NetworkInfo.from_dict(NETWORK_INFO) await device_cache.set(device_cache_data) await network_info_trait.refresh() assert network_info_trait.ip == "1.1.1.1" assert network_info_trait.mac == "aa:bb:cc:dd:ee:ff" assert network_info_trait.bssid == "aa:bb:cc:dd:ee:ff" assert network_info_trait.rssi == -50 mock_rpc_channel.send_command.assert_not_called() async def test_network_info_from_device( network_info_trait: NetworkInfoTrait, roborock_cache: Cache, mock_rpc_channel: AsyncMock ) -> None: """Test that network info is fetched from the device when not in cache.""" mock_rpc_channel.send_command.return_value = { **NETWORK_INFO, "ip": "2.2.2.2", } await network_info_trait.refresh() assert network_info_trait.ip == "2.2.2.2" assert network_info_trait.mac == "aa:bb:cc:dd:ee:ff" assert network_info_trait.bssid == "aa:bb:cc:dd:ee:ff" assert network_info_trait.rssi == -50 mock_rpc_channel.send_command.assert_called_once_with(RoborockCommand.GET_NETWORK_INFO) # Verify it's now in the cache device_cache = DeviceCache(DEVICE_UID, roborock_cache) device_cache_data = await device_cache.get() assert device_cache_data.network_info assert device_cache_data.network_info.ip == "2.2.2.2" Python-roborock-python-roborock-d6da2db/tests/devices/traits/v1/test_rooms.py000066400000000000000000000043721513363643200277220ustar00rootroot00000000000000"""Tests for the RoomMapping related functionality.""" from typing import Any from unittest.mock import AsyncMock import pytest from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.rooms import RoomsTrait from roborock.devices.traits.v1.status import StatusTrait from roborock.roborock_typing import RoborockCommand @pytest.fixture def status_trait(device: RoborockDevice) -> StatusTrait: """Create a StatusTrait instance with mocked dependencies.""" assert device.v1_properties return device.v1_properties.status @pytest.fixture def rooms_trait(device: RoborockDevice) -> RoomsTrait: """Create a RoomsTrait instance with mocked dependencies.""" assert device.v1_properties return device.v1_properties.rooms # Rooms from mock_data.HOME_DATA # {"id": 2362048, "name": "Example room 1"}, # {"id": 2362044, "name": "Example room 2"}, # {"id": 2362041, "name": "Example room 3"}, @pytest.mark.parametrize( ("room_mapping_data"), [ ([[16, "2362048"], [17, "2362044"], [18, "2362041"]]), ([[16, "2362048", 6], [17, "2362044", 14], [18, "2362041", 13]]), ], ) async def test_refresh_rooms_trait( rooms_trait: RoomsTrait, mock_rpc_channel: AsyncMock, room_mapping_data: list[Any], ) -> None: """Test successfully getting room mapping.""" # Setup mock to return the sample room mapping mock_rpc_channel.send_command.side_effect = [room_mapping_data] # Before refresh, rooms should be empty assert not rooms_trait.rooms # Load the room mapping information await rooms_trait.refresh() # Verify the room mappings are now populated assert rooms_trait.rooms rooms = rooms_trait.rooms assert len(rooms) == 3 assert rooms[0].segment_id == 16 assert rooms[0].name == "Example room 1" assert rooms[0].iot_id == "2362048" assert rooms[1].segment_id == 17 assert rooms[1].name == "Example room 2" assert rooms[1].iot_id == "2362044" assert rooms[2].segment_id == 18 assert rooms[2].name == "Example room 3" assert rooms[2].iot_id == "2362041" # Verify the RPC call was made correctly assert mock_rpc_channel.send_command.call_count == 1 mock_rpc_channel.send_command.assert_any_call(RoborockCommand.GET_ROOM_MAPPING) Python-roborock-python-roborock-d6da2db/tests/devices/traits/v1/test_routines.py000066400000000000000000000021331513363643200304240ustar00rootroot00000000000000"""Tests for the RoutinesTrait.""" from unittest.mock import AsyncMock import pytest from roborock.data.containers import HomeDataScene from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.routines import RoutinesTrait @pytest.fixture(name="routines_trait") def routines_trait_fixture(device: RoborockDevice) -> RoutinesTrait: """Fixture for the routines trait.""" assert device.v1_properties return device.v1_properties.routines async def test_get_routines(routines_trait: RoutinesTrait, web_api_client: AsyncMock) -> None: """Test getting routines.""" web_api_client.get_routines.return_value = [HomeDataScene(id=1, name="test_scene")] routines = await routines_trait.get_routines() assert len(routines) == 1 assert routines[0].name == "test_scene" web_api_client.get_routines.assert_called_once() async def test_execute_routine(routines_trait: RoutinesTrait, web_api_client: AsyncMock) -> None: """Test executing a routine.""" await routines_trait.execute_routine(1) web_api_client.execute_routine.assert_called_once_with(1) Python-roborock-python-roborock-d6da2db/tests/devices/traits/v1/test_smart_wash_params.py000066400000000000000000000042171513363643200322740ustar00rootroot00000000000000"""Tests for the DockSummaryTrait class.""" from unittest.mock import AsyncMock, call import pytest from roborock.data.v1.v1_code_mappings import ( RoborockDockTypeCode, ) from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.smart_wash_params import SmartWashParamsTrait from roborock.roborock_typing import RoborockCommand SMART_WASH_DATA = [{"smart_wash": 5, "wash_interval": 6}] @pytest.fixture(name="smart_wash_params") def smart_wash_params_trait( device: RoborockDevice, discover_features_fixture: None, ) -> SmartWashParamsTrait | None: """Create a SmartWashParamsTrait instance with mocked dependencies.""" assert device.v1_properties return device.v1_properties.smart_wash_params @pytest.mark.parametrize( ("dock_type_code"), [ (RoborockDockTypeCode.s8_dock), (RoborockDockTypeCode.p10_dock), (RoborockDockTypeCode.qrevo_s_dock), ], ) async def test_smart_wash_available( smart_wash_params: SmartWashParamsTrait | None, mock_rpc_channel: AsyncMock, dock_type_code: RoborockDockTypeCode, ) -> None: """Test successfully refreshing the smart wash params.""" assert smart_wash_params is not None # Setup mock to return the sample clean summary and clean record mock_rpc_channel.send_command.side_effect = [ SMART_WASH_DATA, ] # Call the method await smart_wash_params.refresh() # Verify the RPC calls were made correctly mock_rpc_channel.send_command.assert_has_calls( [ call(RoborockCommand.GET_SMART_WASH_PARAMS), ] ) # Verify the summary object contains the traits assert smart_wash_params.smart_wash == 5 assert smart_wash_params.wash_interval == 6 @pytest.mark.parametrize( ("dock_type_code"), [ (RoborockDockTypeCode.s7_max_ultra_dock), # Not in WASH_N_FILL_DOCK_TYPES (RoborockDockTypeCode.no_dock), ], ) async def test_unsupported_smart_wash_params( smart_wash_params: SmartWashParamsTrait | None, dock_type_code: RoborockDockTypeCode ) -> None: """Test successfully refreshing the dock summary.""" assert smart_wash_params is None Python-roborock-python-roborock-d6da2db/tests/devices/traits/v1/test_wash_towel_mode.py000066400000000000000000000036571513363643200317500ustar00rootroot00000000000000"""Tests for the WashTowelModeTrait class.""" from unittest.mock import AsyncMock, call import pytest from roborock.data import RoborockDockTypeCode, RoborockDockWashTowelModeCode from roborock.devices.device import RoborockDevice from roborock.devices.traits.v1.wash_towel_mode import WashTowelModeTrait from roborock.roborock_typing import RoborockCommand WASH_TOWEL_MODE_DATA = [{"wash_mode": RoborockDockWashTowelModeCode.smart}] @pytest.fixture(name="wash_towel_mode") def wash_towel_mode_trait( device: RoborockDevice, discover_features_fixture: None, ) -> WashTowelModeTrait | None: """Create a WashTowelModeTrait instance with mocked dependencies.""" assert device.v1_properties return device.v1_properties.wash_towel_mode @pytest.mark.parametrize( ("dock_type_code"), [ (RoborockDockTypeCode.s8_dock), (RoborockDockTypeCode.p10_dock), (RoborockDockTypeCode.qrevo_s_dock), ], ) async def test_wash_towel_mode_available( wash_towel_mode: WashTowelModeTrait | None, mock_rpc_channel: AsyncMock, dock_type_code: RoborockDockTypeCode, ) -> None: """Test successfully refreshing the wash towel mode.""" assert wash_towel_mode is not None mock_rpc_channel.send_command.side_effect = [ WASH_TOWEL_MODE_DATA, ] await wash_towel_mode.refresh() mock_rpc_channel.send_command.assert_has_calls( [ call(RoborockCommand.GET_WASH_TOWEL_MODE), ] ) assert wash_towel_mode.wash_mode == RoborockDockWashTowelModeCode.smart @pytest.mark.parametrize( ("dock_type_code"), [ (RoborockDockTypeCode.s7_max_ultra_dock), (RoborockDockTypeCode.no_dock), ], ) async def test_unsupported_wash_towel_mode( wash_towel_mode: WashTowelModeTrait | None, dock_type_code: RoborockDockTypeCode ) -> None: """Test that the trait is not available for unsupported dock types.""" assert wash_towel_mode is None Python-roborock-python-roborock-d6da2db/tests/devices/transport/000077500000000000000000000000001513363643200253445ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/devices/transport/test_local_channel.py000066400000000000000000000423451513363643200315470ustar00rootroot00000000000000"""Tests for the LocalChannel class.""" import asyncio import json from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch import pytest from roborock.devices.transport.local_channel import LocalChannel, LocalChannelParams from roborock.exceptions import RoborockConnectionException, RoborockException from roborock.protocol import create_local_decoder, create_local_encoder from roborock.protocols.v1_protocol import LocalProtocolVersion from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol TEST_HOST = "192.168.1.100" TEST_LOCAL_KEY = "local_key" TEST_PORT = 58867 TEST_REQUEST = RoborockMessage( protocol=RoborockMessageProtocol.RPC_REQUEST, payload=json.dumps({"dps": {"101": json.dumps({"id": 12345, "method": "get_status"})}}).encode(), ) TEST_RESPONSE = RoborockMessage( protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=json.dumps({"dps": {"102": json.dumps({"id": 12345, "result": {"state": "cleaning"}})}}).encode(), ) TEST_REQUEST2 = RoborockMessage( protocol=RoborockMessageProtocol.RPC_REQUEST, payload=json.dumps({"dps": {"101": json.dumps({"id": 54321, "method": "get_status"})}}).encode(), ) TEST_RESPONSE2 = RoborockMessage( protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=json.dumps({"dps": {"102": json.dumps({"id": 54321, "result": {"state": "cleaning"}})}}).encode(), ) ENCODER = create_local_encoder(TEST_LOCAL_KEY) DECODER = create_local_decoder(TEST_LOCAL_KEY) @pytest.fixture(name="mock_transport") def setup_mock_transport() -> Mock: """Mock transport for testing.""" transport = Mock() transport.write = Mock() transport.close = Mock() return transport @pytest.fixture(name="mock_loop") def setup_mock_loop(mock_transport: Mock) -> Generator[Mock, None, None]: """Mock event loop for testing.""" loop = Mock() loop.create_connection = AsyncMock(return_value=(mock_transport, Mock())) with patch("roborock.devices.transport.local_channel.get_running_loop", return_value=loop): yield loop def create_test_local_channel() -> LocalChannel: """Helper to create a LocalChannel for testing.""" return LocalChannel(host=TEST_HOST, local_key=TEST_LOCAL_KEY, device_uid="test_duid") @pytest.fixture(name="local_channel") async def setup_local_channel_with_hello_mock() -> LocalChannel: """Fixture to set up the local channel with automatic hello mocking.""" channel = create_test_local_channel() async def mock_do_hello(_: LocalProtocolVersion): """Mock _do_hello to return successful params without sending actual request.""" return LocalChannelParams( local_key=channel._params.local_key, connect_nonce=channel._params.connect_nonce, ack_nonce=54321 ) # Replace the _do_hello method setattr(channel, "_do_hello", mock_do_hello) return channel @pytest.fixture(name="received_messages") async def setup_subscribe_callback(local_channel: LocalChannel) -> list[RoborockMessage]: """Fixture to record messages received by the subscriber.""" messages: list[RoborockMessage] = [] await local_channel.subscribe(messages.append) return messages async def test_successful_connection(local_channel: LocalChannel, mock_loop: Mock, mock_transport: Mock) -> None: """Test successful connection to device.""" await local_channel.connect() mock_loop.create_connection.assert_called_once() call_args = mock_loop.create_connection.call_args assert call_args[0][1] == TEST_HOST assert call_args[0][2] == TEST_PORT assert local_channel._is_connected is True async def test_connection_failure(local_channel: LocalChannel, mock_loop: Mock) -> None: """Test connection failure handling.""" mock_loop.create_connection.side_effect = OSError("Connection failed") with pytest.raises(RoborockConnectionException, match="Failed to connect to 192.168.1.100:58867"): await local_channel.connect() assert local_channel._is_connected is False async def test_close_connection(local_channel: LocalChannel, mock_loop: Mock, mock_transport: Mock) -> None: """Test closing the connection.""" await local_channel.connect() local_channel.close() mock_transport.close.assert_called_once() assert local_channel._is_connected is False async def test_close_without_connection(local_channel: LocalChannel) -> None: """Test closing when not connected.""" local_channel.close() assert local_channel._is_connected is False async def test_publish_not_connected(local_channel: LocalChannel) -> None: """Test sending command when not connected raises exception.""" with pytest.raises(RoborockConnectionException, match="Not connected to device"): await local_channel.publish(TEST_REQUEST) async def test_successful_command_response(local_channel: LocalChannel, mock_loop: Mock, mock_transport: Mock) -> None: """Test successful command sending and response handling.""" await local_channel.connect() # Send command in background task await local_channel.publish(TEST_REQUEST) await asyncio.sleep(0.01) # yield # Simulate receiving response via the protocol callback local_channel._data_received(ENCODER(TEST_RESPONSE)) await asyncio.sleep(0.01) # yield # Verify command was sent mock_transport.write.assert_called_once() sent_data = mock_transport.write.call_args[0][0] decoded_sent = next(iter(DECODER(sent_data))) assert decoded_sent == TEST_REQUEST async def test_message_decode_error( local_channel: LocalChannel, caplog: pytest.LogCaptureFixture, received_messages: list[RoborockMessage] ) -> None: """Test handling of message decode errors.""" local_channel._data_received(b"invalid_payload") await asyncio.sleep(0.01) # yield assert received_messages == [] async def test_subscribe_callback( local_channel: LocalChannel, received_messages: list[RoborockMessage], mock_loop: Mock ) -> None: """Test that subscribe callback receives all messages.""" await local_channel.connect() # Send some messages without an RPC local_channel._data_received(ENCODER(TEST_RESPONSE)) local_channel._data_received(ENCODER(TEST_RESPONSE2)) await asyncio.sleep(0.01) # yield assert received_messages == [TEST_RESPONSE, TEST_RESPONSE2] async def test_subscribe_callback_exception_handling( local_channel: LocalChannel, mock_loop: Mock, caplog: pytest.LogCaptureFixture ) -> None: """Test that exceptions in subscriber callbacks are handled gracefully.""" def failing_callback(message: RoborockMessage) -> None: raise ValueError("Test exception") await local_channel.subscribe(failing_callback) await local_channel.connect() # Send message that will cause callback to fail local_channel._data_received(ENCODER(TEST_RESPONSE)) await asyncio.sleep(0.01) # yield # Should log the exception but not crash assert any("Uncaught error in callback 'failing_callback'" in record.message for record in caplog.records) async def test_unsubscribe(local_channel: LocalChannel, mock_loop: Mock) -> None: """Test unsubscribing from messages.""" messages: list[RoborockMessage] = [] unsubscribe = await local_channel.subscribe(messages.append) await local_channel.connect() # Send message while subscribed local_channel._data_received(ENCODER(TEST_RESPONSE)) await asyncio.sleep(0.01) # yield assert len(messages) == 1 # Unsubscribe and send another message unsubscribe() local_channel._data_received(ENCODER(TEST_RESPONSE2)) await asyncio.sleep(0.01) # yield # Should still have only one message assert len(messages) == 1 async def test_connection_lost_callback( local_channel: LocalChannel, mock_loop: Mock, caplog: pytest.LogCaptureFixture ) -> None: """Test connection lost callback handling.""" await local_channel.connect() # Simulate connection loss test_exception = OSError("Connection lost") local_channel._connection_lost(test_exception) assert local_channel._is_connected is False assert local_channel._transport is None async def test_connection_lost_without_exception( local_channel: LocalChannel, mock_loop: Mock, caplog: pytest.LogCaptureFixture ) -> None: """Test connection lost callback without exception.""" await local_channel.connect() # Simulate connection loss without exception local_channel._connection_lost(None) assert local_channel._is_connected is False assert local_channel._transport is None async def test_hello_fallback_to_l01_protocol(mock_loop: Mock, mock_transport: Mock) -> None: """Test that when first hello() message fails (V1) but second succeeds (L01), we use L01.""" # Create a channel without the automatic hello mocking channel = create_test_local_channel() # Mock _do_hello to fail for V1 but succeed for L01 async def mock_do_hello(local_protocol_version: LocalProtocolVersion) -> LocalChannelParams | None: if local_protocol_version == LocalProtocolVersion.V1: # First attempt (V1) fails - return None to simulate failure return None elif local_protocol_version == LocalProtocolVersion.L01: # Second attempt (L01) succeeds return LocalChannelParams( local_key=channel._params.local_key, connect_nonce=channel._params.connect_nonce, ack_nonce=54321 ) return None # Replace the _do_hello method setattr(channel, "_do_hello", mock_do_hello) # Connect and verify L01 protocol is used await channel.connect() # Verify that the channel is using L01 protocol assert channel._local_protocol_version == LocalProtocolVersion.L01 assert channel._params is not None assert channel._params.ack_nonce == 54321 assert channel._is_connected is True async def test_hello_success_with_v1_protocol_first(mock_loop: Mock, mock_transport: Mock) -> None: """Test that when V1 protocol succeeds on first attempt, we use V1.""" # Create a channel without the automatic hello mocking channel = create_test_local_channel() # Clear cached protocol to ensure V1 is tried first channel._local_protocol_version = None # Mock _do_hello to succeed for V1 on first attempt async def mock_do_hello(local_protocol_version: LocalProtocolVersion) -> LocalChannelParams | None: if local_protocol_version == LocalProtocolVersion.V1: # V1 succeeds on first attempt return LocalChannelParams( local_key=channel._params.local_key, connect_nonce=channel._params.connect_nonce, ack_nonce=67890 ) elif local_protocol_version == LocalProtocolVersion.L01: # L01 would succeed but we shouldn't reach it return LocalChannelParams( local_key=channel._params.local_key, connect_nonce=channel._params.connect_nonce, ack_nonce=99999 ) return None # Replace the _do_hello method setattr(channel, "_do_hello", mock_do_hello) # Connect and verify V1 protocol is used await channel.connect() # Verify that the channel is using V1 protocol assert channel._local_protocol_version == LocalProtocolVersion.V1 assert channel._params is not None assert channel._params.ack_nonce == 67890 assert channel._is_connected is True async def test_hello_both_protocols_fail(mock_loop: Mock, mock_transport: Mock) -> None: """Test that when both V1 and L01 protocols fail, connection fails.""" # Create a channel without the automatic hello mocking channel = create_test_local_channel() # Mock _do_hello to fail for both protocols async def mock_do_hello(_: LocalProtocolVersion) -> LocalChannelParams | None: # Both protocols fail return None # Replace the _do_hello method setattr(channel, "_do_hello", mock_do_hello) # Connect should raise an exception with pytest.raises(RoborockException, match="Failed to connect to device with any known protocol"): await channel.connect() # Verify that the channel is not connected and cleaned up assert channel._is_connected is False assert channel._transport is None async def test_hello_preferred_protocol_version_ordering(mock_loop: Mock, mock_transport: Mock) -> None: """Test that preferred protocol version is tried first.""" # Create a channel with preferred L01 protocol channel = create_test_local_channel() channel._local_protocol_version = LocalProtocolVersion.L01 # Track which protocols were attempted and in what order attempted_protocols: list[LocalProtocolVersion] = [] # Mock _do_hello to track attempts and succeed on L01 async def mock_do_hello(local_protocol_version: LocalProtocolVersion) -> LocalChannelParams | None: attempted_protocols.append(local_protocol_version) if local_protocol_version == LocalProtocolVersion.L01: # L01 succeeds return LocalChannelParams( local_key=channel._params.local_key, connect_nonce=channel._params.connect_nonce, ack_nonce=11111 ) return None # Replace the _do_hello method setattr(channel, "_do_hello", mock_do_hello) # Connect and verify L01 is tried first await channel.connect() # Verify that L01 was tried first (preferred version) assert attempted_protocols == [LocalProtocolVersion.L01] assert channel._local_protocol_version == LocalProtocolVersion.L01 assert channel._params is not None assert channel._params.ack_nonce == 11111 assert channel._is_connected is True async def test_keep_alive_task_created_on_connect(local_channel: LocalChannel, mock_loop: Mock) -> None: """Test that _keep_alive_task is created when connect() is called.""" # Before connecting, task should be None assert local_channel._keep_alive_task is None await local_channel.connect() # After connecting, task should be created and not done assert local_channel._keep_alive_task is not None assert isinstance(local_channel._keep_alive_task, asyncio.Task) assert not local_channel._keep_alive_task.done() async def test_keep_alive_task_canceled_on_close(local_channel: LocalChannel, mock_loop: Mock) -> None: """Test that the keep-alive task is properly canceled when close() is called.""" await local_channel.connect() # Verify task exists task = local_channel._keep_alive_task assert task is not None assert not task.done() # Close the connection local_channel.close() # Give the task a moment to be cancelled await asyncio.sleep(0.01) # Task should be canceled and reset to None assert task.cancelled() or task.done() assert local_channel._keep_alive_task is None async def test_keep_alive_task_canceled_on_connection_lost(local_channel: LocalChannel, mock_loop: Mock) -> None: """Test that the keep-alive task is properly canceled when _connection_lost() is called.""" await local_channel.connect() # Verify task exists task = local_channel._keep_alive_task assert task is not None assert not task.done() # Simulate connection loss local_channel._connection_lost(None) # Give the task a moment to be cancelled await asyncio.sleep(0.01) # Task should be canceled and reset to None assert task.cancelled() or task.done() assert local_channel._keep_alive_task is None async def test_keep_alive_ping_loop_executes_periodically(local_channel: LocalChannel, mock_loop: Mock) -> None: """Test that the ping loop continues to execute periodically while connected.""" await local_channel.connect() # Verify the task is running and connected assert local_channel._keep_alive_task is not None assert not local_channel._keep_alive_task.done() assert local_channel._is_connected async def test_keep_alive_ping_exceptions_handled_gracefully( local_channel: LocalChannel, mock_loop: Mock, caplog: pytest.LogCaptureFixture ) -> None: """Test that exceptions in the ping loop are handled gracefully without stopping the loop.""" from roborock.devices.transport.local_channel import _PING_INTERVAL # Set log level to capture DEBUG messages caplog.set_level("DEBUG") ping_call_count = 0 # Mock the _ping method to always fail async def mock_ping() -> None: nonlocal ping_call_count ping_call_count += 1 raise Exception("Test ping failure") # Also need to mock asyncio.sleep to avoid waiting the full interval original_sleep = asyncio.sleep async def mock_sleep(delay: float) -> None: # Only sleep briefly for test speed when waiting for ping interval if delay >= _PING_INTERVAL: await original_sleep(0.01) else: await original_sleep(delay) with patch("asyncio.sleep", side_effect=mock_sleep): setattr(local_channel, "_ping", mock_ping) await local_channel.connect() # Wait for multiple ping attempts await original_sleep(0.1) # Verify the task is still running despite the exception assert local_channel._keep_alive_task is not None assert not local_channel._keep_alive_task.done() # Verify ping was called at least once assert ping_call_count >= 1 # Verify the exception was logged but didn't crash the loop assert any("Keep-alive ping failed" in record.message for record in caplog.records) Python-roborock-python-roborock-d6da2db/tests/devices/transport/test_mqtt_channel.py000066400000000000000000000230571513363643200314410ustar00rootroot00000000000000"""Tests for the MqttChannel class.""" import asyncio import json import logging from collections.abc import AsyncGenerator, Callable from unittest.mock import AsyncMock, Mock import pytest from roborock.data import HomeData, UserData from roborock.devices.transport.mqtt_channel import MqttChannel from roborock.mqtt.session import MqttParams from roborock.protocol import create_mqtt_decoder, create_mqtt_encoder from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol from tests import mock_data USER_DATA = UserData.from_dict(mock_data.USER_DATA) TEST_MQTT_PARAMS = MqttParams( host="localhost", port=1883, tls=False, username="username", password="password", timeout=10.0, ) TEST_LOCAL_KEY = "local_key" TEST_REQUEST = RoborockMessage( protocol=RoborockMessageProtocol.RPC_REQUEST, payload=json.dumps({"dps": {"101": json.dumps({"id": 12345, "method": "get_status"})}}).encode(), ) TEST_RESPONSE = RoborockMessage( protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=json.dumps({"dps": {"102": json.dumps({"id": 12345, "result": {"state": "cleaning"}})}}).encode(), ) TEST_REQUEST2 = RoborockMessage( protocol=RoborockMessageProtocol.RPC_REQUEST, payload=json.dumps({"dps": {"101": json.dumps({"id": 54321, "method": "get_status"})}}).encode(), ) TEST_RESPONSE2 = RoborockMessage( protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=json.dumps({"dps": {"102": json.dumps({"id": 54321, "result": {"state": "cleaning"}})}}).encode(), ) ENCODER = create_mqtt_encoder(TEST_LOCAL_KEY) DECODER = create_mqtt_decoder(TEST_LOCAL_KEY) @pytest.fixture(name="mqtt_session", autouse=True) def setup_mqtt_session() -> Mock: """Fixture to set up the MQTT session for the tests.""" return AsyncMock() @pytest.fixture(name="mqtt_channel", autouse=True) def setup_mqtt_channel(mqtt_session: Mock) -> MqttChannel: """Fixture to set up the MQTT channel for the tests.""" return MqttChannel( mqtt_session, duid="abc123", local_key=TEST_LOCAL_KEY, rriot=USER_DATA.rriot, mqtt_params=TEST_MQTT_PARAMS ) @pytest.fixture(name="mqtt_subscribers", autouse=True) async def setup_subscribe_callback(mqtt_session: Mock) -> AsyncGenerator[list[Callable[[bytes], None]], None]: """Fixture to record messages received by the subscriber.""" subscriber_callbacks = [] def mock_subscribe(_: str, callback: Callable[[bytes], None]) -> Callable[[], None]: subscriber_callbacks.append(callback) return lambda: subscriber_callbacks.remove(callback) mqtt_session.subscribe.side_effect = mock_subscribe yield subscriber_callbacks assert not subscriber_callbacks, "Not all subscribers were unsubscribed" @pytest.fixture(name="mqtt_message_handler") async def setup_message_handler(mqtt_subscribers: list[Callable[[bytes], None]]) -> Callable[[bytes], None]: """Fixture to allow simulating incoming MQTT messages.""" def invoke_all_callbacks(message: bytes) -> None: for callback in mqtt_subscribers: callback(message) return invoke_all_callbacks @pytest.fixture def warning_caplog( caplog: pytest.LogCaptureFixture, ) -> pytest.LogCaptureFixture: """Fixture to capture warning messages.""" caplog.set_level(logging.WARNING) return caplog async def home_home_data_no_devices() -> HomeData: """Mock home data API that returns no devices.""" return HomeData( id=1, name="Test Home", devices=[], products=[], ) async def mock_home_data() -> HomeData: """Mock home data API that returns devices.""" return HomeData.from_dict(mock_data.HOME_DATA_RAW) async def test_publish_success( mqtt_session: Mock, mqtt_channel: MqttChannel, mqtt_message_handler: Callable[[bytes], None], ) -> None: """Test successful RPC command sending and response handling.""" # Send a test request. We use a task so we can simulate receiving the response # while the command is still being processed. await mqtt_channel.publish(TEST_REQUEST) await asyncio.sleep(0.01) # yield # Simulate receiving the response message via MQTT mqtt_message_handler(ENCODER(TEST_RESPONSE)) await asyncio.sleep(0.01) # yield # Verify the command was sent assert mqtt_session.publish.called assert mqtt_session.publish.call_args[0][0] == "rr/m/i/user123/username/abc123" raw_sent_msg = mqtt_session.publish.call_args[0][1] # == b"encoded_message" decoded_message = next(iter(DECODER(raw_sent_msg))) assert decoded_message == TEST_REQUEST assert decoded_message.protocol == RoborockMessageProtocol.RPC_REQUEST @pytest.mark.parametrize(("connected"), [(True), (False)]) async def test_connection_status( mqtt_session: Mock, mqtt_channel: MqttChannel, connected: bool, ) -> None: """Test successful RPC command sending and response handling.""" mqtt_session.connected = connected assert mqtt_channel.is_connected is connected assert mqtt_channel.is_local_connected is False async def test_message_decode_error( mqtt_channel: MqttChannel, mqtt_message_handler: Callable[[bytes], None], caplog: pytest.LogCaptureFixture, ) -> None: """Test an error during message decoding.""" callback = Mock() unsub = await mqtt_channel.subscribe(callback) with caplog.at_level(logging.WARNING): mqtt_message_handler(b"invalid_payload") await asyncio.sleep(0.01) # yield assert callback.call_count == 0 unsub() async def test_concurrent_subscribers(mqtt_session: Mock, mqtt_channel: MqttChannel) -> None: """Test multiple concurrent subscribers receive all messages.""" # Set up multiple subscribers subscriber1_messages: list[RoborockMessage] = [] subscriber2_messages: list[RoborockMessage] = [] subscriber3_messages: list[RoborockMessage] = [] unsub1 = await mqtt_channel.subscribe(subscriber1_messages.append) unsub2 = await mqtt_channel.subscribe(subscriber2_messages.append) unsub3 = await mqtt_channel.subscribe(subscriber3_messages.append) # Verify that each subscription creates a separate call to the MQTT session assert mqtt_session.subscribe.call_count == 3 # All subscriptions should be to the same topic for call in mqtt_session.subscribe.call_args_list: assert call[0][0] == "rr/m/o/user123/username/abc123" # Get the message handlers for each subscriber handler1 = mqtt_session.subscribe.call_args_list[0][0][1] handler2 = mqtt_session.subscribe.call_args_list[1][0][1] handler3 = mqtt_session.subscribe.call_args_list[2][0][1] # Simulate receiving messages - each handler should decode the message independently handler1(ENCODER(TEST_REQUEST)) handler2(ENCODER(TEST_REQUEST)) handler3(ENCODER(TEST_REQUEST)) await asyncio.sleep(0.01) # yield # All subscribers should receive the message assert len(subscriber1_messages) == 1 assert len(subscriber2_messages) == 1 assert len(subscriber3_messages) == 1 assert subscriber1_messages[0] == TEST_REQUEST assert subscriber2_messages[0] == TEST_REQUEST assert subscriber3_messages[0] == TEST_REQUEST # Send another message to all handlers handler1(ENCODER(TEST_RESPONSE)) handler2(ENCODER(TEST_RESPONSE)) handler3(ENCODER(TEST_RESPONSE)) await asyncio.sleep(0.01) # yield # All subscribers should have received both messages assert len(subscriber1_messages) == 2 assert len(subscriber2_messages) == 2 assert len(subscriber3_messages) == 2 assert subscriber1_messages == [TEST_REQUEST, TEST_RESPONSE] assert subscriber2_messages == [TEST_REQUEST, TEST_RESPONSE] assert subscriber3_messages == [TEST_REQUEST, TEST_RESPONSE] # Test unsubscribing one subscriber unsub1() # Send another message only to remaining handlers handler2(ENCODER(TEST_REQUEST2)) handler3(ENCODER(TEST_REQUEST2)) await asyncio.sleep(0.01) # yield # First subscriber should not have received the new message assert len(subscriber1_messages) == 2 assert len(subscriber2_messages) == 3 assert len(subscriber3_messages) == 3 assert subscriber2_messages[2] == TEST_REQUEST2 assert subscriber3_messages[2] == TEST_REQUEST2 # Unsubscribe remaining subscribers unsub2() unsub3() async def test_concurrent_subscribers_with_callback_exception( mqtt_session: Mock, mqtt_channel: MqttChannel, caplog: pytest.LogCaptureFixture ) -> None: """Test that exception in one subscriber callback doesn't affect others.""" caplog.set_level(logging.ERROR) def failing_callback(message: RoborockMessage) -> None: raise ValueError("Callback error") subscriber2_messages: list[RoborockMessage] = [] unsub1 = await mqtt_channel.subscribe(failing_callback) unsub2 = await mqtt_channel.subscribe(subscriber2_messages.append) # Get the message handlers handler1 = mqtt_session.subscribe.call_args_list[0][0][1] handler2 = mqtt_session.subscribe.call_args_list[1][0][1] # Simulate receiving a message - first handler will raise exception handler1(ENCODER(TEST_REQUEST)) handler2(ENCODER(TEST_REQUEST)) await asyncio.sleep(0.01) # yield # Exception should be logged but other subscribers should still work assert len(subscriber2_messages) == 1 assert subscriber2_messages[0] == TEST_REQUEST # Check that exception was logged error_records = [record for record in caplog.records if record.levelname == "ERROR"] assert len(error_records) == 1 assert "Uncaught error in callback 'failing_callback'" in error_records[0].message # Unsubscribe all remaining subscribers unsub1() unsub2() Python-roborock-python-roborock-d6da2db/tests/e2e/000077500000000000000000000000001513363643200223415ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/e2e/__init__.py000066400000000000000000000003761513363643200244600ustar00rootroot00000000000000"""End-to-end tests package.""" pytest_plugins = [ "tests.fixtures.logging_fixtures", "tests.fixtures.local_async_fixtures", "tests.fixtures.pahomqtt_fixtures", "tests.fixtures.aiomqtt_fixtures", "tests.fixtures.web_api_fixtures", ] Python-roborock-python-roborock-d6da2db/tests/e2e/__snapshots__/000077500000000000000000000000001513363643200251575ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/e2e/__snapshots__/test_device_manager.ambr000066400000000000000000001273311513363643200320210ustar00rootroot00000000000000# serializer version: 1 # name: test_a01_device[home_data0] [mqtt >] 00000000 10 29 00 04 4d 51 54 54 05 c2 00 3c 00 00 00 00 |.)..MQTT...<....| 00000010 08 31 39 36 34 38 66 39 34 00 10 32 33 34 36 37 |.19648f94..23467| 00000020 38 65 61 38 35 34 66 31 39 39 65 |8ea854f199e| [mqtt <] 00000000 20 09 02 00 06 22 00 0a 21 00 14 | ...."..!..| [mqtt >] 00000000 82 26 00 01 00 00 20 72 72 2f 6d 2f 6f 2f 75 73 |.&.... rr/m/o/us| 00000010 65 72 31 32 33 2f 31 39 36 34 38 66 39 34 2f 7a |er123/19648f94/z| 00000020 65 6f 5f 64 75 69 64 00 |eo_duid.| [mqtt <] 00000000 90 04 00 01 00 00 |......| [mqtt >] 00000000 30 5a 00 20 72 72 2f 6d 2f 69 2f 75 73 65 72 31 |0Z. rr/m/i/user1| 00000010 32 33 2f 31 39 36 34 38 66 39 34 2f 7a 65 6f 5f |23/19648f94/zeo_| 00000020 64 75 69 64 00 41 30 31 00 00 23 82 00 00 23 83 |duid.A01..#...#.| 00000030 68 a6 a2 24 00 65 00 20 c5 de 2b f6 a9 ba 32 7e |h..$.e. ..+...2~| 00000040 6b 73 82 bb d8 67 d4 db 7e cd 61 aa 8c 38 56 53 |ks...g..~.a..8VS| 00000050 ca 4e 15 0d b1 b7 80 a2 0f 16 58 36 |.N........X6| [mqtt <] 00000000 30 5e 00 20 72 72 2f 6d 2f 6f 2f 75 73 65 72 31 |0^. rr/m/o/user1| 00000010 32 33 2f 31 39 36 34 38 66 39 34 2f 7a 65 6f 5f |23/19648f94/zeo_| 00000020 64 75 69 64 00 00 00 00 37 41 30 31 00 00 00 00 |duid....7A01....| 00000030 00 00 00 17 68 a6 a2 23 00 66 00 20 c6 d0 06 0c |....h..#.f. ....| 00000040 04 eb 86 8c 96 8c 51 45 4f 8e 96 93 9e 3d de 35 |......QEO....=.5| 00000050 bb a3 92 cf 68 49 69 ba 83 25 cc 5d 77 e8 62 8a |....hIi..%.]w.b.| # --- # name: test_l01_device [mqtt >] 00000000 10 29 00 04 4d 51 54 54 05 c2 00 3c 00 00 00 00 |.)..MQTT...<....| 00000010 08 31 39 36 34 38 66 39 34 00 10 32 33 34 36 37 |.19648f94..23467| 00000020 38 65 61 38 35 34 66 31 39 39 65 |8ea854f199e| [mqtt <] 00000000 20 09 02 00 06 22 00 0a 21 00 14 | ...."..!..| [mqtt >] 00000000 82 24 00 01 00 00 1e 72 72 2f 6d 2f 6f 2f 75 73 |.$.....rr/m/o/us| 00000010 65 72 31 32 33 2f 31 39 36 34 38 66 39 34 2f 61 |er123/19648f94/a| 00000020 62 63 31 32 33 00 |bc123.| [mqtt <] 00000000 90 04 00 01 00 00 |......| [mqtt >] 00000000 30 f8 01 00 1e 72 72 2f 6d 2f 69 2f 75 73 65 72 |0....rr/m/i/user| 00000010 31 32 33 2f 31 39 36 34 38 66 39 34 2f 61 62 63 |123/19648f94/abc| 00000020 31 32 33 00 31 2e 30 00 00 23 83 00 00 23 84 68 |123.1.0..#...#.h| 00000030 a6 a2 27 00 65 00 c0 d5 b7 f1 34 a4 76 21 76 0a |..'.e.....4.v!v.| 00000040 ed 60 71 51 04 ae bd 39 9b 41 c6 34 63 89 66 1f |.`qQ...9.A.4c.f.| 00000050 c2 8b 96 83 ec 93 45 55 f0 cf ed 93 0f 45 ff a9 |......EU.....E..| 00000060 a4 8b a5 5a c9 25 36 1a eb cf 1d 6d d9 b5 b6 37 |...Z.%6....m...7| 00000070 8a a3 4d 9c 2f e4 41 f3 75 28 11 6c 2d 39 83 cb |..M./.A.u(.l-9..| 00000080 b1 60 8b 92 d5 b7 a7 be e3 c0 aa 80 94 0c 99 12 |.`..............| 00000090 a2 e1 97 7e 3e ea 29 27 0f 9e 9c 22 97 0b 9c 59 |...~>.)'..."...Y| 000000a0 78 da 88 55 6b 52 58 b7 a3 2b 85 67 49 5e 90 85 |x..UkRX..+.gI^..| 000000b0 d8 7a bb b3 c9 14 6c fb 42 1c 85 96 23 ff 30 02 |.z....l.B...#.0.| 000000c0 78 20 1c 5b 96 e1 f2 ad f2 62 28 c2 8a 9f 97 79 |x .[.....b(....y| 000000d0 f9 73 25 c4 66 98 e8 ea f3 37 20 f9 94 7e 2b d6 |.s%.f....7 ..~+.| 000000e0 fb 9a ed 2c 37 e8 b2 b0 3d f3 93 6f 17 d7 89 31 |...,7...=..o...1| 000000f0 bb e0 42 8b 18 fd 0d 62 2d 95 ca |..B....b-..| [mqtt <] 00000000 30 8c 02 00 1e 72 72 2f 6d 2f 6f 2f 75 73 65 72 |0....rr/m/o/user| 00000010 31 32 33 2f 31 39 36 34 38 66 39 34 2f 61 62 63 |123/19648f94/abc| 00000020 31 32 33 00 00 00 00 e7 31 2e 30 00 00 00 01 00 |123.....1.0.....| 00000030 00 00 17 68 a6 a2 23 00 66 00 d0 84 66 bd 8c 5a |...h..#.f...f..Z| 00000040 42 4a aa 2d 9e bf 93 7e 3e 92 5a 46 38 2b db 75 |BJ.-...~>.ZF8+.u| 00000050 ab 6c 28 b5 3d 80 d9 b7 73 cf b9 9e cf 62 52 ca |.l(.=...s....bR.| 00000060 4e b4 7e b9 89 e9 50 45 4d f3 e1 c8 a9 a4 65 f1 |N.~...PEM.....e.| 00000070 6d ff 2d e4 c6 c8 4e 8b 85 08 5c 20 91 76 f7 af |m.-...N...\ .v..| 00000080 cf 25 80 48 e6 95 97 b1 0f b0 6e 1e 62 26 a1 d1 |.%.H......n.b&..| 00000090 38 c4 f1 39 2a b9 3b 05 0e 37 cb d5 5b cd 95 e7 |8..9*.;..7..[...| 000000a0 4b f6 ff d7 03 dc 6b e3 ac d6 7e ec a7 75 64 08 |K.....k...~..ud.| 000000b0 2d 2a 6d e1 af 94 ee a4 b3 4f ed 1e d8 aa 76 f0 |-*m......O....v.| 000000c0 bd 02 37 7c 6b 5b fb 8d 62 b0 c1 85 79 49 df 67 |..7|k[..b...yI.g| 000000d0 3c 1e 9a a3 b3 4d 1d 50 ac 9f 62 b9 99 4f 45 47 |<....M.P..b..OEG| 000000e0 ba 41 30 53 19 63 92 84 c5 bc a4 33 2f 21 8c dd |.A0S.c.....3/!..| 000000f0 6e f2 b1 ed 08 59 50 2a b1 a9 e2 f1 bb af 4b 6b |n....YP*......Kk| 00000100 7c 87 7f 0c dd 9b 6d 26 a4 20 bb a7 e0 82 5c ||.....m&. ....\| [local >] 00000000 00 00 00 15 31 2e 30 00 00 00 01 00 00 23 85 68 |....1.0......#.h| 00000010 a6 a2 28 00 00 2a 04 e3 89 |..(..*...| [local <] 00000000 00 |.| [local >] 00000000 00 00 00 15 4c 30 31 00 00 00 01 00 00 23 85 68 |....L01......#.h| 00000010 a6 a2 29 00 00 22 92 f7 02 |..).."...| [local <] 00000000 00 00 00 29 4c 30 31 00 00 00 01 00 00 00 17 68 |...)L01........h| 00000010 a6 a2 24 00 01 00 12 c1 07 5b 52 43 96 97 c5 29 |..$......[RC...)| 00000020 59 36 cc 5c 9c 8b f2 ab 8a f0 30 d7 a9 |Y6.\......0..| [local >] 00000000 00 00 00 86 4c 30 31 00 00 23 87 00 00 23 88 68 |....L01..#...#.h| 00000010 a6 a2 2a 00 04 00 6f 62 62 b7 96 c7 51 5f a7 4b |..*...obb...Q_.K| 00000020 92 8f ce 25 cb 15 55 28 7a 93 03 83 ec 3c d9 9b |...%..U(z....<..| 00000030 e2 c0 34 22 93 c0 c6 9a ff b2 9a df c3 01 b3 ad |..4"............| 00000040 f3 a4 7f 05 f5 9c c5 89 38 55 42 09 ad 60 56 88 |........8UB..`V.| 00000050 b7 cb 6f 62 f2 6d 04 a3 39 1f 69 70 64 41 29 d8 |..ob.m..9.ipdA).| 00000060 20 a8 b4 64 d0 ae 37 79 b4 85 44 bb 66 87 14 39 | ..d..7y..D.f..9| 00000070 33 20 60 02 0f 4e b1 bf 87 8a 84 5a 29 44 e1 d5 |3 `..N.....Z)D..| 00000080 40 a6 02 6a 67 81 63 37 eb 07 |@..jg.c7..| [local <] 00000000 00 00 03 b5 4c 30 31 00 00 00 02 00 00 00 17 68 |....L01........h| 00000010 a6 a2 25 00 66 03 9e f6 bf 79 0a 3e 32 de 58 fa |..%.f....y.>2.X.| 00000020 f1 b0 dd a2 47 f6 30 37 c6 c1 24 70 3d bd 9c 15 |....G.07..$p=...| 00000030 1f 2e 64 c7 95 7a e4 4f 5d 0a c6 d6 7a 9f b9 ad |..d..z.O]...z...| 00000040 10 75 e3 b5 ff 4c e0 b5 dd 20 34 53 6f 40 4d ef |.u...L... 4So@M.| 00000050 9c fd c7 83 49 99 80 0b 29 c9 b0 e7 57 4f 7d 24 |....I...)...WO}$| 00000060 3b 09 42 fb 78 1f cc 39 2e ff 05 e3 0a 19 7f be |;.B.x..9........| 00000070 6e cc ee d4 fe 3a dc 92 00 9e 07 09 ae 74 fc 95 |n....:.......t..| 00000080 8b 4e 87 73 40 da bc 06 40 62 e7 49 86 e8 03 36 |.N.s@...@b.I...6| 00000090 01 21 84 47 2b 1c 5f 64 26 8a 3c 65 0f 83 91 86 |.!.G+._d&..Mv..Z| 000002d0 e0 cf 61 65 3b d4 30 04 f1 9f c0 14 33 b4 dc a0 |..ae;.0.....3...| 000002e0 6e cc b7 eb 7d 52 e0 e2 c7 87 7e 31 52 64 92 3f |n...}R....~1Rd.?| 000002f0 5d 55 d8 92 d0 d9 c0 11 92 36 40 f1 cc 67 14 84 |]U.......6@..g..| 00000300 2a 00 08 91 21 b5 c9 12 c0 56 34 57 d2 e8 ef 51 |*...!....V4W...Q| 00000310 81 10 eb c9 d9 84 a3 38 6d b5 b7 2c b6 52 a5 d6 |.......8m..,.R..| 00000320 c5 33 94 a7 ed 27 00 c1 2e e2 88 0f 73 16 59 06 |.3...'......s.Y.| 00000330 47 aa 45 6e e1 c7 31 69 b8 87 ae 1d 8f 01 ab 38 |G.En..1i.......8| 00000340 69 ba a0 48 a0 47 3b a4 8f bf ac 51 64 6d e8 6a |i..H.G;....Qdm.j| 00000350 ce a4 0a 2f af 36 04 97 60 f7 98 da df 84 7b d2 |.../.6..`.....{.| 00000360 c6 c4 ac 9e 7d d7 86 b8 61 2b 1f b7 8e 19 67 69 |....}...a+....gi| 00000370 cf 4e 65 b0 e9 50 b2 19 23 9e 8b de 4f 08 87 d2 |.Ne..P..#...O...| 00000380 14 64 53 b8 f1 34 a9 ce 34 d3 c4 81 64 c0 9f 43 |.dS..4..4...d..C| 00000390 c2 c4 bb 0b b0 ae ed e7 3f 28 cd 90 dd f6 7e dd |........?(....~.| 000003a0 f7 2a 36 ad a6 a3 e2 1c 5e 52 41 a7 da 02 1e b4 |.*6.....^RA.....| 000003b0 b5 c6 bf 22 a1 9f 76 65 45 |..."..veE| [local >] 00000000 00 00 00 7d 4c 30 31 00 00 23 8a 00 00 23 8b 68 |...}L01..#...#.h| 00000010 a6 a2 2b 00 04 00 66 20 60 4b 53 1e 12 de 76 a8 |..+...f `KS...v.| 00000020 54 c9 45 0a 1f 8d ec 16 42 2c 16 30 9b 69 03 a8 |T.E.....B,.0.i..| 00000030 28 76 60 ff cf 40 84 05 29 92 68 05 71 df b5 5f |(v`..@..).h.q.._| 00000040 56 f6 f1 d1 05 89 8f 23 6c 02 56 38 a2 e8 5a 08 |V......#l.V8..Z.| 00000050 02 bd 2b db b7 c8 ff 25 52 ac 76 52 50 e7 a3 24 |..+....%R.vRP..$| 00000060 4d ee 52 b4 00 f9 e2 49 c7 23 4b bd 11 6e cd 31 |M.R....I.#K..n.1| 00000070 32 b3 57 f0 68 3d f4 87 10 21 5d 5a 0f e7 5a b4 |2.W.h=...!]Z..Z.| 00000080 55 |U| [local <] 00000000 00 00 04 2a 4c 30 31 00 00 00 03 00 00 00 17 68 |...*L01........h| 00000010 a6 a2 26 00 66 04 13 ce 36 7e 30 29 16 38 0c d3 |..&.f...6~0).8..| 00000020 b0 34 ac 93 71 51 2c 20 ac a7 fb fa b7 1d 24 98 |.4..qQ, ......$.| 00000030 4f 23 6e 89 0c 21 07 b4 8b a1 5a 86 ab c8 8e 94 |O#n..!....Z.....| 00000040 9b 0f fc 8d 57 bc 01 a8 8e 95 27 e3 6a 08 5f b6 |....W.....'.j._.| 00000050 cc 78 7c 39 c5 07 44 ae 70 2b bb e2 0a 0f 90 e9 |.x|9..D.p+......| 00000060 a2 00 c5 9f 06 f2 b0 a5 85 e8 59 c5 36 bd 87 83 |..........Y.6...| 00000070 78 e4 ed 0d 48 1c 60 bc 7f d9 aa c7 e5 25 cb 1b |x...H.`......%..| 00000080 24 c7 c6 d8 96 c7 5d c8 f7 e5 a9 03 c9 6b 52 3e |$.....]......kR>| 00000090 43 56 0a 82 9f bb f8 3d 92 38 9d 65 d0 26 53 cf |CV.....=.8.e.&S.| 000000a0 62 48 ae ce 77 df 70 4a 0f e1 fc c7 36 da e7 64 |bH..w.pJ....6..d| 000000b0 1f b0 aa 07 a1 1a 46 78 55 ad b7 52 99 33 16 a7 |......FxU..R.3..| 000000c0 ed f4 a6 a3 45 58 bc a9 c0 4e db 0d 8c 7e 23 a2 |....EX...N...~#.| 000000d0 d1 08 34 49 e5 ba 3e df c1 e3 b7 a4 38 78 47 e2 |..4I..>.....8xG.| 000000e0 21 5f 16 30 86 c3 14 f8 f6 16 f9 aa d9 7f f5 87 |!_.0............| 000000f0 70 a1 d2 3b a6 55 63 fe 78 7f c5 f5 bf 6d 51 80 |p..;.Uc.x....mQ.| 00000100 d1 d9 e6 ce 5f 93 16 8b f7 b2 02 41 62 a9 90 95 |...._......Ab...| 00000110 1f a9 82 3b 9e 22 ed 84 c5 31 12 bc 2d 7b 52 05 |...;."...1..-{R.| 00000120 eb c3 b8 e9 66 5e 59 cf cb 4f b1 39 6b 8a 61 b8 |....f^Y..O.9k.a.| 00000130 48 f4 a8 de 92 5e 68 c5 01 36 65 3c 5c 60 83 93 |H....^h..6e<\`..| 00000140 25 f1 d4 96 fa a1 e9 3e 22 18 c2 b5 27 1e bb 92 |%......>"...'...| 00000150 06 3c 6a ee 3e 03 fb a3 73 fe 22 5c ca f4 90 95 |....s."\....| 00000160 be cf 91 dd 1e 3f fe ef 5b ac a6 5d 23 f2 9a 28 |.....?..[..]#..(| 00000170 20 79 ec b7 0c 4b cc a3 3c a6 02 ce 3c eb b9 93 | y...K..<...<...| 00000180 60 5b bb ab ab 1a 86 1f d2 3a 63 38 12 d2 2c 15 |`[.......:c8..,.| 00000190 5e f0 12 23 c0 86 93 b7 70 fc 29 2b 75 41 e5 43 |^..#....p.)+uA.C| 000001a0 ad 64 64 33 4d f3 a9 7b f9 4c 79 62 b0 3a 22 d5 |.dd3M..{.Lyb.:".| 000001b0 0c 22 ee 55 60 11 0d 30 f5 ac ac a6 42 ec 12 85 |.".U`..0....B...| 000001c0 d4 7f d1 ba 11 d3 da 40 03 d6 d6 1b d6 35 72 77 |.......@.....5rw| 000001d0 49 05 be e6 c8 c7 84 4c 25 0b 4d b6 1f 59 2b 09 |I......L%.M..Y+.| 000001e0 d5 4a 59 f1 7d 19 70 a9 39 29 25 fd 0e d1 ad 5d |.JY.}.p.9)%....]| 000001f0 6a 91 c3 61 c7 ad c1 ed 4f 47 8c 54 d7 27 25 ee |j..a....OG.T.'%.| 00000200 77 30 2d 36 73 60 3a d1 9b 5b 8a 8f 52 be f3 f7 |w0-6s`:..[..R...| 00000210 68 a3 f5 16 a6 c3 df 2d c0 93 15 4a f9 00 3b 7d |h......-...J..;}| 00000220 29 8c c4 ab 25 a5 ea d3 03 fc 67 06 b3 d3 23 55 |)...%.....g...#U| 00000230 ef 8c 03 84 e2 af 3c b9 22 f4 cf 9e 44 9f df 4a |......<."...D..J| 00000240 95 7e 22 8a 92 29 ce 86 6f 0a 70 6d 7b 47 2e 99 |.~"..)..o.pm{G..| 00000250 6f d5 46 a8 61 13 2c 00 cb 06 80 fa 6d 73 20 88 |o.F.a.,.....ms .| 00000260 e5 ec 00 89 4d 38 93 5c 11 28 5a 0e e7 3c 21 18 |....M8.\.(Z.....p......| 00000340 12 85 2a 27 5a 92 54 fe ec 6f 51 ee 9a d6 ec 5a |..*'Z.T..oQ....Z| 00000350 60 3e 12 5e 4b 78 c2 60 c5 3e 06 c1 24 43 4f 31 |`>.^Kx.`.>..$CO1| 00000360 1a 37 61 06 d7 b3 f4 a9 bd 5b 1f 4e cf d7 c9 81 |.7a......[.N....| 00000370 1b 5e f5 94 af 10 55 b2 01 e6 89 a7 1d 68 df b2 |.^....U......h..| 00000380 8c a6 9a 2b 36 5c e2 9c 4b 69 0e 2e 03 b6 e3 18 |...+6\..Ki......| 00000390 4c ca 5e 4e e6 44 1b 1f e9 e0 7d 73 2e 72 ce 39 |L.^N.D....}s.r.9| 000003a0 e3 12 90 89 12 eb 93 34 1a 11 4f d1 98 33 c0 41 |.......4..O..3.A| 000003b0 f0 6b 5d 64 90 4a cc 5c f6 2f 46 a0 55 20 d7 36 |.k]d.J.\./F.U .6| 000003c0 0c 92 1a 85 68 aa 44 73 1d d0 7c c4 ba 31 33 a0 |....h.Ds..|..13.| 000003d0 43 00 0e b3 43 68 98 7b 3d f9 4f 7c e8 c4 30 9e |C...Ch.{=.O|..0.| 000003e0 0c b8 c1 89 56 88 a1 1c 5b ff dd 92 2c ef bf 0e |....V...[...,...| 000003f0 23 48 d2 bd 48 84 99 14 a7 e8 19 cc 50 5e 0a 05 |#H..H.......P^..| 00000400 90 1a e0 25 1a 4b 55 74 fe 59 36 47 e3 e5 79 fd |...%.KUt.Y6G..y.| 00000410 a0 9b 5e 72 8d 3e 57 69 0b 7c 21 80 2f a4 d5 12 |..^r.>Wi.|!./...| 00000420 99 be 49 6e f3 0b 57 e5 a8 1e 88 b6 7b 48 |..In..W.....{H| # --- # name: test_q10_device[home_data0] [mqtt >] 00000000 10 29 00 04 4d 51 54 54 05 c2 00 3c 00 00 00 00 |.)..MQTT...<....| 00000010 08 31 39 36 34 38 66 39 34 00 10 32 33 34 36 37 |.19648f94..23467| 00000020 38 65 61 38 35 34 66 31 39 39 65 |8ea854f199e| [mqtt <] 00000000 20 09 02 00 06 22 00 0a 21 00 14 | ...."..!..| [mqtt >] 00000000 82 2e 00 01 00 00 28 72 72 2f 6d 2f 6f 2f 75 73 |......(rr/m/o/us| 00000010 65 72 31 32 33 2f 31 39 36 34 38 66 39 34 2f 64 |er123/19648f94/d| 00000020 65 76 69 63 65 2d 69 64 2d 64 65 66 34 35 36 00 |evice-id-def456.| [mqtt <] 00000000 90 04 00 01 00 00 |......| [mqtt >] 00000000 30 62 00 28 72 72 2f 6d 2f 69 2f 75 73 65 72 31 |0b.(rr/m/i/user1| 00000010 32 33 2f 31 39 36 34 38 66 39 34 2f 64 65 76 69 |23/19648f94/devi| 00000020 63 65 2d 69 64 2d 64 65 66 34 35 36 00 42 30 31 |ce-id-def456.B01| 00000030 00 00 23 82 00 00 23 83 68 a6 a2 23 00 65 00 20 |..#...#.h..#.e. | 00000040 8a bd 8d 51 ad 98 18 0f 13 03 aa 07 25 68 54 bc |...Q........%hT.| 00000050 dc 66 c3 74 f1 1d ad 3e 5a 5a c3 27 b6 fe b6 cb |.f.t...>ZZ.'....| 00000060 fe c8 92 09 |....| # --- # name: test_q7_device[home_data0] [mqtt >] 00000000 10 29 00 04 4d 51 54 54 05 c2 00 3c 00 00 00 00 |.)..MQTT...<....| 00000010 08 31 39 36 34 38 66 39 34 00 10 32 33 34 36 37 |.19648f94..23467| 00000020 38 65 61 38 35 34 66 31 39 39 65 |8ea854f199e| [mqtt <] 00000000 20 09 02 00 06 22 00 0a 21 00 14 | ...."..!..| [mqtt >] 00000000 82 2a 00 01 00 00 24 72 72 2f 6d 2f 6f 2f 75 73 |.*....$rr/m/o/us| 00000010 65 72 31 32 33 2f 31 39 36 34 38 66 39 34 2f 64 |er123/19648f94/d| 00000020 65 76 69 63 65 2d 69 64 2d 71 37 00 |evice-id-q7.| [mqtt <] 00000000 90 04 00 01 00 00 |......| [mqtt >] 00000000 30 ae 01 00 24 72 72 2f 6d 2f 69 2f 75 73 65 72 |0...$rr/m/i/user| 00000010 31 32 33 2f 31 39 36 34 38 66 39 34 2f 64 65 76 |123/19648f94/dev| 00000020 69 63 65 2d 69 64 2d 71 37 00 42 30 31 00 00 23 |ice-id-q7.B01..#| 00000030 83 00 00 23 84 68 a6 a2 25 00 65 00 70 9f 24 5b |...#.h..%.e.p.$[| 00000040 c1 48 2b c9 07 ca c3 e1 c5 01 06 3e 62 44 d8 8d |.H+........>bD..| 00000050 7c 45 19 47 5c 53 87 fe 1a a7 a5 0d b4 a8 b5 7e ||E.G\S.........~| 00000060 19 75 8a 4f 0a 37 ca d0 1f d0 a1 5b e8 ef 45 75 |.u.O.7.....[..Eu| 00000070 73 aa dd 84 c8 ec d6 c2 e7 64 43 c3 58 8a 31 7a |s........dC.X.1z| 00000080 c0 45 0a 5f 06 b6 4f a3 e1 73 05 58 b4 71 2b c3 |.E._..O..s.X.q+.| 00000090 cf e5 68 8a db de a2 3f 1a f7 8e 6d ab a4 7f 71 |..h....?...m...q| 000000a0 34 c2 93 83 01 7d cd 1e b3 78 c1 d7 dc 0c 71 b2 |4....}...x....q.| 000000b0 86 |.| [mqtt <] 00000000 30 92 01 00 24 72 72 2f 6d 2f 6f 2f 75 73 65 72 |0...$rr/m/o/user| 00000010 31 32 33 2f 31 39 36 34 38 66 39 34 2f 64 65 76 |123/19648f94/dev| 00000020 69 63 65 2d 69 64 2d 71 37 00 00 00 00 67 42 30 |ice-id-q7....gB0| 00000030 31 00 00 00 01 00 00 00 17 68 a6 a2 23 00 66 00 |1........h..#.f.| 00000040 50 cc c9 4f 81 fd b0 4c 46 6d d7 bb aa 87 d8 e5 |P..O...LFm......| 00000050 84 54 b7 5b 58 22 d3 d1 53 d0 1d b8 6f 11 53 4f |.T.[X"..S...o.SO| 00000060 77 21 a1 a5 8b 05 7f 9e b7 62 88 df 57 1b fe 50 |w!.......b..W..P| 00000070 f0 9a 70 bc e1 ad c3 f7 cc f7 3f e4 6a dd 1d f5 |..p.......?.j...| 00000080 d2 4a 6d 4d 48 4f b5 75 07 70 7d bf c5 b9 3f e7 |.JmMHO.u.p}...?.| 00000090 73 4b d9 19 cd |sK...| [mqtt >] 00000000 30 de 01 00 24 72 72 2f 6d 2f 69 2f 75 73 65 72 |0...$rr/m/i/user| 00000010 31 32 33 2f 31 39 36 34 38 66 39 34 2f 64 65 76 |123/19648f94/dev| 00000020 69 63 65 2d 69 64 2d 71 37 00 42 30 31 00 00 23 |ice-id-q7.B01..#| 00000030 86 00 00 23 87 68 a6 a2 26 00 65 00 a0 4d ae db |...#.h..&.e..M..| 00000040 7c ad db 6f 8e 4a 1b 01 4c 2b fd fd 1b 4f df 4c ||..o.J..L+...O.L| 00000050 64 fb 3b ed a6 fc 9f e2 21 e8 95 94 49 6c 57 79 |d.;.....!...IlWy| 00000060 9c c5 8e 35 48 fc cc 29 f8 69 9b 54 fb 42 33 7e |...5H..).i.T.B3~| 00000070 63 72 a6 17 0f 87 20 31 74 c3 bb 29 5b 6a f3 a7 |cr.... 1t..)[j..| 00000080 23 bd 10 42 84 4b 6f 09 a5 6c 0b 3c d0 0c a4 ba |#..B.Ko..l.<....| 00000090 90 be 70 27 43 73 35 bd 5f 47 bd 1b b4 e5 0b 98 |..p'Cs5._G......| 000000a0 50 ed 61 80 7d db 40 c1 ad 99 65 e1 7e df a6 b8 |P.a.}.@...e.~...| 000000b0 7d ef 3b 08 92 c3 95 c7 46 a6 f7 32 b6 6d cb 21 |}.;.....F..2.m.!| 000000c0 72 b8 a1 ee 85 49 d6 2a 76 33 c0 01 c3 be fa 58 |r....I.*v3.....X| 000000d0 3d fa f2 72 50 84 e4 17 68 e5 21 00 98 16 9c 62 |=..rP...h.!....b| 000000e0 43 |C| [mqtt <] 00000000 30 82 01 00 24 72 72 2f 6d 2f 6f 2f 75 73 65 72 |0...$rr/m/o/user| 00000010 31 32 33 2f 31 39 36 34 38 66 39 34 2f 64 65 76 |123/19648f94/dev| 00000020 69 63 65 2d 69 64 2d 71 37 00 00 00 00 57 42 30 |ice-id-q7....WB0| 00000030 31 00 00 00 02 00 00 00 17 68 a6 a2 24 00 66 00 |1........h..$.f.| 00000040 40 cc c9 4f 81 fd b0 4c 46 6d d7 bb aa 87 d8 e5 |@..O...LFm......| 00000050 84 54 b7 5b 58 22 d3 d1 53 d0 1d b8 6f 11 53 4f |.T.[X"..S...o.SO| 00000060 77 2b ef a8 bf d2 66 05 8e ce 7f 08 76 c0 18 90 |w+....f.....v...| 00000070 ed 04 66 8e 8f 91 30 63 d2 e0 8c 1a 09 5c 7c ea |..f...0c.....\|.| 00000080 94 e3 24 15 60 |..$.`| # --- # name: test_v1_device [mqtt >] 00000000 10 29 00 04 4d 51 54 54 05 c2 00 3c 00 00 00 00 |.)..MQTT...<....| 00000010 08 31 39 36 34 38 66 39 34 00 10 32 33 34 36 37 |.19648f94..23467| 00000020 38 65 61 38 35 34 66 31 39 39 65 |8ea854f199e| [mqtt <] 00000000 20 09 02 00 06 22 00 0a 21 00 14 | ...."..!..| [mqtt >] 00000000 82 24 00 01 00 00 1e 72 72 2f 6d 2f 6f 2f 75 73 |.$.....rr/m/o/us| 00000010 65 72 31 32 33 2f 31 39 36 34 38 66 39 34 2f 61 |er123/19648f94/a| 00000020 62 63 31 32 33 00 |bc123.| [mqtt <] 00000000 90 04 00 01 00 00 |......| [mqtt >] 00000000 30 f8 01 00 1e 72 72 2f 6d 2f 69 2f 75 73 65 72 |0....rr/m/i/user| 00000010 31 32 33 2f 31 39 36 34 38 66 39 34 2f 61 62 63 |123/19648f94/abc| 00000020 31 32 33 00 31 2e 30 00 00 23 83 00 00 23 84 68 |123.1.0..#...#.h| 00000030 a6 a2 27 00 65 00 c0 d5 b7 f1 34 a4 76 21 76 0a |..'.e.....4.v!v.| 00000040 ed 60 71 51 04 ae bd 39 9b 41 c6 34 63 89 66 1f |.`qQ...9.A.4c.f.| 00000050 c2 8b 96 83 ec 93 45 55 f0 cf ed 93 0f 45 ff a9 |......EU.....E..| 00000060 a4 8b a5 5a c9 25 36 1a eb cf 1d 6d d9 b5 b6 37 |...Z.%6....m...7| 00000070 8a a3 4d 9c 2f e4 41 f3 75 28 11 6c 2d 39 83 cb |..M./.A.u(.l-9..| 00000080 b1 60 8b 92 d5 b7 a7 be e3 c0 aa 80 94 0c 99 12 |.`..............| 00000090 a2 e1 97 7e 3e ea 29 27 0f 9e 9c 22 97 0b 9c 59 |...~>.)'..."...Y| 000000a0 78 da 88 55 6b 52 58 7e de 1c a3 c8 ec 0b 55 1d |x..UkRX~......U.| 000000b0 86 46 cf 86 98 45 05 f3 06 76 db 4f 4e 2f 10 65 |.F...E...v.ON/.e| 000000c0 ae 40 8d 86 4c 66 28 c8 4b 31 a5 ec 43 3d 40 21 |.@..Lf(.K1..C=@!| 000000d0 90 07 ff fb 4f 5b f8 ea f3 37 20 f9 94 7e 2b d6 |....O[...7 ..~+.| 000000e0 fb 9a ed 2c 37 e8 b2 b0 3d f3 93 6f 17 d7 89 31 |...,7...=..o...1| 000000f0 bb e0 42 8b 18 fd 0d 46 bd 10 67 |..B....F..g| [mqtt <] 00000000 30 8c 02 00 1e 72 72 2f 6d 2f 6f 2f 75 73 65 72 |0....rr/m/o/user| 00000010 31 32 33 2f 31 39 36 34 38 66 39 34 2f 61 62 63 |123/19648f94/abc| 00000020 31 32 33 00 00 00 00 e7 31 2e 30 00 00 00 01 00 |123.....1.0.....| 00000030 00 00 17 68 a6 a2 23 00 66 00 d0 84 66 bd 8c 5a |...h..#.f...f..Z| 00000040 42 4a aa 2d 9e bf 93 7e 3e 92 5a 46 38 2b db 75 |BJ.-...~>.ZF8+.u| 00000050 ab 6c 28 b5 3d 80 d9 b7 73 cf b9 9e cf 62 52 ca |.l(.=...s....bR.| 00000060 4e b4 7e b9 89 e9 50 45 4d f3 e1 c8 a9 a4 65 f1 |N.~...PEM.....e.| 00000070 6d ff 2d e4 c6 c8 4e 8b 85 08 5c 20 91 76 f7 af |m.-...N...\ .v..| 00000080 cf 25 80 48 e6 95 97 b1 0f b0 6e 1e 62 26 a1 d1 |.%.H......n.b&..| 00000090 38 c4 f1 39 2a b9 3b 05 0e 37 cb d5 5b cd 95 e7 |8..9*.;..7..[...| 000000a0 4b f6 ff d7 03 dc 6b e3 ac d6 7e ec a7 75 64 08 |K.....k...~..ud.| 000000b0 2d 2a 6d e1 af 94 ee a4 b3 4f ed 1e d8 aa 76 f0 |-*m......O....v.| 000000c0 bd 02 37 7c 6b 5b fb 8d 62 b0 c1 85 79 49 df 67 |..7|k[..b...yI.g| 000000d0 3c 1e 9a a3 b3 4d 1d 50 ac 9f 62 b9 99 4f 45 47 |<....M.P..b..OEG| 000000e0 ba 41 30 53 19 63 92 84 c5 bc a4 33 2f 21 8c dd |.A0S.c.....3/!..| 000000f0 6e f2 b1 ed 08 59 50 2a b1 a9 e2 f1 bb af 4b 6b |n....YP*......Kk| 00000100 7c 87 7f 0c dd 9b 6d 26 a4 20 bb a7 e0 82 5c ||.....m&. ....\| [local >] 00000000 00 00 00 15 31 2e 30 00 00 00 01 00 00 23 85 68 |....1.0......#.h| 00000010 a6 a2 28 00 00 2a 04 e3 89 |..(..*...| [local <] 00000000 00 00 00 27 31 2e 30 00 00 00 01 00 00 00 17 68 |...'1.0........h| 00000010 a6 a2 24 00 01 00 10 e1 cc 62 66 4e c7 42 27 80 |..$......bfN.B'.| 00000020 ee 12 a0 f8 9c 54 d3 d3 b6 78 34 |.....T...x4| [local >] 00000000 00 00 00 77 31 2e 30 00 00 23 87 00 00 23 88 68 |...w1.0..#...#.h| 00000010 a6 a2 29 00 04 00 60 69 d4 c5 8b a1 6e 03 47 01 |..)...`i....n.G.| 00000020 c4 cd bb 48 17 96 fb 9d b8 60 84 05 dc 99 96 e0 |...H.....`......| 00000030 72 3e dc 6d 9d de a5 73 e6 c4 e8 7d 9a a5 ea d7 |r>.m...s...}....| 00000040 73 7f 0d 58 31 a2 38 bc 85 2c 65 9e 93 e4 e8 ca |s..X1.8..,e.....| 00000050 f0 c9 f9 fb 32 52 3c 1b 73 ea 1b ef 1a 71 17 de |....2R<.s....q..| 00000060 74 77 ba 97 6e f7 27 9d c6 1b ac f4 64 6a 27 72 |tw..n.'.....dj'r| 00000070 b6 ae 41 f6 17 60 99 fc 0d 53 ed |..A..`...S.| [local <] 00000000 00 00 03 a7 31 2e 30 00 00 00 02 00 00 00 17 68 |....1.0........h| 00000010 a6 a2 25 00 66 03 90 a4 7d c1 13 61 78 1f aa ec |..%.f...}..ax...| 00000020 22 05 51 cf c4 af fa ba eb 80 2f 0e 34 f6 d5 ae |".Q......./.4...| 00000030 36 13 f2 0e 56 cd 69 4d 0f 4b 30 54 c0 67 1e f9 |6...V.iM.K0T.g..| 00000040 b9 26 c0 0d 54 36 92 b1 47 20 25 ff 10 88 f3 8a |.&..T6..G %.....| 00000050 e1 2b a7 cc 65 0b 27 35 5d 2f 7a 03 85 1a 92 8c |.+..e.'5]/z.....| 00000060 51 2a 8b d8 4f 8d 8e 00 53 c3 d9 0c ea 17 79 9e |Q*..O...S.....y.| 00000070 38 41 93 0e 19 6b cf 41 d3 16 b4 f9 8c db a5 65 |8A...k.A.......e| 00000080 f2 9c dc ae 2d 69 85 f0 7f 7a dd 6c ba 46 1f c3 |....-i...z.l.F..| 00000090 96 4f df be 32 fc 7a ed 6d 86 0b 7b 2d c7 03 00 |.O..2.z.m..{-...| 000000a0 44 b1 ae 6a 32 3b e8 28 56 60 95 ec bd 59 b7 90 |D..j2;.(V`...Y..| 000000b0 72 34 ca a6 ca 27 c7 0b 77 43 b5 76 3e fc f3 76 |r4...'..wC.v>..v| 000000c0 c8 b3 2d fd 63 d8 89 6a 8b ad 11 27 64 d2 76 0f |..-.c..j...'d.v.| 000000d0 96 d4 50 b4 99 cc 6a 81 bf 9a 8e 6b 99 27 92 c9 |..P...j....k.'..| 000000e0 89 9e c3 e0 23 83 65 bd ec b9 fd ec 11 c5 76 a1 |....#.e.......v.| 000000f0 bf ed 7c a5 a0 f8 ac 9b 71 c6 09 31 bd 5c 1f ef |..|.....q..1.\..| 00000100 c5 b5 e3 f3 b3 92 66 d9 aa 76 67 62 c6 e0 db 36 |......f..vgb...6| 00000110 a7 69 74 e6 ea fa eb a3 16 ba 04 6b 4f dc 7a 4d |.it........kO.zM| 00000120 5d 3d 8c 9a 52 0b 88 f6 f7 db 62 a8 ce f1 73 8b |]=..R.....b...s.| 00000130 2e 72 b4 92 53 29 0d 5b 65 5d 14 46 a0 55 4f 74 |.r..S).[e].F.UOt| 00000140 13 a9 27 96 fb bb c9 09 58 71 05 03 9f c0 71 0a |..'.....Xq....q.| 00000150 74 9a 9e 01 6f f9 04 ed 10 56 e9 3d 7c c1 88 11 |t...o....V.=|...| 00000160 19 3e b3 80 58 8c 37 95 ea f7 9b 95 1b 51 36 38 |.>..X.7......Q68| 00000170 c9 97 60 44 01 53 ba 24 c5 f7 15 45 ce 08 e0 51 |..`D.S.$...E...Q| 00000180 6b 8b 2f de 79 e8 6d ef f2 c5 73 31 8e 12 f5 9b |k./.y.m...s1....| 00000190 d7 b0 a6 c8 b5 78 09 7d a6 06 53 4a 45 aa 74 22 |.....x.}..SJE.t"| 000001a0 95 f4 9e 99 d2 63 f4 3b 60 e6 8c 5f 73 68 89 f0 |.....c.;`.._sh..| 000001b0 0f 12 82 a3 8a 63 4b eb a6 75 9e 6d 2f 1f 1a 2c |.....cK..u.m/..,| 000001c0 90 67 03 60 3a fd bc c2 31 72 ce 2c f0 7b 30 2d |.g.`:...1r.,.{0-| 000001d0 5b f0 f4 d2 b9 2f 7b f0 08 aa c7 8a 95 bb 68 7d |[..../{.......h}| 000001e0 40 a8 9b 2c 5e c4 61 a5 cc 86 16 91 d6 cb c4 ab |@..,^.a.........| 000001f0 a9 6e d2 26 ef 7d ad 99 0a 47 bc f5 3c 30 fe 88 |.n.&.}...G..<0..| 00000200 b1 4b 39 27 40 92 96 77 55 eb 32 69 f1 fd 12 ab |.K9'@..wU.2i....| 00000210 c6 39 c1 55 5e 1a df 9e 62 f2 df eb ac 2f 66 44 |.9.U^...b..../fD| 00000220 46 05 7a 9e eb df d1 50 2e 4a 7b 9f 0d 19 c8 64 |F.z....P.J{....d| 00000230 5b df 81 37 60 6f e9 b2 90 ef bc 76 27 43 c6 bb |[..7`o.....v'C..| 00000240 63 6c 17 d3 d0 6d 37 0c 87 5c f8 ab e6 90 35 91 |cl...m7..\....5.| 00000250 e5 d1 49 b1 c5 a7 0e eb 67 e0 ac fe 83 45 c4 6c |..I.....g....E.l| 00000260 28 1d 7f d9 45 64 48 95 68 35 68 d3 80 06 3f 39 |(...EdH.h5h...?9| 00000270 fd e4 6d 85 9d 29 8a f9 1b c4 4b 66 00 2e 36 d8 |..m..)....Kf..6.| 00000280 41 fd ae 70 d5 3c 3e 83 fd a4 1c c0 1a 24 6e 91 |A..p.<>......$n.| 00000290 b4 24 9f 98 6c f0 a4 c2 65 c4 e1 f3 34 bb b1 bd |.$..l...e...4...| 000002a0 15 c1 b3 81 bc 9a 22 eb ad ab dd 22 ad b5 a2 59 |......"...."...Y| 000002b0 88 6d a5 0e 28 d0 5e fe 46 62 f3 6d bf e8 6e 83 |.m..(.^.Fb.m..n.| 000002c0 04 35 38 2d 7d c9 9b 63 c5 0d 5b 1f 99 07 f2 73 |.58-}..c..[....s| 000002d0 63 8e 12 e9 3c f3 0c 5c c0 ca 40 ec d3 db 6d 84 |c...<..\..@...m.| 000002e0 a1 0e 2f 42 6b e5 27 7f f6 b1 cf b9 f0 bf 67 ec |../Bk.'.......g.| 000002f0 9a 14 b8 34 07 f5 10 60 7e 42 1c 3d b8 a0 07 be |...4...`~B.=....| 00000300 0f c7 dd 5a 65 58 45 64 d3 10 1e 96 47 a7 ff e0 |...ZeXEd....G...| 00000310 a7 56 f6 62 a1 c5 41 d6 b1 0f d8 24 56 80 c1 94 |.V.b..A....$V...| 00000320 ac e6 34 4a 94 bf 07 6e bd e5 4c 07 81 f0 e9 73 |..4J...n..L....s| 00000330 2f 56 c7 8b 54 31 f1 c0 b9 06 82 6e 3f 67 19 d1 |/V..T1.....n?g..| 00000340 0f 79 9d 97 30 d3 22 6a 83 6b d7 48 34 7e 45 41 |.y..0."j.k.H4~EA| 00000350 c7 e4 0e 26 98 56 c5 15 2d 35 f5 7e 67 d7 fb e5 |...&.V..-5.~g...| 00000360 d8 76 c0 4d cb 0a 40 e3 0a 9e c7 9a 8c 6f 70 1d |.v.M..@......op.| 00000370 10 b2 38 21 cc 98 5c 93 e7 ab b4 32 ec 15 ce f6 |..8!..\....2....| 00000380 36 3a 09 27 62 bf bf 60 75 6d fb 36 b3 d8 c9 b5 |6:.'b..`um.6....| 00000390 2b 5b d8 03 2e 79 4e c8 6c 66 48 97 ff 57 5f c8 |+[...yN.lfH..W_.| 000003a0 c1 64 16 82 ba 30 5f 2f fd 96 ad |.d...0_/...| [local >] 00000000 00 00 00 77 31 2e 30 00 00 23 8a 00 00 23 8b 68 |...w1.0..#...#.h| 00000010 a6 a2 2a 00 04 00 60 54 4f 82 9c 0f f0 e7 9c bb |..*...`TO.......| 00000020 62 2e 87 79 75 6f 69 fa de 20 5a ef 66 01 1c 8d |b..yuoi.. Z.f...| 00000030 2f 2b ca 36 e5 6f 3c 67 3a ee da 29 cf 87 66 1f |/+.6.o..<.| 00000050 57 8c 73 44 ae b7 7d 2b 94 06 3f 12 6e 09 f0 9a |W.sD..}+..?.n...| 00000060 c9 10 27 10 02 cd cd 69 61 64 28 ef 2c 78 44 db |..'....iad(.,xD.| 00000070 8e 2f e2 d7 42 2c 0e fd 47 05 26 |./..B,..G.&| [local <] 00000000 00 00 04 27 31 2e 30 00 00 00 03 00 00 00 17 68 |...'1.0........h| 00000010 a6 a2 26 00 66 04 10 23 78 4f 6e 2e 1a 98 9f 3d |..&.f..#xOn....=| 00000020 a3 69 31 89 a2 c2 c7 b3 77 2a 69 ec 36 d0 1b 8d |.i1.....w*i.6...| 00000030 f8 b0 e8 1c d8 6b 72 92 69 7b 6d 8c 1b a9 ba a1 |.....kr.i{m.....| 00000040 d8 9d 0c b4 fb 99 ba f0 7a 30 de 17 90 11 df 4b |........z0.....K| 00000050 87 8c 38 1d 3c 23 93 26 ab 0d fb 22 28 a1 e2 85 |..8.<#.&..."(...| 00000060 dd 7d 36 63 7e 48 5c ff 32 57 e7 87 dd 45 5f eb |.}6c~H\.2W...E_.| 00000070 9b 27 f5 82 cb 47 11 af 36 09 de 2e 4e c9 77 63 |.'...G..6...N.wc| 00000080 16 3c 1a 43 9b ad 5c 70 f8 55 e7 ec ba 05 0c 65 |.<.C..\p.U.....e| 00000090 60 c3 73 29 ca 2c 8b 7f eb ce bd 94 b8 69 37 7a |`.s).,.......i7z| 000000a0 ee b7 18 5e cd f3 86 14 d3 e4 72 44 37 86 0c 59 |...^......rD7..Y| 000000b0 67 89 d0 9b d5 0f 8a 56 b7 9f 59 33 95 6c c4 ef |g......V..Y3.l..| 000000c0 47 c3 51 6b 92 6b b0 88 a9 58 f6 1e 2c d9 71 e4 |G.Qk.k...X..,.q.| 000000d0 a3 f9 cb ea 27 61 e0 e6 8a 55 4a 8c 55 4e 37 0c |....'a...UJ.UN7.| 000000e0 da 55 a2 76 b9 f7 bd 36 ef 82 c4 49 88 52 47 3d |.U.v...6...I.RG=| 000000f0 b2 1f d8 23 b8 a8 b6 c1 9a a5 77 5c f2 39 30 fe |...#......w\.90.| 00000100 16 ed 37 a6 79 54 77 e0 f9 b5 41 20 bf 63 fc bb |..7.yTw...A .c..| 00000110 37 9e 4a a7 f5 f7 13 5e 56 a2 ac 7d 33 7d 20 86 |7.J....^V..}3} .| 00000120 c8 4d 95 cd 9c f8 d8 10 68 60 60 6e fc 4b 08 67 |.M......h``n.K.g| 00000130 74 e3 5a c9 ca 15 4f 86 92 c8 bc 34 3c c5 da a1 |t.Z...O....4<...| 00000140 1b 75 18 f8 ec a1 98 70 22 81 fd ec e1 18 46 4a |.u.....p".....FJ| 00000150 0c cf 9c 77 ec a1 b5 57 90 4c 79 8d b5 2a 56 fa |...w...W.Ly..*V.| 00000160 a7 1b 07 05 d8 f3 0b 3b 34 6c ab 3d eb 3e 2e ec |.......;4l.=.>..| 00000170 d9 8e 70 37 bc 47 86 b0 f3 22 e6 2e 72 10 b9 61 |..p7.G..."..r..a| 00000180 ac 41 14 19 ca 3e 89 00 c7 a3 08 ba 04 ea 5c b1 |.A...>........\.| 00000190 11 fd a2 98 7b 37 50 29 0c c9 25 a2 f1 0e 9f ac |....{7P)..%.....| 000001a0 53 97 22 44 72 a2 b1 36 ed 9c 16 0f 0a f0 78 f3 |S."Dr..6......x.| 000001b0 b1 61 1a d7 e8 30 ee 87 e4 1a 77 f0 11 ab 0d c5 |.a...0....w.....| 000001c0 ef 0b 14 ef ee 15 5b 25 35 fd 53 32 db f8 0b 68 |......[%5.S2...h| 000001d0 ae c7 c5 31 3f 6a 31 1c 63 65 03 49 a5 b4 48 e5 |...1?j1.ce.I..H.| 000001e0 ac d5 66 a6 f8 20 fb 84 51 ee ee 6f a5 41 dc 1f |..f.. ..Q..o.A..| 000001f0 27 3c 91 82 7c 87 8b c7 1d c6 2b c3 40 da 90 92 |'<..|.....+.@...| 00000200 58 ea f9 ed 06 5e 25 e5 5a dd d8 35 bf 95 07 84 |X....^%.Z..5....| 00000210 9e 9e f7 a7 de 08 de 2b 0f 68 75 de e5 7e 2d ef |.......+.hu..~-.| 00000220 10 c4 51 96 bb e0 a8 93 39 a0 5e 86 2e 3f 01 8a |..Q.....9.^..?..| 00000230 74 b8 3d b0 9c 2c 9b c2 2d e6 ce 64 ae cc f5 50 |t.=..,..-..d...P| 00000240 a9 c5 c0 9c 49 fb 53 56 96 97 48 72 07 30 01 e0 |....I.SV..Hr.0..| 00000250 a3 0b f7 2d 7d 12 e1 b6 e6 b3 f5 e7 da 21 b4 81 |...-}........!..| 00000260 e0 0a d3 b4 4a 9d b7 96 9f 53 49 f3 62 1e 64 75 |....J....SI.b.du| 00000270 f0 9f ef cb 4c b5 d4 2d 23 d2 9e 39 71 fc 17 8f |....L..-#..9q...| 00000280 5a 9b c7 65 c1 4e c7 4f 6b c4 a8 e1 b8 51 d5 08 |Z..e.N.Ok....Q..| 00000290 09 af 55 bf cf 7f a3 49 41 ea 0f 04 51 4b fd c5 |..U....IA...QK..| 000002a0 94 ed 1e 10 64 12 9c 53 26 7b 62 f9 3c ff 25 17 |....d..S&{b.<.%.| 000002b0 64 49 bf 78 85 9b e1 98 2a ed ca 5d 0f 47 57 3d |dI.x....*..].GW=| 000002c0 04 83 78 12 fb 79 c7 5a 2c 70 9c 83 bf d2 a5 e5 |..x..y.Z,p......| 000002d0 ee 9d 2e 46 17 42 23 35 18 29 4b d1 0d 7a 55 b0 |...F.B#5.)K..zU.| 000002e0 76 da 38 5a 20 14 9f 19 e3 60 dd 8a b7 b7 72 4a |v.8Z ....`....rJ| 000002f0 f8 e2 46 5f 4e 4e be db 86 03 26 72 53 d4 5d 25 |..F_NN....&rS.]%| 00000300 fa ec b7 e2 7e 8b 0b ae c9 6d ee a6 0e 5b 80 90 |....~....m...[..| 00000310 e1 c3 57 06 3f c3 5e 4f 6b c4 a8 e1 b8 51 d5 08 |..W.?.^Ok....Q..| 00000320 09 af 55 bf cf 7f a3 15 43 d1 5a 4e 10 4e dd 5e |..U.....C.ZN.N.^| 00000330 87 55 76 0e ce 9b 9f ea 56 87 73 b4 f5 5f 31 f8 |.Uv.....V.s.._1.| 00000340 81 b2 c0 2d d1 84 22 3a fb f7 e5 2f 8c 74 03 fa |...-..":.../.t..| 00000350 0b 97 e8 a8 92 21 23 cc 1a 7a 91 bc 33 ba ab dc |.....!#..z..3...| 00000360 0d 09 22 af d3 a0 2a 8c 2c 48 53 72 a7 a2 0f 1f |.."...*.,HSr....| 00000370 8c b5 ca a5 4b 62 3d c6 84 3f 9c 44 7c d9 f1 9c |....Kb=..?.D|...| 00000380 8a fe d7 1b 83 ff f7 10 5b bd 1e 89 6a cd 91 c0 |........[...j...| 00000390 3a 95 b6 d5 87 3b 8c 6e a9 4e e5 3f bd 90 9c 46 |:....;.n.N.?...F| 000003a0 0a ef d3 02 e3 8d 5c 35 ba c9 24 4b 99 a2 fb 13 |......\5..$K....| 000003b0 de bb 66 96 04 74 a1 76 73 50 41 54 70 5e 27 bb |..f..t.vsPATp^'.| 000003c0 24 b6 ae ee 35 b4 a9 13 bb 04 60 13 e8 c8 f2 9f |$...5.....`.....| 000003d0 f5 c7 36 27 81 b8 0b fb 7d 65 d1 7a 0f d9 3c 73 |..6'....}e.z..] 00000000 e0 00 |..| [mqtt <] 00000000 20 09 02 00 06 22 00 0a 21 00 14 | ...."..!..| [mqtt <] 00000000 90 04 00 01 00 00 |......| [local >] 00000000 00 00 00 15 31 2e 30 00 00 00 01 00 00 23 8c 68 |....1.0......#.h| 00000010 a6 a2 2c 00 00 b8 95 0e 86 |..,......| [local <] 00000000 00 00 00 27 31 2e 30 00 00 00 01 00 00 00 17 68 |...'1.0........h| 00000010 a6 a2 2b 00 01 00 10 6d b9 48 37 ed 43 59 7a 90 |..+....m.H7.CYz.| 00000020 ff 43 2f 0a 8f 81 44 e7 b6 b3 85 |.C/...D....| [local >] 00000000 00 00 00 77 31 2e 30 00 00 23 8e 00 00 23 8f 68 |...w1.0..#...#.h| 00000010 a6 a2 2e 00 04 00 60 a9 a0 ac af 22 80 bb 11 b7 |......`...."....| 00000020 e4 74 fa c3 0e bd c3 d5 a1 f9 a8 1d f1 4e 04 b9 |.t...........N..| 00000030 05 50 39 bc b6 68 62 5b fc 54 1c ce ac c5 df ce |.P9..hb[.T......| 00000040 93 8f 5c 61 d5 4c 66 8b c3 19 e0 cd b4 8f 63 be |..\a.Lf.......c.| 00000050 2b c0 16 46 4e c8 e7 70 d3 a4 de 0f ac 57 5b e6 |+..FN..p.....W[.| 00000060 79 84 2c ab 87 75 c7 7a d7 64 d6 51 3b 5a 04 85 |y.,..u.z.d.Q;Z..| 00000070 38 a4 39 90 99 6f 4c 84 b5 1b ba |8.9..oL....| [local <] 00000000 00 00 04 27 31 2e 30 00 00 00 02 00 00 00 17 68 |...'1.0........h| 00000010 a6 a2 2d 00 66 04 10 5e 5f 0b 3d ac 87 95 d7 72 |..-.f..^_.=....r| 00000020 96 43 49 74 68 c3 35 e3 c7 bd 9d d2 41 b1 15 99 |.CIth.5.....A...| 00000030 23 aa 66 a2 1a b7 54 da d3 d1 17 a7 d5 96 8c 29 |#.f...T........)| 00000040 1e a0 17 b7 40 ed 89 49 70 0d f9 1e aa b0 68 73 |....@..Ip.....hs| 00000050 6c 0e 82 1e 46 6b b7 7f 75 d6 7e b8 a9 4c d1 57 |l...Fk..u.~..L.W| 00000060 42 95 10 c8 f1 d9 04 9e b9 2c b6 3f dd 1c e5 2f |B........,.?.../| 00000070 08 1f 0f b7 e9 85 5a 34 4b 69 9b 6f 68 dd 45 74 |......Z4Ki.oh.Et| 00000080 d4 bc c6 ad 11 07 98 9f fb aa 7b 67 96 2f 67 60 |..........{g./g`| 00000090 e1 4b 2d 80 72 48 47 09 f0 c3 a2 65 a4 32 3b d2 |.K-.rHG....e.2;.| 000000a0 02 ac 38 76 72 30 27 9d 75 d6 1a aa 05 e5 08 c5 |..8vr0'.u.......| 000000b0 2e c4 3e ab 86 72 6b 61 ce d0 77 1e c4 6f 91 6f |..>..rka..w..o.o| 000000c0 29 d3 19 89 45 21 6b 1e 7a 27 76 e1 1a 71 32 0f |)...E!k.z'v..q2.| 000000d0 af bb 54 7d ea 30 19 e6 95 0b db 0b 88 0b eb 19 |..T}.0..........| 000000e0 18 f1 03 80 3e 2d d3 dd 47 61 93 d1 5a b9 b1 13 |....>-..Ga..Z...| 000000f0 c6 5c b1 f6 52 0a 94 6f 27 05 fe 67 80 3d 3a 51 |.\..R..o'..g.=:Q| 00000100 d6 65 28 10 23 4f 42 9c d6 40 c3 3e c6 64 13 a8 |.e(.#OB..@.>.d..| 00000110 c1 7d 13 17 d2 a0 a1 7b 6d 35 a5 62 73 75 f0 9b |.}.....{m5.bsu..| 00000120 e9 32 96 62 14 0e 97 b3 73 b1 df 48 b4 4c 96 b1 |.2.b....s..H.L..| 00000130 52 8d d5 5b 2f b2 21 f0 e6 81 9b e4 a3 68 11 6c |R..[/.!......h.l| 00000140 bd d8 6c 4c ba a3 1a fd 12 4a c8 e1 39 ce 74 f5 |..lL.....J..9.t.| 00000150 52 71 4d 28 f1 d4 8e a6 68 95 fb e2 b9 f6 62 10 |RqM(....h.....b.| 00000160 a0 51 1b 44 13 53 71 1d 32 94 54 e8 d8 3a d9 d1 |.Q.D.Sq.2.T..:..| 00000170 15 e3 f9 09 91 38 2a ba 4c e1 ca a8 6e 3f cf 38 |.....8*.L...n?.8| 00000180 8a 8f 38 f7 da d1 b4 13 17 df 84 d4 74 0c 2a 65 |..8.........t.*e| 00000190 64 96 ec 5e 9b fb 4d f5 eb ab e8 9a 91 ae 3b d1 |d..^..M.......;.| 000001a0 ac 54 66 a1 18 65 92 f9 7c 67 a0 e2 d2 7e 79 3f |.Tf..e..|g...~y?| 000001b0 5a 9d 31 8d f4 98 cd cf 1b c5 51 04 12 05 34 6c |Z.1.......Q...4l| 000001c0 36 4e 9a 00 2b 10 9c 53 1f cf fe ff 28 d3 ab fe |6N..+..S....(...| 000001d0 dc 7c 85 fb 23 06 00 58 2f 4d 36 fb 1b 04 76 a4 |.|..#..X/M6...v.| 000001e0 7f 73 b8 58 b1 1a 22 2c e6 27 29 09 c7 48 72 f7 |.s.X..",.')..Hr.| 000001f0 63 ce 7b ff 61 60 23 8a 92 06 a0 fa cc fd 6a d0 |c.{.a`#.......j.| 00000200 13 e5 24 33 f7 2c c2 95 06 06 d8 c8 28 21 09 c9 |..$3.,......(!..| 00000210 d8 d5 52 67 a6 81 68 80 00 f1 8b d9 dc bf 16 ea |..Rg..h.........| 00000220 8f 76 2f bd 6e a8 89 77 94 d3 58 52 0e da aa 06 |.v/.n..w..XR....| 00000230 2c 4d 35 89 23 45 8a 58 d0 b1 19 fb d4 05 da f0 |,M5.#E.X........| 00000240 6f 2f 00 e5 11 85 4e 34 a8 55 13 39 a9 2a 46 75 |o/....N4.U.9.*Fu| 00000250 52 e1 1f 66 e3 b8 2a 55 ab b0 ad ea 9b b6 4b 4d |R..f..*U......KM| 00000260 9c 79 9b 09 d1 b6 61 48 89 37 b0 98 3c bf bb 4a |.y....aH.7..<..J| 00000270 f1 22 e2 dd ad e6 3c 98 f6 d0 1f 14 c1 73 ed 7e |."....<......s.~| 00000280 f8 9a fb 4e 64 e9 2f d9 f9 94 58 b1 f4 9d 67 13 |...Nd./...X...g.| 00000290 6d 08 4b 6e 1b ca d6 76 02 3f b0 2c fc 0b 0b 32 |m.Kn...v.?.,...2| 000002a0 ce 38 a1 05 86 d3 db ce dd 06 63 49 e8 c1 d1 41 |.8........cI...A| 000002b0 00 e0 3b 0a ed 7b 7a b1 04 86 a8 bb 1b 18 52 63 |..;..{z.......Rc| 000002c0 db 42 2a c3 41 d8 1c 3c 31 ef 6b 72 7e c2 54 a5 |.B*.A..<1.kr~.T.| 000002d0 20 94 23 b9 8d d0 5e 94 ef 85 b0 73 47 5d 7c f0 | .#...^....sG]|.| 000002e0 ef ac 82 55 13 04 ba ca 4f 1b af fb 5c 0c b9 e7 |...U....O...\...| 000002f0 21 c7 97 75 d3 db 18 51 d1 92 5c 51 97 8a ba 3e |!..u...Q..\Q...>| 00000300 79 80 5c c9 21 8f 6b 8f 04 9e 9a 47 ed ef fe 66 |y.\.!.k....G...f| 00000310 fe d3 76 5b ff dc 31 d9 f9 94 58 b1 f4 9d 67 13 |..v[..1...X...g.| 00000320 6d 08 4b 6e 1b ca d6 7f 0c de c5 f4 a5 0f 49 cf |m.Kn..........I.| 00000330 f9 e5 74 88 96 8a 14 bd 8a 19 0a ce 93 be 30 b8 |..t...........0.| 00000340 35 1a 1e bb f3 0b ed 34 28 7f 73 ca 76 4a 8d 03 |5......4(.s.vJ..| 00000350 81 0a 2c 2c cc fe e8 c6 e2 dd 9d e0 be 45 63 57 |..,,.........EcW| 00000360 ae 34 40 e1 c4 5c 2b ee 40 68 5e d0 35 83 94 ce |.4@..\+.@h^.5...| 00000370 be 2a 27 08 a5 dc 00 d5 b6 af 48 b3 6b 57 99 80 |.*'.......H.kW..| 00000380 ba e8 2d 7e b5 57 4e 91 5e 15 7a 13 af 9d ed 24 |..-~.WN.^.z....$| 00000390 03 6a 35 fb 75 b0 56 36 90 70 ff 59 f9 3f dd 7e |.j5.u.V6.p.Y.?.~| 000003a0 09 1b 05 a1 38 dc 1c 12 5b 9c 4d 82 b2 32 95 1f |....8...[.M..2..| 000003b0 80 4f 29 53 51 9f b3 12 72 1a b4 b9 79 d5 7b 74 |.O)SQ...r...y.{t| 000003c0 99 94 71 90 83 b5 51 1a 47 4e 99 ae 54 78 98 fe |..q...Q.GN..Tx..| 000003d0 38 91 1e 5e 67 72 1b f1 b8 7d 66 83 55 21 94 7c |8..^gr...}f.U!.|| 000003e0 d6 79 8b 61 65 0e 0d 46 c4 3b 6f e8 0e f2 ec 4b |.y.ae..F.;o....K| 000003f0 b6 60 24 40 83 2b 94 06 de a8 88 76 d6 c4 7b 17 |.`$@.+.....v..{.| 00000400 77 50 dd e5 02 1f 70 86 1b f5 f5 56 19 5e 54 00 |wP....p....V.^T.| 00000410 cc 52 61 40 f9 d5 02 3c dd 7d 66 3a 06 89 a0 7a |.Ra@...<.}f:...z| 00000420 98 80 37 eb 64 ea ee b2 82 5a 2a |..7.d....Z*| # --- Python-roborock-python-roborock-d6da2db/tests/e2e/__snapshots__/test_local_session.ambr000066400000000000000000000056351513363643200317270ustar00rootroot00000000000000# serializer version: 1 # name: test_connect [local >] 00000000 00 00 00 15 31 2e 30 00 00 00 01 00 00 23 82 68 |....1.0......#.h| 00000010 a6 a2 24 00 00 e6 b9 24 63 |..$....$c| [local <] 00000000 00 00 00 27 31 2e 30 00 00 00 01 00 00 00 17 68 |...'1.0........h| 00000010 a6 a2 23 00 01 00 10 cb 93 c7 39 b9 21 53 43 48 |..#.......9.!SCH| 00000020 83 b3 c2 af 0f 51 2c da 9e ea 3b |.....Q,...;| # --- # name: test_l01_session [local >] 00000000 00 00 00 15 31 2e 30 00 00 00 01 00 00 23 82 68 |....1.0......#.h| 00000010 a6 a2 24 00 00 e6 b9 24 63 |..$....$c| [local <] 00000000 00 |.| [local >] 00000000 00 00 00 15 4c 30 31 00 00 00 01 00 00 23 82 68 |....L01......#.h| 00000010 a6 a2 25 00 00 ee 2f 30 e8 |..%.../0.| [local <] 00000000 00 00 00 29 4c 30 31 00 00 00 01 00 00 00 17 68 |...)L01........h| 00000010 a6 a2 23 00 01 00 12 a0 4a ec 75 88 03 75 0f d2 |..#.....J.u..u..| 00000020 40 33 69 02 f4 71 50 72 f3 81 56 80 f4 |@3i..qPr..V..| [local >] 00000000 00 00 00 3e 4c 30 31 00 00 00 7b 00 00 23 83 68 |...>L01...{..#.h| 00000010 a6 a2 26 00 65 00 27 9e fd c2 42 b7 01 b4 eb 9c |..&.e.'...B.....| 00000020 00 84 4f fd 51 1f bc a5 65 12 c2 dc 45 0e 21 cb |..O.Q...e...E.!.| 00000030 45 dc bb 0a ba 16 84 28 a7 33 e5 e2 fa a8 f1 f2 |E......(.3......| 00000040 ec f4 |..| [local <] 00000000 00 00 00 37 4c 30 31 00 00 00 7b 00 00 00 17 68 |...7L01...{....h| 00000010 a6 a2 27 00 66 00 20 b7 72 49 8a 64 eb 16 a5 71 |..'.f. .rI.d...q| 00000020 73 eb 9e 7e 37 64 3e 75 c0 70 ea 39 4e de 82 1f |s..~7d>u.p.9N...| 00000030 e2 29 86 de 4a 7b 38 20 55 12 8a |.)..J{8 U..| # --- # name: test_send_command [local >] 00000000 00 00 00 15 31 2e 30 00 00 00 01 00 00 23 82 68 |....1.0......#.h| 00000010 a6 a2 24 00 00 e6 b9 24 63 |..$....$c| [local <] 00000000 00 00 00 27 31 2e 30 00 00 00 01 00 00 00 17 68 |...'1.0........h| 00000010 a6 a2 23 00 01 00 10 cb 93 c7 39 b9 21 53 43 48 |..#.......9.!SCH| 00000020 83 b3 c2 af 0f 51 2c da 9e ea 3b |.....Q,...;| [local >] 00000000 00 00 00 37 31 2e 30 00 00 00 7b 00 00 23 83 68 |...71.0...{..#.h| 00000010 a6 a2 25 00 65 00 20 91 5b 1f 43 34 d5 22 47 9f |..%.e. .[.C4."G.| 00000020 59 4e 45 53 85 f9 c6 6e f2 eb 27 eb 6d 03 d8 92 |YNES...n..'.m...| 00000030 5b 30 83 b4 a4 ea f5 85 be 38 57 |[0.......8W| [local <] 00000000 00 00 00 37 31 2e 30 00 00 00 7b 00 00 00 17 68 |...71.0...{....h| 00000010 a6 a2 26 00 66 00 20 07 8b 28 60 a8 08 18 12 47 |..&.f. ..(`....G| 00000020 05 20 3e f5 53 e3 fd 4a cc 03 72 7b b4 2c d9 84 |. >.S..J..r{.,..| 00000030 7f 4b 18 d8 76 7d 5c 65 87 7c 2d |.K..v}\e.|-| # --- Python-roborock-python-roborock-d6da2db/tests/e2e/__snapshots__/test_mqtt_session.ambr000066400000000000000000000031651513363643200316160ustar00rootroot00000000000000# serializer version: 1 # name: test_session_e2e_publish_message [mqtt >] 00000000 10 21 00 04 4d 51 54 54 05 c2 00 3c 00 00 00 00 |.!..MQTT...<....| 00000010 08 75 73 65 72 6e 61 6d 65 00 08 70 61 73 73 77 |.username..passw| 00000020 6f 72 64 |ord| [mqtt <] 00000000 20 09 02 00 06 22 00 0a 21 00 14 | ...."..!..| [mqtt >] 00000000 30 41 00 07 74 6f 70 69 63 2d 31 00 31 2e 30 00 |0A..topic-1.1.0.| 00000010 00 01 c8 00 00 23 82 68 a6 a2 23 00 65 00 20 91 |.....#.h..#.e. .| 00000020 22 f1 91 1a 6e 89 71 ca ec 2d 44 2a 16 57 e7 5b |"...n.q..-D*.W.[| 00000030 4a 9a c8 97 4b 13 37 3b f5 81 13 45 7c e7 48 03 |J...K.7;...E|.H.| 00000040 99 71 bf |.q.| # --- # name: test_session_e2e_receive_message [mqtt >] 00000000 10 21 00 04 4d 51 54 54 05 c2 00 3c 00 00 00 00 |.!..MQTT...<....| 00000010 08 75 73 65 72 6e 61 6d 65 00 08 70 61 73 73 77 |.username..passw| 00000020 6f 72 64 |ord| [mqtt <] 00000000 20 09 02 00 06 22 00 0a 21 00 14 | ...."..!..| [mqtt <] 00000000 90 04 00 01 00 00 |......| [mqtt >] 00000000 82 0d 00 01 00 00 07 74 6f 70 69 63 2d 31 00 |.......topic-1.| [mqtt <] 00000000 30 31 00 07 74 6f 70 69 63 2d 31 00 31 2e 30 00 |01..topic-1.1.0.| 00000010 00 00 7b 00 00 23 82 68 a6 a2 23 00 66 00 10 45 |..{..#.h..#.f..E| 00000020 3b c3 2b 12 a6 77 d9 55 f6 e0 89 f5 93 a5 30 5d |;.+..w.U......0]| 00000030 a0 72 fa |.r.| # --- Python-roborock-python-roborock-d6da2db/tests/e2e/test_device_manager.py000066400000000000000000000505551513363643200267150ustar00rootroot00000000000000"""End-to-end tests for MQTT session. These tests use a fake MQTT broker to verify the session implementation. We mock out the lower level socket connections to simulate a broker which gets us close to an "end to end" test without needing an actual MQTT broker server. These are higher level tests than the similar tests in tests/mqtt/test_roborock_session.py which use mocks to verify specific behaviors. """ import asyncio import json from collections.abc import AsyncGenerator, Awaitable, Callable from typing import Any import pytest import syrupy from Crypto.Cipher import AES from Crypto.Util.Padding import pad from roborock.data.b01_q7 import WorkStatusMapping from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP from roborock.data.containers import UserData from roborock.data.zeo.zeo_code_mappings import ZeoState from roborock.devices.cache import Cache, InMemoryCache from roborock.devices.device_manager import DeviceManager, UserParams, create_device_manager from roborock.protocol import MessageParser from roborock.protocols.a01_protocol import A01_VERSION from roborock.protocols.b01_q7_protocol import B01_VERSION from roborock.protocols.v1_protocol import LocalProtocolVersion from roborock.roborock_message import RoborockB01Props, RoborockMessage, RoborockMessageProtocol, RoborockZeoProtocol from roborock.web_api import RoborockApiClient from tests import mock_data, mqtt_packet from tests.fixtures.logging import CapturedRequestLog from tests.mock_data import HOME_DATA_RAW, LOCAL_KEY TEST_USERNAME = "user@example.com" TEST_CODE = 1234 # The topic used for the user + device. This is determined from the fake Home # data API response. TEST_TOPIC_FORMAT = "rr/m/o/user123/19648f94/{duid}" TEST_RANDOM = 23 TEST_HOST = mock_data.TEST_LOCAL_API_HOST NETWORK_INFO = { "ip": TEST_HOST, "ssid": "test_wifi", "mac": "aa:bb:cc:dd:ee:ff", "bssid": "aa:bb:cc:dd:ee:ff", "rssi": -50, } # For tests that want to skip the web API login flow TEST_USER_PARAMS = UserParams( username=TEST_USERNAME, user_data=UserData.from_dict(mock_data.USER_DATA), base_url=mock_data.BASE_URL, ) MQTT_DEFAULT_RESPONSES: list[bytes] = [ # MQTT connection response mqtt_packet.gen_connack(rc=0, flags=2), # ACK the request to subscribe to the topic mqtt_packet.gen_suback(mid=1), ] @pytest.fixture(autouse=True) def auto_mock_mqtt_client(mock_aiomqtt_client: None) -> None: """Automatically use the mock mqtt client fixture.""" @pytest.fixture(autouse=True) def auto_fast_backoff(fast_backoff_fixture: None) -> None: """Automatically use the fast backoff fixture.""" @pytest.fixture(autouse=True) def mqtt_server_fixture(mock_paho_mqtt_create_connection: None, mock_paho_mqtt_select: None) -> None: """Fixture to mock the MQTT connection. This is here to pull in the mock socket fixtures into all tests used here. """ @pytest.fixture(autouse=True) def auto_mock_local_client(mock_async_create_local_connection: None) -> None: """Automatically use the mock local client fixture.""" @pytest.fixture(name="device_manager_factory") async def device_manager_factory_fixture() -> AsyncGenerator[Callable[[UserParams], Awaitable[DeviceManager]], None]: """Fixture to create a device manager and handle auto shutdown on test failure.""" cleanup_tasks: list[Callable[[], Awaitable[None]]] = [] cache: Cache = InMemoryCache() async def factory(user_params: UserParams) -> DeviceManager: """Create a device manager and auto cleanup.""" device_manager = await create_device_manager(user_params, cache=cache) cleanup_tasks.append(device_manager.close) return device_manager yield factory await asyncio.gather(*[task() for task in cleanup_tasks]) class ResponseBuilder: """Utility class to build raw response messages. This helps keep track of sequence numbers and timestamps mostly to remove them from the main test body. These are mostly ignored by the client in the response. """ def __init__(self) -> None: """Initialize the response builder.""" self.seq_counter = 0 self.timestamp_counter = 1766520441 self.connect_nonce: int | None = None self.ack_nonce: int | None = None self.protocol = RoborockMessageProtocol.RPC_RESPONSE self.version: bytes = LocalProtocolVersion.V1.value.encode() def build_v1( self, payload: bytes, protocol: RoborockMessageProtocol | None = None, ) -> bytes: """Build an encoded response message.""" self.seq_counter += 1 return self._encrypt( self._build_roborock_message( payload=payload, protocol=protocol if protocol is not None else self.protocol, ), ) def build_v1_rpc( self, data: dict[str, Any], ) -> bytes: """Build an encoded RPC response message.""" self.timestamp_counter += 1 return self.build_v1( payload=json.dumps( { "t": self.timestamp_counter, "dps": { "102": json.dumps(data), }, } ).encode(), ) def build_a01_rpc(self, data: dict[str, Any]) -> bytes: """Build an encoded A01 RPC response message.""" self.timestamp_counter += 1 return self._encrypt( self._build_roborock_message( payload=pad(json.dumps({"dps": data}).encode(), AES.block_size), ), ) def build_b01_q7_rpc(self, data: dict[str, Any] | str, code: int | None = None, msg_id: int | None = None) -> bytes: """Build an encoded B01 RPC response message.""" message: dict[str, Any] = { "msgId": str(msg_id), "data": data, } if code is not None: message["code"] = code return self._build_b01_dps(message) def _build_b01_dps(self, message: dict[str, Any] | str) -> bytes: """Build an encoded B01 RPC response message given an inner message.""" dps_payload = {"dps": {"10000": json.dumps(message)}} self.seq_counter += 1 return self._encrypt( self._build_roborock_message( payload=json.dumps(dps_payload).encode(), ), ) def _build_roborock_message( self, payload: bytes, protocol: RoborockMessageProtocol | None = None, ) -> RoborockMessage: """Build a Roborock message.""" return RoborockMessage( protocol=protocol if protocol is not None else self.protocol, random=TEST_RANDOM, seq=self.seq_counter, payload=payload, version=self.version, ) def _encrypt(self, message: RoborockMessage) -> bytes: """Encrypt a message.""" return MessageParser.build( message, local_key=LOCAL_KEY, connect_nonce=self.connect_nonce, ack_nonce=self.ack_nonce, ) async def test_v1_device( mock_rest: Any, push_mqtt_response: Callable[[bytes], None], local_response_queue: asyncio.Queue[bytes], local_received_requests: asyncio.Queue[bytes], log: CapturedRequestLog, snapshot: syrupy.SnapshotAssertion, device_manager_factory: Callable[[UserParams], Awaitable[DeviceManager]], ) -> None: """Test the device manager end to end flow with a v1 device.""" # Simulate the login flow to get user params web_api = RoborockApiClient(username=TEST_USERNAME) await web_api.request_code() user_data = await web_api.code_login(TEST_CODE) # Prepare MQTT requests response_builder = ResponseBuilder() test_topic = TEST_TOPIC_FORMAT.format(duid="abc123") mqtt_responses: list[bytes] = [ *MQTT_DEFAULT_RESPONSES, # ACK the GET_NETWORK_INFO call. id is deterministic based on deterministic_message_fixtures mqtt_packet.gen_publish( test_topic, mid=2, payload=response_builder.build_v1_rpc(data={"id": 9090, "result": NETWORK_INFO}) ), ] for response in mqtt_responses: push_mqtt_response(response) # Prepare local device responses. The ids are deterministic based on deterministic_message_fixtures response_builder.seq_counter = 0 local_responses: list[bytes] = [ # Queue HELLO response response_builder.build_v1(protocol=RoborockMessageProtocol.HELLO_RESPONSE, payload=b"ok"), # Feature discovery part 1 & 2 response_builder.build_v1_rpc(data={"id": 9094, "result": [mock_data.APP_GET_INIT_STATUS]}), response_builder.build_v1_rpc(data={"id": 9097, "result": [mock_data.STATUS]}), ] for payload in local_responses: local_response_queue.put_nowait(payload) # Create the device manager user_params = UserParams( username=TEST_USERNAME, user_data=user_data, base_url=await web_api.base_url, ) device_manager = await device_manager_factory(user_params) # The mocked Home Data API returns a single v1 device devices = await device_manager.get_devices() assert len(devices) == 1 device = devices[0] assert device.duid == "abc123" assert device.name == "Roborock S7 MaxV" assert device.is_connected assert device.is_local_connected # Verify GET_STATUS response based on mock_data.STATUS assert device.v1_properties assert device.v1_properties.status assert device.v1_properties.status.state_name == "charging" assert device.v1_properties.status.battery == 100 assert device.v1_properties.status.clean_time == 1176 # Verify arbitrary device features assert device.v1_properties.device_features.is_show_clean_finish_reason_supported assert device.v1_properties.device_features.is_customized_clean_supported assert not device.v1_properties.device_features.is_matter_supported # Close the device manager. We will test re-connecting and reusing the network # information and device discovery information from the cache. await device_manager.close() mqtt_responses = [ *MQTT_DEFAULT_RESPONSES, # No network info call this time since it should be cached ] for response in mqtt_responses: push_mqtt_response(response) # Prepare local device responses. response_builder.seq_counter = 0 local_response_queue.put_nowait( response_builder.build_v1(protocol=RoborockMessageProtocol.HELLO_RESPONSE, payload=b"ok") ) device_manager = await device_manager_factory(user_params) # The mocked Home Data API returns a single v1 device devices = await device_manager.get_devices() assert len(devices) == 1 device = devices[0] assert device.duid == "abc123" assert device.name == "Roborock S7 MaxV" assert device.is_connected assert device.is_local_connected # Verify arbitrary device features from cache assert device.v1_properties assert device.v1_properties.device_features assert device.v1_properties.device_features.is_show_clean_finish_reason_supported assert device.v1_properties.device_features.is_customized_clean_supported assert not device.v1_properties.device_features.is_matter_supported # In the previous test, the dock information is fetched and has the side effect of # populating the status trait. This test gets dock information from the cache so # we have to manually refresh status the first time (like other traits). assert device.v1_properties assert device.v1_properties.status assert device.v1_properties.status.state_name is None # Exercise a GET_STATUS call. id is deterministic based on deterministic_message_fixtures local_response_queue.put_nowait(response_builder.build_v1_rpc(data={"id": 9101, "result": [mock_data.STATUS]})) # Verify GET_STATUS response await device.v1_properties.status.refresh() assert device.v1_properties.status.state_name == "charging" assert snapshot == log async def test_l01_device( mock_rest: Any, push_mqtt_response: Callable[[bytes], None], local_response_queue: asyncio.Queue[bytes], local_received_requests: asyncio.Queue[bytes], log: CapturedRequestLog, snapshot: syrupy.SnapshotAssertion, device_manager_factory: Callable[[UserParams], Awaitable[DeviceManager]], ) -> None: """Test the device manager end to end flow with a l01 device.""" # Prepare MQTT requests mqtt_response_builder = ResponseBuilder() test_topic = TEST_TOPIC_FORMAT.format(duid="abc123") mqtt_responses: list[bytes] = [ *MQTT_DEFAULT_RESPONSES, # ACK the GET_NETWORK_INFO call. id is deterministic based on deterministic_message_fixtures mqtt_packet.gen_publish( test_topic, mid=2, payload=mqtt_response_builder.build_v1_rpc(data={"id": 9090, "result": NETWORK_INFO}) ), ] for response in mqtt_responses: push_mqtt_response(response) # Prepare local device responses. The ids are deterministic based on deterministic_message_fixtures local_response_builder = ResponseBuilder() local_response_builder.version = LocalProtocolVersion.L01.value.encode() local_response_builder.connect_nonce = 9093 local_responses: list[bytes] = [ # Initial V1.0 Hello request will fail and cause a retry with L01 b"\x00", # Queue HELLO response with L01 local_response_builder.build_v1(protocol=RoborockMessageProtocol.HELLO_RESPONSE, payload=b"ok"), ] # Feature discovery requests are sent with an ack nonce based on the random sent in HELLO_RESPONSE local_response_builder.ack_nonce = TEST_RANDOM local_responses.extend( [ local_response_builder.build_v1_rpc(data={"id": 9094, "result": [mock_data.APP_GET_INIT_STATUS]}), local_response_builder.build_v1_rpc(data={"id": 9097, "result": [mock_data.STATUS]}), ] ) for payload in local_responses: local_response_queue.put_nowait(payload) # Create the device manager device_manager = await device_manager_factory(TEST_USER_PARAMS) # The mocked Home Data API returns a single v1 device devices = await device_manager.get_devices() assert len(devices) == 1 device = devices[0] assert device.duid == "abc123" assert device.name == "Roborock S7 MaxV" assert device.is_connected assert device.is_local_connected # Verify GET_STATUS response based on mock_data.STATUS assert device.v1_properties assert device.v1_properties.status assert device.v1_properties.status.state_name == "charging" assert device.v1_properties.status.battery == 100 assert device.v1_properties.status.clean_time == 1176 # Verify arbitrary device features assert device.v1_properties.device_features.is_show_clean_finish_reason_supported assert device.v1_properties.device_features.is_customized_clean_supported assert not device.v1_properties.device_features.is_matter_supported assert snapshot == log @pytest.mark.parametrize( "home_data", [ ( { **HOME_DATA_RAW, "devices": [mock_data.Q10_DEVICE_DATA], "products": [mock_data.SS07_PRODUCT_DATA], } ) ], ) async def test_q10_device( mock_rest: Any, push_mqtt_response: Callable[[bytes], None], log: CapturedRequestLog, device_manager_factory: Callable[[UserParams], Awaitable[DeviceManager]], home_data: dict[str, Any], snapshot: syrupy.SnapshotAssertion, ) -> None: """Test the device manager end to end flow with a B01 Q10 device.""" # Prepare MQTT requests for response in MQTT_DEFAULT_RESPONSES: push_mqtt_response(response) # Create the device manager device_manager = await device_manager_factory(TEST_USER_PARAMS) # The mocked Home Data API returns a single v1 device devices = await device_manager.get_devices() assert len(devices) == 1 device = devices[0] assert device.duid == "device-id-def456" assert device.name == "Roborock Q10 S5+" assert device.is_connected assert not device.is_local_connected # Q10 does not support local connections # Send a command. We don't block any response, but just use this to verify # against the golden byte stream snapshot. assert device.b01_q10_properties command = device.b01_q10_properties.command await command.send(B01_Q10_DP.REQUETDPS, params={}) # In the future here we can verify receiving requests from the device assert snapshot == log @pytest.mark.parametrize( "home_data", [ ( { **HOME_DATA_RAW, "devices": [mock_data.Q7_DEVICE_DATA], "products": [mock_data.SC01_PRODUCT_DATA], } ) ], ) async def test_q7_device( mock_rest: Any, push_mqtt_response: Callable[[bytes], None], log: CapturedRequestLog, device_manager_factory: Callable[[UserParams], Awaitable[DeviceManager]], home_data: dict[str, Any], snapshot: syrupy.SnapshotAssertion, ) -> None: """Test the device manager end to end flow with a B01 Q10 device.""" # Prepare MQTT requests response_builder = ResponseBuilder() response_builder.version = B01_VERSION test_topic = TEST_TOPIC_FORMAT.format(duid="device-id-q7") mqtt_responses: list[bytes] = [ *MQTT_DEFAULT_RESPONSES, # ACK the Query status call sent below. id is deterministic based on deterministic_message_fixtures mqtt_packet.gen_publish( test_topic, mid=2, payload=response_builder.build_b01_q7_rpc({"status": 2}, msg_id=9090) ), # ACK the start clean call sent below. id is deterministic based on deterministic_message_fixtures mqtt_packet.gen_publish(test_topic, mid=2, payload=response_builder.build_b01_q7_rpc("ok", msg_id=9093)), ] for response in mqtt_responses: push_mqtt_response(response) # Create the device manager device_manager = await device_manager_factory(TEST_USER_PARAMS) # The mocked Home Data API returns a single v1 device devices = await device_manager.get_devices() assert len(devices) == 1 device = devices[0] assert device.duid == "device-id-q7" assert device.name == "Roborock Q7" assert device.is_connected assert not device.is_local_connected # Q7 does not support local connections # Query a value from the device. assert device.b01_q7_properties props = await device.b01_q7_properties.query_values([RoborockB01Props.STATUS]) assert props assert props.status == WorkStatusMapping.PAUSED # Send a command and block on an OK response. await device.b01_q7_properties.start_clean() assert snapshot == log @pytest.mark.parametrize( "home_data", [ ( { **HOME_DATA_RAW, "devices": [mock_data.ZEO_ONE_DEVICE_DATA], "products": [mock_data.A102_PRODUCT_DATA], } ) ], ) async def test_a01_device( mock_rest: Any, push_mqtt_response: Callable[[bytes], None], log: CapturedRequestLog, device_manager_factory: Callable[[UserParams], Awaitable[DeviceManager]], home_data: dict[str, Any], snapshot: syrupy.SnapshotAssertion, ) -> None: """Test the device manager end to end flow with an A01 device.""" # Prepare MQTT requests response_builder = ResponseBuilder() response_builder.version = A01_VERSION test_topic = TEST_TOPIC_FORMAT.format(duid="zeo_duid") mqtt_responses: list[bytes] = [ *MQTT_DEFAULT_RESPONSES, # ACK the Query state call sent below. id is deterministic based on deterministic_message_fixtures mqtt_packet.gen_publish(test_topic, mid=2, payload=response_builder.build_a01_rpc({"203": 6})), ] for response in mqtt_responses: push_mqtt_response(response) # Create the device manager device_manager = await device_manager_factory(TEST_USER_PARAMS) # The mocked Home Data API returns a single v1 device devices = await device_manager.get_devices() assert len(devices) == 1 device = devices[0] assert device.duid == "zeo_duid" assert device.name == "Zeo One" assert device.is_connected assert not device.is_local_connected # Washing Machine does not support local connections # Query a value from the device. assert device.zeo props: dict[RoborockZeoProtocol, Any] = await device.zeo.query_values([RoborockZeoProtocol.STATE]) assert props assert props[RoborockZeoProtocol.STATE] == ZeoState.spinning.name assert snapshot == log Python-roborock-python-roborock-d6da2db/tests/e2e/test_local_session.py000066400000000000000000000214161513363643200266130ustar00rootroot00000000000000"""End-to-end tests for LocalChannel using fake sockets.""" import asyncio from collections.abc import AsyncGenerator import pytest import syrupy from roborock.devices.transport.local_channel import LocalChannel from roborock.protocol import MessageParser, create_local_decoder from roborock.protocols.v1_protocol import LocalProtocolVersion from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol from tests.fixtures.logging import CapturedRequestLog from tests.fixtures.mqtt import Subscriber from tests.mock_data import LOCAL_KEY TEST_HOST = "192.168.1.100" TEST_DEVICE_UID = "test_device_uid" TEST_RANDOM = 23 @pytest.fixture(name="local_channel") async def local_channel_fixture(mock_async_create_local_connection: None) -> AsyncGenerator[LocalChannel, None]: channel = LocalChannel(host=TEST_HOST, local_key=LOCAL_KEY, device_uid=TEST_DEVICE_UID) yield channel channel.close() def build_raw_response( protocol: RoborockMessageProtocol, seq: int, payload: bytes, version: LocalProtocolVersion = LocalProtocolVersion.V1, connect_nonce: int | None = None, ack_nonce: int | None = None, ) -> bytes: """Build an encoded response message.""" message = RoborockMessage( protocol=protocol, random=23, seq=seq, payload=payload, version=version.value.encode(), ) return MessageParser.build(message, local_key=LOCAL_KEY, connect_nonce=connect_nonce, ack_nonce=ack_nonce) async def test_connect( local_channel: LocalChannel, local_response_queue: asyncio.Queue[bytes], local_received_requests: asyncio.Queue[bytes], log: CapturedRequestLog, snapshot: syrupy.SnapshotAssertion, ) -> None: """Test connecting to the device.""" # Queue HELLO response with payload to ensure it can be parsed local_response_queue.put_nowait(build_raw_response(RoborockMessageProtocol.HELLO_RESPONSE, 1, payload=b"ok")) await local_channel.connect() assert local_channel.is_connected assert local_received_requests.qsize() == 1 # Verify HELLO request request_bytes = await local_received_requests.get() # Note: We cannot use create_local_decoder here because HELLO_REQUEST has payload=None # which causes MessageParser to fail parsing. For now we verify the raw bytes. # Protocol is at offset 19 (2 bytes) # Prefix(4) + Version(3) + Seq(4) + Random(4) + Timestamp(4) = 19 assert len(request_bytes) >= 21 protocol_bytes = request_bytes[19:21] assert int.from_bytes(protocol_bytes, "big") == RoborockMessageProtocol.HELLO_REQUEST assert snapshot == log async def test_send_command( local_channel: LocalChannel, local_response_queue: asyncio.Queue[bytes], local_received_requests: asyncio.Queue[bytes], log: CapturedRequestLog, snapshot: syrupy.SnapshotAssertion, ) -> None: """Test sending a command.""" # Queue HELLO response local_response_queue.put_nowait(build_raw_response(RoborockMessageProtocol.HELLO_RESPONSE, 1, payload=b"ok")) await local_channel.connect() # Clear requests from handshake while not local_received_requests.empty(): await local_received_requests.get() # Send command cmd_seq = 123 msg = RoborockMessage( protocol=RoborockMessageProtocol.RPC_REQUEST, seq=cmd_seq, payload=b'{"method":"get_status"}', ) # Prepare a fake response to the command. local_response_queue.put_nowait( build_raw_response(RoborockMessageProtocol.RPC_RESPONSE, cmd_seq, payload=b'{"status": "ok"}') ) subscriber = Subscriber() unsub = await local_channel.subscribe(subscriber.append) await local_channel.publish(msg) # Verify request received by the server request_bytes = await local_received_requests.get() assert local_received_requests.empty() # Decode request decoder = create_local_decoder(local_key=LOCAL_KEY) msgs = list(decoder(request_bytes)) assert len(msgs) == 1 assert msgs[0].protocol == RoborockMessageProtocol.RPC_REQUEST assert msgs[0].payload == b'{"method":"get_status"}' assert msgs[0].version == LocalProtocolVersion.V1.value.encode() # Verify response received by subscriber await subscriber.wait() assert len(subscriber.messages) == 1 response_message = subscriber.messages[0] assert isinstance(response_message, RoborockMessage) assert response_message.protocol == RoborockMessageProtocol.RPC_RESPONSE assert response_message.payload == b'{"status": "ok"}' unsub() assert snapshot == log async def test_l01_session( local_channel: LocalChannel, local_response_queue: asyncio.Queue[bytes], local_received_requests: asyncio.Queue[bytes], log: CapturedRequestLog, snapshot: syrupy.SnapshotAssertion, ) -> None: """Test connecting to a device that speaks the L01 protocol. Note that this test currently has a delay because the actual local client will delay before retrying with L01 after a failed 1.0 attempt. This should also be improved in the actual client itself, but likely requires a closer look at the actual device response in that scenario or moving to a serial request/response behavior rather than publish/subscribe. """ # Client first attempts 1.0 and we reply with a fake invalid response. The # response is arbitrary, and this could be improved by capturing a real L01 # device response to a 1.0 message. local_response_queue.put_nowait(b"\x00") # The client attempts L01 protocol as a followup. The connect nonce uses # a deterministic number from deterministic_message_fixtures. connect_nonce = 9090 local_response_queue.put_nowait( build_raw_response( RoborockMessageProtocol.HELLO_RESPONSE, 1, payload=b"ok", version=LocalProtocolVersion.L01, connect_nonce=connect_nonce, ack_nonce=None, ) ) await local_channel.connect() assert local_channel.is_connected # Verify 1.0 HELLO request request_bytes = await local_received_requests.get() # Protocol is at offset 19 (2 bytes) # Prefix(4) + Version(3) + Seq(4) + Random(4) + Timestamp(4) = 19 assert len(request_bytes) >= 21 protocol_bytes = request_bytes[19:21] assert int.from_bytes(protocol_bytes, "big") == RoborockMessageProtocol.HELLO_REQUEST # Verify L01 HELLO request request_bytes = await local_received_requests.get() # Protocol is at offset 19 (2 bytes) # Prefix(4) + Version(3) + Seq(4) + Random(4) + Timestamp(4) = 19 assert len(request_bytes) >= 21 protocol_bytes = request_bytes[19:21] assert int.from_bytes(protocol_bytes, "big") == RoborockMessageProtocol.HELLO_REQUEST assert local_received_requests.empty() # Verify the channel switched to L01 protocol assert local_channel.protocol_version == LocalProtocolVersion.L01.value # We have established a connection. Now send some messages. # Publish an L01 command. Currently the caller of the local channel needs to # determine the protocol version to use, but this could be pushed inside of # the channel in the future. cmd_seq = 123 msg = RoborockMessage( protocol=RoborockMessageProtocol.RPC_REQUEST, seq=cmd_seq, payload=b'{"method":"get_status"}', version=b"L01", ) # Prepare a fake response to the command. local_response_queue.put_nowait( build_raw_response( RoborockMessageProtocol.RPC_RESPONSE, cmd_seq, payload=b'{"status": "ok"}', version=LocalProtocolVersion.L01, connect_nonce=connect_nonce, ack_nonce=TEST_RANDOM, ) ) # Set up a subscriber to listen for the response then publish the message. subscriber = Subscriber() unsub = await local_channel.subscribe(subscriber.append) await local_channel.publish(msg) # Verify request received by the server request_bytes = await local_received_requests.get() decoder = create_local_decoder(local_key=LOCAL_KEY, connect_nonce=connect_nonce, ack_nonce=TEST_RANDOM) msgs = list(decoder(request_bytes)) assert len(msgs) == 1 assert msgs[0].protocol == RoborockMessageProtocol.RPC_REQUEST assert msgs[0].payload == b'{"method":"get_status"}' assert msgs[0].version == LocalProtocolVersion.L01.value.encode() # Verify fake response published by the server, received by subscriber await subscriber.wait() assert len(subscriber.messages) == 1 response_message = subscriber.messages[0] assert isinstance(response_message, RoborockMessage) assert response_message.protocol == RoborockMessageProtocol.RPC_RESPONSE assert response_message.payload == b'{"status": "ok"}' assert response_message.version == LocalProtocolVersion.L01.value.encode() unsub() assert snapshot == log Python-roborock-python-roborock-d6da2db/tests/e2e/test_mqtt_session.py000066400000000000000000000105451513363643200265070ustar00rootroot00000000000000"""End-to-end tests for MQTT session. These tests use a fake MQTT broker to verify the session implementation. We mock out the lower level socket connections to simulate a broker which gets us close to an "end to end" test without needing an actual MQTT broker server. These are higher level tests that the similar tests in tests/mqtt/test_roborock_session.py which use mocks to verify specific behaviors. """ from collections.abc import AsyncGenerator, Callable from queue import Queue import pytest import syrupy from roborock.mqtt.roborock_session import create_mqtt_session from roborock.mqtt.session import MqttSession from roborock.protocol import MessageParser from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol from tests import mqtt_packet from tests.fixtures.logging import CapturedRequestLog from tests.fixtures.mqtt import FAKE_PARAMS, Subscriber from tests.mock_data import LOCAL_KEY @pytest.fixture(autouse=True) def auto_mock_mqtt_client(mock_aiomqtt_client: None) -> None: """Automatically use the mock mqtt client fixture.""" @pytest.fixture(autouse=True) def auto_fast_backoff(fast_backoff_fixture: None) -> None: """Automatically use the fast backoff fixture.""" @pytest.fixture(autouse=True) def mqtt_server_fixture(mock_paho_mqtt_create_connection: None, mock_paho_mqtt_select: None) -> None: """Fixture to mock the MQTT connection. This is here to pull in the mock socket pixtures into all tests used here. """ @pytest.fixture(name="session") async def session_fixture( push_mqtt_response: Callable[[bytes], None], ) -> AsyncGenerator[MqttSession, None]: """Fixture to create a new connected MQTT session.""" push_mqtt_response(mqtt_packet.gen_connack(rc=0, flags=2)) session = await create_mqtt_session(FAKE_PARAMS) assert session.connected try: yield session finally: await session.close() async def test_session_e2e_receive_message( push_mqtt_response: Callable[[bytes], None], session: MqttSession, log: CapturedRequestLog, snapshot: syrupy.SnapshotAssertion, ) -> None: """Test receiving a real Roborock message through the session.""" assert session.connected # Subscribe to the topic. We'll next construct and push a message. push_mqtt_response(mqtt_packet.gen_suback(mid=1)) subscriber = Subscriber() await session.subscribe("topic-1", subscriber.append) msg = RoborockMessage( protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=b'{"result":"ok"}', seq=123, ) payload = MessageParser.build(msg, local_key=LOCAL_KEY, prefixed=False) # Simulate receiving the message from the broker push_mqtt_response(mqtt_packet.gen_publish("topic-1", mid=2, payload=payload)) # Verify it was dispatched to the subscriber await subscriber.wait() assert len(subscriber.messages) == 1 received_payload = subscriber.messages[0] assert isinstance(received_payload, bytes) assert received_payload == payload # Verify the message payload contents parsed_msgs, _ = MessageParser.parse(received_payload, local_key=LOCAL_KEY) assert len(parsed_msgs) == 1 parsed_msg = parsed_msgs[0] assert parsed_msg.protocol == RoborockMessageProtocol.RPC_RESPONSE assert parsed_msg.seq == 123 # The payload in parsed_msg should be the decrypted bytes assert parsed_msg.payload == b'{"result":"ok"}' assert snapshot == log async def test_session_e2e_publish_message( push_mqtt_response: Callable[[bytes], None], mqtt_received_requests: Queue, session: MqttSession, log: CapturedRequestLog, snapshot: syrupy.SnapshotAssertion, ) -> None: """Test publishing a real Roborock message.""" # Publish a message to the brokwer msg = RoborockMessage( protocol=RoborockMessageProtocol.RPC_REQUEST, payload=b'{"method":"get_status"}', seq=456, ) payload = MessageParser.build(msg, local_key=LOCAL_KEY, prefixed=False) await session.publish("topic-1", payload) # Verify what was sent to the broker # We expect the payload to be present in the sent bytes found = False while not mqtt_received_requests.empty(): request = mqtt_received_requests.get() if payload in request: found = True break assert found, "Published payload not found in sent requests" assert snapshot == log Python-roborock-python-roborock-d6da2db/tests/fixtures/000077500000000000000000000000001513363643200235375ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/fixtures/__init__.py000066400000000000000000000000001513363643200256360ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/fixtures/aiomqtt_fixtures.py000066400000000000000000000046641513363643200275320ustar00rootroot00000000000000"""Common code for MQTT tests.""" import asyncio import datetime from collections.abc import AsyncGenerator, Callable, Generator from queue import Queue from typing import Any from unittest.mock import patch import paho.mqtt.client as mqtt import pytest from .mqtt import FakeMqttSocketHandler @pytest.fixture(name="mock_aiomqtt_client") async def mock_aiomqtt_client_fixture() -> AsyncGenerator[None, None]: """Fixture to patch the MQTT underlying sync client. The tests use fake sockets, so this ensures that the async mqtt client does not attempt to listen on them directly. We instead just poll the socket for data ourselves. """ event_loop = asyncio.get_running_loop() orig_class = mqtt.Client async def poll_sockets(client: mqtt.Client) -> None: """Poll the mqtt client sockets in a loop to pick up new data.""" try: while True: event_loop.call_soon_threadsafe(client.loop_read) event_loop.call_soon_threadsafe(client.loop_write) await asyncio.sleep(0.01) except asyncio.CancelledError: pass task: asyncio.Task[None] | None = None def new_client(*args: Any, **kwargs: Any) -> mqtt.Client: """Create a new mqtt client and start the socket polling task.""" nonlocal task client = orig_class(*args, **kwargs) task = event_loop.create_task(poll_sockets(client)) return client with ( patch("aiomqtt.client.Client._on_socket_open"), patch("aiomqtt.client.Client._on_socket_close"), patch("aiomqtt.client.Client._on_socket_register_write"), patch("aiomqtt.client.Client._on_socket_unregister_write"), patch("aiomqtt.client.mqtt.Client", side_effect=new_client), ): yield if task: task.cancel() await task @pytest.fixture def fast_backoff_fixture() -> Generator[None, None, None]: """Fixture to speed up backoff.""" with patch( "roborock.mqtt.roborock_session.MIN_BACKOFF_INTERVAL", datetime.timedelta(seconds=0.01), ): yield @pytest.fixture def push_mqtt_response( mqtt_response_queue: Queue, fake_mqtt_socket_handler: FakeMqttSocketHandler ) -> Callable[[bytes], None]: """Fixture to push a response to the client.""" def _push(data: bytes) -> None: mqtt_response_queue.put(data) fake_mqtt_socket_handler.push_response() return _push Python-roborock-python-roborock-d6da2db/tests/fixtures/channel_fixtures.py000066400000000000000000000041521513363643200274540ustar00rootroot00000000000000from collections.abc import Callable from unittest.mock import AsyncMock, MagicMock from roborock.mqtt.health_manager import HealthManager from roborock.protocols.v1_protocol import LocalProtocolVersion from roborock.roborock_message import RoborockMessage class FakeChannel: """A fake channel that handles publish and subscribe calls.""" def __init__(self): """Initialize the fake channel.""" self.subscribers: list[Callable[[RoborockMessage], None]] = [] self.published_messages: list[RoborockMessage] = [] self.response_queue: list[RoborockMessage] = [] self._is_connected = False self.publish_side_effect: Exception | None = None self.publish = AsyncMock(side_effect=self._publish) self.subscribe = AsyncMock(side_effect=self._subscribe) self.connect = AsyncMock(side_effect=self._connect) self.close = MagicMock(side_effect=self._close) self.protocol_version = LocalProtocolVersion.V1 self.restart = AsyncMock() self.health_manager = HealthManager(self.restart) async def _connect(self) -> None: self._is_connected = True def _close(self) -> None: self._is_connected = False @property def is_connected(self) -> bool: """Return true if connected.""" return self._is_connected async def _publish(self, message: RoborockMessage) -> None: """Simulate publishing a message and triggering a response.""" self.published_messages.append(message) if self.publish_side_effect: raise self.publish_side_effect # When a message is published, simulate a response if self.response_queue: response = self.response_queue.pop(0) # Give a chance for the subscriber to be registered for subscriber in list(self.subscribers): subscriber(response) async def _subscribe(self, callback: Callable[[RoborockMessage], None]) -> Callable[[], None]: """Simulate subscribing to messages.""" self.subscribers.append(callback) return lambda: self.subscribers.remove(callback) Python-roborock-python-roborock-d6da2db/tests/fixtures/local_async_fixtures.py000066400000000000000000000061151513363643200303340ustar00rootroot00000000000000import asyncio import logging import warnings from collections.abc import Awaitable, Callable, Generator from typing import Any from unittest.mock import Mock, patch import pytest from .logging import CapturedRequestLog _LOGGER = logging.getLogger(__name__) AsyncLocalRequestHandler = Callable[[bytes], Awaitable[bytes | None]] @pytest.fixture(name="local_received_requests") def received_requests_fixture() -> asyncio.Queue[bytes]: """Fixture that provides access to the received requests.""" return asyncio.Queue() @pytest.fixture(name="local_response_queue") def response_queue_fixture() -> Generator[asyncio.Queue[bytes], None, None]: """Fixture that provides a queue of responses to be sent to the client.""" response_queue: asyncio.Queue[bytes] = asyncio.Queue() yield response_queue if not response_queue.empty(): warnings.warn("Some enqueued local device responses were not consumed during the test") @pytest.fixture(name="local_async_request_handler") def local_request_handler_fixture( local_received_requests: asyncio.Queue[bytes], local_response_queue: asyncio.Queue[bytes] ) -> AsyncLocalRequestHandler: """Fixture records incoming requests and replies with responses from the queue.""" async def handle_request(client_request: bytes) -> bytes | None: """Handle an incoming request from the client.""" local_received_requests.put_nowait(client_request) # Insert a prepared response into the response buffer if not local_response_queue.empty(): return await local_response_queue.get() return None return handle_request @pytest.fixture(name="mock_async_create_local_connection") def create_local_connection_fixture( local_async_request_handler: AsyncLocalRequestHandler, log: CapturedRequestLog, ) -> Generator[None, None, None]: """Fixture that overrides the transport creation to wire it up to the mock socket.""" tasks = [] async def create_connection(protocol_factory: Callable[[], asyncio.Protocol], *args, **kwargs) -> tuple[Any, Any]: protocol = protocol_factory() async def handle_write(data: bytes) -> None: log.add_log_entry("[local >]", data) response = await local_async_request_handler(data) if response is not None: # Call data_received directly to avoid loop scheduling issues in test log.add_log_entry("[local <]", response) protocol.data_received(response) def start_handle_write(data: bytes) -> None: tasks.append(asyncio.create_task(handle_write(data))) closed = asyncio.Event() mock_transport = Mock() mock_transport.write = start_handle_write mock_transport.close = closed.set mock_transport.is_reading = lambda: not closed.is_set() return (mock_transport, protocol) with patch("roborock.devices.transport.local_channel.get_running_loop") as mock_loop: mock_loop.return_value.create_connection.side_effect = create_connection yield for task in tasks: task.cancel() Python-roborock-python-roborock-d6da2db/tests/fixtures/logging.py000066400000000000000000000045371513363643200255500ustar00rootroot00000000000000"""Logging utilities for tests.""" class CapturedRequestLog: """Log of requests and responses for snapshot assertions. The log captures the raw bytes of each request and response along with a label indicating the direction of the message. """ def __init__(self) -> None: """Initialize the request log.""" self.entries: list[tuple[str, bytes]] = [] def add_log_entry(self, label: str, data: bytes) -> None: """Add a request entry.""" self.entries.append((label, data)) def __repr__(self): """Return a string representation of the log entries. This assumes that the client will behave in a request-response manner, so each request is followed by a response. If a test uses non-deterministic message order, this may not be accurate and the test would need to decode the raw messages and remove any ordering assumptions. """ lines = [] for label, data in self.entries: lines.append(label) lines.extend(self._hexdump(data)) return "\n".join(lines) def _hexdump(self, data: bytes, bytes_per_line: int = 16) -> list[str]: """Print a hexdump of the given bytes object in a tcpdump/hexdump -C style. This makes the packets easier to read and compare in test snapshots. Args: data: The bytes object to print. bytes_per_line: The number of bytes to display per line (default is 16). """ # Use '.' for non-printable characters (ASCII < 32 or > 126) def to_printable_ascii(byte_val): return chr(byte_val) if 32 <= byte_val <= 126 else "." offset = 0 lines = [] while offset < len(data): chunk = data[offset : offset + bytes_per_line] # Format the hex values, space-padded to ensure alignment hex_values = " ".join(f"{byte:02x}" for byte in chunk) # Pad hex string to a fixed width so ASCII column lines up # 3 chars per byte ('xx ') for a full line of 16 bytes padded_hex = f"{hex_values:<{bytes_per_line * 3}}" # Format the ASCII values ascii_values = "".join(to_printable_ascii(byte) for byte in chunk) lines.append(f"{offset:08x} {padded_hex} |{ascii_values}|") offset += bytes_per_line return lines Python-roborock-python-roborock-d6da2db/tests/fixtures/logging_fixtures.py000066400000000000000000000045771513363643200275050ustar00rootroot00000000000000from collections.abc import Generator from unittest.mock import patch import pytest from .logging import CapturedRequestLog # Fixed timestamp for deterministic tests for asserting on message contents FAKE_TIMESTAMP = 1755750946.721395 @pytest.fixture def deterministic_message_fixtures() -> Generator[None, None, None]: """Fixture to use predictable get_next_int and timestamp values for each test. This test mocks out the functions used to generate requests that have some entropy such as the nonces, timestamps, and request IDs. This makes the generated messages deterministic so we can snapshot them in a test. """ # Pick an arbitrary sequence number used for outgoing requests next_int = 9090 def get_next_int(min_value: int, max_value: int) -> int: nonlocal next_int result = next_int next_int += 1 if next_int > max_value: next_int = min_value return result # Pick an arbitrary timestamp used for the message encryption timestamp = FAKE_TIMESTAMP def get_timestamp() -> int: """Get a monotonically increasing timestamp for testing.""" nonlocal timestamp timestamp += 1 return int(timestamp) # Use predictable seeds for token_bytes token_chr = "A" def get_token_bytes(n: int) -> bytes: nonlocal token_chr result = token_chr.encode() * n # Cycle to the next character token_chr = chr(ord(token_chr) + 1) if token_chr > "Z": token_chr = "A" return result with ( patch("roborock.devices.transport.local_channel.get_next_int", side_effect=get_next_int), patch("roborock.protocols.b01_q7_protocol.get_next_int", side_effect=get_next_int), patch("roborock.protocols.v1_protocol.get_next_int", side_effect=get_next_int), patch("roborock.protocols.v1_protocol.get_timestamp", side_effect=get_timestamp), patch("roborock.protocols.v1_protocol.secrets.token_bytes", side_effect=get_token_bytes), patch("roborock.roborock_message.get_next_int", side_effect=get_next_int), patch("roborock.roborock_message.get_timestamp", side_effect=get_timestamp), ): yield @pytest.fixture(name="log") def log_fixture(deterministic_message_fixtures: None) -> CapturedRequestLog: """Fixture that creates a captured request log.""" return CapturedRequestLog() Python-roborock-python-roborock-d6da2db/tests/fixtures/mqtt.py000066400000000000000000000071621513363643200251040ustar00rootroot00000000000000"""Common code for MQTT tests.""" import asyncio import io import logging from collections.abc import Callable from queue import Queue from roborock.mqtt.session import MqttParams from roborock.roborock_message import RoborockMessage from .logging import CapturedRequestLog _LOGGER = logging.getLogger(__name__) # Used by fixtures to handle incoming requests and prepare responses MqttRequestHandler = Callable[[bytes], bytes | None] class FakeMqttSocketHandler: """Fake socket used by the test to simulate a connection to the broker. The socket handler is used to intercept the socket send and recv calls and populate the response buffer with data to be sent back to the client. The handle request callback handles the incoming requests and prepares the responses. """ def __init__( self, handle_request: MqttRequestHandler, response_queue: Queue[bytes], log: CapturedRequestLog ) -> None: self.response_buf = io.BytesIO() self.handle_request = handle_request self.response_queue = response_queue self.log = log self.client_connected = False def pending(self) -> int: """Return the number of bytes in the response buffer.""" return len(self.response_buf.getvalue()) def handle_socket_recv(self, read_size: int) -> bytes: """Intercept a client recv() and populate the buffer.""" if self.pending() == 0: raise BlockingIOError("No response queued") self.response_buf.seek(0) data = self.response_buf.read(read_size) _LOGGER.debug("Response: 0x%s", data.hex()) # Consume the rest of the data in the buffer remaining_data = self.response_buf.read() self.response_buf = io.BytesIO(remaining_data) return data def handle_socket_send(self, client_request: bytes) -> int: """Receive an incoming request from the client.""" self.client_connected = True _LOGGER.debug("Request: 0x%s", client_request.hex()) self.log.add_log_entry("[mqtt >]", client_request) if (response := self.handle_request(client_request)) is not None: # Enqueue a response to be sent back to the client in the buffer. # The buffer will be emptied when the client calls recv() on the socket _LOGGER.debug("Queued: 0x%s", response.hex()) self.log.add_log_entry("[mqtt <]", response) self.response_buf.write(response) return len(client_request) def push_response(self) -> None: """Push a response to the client.""" if not self.response_queue.empty() and self.client_connected: response = self.response_queue.get() # Enqueue a response to be sent back to the client in the buffer. # The buffer will be emptied when the client calls recv() on the socket _LOGGER.debug("Queued: 0x%s", response.hex()) self.log.add_log_entry("[mqtt <]", response) self.response_buf.write(response) FAKE_PARAMS = MqttParams( host="localhost", port=1883, tls=False, username="username", password="password", timeout=10.0, ) class Subscriber: """Mock subscriber class. We use this to hold on to received messages for verification. """ def __init__(self) -> None: self.messages: list[RoborockMessage | bytes] = [] self._event = asyncio.Event() def append(self, message: RoborockMessage | bytes) -> None: self.messages.append(message) self._event.set() async def wait(self) -> None: await asyncio.wait_for(self._event.wait(), timeout=1.0) self._event.clear() Python-roborock-python-roborock-d6da2db/tests/fixtures/pahomqtt_fixtures.py000066400000000000000000000075171513363643200277110ustar00rootroot00000000000000"""Common code for MQTT tests.""" import logging import warnings from collections.abc import Callable, Generator from queue import Queue from typing import Any from unittest.mock import Mock, patch import pytest from .logging import CapturedRequestLog from .mqtt import FakeMqttSocketHandler pytest_plugins = [ "tests.fixtures.logging_fixtures", ] _LOGGER = logging.getLogger(__name__) # Used by fixtures to handle incoming requests and prepare responses MqttRequestHandler = Callable[[bytes], bytes | None] @pytest.fixture(name="mock_paho_mqtt_create_connection") def create_connection_fixture(mock_sock: Mock) -> Generator[None, None, None]: """Fixture that overrides the MQTT socket creation to wire it up to the mock socket.""" with patch("paho.mqtt.client.socket.create_connection", return_value=mock_sock): yield @pytest.fixture(name="mock_paho_mqtt_select") def select_fixture(mock_sock: Mock, fake_mqtt_socket_handler: FakeMqttSocketHandler) -> Generator[None, None, None]: """Fixture that overrides the MQTT client select calls to make select work on the mock socket. This patch select to activate our mock socket when ready with data. Internal mqtt sockets are always ready since they are used internally to wake the select loop. Ours is ready if there is data in the buffer. """ def is_ready(sock: Any) -> bool: return sock is not mock_sock or (fake_mqtt_socket_handler.pending() > 0) def handle_select(rlist: list, wlist: list, *args: Any) -> list: return [list(filter(is_ready, rlist)), list(filter(is_ready, wlist))] with patch("paho.mqtt.client.select.select", side_effect=handle_select): yield @pytest.fixture(name="fake_mqtt_socket_handler") def fake_mqtt_socket_handler_fixture( mqtt_request_handler: MqttRequestHandler, mqtt_response_queue: Queue[bytes], log: CapturedRequestLog ) -> Generator[FakeMqttSocketHandler, None, None]: """Fixture that creates a fake MQTT broker.""" socket_handler = FakeMqttSocketHandler(mqtt_request_handler, mqtt_response_queue, log) yield socket_handler if len(socket_handler.response_buf.getvalue()) > 0: warnings.warn("Some enqueued MQTT responses were not consumed during the test") @pytest.fixture(name="mock_sock") def mock_sock_fixture(fake_mqtt_socket_handler: FakeMqttSocketHandler) -> Mock: """Fixture that creates a mock socket connection and wires it to the handler.""" mock_sock = Mock() mock_sock.recv = fake_mqtt_socket_handler.handle_socket_recv mock_sock.send = fake_mqtt_socket_handler.handle_socket_send mock_sock.pending = fake_mqtt_socket_handler.pending return mock_sock @pytest.fixture(name="mqtt_received_requests") def received_requests_fixture() -> Queue[bytes]: """Fixture that provides access to the received requests.""" return Queue() @pytest.fixture(name="mqtt_response_queue") def response_queue_fixture() -> Generator[Queue[bytes], None, None]: """Fixture that provides a queue for enqueueing responses to be sent to the client under test.""" response_queue: Queue[bytes] = Queue() yield response_queue if not response_queue.empty(): warnings.warn("Some enqueued MQTT responses were not consumed during the test") @pytest.fixture(name="mqtt_request_handler") def mqtt_request_handler_fixture( mqtt_received_requests: Queue[bytes], mqtt_response_queue: Queue[bytes] ) -> MqttRequestHandler: """Fixture records incoming requests and replies with responses from the queue.""" def handle_request(client_request: bytes) -> bytes | None: """Handle an incoming request from the client.""" mqtt_received_requests.put(client_request) # Insert a prepared response into the response buffer if not mqtt_response_queue.empty(): return mqtt_response_queue.get() return None return handle_request Python-roborock-python-roborock-d6da2db/tests/fixtures/web_api_fixtures.py000066400000000000000000000131121513363643200274460ustar00rootroot00000000000000import re from collections.abc import Generator from typing import Any from unittest.mock import patch import pytest from aioresponses import aioresponses from tests.mock_data import HOME_DATA_RAW, HOME_DATA_SCENES_RAW, USER_DATA @pytest.fixture def skip_rate_limit() -> Generator[None, None, None]: """Don't rate limit tests as they aren't actually hitting the api.""" with ( patch("roborock.web_api.RoborockApiClient._login_limiter.try_acquire"), patch("roborock.web_api.RoborockApiClient._home_data_limiter.try_acquire"), ): yield @pytest.fixture(name="home_data") def home_data_fixture() -> dict[str, Any]: """Fixture to provide HomeData instance for tests.""" return HOME_DATA_RAW @pytest.fixture(name="mock_rest") def mock_rest_fixture(skip_rate_limit: Any, home_data: dict[str, Any]) -> aioresponses: """Mock all rest endpoints so they won't hit real endpoints""" with aioresponses() as mocked: # Match the base URL and allow any query params mocked.post( re.compile(r"https://.*iot\.roborock\.com/api/v1/getUrlByEmail.*"), status=200, payload={ "code": 200, "data": {"country": "US", "countrycode": "1", "url": "https://usiot.roborock.com"}, "msg": "success", }, ) mocked.post( re.compile(r"https://.*iot\.roborock\.com/api/v1/login.*"), status=200, payload={"code": 200, "data": USER_DATA, "msg": "success"}, ) mocked.post( re.compile(r"https://.*iot\.roborock\.com/api/v1/loginWithCode.*"), status=200, payload={"code": 200, "data": USER_DATA, "msg": "success"}, ) mocked.post( re.compile(r"https://.*iot\.roborock\.com/api/v1/sendEmailCode.*"), status=200, payload={"code": 200, "data": None, "msg": "success"}, ) mocked.get( re.compile(r"https://.*iot\.roborock\.com/api/v1/getHomeDetail.*"), status=200, payload={ "code": 200, "data": {"deviceListOrder": None, "id": 123456, "name": "My Home", "rrHomeId": 123456, "tuyaHomeId": 0}, "msg": "success", }, ) mocked.get( re.compile(r"https://api-.*\.roborock\.com/v2/user/homes*"), status=200, payload={"api": None, "code": 200, "result": home_data, "status": "ok", "success": True}, ) mocked.get( re.compile(r"https://api-.*\.roborock\.com/v3/user/homes*"), status=200, payload={"api": None, "code": 200, "result": home_data, "status": "ok", "success": True}, ) mocked.post( re.compile(r"https://api-.*\.roborock\.com/nc/prepare"), status=200, payload={ "api": None, "result": {"r": "US", "s": "ffffff", "t": "eOf6d2BBBB"}, "status": "ok", "success": True, }, ) mocked.get( re.compile(r"https://api-.*\.roborock\.com/user/devices/newadd/*"), status=200, payload={ "api": "获取新增设备信息", "result": { "activeTime": 1737724598, "attribute": None, "cid": None, "createTime": 0, "deviceStatus": None, "duid": "rand_duid", "extra": "{}", "f": False, "featureSet": "0", "fv": "02.16.12", "iconUrl": "", "lat": None, "localKey": "random_lk", "lon": None, "name": "S7", "newFeatureSet": "0000000000002000", "online": True, "productId": "rand_prod_id", "pv": "1.0", "roomId": None, "runtimeEnv": None, "setting": None, "share": False, "shareTime": None, "silentOtaSwitch": False, "sn": "Rand_sn", "timeZoneId": "America/New_York", "tuyaMigrated": False, "tuyaUuid": None, }, "status": "ok", "success": True, }, ) mocked.get( re.compile(r"https://api-.*\.roborock\.com/user/scene/device/.*"), status=200, payload={"api": None, "code": 200, "result": HOME_DATA_SCENES_RAW, "status": "ok", "success": True}, ) mocked.post( re.compile(r"https://api-.*\.roborock\.com/user/scene/.*/execute"), status=200, payload={"api": None, "code": 200, "result": None, "status": "ok", "success": True}, ) mocked.post( re.compile(r"https://.*iot\.roborock\.com/api/v4/email/code/send.*"), status=200, payload={"code": 200, "data": None, "msg": "success"}, ) mocked.post( re.compile(r"https://.*iot\.roborock\.com/api/v3/key/sign.*"), status=200, payload={"code": 200, "data": {"k": "mock_k"}, "msg": "success"}, ) mocked.post( re.compile(r"https://.*iot\.roborock\.com/api/v4/auth/email/login/code.*"), status=200, payload={"code": 200, "data": USER_DATA, "msg": "success"}, ) yield mocked Python-roborock-python-roborock-d6da2db/tests/map/000077500000000000000000000000001513363643200224435ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/map/test_map_parser.py000066400000000000000000000013011513363643200262000ustar00rootroot00000000000000"""Tests for the map parser.""" from pathlib import Path import pytest from roborock.exceptions import RoborockException from roborock.map.map_parser import MapParser, MapParserConfig MAP_DATA_FILE = Path(__file__).parent / "raw_map_data" DEFAULT_MAP_CONFIG = MapParserConfig() @pytest.mark.parametrize("map_content", [b"", b"12345"]) def test_invalid_map_content(map_content: bytes): """Test that parsing map data returns the expected image and data.""" parser = MapParser(DEFAULT_MAP_CONFIG) with pytest.raises(RoborockException, match="Failed to parse map data"): parser.parse(map_content) # We can add additional tests here in the future that actually parse valid map data Python-roborock-python-roborock-d6da2db/tests/mock_data.py000066400000000000000000000225321513363643200241660ustar00rootroot00000000000000"""Mock data for Roborock tests.""" import hashlib import json import pathlib from typing import Any # All data is based on a U.S. customer with a Roborock S7 MaxV Ultra USER_EMAIL = "user@domain.com" BASE_URL = "https://usiot.roborock.com" USER_ID = "user123" K_VALUE = "qiCNieZa" USER_DATA = { "uid": 123456, "tokentype": "token_type", "token": "abc123", "rruid": "abc123", "region": "us", "countrycode": "1", "country": "US", "nickname": "user_nickname", "rriot": { "u": USER_ID, "s": "pass123", "h": "unknown123", "k": K_VALUE, "r": { "r": "US", "a": "https://api-us.roborock.com", "m": "tcp://mqtt-us.roborock.com:8883", # Skip SSL code in MQTT client library "l": "https://wood-us.roborock.com", }, }, "tuyaDeviceState": 2, "avatarurl": "https://files.roborock.com/iottest/default_avatar.png", } LOCAL_KEY = "key123key123key1" # 16 bytes / 128 bits PRODUCT_ID = "product-id-123" HOME_DATA_SCENES_RAW = [ { "id": 1234567, "name": "My plan", "param": json.dumps( { "triggers": [], "action": { "type": "S", "items": [ { "id": 5, "type": "CMD", "name": "", "entityId": "EEEEEEEEEEEEEE", "param": json.dumps( { "id": 5, "method": "do_scenes_app_start", "params": [ { "fan_power": 104, "water_box_mode": 200, "mop_mode": 300, "mop_template_id": 300, "repeat": 1, "auto_dustCollection": 1, "source": 101, } ], } ), "finishDpIds": [130], }, { "id": 4, "type": "CMD", "name": "", "entityId": "EEEEEEEEEEEEEE", "param": json.dumps( { "id": 4, "method": "do_scenes_segments", "params": { "data": [ { "tid": "111111111111111111", "segs": [ {"sid": 19}, {"sid": 18}, {"sid": 22}, {"sid": 21}, {"sid": 16}, ], "map_flag": 0, "fan_power": 105, "water_box_mode": 201, "mop_mode": 300, "mop_template_id": 300, "repeat": 1, "clean_order_mode": 1, "auto_dry": 1, "auto_dustCollection": 1, "region_num": 0, } ], "source": 101, }, } ), "finishDpIds": [130], }, ], }, "matchType": "NONE", "tagId": "4444", } ), "enabled": True, "extra": None, "type": "WORKFLOW", } ] TESTDATA = pathlib.Path("tests/testdata") PRODUCTS = {file.name: json.load(file.open()) for file in TESTDATA.glob("home_data_product_*.json")} DEVICES = {file.name: json.load(file.open()) for file in TESTDATA.glob("home_data_device_*.json")} # Products A27_PRODUCT_DATA = PRODUCTS["home_data_product_a27.json"] SC01_PRODUCT_DATA = PRODUCTS["home_data_product_sc01.json"] SS07_PRODUCT_DATA = PRODUCTS["home_data_product_ss07.json"] A102_PRODUCT_DATA = PRODUCTS["home_data_product_a102.json"] A114_PRODUCT_DATA = PRODUCTS["home_data_product_a114.json"] # Devices S7_DEVICE_DATA = DEVICES["home_data_device_s7_maxv.json"] Q7_DEVICE_DATA = DEVICES["home_data_device_q7.json"] Q10_DEVICE_DATA = DEVICES["home_data_device_q10.json"] ZEO_ONE_DEVICE_DATA = DEVICES["home_data_device_zeo_one.json"] SAROS_10R_DEVICE_DATA = DEVICES["home_data_device_saros_10r.json"] HOME_DATA_RAW: dict[str, Any] = { "id": 123456, "name": "My Home", "lon": None, "lat": None, "geoName": None, "products": [ A27_PRODUCT_DATA, ], "devices": [ S7_DEVICE_DATA, ], "receivedDevices": [], "rooms": [ {"id": 2362048, "name": "Example room 1"}, {"id": 2362044, "name": "Example room 2"}, {"id": 2362041, "name": "Example room 3"}, ], } CLEAN_RECORD = { "begin": 1672543330, "end": 1672544638, "duration": 1176, "area": 20965000, "error": 0, "complete": 1, "start_type": 2, "clean_type": 3, "finish_reason": 56, "dust_collection_status": 1, "avoid_count": 19, "wash_count": 2, "map_flag": 0, } CLEAN_SUMMARY = { "clean_time": 74382, "clean_area": 1159182500, "clean_count": 31, "dust_collection_count": 25, "records": [ 1672543330, 1672458041, ], } CONSUMABLE = { "main_brush_work_time": 74382, "side_brush_work_time": 74383, "filter_work_time": 74384, "filter_element_work_time": 0, "sensor_dirty_time": 74385, "strainer_work_times": 65, "dust_collection_work_times": 25, "cleaning_brush_work_times": 66, } DND_TIMER = { "start_hour": 22, "start_minute": 0, "end_hour": 7, "end_minute": 0, "enabled": 1, } STATUS = { "msg_ver": 2, "msg_seq": 458, "state": 8, "battery": 100, "clean_time": 1176, "clean_area": 20965000, "error_code": 0, "map_present": 1, "in_cleaning": 0, "in_returning": 0, "in_fresh_state": 1, "lab_status": 1, "water_box_status": 1, "back_type": -1, "wash_phase": 0, "wash_ready": 0, "fan_power": 102, "dnd_enabled": 0, "map_status": 3, "is_locating": 0, "lock_status": 0, "water_box_mode": 203, "water_box_carriage_status": 1, "mop_forbidden_enable": 1, "camera_status": 3457, "is_exploring": 0, "home_sec_status": 0, "home_sec_enable_password": 0, "adbumper_status": [0, 0, 0], "water_shortage_status": 0, "dock_type": 3, "dust_collection_status": 0, "auto_dust_collection": 1, "avoid_count": 19, "mop_mode": 300, "debug_mode": 0, "collision_avoid_status": 1, "switch_map_mode": 0, "dock_error_status": 0, "charge_status": 1, "unsave_map_reason": 0, "unsave_map_flag": 0, } BASE_URL_REQUEST = { "code": 200, "msg": "success", "data": {"url": "https://sample.com", "countrycode": 1, "country": "US"}, } GET_CODE_RESPONSE = {"code": 200, "msg": "success", "data": None} HASHED_USER = hashlib.md5((USER_ID + ":" + K_VALUE).encode()).hexdigest()[2:10] MQTT_PUBLISH_TOPIC = f"rr/m/o/{USER_ID}/{HASHED_USER}/{PRODUCT_ID}" TEST_LOCAL_API_HOST = "1.1.1.1" NETWORK_INFO = { "ip": TEST_LOCAL_API_HOST, "ssid": "test_wifi", "mac": "aa:bb:cc:dd:ee:ff", "bssid": "aa:bb:cc:dd:ee:ff", "rssi": -50, } APP_GET_INIT_STATUS = { "local_info": { "name": "custom_A.03.0069_FCC", "bom": "A.03.0069", "location": "us", "language": "en", "wifiplan": "0x39", "timezone": "US/Pacific", "logserver": "awsusor0.fds.api.xiaomi.com", "featureset": 1, }, "feature_info": [111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 122, 123, 124, 125], "new_feature_info": 633887780925447, "new_feature_info2": 8192, "new_feature_info_str": "0000000000002000", "status_info": { "state": 8, "battery": 100, "clean_time": 5610, "clean_area": 96490000, "error_code": 0, "in_cleaning": 0, "in_returning": 0, "in_fresh_state": 1, "lab_status": 1, "water_box_status": 0, "map_status": 3, "is_locating": 0, "lock_status": 0, "water_box_mode": 204, "distance_off": 0, "water_box_carriage_status": 0, "mop_forbidden_enable": 0, }, } Python-roborock-python-roborock-d6da2db/tests/mqtt/000077500000000000000000000000001513363643200226535ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/mqtt/test_health_manager.py000066400000000000000000000044741513363643200272340ustar00rootroot00000000000000"""Tests for the health manager.""" import datetime from unittest.mock import AsyncMock, patch from roborock.mqtt.health_manager import HealthManager async def test_health_manager_restart_called_after_timeouts() -> None: """Test that the health manager calls restart after consecutive timeouts.""" restart = AsyncMock() health_manager = HealthManager(restart=restart) await health_manager.on_timeout() await health_manager.on_timeout() restart.assert_not_called() await health_manager.on_timeout() restart.assert_called_once() async def test_health_manager_success_resets_counter() -> None: """Test that a successful message resets the timeout counter.""" restart = AsyncMock() health_manager = HealthManager(restart=restart) await health_manager.on_timeout() await health_manager.on_timeout() restart.assert_not_called() await health_manager.on_success() await health_manager.on_timeout() await health_manager.on_timeout() restart.assert_not_called() await health_manager.on_timeout() restart.assert_called_once() async def test_cooldown() -> None: """Test that the health manager respects the restart cooldown.""" restart = AsyncMock() health_manager = HealthManager(restart=restart) with patch("roborock.mqtt.health_manager.datetime") as mock_datetime: now = datetime.datetime(2023, 1, 1, 12, 0, 0) mock_datetime.datetime.now.return_value = now # Trigger first restart await health_manager.on_timeout() await health_manager.on_timeout() await health_manager.on_timeout() restart.assert_called_once() restart.reset_mock() # Advance time but stay within cooldown (30 mins) mock_datetime.datetime.now.return_value = now + datetime.timedelta(minutes=10) # Trigger timeouts again await health_manager.on_timeout() await health_manager.on_timeout() await health_manager.on_timeout() restart.assert_not_called() # Advance time past cooldown mock_datetime.datetime.now.return_value = now + datetime.timedelta(minutes=31) # Trigger timeouts again await health_manager.on_timeout() await health_manager.on_timeout() await health_manager.on_timeout() restart.assert_called_once() Python-roborock-python-roborock-d6da2db/tests/mqtt/test_roborock_session.py000066400000000000000000000415511513363643200276550ustar00rootroot00000000000000"""Tests for the MQTT session module.""" import asyncio import copy import datetime from collections.abc import Callable, Generator from typing import Any from unittest.mock import AsyncMock, Mock, patch import aiomqtt import pytest from roborock.diagnostics import Diagnostics from roborock.mqtt.roborock_session import RoborockMqttSession, create_mqtt_session from roborock.mqtt.session import MqttSessionException, MqttSessionUnauthorized from tests import mqtt_packet from tests.fixtures.mqtt import FAKE_PARAMS, Subscriber pytest_plugins = [ "tests.fixtures.logging_fixtures", "tests.fixtures.pahomqtt_fixtures", "tests.fixtures.aiomqtt_fixtures", ] @pytest.fixture(autouse=True) def mqtt_server_fixture( mock_paho_mqtt_create_connection: None, mock_paho_mqtt_select: None, ) -> None: """Fixture to prepare a fake MQTT server.""" @pytest.fixture(autouse=True) def auto_mock_aiomqtt_client( mock_aiomqtt_client: None, ) -> None: """Automatically use the mock mqtt client fixture.""" @pytest.fixture(autouse=True) def auto_fast_backoff(fast_backoff_fixture: None) -> None: """Automatically use the fast backoff fixture.""" class FakeAsyncIterator: """Fake async iterator that waits for messages to arrive, but they never do. This is used for testing exceptions in other client functions. """ def __init__(self) -> None: self.loop = True def __aiter__(self): return self async def __anext__(self) -> None: """Iterator that does not generate any messages.""" while self.loop: await asyncio.sleep(0.01) @pytest.fixture(name="message_iterator") def message_iterator_fixture() -> FakeAsyncIterator: """Fixture to provide a side effect for creating the MQTT client.""" return FakeAsyncIterator() @pytest.fixture(name="mock_client") def mock_client_fixture(message_iterator: FakeAsyncIterator) -> Generator[AsyncMock, None, None]: """A fixture that provides a mocked aiomqtt Client. This is lighter weight that `mock_aiomqtt_client` that uses real sockets. """ mock_client = AsyncMock() mock_client.messages = message_iterator return mock_client @pytest.fixture(name="create_client_side_effect") def create_client_side_effect_fixture() -> Exception | None: """Fixture to provide a side effect for creating the MQTT client.""" return None @pytest.fixture(name="mock_aenter_client") def mock_aenter_client_fixture(mock_client: AsyncMock, create_client_side_effect: Exception | None) -> AsyncMock: """Fixture to provide a side effect for creating the MQTT client.""" mock_aenter = AsyncMock() mock_aenter.return_value = mock_client mock_aenter.side_effect = create_client_side_effect return mock_aenter @pytest.fixture(name="mqtt_client_lite") def mqtt_client_lite_fixture( mock_client: AsyncMock, mock_aenter_client: AsyncMock, ) -> Generator[AsyncMock, None, None]: """Fixture to create a mock MQTT client with patched aiomqtt.Client.""" mock_shim = Mock() mock_shim.return_value.__aenter__ = mock_aenter_client mock_shim.return_value.__aexit__ = AsyncMock() with patch("roborock.mqtt.roborock_session.aiomqtt.Client", mock_shim): yield mock_client async def test_session(push_mqtt_response: Callable[[bytes], None]) -> None: """Test the MQTT session.""" push_mqtt_response(mqtt_packet.gen_connack(rc=0, flags=2)) session = await create_mqtt_session(FAKE_PARAMS) assert session.connected push_mqtt_response(mqtt_packet.gen_suback(mid=1)) subscriber1 = Subscriber() unsub1 = await session.subscribe("topic-1", subscriber1.append) push_mqtt_response(mqtt_packet.gen_suback(mid=2)) subscriber2 = Subscriber() await session.subscribe("topic-2", subscriber2.append) push_mqtt_response(mqtt_packet.gen_publish("topic-1", mid=3, payload=b"12345")) await subscriber1.wait() assert subscriber1.messages == [b"12345"] assert not subscriber2.messages push_mqtt_response(mqtt_packet.gen_publish("topic-2", mid=4, payload=b"67890")) await subscriber2.wait() assert subscriber2.messages == [b"67890"] push_mqtt_response(mqtt_packet.gen_publish("topic-1", mid=5, payload=b"ABC")) await subscriber1.wait() assert subscriber1.messages == [b"12345", b"ABC"] assert subscriber2.messages == [b"67890"] # Messages are no longer received after unsubscribing unsub1() push_mqtt_response(mqtt_packet.gen_publish("topic-1", payload=b"ignored")) assert subscriber1.messages == [b"12345", b"ABC"] assert session.connected await session.close() assert not session.connected async def test_session_no_subscribers(push_mqtt_response: Callable[[bytes], None]) -> None: """Test the MQTT session.""" push_mqtt_response(mqtt_packet.gen_connack(rc=0, flags=2)) session = await create_mqtt_session(FAKE_PARAMS) assert session.connected await session.close() assert not session.connected async def test_publish_command(push_mqtt_response: Callable[[bytes], None]) -> None: """Test publishing during an MQTT session.""" push_mqtt_response(mqtt_packet.gen_connack(rc=0, flags=2)) session = await create_mqtt_session(FAKE_PARAMS) push_mqtt_response(mqtt_packet.gen_publish("topic-1", mid=3, payload=b"12345")) await session.publish("topic-1", message=b"payload") assert session.connected await session.close() assert not session.connected async def test_publish_failure(mqtt_client_lite: AsyncMock) -> None: """Test an MQTT error is received when publishing a message.""" session = await create_mqtt_session(FAKE_PARAMS) assert session.connected mqtt_client_lite.publish.side_effect = aiomqtt.MqttError with pytest.raises(MqttSessionException, match="Error publishing message"): await session.publish("topic-1", message=b"payload") await session.close() async def test_subscribe_failure(mqtt_client_lite: AsyncMock) -> None: """Test an MQTT error while subscribing.""" session = await create_mqtt_session(FAKE_PARAMS) assert session.connected mqtt_client_lite.subscribe.side_effect = aiomqtt.MqttError subscriber1 = Subscriber() with pytest.raises(MqttSessionException, match="Error subscribing to topic"): await session.subscribe("topic-1", subscriber1.append) assert not subscriber1.messages await session.close() async def test_restart(push_mqtt_response: Callable[[bytes], None]) -> None: """Test restarting the MQTT session.""" push_mqtt_response(mqtt_packet.gen_connack(rc=0, flags=2)) session = await create_mqtt_session(FAKE_PARAMS) assert session.connected # Subscribe to a topic push_mqtt_response(mqtt_packet.gen_suback(mid=1)) subscriber = Subscriber() await session.subscribe("topic-1", subscriber.append) # Verify we can receive messages push_mqtt_response(mqtt_packet.gen_publish("topic-1", mid=2, payload=b"12345")) await subscriber.wait() assert subscriber.messages == [b"12345"] # Restart the session. await session.restart() # This is a hack where we grab on to the client and wait for it to be # closed properly and restarted. while session._client: # type: ignore[attr-defined] await asyncio.sleep(0.01) # We need to queue up a new connack for the reconnection push_mqtt_response(mqtt_packet.gen_connack(rc=0, flags=2)) # And a suback for the resubscription. Since we created a new client, # the message ID resets to 1. push_mqtt_response(mqtt_packet.gen_suback(mid=1)) push_mqtt_response(mqtt_packet.gen_publish("topic-1", mid=4, payload=b"67890")) await subscriber.wait() assert subscriber.messages == [b"12345", b"67890"] await session.close() async def test_idle_timeout_resubscribe(mqtt_client_lite: AsyncMock) -> None: """Test that resubscribing before idle timeout cancels the unsubscribe.""" # Create session with idle timeout session = RoborockMqttSession(FAKE_PARAMS, topic_idle_timeout=datetime.timedelta(seconds=5)) await session.start() assert session.connected topic = "test/topic" subscriber1 = Subscriber() unsub1 = await session.subscribe(topic, subscriber1.append) # Unsubscribe to start idle timer unsub1() # Resubscribe before idle timeout expires (should cancel timer) subscriber2 = Subscriber() await session.subscribe(topic, subscriber2.append) # Give a brief moment for any async operations to complete await asyncio.sleep(0.01) # unsubscribe should NOT have been called because we resubscribed mqtt_client_lite.unsubscribe.assert_not_called() await session.close() async def test_idle_timeout_unsubscribe(mqtt_client_lite: AsyncMock) -> None: """Test that unsubscribe happens after idle timeout expires.""" # Create session with very short idle timeout for fast test session = RoborockMqttSession(FAKE_PARAMS, topic_idle_timeout=datetime.timedelta(milliseconds=50)) await session.start() assert session.connected topic = "test/topic" subscriber = Subscriber() unsub = await session.subscribe(topic, subscriber.append) # Unsubscribe to start idle timer unsub() # Wait for idle timeout plus a small buffer await asyncio.sleep(0.1) # unsubscribe should have been called after idle timeout mqtt_client_lite.unsubscribe.assert_called_once_with(topic) await session.close() async def test_idle_timeout_multiple_callbacks(mqtt_client_lite: AsyncMock) -> None: """Test that unsubscribe is delayed when multiple subscribers exist.""" # Create session with very short idle timeout for fast test session = RoborockMqttSession(FAKE_PARAMS, topic_idle_timeout=datetime.timedelta(milliseconds=50)) await session.start() assert session.connected topic = "test/topic" subscriber1 = Subscriber() subscriber2 = Subscriber() unsub1 = await session.subscribe(topic, subscriber1.append) unsub2 = await session.subscribe(topic, subscriber2.append) # Unsubscribe first callback (should NOT start timer, subscriber2 still active) unsub1() # Brief wait to ensure no timer fires await asyncio.sleep(0.1) # unsubscribe should NOT have been called because subscriber2 is still active mqtt_client_lite.unsubscribe.assert_not_called() # Unsubscribe second callback (NOW timer should start) unsub2() # Wait for idle timeout plus a small buffer await asyncio.sleep(0.1) # Now unsubscribe should have been called mqtt_client_lite.unsubscribe.assert_called_once_with(topic) await session.close() async def test_subscription_reuse(mqtt_client_lite: AsyncMock) -> None: """Test that subscriptions are reused and not duplicated.""" session = RoborockMqttSession(FAKE_PARAMS) await session.start() assert session.connected # 1. First subscription cb1 = Mock() unsub1 = await session.subscribe("topic1", cb1) # Verify subscribe called mqtt_client_lite.subscribe.assert_called_with("topic1") mqtt_client_lite.subscribe.reset_mock() # 2. Second subscription (same topic) cb2 = Mock() unsub2 = await session.subscribe("topic1", cb2) # Verify subscribe NOT called mqtt_client_lite.subscribe.assert_not_called() # 3. Unsubscribe one unsub1() # Verify unsubscribe NOT called (still have cb2) mqtt_client_lite.unsubscribe.assert_not_called() # 4. Unsubscribe second (starts idle timer) unsub2() # Verify unsubscribe NOT called yet (idle) mqtt_client_lite.unsubscribe.assert_not_called() # 5. Resubscribe during idle cb3 = Mock() _ = await session.subscribe("topic1", cb3) # Verify subscribe NOT called (reused) mqtt_client_lite.subscribe.assert_not_called() await session.close() @pytest.mark.parametrize( ("side_effect", "expected_exception", "match"), [ ( aiomqtt.MqttError("Connection failed"), MqttSessionException, "Error starting MQTT session", ), ( aiomqtt.MqttCodeError(rc=135), MqttSessionUnauthorized, "Authorization error starting MQTT session", ), ( aiomqtt.MqttCodeError(rc=128), MqttSessionException, "Error starting MQTT session", ), ( ValueError("Unexpected"), MqttSessionException, "Unexpected error starting session", ), ], ) async def test_connect_failure( side_effect: Exception, expected_exception: type[Exception], match: str, ) -> None: """Test connection failure with different exceptions.""" mock_aenter = AsyncMock() mock_aenter.side_effect = side_effect with patch("roborock.mqtt.roborock_session.aiomqtt.Client.__aenter__", mock_aenter): with pytest.raises(expected_exception, match=match): await create_mqtt_session(FAKE_PARAMS) async def test_diagnostics_data(push_mqtt_response: Callable[[bytes], None]) -> None: """Test the MQTT session.""" diagnostics = Diagnostics() params = copy.deepcopy(FAKE_PARAMS) params.diagnostics = diagnostics push_mqtt_response(mqtt_packet.gen_connack(rc=0, flags=2)) session = await create_mqtt_session(params) assert session.connected # Verify diagnostics after connection data = diagnostics.as_dict() assert data.get("start_attempt") == 1 assert data.get("start_loop") == 1 assert data.get("start_success") == 1 assert data.get("subscribe_count") is None assert data.get("dispatch_message_count") is None assert data.get("close") is None push_mqtt_response(mqtt_packet.gen_suback(mid=1)) subscriber1 = Subscriber() unsub1 = await session.subscribe("topic-1", subscriber1.append) push_mqtt_response(mqtt_packet.gen_suback(mid=2)) subscriber2 = Subscriber() await session.subscribe("topic-2", subscriber2.append) push_mqtt_response(mqtt_packet.gen_publish("topic-1", mid=3, payload=b"12345")) await subscriber1.wait() assert subscriber1.messages == [b"12345"] assert not subscriber2.messages push_mqtt_response(mqtt_packet.gen_publish("topic-2", mid=4, payload=b"67890")) await subscriber2.wait() assert subscriber2.messages == [b"67890"] push_mqtt_response(mqtt_packet.gen_publish("topic-1", mid=5, payload=b"ABC")) await subscriber1.wait() assert subscriber1.messages == [b"12345", b"ABC"] assert subscriber2.messages == [b"67890"] # Verify diagnostics after subscribing and receiving messages data = diagnostics.as_dict() assert data.get("start_attempt") == 1 assert data.get("start_loop") == 1 assert data.get("subscribe_count") == 2 assert data.get("dispatch_message_count") == 3 assert data.get("close") is None # Messages are no longer received after unsubscribing unsub1() push_mqtt_response(mqtt_packet.gen_publish("topic-1", payload=b"ignored")) assert subscriber1.messages == [b"12345", b"ABC"] assert session.connected await session.close() assert not session.connected # Verify diagnostics after closing session data = diagnostics.as_dict() assert data.get("start_attempt") == 1 assert data.get("start_loop") == 1 assert data.get("subscribe_count") == 2 assert data.get("dispatch_message_count") == 3 assert data.get("close") == 1 @pytest.mark.parametrize( ("create_client_side_effect"), [ # Unauthorized aiomqtt.MqttCodeError(rc=135), ], ) async def test_session_unauthorized_hook(mqtt_client_lite: AsyncMock) -> None: """Test the MQTT session.""" unauthorized = asyncio.Event() params = copy.deepcopy(FAKE_PARAMS) params.unauthorized_hook = unauthorized.set with pytest.raises(MqttSessionUnauthorized): await create_mqtt_session(params) assert unauthorized.is_set() async def test_session_unauthorized_after_start( mock_aenter_client: AsyncMock, message_iterator: FakeAsyncIterator, mqtt_client_lite: AsyncMock, push_mqtt_response: Callable[[bytes], None], ) -> None: """Test the MQTT session.""" # Configure a hook that is notified of unauthorized errors unauthorized = asyncio.Event() params = copy.deepcopy(FAKE_PARAMS) params.unauthorized_hook = unauthorized.set # The client will succeed on first connection attempt, then fail with # unauthorized messages on all future attempts. request_count = 0 def succeed_then_fail_unauthorized() -> Any: nonlocal request_count request_count += 1 if request_count == 1: return mqtt_client_lite raise aiomqtt.MqttCodeError(rc=135) mock_aenter_client.side_effect = succeed_then_fail_unauthorized # Don't produce messages, just exit and restart to reconnect message_iterator.loop = False session = await create_mqtt_session(params) assert session.connected try: async with asyncio.timeout(10): assert await unauthorized.wait() finally: await session.close() Python-roborock-python-roborock-d6da2db/tests/mqtt_packet.py000066400000000000000000000072111513363643200245550ustar00rootroot00000000000000"""Module for crafting MQTT packets. This library is copied from the paho mqtt client library tests, with just the parts needed for some roborock messages. This message format in this file is not specific to roborock. """ import struct PROP_RECEIVE_MAXIMUM = 33 PROP_TOPIC_ALIAS_MAXIMUM = 34 def gen_uint16_prop(identifier: int, word: int) -> bytes: """Generate a property with a uint16_t value.""" prop = struct.pack("!BH", identifier, word) return prop def pack_varint(varint: int) -> bytes: """Pack a variable integer.""" s = b"" while True: byte = varint % 128 varint = varint // 128 # If there are more digits to encode, set the top bit of this digit if varint > 0: byte = byte | 0x80 s = s + struct.pack("!B", byte) if varint == 0: return s def prop_finalise(props: bytes) -> bytes: """Finalise the properties.""" if props is None: return pack_varint(0) else: return pack_varint(len(props)) + props def gen_connack(flags=0, rc=0, properties=b"", property_helper=True): """Generate a CONNACK packet.""" if property_helper: if properties is not None: properties = ( gen_uint16_prop(PROP_TOPIC_ALIAS_MAXIMUM, 10) + properties + gen_uint16_prop(PROP_RECEIVE_MAXIMUM, 20) ) else: properties = b"" properties = prop_finalise(properties) packet = struct.pack("!BBBB", 32, 2 + len(properties), flags, rc) + properties return packet def gen_suback(mid: int, qos: int = 0) -> bytes: """Generate a SUBACK packet.""" return struct.pack("!BBHBB", 144, 2 + 1 + 1, mid, 0, qos) def _gen_short(cmd: int, reason_code: int) -> bytes: return struct.pack("!BBB", cmd, 1, reason_code) def gen_disconnect(reason_code: int = 0) -> bytes: """Generate a DISCONNECT packet.""" return _gen_short(0xE0, reason_code) def _gen_command_with_mid(cmd: int, mid: int, reason_code: int = 0) -> bytes: return struct.pack("!BBHB", cmd, 3, mid, reason_code) def gen_puback(mid: int, reason_code: int = 0) -> bytes: """Generate a PUBACK packet.""" return _gen_command_with_mid(64, mid, reason_code) def _pack_remaining_length(remaining_length: int) -> bytes: """Pack a remaining length.""" s = b"" while True: byte = remaining_length % 128 remaining_length = remaining_length // 128 # If there are more digits to encode, set the top bit of this digit if remaining_length > 0: byte = byte | 0x80 s = s + struct.pack("!B", byte) if remaining_length == 0: return s def gen_publish( topic: str, payload: bytes | None = None, retain: bool = False, dup: bool = False, mid: int = 0, properties: bytes = b"", ) -> bytes: """Generate a PUBLISH packet.""" if isinstance(topic, str): topic_b = topic.encode("utf-8") rl = 2 + len(topic_b) pack_format = "H" + str(len(topic_b)) + "s" properties = prop_finalise(properties) rl += len(properties) # This will break if len(properties) > 127 pack_format = f"{pack_format}{len(properties)}s" if payload is not None: # payload = payload.encode("utf-8") rl = rl + len(payload) pack_format = pack_format + str(len(payload)) + "s" else: payload = b"" pack_format = pack_format + "0s" rlpacked = _pack_remaining_length(rl) cmd = 48 if retain: cmd = cmd + 1 if dup: cmd = cmd + 8 return struct.pack( "!B" + str(len(rlpacked)) + "s" + pack_format, cmd, rlpacked, len(topic_b), topic_b, properties, payload ) Python-roborock-python-roborock-d6da2db/tests/protocols/000077500000000000000000000000001513363643200237125ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/protocols/__init__.py000066400000000000000000000000471513363643200260240ustar00rootroot00000000000000"""Tests for the protocols package.""" Python-roborock-python-roborock-d6da2db/tests/protocols/__snapshots__/000077500000000000000000000000001513363643200265305ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/protocols/__snapshots__/test_b01_q07_protocol.ambr000066400000000000000000000003271513363643200334260ustar00rootroot00000000000000# serializer version: 1 # name: test_decode_rpc_payload[get_prop] ''' { "10001": "{\"msgId\":\"200000000001\",\"code\":0,\"method\":\"prop.get\",\"data\":{\"status\":4,\"main_brush\":4088}}" } ''' # --- Python-roborock-python-roborock-d6da2db/tests/protocols/__snapshots__/test_b01_q10_protocol.ambr000066400000000000000000000040231513363643200334150ustar00rootroot00000000000000# serializer version: 1 # name: test_decode_rpc_payload[dpBattery] ''' { "dpBattery": 100 } ''' # --- # name: test_decode_rpc_payload[dpRequetdps] ''' { "dpStatus": 8, "dpBattery": 100, "dpfunLevel": 2, "dpWaterLevel": 1, "dpMainBrushLife": 0, "dpSideBrushLife": 0, "dpFilterLife": 0, "dpCleanCount": 1, "dpCleanMode": 1, "dpCleanTaskType": 0, "dpBackType": 5, "dpBreakpointClean": 0, "dpValleyPointCharging": false, "dpRobotCountryCode": "us", "dpUserPlan": 0, "dpNotDisturb": 1, "dpVolume": 74, "dpTotalCleanArea": 0, "dpTotalCleanCount": 0, "dpTotalCleanTime": 0, "dpDustSwitch": 1, "dpMopState": 1, "dpAutoBoost": 0, "dpChildLock": 0, "dpDustSetting": 0, "dpMapSaveSwitch": true, "dpRecendCleanRecord": false, "dpCleanTime": 0, "dpMultiMapSwitch": 1, "dpSensorLife": 0, "dpCleanArea": 0, "dpCarpetCleanType": 0, "dpCleanLine": 0, "dpTimeZone": { "timeZoneCity": "America/Los_Angeles", "timeZoneSec": -28800 }, "dpAreaUnit": 0, "dpNetInfo": { "ipAdress": "1.1.1.2", "mac": "99:AA:88:BB:77:CC", "signal": -50, "wifiName": "wifi-network-name" }, "dpRobotType": 1, "dpLineLaserObstacleAvoidance": 1, "dpCleanProgess": 100, "dpGroundClean": 0, "dpFault": 0, "dpNotDisturbExpand": { "disturb_dust_enable": 1, "disturb_light": 1, "disturb_resume_clean": 1, "disturb_voice": 1 }, "dpTimerType": 1, "dpAddCleanState": 0 } ''' # --- # name: test_decode_rpc_payload[dpStatus-dpCleanTaskType] ''' { "dpStatus": 8, "dpCleanTaskType": 0 } ''' # --- # name: test_encode_mqtt_payload[dpRequetdps-None] b'{"dps": {"102": {}}}' # --- # name: test_encode_mqtt_payload[dpRequetdps-params0] b'{"dps": {"102": {}}}' # --- # name: test_encode_mqtt_payload[dpStartClean-params2] b'{"dps": {"201": {"cmd": 1}}}' # --- # name: test_encode_mqtt_payload[dpWaterLevel-2] b'{"dps": {"124": 2}}' # --- Python-roborock-python-roborock-d6da2db/tests/protocols/__snapshots__/test_v1_protocol.ambr000066400000000000000000000142451513363643200327070ustar00rootroot00000000000000# serializer version: 1 # name: test_decode_rpc_payload[app_get_init_status2] 23607 # --- # name: test_decode_rpc_payload[app_get_init_status2].1 ''' [ { "local_info": { "name": "custom_A.03.0096_FCC", "bom": "A.03.0096", "location": "us", "language": "en", "wifiplan": "US", "timezone": "US/Pacific", "logserver": "awsusor0.fds.api.xiaomi.com", "featureset": 1 }, "feature_info": [ 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125 ], "new_feature_info": 10738169343, "status_info": { "state": 8, "battery": 100, "clean_time": 251, "clean_area": 3847500, "error_code": 0, "in_cleaning": 0, "in_returning": 0, "in_fresh_state": 1, "lab_status": 3, "water_box_status": 0, "map_status": 7, "is_locating": 0, "lock_status": 0, "water_box_mode": 203, "distance_off": 0, "water_box_carriage_status": 0, "mop_forbidden_enable": 0, "camera_status": 3495, "is_exploring": 0, "home_sec_status": 0, "home_sec_enable_password": 1, "adbumper_status": [ 0, 0, 0 ] } } ] ''' # --- # name: test_decode_rpc_payload[app_get_init_status] 20001 # --- # name: test_decode_rpc_payload[app_get_init_status].1 ''' [ { "local_info": { "name": "custom_A.03.0069_FCC", "bom": "A.03.0069", "location": "us", "language": "en", "wifiplan": "0x39", "timezone": "US/Pacific", "logserver": "awsusor0.fds.api.xiaomi.com", "featureset": 1 }, "feature_info": [ 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 122, 123, 124, 125 ], "new_feature_info": 633887780925447, "new_feature_info2": 8192, "new_feature_info_str": "0000000000002000", "status_info": { "state": 8, "battery": 100, "clean_time": 5610, "clean_area": 96490000, "error_code": 0, "in_cleaning": 0, "in_returning": 0, "in_fresh_state": 1, "lab_status": 1, "water_box_status": 0, "map_status": 3, "is_locating": 0, "lock_status": 0, "water_box_mode": 204, "distance_off": 0, "water_box_carriage_status": 0, "mop_forbidden_enable": 0 } } ] ''' # --- # name: test_decode_rpc_payload[get_clean_summary] 20001 # --- # name: test_decode_rpc_payload[get_clean_summary].1 ''' [ 1442559, 24258125000, 296, [ 1756848207, 1754930385, 1753203976, 1752183435, 1747427370, 1746204046, 1745601543, 1744387080, 1743528522, 1742489154, 1741022299, 1740433682, 1739902516, 1738875106, 1738864366, 1738620067, 1736873889, 1736197544, 1736121269, 1734458038 ] ] ''' # --- # name: test_decode_rpc_payload[get_consumeables] 20001 # --- # name: test_decode_rpc_payload[get_consumeables].1 ''' [ { "main_brush_work_time": 879348, "side_brush_work_time": 707618, "filter_work_time": 738722, "filter_element_work_time": 0, "sensor_dirty_time": 455517 } ] ''' # --- # name: test_decode_rpc_payload[get_dnd] 20002 # --- # name: test_decode_rpc_payload[get_dnd].1 ''' [ { "start_hour": 22, "start_minute": 0, "end_hour": 8, "end_minute": 0, "enabled": 1 } ] ''' # --- # name: test_decode_rpc_payload[get_last_clean_record] 20003 # --- # name: test_decode_rpc_payload[get_last_clean_record].1 ''' [ [ 1738864366, 1738868964, 4358, 81122500, 0, 0, 1, 1, 21 ] ] ''' # --- # name: test_decode_rpc_payload[get_multi_maps_list] 20001 # --- # name: test_decode_rpc_payload[get_multi_maps_list].1 ''' [ { "max_multi_map": 1, "max_bak_map": 1, "multi_map_count": 1, "map_info": [ { "mapFlag": 0, "add_time": 1747132930, "length": 0, "name": "", "bak_maps": [ { "mapFlag": 4, "add_time": 1747132936 } ] } ] } ] ''' # --- # name: test_decode_rpc_payload[get_room_mapping2] 20001 # --- # name: test_decode_rpc_payload[get_room_mapping2].1 ''' [ [ 16, "2537178", 6 ], [ 17, "2537175", 14 ], [ 18, "2537174", 13 ], [ 19, "2537176", 14 ], [ 20, "10655627", 12 ], [ 21, "2537145", 2 ], [ 22, "2537147", 12 ] ] ''' # --- # name: test_decode_rpc_payload[get_room_mapping] 20001 # --- # name: test_decode_rpc_payload[get_room_mapping].1 ''' [ [ 16, "3031886" ], [ 17, "3031880" ], [ 18, "3031883" ] ] ''' # --- # name: test_decode_rpc_payload[get_status] 20001 # --- # name: test_decode_rpc_payload[get_status].1 ''' [ { "msg_ver": 2, "msg_seq": 515, "state": 8, "battery": 100, "clean_time": 5405, "clean_area": 91287500, "error_code": 0, "map_present": 1, "in_cleaning": 0, "in_returning": 0, "in_fresh_state": 1, "lab_status": 1, "water_box_status": 0, "fan_power": 106, "dnd_enabled": 1, "map_status": 3, "is_locating": 0, "lock_status": 0, "water_box_mode": 204, "distance_off": 0, "water_box_carriage_status": 0, "mop_forbidden_enable": 0, "unsave_map_reason": 4, "unsave_map_flag": 0 } ] ''' # --- # name: test_decode_rpc_payload[get_volume] 20001 # --- # name: test_decode_rpc_payload[get_volume].1 ''' [ 90 ] ''' # --- Python-roborock-python-roborock-d6da2db/tests/protocols/common.py000066400000000000000000000013141513363643200255530ustar00rootroot00000000000000"""Common test utils for the protocols package.""" import json from typing import Any from Crypto.Cipher import AES from Crypto.Util.Padding import pad from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol def build_a01_message(message: dict[Any, Any], seq: int = 2020) -> RoborockMessage: """Build an encoded A01 RPC response message.""" return RoborockMessage( protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=pad( json.dumps( { "dps": message, # {10000: json.dumps(message)}, } ).encode(), AES.block_size, ), version=b"A01", seq=seq, ) Python-roborock-python-roborock-d6da2db/tests/protocols/test_a01_protocol.py000066400000000000000000000206231513363643200276300ustar00rootroot00000000000000"""Tests for A01 protocol encoding and decoding.""" import json from typing import Any import pytest from Crypto.Cipher import AES from Crypto.Util.Padding import unpad from roborock.exceptions import RoborockException from roborock.protocols.a01_protocol import decode_rpc_response, encode_mqtt_payload from roborock.roborock_message import ( RoborockDyadDataProtocol, RoborockMessage, RoborockMessageProtocol, RoborockZeoProtocol, ) def test_encode_mqtt_payload_basic(): """Test basic MQTT payload encoding.""" # Test data with proper protocol keys data: dict[RoborockDyadDataProtocol | RoborockZeoProtocol, Any] = { RoborockDyadDataProtocol.START: {"test": "data", "number": 42} } result = encode_mqtt_payload(data) # Verify result is a RoborockMessage assert isinstance(result, RoborockMessage) assert result.protocol == RoborockMessageProtocol.RPC_REQUEST assert result.version == b"A01" assert result.payload is not None assert isinstance(result.payload, bytes) assert len(result.payload) % 16 == 0 # Should be padded to AES block size # Decode the payload to verify structure decoded_data = decode_rpc_response(result) assert decoded_data == {200: {"test": "data", "number": 42}} def test_encode_mqtt_payload_empty_data(): """Test encoding with empty data.""" data: dict[RoborockDyadDataProtocol | RoborockZeoProtocol, Any] = {} result = encode_mqtt_payload(data) assert isinstance(result, RoborockMessage) assert result.protocol == RoborockMessageProtocol.RPC_REQUEST assert result.payload is not None # Decode the payload to verify structure decoded_data = decode_rpc_response(result) assert decoded_data == {} def test_value_encoder(): """Test that value_encoder is applied to all values.""" data: dict[RoborockDyadDataProtocol | RoborockZeoProtocol, Any] = {RoborockDyadDataProtocol.ID_QUERY: [101, 102]} result = encode_mqtt_payload(data, value_encoder=json.dumps) # Decode manually to check the raw JSON structure decoded_json = json.loads(unpad(result.payload, AES.block_size).decode()) # ID_QUERY (10000) should be a string "[101, 102]", not a list [101, 102] assert decoded_json["dps"]["10000"] == "[101, 102]" assert isinstance(decoded_json["dps"]["10000"], str) def test_encode_mqtt_payload_complex_data(): """Test encoding with complex nested data.""" data: dict[RoborockDyadDataProtocol | RoborockZeoProtocol, Any] = { RoborockDyadDataProtocol.STATUS: { "nested": {"deep": {"value": 123}}, "list": [1, 2, 3, "test"], "boolean": True, "null": None, }, RoborockZeoProtocol.MODE: "simple_value", } result = encode_mqtt_payload(data) assert isinstance(result, RoborockMessage) assert result.protocol == RoborockMessageProtocol.RPC_REQUEST assert result.payload is not None assert isinstance(result.payload, bytes) # Decode the payload to verify structure decoded_data = decode_rpc_response(result) assert decoded_data == { 201: { "nested": {"deep": {"value": 123}}, # Note: The list inside the dictionary is NOT converted because # our fix only targets top-level list values in the dps map "list": [1, 2, 3, "test"], "boolean": True, "null": None, }, 204: "simple_value", } def test_decode_rpc_response_valid_message(): """Test decoding a valid RPC response.""" # Create a valid padded JSON payload payload_data = {"dps": {"1": {"key": "value"}, "2": 42, "10": ["list", "data"]}} json_payload = json.dumps(payload_data).encode("utf-8") # Pad to AES block size (16 bytes) padding_length = 16 - (len(json_payload) % 16) padded_payload = json_payload + bytes([padding_length] * padding_length) message = RoborockMessage(protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=padded_payload) result = decode_rpc_response(message) assert isinstance(result, dict) assert 1 in result assert 2 in result assert 10 in result assert result[1] == {"key": "value"} assert result[2] == 42 assert result[10] == ["list", "data"] def test_decode_rpc_response_string_keys(): """Test decoding with string keys that can be converted to integers.""" payload_data = {"dps": {"1": "first", "100": "hundred", "999": {"nested": "data"}}} json_payload = json.dumps(payload_data).encode("utf-8") # Pad to AES block size padding_length = 16 - (len(json_payload) % 16) padded_payload = json_payload + bytes([padding_length] * padding_length) message = RoborockMessage(protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=padded_payload) result = decode_rpc_response(message) assert result[1] == "first" assert result[100] == "hundred" assert result[999] == {"nested": "data"} def test_decode_rpc_response_missing_payload(): """Test decoding fails when payload is missing.""" message = RoborockMessage(protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=None) with pytest.raises(RoborockException, match="Invalid A01 message format: missing payload"): decode_rpc_response(message) def test_decode_rpc_response_invalid_padding(): """Test decoding fails with invalid padding.""" # Create invalid padded data invalid_payload = b"invalid padding data" message = RoborockMessage(protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=invalid_payload) with pytest.raises(RoborockException, match="Unable to unpad A01 payload"): decode_rpc_response(message) def test_decode_rpc_response_invalid_json(): """Test decoding fails with invalid JSON after unpadding.""" # Create properly padded but invalid JSON invalid_json = b"invalid json data" padding_length = 16 - (len(invalid_json) % 16) padded_payload = invalid_json + bytes([padding_length] * padding_length) message = RoborockMessage(protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=padded_payload) with pytest.raises(RoborockException, match="Invalid A01 message payload"): decode_rpc_response(message) def test_decode_rpc_response_missing_dps(): """Test decoding with missing 'dps' key returns empty dict.""" payload_data = {"other_key": "value"} json_payload = json.dumps(payload_data).encode("utf-8") # Pad to AES block size padding_length = 16 - (len(json_payload) % 16) padded_payload = json_payload + bytes([padding_length] * padding_length) message = RoborockMessage(protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=padded_payload) result = decode_rpc_response(message) assert result == {} def test_decode_rpc_response_dps_not_dict(): """Test decoding fails when 'dps' is not a dictionary.""" payload_data = {"dps": "not_a_dict"} json_payload = json.dumps(payload_data).encode("utf-8") # Pad to AES block size padding_length = 16 - (len(json_payload) % 16) padded_payload = json_payload + bytes([padding_length] * padding_length) message = RoborockMessage(protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=padded_payload) with pytest.raises(RoborockException, match=r"Invalid A01 message format.*'dps' should be a dictionary"): decode_rpc_response(message) def test_decode_rpc_response_invalid_key(): """Test decoding fails when dps contains non-integer keys.""" payload_data = {"dps": {"1": "valid", "not_a_number": "invalid"}} json_payload = json.dumps(payload_data).encode("utf-8") # Pad to AES block size padding_length = 16 - (len(json_payload) % 16) padded_payload = json_payload + bytes([padding_length] * padding_length) message = RoborockMessage(protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=padded_payload) with pytest.raises(RoborockException, match=r"Invalid A01 message format:.*'dps' key should be an integer"): decode_rpc_response(message) def test_decode_rpc_response_empty_dps(): """Test decoding with empty dps dictionary.""" payload_data: dict[str, Any] = {"dps": {}} json_payload = json.dumps(payload_data).encode("utf-8") # Pad to AES block size padding_length = 16 - (len(json_payload) % 16) padded_payload = json_payload + bytes([padding_length] * padding_length) message = RoborockMessage(protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=padded_payload) result = decode_rpc_response(message) assert result == {} Python-roborock-python-roborock-d6da2db/tests/protocols/test_b01_q07_protocol.py000066400000000000000000000045571513363643200303300ustar00rootroot00000000000000"""Tests for the B01 protocol message encoding and decoding.""" import json import pathlib from collections.abc import Generator import pytest from Crypto.Cipher import AES from Crypto.Util.Padding import unpad from freezegun import freeze_time from syrupy import SnapshotAssertion from roborock.protocols.b01_q7_protocol import Q7RequestMessage, decode_rpc_response, encode_mqtt_payload from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol TESTDATA_PATH = pathlib.Path("tests/protocols/testdata/b01_q7_protocol") TESTDATA_FILES = list(TESTDATA_PATH.glob("*.json")) TESTDATA_IDS = [x.stem for x in TESTDATA_FILES] @pytest.fixture(autouse=True) def fixed_time_fixture() -> Generator[None, None, None]: """Fixture to freeze time for predictable request IDs.""" with freeze_time("2025-01-20T12:00:00"): yield @pytest.mark.parametrize("filename", TESTDATA_FILES, ids=TESTDATA_IDS) def test_decode_rpc_payload(filename: str, snapshot: SnapshotAssertion) -> None: """Test decoding a B01 RPC response protocol message.""" with open(filename, "rb") as f: payload = f.read() message = RoborockMessage( protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=payload, seq=12750, version=b"B01", random=97431, timestamp=1652547161, ) decoded_message = decode_rpc_response(message) assert json.dumps(decoded_message, indent=2) == snapshot @pytest.mark.parametrize( ("dps", "command", "params", "msg_id"), [ ( 10000, "prop.get", {"property": ["status", "fault"]}, 123456789, ), ], ) def test_encode_mqtt_payload(dps: int, command: str, params: dict[str, list[str]], msg_id: int) -> None: """Test encoding of MQTT payload for B01 commands.""" message = encode_mqtt_payload(Q7RequestMessage(dps, command, params, msg_id)) assert isinstance(message, RoborockMessage) assert message.protocol == RoborockMessageProtocol.RPC_REQUEST assert message.version == b"B01" assert message.payload is not None unpadded = unpad(message.payload, AES.block_size) decoded_json = json.loads(unpadded.decode("utf-8")) assert decoded_json["dps"][str(dps)]["method"] == command assert decoded_json["dps"][str(dps)]["msgId"] == str(msg_id) assert decoded_json["dps"][str(dps)]["params"] == params Python-roborock-python-roborock-d6da2db/tests/protocols/test_b01_q10_protocol.py000066400000000000000000000073721513363643200303200ustar00rootroot00000000000000"""Tests for the B01 protocol message encoding and decoding.""" import json import pathlib from collections.abc import Generator from typing import Any import pytest from freezegun import freeze_time from syrupy import SnapshotAssertion from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXWaterLevel from roborock.exceptions import RoborockException from roborock.protocols.b01_q10_protocol import ( decode_rpc_response, encode_mqtt_payload, ) from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol TESTDATA_PATH = pathlib.Path("tests/protocols/testdata/b01_q10_protocol/") TESTDATA_FILES = list(TESTDATA_PATH.glob("*.json")) TESTDATA_IDS = [x.stem for x in TESTDATA_FILES] @pytest.fixture(autouse=True) def fixed_time_fixture() -> Generator[None, None, None]: """Fixture to freeze time for predictable request IDs.""" with freeze_time("2025-01-20T12:00:00"): yield @pytest.mark.parametrize("filename", TESTDATA_FILES, ids=TESTDATA_IDS) def test_decode_rpc_payload(filename: str, snapshot: SnapshotAssertion) -> None: """Test decoding a B01 RPC response protocol message.""" with open(filename, "rb") as f: payload = f.read() message = RoborockMessage( protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=payload, seq=12750, version=b"B01", random=97431, timestamp=1652547161, ) decoded_message = decode_rpc_response(message) assert json.dumps(decoded_message, indent=2) == snapshot @pytest.mark.parametrize( ("payload", "expected_error_message"), [ (b"", "missing payload"), (b"n", "Invalid B01 json payload"), (b"{}", "missing 'dps'"), (b'{"dps": []}', "'dps' should be a dictionary"), (b'{"dps": {"not_a_number": 123}}', "dps key is not a valid integer"), (b'{"dps": {"101": 123}}', "Invalid dpCommon format: expected dict"), (b'{"dps": {"101": {"not_a_number": 123}}}', "Invalid dpCommon format: dps key is not a valid intege"), ], ) def test_decode_invalid_rpc_payload(payload: bytes, expected_error_message: str) -> None: """Test decoding a B01 RPC response protocol message.""" message = RoborockMessage( protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=payload, seq=12750, version=b"B01", random=97431, timestamp=1652547161, ) with pytest.raises(RoborockException, match=expected_error_message): decode_rpc_response(message) def test_decode_unknown_dps_code() -> None: """Test decoding a B01 RPC response protocol message.""" message = RoborockMessage( protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=b'{"dps": {"909090": 123, "122":100}}', seq=12750, version=b"B01", random=97431, timestamp=1652547161, ) decoded_message = decode_rpc_response(message) assert decoded_message == { B01_Q10_DP.BATTERY: 100, } @pytest.mark.parametrize( ("command", "params"), [ (B01_Q10_DP.REQUETDPS, {}), (B01_Q10_DP.REQUETDPS, None), (B01_Q10_DP.START_CLEAN, {"cmd": 1}), (B01_Q10_DP.WATER_LEVEL, YXWaterLevel.MIDDLE.code), ], ) def test_encode_mqtt_payload(command: B01_Q10_DP, params: dict[str, Any], snapshot) -> None: """Test encoding of MQTT payload for B01 Q10 commands.""" message = encode_mqtt_payload(command, params) assert isinstance(message, RoborockMessage) assert message.protocol == RoborockMessageProtocol.RPC_REQUEST assert message.version == b"B01" assert message.payload is not None # Snapshot the raw payload to ensure stable encoding. We verify it is # valid json assert snapshot == message.payload json.loads(message.payload.decode()) Python-roborock-python-roborock-d6da2db/tests/protocols/test_l01_protocol.py000066400000000000000000000037761513363643200276550ustar00rootroot00000000000000from roborock.protocol import Utils def test_encryption(): """Tests the L01 GCM encryption logic.""" local_key = "b8Hj5mFk3QzT7rLp" timestamp = 1753606905 sequence = 1 nonce = 304251 connect_nonce = 893563 ack_nonce = 485592656 payload_str = ( '{"dps":{"101":"{\\"id\\":1806,\\"method\\":\\"get_prop\\",\\"params\\":[\\"get_status\\"]}"},"t":1753606905}' ) payload = payload_str.encode("utf-8") encrypted_data = Utils.encrypt_gcm_l01( plaintext=payload, local_key=local_key, timestamp=timestamp, sequence=sequence, nonce=nonce, connect_nonce=connect_nonce, ack_nonce=ack_nonce, ) expected_data = bytes.fromhex( "fd60c8daca1ccae67f6077477bfa9d37189a38d75b3c4a907c2435d3c146ee84d8f99597e3e1571a015961ceaa4d64bc3695fae024c341" "6737d77150341de29cad2f95bfaf532358f12bbff89f140fef5b1ee284c3abfe3b83a577910a72056dab4d5a75b182d1a0cba145e3e450" "f3927443" ) assert encrypted_data == expected_data def test_decryption(): """Tests the L01 GCM decryption logic.""" local_key = "b8Hj5mFk3QzT7rLp" timestamp = 1753606905 sequence = 1 nonce = 304251 connect_nonce = 893563 ack_nonce = 485592656 payload = bytes.fromhex( "fd60c8daca1ccae67f6077477bfa9d37189a38d75b3c4a907c2435d3c146ee84d8f99597e3e1571a015961ceaa4d64bc3695fae024c341" "6737d77150341de29cad2f95bfaf532358f12bbff89f140fef5b1ee284c3abfe3b83a577910a72056dab4d5a75b182d1a0cba145e3e450" "f3927443" ) decrypted_data = Utils.decrypt_gcm_l01( payload=payload, local_key=local_key, timestamp=timestamp, sequence=sequence, nonce=nonce, connect_nonce=connect_nonce, ack_nonce=ack_nonce, ) decrypted_str = decrypted_data.decode("utf-8") expected_str = ( '{"dps":{"101":"{\\"id\\":1806,\\"method\\":\\"get_prop\\",\\"params\\":[\\"get_status\\"]}"},"t":1753606905}' ) assert decrypted_str == expected_str Python-roborock-python-roborock-d6da2db/tests/protocols/test_v1_protocol.py000066400000000000000000000261161513363643200276000ustar00rootroot00000000000000"""Tests for the v1 protocol message encoding and decoding.""" import json import logging import pathlib from collections.abc import Generator from unittest.mock import patch import pytest from freezegun import freeze_time from syrupy import SnapshotAssertion from roborock.data.containers import RoborockBase, UserData from roborock.exceptions import RoborockException from roborock.protocol import Utils from roborock.protocols.v1_protocol import ( RequestMessage, SecurityData, create_map_response_decoder, decode_rpc_response, ) from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol from roborock.roborock_typing import RoborockCommand from tests import mock_data USER_DATA = UserData.from_dict(mock_data.USER_DATA) TEST_REQUEST_ID = 44444 TEST_ENDPOINT = "87ItGWdb" TEST_ENDPOINT_BYTES = TEST_ENDPOINT.encode() SECURITY_DATA = SecurityData( endpoint=TEST_ENDPOINT, nonce=b"\x91\xbe\x10\xc9b+\x9d\x8a\xcdH*\x19\xf6\xfe\x81h", ) TESTDATA_PATH = pathlib.Path("tests/protocols/testdata/v1_protocol/") TESTDATA_FILES = list(TESTDATA_PATH.glob("*.json")) TESTDATA_IDS = [x.stem for x in TESTDATA_FILES] @pytest.fixture(autouse=True) def fixed_time_fixture() -> Generator[None, None, None]: """Fixture to freeze time for predictable request IDs.""" # Freeze time to a specific point so request IDs are predictable with freeze_time("2025-01-20T12:00:00"): yield @pytest.fixture(name="test_request_id", autouse=True) def request_id_fixture() -> Generator[int, None, None]: """Fixture to provide a fixed request ID.""" with patch("roborock.protocols.v1_protocol.get_next_int", return_value=TEST_REQUEST_ID): yield TEST_REQUEST_ID @pytest.mark.parametrize( ("command", "params", "expected"), [ ( RoborockCommand.GET_STATUS, None, b'{"dps":{"101":"{\\"id\\":44444,\\"method\\":\\"get_status\\",\\"params\\":[]}"},"t":1737374400}', ) ], ) def test_encode_local_payload(command, params, expected): """Test encoding of local payload for V1 commands.""" message = RequestMessage(command, params).encode_message(RoborockMessageProtocol.GENERAL_REQUEST) assert isinstance(message, RoborockMessage) assert message.protocol == RoborockMessageProtocol.GENERAL_REQUEST assert message.payload == expected @pytest.mark.parametrize( ("command", "params", "expected"), [ ( RoborockCommand.GET_STATUS, None, b'{"dps":{"101":"{\\"id\\":44444,\\"method\\":\\"get_status\\",\\"params\\":[],\\"security\\":{\\"endpoint\\":\\"87ItGWdb\\",\\"nonce\\":\\"91be10c9622b9d8acd482a19f6fe8168\\"}}"},"t":1737374400}', ) ], ) def test_encode_mqtt_payload(command, params, expected): """Test encoding of local payload for V1 commands.""" request_message = RequestMessage(command, params=params) message = request_message.encode_message(RoborockMessageProtocol.RPC_REQUEST, SECURITY_DATA) assert isinstance(message, RoborockMessage) assert message.protocol == RoborockMessageProtocol.RPC_REQUEST assert message.payload == expected @pytest.mark.parametrize( ("payload", "expected"), [ ( b'{"t":1652547161,"dps":{"102":"{\\"id\\":20005,\\"result\\":[{\\"msg_ver\\":2,\\"msg_seq\\":1072,\\"state\\":8,\\"battery\\":100,\\"clean_time\\":1041,\\"clean_area\\":37080000,\\"error_code\\":0,\\"map_present\\":1,\\"in_cleaning\\":0,\\"in_returning\\":0,\\"in_fresh_state\\":1,\\"lab_status\\":1,\\"water_box_status\\":0,\\"fan_power\\":103,\\"dnd_enabled\\":0,\\"map_status\\":3,\\"is_locating\\":0,\\"lock_status\\":0,\\"water_box_mode\\":202,\\"distance_off\\":0,\\"water_box_carriage_status\\":0,\\"mop_forbidden_enable\\":0,\\"unsave_map_reason\\":0,\\"unsave_map_flag\\":0}]}"}}', [ { "msg_ver": 2, "msg_seq": 1072, "state": 8, "battery": 100, "clean_time": 1041, "clean_area": 37080000, "error_code": 0, "map_present": 1, "in_cleaning": 0, "in_returning": 0, "in_fresh_state": 1, "lab_status": 1, "water_box_status": 0, "fan_power": 103, "dnd_enabled": 0, "map_status": 3, "is_locating": 0, "lock_status": 0, "water_box_mode": 202, "distance_off": 0, "water_box_carriage_status": 0, "mop_forbidden_enable": 0, "unsave_map_reason": 0, "unsave_map_flag": 0, } ], ), ], ) def test_decode_rpc_response(payload: bytes, expected: RoborockBase) -> None: """Test decoding a v1 RPC response protocol message.""" # The values other than the payload are arbitrary message = RoborockMessage( protocol=RoborockMessageProtocol.GENERAL_RESPONSE, payload=payload, seq=12750, version=b"1.0", random=97431, timestamp=1652547161, ) decoded_message = decode_rpc_response(message) assert decoded_message.request_id == 20005 assert decoded_message.data == expected @pytest.mark.parametrize("filename", TESTDATA_FILES, ids=TESTDATA_IDS) def test_decode_rpc_payload(filename: str, snapshot: SnapshotAssertion) -> None: """Test decoding a v1 RPC response protocol message.""" with open(filename, "rb") as f: payload = f.read() # The values other than the payload are arbitrary message = RoborockMessage( protocol=RoborockMessageProtocol.GENERAL_RESPONSE, payload=payload, seq=12750, version=b"1.0", random=97431, timestamp=1652547161, ) decoded_message = decode_rpc_response(message) assert decoded_message.request_id == snapshot assert json.dumps(decoded_message.data, indent=2) == snapshot def test_create_map_response_decoder(): """Test creating and using a map response decoder.""" test_data = b"some map\n" compressed_data = ( b"\x1f\x8b\x08\x08\xf9\x13\x99h\x00\x03foo\x00+\xce\xcfMU\xc8M,\xe0\x02\x00@\xdb\xc6\x1a\t\x00\x00\x00" ) # Create header: endpoint(8) + padding(8) + request_id(2) + padding(6) # request_id = 44508 (0xaddc in little endian) header = TEST_ENDPOINT_BYTES + b"\x00" * 8 + b"\xdc\xad" + b"\x00" * 6 encrypted_data = Utils.encrypt_cbc(compressed_data, SECURITY_DATA.nonce) payload = header + encrypted_data message = RoborockMessage( protocol=RoborockMessageProtocol.MAP_RESPONSE, payload=payload, seq=12750, version=b"1.0", random=97431, timestamp=1652547161, ) decoder = create_map_response_decoder(SECURITY_DATA) result = decoder(message) assert result is not None assert result.request_id == 44508 assert result.data == test_data def test_create_map_response_decoder_invalid_endpoint(caplog: pytest.LogCaptureFixture): """Test map response decoder with invalid endpoint.""" caplog.set_level(logging.DEBUG) # Create header with wrong endpoint header = b"wrongend" + b"\x00" * 8 + b"\xdc\xad" + b"\x00" * 6 payload = header + b"encrypted_data" message = RoborockMessage( protocol=RoborockMessageProtocol.MAP_RESPONSE, payload=payload, seq=12750, version=b"1.0", random=97431, timestamp=1652547161, ) decoder = create_map_response_decoder(SECURITY_DATA) assert decoder(message) is None assert "Received map response not requested by this device, ignoring." in caplog.text def test_create_map_response_decoder_invalid_payload(): """Test map response decoder with invalid payload.""" message = RoborockMessage( protocol=RoborockMessageProtocol.MAP_RESPONSE, payload=b"short", # Too short payload seq=12750, version=b"1.0", random=97431, timestamp=1652547161, ) decoder = create_map_response_decoder(SECURITY_DATA) with pytest.raises(RoborockException, match="Invalid V1 map response format: missing payload"): decoder(message) @pytest.mark.parametrize( ("payload", "expected_data", "expected_error"), [ ( b'{"t":1757883536,"dps":{"102":"{\\"id\\":20001,\\"result\\":\\"unknown_method\\"}"}}', {}, "The method called is not recognized by the device.", ), ( b'{"t":1757883536,"dps":{"102":"{\\"id\\":20001,\\"result\\":\\"other\\"}"}}', {}, "Unexpected API Result", ), ], ) def test_decode_result_with_error(payload: bytes, expected_data: dict[str, str], expected_error: str) -> None: """Test decoding a v1 RPC response protocol message.""" # The values other than the payload are arbitrary message = RoborockMessage( protocol=RoborockMessageProtocol.GENERAL_RESPONSE, payload=payload, seq=12750, version=b"1.0", random=97431, timestamp=1652547161, ) decoded_message = decode_rpc_response(message) assert decoded_message.request_id == 20001 assert decoded_message.data == expected_data assert decoded_message.api_error assert expected_error in str(decoded_message.api_error) def test_decode_no_request_id(): """Test map response decoder without a request id is raised as an exception.""" message = RoborockMessage( protocol=RoborockMessageProtocol.GENERAL_RESPONSE, payload=b'{"t":1757883536,"dps":{"102":"{\\"result\\":\\"unknown_method\\"}"}}', seq=12750, version=b"1.0", random=97431, timestamp=1652547161, ) with pytest.raises(RoborockException, match="The method called is not recognized by the device"): decode_rpc_response(message) def test_decode_error_without_result() -> None: """Test decoding an error-only V1 RPC response (no 'result' key).""" payload = ( b'{"t":1768419740,"dps":{"102":"{\\"id\\":20062,\\"error\\":{\\"code\\":-10007,' b'\\"message\\":\\"invalid status\\"}}"}}' ) message = RoborockMessage( protocol=RoborockMessageProtocol.GENERAL_RESPONSE, payload=payload, seq=12750, version=b"1.0", random=97431, timestamp=1768419740, ) decoded_message = decode_rpc_response(message) assert decoded_message.request_id == 20062 assert decoded_message.data == {} assert decoded_message.api_error assert isinstance(decoded_message.api_error.args[0], dict) assert decoded_message.api_error.args[0]["code"] == -10007 def test_invalid_unicode() -> None: """Test an error while decoding unicode bytes""" message = RoborockMessage( protocol=RoborockMessageProtocol.GENERAL_RESPONSE, payload=b"hello\xff\xfe", # Invalid UTF-8 bytes seq=12750, version=b"1.0", random=97431, timestamp=1652547161, ) with pytest.raises(RoborockException, match="Invalid V1 message payload"): decode_rpc_response(message) Python-roborock-python-roborock-d6da2db/tests/protocols/testdata/000077500000000000000000000000001513363643200255235ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/protocols/testdata/b01_q10_protocol/000077500000000000000000000000001513363643200305075ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/protocols/testdata/b01_q10_protocol/dpBattery.json000066400000000000000000000000431513363643200333350ustar00rootroot00000000000000{"dps":{"122":100},"t":1766800902} Python-roborock-python-roborock-d6da2db/tests/protocols/testdata/b01_q10_protocol/dpRequetdps.json000066400000000000000000000011371513363643200337040ustar00rootroot00000000000000{"dps":{"101":{"104":0,"105":false,"109":"us","207":0,"25":1,"26":74,"29":0,"30":0,"31":0,"37":1,"40":1,"45":0,"47":0,"50":0,"51":true,"53":false,"6":0,"60":1,"67":0,"7":0,"76":0,"78":0,"79":{"timeZoneCity":"America/Los_Angeles","timeZoneSec":-28800},"80":0,"81":{"ipAdress":"1.1.1.2","mac":"99:AA:88:BB:77:CC","signal":-50,"wifiName":"wifi-network-name"},"83":1,"86":1,"87":100,"88":0,"90":0,"92":{"disturb_dust_enable":1,"disturb_light":1,"disturb_resume_clean":1,"disturb_voice":1},"93":1,"96":0},"121":8,"122":100,"123":2,"124":1,"125":0,"126":0,"127":0,"136":1,"137":1,"138":0,"139":5},"t":1766802312} dpStatus-dpCleanTaskType.json000066400000000000000000000000511513363643200361570ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/protocols/testdata/b01_q10_protocol{"dps":{"121":8,"138":0},"t":1766800904} Python-roborock-python-roborock-d6da2db/tests/protocols/testdata/b01_q7_protocol/000077500000000000000000000000001513363643200304355ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/protocols/testdata/b01_q7_protocol/get_prop.json000066400000000000000000000002171513363643200331470ustar00rootroot00000000000000{"t":1765660648,"dps":{"10001":"{\"msgId\":\"200000000001\",\"code\":0,\"method\":\"prop.get\",\"data\":{\"status\":4,\"main_brush\":4088}}"}} Python-roborock-python-roborock-d6da2db/tests/protocols/testdata/v1_protocol/000077500000000000000000000000001513363643200277725ustar00rootroot00000000000000app_get_init_status.json000066400000000000000000000015011513363643200346500ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/protocols/testdata/v1_protocol{"t":1760665602,"dps":{"102":"{\"id\":20001,\"result\":[{\"local_info\":{\"name\":\"custom_A.03.0069_FCC\",\"bom\":\"A.03.0069\",\"location\":\"us\",\"language\":\"en\",\"wifiplan\":\"0x39\",\"timezone\":\"US/Pacific\",\"logserver\":\"awsusor0.fds.api.xiaomi.com\",\"featureset\":1},\"feature_info\":[111,112,113,114,115,116,117,118,119,120,122,123,124,125],\"new_feature_info\":633887780925447,\"new_feature_info2\":8192,\"new_feature_info_str\":\"0000000000002000\",\"status_info\":{\"state\":8,\"battery\":100,\"clean_time\":5610,\"clean_area\":96490000,\"error_code\":0,\"in_cleaning\":0,\"in_returning\":0,\"in_fresh_state\":1,\"lab_status\":1,\"water_box_status\":0,\"map_status\":3,\"is_locating\":0,\"lock_status\":0,\"water_box_mode\":204,\"distance_off\":0,\"water_box_carriage_status\":0,\"mop_forbidden_enable\":0}}]}"}} app_get_init_status2.json000066400000000000000000000015571513363643200347450ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/protocols/testdata/v1_protocol{"t":1765918422,"dps":{"102":"{\"id\":23607,\"result\":[{\"local_info\":{\"name\":\"custom_A.03.0096_FCC\",\"bom\":\"A.03.0096\",\"location\":\"us\",\"language\":\"en\",\"wifiplan\":\"US\",\"timezone\":\"US/Pacific\",\"logserver\":\"awsusor0.fds.api.xiaomi.com\",\"featureset\":1},\"feature_info\":[111,112,113,114,115,116,117,118,119,120,121,122,123,124,125],\"new_feature_info\":10738169343,\"status_info\":{\"state\":8,\"battery\":100,\"clean_time\":251,\"clean_area\":3847500,\"error_code\":0,\"in_cleaning\":0,\"in_returning\":0,\"in_fresh_state\":1,\"lab_status\":3,\"water_box_status\":0,\"map_status\":7,\"is_locating\":0,\"lock_status\":0,\"water_box_mode\":203,\"distance_off\":0,\"water_box_carriage_status\":0,\"mop_forbidden_enable\":0,\"camera_status\":3495,\"is_exploring\":0,\"home_sec_status\":0,\"home_sec_enable_password\":1,\"adbumper_status\":[0,0,0]}}]}"}} Python-roborock-python-roborock-d6da2db/tests/protocols/testdata/v1_protocol/get_clean_summary.json000066400000000000000000000005051513363643200343630ustar00rootroot00000000000000{ "t": 1757878288, "dps": { "102": "{\"id\":20001,\"result\":[1442559,24258125000,296,[1756848207,1754930385,1753203976,1752183435,1747427370,1746204046,1745601543,1744387080,1743528522,1742489154,1741022299,1740433682,1739902516,1738875106,1738864366,1738620067,1736873889,1736197544,1736121269,1734458038]]}" } } Python-roborock-python-roborock-d6da2db/tests/protocols/testdata/v1_protocol/get_consumeables.json000066400000000000000000000003271513363643200342060ustar00rootroot00000000000000{"t":1759038395,"dps":{"102":"{\"id\":20001,\"result\":[{\"main_brush_work_time\":879348,\"side_brush_work_time\":707618,\"filter_work_time\":738722,\"filter_element_work_time\":0,\"sensor_dirty_time\":455517}]}"}} Python-roborock-python-roborock-d6da2db/tests/protocols/testdata/v1_protocol/get_dnd.json000066400000000000000000000002261513363643200322710ustar00rootroot00000000000000{"t": 1755785801, "dps": {"102": "{\"id\":20002,\"result\":[{\"start_hour\":22,\"start_minute\":0,\"end_hour\":8,\"end_minute\":0,\"enabled\":1}]}"}} get_last_clean_record.json000066400000000000000000000001561513363643200351120ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/protocols/testdata/v1_protocol{"t":1760845052,"dps":{"102":"{\"id\":20003,\"result\":[[1738864366,1738868964,4358,81122500,0,0,1,1,21]]}"}} get_multi_maps_list.json000066400000000000000000000004041513363643200346500ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/protocols/testdata/v1_protocol{"t":1758987228,"dps":{"102":"{\"id\":20001,\"result\":[{\"max_multi_map\":1,\"max_bak_map\":1,\"multi_map_count\":1,\"map_info\":[{\"mapFlag\":0,\"add_time\":1747132930,\"length\":0,\"name\":\"\",\"bak_maps\":[{\"mapFlag\":4,\"add_time\":1747132936}]}]}]}"}} Python-roborock-python-roborock-d6da2db/tests/protocols/testdata/v1_protocol/get_room_mapping.json000066400000000000000000000001601513363643200342100ustar00rootroot00000000000000{"t":1759590351,"dps":{"102":"{\"id\":20001,\"result\":[[16,\"3031886\"],[17,\"3031880\"],[18,\"3031883\"]]}"}} Python-roborock-python-roborock-d6da2db/tests/protocols/testdata/v1_protocol/get_room_mapping2.json000066400000000000000000000003341513363643200342750ustar00rootroot00000000000000{"t":1759590351,"dps":{"102":"{\"id\":20001,\"result\":[[16, \"2537178\", 6], [17, \"2537175\", 14], [18, \"2537174\", 13], [19, \"2537176\", 14], [20, \"10655627\", 12], [21, \"2537145\", 2], [22, \"2537147\", 12]]}"}} Python-roborock-python-roborock-d6da2db/tests/protocols/testdata/v1_protocol/get_status.json000066400000000000000000000010321513363643200330430ustar00rootroot00000000000000{"t": 1755785773, "dps": {"102": "{\"id\":20001,\"result\":[{\"msg_ver\":2,\"msg_seq\":515,\"state\":8,\"battery\":100,\"clean_time\":5405,\"clean_area\":91287500,\"error_code\":0,\"map_present\":1,\"in_cleaning\":0,\"in_returning\":0,\"in_fresh_state\":1,\"lab_status\":1,\"water_box_status\":0,\"fan_power\":106,\"dnd_enabled\":1,\"map_status\":3,\"is_locating\":0,\"lock_status\":0,\"water_box_mode\":204,\"distance_off\":0,\"water_box_carriage_status\":0,\"mop_forbidden_enable\":0,\"unsave_map_reason\":4,\"unsave_map_flag\":0}]}"}} Python-roborock-python-roborock-d6da2db/tests/protocols/testdata/v1_protocol/get_volume.json000066400000000000000000000001001513363643200330220ustar00rootroot00000000000000{"t":1757903261,"dps":{"102":"{\"id\":20001,\"result\":[90]}"}} Python-roborock-python-roborock-d6da2db/tests/test_broadcast_protocol.py000066400000000000000000000017401513363643200271640ustar00rootroot00000000000000from roborock.broadcast_protocol import RoborockProtocol def test_l01_data(): data = bytes.fromhex( "4c30310000000000000043841496d5a31e34b5b02c1867c445509ba5a21aec1fa4b307bddeb27a75d9b366193e8a97d0534dc39851c" "980609f2670cdcaee04594ec5c93e3c5ae609b0c9a203139ac8e40c8c" ) prot = RoborockProtocol() prot.datagram_received(data, None) device = prot.devices_found[0] assert device.duid == "ZrQn1jfZtJQLoPOL7620e" assert device.ip == "192.168.1.4" assert device.version == b"L01" def test_v1_data(): data = bytes.fromhex( "312e30000003e003e80040b87035058b439f36af42f249605f8661897173f111bb849a6231831f5874a0cf220a25872ea412d796b4902ee" "57fdc120074b901b482acb1fe6d06317e3a72ddac654fe0" ) prot = RoborockProtocol() prot.datagram_received(data, None) device = prot.devices_found[0] assert device.duid == "h96rOV3e8DTPMAOLiypREl" assert device.ip == "192.168.20.250" assert device.version == b"1.0" Python-roborock-python-roborock-d6da2db/tests/test_callbacks.py000066400000000000000000000154271513363643200252270ustar00rootroot00000000000000"""Tests for the callbacks module.""" import logging from unittest.mock import Mock from roborock.callbacks import CallbackList, CallbackMap, safe_callback def test_safe_callback_successful_execution(): """Test that safe_callback executes callback successfully.""" mock_callback = Mock() wrapped = safe_callback(mock_callback) wrapped("test_value") mock_callback.assert_called_once_with("test_value") def test_safe_callback_catches_exception(): """Test that safe_callback catches and logs exceptions.""" def failing_callback(value): raise ValueError("Test exception") mock_logger = Mock(spec=logging.Logger) wrapped = safe_callback(failing_callback, mock_logger) # Should not raise exception wrapped("test_value") mock_logger.error.assert_called_once() assert "Uncaught error in callback" in mock_logger.error.call_args[0][0] def test_safe_callback_uses_default_logger(): """Test that safe_callback uses default logger when none provided.""" def failing_callback(value): raise ValueError("Test exception") wrapped = safe_callback(failing_callback) # Should not raise exception wrapped("test_value") # CallbackMap tests def test_callback_map_add_callback_and_invoke(): """Test adding callback and invoking it.""" callback_map = CallbackMap[str, str]() mock_callback = Mock() remove_fn = callback_map.add_callback("key1", mock_callback) callback_map("key1", "test_value") mock_callback.assert_called_once_with("test_value") assert callable(remove_fn) def test_callback_map_multiple_callbacks_same_key(): """Test multiple callbacks for the same key.""" callback_map = CallbackMap[str, str]() mock_callback1 = Mock() mock_callback2 = Mock() callback_map.add_callback("key1", mock_callback1) callback_map.add_callback("key1", mock_callback2) callback_map("key1", "test_value") mock_callback1.assert_called_once_with("test_value") mock_callback2.assert_called_once_with("test_value") def test_callback_map_different_keys(): """Test callbacks for different keys.""" callback_map = CallbackMap[str, str]() mock_callback1 = Mock() mock_callback2 = Mock() callback_map.add_callback("key1", mock_callback1) callback_map.add_callback("key2", mock_callback2) callback_map("key1", "value1") callback_map("key2", "value2") mock_callback1.assert_called_once_with("value1") mock_callback2.assert_called_once_with("value2") def test_callback_map_get_callbacks(): """Test getting callbacks for a key.""" callback_map = CallbackMap[str, str]() mock_callback = Mock() # No callbacks initially assert callback_map.get_callbacks("key1") == [] # Add callback callback_map.add_callback("key1", mock_callback) callbacks = callback_map.get_callbacks("key1") assert len(callbacks) == 1 assert callbacks[0] == mock_callback def test_callback_map_remove_callback(): """Test removing callback.""" callback_map = CallbackMap[str, str]() mock_callback = Mock() remove_fn = callback_map.add_callback("key1", mock_callback) # Callback should be there assert len(callback_map.get_callbacks("key1")) == 1 # Remove callback remove_fn() # Callback should be gone assert callback_map.get_callbacks("key1") == [] def test_callback_map_remove_callback_cleans_up_key(): """Test that removing last callback for a key removes the key.""" callback_map = CallbackMap[str, str]() mock_callback = Mock() remove_fn = callback_map.add_callback("key1", mock_callback) # Key should exist assert "key1" in callback_map._callbacks # Remove callback remove_fn() # Key should be removed assert "key1" not in callback_map._callbacks def test_callback_map_exception_handling(caplog): """Test that exceptions in callbacks are handled gracefully.""" callback_map = CallbackMap[str, str]() def failing_callback(value): raise ValueError("Test exception") callback_map.add_callback("key1", failing_callback) with caplog.at_level(logging.ERROR): callback_map("key1", "test_value") assert "Uncaught error in callback" in caplog.text def test_callback_map_custom_logger(): """Test using custom logger.""" mock_logger = Mock(spec=logging.Logger) callback_map = CallbackMap[str, str](logger=mock_logger) def failing_callback(value): raise ValueError("Test exception") callback_map.add_callback("key1", failing_callback) callback_map("key1", "test_value") mock_logger.error.assert_called_once() # CallbackList tests def test_callback_list_add_callback_and_invoke(): """Test adding callback and invoking it.""" callback_list = CallbackList[str]() mock_callback = Mock() remove_fn = callback_list.add_callback(mock_callback) callback_list("test_value") mock_callback.assert_called_once_with("test_value") assert callable(remove_fn) def test_callback_list_multiple_callbacks(): """Test multiple callbacks in the list.""" callback_list = CallbackList[str]() mock_callback1 = Mock() mock_callback2 = Mock() callback_list.add_callback(mock_callback1) callback_list.add_callback(mock_callback2) callback_list("test_value") mock_callback1.assert_called_once_with("test_value") mock_callback2.assert_called_once_with("test_value") def test_callback_list_remove_callback(): """Test removing callback from list.""" callback_list = CallbackList[str]() mock_callback1 = Mock() mock_callback2 = Mock() remove_fn1 = callback_list.add_callback(mock_callback1) callback_list.add_callback(mock_callback2) # Both should be called callback_list("test_value") assert mock_callback1.call_count == 1 assert mock_callback2.call_count == 1 # Remove first callback remove_fn1() # Only second should be called callback_list("test_value2") assert mock_callback1.call_count == 1 # Still 1 assert mock_callback2.call_count == 2 # Now 2 def test_callback_list_exception_handling(caplog): """Test that exceptions in callbacks are handled gracefully.""" callback_list = CallbackList[str]() def failing_callback(value): raise ValueError("Test exception") callback_list.add_callback(failing_callback) with caplog.at_level(logging.ERROR): callback_list("test_value") assert "Uncaught error in callback" in caplog.text def test_callback_list_custom_logger(): """Test using custom logger.""" mock_logger = Mock(spec=logging.Logger) callback_list = CallbackList[str](logger=mock_logger) def failing_callback(value): raise ValueError("Test exception") callback_list.add_callback(failing_callback) callback_list("test_value") mock_logger.error.assert_called_once() Python-roborock-python-roborock-d6da2db/tests/test_diagnostics.py000066400000000000000000000051661513363643200256160ustar00rootroot00000000000000"""Tests for diagnostics module.""" import pytest from roborock.diagnostics import Diagnostics, redact_device_uid, redact_topic_name def test_empty_diagnostics(): """Test that a new Diagnostics object is empty.""" diag = Diagnostics() assert diag.as_dict() == {} def test_increment_counter(): """Test incrementing counters in Diagnostics.""" diag = Diagnostics() diag.increment("test_event") diag.increment("test_event", 2) assert diag.as_dict() == {"test_event": 3} def test_elapsed_timing(): """Test elapsed timing in Diagnostics.""" diag = Diagnostics() with diag.timer("test_operation"): pass # Simulate operation data = diag.as_dict() assert data["test_operation_count"] == 1 assert data["test_operation_sum"] >= 0 with diag.timer("test_operation"): pass # Simulate operation data = diag.as_dict() assert data["test_operation_count"] == 2 assert data["test_operation_sum"] >= 0 def test_subkey_diagnostics(): """Test subkey diagnostics in Diagnostics.""" diag = Diagnostics() sub_diag = diag.subkey("submodule") sub_diag.increment("sub_event", 5) expected = {"submodule": {"sub_event": 5}} assert diag.as_dict() == expected def test_reset_diagnostics(): """Test resetting diagnostics.""" diag = Diagnostics() diag.increment("event", 10) sub_diag = diag.subkey("submodule") sub_diag.increment("sub_event", 5) assert diag.as_dict() == {"event": 10, "submodule": {"sub_event": 5}} diag.reset() assert diag.as_dict() == {} @pytest.mark.parametrize( "topic,expected", [ ( "rr/m/o/1DuR4nbBzz3OPbv0NNamVP/b8632r9e/3zQRtuIfY14BrRTivxxcMd", "rr/m/o/1DuR4nbBzz3OPbv0NNamVP/*****32r9e/*****xxcMd", ), ("rr/m/o/1DuR4nbBzz3OPbv0NNamVP//3zQRtuIfY14BrRTivxxcMd", "rr/m/o/1DuR4nbBzz3OPbv0NNamVP/*****/*****xxcMd"), ("rr/m/o//b8632r9e/3zQRtuIfY14BrRTivxxcMd", "rr/m/o//*****32r9e/*****xxcMd"), ("roborock/short/updates", "roborock/short/updates"), # Too short to redact ], ) def test_redact_topic_name(topic: str, expected: str) -> None: """Test redacting sensitive information from topic names.""" redacted = redact_topic_name(topic) assert redacted == expected @pytest.mark.parametrize( "duid,expected", [ ("3zQRtuIfY14BrRTivxxcMd", "******xxcMd"), ("3zQ", "******3zQ"), ("", "******"), ], ) def test_redact_device(duid: str, expected: str) -> None: """Test redacting sensitive information from device UIDs.""" redacted = redact_device_uid(duid) assert redacted == expected Python-roborock-python-roborock-d6da2db/tests/test_protocol.py000066400000000000000000000027601513363643200251450ustar00rootroot00000000000000import pytest from roborock.protocol import create_local_decoder, create_local_encoder from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol TEST_LOCAL_KEY = "local_key" @pytest.mark.parametrize( ("garbage"), [ b"", b"\x00\x00\x05\xa1", b"\x00\x00\x05\xa1\xff\xff", ], ) def test_decoder_clean_message(garbage: bytes): encoder = create_local_encoder(TEST_LOCAL_KEY) decoder = create_local_decoder(TEST_LOCAL_KEY) msg = RoborockMessage( protocol=RoborockMessageProtocol.RPC_REQUEST, payload=b"test_payload", version=b"1.0", seq=1, random=123, ) encoded = encoder(msg) decoded = decoder(garbage + encoded) assert len(decoded) == 1 assert decoded[0].payload == b"test_payload" def test_decoder_split_padding_variable(): """Test variable padding split across chunks.""" encoder = create_local_encoder(TEST_LOCAL_KEY, connect_nonce=123, ack_nonce=456) decoder = create_local_decoder(TEST_LOCAL_KEY, connect_nonce=123, ack_nonce=456) msg = RoborockMessage( protocol=RoborockMessageProtocol.RPC_REQUEST, payload=b"test_payload", version=b"L01", ) encoded = encoder(msg) garbage = b"\x00\x00\x05\xa1\xff\xff" # 6 bytes # Send garbage decoded1 = decoder(garbage) assert len(decoded1) == 0 # Send message decoded2 = decoder(encoded) assert len(decoded2) == 1 assert decoded2[0].payload == b"test_payload" Python-roborock-python-roborock-d6da2db/tests/test_roborock_message.py000066400000000000000000000017131513363643200266250ustar00rootroot00000000000000import json from freezegun import freeze_time from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol def test_roborock_message() -> None: """Test the RoborockMessage class is initialized.""" with freeze_time("2025-01-20T12:00:00"): message1 = RoborockMessage( protocol=RoborockMessageProtocol.RPC_REQUEST, payload=json.dumps({"dps": {"101": json.dumps({"id": 4321})}}).encode(), ) with freeze_time("2025-01-20T11:00:00"): # Back in time 1hr to test timestamp message2 = RoborockMessage( protocol=RoborockMessageProtocol.RPC_RESPONSE, payload=json.dumps({"dps": {"94": json.dumps({"id": 444}), "102": json.dumps({"id": 333})}}).encode(), ) # Ensure the sequence, random numbers, etc are initialized properly assert message1.seq != message2.seq assert message1.random != message2.random assert message1.timestamp > message2.timestamp Python-roborock-python-roborock-d6da2db/tests/test_supported_features.py000066400000000000000000000061601513363643200272250ustar00rootroot00000000000000from syrupy import SnapshotAssertion from roborock import SHORT_MODEL_TO_ENUM from roborock.data.code_mappings import RoborockProductNickname from roborock.device_features import DeviceFeatures def test_supported_features_qrevo_maxv(): """Ensure that a QREVO MaxV has some more complicated features enabled.""" model = "roborock.vacuum.a87" product_nickname = SHORT_MODEL_TO_ENUM.get(model.split(".")[-1]) device_features = DeviceFeatures.from_feature_flags( new_feature_info=4499197267967999, new_feature_info_str="508A977F7EFEFFFF", feature_info=[111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125], product_nickname=product_nickname, ) assert device_features print("\n".join(device_features.get_supported_features())) num_true = sum(v for v in vars(device_features).values() if isinstance(v, bool)) print(num_true) assert num_true != 0 assert device_features.is_dust_collection_setting_supported assert device_features.is_led_status_switch_supported assert not device_features.is_matter_supported print(device_features) def test_supported_features_s7(): """Ensure that a S7 has some more basic features enabled.""" model = "roborock.vacuum.a15" product_nickname = SHORT_MODEL_TO_ENUM.get(model.split(".")[-1]) device_features = DeviceFeatures.from_feature_flags( new_feature_info=636084721975295, new_feature_info_str="0000000000002000", feature_info=[111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 122, 123, 124, 125], product_nickname=product_nickname, ) num_true = sum(v for v in vars(device_features).values() if isinstance(v, bool)) assert num_true != 0 assert device_features assert device_features.is_custom_mode_supported assert device_features.is_led_status_switch_supported assert not device_features.is_hot_wash_towel_supported num_true = sum(v for v in vars(device_features).values() if isinstance(v, bool)) assert num_true != 0 def test_device_feature_serialization(snapshot: SnapshotAssertion) -> None: """Test serialization and deserialization of DeviceFeatures.""" device_features = DeviceFeatures.from_feature_flags( new_feature_info=636084721975295, new_feature_info_str="0000000000002000", feature_info=[111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 122, 123, 124, 125], product_nickname=RoborockProductNickname.TANOSS, ) serialized = device_features.as_dict() deserialized = DeviceFeatures.from_dict(serialized) assert deserialized == device_features def test_new_feature_str_missing(): """Ensure that DeviceFeatures missing fields can still be created.""" device_features = DeviceFeatures.from_feature_flags( new_feature_info=0, new_feature_info_str="", feature_info=[], product_nickname=None, ) # Check arbitrary fields that are false by default assert not device_features.is_dust_collection_setting_supported assert not device_features.is_hot_wash_towel_supported assert not device_features.is_show_clean_finish_reason_supported Python-roborock-python-roborock-d6da2db/tests/test_web_api.py000066400000000000000000000302561513363643200247130ustar00rootroot00000000000000import re from typing import Any import aiohttp import pytest from aioresponses.compat import normalize_url from roborock import HomeData, HomeDataScene, UserData from roborock.exceptions import RoborockAccountDoesNotExist from roborock.web_api import IotLoginInfo, RoborockApiClient from tests.mock_data import HOME_DATA_RAW, USER_DATA pytest_plugins = [ "tests.fixtures.web_api_fixtures", ] @pytest.fixture(autouse=True) def auto_mock_rest_fixture(mock_rest: Any) -> None: """Auto use the mock rest fixture for all tests in this module.""" pass async def test_pass_login_flow() -> None: """Test that we can login with a password and we get back the correct userdata object.""" my_session = aiohttp.ClientSession() api = RoborockApiClient(username="test_user@gmail.com", session=my_session) ud = await api.pass_login("password") assert ud == UserData.from_dict(USER_DATA) assert not my_session.closed async def test_code_login_flow() -> None: """Test that we can login with a code and we get back the correct userdata object.""" api = RoborockApiClient(username="test_user@gmail.com") await api.request_code() ud = await api.code_login(4123) assert ud == UserData.from_dict(USER_DATA) async def test_get_home_data_v2(): """Test a full standard flow where we get the home data to end it off. This matches what HA does""" api = RoborockApiClient(username="test_user@gmail.com") await api.request_code() ud = await api.code_login(4123) hd = await api.get_home_data_v2(ud) assert hd == HomeData.from_dict(HOME_DATA_RAW) async def test_nc_prepare(): """Test adding a device and that nothing breaks""" api = RoborockApiClient(username="test_user@gmail.com") await api.request_code() ud = await api.code_login(4123) prepare = await api.nc_prepare(ud, "America/New_York") new_device = await api.add_device(ud, prepare["s"], prepare["t"]) assert new_device["duid"] == "rand_duid" async def test_get_scenes(): """Test that we can get scenes""" api = RoborockApiClient(username="test_user@gmail.com") ud = await api.pass_login("password") sc = await api.get_scenes(ud, "123456") assert sc == [ HomeDataScene.from_dict( { "id": 1234567, "name": "My plan", } ) ] async def test_execute_scene(mock_rest): """Test that we can execute a scene""" api = RoborockApiClient(username="test_user@gmail.com") ud = await api.pass_login("password") await api.execute_scene(ud, 123456) mock_rest.assert_any_call("https://api-us.roborock.com/user/scene/123456/execute", "post") async def test_code_login_v4_flow(mock_rest) -> None: """Test that we can login with a code and we get back the correct userdata object.""" api = RoborockApiClient(username="test_user@gmail.com") await api.request_code_v4() ud = await api.code_login_v4(4123, "US", 1) assert ud == UserData.from_dict(USER_DATA) async def test_code_login_v4_account_does_not_exist(mock_rest) -> None: """Test that response code 3039 raises RoborockAccountDoesNotExist.""" mock_rest.clear() mock_rest.post( re.compile(r"https://.*iot\.roborock\.com/api/v1/getUrlByEmail.*"), status=200, payload={ "code": 200, "data": {"country": "US", "countrycode": "1", "url": "https://usiot.roborock.com"}, "msg": "success", }, ) mock_rest.post( re.compile(r"https://.*iot\.roborock\.com/api/v4/email/code/send.*"), status=200, payload={"code": 200, "data": None, "msg": "success"}, ) mock_rest.post( re.compile(r"https://.*iot\.roborock\.com/api/v3/key/sign.*"), status=200, payload={"code": 200, "data": {"k": "mock_k"}, "msg": "success"}, ) mock_rest.post( re.compile(r"https://.*iot\.roborock\.com/api/v4/auth/email/login/code.*"), status=200, payload={"code": 3039, "data": None, "msg": "account does not exist"}, ) api = RoborockApiClient(username="test_user@gmail.com") await api.request_code_v4() with pytest.raises(RoborockAccountDoesNotExist) as exc_info: await api.code_login_v4(4123, "US", 1) assert "This account does not exist" in str(exc_info.value) async def test_url_cycling(mock_rest) -> None: """Test that we cycle through the URLs correctly.""" # Clear mock rest so that we can override the patches. mock_rest.clear() # 1. Mock US URL to return valid status but None for countrycode mock_rest.post( re.compile("https://usiot.roborock.com/api/v1/getUrlByEmail.*"), status=200, payload={ "code": 200, "data": {"url": "https://usiot.roborock.com", "country": None, "countrycode": None}, "msg": "Success", }, ) # 2. Mock EU URL to return valid status but None for countrycode mock_rest.post( re.compile("https://euiot.roborock.com/api/v1/getUrlByEmail.*"), status=200, payload={ "code": 200, "data": {"url": "https://euiot.roborock.com", "country": None, "countrycode": None}, "msg": "Success", }, ) # 3. Mock CN URL to return the correct, valid data mock_rest.post( re.compile("https://cniot.roborock.com/api/v1/getUrlByEmail.*"), status=200, payload={ "code": 200, "data": {"url": "https://cniot.roborock.com", "country": "CN", "countrycode": "86"}, "msg": "Success", }, ) # The RU URL should not be called, but we can mock it just in case # to catch unexpected behavior. mock_rest.post(re.compile("https://ruiot.roborock.com/api/v1/getUrlByEmail.*"), status=500) client = RoborockApiClient("test@example.com") result = await client._get_iot_login_info() assert result is not None assert isinstance(result, IotLoginInfo) assert result.base_url == "https://cniot.roborock.com" assert result.country == "CN" assert result.country_code == "86" assert client._iot_login_info == result # Check that all three urls were called. We have to do this kind of weirdly as aioresponses seems to have a bug. assert ( len( mock_rest.requests[ ( "post", normalize_url( "https://usiot.roborock.com/api/v1/getUrlByEmail?email=test%2540example.com&needtwostepauth=false" ), ) ] ) == 1 ) assert ( len( mock_rest.requests[ ( "post", normalize_url( "https://euiot.roborock.com/api/v1/getUrlByEmail?email=test%2540example.com&needtwostepauth=false" ), ) ] ) == 1 ) assert ( len( mock_rest.requests[ ( "post", normalize_url( "https://cniot.roborock.com/api/v1/getUrlByEmail?email=test%2540example.com&needtwostepauth=false" ), ) ] ) == 1 ) # Make sure we just have the three we tested for above. assert len(mock_rest.requests) == 3 async def test_thirty_thirty_cycling(mock_rest) -> None: """Test that we cycle through the URLs correctly when users have deleted accounts in higher prio regions.""" # Clear mock rest so that we can override the patches. mock_rest.clear() mock_rest.post( re.compile("https://usiot.roborock.com/api/v1/getUrlByEmail.*"), status=200, payload={ "code": 200, "data": {"url": "https://usiot.roborock.com", "country": "US", "countrycode": 1}, "msg": "Account in deletion", }, ) mock_rest.post( re.compile("https://euiot.roborock.com/api/v1/getUrlByEmail.*"), status=200, payload={ "code": 200, "data": {"url": "https://euiot.roborock.com", "country": "EU", "countrycode": 49}, "msg": "Success", }, ) mock_rest.post( re.compile("https://usiot.roborock.com/api/v4/email/code/send.*"), status=200, payload={ "code": 3030, }, ) mock_rest.post( re.compile("https://euiot.roborock.com/api/v4/email/code/send.*"), status=200, payload={ "code": 200, }, ) mock_rest.post(re.compile("https://ruiot.roborock.com/api/v1/getUrlByEmail.*"), status=500) mock_rest.post(re.compile("https://cniot.roborock.com/api/v1/getUrlByEmail.*"), status=500) client = RoborockApiClient("test@example.com") await client.request_code_v4() assert ( len( mock_rest.requests[ ( "post", normalize_url("https://euiot.roborock.com/api/v4/email/code/send"), ) ] ) == 1 ) assert ( len( mock_rest.requests[ ( "post", normalize_url("https://usiot.roborock.com/api/v4/email/code/send"), ) ] ) == 1 ) # Assert that we didn't try on the Russian or Chinese regions assert "https://ruiot.roborock.com/api/v4/email/code/send" not in mock_rest.requests assert "https://cniot.roborock.com/api/v4/email/code/send" not in mock_rest.requests async def test_missing_country_login(mock_rest) -> None: """Test that we cycle through the URLs correctly.""" mock_rest.clear() # Make country None, but country code set. mock_rest.post( re.compile("https://usiot.roborock.com/api/v1/getUrlByEmail.*"), status=200, payload={ "code": 200, "data": {"url": "https://usiot.roborock.com", "country": None, "countrycode": 1}, "msg": "Success", }, ) # v4 is not mocked, so it would fail it were called. mock_rest.post( re.compile(r"https://.*iot\.roborock\.com/api/v1/loginWithCode.*"), status=200, payload={"code": 200, "data": USER_DATA, "msg": "success"}, ) mock_rest.post( re.compile(r"https://.*iot\.roborock\.com/api/v1/sendEmailCode.*"), status=200, payload={"code": 200, "data": None, "msg": "success"}, ) client = RoborockApiClient("test@example.com") await client.request_code_v4() ud = await client.code_login_v4(4123) assert ud is not None # Ensure we have no surprise REST calls. assert len(mock_rest.requests) == 3 async def test_get_schedules(mock_rest) -> None: """Test that we can get schedules.""" api = RoborockApiClient(username="test_user@gmail.com") ud = await api.pass_login("password") # Mock the response mock_rest.get( "https://api-us.roborock.com/user/devices/123456/jobs", status=200, payload={ "api": None, "result": [ { "id": 3878757, "cron": "03 13 15 12 ?", "repeated": False, "enabled": True, "param": { "id": 1, "method": "server_scheduled_start", "params": [ { "repeat": 1, "water_box_mode": 202, "segments": "0", "fan_power": 102, "mop_mode": 300, "clean_mop": 1, "map_index": -1, "name": "1765735413736", } ], }, } ], "status": "ok", "success": True, }, ) schedules = await api.get_schedules(ud, "123456") assert len(schedules) == 1 schedule = schedules[0] assert schedule.id == 3878757 assert schedule.cron == "03 13 15 12 ?" assert schedule.repeated is False assert schedule.enabled is True Python-roborock-python-roborock-d6da2db/tests/testdata/000077500000000000000000000000001513363643200234775ustar00rootroot00000000000000Python-roborock-python-roborock-d6da2db/tests/testdata/home_data_device_q10.json000066400000000000000000000035571513363643200303250ustar00rootroot00000000000000 { "duid": "device-id-def456", "name": "Roborock Q10 S5+", "localKey": "key123key123key1", "productId": "product-id-q10-ss07", "fv": "03.10.0", "activeTime": 1767044247, "timeZoneId": "America/Los_Angeles", "iconUrl": "", "share": false, "online": true, "pv": "B01", "tuyaMigrated": false, "sn": "1EED544EBAD842", "deviceStatus": { "121": 8, "135": 0, "136": 1, "127": 0, "123": 2, "137": 1, "122": 100, "101": { "104": 0, "105": false, "109": "us", "207": 0, "25": 1, "26": 74, "29": 7, "30": 1, "31": 16, "37": 1, "40": 1, "45": 0, "47": 0, "50": 0, "51": true, "53": true, "6": 0, "60": 1, "67": 0, "7": 0, "76": 0, "78": 0, "79": { "timeZoneCity": "America/Los_Angeles", "timeZoneSec": -28800 }, "80": 0, "81": { "ipAdress": "192.168.1.52", "mac": "2D:C8:F6:BE:CB:1E", "signal": -42, "wifiName": "wifi-network-name" }, "83": 1, "86": 1, "87": 100, "88": 0, "90": 0, "92": { "disturb_dust_enable": 1, "disturb_light": 1, "disturb_resume_clean": 1, "disturb_voice": 1 }, "93": 1, "96": 0 }, "139": 5, "138": 0, "124": 1, "125": 0, "126": 0 }, "silentOtaSwitch": false, "f": false, "createTime": 1767044139, "cid": "4C" } Python-roborock-python-roborock-d6da2db/tests/testdata/home_data_device_q7.json000066400000000000000000000016001513363643200302360ustar00rootroot00000000000000{ "duid": "device-id-q7", "name": "Roborock Q7", "localKey": "key123key123key1", "productId": "q7_product_id", "fv": "03.01.71", "activeTime": 1749513705, "timeZoneId": "Pacific/Auckland", "iconUrl": "", "share": true, "shareTime": 1754789238, "online": true, "pv": "B01", "tuyaMigrated": false, "extra": "{\"1749518432\": \"0\", \"1753581557\": \"0\", \"clean_finish\": \"{}\"}", "sn": "q7_sn", "deviceStatus": { "135": 0, "120": 0, "121": 8, "122": 100, "123": 4, "124": 2, "125": 77, "126": 4294965348, "127": 54, "136": 1, "137": 1, "138": 0, "139": 0, "141": 0, "142": 0 }, "silentOtaSwitch": false, "f": false, "createTime": 1749513706, "cid": "DE", "shareType": "UNLIMITED_TIME" } Python-roborock-python-roborock-d6da2db/tests/testdata/home_data_device_s5e.json000066400000000000000000000014421513363643200304070ustar00rootroot00000000000000{ "duid": "device-uid-s5e", "name": "Roborock Downstairs", "localKey": "key123key123key1", "productId": "73EnOOM2NhDujvnvb7hvvv", "fv": "02.16.62", "activeTime": 1633770040, "timeZoneId": "America/Los_Angeles", "iconUrl": "", "share": false, "online": true, "pv": "1.0", "tuyaUuid": "tuya-uuid-s5e", "tuyaMigrated": true, "extra": "{\"xxxx\": \"xxxx\", \"roTuyaDid\": \"tuya-did-s5e\", \"roTuyaActiveTime\": 1601112710}", "sn": "sn123sn123sn123sn123", "featureSet": "0", "newFeatureSet": "0000000000002000", "deviceStatus": { "121": 8, "122": 100, "123": 102, "124": 202, "125": 17, "126": 0, "127": 0, "120": 0 }, "silentOtaSwitch": false, "f": false } Python-roborock-python-roborock-d6da2db/tests/testdata/home_data_device_s7_maxv.json000066400000000000000000000015571513363643200313060ustar00rootroot00000000000000{ "duid": "abc123", "name": "Roborock S7 MaxV", "attribute": null, "activeTime": 1672364449, "localKey": "key123key123key1", "runtimeEnv": null, "timeZoneId": "America/Los_Angeles", "iconUrl": "no_url", "productId": "product-id-s7-maxv", "lon": null, "lat": null, "share": false, "shareTime": null, "online": true, "fv": "02.56.02", "pv": "1.0", "roomId": 2362003, "tuyaUuid": null, "tuyaMigrated": false, "extra": "{\"RRPhotoPrivacyVersion\": \"1\"}", "sn": "abc123", "featureSet": "2234201184108543", "newFeatureSet": "0000000000002041", "deviceStatus": { "121": 8, "122": 100, "123": 102, "124": 203, "125": 94, "126": 90, "127": 87, "128": 0, "133": 1, "120": 0 }, "silentOtaSwitch": true } Python-roborock-python-roborock-d6da2db/tests/testdata/home_data_device_saros_10r.json000066400000000000000000000014341513363643200315250ustar00rootroot00000000000000{ "duid": "device-id-saros-10r", "name": "JC", "localKey": "key123key123key1", "productId": "product-saros-10r", "fv": "02.50.56", "activeTime": 1672364449, "timeZoneId": "Europe/Berlin", "iconUrl": "", "share": false, "online": true, "pv": "1.0", "tuyaMigrated": false, "sn": "device-saros-10r-sn", "featureSet": "4499197267967999", "newFeatureSet": "0005075028ED3EDDCFFF8F7F7EFEFFFF", "deviceStatus": { "121": 8, "122": 100, "123": 102, "124": 225, "125": 100, "126": 99, "127": 99, "128": 0, "133": 1, "135": 0, "139": 0, "120": 0, "134": 0 }, "silentOtaSwitch": false, "f": false, "createTime": 1767044139 } Python-roborock-python-roborock-d6da2db/tests/testdata/home_data_device_zeo_one.json000066400000000000000000000030141513363643200313460ustar00rootroot00000000000000{ "duid": "zeo_duid", "name": "Zeo One", "fv": "01.00.94", "productId": "product-id-zeo-one", "localKey": "key123key123key1", "activeTime": 1699964128, "timeZoneId": "Europe/Berlin", "iconUrl": "", "share": true, "shareTime": 1712763572, "online": true, "pv": "A01", "tuyaMigrated": false, "sn": "zeo_sn", "featureSet": "0", "newFeatureSet": "40", "deviceStatus": { "208": 2, "205": 33, "221": 0, "226": 0, "10001": "{\"f\":\"t\"}", "214": 2, "225": 0, "232": 0, "222": 347414, "206": 0, "200": 1, "219": 0, "223": 0, "220": 0, "201": 0, "202": 1, "10005": "{\"sn\":\"zeo_sn\",\"ssid\":\"internet\",\"timezone\":\"Europe/Berlin\",\"posix_timezone\":\"CET-1CEST,M3.5.0,M10.5.0/3\",\"ip\":\"192.111.11.11\",\"mac\":\"b0:4a:00:00:00:00\",\"rssi\":-57,\"oba\":{\"language\":\"en\",\"name\":\"A.03.0403_CE\",\"bom\":\"A.03.0403\",\"location\":\"de\",\"wifiplan\":\"EU\",\"timezone\":\"CET-1CEST,M3.5.0,M10.5.0/3;Europe/Berlin\",\"logserver\":\"awsde0\",\"loglevel\":\"4\",\"featureset\":\"0\"}}", "211": 1, "210": 1, "217": 0, "203": 7, "213": 2, "209": 7, "224": 21, "218": 227, "212": 1, "207": 4, "204": 1, "10007": "{\"mqttOtaData\":{\"mqttOtaStatus\":{\"status\":\"IDLE\"}}}", "227": 1 }, "silentOtaSwitch": false, "f": false } Python-roborock-python-roborock-d6da2db/tests/testdata/home_data_product_a102.json000066400000000000000000000156221513363643200306040ustar00rootroot00000000000000{ "id": "product-id-zeo-one", "name": "Zeo One", "model": "roborock.wm.a102", "category": "roborock.wm", "capability": 2, "schema": [ { "id": "134", "name": "烘干状态", "code": "drying_status", "mode": "ro", "type": "RAW" }, { "id": "200", "name": "启动", "code": "start", "mode": "rw", "type": "BOOL" }, { "id": "201", "name": "暂停", "code": "pause", "mode": "rw", "type": "BOOL" }, { "id": "202", "name": "关机", "code": "shutdown", "mode": "rw", "type": "BOOL" }, { "id": "203", "name": "状态", "code": "status", "mode": "ro", "type": "VALUE" }, { "id": "204", "name": "模式", "code": "mode", "mode": "rw", "type": "VALUE" }, { "id": "205", "name": "程序", "code": "program", "mode": "rw", "type": "VALUE" }, { "id": "206", "name": "童锁", "code": "child_lock", "mode": "rw", "type": "BOOL" }, { "id": "207", "name": "洗涤温度", "code": "temp", "mode": "rw", "type": "VALUE" }, { "id": "208", "name": "漂洗次数", "code": "rinse_times", "mode": "rw", "type": "VALUE" }, { "id": "209", "name": "滚筒转速", "code": "spin_level", "mode": "rw", "type": "VALUE" }, { "id": "210", "name": "干燥度", "code": "drying_mode", "mode": "rw", "type": "VALUE" }, { "id": "211", "name": "自动投放-洗衣液", "code": "detergent_set", "mode": "rw", "type": "BOOL" }, { "id": "212", "name": "自动投放-柔顺剂", "code": "softener_set", "mode": "rw", "type": "BOOL" }, { "id": "213", "name": "洗衣液投放量", "code": "detergent_type", "mode": "rw", "type": "VALUE" }, { "id": "214", "name": "柔顺剂投放量", "code": "softener_type", "mode": "rw", "type": "VALUE" }, { "id": "217", "name": "预约时间", "code": "countdown", "mode": "rw", "type": "VALUE" }, { "id": "218", "name": "洗衣剩余时间", "code": "washing_left", "mode": "ro", "type": "VALUE" }, { "id": "219", "name": "门锁状态", "code": "doorlock_state", "mode": "ro", "type": "BOOL" }, { "id": "220", "name": "故障", "code": "error", "mode": "ro", "type": "VALUE" }, { "id": "221", "name": "云程序设置", "code": "custom_param_save", "mode": "rw", "type": "VALUE" }, { "id": "222", "name": "云程序读取", "code": "custom_param_get", "mode": "ro", "type": "VALUE" }, { "id": "223", "name": "提示音", "code": "sound_set", "mode": "rw", "type": "BOOL" }, { "id": "224", "name": "距离上次筒自洁次数", "code": "times_after_clean", "mode": "ro", "type": "VALUE" }, { "id": "225", "name": "记忆洗衣偏好开关", "code": "default_setting", "mode": "rw", "type": "BOOL" }, { "id": "226", "name": "洗衣液用尽", "code": "detergent_empty", "mode": "ro", "type": "BOOL" }, { "id": "227", "name": "柔顺剂用尽", "code": "softener_empty", "mode": "ro", "type": "BOOL" }, { "id": "229", "name": "筒灯设定", "code": "light_setting", "mode": "rw", "type": "BOOL" }, { "id": "230", "name": "洗衣液投放量(单次)", "code": "detergent_volume", "mode": "rw", "type": "VALUE" }, { "id": "231", "name": "柔顺剂投放量(单次)", "code": "softener_volume", "mode": "rw", "type": "VALUE" }, { "id": "232", "name": "远程控制授权", "code": "app_authorization", "mode": "rw", "type": "VALUE" }, { "id": "10000", "name": "ID点查询", "code": "id_query", "mode": "rw", "type": "STRING" }, { "id": "10001", "name": "防串货", "code": "f_c", "mode": "ro", "type": "STRING" }, { "id": "10004", "name": "语音包/OBA信息", "code": "snd_state", "mode": "rw", "type": "STRING" }, { "id": "10005", "name": "产品信息", "code": "product_info", "mode": "ro", "type": "STRING" }, { "id": "10006", "name": "隐私协议", "code": "privacy_info", "mode": "rw", "type": "STRING" }, { "id": "10007", "name": "OTA info", "code": "ota_nfo", "mode": "rw", "type": "STRING" }, { "id": "10008", "name": "洗衣记录", "code": "washing_log", "mode": "ro", "type": "BOOL" }, { "id": "10101", "name": "rpc req", "code": "rpc_req", "mode": "wo", "type": "STRING" }, { "id": "10102", "name": "rpc resp", "code": "rpc_resp", "mode": "ro", "type": "STRING" } ] } Python-roborock-python-roborock-d6da2db/tests/testdata/home_data_product_a114.json000066400000000000000000000076601513363643200306120ustar00rootroot00000000000000{ "id": "product-saros-10r", "name": "Saros 10R", "model": "roborock.vacuum.a144", "category": "robot.vacuum.cleaner", "capability": 0, "schema": [ { "id": 101, "name": "rpc_request", "code": "rpc_request", "mode": "rw", "type": "RAW" }, { "id": 102, "name": "rpc_response", "code": "rpc_response", "mode": "rw", "type": "RAW" }, { "id": 120, "name": "\u9519\u8bef\u4ee3\u7801", "code": "error_code", "mode": "ro", "type": "ENUM", "property": "{\"range\": [\"\"]}" }, { "id": 121, "name": "\u8bbe\u5907\u72b6\u6001", "code": "state", "mode": "ro", "type": "ENUM", "property": "{\"range\": [\"\"]}" }, { "id": 122, "name": "\u8bbe\u5907\u7535\u91cf", "code": "battery", "mode": "ro", "type": "ENUM", "property": "{\"range\": [\"\"]}" }, { "id": 123, "name": "\u6e05\u626b\u6a21\u5f0f", "code": "fan_power", "mode": "rw", "type": "ENUM", "property": "{\"range\": [\"\"]}" }, { "id": 124, "name": "\u62d6\u5730\u6a21\u5f0f", "code": "water_box_mode", "mode": "rw", "type": "ENUM", "property": "{\"range\": [\"\"]}" }, { "id": 125, "name": "\u4e3b\u5237\u5bff\u547d", "code": "main_brush_life", "mode": "rw", "type": "VALUE", "property": "{\"max\": 100, \"min\": 0, \"step\": 1, \"unit\": \"null\", \"scale\": 1}" }, { "id": 126, "name": "\u8fb9\u5237\u5bff\u547d", "code": "side_brush_life", "mode": "rw", "type": "VALUE", "property": "{\"max\": 100, \"min\": 0, \"step\": 1, \"unit\": \"null\", \"scale\": 1}" }, { "id": 127, "name": "\u6ee4\u7f51\u5bff\u547d", "code": "filter_life", "mode": "rw", "type": "VALUE", "property": "{\"max\": 100, \"min\": 0, \"step\": 1, \"unit\": \"null\", \"scale\": 1}" }, { "id": 128, "name": "\u989d\u5916\u72b6\u6001", "code": "additional_props", "mode": "ro", "type": "RAW" }, { "id": 130, "name": "\u5b8c\u6210\u4e8b\u4ef6", "code": "task_complete", "mode": "ro", "type": "RAW" }, { "id": 131, "name": "\u7535\u91cf\u4e0d\u8db3\u4efb\u52a1\u53d6\u6d88", "code": "task_cancel_low_power", "mode": "ro", "type": "RAW" }, { "id": 132, "name": "\u8fd0\u52a8\u4e2d\u4efb\u52a1\u53d6\u6d88", "code": "task_cancel_in_motion", "mode": "ro", "type": "RAW" }, { "id": 133, "name": "\u5145\u7535\u72b6\u6001", "code": "charge_status", "mode": "ro", "type": "RAW" }, { "id": 134, "name": "\u70d8\u5e72\u72b6\u6001", "code": "drying_status", "mode": "ro", "type": "RAW" }, { "id": 135, "name": "\u79bb\u7ebf\u539f\u56e0\u7ec6\u5206", "code": "offline_status", "mode": "ro", "type": "RAW" }, { "id": 139, "name": "\u56de\u57fa\u7ad9\u76ee\u7684", "code": "back_type", "mode": "ro", "type": "RAW" } ] } Python-roborock-python-roborock-d6da2db/tests/testdata/home_data_product_a27.json000066400000000000000000000101251513363643200305230ustar00rootroot00000000000000{ "id": "product-id-s7-maxv", "name": "Roborock S7 MaxV", "code": "a27", "model": "roborock.vacuum.a27", "iconUrl": null, "attribute": null, "capability": 0, "category": "robot.vacuum.cleaner", "schema": [ { "id": "101", "name": "rpc_request", "code": "rpc_request_code", "mode": "rw", "type": "RAW", "property": null, "desc": null }, { "id": "102", "name": "rpc_response", "code": "rpc_response", "mode": "rw", "type": "RAW", "property": null, "desc": null }, { "id": "120", "name": "错误代码", "code": "error_code", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}", "desc": null }, { "id": "121", "name": "设备状态", "code": "state", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}", "desc": null }, { "id": "122", "name": "设备电量", "code": "battery", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}", "desc": null }, { "id": "123", "name": "清扫模式", "code": "fan_power", "mode": "rw", "type": "ENUM", "property": "{\"range\": []}", "desc": null }, { "id": "124", "name": "拖地模式", "code": "water_box_mode", "mode": "rw", "type": "ENUM", "property": "{\"range\": []}", "desc": null }, { "id": "125", "name": "主刷寿命", "code": "main_brush_life", "mode": "rw", "type": "VALUE", "property": "{\"max\": 100, \"min\": 0, \"step\": 1, \"unit\": null, \"scale\": 1}", "desc": null }, { "id": "126", "name": "边刷寿命", "code": "side_brush_life", "mode": "rw", "type": "VALUE", "property": "{\"max\": 100, \"min\": 0, \"step\": 1, \"unit\": null, \"scale\": 1}", "desc": null }, { "id": "127", "name": "滤网寿命", "code": "filter_life", "mode": "rw", "type": "VALUE", "property": "{\"max\": 100, \"min\": 0, \"step\": 1, \"unit\": null, \"scale\": 1}", "desc": null }, { "id": "128", "name": "额外状态", "code": "additional_props", "mode": "ro", "type": "RAW", "property": null, "desc": null }, { "id": "130", "name": "完成事件", "code": "task_complete", "mode": "ro", "type": "RAW", "property": null, "desc": null }, { "id": "131", "name": "电量不足任务取消", "code": "task_cancel_low_power", "mode": "ro", "type": "RAW", "property": null, "desc": null }, { "id": "132", "name": "运动中任务取消", "code": "task_cancel_in_motion", "mode": "ro", "type": "RAW", "property": null, "desc": null }, { "id": "133", "name": "充电状态", "code": "charge_status", "mode": "ro", "type": "RAW", "property": null, "desc": null }, { "id": "134", "name": "烘干状态", "code": "drying_status", "mode": "ro", "type": "RAW", "property": null, "desc": null } ] } Python-roborock-python-roborock-d6da2db/tests/testdata/home_data_product_s5e.json000066400000000000000000000073321513363643200306340ustar00rootroot00000000000000{ "id": "73EnOOM2NhDujvnvb7hvvv", "name": "S5 Max", "model": "roborock.vacuum.s5e", "category": "robot.vacuum.cleaner", "capability": 0, "schema": [ { "id": 101, "name": "rpc_request", "code": "rpc_request", "mode": "rw", "type": "RAW" }, { "id": 102, "name": "rpc_response", "code": "rpc_response", "mode": "rw", "type": "RAW" }, { "id": 120, "name": "\u9519\u8bef\u4ee3\u7801", "code": "error_code", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 121, "name": "\u8bbe\u5907\u72b6\u6001", "code": "state", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 122, "name": "\u8bbe\u5907\u7535\u91cf", "code": "battery", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 123, "name": "\u6e05\u626b\u6a21\u5f0f", "code": "fan_power", "mode": "rw", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 124, "name": "\u62d6\u5730\u6a21\u5f0f", "code": "water_box_mode", "mode": "rw", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 125, "name": "\u4e3b\u5237\u5bff\u547d", "code": "main_brush_life", "mode": "rw", "type": "VALUE", "property": "{\"max\": 100, \"min\": 0, \"step\": 1, \"unit\": null, \"scale\": 1}" }, { "id": 126, "name": "\u8fb9\u5237\u5bff\u547d", "code": "side_brush_life", "mode": "rw", "type": "VALUE", "property": "{\"max\": 100, \"min\": 0, \"step\": 1, \"unit\": null, \"scale\": 1}" }, { "id": 127, "name": "\u6ee4\u7f51\u5bff\u547d", "code": "filter_life", "mode": "rw", "type": "VALUE", "property": "{\"max\": 100, \"min\": 0, \"step\": 1, \"unit\": null, \"scale\": 1}" }, { "id": 128, "name": "\u989d\u5916\u72b6\u6001", "code": "additional_props", "mode": "ro", "type": "RAW" }, { "id": 130, "name": "\u5b8c\u6210\u4e8b\u4ef6", "code": "task_complete", "mode": "ro", "type": "RAW" }, { "id": 131, "name": "\u7535\u91cf\u4e0d\u8db3\u4efb\u52a1\u53d6\u6d88", "code": "task_cancel_low_power", "mode": "ro", "type": "RAW" }, { "id": 132, "name": "\u8fd0\u52a8\u4e2d\u4efb\u52a1\u53d6\u6d88", "code": "task_cancel_in_motion", "mode": "ro", "type": "RAW" }, { "id": 133, "name": "\u5145\u7535\u72b6\u6001", "code": "charge_status", "mode": "ro", "type": "RAW" }, { "id": 134, "name": "\u70d8\u5e72\u72b6\u6001", "code": "drying_status", "mode": "ro", "type": "RAW" }, { "id": 135, "name": "\u79bb\u7ebf\u539f\u56e0\u7ec6\u5206", "code": "offline_status", "mode": "ro", "type": "RAW" } ] } Python-roborock-python-roborock-d6da2db/tests/testdata/home_data_product_sc01.json000066400000000000000000000135511513363643200307060ustar00rootroot00000000000000{ "id": "q7_product_id", "name": "Roborock Q7 Series", "model": "roborock.vacuum.sc01", "category": "robot.vacuum.cleaner", "capability": 0, "schema": [ { "id": 101, "name": "RPC Request", "code": "rpc_request", "mode": "rw", "type": "RAW", "property": "null" }, { "id": 102, "name": "RPC Response", "code": "rpc_response", "mode": "rw", "type": "RAW", "property": "null" }, { "id": 120, "name": "错误代码", "code": "error_code", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 121, "name": "设备状态", "code": "state", "mode": "ro", "type": "VALUE", "property": "null" }, { "id": 122, "name": "设备电量", "code": "battery", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 123, "name": "吸力档位", "code": "fan_power", "mode": "rw", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 124, "name": "拖地档位", "code": "water_box_mode", "mode": "rw", "type": "RAW", "property": "null" }, { "id": 125, "name": "主刷寿命", "code": "main_brush_life", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 126, "name": "边刷寿命", "code": "side_brush_life", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 127, "name": "滤网寿命", "code": "filter_life", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 135, "name": "离线原因", "code": "offline_status", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 136, "name": "清洁次数", "code": "clean_times", "mode": "rw", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 137, "name": "扫拖模式", "code": "cleaning_preference", "mode": "rw", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 138, "name": "清洁任务类型", "code": "clean_task_type", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 139, "name": "返回基站类型", "code": "back_type", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 141, "name": "清洁进度", "code": "cleaning_progress", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 142, "name": "窜货信息", "code": "fc_state", "mode": "ro", "type": "RAW", "property": "null" }, { "id": 201, "name": "启动清洁任务", "code": "start_clean_task", "mode": "wo", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 202, "name": "返回基站任务", "code": "start_back_dock_task", "mode": "wo", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 203, "name": "启动基站任务", "code": "start_dock_task", "mode": "wo", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 204, "name": "暂停任务", "code": "pause", "mode": "wo", "type": "RAW", "property": "null" }, { "id": 205, "name": "继续任务", "code": "resume", "mode": "wo", "type": "RAW", "property": "null" }, { "id": 206, "name": "结束任务", "code": "stop", "mode": "wo", "type": "RAW", "property": "null" }, { "id": 10000, "name": "request_cmd", "code": "request_cmd", "mode": "wo", "type": "RAW", "property": "null" }, { "id": 10001, "name": "response_cmd", "code": "response_cmd", "mode": "ro", "type": "RAW", "property": "null" }, { "id": 10002, "name": "request_map", "code": "request_map", "mode": "ro", "type": "RAW", "property": "null" }, { "id": 10003, "name": "response_map", "code": "response_map", "mode": "ro", "type": "RAW", "property": "null" }, { "id": 10004, "name": "event_report", "code": "event_report", "mode": "rw", "type": "RAW", "property": "null" } ] } Python-roborock-python-roborock-d6da2db/tests/testdata/home_data_product_ss07.json000066400000000000000000000131661513363643200307360ustar00rootroot00000000000000 { "id": "product-id-q10-ss07", "name": "Roborock Q10 Series", "model": "roborock.vacuum.ss07", "category": "robot.vacuum.cleaner", "capability": 0, "schema": [ { "id": 101, "name": "RPC Request", "code": "rpc_request", "mode": "rw", "type": "RAW", "property": "null" }, { "id": 102, "name": "RPC Response", "code": "rpc_response", "mode": "rw", "type": "RAW", "property": "null" }, { "id": 120, "name": "\u9519\u8bef\u4ee3\u7801", "code": "error_code", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 121, "name": "\u8bbe\u5907\u72b6\u6001", "code": "state", "mode": "ro", "type": "VALUE", "property": "null" }, { "id": 122, "name": "\u8bbe\u5907\u7535\u91cf", "code": "battery", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 123, "name": "\u5438\u529b\u6863\u4f4d", "code": "fan_power", "mode": "rw", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 124, "name": "\u62d6\u5730\u6863\u4f4d", "code": "water_box_mode", "mode": "rw", "type": "RAW", "property": "null" }, { "id": 125, "name": "\u4e3b\u5237\u5bff\u547d", "code": "main_brush_life", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 126, "name": "\u8fb9\u5237\u5bff\u547d", "code": "side_brush_life", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 127, "name": "\u6ee4\u7f51\u5bff\u547d", "code": "filter_life", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 135, "name": "\u79bb\u7ebf\u539f\u56e0", "code": "offline_status", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 136, "name": "\u6e05\u6d01\u6b21\u6570", "code": "clean_times", "mode": "rw", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 137, "name": "\u626b\u62d6\u6a21\u5f0f", "code": "cleaning_preference", "mode": "rw", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 138, "name": "\u6e05\u6d01\u4efb\u52a1\u7c7b\u578b", "code": "clean_task_type", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 139, "name": "\u8fd4\u56de\u57fa\u7ad9\u7c7b\u578b", "code": "back_type", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 140, "name": "\u57fa\u7ad9\u4efb\u52a1\u7c7b\u578b", "code": "dock_task_type", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 141, "name": "\u6e05\u6d01\u8fdb\u5ea6", "code": "cleaning_progress", "mode": "ro", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 142, "name": "\u7a9c\u8d27\u4fe1\u606f", "code": "fc_state", "mode": "ro", "type": "RAW", "property": "null" }, { "id": 201, "name": "\u542f\u52a8\u6e05\u6d01\u4efb\u52a1", "code": "start_clean_task", "mode": "wo", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 202, "name": "\u8fd4\u56de\u57fa\u7ad9\u4efb\u52a1", "code": "start_back_dock_task", "mode": "wo", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 203, "name": "\u542f\u52a8\u57fa\u7ad9\u4efb\u52a1", "code": "start_dock_task", "mode": "wo", "type": "ENUM", "property": "{\"range\": []}" }, { "id": 204, "name": "\u6682\u505c\u4efb\u52a1", "code": "pause", "mode": "wo", "type": "RAW", "property": "null" }, { "id": 205, "name": "\u7ee7\u7eed\u4efb\u52a1", "code": "resume", "mode": "wo", "type": "RAW", "property": "null" }, { "id": 206, "name": "\u7ed3\u675f\u4efb\u52a1", "code": "stop", "mode": "wo", "type": "RAW", "property": "null" }, { "id": 207, "name": "\u7528\u6237\u6539\u5584\u8ba1\u5212", "code": "ceip", "mode": "rw", "type": "ENUM", "property": "{\"range\": [\"0,1\"]}" } ] } Python-roborock-python-roborock-d6da2db/uv.lock000066400000000000000000013161521513363643200220410ustar00rootroot00000000000000version = 1 revision = 3 requires-python = ">=3.11, <4" [[package]] name = "aiohappyeyeballs" version = "2.6.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, ] [[package]] name = "aiohttp" version = "3.13.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, { name = "aiosignal" }, { name = "attrs" }, { name = "frozenlist" }, { name = "multidict" }, { name = "propcache" }, { name = "yarl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" }, { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" }, { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, ] [[package]] name = "aiomqtt" version = "2.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "paho-mqtt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/db/b5/798e4855d17f0f3a2e2ed21c07473fcb4bb45993116693d0f68553927e2c/aiomqtt-2.5.0.tar.gz", hash = "sha256:70e181c140a54ae736394efe2b9e865f665551a5417f6957456cc46010487b21", size = 86453, upload-time = "2026-01-04T16:46:51.079Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/84/24/1d5d7d89906db1a94b5029942c2fd909cf8e8551b288f56c03053d5615f8/aiomqtt-2.5.0-py3-none-any.whl", hash = "sha256:65dabeafeeee7b88864361ae9a118d81bd27082093f32f670a5c5fab17de8cf2", size = 15983, upload-time = "2026-01-04T16:46:49.736Z" }, ] [[package]] name = "aioresponses" version = "0.7.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "packaging" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/03/532bbc645bdebcf3b6af3b25d46655259d66ce69abba7720b71ebfabbade/aioresponses-0.7.8.tar.gz", hash = "sha256:b861cdfe5dc58f3b8afac7b0a6973d5d7b2cb608dd0f6253d16b8ee8eaf6df11", size = 40253, upload-time = "2025-01-19T18:14:03.222Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/12/b7/584157e43c98aa89810bc2f7099e7e01c728ecf905a66cf705106009228f/aioresponses-0.7.8-py2.py3-none-any.whl", hash = "sha256:b73bd4400d978855e55004b23a3a84cb0f018183bcf066a85ad392800b5b9a94", size = 12518, upload-time = "2025-01-19T18:13:59.633Z" }, ] [[package]] name = "aiosignal" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "frozenlist" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] [[package]] name = "appdirs" version = "1.4.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" }, ] [[package]] name = "attrs" version = "25.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] [[package]] name = "cfgv" version = "3.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, ] [[package]] name = "click" version = "8.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] [[package]] name = "click-shell" version = "2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/26/93dd93fb1714f64376989b9e809982fd64d5f26e666b6d55458066c40b53/click-shell-2.1.tar.gz", hash = "sha256:ce0c91faae284c41a39bec966f928791ad4a45763755445f1fe2041fd091aa37", size = 8421, upload-time = "2021-06-27T00:07:00.845Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/21/ce/d81dcb726c436bf3f77d0145e03bf364c189cc95e6551e797bc0511dcea0/click_shell-2.1-py2.py3-none-any.whl", hash = "sha256:2d971a2e50eb7ad387cf0ce79ba4b844e66e0580784e2efe2df58b50a2f047f0", size = 8582, upload-time = "2021-06-27T00:06:59.938Z" }, ] [[package]] name = "codespell" version = "2.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/15/e0/709453393c0ea77d007d907dd436b3ee262e28b30995ea1aa36c6ffbccaf/codespell-2.4.1.tar.gz", hash = "sha256:299fcdcb09d23e81e35a671bbe746d5ad7e8385972e65dbb833a2eaac33c01e5", size = 344740, upload-time = "2025-01-28T18:52:39.411Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/20/01/b394922252051e97aab231d416c86da3d8a6d781eeadcdca1082867de64e/codespell-2.4.1-py3-none-any.whl", hash = "sha256:3dadafa67df7e4a3dbf51e0d7315061b80d265f9552ebd699b3dd6834b47e425", size = 344501, upload-time = "2025-01-28T18:52:37.057Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "construct" version = "2.10.70" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/02/77/8c84b98eca70d245a2a956452f21d57930d22ab88cbeed9290ca630cf03f/construct-2.10.70.tar.gz", hash = "sha256:4d2472f9684731e58cc9c56c463be63baa1447d674e0d66aeb5627b22f512c29", size = 86337, upload-time = "2023-11-29T08:44:49.545Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b2/fb/08b3f4bf05da99aba8ffea52a558758def16e8516bc75ca94ff73587e7d3/construct-2.10.70-py3-none-any.whl", hash = "sha256:c80be81ef595a1a821ec69dc16099550ed22197615f4320b57cc9ce2a672cb30", size = 63020, upload-time = "2023-11-29T08:44:46.876Z" }, ] [[package]] name = "coverage" version = "7.12.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/89/26/4a96807b193b011588099c3b5c89fbb05294e5b90e71018e065465f34eb6/coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c", size = 819341, upload-time = "2025-11-18T13:34:20.766Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5a/0c/0dfe7f0487477d96432e4815537263363fb6dd7289743a796e8e51eabdf2/coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f", size = 217535, upload-time = "2025-11-18T13:32:08.812Z" }, { url = "https://files.pythonhosted.org/packages/9b/f5/f9a4a053a5bbff023d3bec259faac8f11a1e5a6479c2ccf586f910d8dac7/coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3", size = 218044, upload-time = "2025-11-18T13:32:10.329Z" }, { url = "https://files.pythonhosted.org/packages/95/c5/84fc3697c1fa10cd8571919bf9693f693b7373278daaf3b73e328d502bc8/coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e", size = 248440, upload-time = "2025-11-18T13:32:12.536Z" }, { url = "https://files.pythonhosted.org/packages/f4/36/2d93fbf6a04670f3874aed397d5a5371948a076e3249244a9e84fb0e02d6/coverage-7.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7", size = 250361, upload-time = "2025-11-18T13:32:13.852Z" }, { url = "https://files.pythonhosted.org/packages/5d/49/66dc65cc456a6bfc41ea3d0758c4afeaa4068a2b2931bf83be6894cf1058/coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245", size = 252472, upload-time = "2025-11-18T13:32:15.068Z" }, { url = "https://files.pythonhosted.org/packages/35/1f/ebb8a18dffd406db9fcd4b3ae42254aedcaf612470e8712f12041325930f/coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b", size = 248592, upload-time = "2025-11-18T13:32:16.328Z" }, { url = "https://files.pythonhosted.org/packages/da/a8/67f213c06e5ea3b3d4980df7dc344d7fea88240b5fe878a5dcbdfe0e2315/coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64", size = 250167, upload-time = "2025-11-18T13:32:17.687Z" }, { url = "https://files.pythonhosted.org/packages/f0/00/e52aef68154164ea40cc8389c120c314c747fe63a04b013a5782e989b77f/coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742", size = 248238, upload-time = "2025-11-18T13:32:19.2Z" }, { url = "https://files.pythonhosted.org/packages/1f/a4/4d88750bcf9d6d66f77865e5a05a20e14db44074c25fd22519777cb69025/coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c", size = 247964, upload-time = "2025-11-18T13:32:21.027Z" }, { url = "https://files.pythonhosted.org/packages/a7/6b/b74693158899d5b47b0bf6238d2c6722e20ba749f86b74454fac0696bb00/coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984", size = 248862, upload-time = "2025-11-18T13:32:22.304Z" }, { url = "https://files.pythonhosted.org/packages/18/de/6af6730227ce0e8ade307b1cc4a08e7f51b419a78d02083a86c04ccceb29/coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6", size = 220033, upload-time = "2025-11-18T13:32:23.714Z" }, { url = "https://files.pythonhosted.org/packages/e2/a1/e7f63021a7c4fe20994359fcdeae43cbef4a4d0ca36a5a1639feeea5d9e1/coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4", size = 220966, upload-time = "2025-11-18T13:32:25.599Z" }, { url = "https://files.pythonhosted.org/packages/77/e8/deae26453f37c20c3aa0c4433a1e32cdc169bf415cce223a693117aa3ddd/coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc", size = 219637, upload-time = "2025-11-18T13:32:27.265Z" }, { url = "https://files.pythonhosted.org/packages/02/bf/638c0427c0f0d47638242e2438127f3c8ee3cfc06c7fdeb16778ed47f836/coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647", size = 217704, upload-time = "2025-11-18T13:32:28.906Z" }, { url = "https://files.pythonhosted.org/packages/08/e1/706fae6692a66c2d6b871a608bbde0da6281903fa0e9f53a39ed441da36a/coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736", size = 218064, upload-time = "2025-11-18T13:32:30.161Z" }, { url = "https://files.pythonhosted.org/packages/a9/8b/eb0231d0540f8af3ffda39720ff43cb91926489d01524e68f60e961366e4/coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60", size = 249560, upload-time = "2025-11-18T13:32:31.835Z" }, { url = "https://files.pythonhosted.org/packages/e9/a1/67fb52af642e974d159b5b379e4d4c59d0ebe1288677fbd04bbffe665a82/coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8", size = 252318, upload-time = "2025-11-18T13:32:33.178Z" }, { url = "https://files.pythonhosted.org/packages/41/e5/38228f31b2c7665ebf9bdfdddd7a184d56450755c7e43ac721c11a4b8dab/coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f", size = 253403, upload-time = "2025-11-18T13:32:34.45Z" }, { url = "https://files.pythonhosted.org/packages/ec/4b/df78e4c8188f9960684267c5a4897836f3f0f20a20c51606ee778a1d9749/coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70", size = 249984, upload-time = "2025-11-18T13:32:35.747Z" }, { url = "https://files.pythonhosted.org/packages/ba/51/bb163933d195a345c6f63eab9e55743413d064c291b6220df754075c2769/coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0", size = 251339, upload-time = "2025-11-18T13:32:37.352Z" }, { url = "https://files.pythonhosted.org/packages/15/40/c9b29cdb8412c837cdcbc2cfa054547dd83affe6cbbd4ce4fdb92b6ba7d1/coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068", size = 249489, upload-time = "2025-11-18T13:32:39.212Z" }, { url = "https://files.pythonhosted.org/packages/c8/da/b3131e20ba07a0de4437a50ef3b47840dfabf9293675b0cd5c2c7f66dd61/coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b", size = 249070, upload-time = "2025-11-18T13:32:40.598Z" }, { url = "https://files.pythonhosted.org/packages/70/81/b653329b5f6302c08d683ceff6785bc60a34be9ae92a5c7b63ee7ee7acec/coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937", size = 250929, upload-time = "2025-11-18T13:32:42.915Z" }, { url = "https://files.pythonhosted.org/packages/a3/00/250ac3bca9f252a5fb1338b5ad01331ebb7b40223f72bef5b1b2cb03aa64/coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa", size = 220241, upload-time = "2025-11-18T13:32:44.665Z" }, { url = "https://files.pythonhosted.org/packages/64/1c/77e79e76d37ce83302f6c21980b45e09f8aa4551965213a10e62d71ce0ab/coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a", size = 221051, upload-time = "2025-11-18T13:32:46.008Z" }, { url = "https://files.pythonhosted.org/packages/31/f5/641b8a25baae564f9e52cac0e2667b123de961985709a004e287ee7663cc/coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c", size = 219692, upload-time = "2025-11-18T13:32:47.372Z" }, { url = "https://files.pythonhosted.org/packages/b8/14/771700b4048774e48d2c54ed0c674273702713c9ee7acdfede40c2666747/coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941", size = 217725, upload-time = "2025-11-18T13:32:49.22Z" }, { url = "https://files.pythonhosted.org/packages/17/a7/3aa4144d3bcb719bf67b22d2d51c2d577bf801498c13cb08f64173e80497/coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a", size = 218098, upload-time = "2025-11-18T13:32:50.78Z" }, { url = "https://files.pythonhosted.org/packages/fc/9c/b846bbc774ff81091a12a10203e70562c91ae71badda00c5ae5b613527b1/coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d", size = 249093, upload-time = "2025-11-18T13:32:52.554Z" }, { url = "https://files.pythonhosted.org/packages/76/b6/67d7c0e1f400b32c883e9342de4a8c2ae7c1a0b57c5de87622b7262e2309/coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211", size = 251686, upload-time = "2025-11-18T13:32:54.862Z" }, { url = "https://files.pythonhosted.org/packages/cc/75/b095bd4b39d49c3be4bffbb3135fea18a99a431c52dd7513637c0762fecb/coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d", size = 252930, upload-time = "2025-11-18T13:32:56.417Z" }, { url = "https://files.pythonhosted.org/packages/6e/f3/466f63015c7c80550bead3093aacabf5380c1220a2a93c35d374cae8f762/coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c", size = 249296, upload-time = "2025-11-18T13:32:58.074Z" }, { url = "https://files.pythonhosted.org/packages/27/86/eba2209bf2b7e28c68698fc13437519a295b2d228ba9e0ec91673e09fa92/coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9", size = 251068, upload-time = "2025-11-18T13:32:59.646Z" }, { url = "https://files.pythonhosted.org/packages/ec/55/ca8ae7dbba962a3351f18940b359b94c6bafdd7757945fdc79ec9e452dc7/coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0", size = 249034, upload-time = "2025-11-18T13:33:01.481Z" }, { url = "https://files.pythonhosted.org/packages/7a/d7/39136149325cad92d420b023b5fd900dabdd1c3a0d1d5f148ef4a8cedef5/coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508", size = 248853, upload-time = "2025-11-18T13:33:02.935Z" }, { url = "https://files.pythonhosted.org/packages/fe/b6/76e1add8b87ef60e00643b0b7f8f7bb73d4bf5249a3be19ebefc5793dd25/coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc", size = 250619, upload-time = "2025-11-18T13:33:04.336Z" }, { url = "https://files.pythonhosted.org/packages/95/87/924c6dc64f9203f7a3c1832a6a0eee5a8335dbe5f1bdadcc278d6f1b4d74/coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8", size = 220261, upload-time = "2025-11-18T13:33:06.493Z" }, { url = "https://files.pythonhosted.org/packages/91/77/dd4aff9af16ff776bf355a24d87eeb48fc6acde54c907cc1ea89b14a8804/coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07", size = 221072, upload-time = "2025-11-18T13:33:07.926Z" }, { url = "https://files.pythonhosted.org/packages/70/49/5c9dc46205fef31b1b226a6e16513193715290584317fd4df91cdaf28b22/coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc", size = 219702, upload-time = "2025-11-18T13:33:09.631Z" }, { url = "https://files.pythonhosted.org/packages/9b/62/f87922641c7198667994dd472a91e1d9b829c95d6c29529ceb52132436ad/coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87", size = 218420, upload-time = "2025-11-18T13:33:11.153Z" }, { url = "https://files.pythonhosted.org/packages/85/dd/1cc13b2395ef15dbb27d7370a2509b4aee77890a464fb35d72d428f84871/coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6", size = 218773, upload-time = "2025-11-18T13:33:12.569Z" }, { url = "https://files.pythonhosted.org/packages/74/40/35773cc4bb1e9d4658d4fb669eb4195b3151bef3bbd6f866aba5cd5dac82/coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7", size = 260078, upload-time = "2025-11-18T13:33:14.037Z" }, { url = "https://files.pythonhosted.org/packages/ec/ee/231bb1a6ffc2905e396557585ebc6bdc559e7c66708376d245a1f1d330fc/coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560", size = 262144, upload-time = "2025-11-18T13:33:15.601Z" }, { url = "https://files.pythonhosted.org/packages/28/be/32f4aa9f3bf0b56f3971001b56508352c7753915345d45fab4296a986f01/coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12", size = 264574, upload-time = "2025-11-18T13:33:17.354Z" }, { url = "https://files.pythonhosted.org/packages/68/7c/00489fcbc2245d13ab12189b977e0cf06ff3351cb98bc6beba8bd68c5902/coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296", size = 259298, upload-time = "2025-11-18T13:33:18.958Z" }, { url = "https://files.pythonhosted.org/packages/96/b4/f0760d65d56c3bea95b449e02570d4abd2549dc784bf39a2d4721a2d8ceb/coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507", size = 262150, upload-time = "2025-11-18T13:33:20.644Z" }, { url = "https://files.pythonhosted.org/packages/c5/71/9a9314df00f9326d78c1e5a910f520d599205907432d90d1c1b7a97aa4b1/coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d", size = 259763, upload-time = "2025-11-18T13:33:22.189Z" }, { url = "https://files.pythonhosted.org/packages/10/34/01a0aceed13fbdf925876b9a15d50862eb8845454301fe3cdd1df08b2182/coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2", size = 258653, upload-time = "2025-11-18T13:33:24.239Z" }, { url = "https://files.pythonhosted.org/packages/8d/04/81d8fd64928acf1574bbb0181f66901c6c1c6279c8ccf5f84259d2c68ae9/coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455", size = 260856, upload-time = "2025-11-18T13:33:26.365Z" }, { url = "https://files.pythonhosted.org/packages/f2/76/fa2a37bfaeaf1f766a2d2360a25a5297d4fb567098112f6517475eee120b/coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d", size = 220936, upload-time = "2025-11-18T13:33:28.165Z" }, { url = "https://files.pythonhosted.org/packages/f9/52/60f64d932d555102611c366afb0eb434b34266b1d9266fc2fe18ab641c47/coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c", size = 222001, upload-time = "2025-11-18T13:33:29.656Z" }, { url = "https://files.pythonhosted.org/packages/77/df/c303164154a5a3aea7472bf323b7c857fed93b26618ed9fc5c2955566bb0/coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d", size = 220273, upload-time = "2025-11-18T13:33:31.415Z" }, { url = "https://files.pythonhosted.org/packages/bf/2e/fc12db0883478d6e12bbd62d481210f0c8daf036102aa11434a0c5755825/coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92", size = 217777, upload-time = "2025-11-18T13:33:32.86Z" }, { url = "https://files.pythonhosted.org/packages/1f/c1/ce3e525d223350c6ec16b9be8a057623f54226ef7f4c2fee361ebb6a02b8/coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360", size = 218100, upload-time = "2025-11-18T13:33:34.532Z" }, { url = "https://files.pythonhosted.org/packages/15/87/113757441504aee3808cb422990ed7c8bcc2d53a6779c66c5adef0942939/coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac", size = 249151, upload-time = "2025-11-18T13:33:36.135Z" }, { url = "https://files.pythonhosted.org/packages/d9/1d/9529d9bd44049b6b05bb319c03a3a7e4b0a8a802d28fa348ad407e10706d/coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d", size = 251667, upload-time = "2025-11-18T13:33:37.996Z" }, { url = "https://files.pythonhosted.org/packages/11/bb/567e751c41e9c03dc29d3ce74b8c89a1e3396313e34f255a2a2e8b9ebb56/coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c", size = 253003, upload-time = "2025-11-18T13:33:39.553Z" }, { url = "https://files.pythonhosted.org/packages/e4/b3/c2cce2d8526a02fb9e9ca14a263ca6fc074449b33a6afa4892838c903528/coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434", size = 249185, upload-time = "2025-11-18T13:33:42.086Z" }, { url = "https://files.pythonhosted.org/packages/0e/a7/967f93bb66e82c9113c66a8d0b65ecf72fc865adfba5a145f50c7af7e58d/coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc", size = 251025, upload-time = "2025-11-18T13:33:43.634Z" }, { url = "https://files.pythonhosted.org/packages/b9/b2/f2f6f56337bc1af465d5b2dc1ee7ee2141b8b9272f3bf6213fcbc309a836/coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc", size = 248979, upload-time = "2025-11-18T13:33:46.04Z" }, { url = "https://files.pythonhosted.org/packages/f4/7a/bf4209f45a4aec09d10a01a57313a46c0e0e8f4c55ff2965467d41a92036/coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e", size = 248800, upload-time = "2025-11-18T13:33:47.546Z" }, { url = "https://files.pythonhosted.org/packages/b8/b7/1e01b8696fb0521810f60c5bbebf699100d6754183e6cc0679bf2ed76531/coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17", size = 250460, upload-time = "2025-11-18T13:33:49.537Z" }, { url = "https://files.pythonhosted.org/packages/71/ae/84324fb9cb46c024760e706353d9b771a81b398d117d8c1fe010391c186f/coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933", size = 220533, upload-time = "2025-11-18T13:33:51.16Z" }, { url = "https://files.pythonhosted.org/packages/e2/71/1033629deb8460a8f97f83e6ac4ca3b93952e2b6f826056684df8275e015/coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe", size = 221348, upload-time = "2025-11-18T13:33:52.776Z" }, { url = "https://files.pythonhosted.org/packages/0a/5f/ac8107a902f623b0c251abdb749be282dc2ab61854a8a4fcf49e276fce2f/coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d", size = 219922, upload-time = "2025-11-18T13:33:54.316Z" }, { url = "https://files.pythonhosted.org/packages/79/6e/f27af2d4da367f16077d21ef6fe796c874408219fa6dd3f3efe7751bd910/coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d", size = 218511, upload-time = "2025-11-18T13:33:56.343Z" }, { url = "https://files.pythonhosted.org/packages/67/dd/65fd874aa460c30da78f9d259400d8e6a4ef457d61ab052fd248f0050558/coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03", size = 218771, upload-time = "2025-11-18T13:33:57.966Z" }, { url = "https://files.pythonhosted.org/packages/55/e0/7c6b71d327d8068cb79c05f8f45bf1b6145f7a0de23bbebe63578fe5240a/coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9", size = 260151, upload-time = "2025-11-18T13:33:59.597Z" }, { url = "https://files.pythonhosted.org/packages/49/ce/4697457d58285b7200de6b46d606ea71066c6e674571a946a6ea908fb588/coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6", size = 262257, upload-time = "2025-11-18T13:34:01.166Z" }, { url = "https://files.pythonhosted.org/packages/2f/33/acbc6e447aee4ceba88c15528dbe04a35fb4d67b59d393d2e0d6f1e242c1/coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339", size = 264671, upload-time = "2025-11-18T13:34:02.795Z" }, { url = "https://files.pythonhosted.org/packages/87/ec/e2822a795c1ed44d569980097be839c5e734d4c0c1119ef8e0a073496a30/coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e", size = 259231, upload-time = "2025-11-18T13:34:04.397Z" }, { url = "https://files.pythonhosted.org/packages/72/c5/a7ec5395bb4a49c9b7ad97e63f0c92f6bf4a9e006b1393555a02dae75f16/coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13", size = 262137, upload-time = "2025-11-18T13:34:06.068Z" }, { url = "https://files.pythonhosted.org/packages/67/0c/02c08858b764129f4ecb8e316684272972e60777ae986f3865b10940bdd6/coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f", size = 259745, upload-time = "2025-11-18T13:34:08.04Z" }, { url = "https://files.pythonhosted.org/packages/5a/04/4fd32b7084505f3829a8fe45c1a74a7a728cb251aaadbe3bec04abcef06d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1", size = 258570, upload-time = "2025-11-18T13:34:09.676Z" }, { url = "https://files.pythonhosted.org/packages/48/35/2365e37c90df4f5342c4fa202223744119fe31264ee2924f09f074ea9b6d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b", size = 260899, upload-time = "2025-11-18T13:34:11.259Z" }, { url = "https://files.pythonhosted.org/packages/05/56/26ab0464ca733fa325e8e71455c58c1c374ce30f7c04cebb88eabb037b18/coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a", size = 221313, upload-time = "2025-11-18T13:34:12.863Z" }, { url = "https://files.pythonhosted.org/packages/da/1c/017a3e1113ed34d998b27d2c6dba08a9e7cb97d362f0ec988fcd873dcf81/coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291", size = 222423, upload-time = "2025-11-18T13:34:15.14Z" }, { url = "https://files.pythonhosted.org/packages/4c/36/bcc504fdd5169301b52568802bb1b9cdde2e27a01d39fbb3b4b508ab7c2c/coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384", size = 220459, upload-time = "2025-11-18T13:34:17.222Z" }, { url = "https://files.pythonhosted.org/packages/ce/a3/43b749004e3c09452e39bb56347a008f0a0668aad37324a99b5c8ca91d9e/coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a", size = 209503, upload-time = "2025-11-18T13:34:18.892Z" }, ] [package.optional-dependencies] toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] [[package]] name = "distlib" version = "0.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] [[package]] name = "filelock" version = "3.20.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, ] [[package]] name = "freezegun" version = "1.5.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, ] sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" }, ] [[package]] name = "frozenlist" version = "1.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] [[package]] name = "identify" version = "2.6.15" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, ] [[package]] name = "idna" version = "3.10" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] name = "jinja2" version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] [[package]] name = "librt" version = "0.7.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b3/d9/6f3d3fcf5e5543ed8a60cc70fa7d50508ed60b8a10e9af6d2058159ab54e/librt-0.7.3.tar.gz", hash = "sha256:3ec50cf65235ff5c02c5b747748d9222e564ad48597122a361269dd3aa808798", size = 144549, upload-time = "2025-12-06T19:04:45.553Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/21/e6/f6391f5c6f158d31ed9af6bd1b1bcd3ffafdea1d816bc4219d0d90175a7f/librt-0.7.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:687403cced6a29590e6be6964463835315905221d797bc5c934a98750fe1a9af", size = 54711, upload-time = "2025-12-06T19:03:24.6Z" }, { url = "https://files.pythonhosted.org/packages/ab/1b/53c208188c178987c081560a0fcf36f5ca500d5e21769596c845ef2f40d4/librt-0.7.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:24d70810f6e2ea853ff79338001533716b373cc0f63e2a0be5bc96129edb5fb5", size = 56664, upload-time = "2025-12-06T19:03:25.969Z" }, { url = "https://files.pythonhosted.org/packages/cb/5c/d9da832b9a1e5f8366e8a044ec80217945385b26cb89fd6f94bfdc7d80b0/librt-0.7.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bf8c7735fbfc0754111f00edda35cf9e98a8d478de6c47b04eaa9cef4300eaa7", size = 161701, upload-time = "2025-12-06T19:03:27.035Z" }, { url = "https://files.pythonhosted.org/packages/20/aa/1e0a7aba15e78529dd21f233076b876ee58c8b8711b1793315bdd3b263b0/librt-0.7.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32d43610dff472eab939f4d7fbdd240d1667794192690433672ae22d7af8445", size = 171040, upload-time = "2025-12-06T19:03:28.482Z" }, { url = "https://files.pythonhosted.org/packages/69/46/3cfa325c1c2bc25775ec6ec1718cfbec9cff4ac767d37d2d3a2d1cc6f02c/librt-0.7.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:adeaa886d607fb02563c1f625cf2ee58778a2567c0c109378da8f17ec3076ad7", size = 184720, upload-time = "2025-12-06T19:03:29.599Z" }, { url = "https://files.pythonhosted.org/packages/99/bb/e4553433d7ac47f4c75d0a7e59b13aee0e08e88ceadbee356527a9629b0a/librt-0.7.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:572a24fc5958c61431da456a0ef1eeea6b4989d81eeb18b8e5f1f3077592200b", size = 180731, upload-time = "2025-12-06T19:03:31.201Z" }, { url = "https://files.pythonhosted.org/packages/35/89/51cd73006232981a3106d4081fbaa584ac4e27b49bc02266468d3919db03/librt-0.7.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6488e69d408b492e08bfb68f20c4a899a354b4386a446ecd490baff8d0862720", size = 174565, upload-time = "2025-12-06T19:03:32.818Z" }, { url = "https://files.pythonhosted.org/packages/42/54/0578a78b587e5aa22486af34239a052c6366835b55fc307bc64380229e3f/librt-0.7.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ed028fc3d41adda916320712838aec289956c89b4f0a361ceadf83a53b4c047a", size = 195247, upload-time = "2025-12-06T19:03:34.434Z" }, { url = "https://files.pythonhosted.org/packages/b5/0a/ee747cd999753dd9447e50b98fc36ee433b6c841a42dbf6d47b64b32a56e/librt-0.7.3-cp311-cp311-win32.whl", hash = "sha256:2cf9d73499486ce39eebbff5f42452518cc1f88d8b7ea4a711ab32962b176ee2", size = 47514, upload-time = "2025-12-06T19:03:35.959Z" }, { url = "https://files.pythonhosted.org/packages/ec/af/8b13845178dec488e752878f8e290f8f89e7e34ae1528b70277aa1a6dd1e/librt-0.7.3-cp311-cp311-win_amd64.whl", hash = "sha256:35f1609e3484a649bb80431310ddbec81114cd86648f1d9482bc72a3b86ded2e", size = 54695, upload-time = "2025-12-06T19:03:36.956Z" }, { url = "https://files.pythonhosted.org/packages/02/7a/ae59578501b1a25850266778f59279f4f3e726acc5c44255bfcb07b4bc57/librt-0.7.3-cp311-cp311-win_arm64.whl", hash = "sha256:550fdbfbf5bba6a2960b27376ca76d6aaa2bd4b1a06c4255edd8520c306fcfc0", size = 48142, upload-time = "2025-12-06T19:03:38.263Z" }, { url = "https://files.pythonhosted.org/packages/29/90/ed8595fa4e35b6020317b5ea8d226a782dcbac7a997c19ae89fb07a41c66/librt-0.7.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0fa9ac2e49a6bee56e47573a6786cb635e128a7b12a0dc7851090037c0d397a3", size = 55687, upload-time = "2025-12-06T19:03:39.245Z" }, { url = "https://files.pythonhosted.org/packages/dd/f6/6a20702a07b41006cb001a759440cb6b5362530920978f64a2b2ae2bf729/librt-0.7.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2e980cf1ed1a2420a6424e2ed884629cdead291686f1048810a817de07b5eb18", size = 57127, upload-time = "2025-12-06T19:03:40.3Z" }, { url = "https://files.pythonhosted.org/packages/79/f3/b0c4703d5ffe9359b67bb2ccb86c42d4e930a363cfc72262ac3ba53cff3e/librt-0.7.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e094e445c37c57e9ec612847812c301840239d34ccc5d153a982fa9814478c60", size = 165336, upload-time = "2025-12-06T19:03:41.369Z" }, { url = "https://files.pythonhosted.org/packages/02/69/3ba05b73ab29ccbe003856232cea4049769be5942d799e628d1470ed1694/librt-0.7.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aca73d70c3f553552ba9133d4a09e767dcfeee352d8d8d3eb3f77e38a3beb3ed", size = 174237, upload-time = "2025-12-06T19:03:42.44Z" }, { url = "https://files.pythonhosted.org/packages/22/ad/d7c2671e7bf6c285ef408aa435e9cd3fdc06fd994601e1f2b242df12034f/librt-0.7.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c634a0a6db395fdaba0361aa78395597ee72c3aad651b9a307a3a7eaf5efd67e", size = 189017, upload-time = "2025-12-06T19:03:44.01Z" }, { url = "https://files.pythonhosted.org/packages/f4/94/d13f57193148004592b618555f296b41d2d79b1dc814ff8b3273a0bf1546/librt-0.7.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a59a69deeb458c858b8fea6acf9e2acd5d755d76cd81a655256bc65c20dfff5b", size = 183983, upload-time = "2025-12-06T19:03:45.834Z" }, { url = "https://files.pythonhosted.org/packages/02/10/b612a9944ebd39fa143c7e2e2d33f2cb790205e025ddd903fb509a3a3bb3/librt-0.7.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d91e60ac44bbe3a77a67af4a4c13114cbe9f6d540337ce22f2c9eaf7454ca71f", size = 177602, upload-time = "2025-12-06T19:03:46.944Z" }, { url = "https://files.pythonhosted.org/packages/1f/48/77bc05c4cc232efae6c5592c0095034390992edbd5bae8d6cf1263bb7157/librt-0.7.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:703456146dc2bf430f7832fd1341adac5c893ec3c1430194fdcefba00012555c", size = 199282, upload-time = "2025-12-06T19:03:48.069Z" }, { url = "https://files.pythonhosted.org/packages/12/aa/05916ccd864227db1ffec2a303ae34f385c6b22d4e7ce9f07054dbcf083c/librt-0.7.3-cp312-cp312-win32.whl", hash = "sha256:b7c1239b64b70be7759554ad1a86288220bbb04d68518b527783c4ad3fb4f80b", size = 47879, upload-time = "2025-12-06T19:03:49.289Z" }, { url = "https://files.pythonhosted.org/packages/50/92/7f41c42d31ea818b3c4b9cc1562e9714bac3c676dd18f6d5dd3d0f2aa179/librt-0.7.3-cp312-cp312-win_amd64.whl", hash = "sha256:ef59c938f72bdbc6ab52dc50f81d0637fde0f194b02d636987cea2ab30f8f55a", size = 54972, upload-time = "2025-12-06T19:03:50.335Z" }, { url = "https://files.pythonhosted.org/packages/3f/dc/53582bbfb422311afcbc92adb75711f04e989cec052f08ec0152fbc36c9c/librt-0.7.3-cp312-cp312-win_arm64.whl", hash = "sha256:ff21c554304e8226bf80c3a7754be27c6c3549a9fec563a03c06ee8f494da8fc", size = 48338, upload-time = "2025-12-06T19:03:51.431Z" }, { url = "https://files.pythonhosted.org/packages/93/7d/e0ce1837dfb452427db556e6d4c5301ba3b22fe8de318379fbd0593759b9/librt-0.7.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56f2a47beda8409061bc1c865bef2d4bd9ff9255219402c0817e68ab5ad89aed", size = 55742, upload-time = "2025-12-06T19:03:52.459Z" }, { url = "https://files.pythonhosted.org/packages/be/c0/3564262301e507e1d5cf31c7d84cb12addf0d35e05ba53312494a2eba9a4/librt-0.7.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14569ac5dd38cfccf0a14597a88038fb16811a6fede25c67b79c6d50fc2c8fdc", size = 57163, upload-time = "2025-12-06T19:03:53.516Z" }, { url = "https://files.pythonhosted.org/packages/be/ac/245e72b7e443d24a562f6047563c7f59833384053073ef9410476f68505b/librt-0.7.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6038ccbd5968325a5d6fd393cf6e00b622a8de545f0994b89dd0f748dcf3e19e", size = 165840, upload-time = "2025-12-06T19:03:54.918Z" }, { url = "https://files.pythonhosted.org/packages/98/af/587e4491f40adba066ba39a450c66bad794c8d92094f936a201bfc7c2b5f/librt-0.7.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d39079379a9a28e74f4d57dc6357fa310a1977b51ff12239d7271ec7e71d67f5", size = 174827, upload-time = "2025-12-06T19:03:56.082Z" }, { url = "https://files.pythonhosted.org/packages/78/21/5b8c60ea208bc83dd00421022a3874330685d7e856404128dc3728d5d1af/librt-0.7.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8837d5a52a2d7aa9f4c3220a8484013aed1d8ad75240d9a75ede63709ef89055", size = 189612, upload-time = "2025-12-06T19:03:57.507Z" }, { url = "https://files.pythonhosted.org/packages/da/2f/8b819169ef696421fb81cd04c6cdf225f6e96f197366001e9d45180d7e9e/librt-0.7.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:399bbd7bcc1633c3e356ae274a1deb8781c7bf84d9c7962cc1ae0c6e87837292", size = 184584, upload-time = "2025-12-06T19:03:58.686Z" }, { url = "https://files.pythonhosted.org/packages/6c/fc/af9d225a9395b77bd7678362cb055d0b8139c2018c37665de110ca388022/librt-0.7.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8d8cf653e798ee4c4e654062b633db36984a1572f68c3aa25e364a0ddfbbb910", size = 178269, upload-time = "2025-12-06T19:03:59.769Z" }, { url = "https://files.pythonhosted.org/packages/6c/d8/7b4fa1683b772966749d5683aa3fd605813defffe157833a8fa69cc89207/librt-0.7.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2f03484b54bf4ae80ab2e504a8d99d20d551bfe64a7ec91e218010b467d77093", size = 199852, upload-time = "2025-12-06T19:04:00.901Z" }, { url = "https://files.pythonhosted.org/packages/77/e8/4598413aece46ca38d9260ef6c51534bd5f34b5c21474fcf210ce3a02123/librt-0.7.3-cp313-cp313-win32.whl", hash = "sha256:44b3689b040df57f492e02cd4f0bacd1b42c5400e4b8048160c9d5e866de8abe", size = 47936, upload-time = "2025-12-06T19:04:02.054Z" }, { url = "https://files.pythonhosted.org/packages/af/80/ac0e92d5ef8c6791b3e2c62373863827a279265e0935acdf807901353b0e/librt-0.7.3-cp313-cp313-win_amd64.whl", hash = "sha256:6b407c23f16ccc36614c136251d6b32bf30de7a57f8e782378f1107be008ddb0", size = 54965, upload-time = "2025-12-06T19:04:03.224Z" }, { url = "https://files.pythonhosted.org/packages/f1/fd/042f823fcbff25c1449bb4203a29919891ca74141b68d3a5f6612c4ce283/librt-0.7.3-cp313-cp313-win_arm64.whl", hash = "sha256:abfc57cab3c53c4546aee31859ef06753bfc136c9d208129bad23e2eca39155a", size = 48350, upload-time = "2025-12-06T19:04:04.234Z" }, { url = "https://files.pythonhosted.org/packages/3e/ae/c6ecc7bb97134a71b5241e8855d39964c0e5f4d96558f0d60593892806d2/librt-0.7.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:120dd21d46ff875e849f1aae19346223cf15656be489242fe884036b23d39e93", size = 55175, upload-time = "2025-12-06T19:04:05.308Z" }, { url = "https://files.pythonhosted.org/packages/cf/bc/2cc0cb0ab787b39aa5c7645cd792433c875982bdf12dccca558b89624594/librt-0.7.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1617bea5ab31266e152871208502ee943cb349c224846928a1173c864261375e", size = 56881, upload-time = "2025-12-06T19:04:06.674Z" }, { url = "https://files.pythonhosted.org/packages/8e/87/397417a386190b70f5bf26fcedbaa1515f19dce33366e2684c6b7ee83086/librt-0.7.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93b2a1f325fefa1482516ced160c8c7b4b8d53226763fa6c93d151fa25164207", size = 163710, upload-time = "2025-12-06T19:04:08.437Z" }, { url = "https://files.pythonhosted.org/packages/c9/37/7338f85b80e8a17525d941211451199845093ca242b32efbf01df8531e72/librt-0.7.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d4801db8354436fd3936531e7f0e4feb411f62433a6b6cb32bb416e20b529f", size = 172471, upload-time = "2025-12-06T19:04:10.124Z" }, { url = "https://files.pythonhosted.org/packages/3b/e0/741704edabbfae2c852fedc1b40d9ed5a783c70ed3ed8e4fe98f84b25d13/librt-0.7.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11ad45122bbed42cfc8b0597450660126ef28fd2d9ae1a219bc5af8406f95678", size = 186804, upload-time = "2025-12-06T19:04:11.586Z" }, { url = "https://files.pythonhosted.org/packages/f4/d1/0a82129d6ba242f3be9af34815be089f35051bc79619f5c27d2c449ecef6/librt-0.7.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6b4e7bff1d76dd2b46443078519dc75df1b5e01562345f0bb740cea5266d8218", size = 181817, upload-time = "2025-12-06T19:04:12.802Z" }, { url = "https://files.pythonhosted.org/packages/4f/32/704f80bcf9979c68d4357c46f2af788fbf9d5edda9e7de5786ed2255e911/librt-0.7.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:d86f94743a11873317094326456b23f8a5788bad9161fd2f0e52088c33564620", size = 175602, upload-time = "2025-12-06T19:04:14.004Z" }, { url = "https://files.pythonhosted.org/packages/f7/6d/4355cfa0fae0c062ba72f541d13db5bc575770125a7ad3d4f46f4109d305/librt-0.7.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:754a0d09997095ad764ccef050dd5bf26cbf457aab9effcba5890dad081d879e", size = 196497, upload-time = "2025-12-06T19:04:15.487Z" }, { url = "https://files.pythonhosted.org/packages/2e/eb/ac6d8517d44209e5a712fde46f26d0055e3e8969f24d715f70bd36056230/librt-0.7.3-cp314-cp314-win32.whl", hash = "sha256:fbd7351d43b80d9c64c3cfcb50008f786cc82cba0450e8599fdd64f264320bd3", size = 44678, upload-time = "2025-12-06T19:04:16.688Z" }, { url = "https://files.pythonhosted.org/packages/e9/93/238f026d141faf9958da588c761a0812a1a21c98cc54a76f3608454e4e59/librt-0.7.3-cp314-cp314-win_amd64.whl", hash = "sha256:d376a35c6561e81d2590506804b428fc1075fcc6298fc5bb49b771534c0ba010", size = 51689, upload-time = "2025-12-06T19:04:17.726Z" }, { url = "https://files.pythonhosted.org/packages/52/44/43f462ad9dcf9ed7d3172fe2e30d77b980956250bd90e9889a9cca93df2a/librt-0.7.3-cp314-cp314-win_arm64.whl", hash = "sha256:cbdb3f337c88b43c3b49ca377731912c101178be91cb5071aac48faa898e6f8e", size = 44662, upload-time = "2025-12-06T19:04:18.771Z" }, { url = "https://files.pythonhosted.org/packages/1d/35/fed6348915f96b7323241de97f26e2af481e95183b34991df12fd5ce31b1/librt-0.7.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9f0e0927efe87cd42ad600628e595a1a0aa1c64f6d0b55f7e6059079a428641a", size = 57347, upload-time = "2025-12-06T19:04:19.812Z" }, { url = "https://files.pythonhosted.org/packages/9a/f2/045383ccc83e3fea4fba1b761796584bc26817b6b2efb6b8a6731431d16f/librt-0.7.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:020c6db391268bcc8ce75105cb572df8cb659a43fd347366aaa407c366e5117a", size = 59223, upload-time = "2025-12-06T19:04:20.862Z" }, { url = "https://files.pythonhosted.org/packages/77/3f/c081f8455ab1d7f4a10dbe58463ff97119272ff32494f21839c3b9029c2c/librt-0.7.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7af7785f5edd1f418da09a8cdb9ec84b0213e23d597413e06525340bcce1ea4f", size = 183861, upload-time = "2025-12-06T19:04:21.963Z" }, { url = "https://files.pythonhosted.org/packages/1d/f5/73c5093c22c31fbeaebc25168837f05ebfd8bf26ce00855ef97a5308f36f/librt-0.7.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ccadf260bb46a61b9c7e89e2218f6efea9f3eeaaab4e3d1f58571890e54858e", size = 194594, upload-time = "2025-12-06T19:04:23.14Z" }, { url = "https://files.pythonhosted.org/packages/78/b8/d5f17d4afe16612a4a94abfded94c16c5a033f183074fb130dfe56fc1a42/librt-0.7.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9883b2d819ce83f87ba82a746c81d14ada78784db431e57cc9719179847376e", size = 206759, upload-time = "2025-12-06T19:04:24.328Z" }, { url = "https://files.pythonhosted.org/packages/36/2e/021765c1be85ee23ffd5b5b968bb4cba7526a4db2a0fc27dcafbdfc32da7/librt-0.7.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:59cb0470612d21fa1efddfa0dd710756b50d9c7fb6c1236bbf8ef8529331dc70", size = 203210, upload-time = "2025-12-06T19:04:25.544Z" }, { url = "https://files.pythonhosted.org/packages/77/f0/9923656e42da4fd18c594bd08cf6d7e152d4158f8b808e210d967f0dcceb/librt-0.7.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:1fe603877e1865b5fd047a5e40379509a4a60204aa7aa0f72b16f7a41c3f0712", size = 196708, upload-time = "2025-12-06T19:04:26.725Z" }, { url = "https://files.pythonhosted.org/packages/fc/0b/0708b886ac760e64d6fbe7e16024e4be3ad1a3629d19489a97e9cf4c3431/librt-0.7.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5460d99ed30f043595bbdc888f542bad2caeb6226b01c33cda3ae444e8f82d42", size = 217212, upload-time = "2025-12-06T19:04:27.892Z" }, { url = "https://files.pythonhosted.org/packages/5d/7f/12a73ff17bca4351e73d585dd9ebf46723c4a8622c4af7fe11a2e2d011ff/librt-0.7.3-cp314-cp314t-win32.whl", hash = "sha256:d09f677693328503c9e492e33e9601464297c01f9ebd966ea8fc5308f3069bfd", size = 45586, upload-time = "2025-12-06T19:04:29.116Z" }, { url = "https://files.pythonhosted.org/packages/e2/df/8decd032ac9b995e4f5606cde783711a71094128d88d97a52e397daf2c89/librt-0.7.3-cp314-cp314t-win_amd64.whl", hash = "sha256:25711f364c64cab2c910a0247e90b51421e45dbc8910ceeb4eac97a9e132fc6f", size = 53002, upload-time = "2025-12-06T19:04:30.173Z" }, { url = "https://files.pythonhosted.org/packages/de/0c/6605b6199de8178afe7efc77ca1d8e6db00453bc1d3349d27605c0f42104/librt-0.7.3-cp314-cp314t-win_arm64.whl", hash = "sha256:a9f9b661f82693eb56beb0605156c7fca57f535704ab91837405913417d6990b", size = 45647, upload-time = "2025-12-06T19:04:31.302Z" }, ] [[package]] name = "lxml" version = "6.0.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" }, { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" }, { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" }, { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" }, { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" }, { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" }, { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" }, { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" }, { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" }, { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" }, { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" }, { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" }, { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" }, { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" }, { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" }, { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" }, { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" }, { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" }, { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" }, { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" }, { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" }, { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, ] [[package]] name = "markdown2" version = "2.5.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/42/f8/b2ae8bf5f28f9b510ae097415e6e4cb63226bb28d7ee01aec03a755ba03b/markdown2-2.5.4.tar.gz", hash = "sha256:a09873f0b3c23dbfae589b0080587df52ad75bb09a5fa6559147554736676889", size = 145652, upload-time = "2025-07-27T16:16:24.307Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b8/06/2697b5043c3ecb720ce0d243fc7cf5024c0b5b1e450506e9b21939019963/markdown2-2.5.4-py3-none-any.whl", hash = "sha256:3c4b2934e677be7fec0e6f2de4410e116681f4ad50ec8e5ba7557be506d3f439", size = 49954, upload-time = "2025-07-27T16:16:23.026Z" }, ] [[package]] name = "markupsafe" version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] name = "multidict" version = "6.7.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/34/9e/5c727587644d67b2ed479041e4b1c58e30afc011e3d45d25bbe35781217c/multidict-6.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4d409aa42a94c0b3fa617708ef5276dfe81012ba6753a0370fcc9d0195d0a1fc", size = 76604, upload-time = "2025-10-06T14:48:54.277Z" }, { url = "https://files.pythonhosted.org/packages/17/e4/67b5c27bd17c085a5ea8f1ec05b8a3e5cba0ca734bfcad5560fb129e70ca/multidict-6.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14c9e076eede3b54c636f8ce1c9c252b5f057c62131211f0ceeec273810c9721", size = 44715, upload-time = "2025-10-06T14:48:55.445Z" }, { url = "https://files.pythonhosted.org/packages/4d/e1/866a5d77be6ea435711bef2a4291eed11032679b6b28b56b4776ab06ba3e/multidict-6.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c09703000a9d0fa3c3404b27041e574cc7f4df4c6563873246d0e11812a94b6", size = 44332, upload-time = "2025-10-06T14:48:56.706Z" }, { url = "https://files.pythonhosted.org/packages/31/61/0c2d50241ada71ff61a79518db85ada85fdabfcf395d5968dae1cbda04e5/multidict-6.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a265acbb7bb33a3a2d626afbe756371dce0279e7b17f4f4eda406459c2b5ff1c", size = 245212, upload-time = "2025-10-06T14:48:58.042Z" }, { url = "https://files.pythonhosted.org/packages/ac/e0/919666a4e4b57fff1b57f279be1c9316e6cdc5de8a8b525d76f6598fefc7/multidict-6.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51cb455de290ae462593e5b1cb1118c5c22ea7f0d3620d9940bf695cea5a4bd7", size = 246671, upload-time = "2025-10-06T14:49:00.004Z" }, { url = "https://files.pythonhosted.org/packages/a1/cc/d027d9c5a520f3321b65adea289b965e7bcbd2c34402663f482648c716ce/multidict-6.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db99677b4457c7a5c5a949353e125ba72d62b35f74e26da141530fbb012218a7", size = 225491, upload-time = "2025-10-06T14:49:01.393Z" }, { url = "https://files.pythonhosted.org/packages/75/c4/bbd633980ce6155a28ff04e6a6492dd3335858394d7bb752d8b108708558/multidict-6.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f470f68adc395e0183b92a2f4689264d1ea4b40504a24d9882c27375e6662bb9", size = 257322, upload-time = "2025-10-06T14:49:02.745Z" }, { url = "https://files.pythonhosted.org/packages/4c/6d/d622322d344f1f053eae47e033b0b3f965af01212de21b10bcf91be991fb/multidict-6.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0db4956f82723cc1c270de9c6e799b4c341d327762ec78ef82bb962f79cc07d8", size = 254694, upload-time = "2025-10-06T14:49:04.15Z" }, { url = "https://files.pythonhosted.org/packages/a8/9f/78f8761c2705d4c6d7516faed63c0ebdac569f6db1bef95e0d5218fdc146/multidict-6.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e56d780c238f9e1ae66a22d2adf8d16f485381878250db8d496623cd38b22bd", size = 246715, upload-time = "2025-10-06T14:49:05.967Z" }, { url = "https://files.pythonhosted.org/packages/78/59/950818e04f91b9c2b95aab3d923d9eabd01689d0dcd889563988e9ea0fd8/multidict-6.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d14baca2ee12c1a64740d4531356ba50b82543017f3ad6de0deb943c5979abb", size = 243189, upload-time = "2025-10-06T14:49:07.37Z" }, { url = "https://files.pythonhosted.org/packages/7a/3d/77c79e1934cad2ee74991840f8a0110966d9599b3af95964c0cd79bb905b/multidict-6.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:295a92a76188917c7f99cda95858c822f9e4aae5824246bba9b6b44004ddd0a6", size = 237845, upload-time = "2025-10-06T14:49:08.759Z" }, { url = "https://files.pythonhosted.org/packages/63/1b/834ce32a0a97a3b70f86437f685f880136677ac00d8bce0027e9fd9c2db7/multidict-6.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39f1719f57adbb767ef592a50ae5ebb794220d1188f9ca93de471336401c34d2", size = 246374, upload-time = "2025-10-06T14:49:10.574Z" }, { url = "https://files.pythonhosted.org/packages/23/ef/43d1c3ba205b5dec93dc97f3fba179dfa47910fc73aaaea4f7ceb41cec2a/multidict-6.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0a13fb8e748dfc94749f622de065dd5c1def7e0d2216dba72b1d8069a389c6ff", size = 253345, upload-time = "2025-10-06T14:49:12.331Z" }, { url = "https://files.pythonhosted.org/packages/6b/03/eaf95bcc2d19ead522001f6a650ef32811aa9e3624ff0ad37c445c7a588c/multidict-6.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e3aa16de190d29a0ea1b48253c57d99a68492c8dd8948638073ab9e74dc9410b", size = 246940, upload-time = "2025-10-06T14:49:13.821Z" }, { url = "https://files.pythonhosted.org/packages/e8/df/ec8a5fd66ea6cd6f525b1fcbb23511b033c3e9bc42b81384834ffa484a62/multidict-6.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a048ce45dcdaaf1defb76b2e684f997fb5abf74437b6cb7b22ddad934a964e34", size = 242229, upload-time = "2025-10-06T14:49:15.603Z" }, { url = "https://files.pythonhosted.org/packages/8a/a2/59b405d59fd39ec86d1142630e9049243015a5f5291ba49cadf3c090c541/multidict-6.7.0-cp311-cp311-win32.whl", hash = "sha256:a90af66facec4cebe4181b9e62a68be65e45ac9b52b67de9eec118701856e7ff", size = 41308, upload-time = "2025-10-06T14:49:16.871Z" }, { url = "https://files.pythonhosted.org/packages/32/0f/13228f26f8b882c34da36efa776c3b7348455ec383bab4a66390e42963ae/multidict-6.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:95b5ffa4349df2887518bb839409bcf22caa72d82beec453216802f475b23c81", size = 46037, upload-time = "2025-10-06T14:49:18.457Z" }, { url = "https://files.pythonhosted.org/packages/84/1f/68588e31b000535a3207fd3c909ebeec4fb36b52c442107499c18a896a2a/multidict-6.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:329aa225b085b6f004a4955271a7ba9f1087e39dcb7e65f6284a988264a63912", size = 43023, upload-time = "2025-10-06T14:49:19.648Z" }, { url = "https://files.pythonhosted.org/packages/c2/9e/9f61ac18d9c8b475889f32ccfa91c9f59363480613fc807b6e3023d6f60b/multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184", size = 76877, upload-time = "2025-10-06T14:49:20.884Z" }, { url = "https://files.pythonhosted.org/packages/38/6f/614f09a04e6184f8824268fce4bc925e9849edfa654ddd59f0b64508c595/multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45", size = 45467, upload-time = "2025-10-06T14:49:22.054Z" }, { url = "https://files.pythonhosted.org/packages/b3/93/c4f67a436dd026f2e780c433277fff72be79152894d9fc36f44569cab1a6/multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa", size = 43834, upload-time = "2025-10-06T14:49:23.566Z" }, { url = "https://files.pythonhosted.org/packages/7f/f5/013798161ca665e4a422afbc5e2d9e4070142a9ff8905e482139cd09e4d0/multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7", size = 250545, upload-time = "2025-10-06T14:49:24.882Z" }, { url = "https://files.pythonhosted.org/packages/71/2f/91dbac13e0ba94669ea5119ba267c9a832f0cb65419aca75549fcf09a3dc/multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e", size = 258305, upload-time = "2025-10-06T14:49:26.778Z" }, { url = "https://files.pythonhosted.org/packages/ef/b0/754038b26f6e04488b48ac621f779c341338d78503fb45403755af2df477/multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546", size = 242363, upload-time = "2025-10-06T14:49:28.562Z" }, { url = "https://files.pythonhosted.org/packages/87/15/9da40b9336a7c9fa606c4cf2ed80a649dffeb42b905d4f63a1d7eb17d746/multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4", size = 268375, upload-time = "2025-10-06T14:49:29.96Z" }, { url = "https://files.pythonhosted.org/packages/82/72/c53fcade0cc94dfaad583105fd92b3a783af2091eddcb41a6d5a52474000/multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1", size = 269346, upload-time = "2025-10-06T14:49:31.404Z" }, { url = "https://files.pythonhosted.org/packages/0d/e2/9baffdae21a76f77ef8447f1a05a96ec4bc0a24dae08767abc0a2fe680b8/multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d", size = 256107, upload-time = "2025-10-06T14:49:32.974Z" }, { url = "https://files.pythonhosted.org/packages/3c/06/3f06f611087dc60d65ef775f1fb5aca7c6d61c6db4990e7cda0cef9b1651/multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304", size = 253592, upload-time = "2025-10-06T14:49:34.52Z" }, { url = "https://files.pythonhosted.org/packages/20/24/54e804ec7945b6023b340c412ce9c3f81e91b3bf5fa5ce65558740141bee/multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12", size = 251024, upload-time = "2025-10-06T14:49:35.956Z" }, { url = "https://files.pythonhosted.org/packages/14/48/011cba467ea0b17ceb938315d219391d3e421dfd35928e5dbdc3f4ae76ef/multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62", size = 251484, upload-time = "2025-10-06T14:49:37.631Z" }, { url = "https://files.pythonhosted.org/packages/0d/2f/919258b43bb35b99fa127435cfb2d91798eb3a943396631ef43e3720dcf4/multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0", size = 263579, upload-time = "2025-10-06T14:49:39.502Z" }, { url = "https://files.pythonhosted.org/packages/31/22/a0e884d86b5242b5a74cf08e876bdf299e413016b66e55511f7a804a366e/multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a", size = 259654, upload-time = "2025-10-06T14:49:41.32Z" }, { url = "https://files.pythonhosted.org/packages/b2/e5/17e10e1b5c5f5a40f2fcbb45953c9b215f8a4098003915e46a93f5fcaa8f/multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8", size = 251511, upload-time = "2025-10-06T14:49:46.021Z" }, { url = "https://files.pythonhosted.org/packages/e3/9a/201bb1e17e7af53139597069c375e7b0dcbd47594604f65c2d5359508566/multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4", size = 41895, upload-time = "2025-10-06T14:49:48.718Z" }, { url = "https://files.pythonhosted.org/packages/46/e2/348cd32faad84eaf1d20cce80e2bb0ef8d312c55bca1f7fa9865e7770aaf/multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b", size = 46073, upload-time = "2025-10-06T14:49:50.28Z" }, { url = "https://files.pythonhosted.org/packages/25/ec/aad2613c1910dce907480e0c3aa306905830f25df2e54ccc9dea450cb5aa/multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec", size = 43226, upload-time = "2025-10-06T14:49:52.304Z" }, { url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135, upload-time = "2025-10-06T14:49:54.26Z" }, { url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117, upload-time = "2025-10-06T14:49:55.82Z" }, { url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472, upload-time = "2025-10-06T14:49:57.048Z" }, { url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342, upload-time = "2025-10-06T14:49:58.368Z" }, { url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082, upload-time = "2025-10-06T14:49:59.89Z" }, { url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704, upload-time = "2025-10-06T14:50:01.485Z" }, { url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355, upload-time = "2025-10-06T14:50:02.955Z" }, { url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259, upload-time = "2025-10-06T14:50:04.446Z" }, { url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903, upload-time = "2025-10-06T14:50:05.98Z" }, { url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365, upload-time = "2025-10-06T14:50:07.511Z" }, { url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062, upload-time = "2025-10-06T14:50:09.074Z" }, { url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683, upload-time = "2025-10-06T14:50:10.714Z" }, { url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload-time = "2025-10-06T14:50:12.28Z" }, { url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload-time = "2025-10-06T14:50:14.16Z" }, { url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload-time = "2025-10-06T14:50:15.639Z" }, { url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload-time = "2025-10-06T14:50:17.066Z" }, { url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload-time = "2025-10-06T14:50:18.264Z" }, { url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload-time = "2025-10-06T14:50:19.853Z" }, { url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload-time = "2025-10-06T14:50:21.223Z" }, { url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload-time = "2025-10-06T14:50:22.871Z" }, { url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload-time = "2025-10-06T14:50:24.258Z" }, { url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588, upload-time = "2025-10-06T14:50:25.716Z" }, { url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966, upload-time = "2025-10-06T14:50:28.192Z" }, { url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618, upload-time = "2025-10-06T14:50:29.82Z" }, { url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539, upload-time = "2025-10-06T14:50:31.731Z" }, { url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345, upload-time = "2025-10-06T14:50:33.26Z" }, { url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934, upload-time = "2025-10-06T14:50:34.808Z" }, { url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243, upload-time = "2025-10-06T14:50:36.436Z" }, { url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878, upload-time = "2025-10-06T14:50:37.953Z" }, { url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452, upload-time = "2025-10-06T14:50:39.574Z" }, { url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload-time = "2025-10-06T14:50:41.612Z" }, { url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload-time = "2025-10-06T14:50:43.972Z" }, { url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload-time = "2025-10-06T14:50:45.648Z" }, { url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload-time = "2025-10-06T14:50:47.154Z" }, { url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload-time = "2025-10-06T14:50:48.851Z" }, { url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload-time = "2025-10-06T14:50:50.16Z" }, { url = "https://files.pythonhosted.org/packages/e2/b1/3da6934455dd4b261d4c72f897e3a5728eba81db59959f3a639245891baa/multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", size = 75128, upload-time = "2025-10-06T14:50:51.92Z" }, { url = "https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", size = 44410, upload-time = "2025-10-06T14:50:53.275Z" }, { url = "https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", size = 43205, upload-time = "2025-10-06T14:50:54.911Z" }, { url = "https://files.pythonhosted.org/packages/02/68/6b086fef8a3f1a8541b9236c594f0c9245617c29841f2e0395d979485cde/multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", size = 245084, upload-time = "2025-10-06T14:50:56.369Z" }, { url = "https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", size = 252667, upload-time = "2025-10-06T14:50:57.991Z" }, { url = "https://files.pythonhosted.org/packages/02/a5/eeb3f43ab45878f1895118c3ef157a480db58ede3f248e29b5354139c2c9/multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", size = 233590, upload-time = "2025-10-06T14:50:59.589Z" }, { url = "https://files.pythonhosted.org/packages/6a/1e/76d02f8270b97269d7e3dbd45644b1785bda457b474315f8cf999525a193/multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", size = 264112, upload-time = "2025-10-06T14:51:01.183Z" }, { url = "https://files.pythonhosted.org/packages/76/0b/c28a70ecb58963847c2a8efe334904cd254812b10e535aefb3bcce513918/multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", size = 261194, upload-time = "2025-10-06T14:51:02.794Z" }, { url = "https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", size = 248510, upload-time = "2025-10-06T14:51:04.724Z" }, { url = "https://files.pythonhosted.org/packages/93/cd/06c1fa8282af1d1c46fd55c10a7930af652afdce43999501d4d68664170c/multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", size = 248395, upload-time = "2025-10-06T14:51:06.306Z" }, { url = "https://files.pythonhosted.org/packages/99/ac/82cb419dd6b04ccf9e7e61befc00c77614fc8134362488b553402ecd55ce/multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", size = 239520, upload-time = "2025-10-06T14:51:08.091Z" }, { url = "https://files.pythonhosted.org/packages/fa/f3/a0f9bf09493421bd8716a362e0cd1d244f5a6550f5beffdd6b47e885b331/multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7", size = 245479, upload-time = "2025-10-06T14:51:10.365Z" }, { url = "https://files.pythonhosted.org/packages/8d/01/476d38fc73a212843f43c852b0eee266b6971f0e28329c2184a8df90c376/multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", size = 258903, upload-time = "2025-10-06T14:51:12.466Z" }, { url = "https://files.pythonhosted.org/packages/49/6d/23faeb0868adba613b817d0e69c5f15531b24d462af8012c4f6de4fa8dc3/multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", size = 252333, upload-time = "2025-10-06T14:51:14.48Z" }, { url = "https://files.pythonhosted.org/packages/1e/cc/48d02ac22b30fa247f7dad82866e4b1015431092f4ba6ebc7e77596e0b18/multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", size = 243411, upload-time = "2025-10-06T14:51:16.072Z" }, { url = "https://files.pythonhosted.org/packages/4a/03/29a8bf5a18abf1fe34535c88adbdfa88c9fb869b5a3b120692c64abe8284/multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", size = 40940, upload-time = "2025-10-06T14:51:17.544Z" }, { url = "https://files.pythonhosted.org/packages/82/16/7ed27b680791b939de138f906d5cf2b4657b0d45ca6f5dd6236fdddafb1a/multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", size = 45087, upload-time = "2025-10-06T14:51:18.875Z" }, { url = "https://files.pythonhosted.org/packages/cd/3c/e3e62eb35a1950292fe39315d3c89941e30a9d07d5d2df42965ab041da43/multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", size = 42368, upload-time = "2025-10-06T14:51:20.225Z" }, { url = "https://files.pythonhosted.org/packages/8b/40/cd499bd0dbc5f1136726db3153042a735fffd0d77268e2ee20d5f33c010f/multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", size = 82326, upload-time = "2025-10-06T14:51:21.588Z" }, { url = "https://files.pythonhosted.org/packages/13/8a/18e031eca251c8df76daf0288e6790561806e439f5ce99a170b4af30676b/multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", size = 48065, upload-time = "2025-10-06T14:51:22.93Z" }, { url = "https://files.pythonhosted.org/packages/40/71/5e6701277470a87d234e433fb0a3a7deaf3bcd92566e421e7ae9776319de/multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", size = 46475, upload-time = "2025-10-06T14:51:24.352Z" }, { url = "https://files.pythonhosted.org/packages/fe/6a/bab00cbab6d9cfb57afe1663318f72ec28289ea03fd4e8236bb78429893a/multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", size = 239324, upload-time = "2025-10-06T14:51:25.822Z" }, { url = "https://files.pythonhosted.org/packages/2a/5f/8de95f629fc22a7769ade8b41028e3e5a822c1f8904f618d175945a81ad3/multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", size = 246877, upload-time = "2025-10-06T14:51:27.604Z" }, { url = "https://files.pythonhosted.org/packages/23/b4/38881a960458f25b89e9f4a4fdcb02ac101cfa710190db6e5528841e67de/multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", size = 225824, upload-time = "2025-10-06T14:51:29.664Z" }, { url = "https://files.pythonhosted.org/packages/1e/39/6566210c83f8a261575f18e7144736059f0c460b362e96e9cf797a24b8e7/multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", size = 253558, upload-time = "2025-10-06T14:51:31.684Z" }, { url = "https://files.pythonhosted.org/packages/00/a3/67f18315100f64c269f46e6c0319fa87ba68f0f64f2b8e7fd7c72b913a0b/multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", size = 252339, upload-time = "2025-10-06T14:51:33.699Z" }, { url = "https://files.pythonhosted.org/packages/c8/2a/1cb77266afee2458d82f50da41beba02159b1d6b1f7973afc9a1cad1499b/multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", size = 244895, upload-time = "2025-10-06T14:51:36.189Z" }, { url = "https://files.pythonhosted.org/packages/dd/72/09fa7dd487f119b2eb9524946ddd36e2067c08510576d43ff68469563b3b/multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", size = 241862, upload-time = "2025-10-06T14:51:41.291Z" }, { url = "https://files.pythonhosted.org/packages/65/92/bc1f8bd0853d8669300f732c801974dfc3702c3eeadae2f60cef54dc69d7/multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", size = 232376, upload-time = "2025-10-06T14:51:43.55Z" }, { url = "https://files.pythonhosted.org/packages/09/86/ac39399e5cb9d0c2ac8ef6e10a768e4d3bc933ac808d49c41f9dc23337eb/multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", size = 240272, upload-time = "2025-10-06T14:51:45.265Z" }, { url = "https://files.pythonhosted.org/packages/3d/b6/fed5ac6b8563ec72df6cb1ea8dac6d17f0a4a1f65045f66b6d3bf1497c02/multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", size = 248774, upload-time = "2025-10-06T14:51:46.836Z" }, { url = "https://files.pythonhosted.org/packages/6b/8d/b954d8c0dc132b68f760aefd45870978deec6818897389dace00fcde32ff/multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", size = 242731, upload-time = "2025-10-06T14:51:48.541Z" }, { url = "https://files.pythonhosted.org/packages/16/9d/a2dac7009125d3540c2f54e194829ea18ac53716c61b655d8ed300120b0f/multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", size = 240193, upload-time = "2025-10-06T14:51:50.355Z" }, { url = "https://files.pythonhosted.org/packages/39/ca/c05f144128ea232ae2178b008d5011d4e2cea86e4ee8c85c2631b1b94802/multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", size = 48023, upload-time = "2025-10-06T14:51:51.883Z" }, { url = "https://files.pythonhosted.org/packages/ba/8f/0a60e501584145588be1af5cc829265701ba3c35a64aec8e07cbb71d39bb/multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", size = 53507, upload-time = "2025-10-06T14:51:53.672Z" }, { url = "https://files.pythonhosted.org/packages/7f/ae/3148b988a9c6239903e786eac19c889fab607c31d6efa7fb2147e5680f23/multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", size = 44804, upload-time = "2025-10-06T14:51:55.415Z" }, { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, ] [[package]] name = "mypy" version = "1.19.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, ] [[package]] name = "mypy-extensions" version = "1.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] name = "nodeenv" version = "1.9.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] [[package]] name = "packaging" version = "25.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] [[package]] name = "paho-mqtt" version = "2.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" }, ] [[package]] name = "pathspec" version = "0.12.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, ] [[package]] name = "pdoc" version = "16.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, { name = "markdown2" }, { name = "markupsafe" }, { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/fe/ab3f34a5fb08c6b698439a2c2643caf8fef0d61a86dd3fdcd5501c670ab8/pdoc-16.0.0.tar.gz", hash = "sha256:fdadc40cc717ec53919e3cd720390d4e3bcd40405cb51c4918c119447f913514", size = 111890, upload-time = "2025-10-27T16:02:16.345Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/16/a1/56a17b7f9e18c2bb8df73f3833345d97083b344708b97bab148fdd7e0b82/pdoc-16.0.0-py3-none-any.whl", hash = "sha256:070b51de2743b9b1a4e0ab193a06c9e6c12cf4151cf9137656eebb16e8556628", size = 100014, upload-time = "2025-10-27T16:02:15.007Z" }, ] [[package]] name = "pillow" version = "11.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" }, { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" }, { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" }, { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" }, { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" }, { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" }, { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" }, { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" }, { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" }, { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" }, { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" }, { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" }, { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" }, { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" }, { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" }, { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" }, { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" }, { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" }, ] [[package]] name = "platformdirs" version = "4.5.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "pre-commit" version = "4.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, { name = "identify" }, { name = "nodeenv" }, { name = "pyyaml" }, { name = "virtualenv" }, ] sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] [[package]] name = "propcache" version = "0.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] [[package]] name = "pycryptodome" version = "3.23.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, ] [[package]] name = "pycryptodomex" version = "3.23.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2e/00/10edb04777069a42490a38c137099d4b17ba6e36a4e6e28bdc7470e9e853/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7b37e08e3871efe2187bc1fd9320cc81d87caf19816c648f24443483005ff886", size = 2498764, upload-time = "2025-05-17T17:22:21.453Z" }, { url = "https://files.pythonhosted.org/packages/6b/3f/2872a9c2d3a27eac094f9ceaa5a8a483b774ae69018040ea3240d5b11154/pycryptodomex-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:91979028227543010d7b2ba2471cf1d1e398b3f183cb105ac584df0c36dac28d", size = 1643012, upload-time = "2025-05-17T17:22:23.702Z" }, { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" }, { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" }, ] [[package]] name = "pygments" version = "2.19.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] name = "pyrate-limiter" version = "3.9.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ec/da/f682c5c5f9f0a5414363eb4397e6b07d84a02cde69c4ceadcbf32c85537c/pyrate_limiter-3.9.0.tar.gz", hash = "sha256:6b882e2c77cda07a241d3730975daea4258344b39c878f1dd8849df73f70b0ce", size = 289308, upload-time = "2025-07-30T14:36:58.659Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/af/d8bf0959ece9bc4679bd203908c31019556a421d76d8143b0c6871c7f614/pyrate_limiter-3.9.0-py3-none-any.whl", hash = "sha256:77357840c8cf97a36d67005d4e090787043f54000c12c2b414ff65657653e378", size = 33628, upload-time = "2025-07-30T14:36:57.71Z" }, ] [[package]] name = "pyshark" version = "0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "appdirs" }, { name = "lxml" }, { name = "packaging" }, { name = "termcolor" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e1/3c/0e6f306a8a0490bfe58d0683553b4c60c6dfcd2cd2c6a68b46673b177dd0/pyshark-0.6.tar.gz", hash = "sha256:a424d83e0ca6224a96bbe30cd3f89d5491654d783faaaf90adaf45867a0bcb17", size = 27053, upload-time = "2023-04-26T09:33:43.811Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1d/d9/7884ff926c3d05ec65fdf84ac499fb0f088e440d02c606b0ac41645605de/pyshark-0.6-py3-none-any.whl", hash = "sha256:98e8a1ebdcbfbb6e8defd0c96736ea51bf8234339f980b15dd3545f87f5146d4", size = 41359, upload-time = "2023-04-26T09:33:41.022Z" }, ] [[package]] name = "pytest" version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] [[package]] name = "pytest-asyncio" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] [[package]] name = "pytest-cov" version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", extra = ["toml"] }, { name = "pluggy" }, { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] [[package]] name = "pytest-timeout" version = "2.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, ] [[package]] name = "python-dateutil" version = "2.9.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] name = "python-roborock" version = "4.7.2" source = { editable = "." } dependencies = [ { name = "aiohttp" }, { name = "aiomqtt" }, { name = "click" }, { name = "click-shell" }, { name = "construct" }, { name = "paho-mqtt" }, { name = "pycryptodome" }, { name = "pycryptodomex", marker = "sys_platform == 'darwin'" }, { name = "pyrate-limiter" }, { name = "vacuum-map-parser-roborock" }, ] [package.dev-dependencies] dev = [ { name = "aioresponses" }, { name = "codespell" }, { name = "freezegun" }, { name = "mypy" }, { name = "pdoc" }, { name = "pre-commit" }, { name = "pyshark" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-timeout" }, { name = "pyyaml" }, { name = "ruff" }, { name = "syrupy" }, ] [package.metadata] requires-dist = [ { name = "aiohttp", specifier = ">=3.8.2,<4" }, { name = "aiomqtt", specifier = ">=2.5.0,<3" }, { name = "click", specifier = ">=8" }, { name = "click-shell", specifier = "~=2.1" }, { name = "construct", specifier = ">=2.10.57,<3" }, { name = "paho-mqtt", specifier = ">=1.6.1,<3.0.0" }, { name = "pycryptodome", specifier = "~=3.18" }, { name = "pycryptodomex", marker = "sys_platform == 'darwin'", specifier = "~=3.18" }, { name = "pyrate-limiter", specifier = ">=3.7.0,<4" }, { name = "vacuum-map-parser-roborock" }, ] [package.metadata.requires-dev] dev = [ { name = "aioresponses", specifier = ">=0.7.7,<0.8" }, { name = "codespell" }, { name = "freezegun", specifier = ">=1.5.1,<2" }, { name = "mypy" }, { name = "pdoc", specifier = ">=15.0.4,<17" }, { name = "pre-commit", specifier = ">=3.5,<5.0" }, { name = "pyshark", specifier = ">=0.6" }, { name = "pyshark", specifier = ">=0.6,<0.7" }, { name = "pytest" }, { name = "pytest-asyncio", specifier = ">=1.1.0" }, { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "pytest-timeout", specifier = ">=2.3.1,<3" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "ruff", specifier = "==0.14.11" }, { name = "syrupy", specifier = ">=4.9.1,<6" }, ] [[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] name = "ruff" version = "0.14.11" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d4/77/9a7fe084d268f8855d493e5031ea03fa0af8cc05887f638bf1c4e3363eb8/ruff-0.14.11.tar.gz", hash = "sha256:f6dc463bfa5c07a59b1ff2c3b9767373e541346ea105503b4c0369c520a66958", size = 5993417, upload-time = "2026-01-08T19:11:58.322Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f0/a6/a4c40a5aaa7e331f245d2dc1ac8ece306681f52b636b40ef87c88b9f7afd/ruff-0.14.11-py3-none-linux_armv6l.whl", hash = "sha256:f6ff2d95cbd335841a7217bdfd9c1d2e44eac2c584197ab1385579d55ff8830e", size = 12951208, upload-time = "2026-01-08T19:12:09.218Z" }, { url = "https://files.pythonhosted.org/packages/5c/5c/360a35cb7204b328b685d3129c08aca24765ff92b5a7efedbdd6c150d555/ruff-0.14.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f6eb5c1c8033680f4172ea9c8d3706c156223010b8b97b05e82c59bdc774ee6", size = 13330075, upload-time = "2026-01-08T19:12:02.549Z" }, { url = "https://files.pythonhosted.org/packages/1b/9e/0cc2f1be7a7d33cae541824cf3f95b4ff40d03557b575912b5b70273c9ec/ruff-0.14.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2fc34cc896f90080fca01259f96c566f74069a04b25b6205d55379d12a6855e", size = 12257809, upload-time = "2026-01-08T19:12:00.366Z" }, { url = "https://files.pythonhosted.org/packages/a7/e5/5faab97c15bb75228d9f74637e775d26ac703cc2b4898564c01ab3637c02/ruff-0.14.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53386375001773ae812b43205d6064dae49ff0968774e6befe16a994fc233caa", size = 12678447, upload-time = "2026-01-08T19:12:13.899Z" }, { url = "https://files.pythonhosted.org/packages/1b/33/e9767f60a2bef779fb5855cab0af76c488e0ce90f7bb7b8a45c8a2ba4178/ruff-0.14.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a697737dce1ca97a0a55b5ff0434ee7205943d4874d638fe3ae66166ff46edbe", size = 12758560, upload-time = "2026-01-08T19:11:42.55Z" }, { url = "https://files.pythonhosted.org/packages/eb/84/4c6cf627a21462bb5102f7be2a320b084228ff26e105510cd2255ea868e5/ruff-0.14.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6845ca1da8ab81ab1dce755a32ad13f1db72e7fba27c486d5d90d65e04d17b8f", size = 13599296, upload-time = "2026-01-08T19:11:30.371Z" }, { url = "https://files.pythonhosted.org/packages/88/e1/92b5ed7ea66d849f6157e695dc23d5d6d982bd6aa8d077895652c38a7cae/ruff-0.14.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e36ce2fd31b54065ec6f76cb08d60159e1b32bdf08507862e32f47e6dde8bcbf", size = 15048981, upload-time = "2026-01-08T19:12:04.742Z" }, { url = "https://files.pythonhosted.org/packages/61/df/c1bd30992615ac17c2fb64b8a7376ca22c04a70555b5d05b8f717163cf9f/ruff-0.14.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:590bcc0e2097ecf74e62a5c10a6b71f008ad82eb97b0a0079e85defe19fe74d9", size = 14633183, upload-time = "2026-01-08T19:11:40.069Z" }, { url = "https://files.pythonhosted.org/packages/04/e9/fe552902f25013dd28a5428a42347d9ad20c4b534834a325a28305747d64/ruff-0.14.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53fe71125fc158210d57fe4da26e622c9c294022988d08d9347ec1cf782adafe", size = 14050453, upload-time = "2026-01-08T19:11:37.555Z" }, { url = "https://files.pythonhosted.org/packages/ae/93/f36d89fa021543187f98991609ce6e47e24f35f008dfe1af01379d248a41/ruff-0.14.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a35c9da08562f1598ded8470fcfef2afb5cf881996e6c0a502ceb61f4bc9c8a3", size = 13757889, upload-time = "2026-01-08T19:12:07.094Z" }, { url = "https://files.pythonhosted.org/packages/b7/9f/c7fb6ecf554f28709a6a1f2a7f74750d400979e8cd47ed29feeaa1bd4db8/ruff-0.14.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0f3727189a52179393ecf92ec7057c2210203e6af2676f08d92140d3e1ee72c1", size = 13955832, upload-time = "2026-01-08T19:11:55.064Z" }, { url = "https://files.pythonhosted.org/packages/db/a0/153315310f250f76900a98278cf878c64dfb6d044e184491dd3289796734/ruff-0.14.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:eb09f849bd37147a789b85995ff734a6c4a095bed5fd1608c4f56afc3634cde2", size = 12586522, upload-time = "2026-01-08T19:11:35.356Z" }, { url = "https://files.pythonhosted.org/packages/2f/2b/a73a2b6e6d2df1d74bf2b78098be1572191e54bec0e59e29382d13c3adc5/ruff-0.14.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c61782543c1231bf71041461c1f28c64b961d457d0f238ac388e2ab173d7ecb7", size = 12724637, upload-time = "2026-01-08T19:11:47.796Z" }, { url = "https://files.pythonhosted.org/packages/f0/41/09100590320394401cd3c48fc718a8ba71c7ddb1ffd07e0ad6576b3a3df2/ruff-0.14.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:82ff352ea68fb6766140381748e1f67f83c39860b6446966cff48a315c3e2491", size = 13145837, upload-time = "2026-01-08T19:11:32.87Z" }, { url = "https://files.pythonhosted.org/packages/3b/d8/e035db859d1d3edf909381eb8ff3e89a672d6572e9454093538fe6f164b0/ruff-0.14.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:728e56879df4ca5b62a9dde2dd0eb0edda2a55160c0ea28c4025f18c03f86984", size = 13850469, upload-time = "2026-01-08T19:12:11.694Z" }, { url = "https://files.pythonhosted.org/packages/4e/02/bb3ff8b6e6d02ce9e3740f4c17dfbbfb55f34c789c139e9cd91985f356c7/ruff-0.14.11-py3-none-win32.whl", hash = "sha256:337c5dd11f16ee52ae217757d9b82a26400be7efac883e9e852646f1557ed841", size = 12851094, upload-time = "2026-01-08T19:11:45.163Z" }, { url = "https://files.pythonhosted.org/packages/58/f1/90ddc533918d3a2ad628bc3044cdfc094949e6d4b929220c3f0eb8a1c998/ruff-0.14.11-py3-none-win_amd64.whl", hash = "sha256:f981cea63d08456b2c070e64b79cb62f951aa1305282974d4d5216e6e0178ae6", size = 14001379, upload-time = "2026-01-08T19:11:52.591Z" }, { url = "https://files.pythonhosted.org/packages/c4/1c/1dbe51782c0e1e9cfce1d1004752672d2d4629ea46945d19d731ad772b3b/ruff-0.14.11-py3-none-win_arm64.whl", hash = "sha256:649fb6c9edd7f751db276ef42df1f3df41c38d67d199570ae2a7bd6cbc3590f0", size = 12938644, upload-time = "2026-01-08T19:11:50.027Z" }, ] [[package]] name = "six" version = "1.17.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] name = "syrupy" version = "5.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c1/90/1a442d21527009d4b40f37fe50b606ebb68a6407142c2b5cc508c34b696b/syrupy-5.0.0.tar.gz", hash = "sha256:3282fe963fa5d4d3e47231b16d1d4d0f4523705e8199eeb99a22a1bc9f5942f2", size = 48881, upload-time = "2025-09-28T21:15:12.783Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9d/9a/6c68aad2ccfce6e2eeebbf5bb709d0240592eb51ff142ec4c8fbf3c2460a/syrupy-5.0.0-py3-none-any.whl", hash = "sha256:c848e1a980ca52a28715cd2d2b4d434db424699c05653bd1158fb31cf56e9546", size = 49087, upload-time = "2025-09-28T21:15:11.639Z" }, ] [[package]] name = "termcolor" version = "3.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ca/6c/3d75c196ac07ac8749600b60b03f4f6094d54e132c4d94ebac6ee0e0add0/termcolor-3.1.0.tar.gz", hash = "sha256:6a6dd7fbee581909eeec6a756cff1d7f7c376063b14e4a298dc4980309e55970", size = 14324, upload-time = "2025-04-30T11:37:53.791Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/4f/bd/de8d508070629b6d84a30d01d57e4a65c69aa7f5abe7560b8fad3b50ea59/termcolor-3.1.0-py3-none-any.whl", hash = "sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa", size = 7684, upload-time = "2025-04-30T11:37:52.382Z" }, ] [[package]] name = "tomli" version = "2.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, ] [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] name = "vacuum-map-parser-base" version = "0.1.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pillow" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8f/f8/5633c2d294ce0102bdfc008684a937d50cf59bab48286de962997e108db3/vacuum_map_parser_base-0.1.5.tar.gz", hash = "sha256:efbf889ae7a7a8fe6478354a1711e857ee781c2d7f3a09e5b30e714b60036c4a", size = 18330, upload-time = "2025-05-05T03:44:11.942Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c5/5c/6d16e20b76504ff7694d405bbfdc587ab6fe1d096940e505d33c632e4b8e/vacuum_map_parser_base-0.1.5-py3-none-any.whl", hash = "sha256:cdbbe1905ab7b3e5929a1aefaa80b6972f796d46d53ccccdfde13d4afb510b59", size = 19076, upload-time = "2025-05-05T03:44:10.704Z" }, ] [[package]] name = "vacuum-map-parser-roborock" version = "0.1.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pillow" }, { name = "vacuum-map-parser-base" }, ] sdist = { url = "https://files.pythonhosted.org/packages/39/b4/82583167a6b667151a6432fe9084232a090b36985751cd5c428998b2d080/vacuum_map_parser_roborock-0.1.4.tar.gz", hash = "sha256:07ab7cd8aaf0e94da62d2a228013b2f6b8acb0e6d2215b697b6441ffdfd70e89", size = 15315, upload-time = "2025-05-05T03:53:33.459Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2a/56/e80291e0bfd38078bf9338fe379076d1fd55dea0174eee71897e55a0c9dc/vacuum_map_parser_roborock-0.1.4-py3-none-any.whl", hash = "sha256:8b5a00484a88c5d103a99ed7580677939c0801430f04752d9ae6265dfcec5969", size = 13758, upload-time = "2025-05-05T03:53:32.544Z" }, ] [[package]] name = "virtualenv" version = "20.35.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a4/d5/b0ccd381d55c8f45d46f77df6ae59fbc23d19e901e2d523395598e5f4c93/virtualenv-20.35.3.tar.gz", hash = "sha256:4f1a845d131133bdff10590489610c98c168ff99dc75d6c96853801f7f67af44", size = 6002907, upload-time = "2025-10-10T21:23:33.178Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/27/73/d9a94da0e9d470a543c1b9d3ccbceb0f59455983088e727b8a1824ed90fb/virtualenv-20.35.3-py3-none-any.whl", hash = "sha256:63d106565078d8c8d0b206d48080f938a8b25361e19432d2c9db40d2899c810a", size = 5981061, upload-time = "2025-10-10T21:23:30.433Z" }, ] [[package]] name = "yarl" version = "1.22.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" }, { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" }, { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" }, { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" }, { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" }, { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" }, { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" }, { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" }, { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" }, { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" }, { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" }, { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" }, { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" }, { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" }, { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" }, { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" }, { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, ]