pax_global_header00006660000000000000000000000064152036743640014524gustar00rootroot0000000000000052 comment=45be4557a80d6f2c8d2ff4c8d61c23688e3a83de Bluetooth-Devices-bleak-retry-connector-2326a9d/000077500000000000000000000000001520367436400216145ustar00rootroot00000000000000Bluetooth-Devices-bleak-retry-connector-2326a9d/.all-contributorsrc000066400000000000000000000004741520367436400254520ustar00rootroot00000000000000{ "projectName": "bleak-retry-connector", "projectOwner": "bluetooth-devices", "repoType": "github", "repoHost": "https://github.com", "files": [ "README.md" ], "imageSize": 80, "commit": true, "commitConvention": "angular", "contributors": [], "contributorsPerLine": 7, "skipCi": true } Bluetooth-Devices-bleak-retry-connector-2326a9d/.editorconfig000066400000000000000000000004441520367436400242730ustar00rootroot00000000000000# http://editorconfig.org root = true [*] indent_style = space indent_size = 4 trim_trailing_whitespace = true insert_final_newline = true charset = utf-8 end_of_line = lf [*.bat] indent_style = tab end_of_line = crlf [LICENSE] insert_final_newline = false [Makefile] indent_style = tab Bluetooth-Devices-bleak-retry-connector-2326a9d/.flake8000066400000000000000000000000561520367436400227700ustar00rootroot00000000000000[flake8] exclude = docs max-line-length = 120 Bluetooth-Devices-bleak-retry-connector-2326a9d/.github/000077500000000000000000000000001520367436400231545ustar00rootroot00000000000000Bluetooth-Devices-bleak-retry-connector-2326a9d/.github/ISSUE_TEMPLATE/000077500000000000000000000000001520367436400253375ustar00rootroot00000000000000Bluetooth-Devices-bleak-retry-connector-2326a9d/.github/ISSUE_TEMPLATE/1-bug_report.md000066400000000000000000000004221520367436400301650ustar00rootroot00000000000000--- name: Bug report about: Create a report to help us improve labels: bug --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: **Additional context** Add any other context about the problem here. Bluetooth-Devices-bleak-retry-connector-2326a9d/.github/ISSUE_TEMPLATE/2-feature-request.md000066400000000000000000000006721520367436400311460ustar00rootroot00000000000000--- name: Feature request about: Suggest an idea for this project labels: enhancement --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Additional context** Add any other context or screenshots about the feature request here. Bluetooth-Devices-bleak-retry-connector-2326a9d/.github/dependabot.yml000066400000000000000000000013431520367436400260050ustar00rootroot00000000000000# To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" commit-message: prefix: "chore(ci): " groups: github-actions: patterns: - "*" - package-ecosystem: "pip" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" Bluetooth-Devices-bleak-retry-connector-2326a9d/.github/labels.toml000066400000000000000000000035151520367436400253170ustar00rootroot00000000000000[breaking] color = "ffcc00" name = "breaking" description = "Breaking change." [bug] color = "d73a4a" name = "bug" description = "Something isn't working" [dependencies] color = "0366d6" name = "dependencies" description = "Pull requests that update a dependency file" [github_actions] color = "000000" name = "github_actions" description = "Update of github actions" [documentation] color = "1bc4a5" name = "documentation" description = "Improvements or additions to documentation" [duplicate] color = "cfd3d7" name = "duplicate" description = "This issue or pull request already exists" [enhancement] color = "a2eeef" name = "enhancement" description = "New feature or request" ["good first issue"] color = "7057ff" name = "good first issue" description = "Good for newcomers" ["help wanted"] color = "008672" name = "help wanted" description = "Extra attention is needed" [invalid] color = "e4e669" name = "invalid" description = "This doesn't seem right" [nochangelog] color = "555555" name = "nochangelog" description = "Exclude pull requests from changelog" [question] color = "d876e3" name = "question" description = "Further information is requested" [removed] color = "e99695" name = "removed" description = "Removed piece of functionalities." [tests] color = "bfd4f2" name = "tests" description = "CI, CD and testing related changes" [wontfix] color = "ffffff" name = "wontfix" description = "This will not be worked on" [discussion] color = "c2e0c6" name = "discussion" description = "Some discussion around the project" [hacktoberfest] color = "ffa663" name = "hacktoberfest" description = "Good issues for Hacktoberfest" [answered] color = "0ee2b6" name = "answered" description = "Automatically closes as answered after a delay" [waiting] color = "5f7972" name = "waiting" description = "Automatically closes if no answer after a delay" Bluetooth-Devices-bleak-retry-connector-2326a9d/.github/workflows/000077500000000000000000000000001520367436400252115ustar00rootroot00000000000000Bluetooth-Devices-bleak-retry-connector-2326a9d/.github/workflows/ci.yml000066400000000000000000000047521520367436400263370ustar00rootroot00000000000000name: CI on: push: branches: - main pull_request: concurrency: group: ${{ github.head_ref || github.run_id }} cancel-in-progress: true jobs: 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 # 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 test: strategy: fail-fast: false matrix: python-version: - "3.10" - "3.11" - "3.12" - "3.13" - "3.14" os: - ubuntu-latest runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - uses: snok/install-poetry@v1 - name: Install Dependencies shell: bash run: poetry install - name: Test with Pytest shell: bash run: poetry run pytest --cov-report=xml - name: Upload coverage to Codecov uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} release: runs-on: ubuntu-latest environment: release if: github.ref == 'refs/heads/main' needs: - test - lint - commitlint permissions: id-token: write contents: write steps: - uses: actions/checkout@v6 with: fetch-depth: 0 # Run semantic release: # - Update CHANGELOG.md # - Update version in code # - Create git tag # - Create GitHub release - name: Python Semantic Release id: release uses: python-semantic-release/python-semantic-release@v10.5.3 with: github_token: ${{ secrets.GITHUB_TOKEN }} - name: Upload package to PyPI uses: pypa/gh-action-pypi-publish@release/v1 if: steps.release.outputs.released == 'true' - name: Upload Github Release Assets 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 }} Bluetooth-Devices-bleak-retry-connector-2326a9d/.github/workflows/hacktoberfest.yml000066400000000000000000000005341520367436400305620ustar00rootroot00000000000000name: Hacktoberfest on: schedule: # Run every day in October - cron: "0 0 * 10 *" # Run on the 1st of November to revert - cron: "0 13 1 11 *" jobs: hacktoberfest: runs-on: ubuntu-latest steps: - uses: browniebroke/hacktoberfest-labeler-action@v2.6.0 with: github_token: ${{ secrets.GH_PAT }} Bluetooth-Devices-bleak-retry-connector-2326a9d/.github/workflows/issue-manager.yml000066400000000000000000000013401520367436400304720ustar00rootroot00000000000000name: Issue Manager on: schedule: - cron: "0 0 * * *" issue_comment: types: - created issues: types: - labeled pull_request_target: types: - labeled workflow_dispatch: jobs: issue-manager: runs-on: ubuntu-latest steps: - uses: tiangolo/issue-manager@0.6.0 with: token: ${{ secrets.GITHUB_TOKEN }} config: > { "answered": { "message": "Assuming the original issue was solved, it will be automatically closed now." }, "waiting": { "message": "Automatically closing. To re-open, please provide the additional information requested." } } Bluetooth-Devices-bleak-retry-connector-2326a9d/.github/workflows/labels.yml000066400000000000000000000007751520367436400272070ustar00rootroot00000000000000name: Sync Github labels on: push: branches: - main paths: - ".github/**" jobs: labels: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: 3.11 - name: Install labels run: pip install labels - name: Sync config with Github run: labels -u ${{ github.repository_owner }} -t ${{ secrets.GITHUB_TOKEN }} sync -f .github/labels.toml Bluetooth-Devices-bleak-retry-connector-2326a9d/.gitignore000066400000000000000000000040661520367436400236120ustar00rootroot00000000000000# Created by .ignore support plugin (hsz.mobi) ### Python template # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ Bluetooth-Devices-bleak-retry-connector-2326a9d/.gitpod.yml000066400000000000000000000003061520367436400237020ustar00rootroot00000000000000tasks: - command: | pip install poetry PIP_USER=false poetry install - command: | pip install pre-commit pre-commit install PIP_USER=false pre-commit install-hooks Bluetooth-Devices-bleak-retry-connector-2326a9d/.idea/000077500000000000000000000000001520367436400225745ustar00rootroot00000000000000Bluetooth-Devices-bleak-retry-connector-2326a9d/.idea/bleak-retry-connector.iml000066400000000000000000000005151520367436400275110ustar00rootroot00000000000000 Bluetooth-Devices-bleak-retry-connector-2326a9d/.idea/watcherTasks.xml000066400000000000000000000052531520367436400257660ustar00rootroot00000000000000 Bluetooth-Devices-bleak-retry-connector-2326a9d/.idea/workspace.xml000066400000000000000000000027411520367436400253200ustar00rootroot00000000000000 Bluetooth-Devices-bleak-retry-connector-2326a9d/.pre-commit-config.yaml000066400000000000000000000033761520367436400261060ustar00rootroot00000000000000# 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] ci: autofix_commit_msg: "chore(pre-commit.ci): auto fixes" autoupdate_commit_msg: "chore(pre-commit.ci): pre-commit autoupdate" repos: - repo: https://github.com/commitizen-tools/commitizen rev: v4.16.2 hooks: - id: commitizen stages: [commit-msg] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.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-xml - id: check-yaml - id: detect-private-key - id: end-of-file-fixer - id: trailing-whitespace - id: debug-statements - repo: https://github.com/pre-commit/mirrors-prettier rev: v4.0.0-alpha.8 hooks: - id: prettier args: ["--tab-width", "2"] additional_dependencies: - prettier@2.8.4 - repo: https://github.com/asottile/pyupgrade rev: v3.21.2 hooks: - id: pyupgrade args: [--py310-plus] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.13 hooks: - id: ruff-check args: [--fix] - id: ruff-format - repo: https://github.com/codespell-project/codespell rev: v2.4.2 hooks: - id: codespell - repo: https://github.com/PyCQA/flake8 rev: 7.3.0 hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy rev: v2.1.0 hooks: - id: mypy additional_dependencies: [] - repo: https://github.com/PyCQA/bandit rev: 1.9.4 hooks: - id: bandit args: [-x, tests] Bluetooth-Devices-bleak-retry-connector-2326a9d/.readthedocs.yml000066400000000000000000000010061520367436400246770ustar00rootroot00000000000000# Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details # Required version: 2 # Build documentation in the docs/ directory with Sphinx sphinx: configuration: docs/source/conf.py # Set the version of Python and other tools you might need build: os: ubuntu-22.04 tools: python: "3.13" # Optionally declare the Python requirements required to build your docs python: install: - method: pip path: . - requirements: docs/requirements.txt Bluetooth-Devices-bleak-retry-connector-2326a9d/CHANGELOG.md000066400000000000000000001376361520367436400234450ustar00rootroot00000000000000# CHANGELOG ## v3.10.0 (2025-04-01) ### Chores - Update dependabot.yml to increase GHA update frequency ([`53b72a1`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/53b72a18b7f889519eca49a9f5fb43199d56cb9b)) - Update deps ([#152](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/152), [`c4b0050`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/c4b0050678e01f2e92a92a419d8fd08f2f972045)) dependabot does not yet support new pyproject.toml format - **ci**: Bump the github-actions group across 1 directory with 8 updates ([#150](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/150), [`1f67cb0`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/1f67cb0df49102ddd64c9d19ee44f33d1ecffc5d)) * chore(ci): bump the github-actions group across 1 directory with 8 updates Bumps the github-actions group with 8 updates in the / directory: | Package | From | To | | --- | --- | --- | | [actions/checkout](https://github.com/actions/checkout) | `3` | `4` | | [actions/setup-python](https://github.com/actions/setup-python) | `3` | `5` | | [pre-commit/action](https://github.com/pre-commit/action) | `2.0.3` | `3.0.1` | | [wagoid/commitlint-github-action](https://github.com/wagoid/commitlint-github-action) | `4.1.11` | `6.2.1` | | [codecov/codecov-action](https://github.com/codecov/codecov-action) | `3` | `5` | | [python-semantic-release/python-semantic-release](https://github.com/python-semantic-release/python-semantic-release) | `7.34.6` | `9.21.0` | | [browniebroke/hacktoberfest-labeler-action](https://github.com/browniebroke/hacktoberfest-labeler-action) | `2.2.0` | `2.3.0` | | [tiangolo/issue-manager](https://github.com/tiangolo/issue-manager) | `0.4.0` | `0.5.1` | Updates `actions/checkout` from 3 to 4 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3...v4) Updates `actions/setup-python` from 3 to 5 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v3...v5) Updates `pre-commit/action` from 2.0.3 to 3.0.1 - [Release notes](https://github.com/pre-commit/action/releases) - [Commits](https://github.com/pre-commit/action/compare/v2.0.3...v3.0.1) Updates `wagoid/commitlint-github-action` from 4.1.11 to 6.2.1 - [Changelog](https://github.com/wagoid/commitlint-github-action/blob/master/CHANGELOG.md) - [Commits](https://github.com/wagoid/commitlint-github-action/compare/v4.1.11...v6.2.1) Updates `codecov/codecov-action` from 3 to 5 - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v3...v5) Updates `python-semantic-release/python-semantic-release` from 7.34.6 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/v7.34.6...v9.21.0) Updates `browniebroke/hacktoberfest-labeler-action` from 2.2.0 to 2.3.0 - [Release notes](https://github.com/browniebroke/hacktoberfest-labeler-action/releases) - [Changelog](https://github.com/browniebroke/hacktoberfest-labeler-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/browniebroke/hacktoberfest-labeler-action/compare/v2.2.0...v2.3.0) Updates `tiangolo/issue-manager` from 0.4.0 to 0.5.1 - [Release notes](https://github.com/tiangolo/issue-manager/releases) - [Commits](https://github.com/tiangolo/issue-manager/compare/0.4.0...0.5.1) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/setup-python dependency-type: direct:production - dependency-name: pre-commit/action dependency-type: direct:production - dependency-name: wagoid/commitlint-github-action dependency-type: direct:production - dependency-name: codecov/codecov-action dependency-type: direct:production - dependency-name: python-semantic-release/python-semantic-release dependency-type: direct:production - dependency-name: browniebroke/hacktoberfest-labeler-action dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: tiangolo/issue-manager dependency-type: direct:production dependency-group: github-actions ... Signed-off-by: dependabot[bot] * chore: update pyproject * chore: adjust actions --------- Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston - **pre-commit.ci**: Pre-commit autoupdate ([#149](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/149), [`b71c2d6`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/b71c2d6f9a2fe8758a77da50a4e061e14106cd83)) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> - **pre-commit.ci**: Pre-commit autoupdate ([#151](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/151), [`ace8adf`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/ace8adf7c33317406bca6fbe210643beabadfe67)) updates: - [github.com/commitizen-tools/commitizen: v4.2.2 → v4.4.1](https://github.com/commitizen-tools/commitizen/compare/v4.2.2...v4.4.1) - [github.com/PyCQA/isort: 6.0.0 → 6.0.1](https://github.com/PyCQA/isort/compare/6.0.0...6.0.1) - [github.com/PyCQA/flake8: 7.1.2 → 7.2.0](https://github.com/PyCQA/flake8/compare/7.1.2...7.2.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> ### Features - Switch to trusted publishing ([#153](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/153), [`4b8510c`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/4b8510c6fac4b0d640afbb8152f72ab383cd1dfd)) ## v3.9.0 (2025-02-20) ### Chores - Update dependabot.yml to include GHA ([`3158e20`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/3158e2074c11b99d2a11c21ccc197a1d1e93faf2)) - **pre-commit.ci**: Pre-commit autoupdate ([#146](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/146), [`c9b4072`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/c9b4072d099ec9ca842a7a406eb47569a5265092)) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> - **pre-commit.ci**: Pre-commit autoupdate ([#147](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/147), [`18cd61e`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/18cd61ed26a6de86879d6f34a6603b47d8a16452)) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> ### Features - Add internal stop_discovery method for habluetooth ([#148](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/148), [`ae9feac`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/ae9feac7138ace0ba62cb30112f29a3ce6f47b28)) ## v3.8.1 (2025-02-04) ### Bug Fixes - Update poetry to v2 ([#144](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/144), [`9b45308`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/9b45308e6055c4a42c50228b2e21b20e2adfd604)) ### Chores - **deps**: Bump bluetooth-adapters from 0.21.0 to 0.21.1 ([#139](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/139), [`d6a5e37`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/d6a5e37f639a644ca6beed7a707e521f30f59757)) Bumps [bluetooth-adapters](https://github.com/bluetooth-devices/bluetooth-adapters) from 0.21.0 to 0.21.1. - [Release notes](https://github.com/bluetooth-devices/bluetooth-adapters/releases) - [Changelog](https://github.com/Bluetooth-Devices/bluetooth-adapters/blob/main/CHANGELOG.md) - [Commits](https://github.com/bluetooth-devices/bluetooth-adapters/compare/v0.21.0...v0.21.1) --- updated-dependencies: - dependency-name: bluetooth-adapters 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 dbus-fast from 2.30.2 to 2.32.0 ([#141](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/141), [`060341f`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/060341f63492556c63dda1a90c39a4bcce5b0268)) - **deps-dev**: Bump pytest-asyncio from 0.25.2 to 0.25.3 ([#142](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/142), [`a29042c`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/a29042c36d3ce638990e63b2acd160dd7c4d1823)) - **pre-commit.ci**: Pre-commit autoupdate ([#140](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/140), [`6a4e839`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/6a4e839c039b9f527612499707030cc03c4399b1)) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> - **pre-commit.ci**: Pre-commit autoupdate ([#143](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/143), [`87c49e5`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/87c49e5483a61f8d1a13bffc26c954d4aef7cdb3)) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> ## v3.8.0 (2025-01-21) ### Chores - **deps**: Bump bluetooth-adapters from 0.20.2 to 0.21.0 ([#137](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/137), [`bc3045c`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/bc3045c1e44aea3a3075fabbe7172a7ac2a388dc)) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> - **deps-dev**: Bump pytest-asyncio from 0.23.8 to 0.25.2 ([#136](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/136), [`d71425a`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/d71425a0298418ec6674772e74dc683f2cfcfbb3)) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ### Features - Add method to fetch current allocations ([#138](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/138), [`4dc325b`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/4dc325b4db5afc613064ba1257987a4a0e00fa7e)) ## v3.7.0 (2025-01-18) ### Chores - Create dependabot.yml ([`bece8dd`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/bece8dd77ef736a24a8b4ede66472ded83bc5759)) - Update Python 3.13 in CI ([#127](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/127), [`200dc40`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/200dc403c398ab3f5b328e9a0cd644bfe6e27ba3)) - **deps**: Bump aiohttp from 3.9.1 to 3.10.11 ([#135](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/135), [`f713f8a`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/f713f8a8bd458e7cdea31f9982f20e008cccd073)) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> - **deps**: Bump bluetooth-adapters from 0.16.2 to 0.20.2 ([#128](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/128), [`c5f661d`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/c5f661d1d1e976bd66e42edc06b0633e42f1835d)) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> - **deps**: Bump dbus-fast from 2.21.0 to 2.30.2 ([#129](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/129), [`717149c`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/717149c289394b24b756a5e19cb024130ecee2a5)) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> - **deps**: Bump idna from 3.6 to 3.7 ([#134](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/134), [`8afeaca`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/8afeacabe79580958fdbade047bc1b9a00144324)) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> - **deps-dev**: Bump pytest from 7.4.4 to 8.3.4 ([#131](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/131), [`9b5b266`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/9b5b266027e5b7eb523d438d72e5e9e7dbe89a3f)) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> - **deps-dev**: Bump pytest-asyncio from 0.19.0 to 0.23.8 ([#130](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/130), [`1506d86`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/1506d866c80fe0dd6f246fd4e8599ed79cfb7c0d)) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> - **deps-dev**: Bump pytest-cov from 3.0.0 to 6.0.0 ([#132](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/132), [`50a2912`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/50a291211532a186d8a1df8c7406e5185e9b4999)) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> - **pre-commit.ci**: Pre-commit autoupdate ([#120](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/120), [`4732a33`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/4732a3346329c0532da07c745cc575cdd796cd52)) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston - **pre-commit.ci**: Pre-commit autoupdate ([#121](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/121), [`4eebb4e`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/4eebb4e028a7935967ff3f2a2ac4e818a1f497d5)) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> - **pre-commit.ci**: Pre-commit autoupdate ([#123](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/123), [`9d7a463`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/9d7a46368accb79622ec30ddee79c2f2beb3454c)) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> - **pre-commit.ci**: Pre-commit autoupdate ([#124](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/124), [`7c8bd62`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/7c8bd629cee757661c58d00006b7d197aeb17410)) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> - **pre-commit.ci**: Pre-commit autoupdate ([#125](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/125), [`edea1ad`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/edea1adc00ba0ea84e9eed1149057a6f37a30165)) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> - **pre-commit.ci**: Pre-commit autoupdate ([#126](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/126), [`f009e05`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/f009e0501735d2db725b15404ac67be2e7171b4d)) ### Features - Add support for getting callbacks on slot allocation change ([#133](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/133), [`ae21ecb`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/ae21ecb8524555dceafbbe47fb5d3b62efd51f1a)) ## v3.6.0 (2024-10-05) ### Chores - **pre-commit.ci**: Pre-commit autoupdate ([#112](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/112), [`87b345f`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/87b345f05c27764232ff2aee0637fe2c9b1adf70)) * chore(pre-commit.ci): pre-commit autoupdate updates: - [github.com/commitizen-tools/commitizen: v2.42.0 → v3.27.0](https://github.com/commitizen-tools/commitizen/compare/v2.42.0...v3.27.0) - [github.com/pre-commit/pre-commit-hooks: v4.4.0 → v4.6.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.4.0...v4.6.0) - [github.com/pre-commit/mirrors-prettier: v2.7.1 → v4.0.0-alpha.8](https://github.com/pre-commit/mirrors-prettier/compare/v2.7.1...v4.0.0-alpha.8) - [github.com/asottile/pyupgrade: v3.3.1 → v3.16.0](https://github.com/asottile/pyupgrade/compare/v3.3.1...v3.16.0) - [github.com/PyCQA/isort: 5.12.0 → 5.13.2](https://github.com/PyCQA/isort/compare/5.12.0...5.13.2) - [github.com/psf/black: 23.1.0 → 24.4.2](https://github.com/psf/black/compare/23.1.0...24.4.2) - [github.com/codespell-project/codespell: v2.2.2 → v2.3.0](https://github.com/codespell-project/codespell/compare/v2.2.2...v2.3.0) - [github.com/PyCQA/flake8: 6.0.0 → 7.1.0](https://github.com/PyCQA/flake8/compare/6.0.0...7.1.0) - [github.com/pre-commit/mirrors-mypy: v1.0.1 → v1.10.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.0.1...v1.10.1) - [github.com/PyCQA/bandit: 1.7.4 → 1.7.9](https://github.com/PyCQA/bandit/compare/1.7.4...1.7.9) * chore(pre-commit.ci): auto fixes --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> - **pre-commit.ci**: Pre-commit autoupdate ([#113](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/113), [`4226fa2`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/4226fa2569b02b1629cdc99e1be181dbdd568b37)) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> - **pre-commit.ci**: Pre-commit autoupdate ([#114](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/114), [`92c271d`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/92c271dff0008fac509d602e0a7ea3b74c826f83)) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> - **pre-commit.ci**: Pre-commit autoupdate ([#115](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/115), [`98c40cb`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/98c40cb482379f90e7861376ac9c391eaf05a08c)) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> - **pre-commit.ci**: Pre-commit autoupdate ([#116](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/116), [`230b739`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/230b73900dfacddf5ba48f8ed2a2fc1d4f47edfe)) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> - **pre-commit.ci**: Pre-commit autoupdate ([#117](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/117), [`ef36e57`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/ef36e57234aca7cd04fd0e7922b809f01b41b777)) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> - **pre-commit.ci**: Pre-commit autoupdate ([#118](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/118), [`12b4f0b`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/12b4f0b09ef54044e519f52621a2b5b863b0efcc)) updates: - [github.com/commitizen-tools/commitizen: v3.29.0 → v3.29.1](https://github.com/commitizen-tools/commitizen/compare/v3.29.0...v3.29.1) - [github.com/PyCQA/bandit: 1.7.9 → 1.7.10](https://github.com/PyCQA/bandit/compare/1.7.9...1.7.10) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> ### Features - Add support for Python 3.13 ([#119](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/119), [`f2c3fa5`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/f2c3fa58217d4133b83aafd5ea885edc9e78ae85)) ## v3.5.0 (2024-04-10) ### Features - Add device path to the disconnect debug logging ([#111](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/111), [`8e010b3`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/8e010b3f3754b156e68699f5727be77d4f8412a3)) ## v3.4.0 (2024-01-01) ### Chores - Add python 3.12 to the CI ([#104](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/104), [`c6fac48`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/c6fac48a1dd240f0921d3e98736b8f08fad3428f)) ### Features - Add close_stale_connections_by_address ([#110](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/110), [`74de12f`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/74de12fb5a9269bce677cb76ae0f05daf1af343a)) ## v3.3.0 (2023-10-25) ### Features - Handle services changed during connecting ([#108](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/108), [`1c65413`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/1c65413cad1e6c42d6bf2c0a8cdec82d9d9a7484)) ## v3.2.1 (2023-09-14) ### Bug Fixes - Correct fetching the global bluez manager when its not running ([#106](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/106), [`38c63a9`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/38c63a974dd05f7a1e42d647e3ca13884e9b4e62)) ## v3.2.0 (2023-09-14) ### Chores - Log exception type when device disappears ([#105](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/105), [`445bd43`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/445bd43c62bb9f4afc60b3d359fa94aeaa3abd98)) ### Features - Remove devices on cache clear to cleanup disk cache ([#103](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/103), [`349e0de`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/349e0deeb2bee443e82d23b816bbd7036a476718)) ## v3.1.3 (2023-09-07) ### Bug Fixes - Ensure timeouts work with py3.11 ([#102](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/102), [`4951aef`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/4951aefd9de0e22235dbbd64a15357e67f496d87)) ## v3.1.2 (2023-09-03) ### Bug Fixes - Increase bleak safety timeout to allow for longer disconnect timeout ([#101](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/101), [`39380a7`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/39380a744b9aed832b51ad20671af86b99186560)) ## v3.1.1 (2023-07-25) ### Bug Fixes - Check more often for a device to reappear after the adapter runs out of slots ([#100](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/100), [`4c9c9c0`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/4c9c9c0670c79d9425e26b761a20d588dd259a26)) ## v3.1.0 (2023-07-19) ### Chores - Bump python-semantic-release to fix release process ([#98](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/98), [`ee8ebcb`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/ee8ebcbbe3761d32da6e8c542e2cfb10162706f2)) - Downgrade python-semantic-release ([#99](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/99), [`beee26f`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/beee26f53e5aae2ed78b7c05ea3e8c09b8c7c6a3)) - Fix ci ([#96](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/96), [`50da16b`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/50da16be853b075fabb7a6f81f85380a1ac15e86)) ### Features - Decrease backoff times ([#97](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/97), [`37b71c8`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/37b71c8bf1bd456de3d44ca4f7845de07c853bbc)) - Update the out of slots message to be more clear ([#95](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/95), [`9269a82`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/9269a82f5f1c88c382856c88e98102d1b83dc436)) ## v3.0.2 (2023-03-25) ### Bug Fixes - Bluez services cache clear was ineffective ([#93](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/93), [`ec86cb6`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/ec86cb6788ba075920867b5cb06d1f5fa49d18ae)) ## v3.0.1 (2023-03-18) ### Bug Fixes - Update for bleak 0.20.0 ([#92](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/92), [`78f9a1e`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/78f9a1e81768ee9543595a6c8673c8c635f63244)) ## v3.0.0 (2023-02-25) ### Bug Fixes - Bump python-semantic-release ([#90](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/90), [`c401988`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/c4019883c9bad3f91a20029e8adf35962a59a488)) - Lint ([#89](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/89), [`c3b5ff8`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/c3b5ff8870b8a5c6cb7972d9e1a0ca677cc0c78d)) - Typing for generic BleakClient classes and the retry_bluetooth_connection_error decorator ([#86](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/86), [`8ddf242`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/8ddf2426ff2fc5274dc2e8a905233a2c30f57fbb)) * fix: typing for the generic BleakClient client class Using a bound TypeVar we can ensure that any client class we are dealing with is either BleakClient or a descendant of it and that type then stays consistent throughout the lifecycle. Signed-off-by: Felix Kaechele * fix: typing for the retry_bluetooth_connection_error decorator Use TypeVar together with ParamVar to drop the use of the unsafe cast operation. --------- Co-authored-by: J. Nick Koston ### Chores - Drop Python 3.9 support ([#88](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/88), [`58f9958`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/58f9958785b40d2fbade39ef7f56dab931f888a6)) BREAKING CHANGE: In preparation for the use of Python 3.10 typing features such as ParamSpec, which is unavailable on Python 3.9. Following the schema of supporting the current and one previous Python release this drops support for Python 3.9. Signed-off-by: Felix Kaechele - Update pre-commit hooks ([#87](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/87), [`fd08a1c`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/fd08a1cf76ce012feadcf4a491b0c31f346b783b)) Co-authored-by: J. Nick Koston ### Breaking Changes - In preparation for the use of Python 3.10 typing features such as ParamSpec, which is unavailable on Python 3.9. ## v2.13.1 (2023-01-12) ### Bug Fixes - Make bluetooth-adapters install Linux only as well ([#85](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/85), [`910f0b7`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/910f0b7147c31d1133bc5d308d134a72e47c3ff5)) - Only import from bluetooth_adapters when running on linux ([#84](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/84), [`51926f7`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/51926f7a679437df875f7cb5b6e53253ae10f0b6)) ## v2.13.0 (2022-12-23) ### Features - Remove freshen fallback logic since Home Assistant always provides us the best path to the device now ([#83](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/83), [`0954d2d`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/0954d2dfc7ff06f3b7445140c644aeaf7ea36384)) ## v2.12.1 (2022-12-22) ### Bug Fixes - _on_characteristic_value_changed in BleakSlotManager should accept any arguments ([#82](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/82), [`71cc37e`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/71cc37ef6b0b7492fb58aaeb9115737e95bd9f0e)) ## v2.12.0 (2022-12-22) ### Features - Add utility function to get device_source ([#81](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/81), [`d72ce15`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/d72ce150edba658b4d4edb43f3bbd158cba9988f)) ## v2.11.0 (2022-12-22) ### Features - Add connection slot manager ([#80](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/80), [`d8bb8d9`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/d8bb8d96fb019152fb97e4006a8e6a1d11213a7d)) ## v2.10.2 (2022-12-12) ### Bug Fixes - Stop trying to get devices from bluez if dbus setup times out ([#78](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/78), [`a8da722`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/a8da7222d6d7ab725152141f560dc1bb681bf4cf)) ## v2.10.1 (2022-12-05) ### Bug Fixes - Optimize IS_LINUX check in restore_discoveries ([#77](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/77), [`f22eb33`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/f22eb33e1d29d5a6ca8697061de9fbb1bf583bec)) ## v2.10.0 (2022-12-05) ### Features - Add restore_discoveries to fix missing devices ([#76](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/76), [`f4432ac`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/f4432ac086abc0e847ac12818fa22cfaa04a3521)) ## v2.9.0 (2022-12-03) ### Features - Add function to clear the cache ([#75](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/75), [`6ca6011`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/6ca601104cd13cefa9c2d6db05cdc019aaf18329)) ## v2.8.9 (2022-12-02) ### Bug Fixes - Always log the connection attempt number ([#74](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/74), [`3306053`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/3306053a3903efa565355e2331b31db739bac094)) ## v2.8.8 (2022-12-02) ### Bug Fixes - Avoid logging connecting and connected since our BLEDevice may be stale ([#72](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/72), [`10e040c`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/10e040c9eb563d31b3e0caf41ee390234e239c4f)) ### Chores - Add py311 to the CI ([#73](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/73), [`8bbe3f2`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/8bbe3f24772efd720f28135f376171a37c2897d9)) ## v2.8.7 (2022-12-02) ### Bug Fixes - Enable service cache by default since esp32s are unreliable without it ([#71](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/71), [`0e90c1c`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/0e90c1c79fac01e5e0a39c51b733616d1d324aeb)) ## v2.8.6 (2022-11-30) ### Bug Fixes - Stop trying to check dbus once the socket is missing ([#70](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/70), [`74bd63b`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/74bd63b5b5c68eca6e7f0fa4e932a3ebab26a59e)) ## v2.8.5 (2022-11-19) ### Bug Fixes - Teach the connector about more esp32 errors and times ([#68](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/68), [`09cb73d`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/09cb73df4d6908665220df74f91aee4d200f6bad)) ## v2.8.4 (2022-11-11) ### Bug Fixes - Increase backoff when local ble adapter runs out of connection slots ([#67](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/67), [`cac7e57`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/cac7e57fbb13ee7beaa1eb18d51def661bc92ee3)) ## v2.8.3 (2022-11-06) ### Bug Fixes - Adjust connect timeout to match macos write timeout ([#66](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/66), [`1396fdc`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/1396fdc0b3235cf67ae919bf1c2a308d4437d023)) ## v2.8.2 (2022-11-01) ### Bug Fixes - Adjust backoffs for slower esp32 proxies ([#64](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/64), [`702a829`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/702a82921ad30fb4934b5056271cea842a758c08)) ## v2.8.1 (2022-10-31) ### Bug Fixes - Reduce logging as timeouts are expected ([#63](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/63), [`8b91838`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/8b918380d4772544e4471456df459f5d6d457a61)) ## v2.8.0 (2022-10-31) ### Features - Mark ESP_GATT_ERROR as a transient error ([#62](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/62), [`6d76ac4`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/6d76ac433c0d4727c12c4f7b4de0b039e7bbc4c2)) ## v2.7.0 (2022-10-30) ### Features - Log the adapter when connecting ([#61](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/61), [`ab873c8`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/ab873c83da6dd37cd4da3e4e61c3f6fc1ffa0c9f)) ## v2.6.0 (2022-10-30) ### Features - Teach the connector about transient esp32 errors ([#60](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/60), [`486fbbc`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/486fbbc13b9665fcdacf79f7240240602c8f477a)) ## v2.5.0 (2022-10-29) ### Features - Increase timeouts now that bleak has resolved the timeout with service discovery and bluez ([#59](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/59), [`2a65e27`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/2a65e276ffe3ab598eb8f6eb3cf3bcf7a5269780)) ## v2.4.2 (2022-10-24) ### Bug Fixes - Missing backoff execution with esp32 ([#58](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/58), [`3229424`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/3229424cae6dc7a9052efe080e110327eaa60f4d)) ## v2.4.1 (2022-10-24) ### Bug Fixes - Ensure we back off for longer when out of slots ([#57](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/57), [`efeced3`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/efeced3fa36fad7d0659e3ed30a7a150370dc923)) ## v2.4.0 (2022-10-24) ### Features - Improve handling of out of esp32 proxy connection slots ([#56](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/56), [`982b7ae`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/982b7ae1cc12d50a899329466fd4b760aaaec5ca)) ## v2.3.2 (2022-10-22) ### Bug Fixes - Ensure client is returned when debug is off ([#55](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/55), [`7ddcac8`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/7ddcac8f14126817cb5df4e7773739c6656dcd24)) ## v2.3.1 (2022-10-18) ### Bug Fixes - Do not attempt to disconnect non-bluez bledevices ([#54](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/54), [`54b6c84`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/54b6c8446629a216eeaf570f1677b67b38b6f081)) ## v2.3.0 (2022-10-15) ### Features - Add a retry_bluetooth_connection_error decorator ([#53](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/53), [`8bb706d`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/8bb706d09fa2cdaa3e2a3caf830dc92b26add4cc)) ## v2.2.0 (2022-10-15) ### Features - Update for new bleak 19 ([#52](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/52), [`9baafa5`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/9baafa5cbffa9fcd8ee8bd3040014c0d06a2085c)) ## v2.1.3 (2022-09-26) ### Bug Fixes - Bump dbus-fast ([#51](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/51), [`68167a3`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/68167a3eee222c3b0c241616c302aacacb8a3cdd)) ## v2.1.2 (2022-09-26) ### Bug Fixes - Adjust stale comment in freshen_ble_device ([#50](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/50), [`6cabc1f`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/6cabc1f557629c7591cb1cef482ae5d9791349b5)) ## v2.1.1 (2022-09-26) ### Bug Fixes - Set disconnected_callback in the constructor for newer bleak compat ([#49](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/49), [`e2e25b3`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/e2e25b3d6077aac7c766ecb844b0b492e66efff1)) ## v2.1.0 (2022-09-26) ### Features - Add get_device_by_adapter api ([#48](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/48), [`238b1f0`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/238b1f09b07e4e65dbf79472adbe9f7932f553fc)) ## v2.0.2 (2022-09-25) ### Bug Fixes - Republish to fix python-semantic-release detecting the wrong version ([#47](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/47), [`65f3cf2`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/65f3cf23dc2eab0f666dd45bd7b82058d32e2ba2)) - Republish to fix semantic-release detecting the wrong version ([#46](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/46), [`0338653`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/03386533e1d7fcb0c6d7ec0c34c085a356f6ecd0)) ### Features - Updates for bleak 0.18.0 ([#45](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/45), [`37b8729`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/37b872972f47216aa73a6e5b52ea7f9d5c116910)) BREAKING CHANGE: remove support for bleak < 0.18.0 ### Breaking Changes - Remove support for bleak < 0.18.0 ## v1.17.3 (2022-09-24) ### Bug Fixes - Log message when freshen fails ([#44](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/44), [`8365937`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/83659374d661c67b81ba204bda9d0f8bf886adf1)) ## v1.17.2 (2022-09-23) ### Bug Fixes - Add a guard to freshen_ble_device so it can be called on non-linux ([#43](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/43), [`4558a67`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/4558a67d4a291f6986dbc7d80fc1cfb2c9f4b4da)) ## v1.17.1 (2022-09-15) ### Bug Fixes - Adjust backoff times to reduce race risk ([#40](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/40), [`786b442`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/786b442c5e0102b6693cf98f86770a6ad80e4157)) ## v1.17.0 (2022-09-15) ### Features - Provide a BLEAK_RETRY_EXCEPTIONS constant ([#39](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/39), [`55dc2e1`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/55dc2e141d9059ed544ab4f0d333d09c97f6fab0)) ## v1.16.0 (2022-09-14) ### Features - Do not disconnect unexpectedly connected devices if bleak supports reusing them ([#35](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/35), [`be603ce`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/be603ce379f6a46ee750e7c3bcbd79d533d2a3ff)) Bleak 0.17 supports connecting to devices that are already connected in BlueZ. We now detect this and adjust the BLEDevice to point to the already connected device so they do not have to wait for a connection. This also fixes a race where the connection times out but the connection is actually made on the bus but we think it failed because we hit the timeout, so the next attempt will instead sail right though and be connected. ## v1.15.1 (2022-09-13) ### Bug Fixes - Revert requirement for newer bleak ([#34](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/34), [`fe7ec26`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/fe7ec26a7479855e6c37dd4f9b5ac86c93d8d1b8)) ## v1.15.0 (2022-09-12) ### Features - Bleak 0.17 support ([#33](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/33), [`ffce2c5`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/ffce2c51d3acddfa1efa9e2a396956521a768dd1)) ## v1.14.0 (2022-09-11) ### Features - Implement a smarter backoff ([#32](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/32), [`8272daa`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/8272daa12d3553001b53f637407a720ab209a57f)) ## v1.13.2 (2022-09-11) ### Bug Fixes - Race during disconnect when unexpectedly connected ([#30](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/30), [`2ceef9f`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/2ceef9f49cedb3f2721f5d969b4117fe2cb2de7c)) ## v1.13.1 (2022-09-11) ### Bug Fixes - Disconnect unexpectedly connected devices on other adapters ([#29](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/29), [`85a3efe`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/85a3efe1589dc48eb009da7c3aaa69d7decfe26d)) ## v1.13.0 (2022-09-10) ### Features - Make get_device and close_stale_connections part of __all__ ([#27](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/27), [`4d7edfd`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/4d7edfd2f2597e8cba96c925a1e7f4ae55986623)) ## v1.12.3 (2022-09-10) ### Bug Fixes - Disconnect devices that are unexpectedly connected before connecting ([#26](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/26), [`47b31d3`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/47b31d38b481288472a6923d968a9c4dd6f2b1c6)) ## v1.12.2 (2022-09-10) ### Bug Fixes - Handle already connected devices with no rssi value ([#25](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/25), [`0dfd3b0`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/0dfd3b07ae6836a61d31c613534e3322dabd3761)) ## v1.12.1 (2022-09-10) ### Bug Fixes - Get_device returning no device when already connected ([#24](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/24), [`1063b76`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/1063b764959cabfdab572de85d1ad622e6ff7a20)) ## v1.12.0 (2022-09-10) ### Features - Add get_device helper to find already connected devices ([#23](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/23), [`595e6a0`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/595e6a09c06a29a55000e6b582db12b85884f75a)) ## v1.11.1 (2022-09-10) ### Bug Fixes - Handle Dbus EOFError while connecting ([#22](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/22), [`b0bc92d`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/b0bc92d00b77f570836fa59f4f88403152f78539)) ## v1.11.0 (2022-08-20) ### Features - Handle stale BLEDevices when an adapter goes offline ([#21](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/21), [`012c94c`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/012c94c17e81511f84764037a48be1ba686453b3)) ## v1.10.1 (2022-08-19) ### Bug Fixes - Add workaround for when get_services raises ([#20](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/20), [`1c92f6e`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/1c92f6ed3b643f8f739e7a27b56111cd71e23696)) ## v1.10.0 (2022-08-19) ### Features - Log path to the device ([#19](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/19), [`6a9f293`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/6a9f2930e06ee393f0b3885c3d845d485c18babd)) ## v1.9.0 (2022-08-19) ### Features - Add ble_device_callback to get a new BLEDevice between connect attempts ([#18](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/18), [`450268b`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/450268b8498f730576957f0dbb1cbe0dedbdf14a)) ## v1.8.0 (2022-08-15) ### Features - Add last known rssi to the debug log ([#17](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/17), [`1032317`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/10323172a1faacca01e6bfb690e92b9e5fb1bd80)) ## v1.7.2 (2022-08-12) ### Bug Fixes - Handle device going in and out of range frequently ([#16](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/16), [`89b8c1b`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/89b8c1ba63151043d0d6977b0c4a173cb616a9a5)) ## v1.7.1 (2022-08-12) ### Bug Fixes - Race during disconnect error ([#14](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/14), [`dccbbb1`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/dccbbb1e34028dbd3e3b155502bb70d1ffaa11a8)) ## v1.7.0 (2022-08-11) ### Features - Add ble_device_has_changed helper ([#13](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/13), [`0a23bb8`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/0a23bb8bbd2c8d0fafc20f3d2da36415ed4759be)) ## v1.6.0 (2022-08-11) ### Features - Cached services ([#11](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/11), [`1fe23d6`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/1fe23d6397a7ac2b5994778a9ddc06e687de5ba3)) ## v1.5.0 (2022-08-08) ### Features - Rethrow UnknownObject as BleakNotFoundError ([#12](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/12), [`a07c50e`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/a07c50e8a910aadc6cec806c0e8888a00def97f6)) ## v1.4.0 (2022-08-05) ### Features - Improve error reporting when there is a poor connection ([#10](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/10), [`d022777`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/d0227773ff01b4d665fd7bd3e94a330d61214f88)) ## v1.3.0 (2022-08-04) ### Features - Improve chance of connecting with poor signal ([#9](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/9), [`f0322e7`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/f0322e73d450eaf0d088f0dd26a934f3fff40907)) ## v1.2.0 (2022-08-03) ### Features - Handle BrokenPipeError from dbus-next via bleak ([#8](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/8), [`21da55d`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/21da55dcc37754bcbf904c6ab8162cd4f091e2c4)) ## v1.1.1 (2022-08-02) ### Bug Fixes - Add back the bleak overall safety timeout ([#7](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/7), [`f3f8ded`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/f3f8ded4082bb155d2626a3ec3c693b11bbc355b)) ## v1.1.0 (2022-07-24) ### Features - Pass additional kwargs to the client class ([#6](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/6), [`808e05b`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/808e05bc2d831307f3e093a9d9d42a2409a0a681)) ## v1.0.2 (2022-07-22) ### Bug Fixes - Push a new release now that pypi is working again ([#5](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/5), [`3480e22`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/3480e225e8567e6b4a75d166c6a5b3e4661ebb46)) ## v1.0.1 (2022-07-22) ### Bug Fixes - Add comments ([#4](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/4), [`4bc5563`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/4bc5563c23bc8cdb9ae44ede0d2ea86693968610)) ## v1.0.0 (2022-07-22) ## v0.1.1 (2022-07-22) ### Bug Fixes - Republish ([#3](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/3), [`2b1a504`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/2b1a5042f2250db16d655b1f18a24e74f82f77d2)) ## v0.1.0 (2022-07-22) ### Features - First release ([#2](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/2), [`f11f9b5`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/f11f9b5ea1a998bfbd407ffaff299d40243e4e0a)) ## v0.0.1 (2022-07-22) ### Chores - Initial commit ([`7128f20`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/7128f2035025491075f84ca9f9b86306291dd1da)) ### Features - Init repo ([#1](https://github.com/Bluetooth-Devices/bleak-retry-connector/pull/1), [`ea99576`](https://github.com/Bluetooth-Devices/bleak-retry-connector/commit/ea99576e4ef2ae10ecfbcd067256d8476d1bf8de)) Bluetooth-Devices-bleak-retry-connector-2326a9d/CONTRIBUTING.md000066400000000000000000000075021520367436400240510ustar00rootroot00000000000000# Contributing Contributions are welcome, and they are greatly appreciated! Every little helps, and credit will always be given. You can contribute in many ways: ## Types of Contributions ### Report Bugs Report bugs to [our issue page][gh-issues]. If you are reporting a bug, please include: - Your operating system name and version. - Any details about your local setup that might be helpful in troubleshooting. - Detailed steps to reproduce the bug. ### Fix Bugs Look through the GitHub issues for bugs. Anything tagged with "bug" and "help wanted" is open to whoever wants to implement it. ### Implement Features Look through the GitHub issues for features. Anything tagged with "enhancement" and "help wanted" is open to whoever wants to implement it. ### Write Documentation Bleak Retry Connector could always use more documentation, whether as part of the official Bleak Retry Connector docs, in docstrings, or even on the web in blog posts, articles, and such. ### Submit Feedback The best way to send feedback [our issue page][gh-issues] on GitHub. If you are proposing a feature: - Explain in detail how it would work. - Keep the scope as narrow as possible, to make it easier to implement. - Remember that this is a volunteer-driven project, and that contributions are welcome 😊 ## Get Started! Ready to contribute? Here's how to set yourself up for local development. 1. Fork the repo on GitHub. 2. Clone your fork locally: ```shell $ git clone git@github.com:your_name_here/bleak-retry-connector.git ``` 3. Install the project dependencies with [Poetry](https://python-poetry.org): ```shell $ poetry install ``` 4. Create a branch for local development: ```shell $ git checkout -b name-of-your-bugfix-or-feature ``` Now you can make your changes locally. 5. When you're done making changes, check that your changes pass our tests: ```shell $ poetry run pytest ``` 6. Linting is done through [pre-commit](https://pre-commit.com). Provided you have the tool installed globally, you can run them all as one-off: ```shell $ pre-commit run -a ``` Or better, install the hooks once and have them run automatically each time you commit: ```shell $ pre-commit install ``` 7. Commit your changes and push your branch to GitHub: ```shell $ git add . $ git commit -m "feat(something): your detailed description of your changes" $ git push origin name-of-your-bugfix-or-feature ``` Note: the commit message should follow [the conventional commits](https://www.conventionalcommits.org). We run [`commitlint` on CI](https://github.com/marketplace/actions/commit-linter) to validate it, and if you've installed pre-commit hooks at the previous step, the message will be checked at commit time. 8. Submit a pull request through the GitHub website or using the GitHub CLI (if you have it installed): ```shell $ gh pr create --fill ``` ## Pull Request Guidelines We like to have the pull request open as soon as possible, that's a great place to discuss any piece of work, even unfinished. You can use draft pull request if it's still a work in progress. Here are a few guidelines to follow: 1. Include tests for feature or bug fixes. 2. Update the documentation for significant features. 3. Ensure tests are passing on CI. ## Tips To run a subset of tests: ```shell $ pytest tests ``` ## Making a new release The deployment should be automated and can be triggered from the Semantic Release workflow in GitHub. The next version will be based on [the commit logs](https://python-semantic-release.readthedocs.io/en/latest/commit-log-parsing.html#commit-log-parsing). This is done by [python-semantic-release](https://python-semantic-release.readthedocs.io/en/latest/index.html) via a GitHub action. [gh-issues]: https://github.com/bluetooth-devices/bleak-retry-connector/issues Bluetooth-Devices-bleak-retry-connector-2326a9d/LICENSE000066400000000000000000000020601520367436400226170ustar00rootroot00000000000000 MIT License Copyright (c) 2022 J. Nick Koston Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. Bluetooth-Devices-bleak-retry-connector-2326a9d/README.md000066400000000000000000000133601520367436400230760ustar00rootroot00000000000000# Bleak Retry Connector

CI Status Documentation Status Test coverage percentage

Poetry ruff pre-commit

PyPI Version Supported Python versions License

A connector for Bleak Clients that handles transient connection failures ## Installation Install this via pip (or your favourite package manager): `pip install bleak-retry-connector` ## Usage ### Quick Start Replace your direct `BleakClient.connect()` calls with `establish_connection()` for automatic retry logic and better error handling: ```python import asyncio from bleak import BleakScanner from bleak_retry_connector import establish_connection, BleakClientWithServiceCache async def connect_to_device(): # Find your device device = await BleakScanner.find_device_by_address("AA:BB:CC:DD:EE:FF") if device is None: print("Device not found!") return # Establish connection with automatic retry logic client = await establish_connection( BleakClientWithServiceCache, # Use BleakClientWithServiceCache for service caching device, device.name or "Unknown Device", max_attempts=3 # Will retry up to 3 times with backoff ) try: # Use the connected client normally services = client.services print(f"Connected! Found {len(list(services))} services") # Read a characteristic value = await client.read_gatt_char("00002a00-0000-1000-8000-00805f9b34fb") print(f"Read value: {value}") finally: await client.disconnect() # Run the example asyncio.run(connect_to_device()) ``` ### Why Use bleak-retry-connector? - **Automatic Retry Logic**: Handles transient connection failures automatically - **Intelligent Backoff**: Uses appropriate delays between retry attempts - **Service Caching**: `BleakClientWithServiceCache` caches services for faster reconnections - **Better Error Messages**: Provides clear, actionable error messages - **Platform-Specific Handling**: Manages quirks across different operating systems - **Connection Slot Management**: Handles limited connection slots on some devices ### Common Connection Issues This Solves - Device not found errors that resolve on retry - Connection timeouts on first attempt - "Out of connection slots" errors on ESP32 devices - Interference from other Bluetooth operations - Platform-specific connection quirks For detailed documentation and advanced usage, see the [full documentation](https://bleak-retry-connector.readthedocs.io/en/latest/usage.html). ## Contributors ✨ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! ## Credits This package was created with [Cookiecutter](https://github.com/audreyr/cookiecutter) and the [browniebroke/cookiecutter-pypackage](https://github.com/browniebroke/cookiecutter-pypackage) project template. Bluetooth-Devices-bleak-retry-connector-2326a9d/commitlint.config.mjs000066400000000000000000000003621520367436400257530ustar00rootroot00000000000000export default { extends: ["@commitlint/config-conventional"], rules: { "header-max-length": [0, "always", Infinity], "body-max-line-length": [0, "always", Infinity], "footer-max-line-length": [0, "always", Infinity], }, }; Bluetooth-Devices-bleak-retry-connector-2326a9d/docs/000077500000000000000000000000001520367436400225445ustar00rootroot00000000000000Bluetooth-Devices-bleak-retry-connector-2326a9d/docs/Makefile000066400000000000000000000011751520367436400242100ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = source BUILDDIR = build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile $(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) Bluetooth-Devices-bleak-retry-connector-2326a9d/docs/make.bat000066400000000000000000000013741520367436400241560ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=source set BUILDDIR=build if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd Bluetooth-Devices-bleak-retry-connector-2326a9d/docs/requirements.txt000066400000000000000000000002771520367436400260360ustar00rootroot00000000000000# Documentation dependencies # This file is used by ReadTheDocs to install dependencies # These match the poetry docs group dependencies myst-parser>=4.0.1 sphinx-rtd-theme>=3.0.2 sphinx>=8 Bluetooth-Devices-bleak-retry-connector-2326a9d/docs/source/000077500000000000000000000000001520367436400240445ustar00rootroot00000000000000Bluetooth-Devices-bleak-retry-connector-2326a9d/docs/source/_static/000077500000000000000000000000001520367436400254725ustar00rootroot00000000000000Bluetooth-Devices-bleak-retry-connector-2326a9d/docs/source/_static/.gitkeep000066400000000000000000000000001520367436400271110ustar00rootroot00000000000000Bluetooth-Devices-bleak-retry-connector-2326a9d/docs/source/changelog.md000066400000000000000000000000451520367436400263140ustar00rootroot00000000000000```{include} ../../CHANGELOG.md ``` Bluetooth-Devices-bleak-retry-connector-2326a9d/docs/source/conf.py000066400000000000000000000036571520367436400253560ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # # import os # import sys # sys.path.insert(0, os.path.abspath('.')) from typing import Any # -- Project information ----------------------------------------------------- project = "Bleak Retry Connector" copyright = "2020, J. Nick Koston" author = "J. Nick Koston" # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ "myst_parser", ] # The suffix of source filenames. source_suffix = [".rst", ".md"] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns: list[Any] = [] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = "sphinx_rtd_theme" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] Bluetooth-Devices-bleak-retry-connector-2326a9d/docs/source/contributing.md000066400000000000000000000000501520367436400270700ustar00rootroot00000000000000```{include} ../../CONTRIBUTING.md ``` Bluetooth-Devices-bleak-retry-connector-2326a9d/docs/source/index.md000066400000000000000000000003651520367436400255010ustar00rootroot00000000000000# Welcome to Bleak Retry Connector documentation! ```{toctree} :caption: Installation & Usage :maxdepth: 2 installation usage ``` ```{toctree} :caption: Project Info :maxdepth: 2 changelog contributing ``` ```{include} ../../README.md ``` Bluetooth-Devices-bleak-retry-connector-2326a9d/docs/source/installation.md000066400000000000000000000003001520367436400270600ustar00rootroot00000000000000# Installation The package is published on [PyPI](https://pypi.org/project/deezer-python/) and can be installed with `pip` (or any equivalent): ```bash pip install bleak-retry-connector ``` Bluetooth-Devices-bleak-retry-connector-2326a9d/docs/source/usage.md000066400000000000000000000565011520367436400255010ustar00rootroot00000000000000# Usage Guide ## Why Use bleak-retry-connector? This package provides robust retry logic and intelligent backoff strategies for establishing BLE connections. Key benefits include: - **Automatic retry with backoff** - Handles transient connection failures with intelligent retry timing - **Connection slot management** - Critical for ESPHome Bluetooth proxies that have limited connection slots - **Service caching** - Speeds up reconnections by caching GATT services - **Platform-specific optimizations** - Special handling for Linux/BlueZ, macOS, and ESP32 devices - **Error categorization** - Distinguishes between transient errors, missing devices, and out-of-slots conditions ### Essential for ESPHome Bluetooth Proxies If you're using ESPHome Bluetooth proxies, this package is **critical** because: 1. **Proper slot management** - ESP32 devices have limited connection slots that must be carefully managed 2. **Handles ESP-specific errors** - Recognizes ESP32 error codes like `ESP_GATT_CONN_CONN_CANCEL` (out of slots) 3. **Appropriate backoff timing** - Uses longer backoff (4 seconds) when slots are exhausted to allow proper cleanup 4. **Prevents slot exhaustion** - Manages connection attempts to avoid overwhelming the proxy ## BleakClientWithServiceCache `BleakClientWithServiceCache` is a subclass of `BleakClient` that provides service caching capabilities for faster reconnections. ### Basic Usage ```python from bleak_retry_connector import BleakClientWithServiceCache from bleak.backends.device import BLEDevice async def connect_with_cache(device: BLEDevice): client = BleakClientWithServiceCache(device) await client.connect() # Use the client normally services = client.services # Clear cache if needed (e.g., after service changes) await client.clear_cache() await client.disconnect() ``` ### Key Features - **Automatic service caching**: Services are cached between connections for faster reconnections - **Cache clearing**: Call `clear_cache()` to force a fresh service discovery - **Connection parameter tuning**: Call `set_connection_params()` to adjust BLE connection intervals - **Drop-in replacement**: Can be used anywhere `BleakClient` is used ### Extension Methods `BleakClientWithServiceCache` provides extension methods that are forwarded to the underlying backend (e.g., habluetooth). These methods allow integrations to control BLE behavior beyond what standard bleak provides. #### clear_cache ```python async def clear_cache(self) -> bool ``` Clears the cached GATT services, forcing a fresh service discovery on the next access. Useful when a device's firmware has been updated or services have changed. Returns `True` if the cache was successfully cleared, `False` otherwise. ```python client = await establish_connection( BleakClientWithServiceCache, device, name="MyDevice" ) # If characteristics are missing, clear cache and reconnect await client.clear_cache() await client.disconnect() ``` #### set_connection_params ```python async def set_connection_params( self, min_interval: int, max_interval: int, latency: int, timeout: int, ) -> None ``` Sets BLE connection parameters on a connected device. This is useful for "Always Connected" devices where battery conservation is important — switching from fast intervals (~7.5ms) to slow intervals (e.g., 1000ms) after the initial data sync can significantly reduce power consumption. Parameters are in BLE units: - **min_interval** / **max_interval**: Connection interval in units of 1.25ms (e.g., 800 = 1000ms) - **latency**: Number of connection events the peripheral can skip (typically 0) - **timeout**: Supervision timeout in units of 10ms (e.g., 600 = 6000ms) ```python client = await establish_connection( BleakClientWithServiceCache, device, name="MyDevice" ) # After initial sync, switch to slow intervals to save battery await client.set_connection_params( min_interval=800, # 1000ms max_interval=800, # 1000ms latency=0, timeout=600, # 6000ms ) ``` The method delegates to the backend (habluetooth), which routes to either: - **ESPHome proxy**: Sends a protobuf message to the ESP32 to call `esp_ble_gap_update_conn_params()` - **Local BlueZ adapter**: Uses the MGMT API (`MGMT_OP_LOAD_CONN_PARAM`) ## establish_connection `establish_connection` is the main function for establishing robust BLE connections with automatic retry logic. ### Function Signature ```python async def establish_connection( client_class: type[BleakClient], device: BLEDevice, name: str, disconnected_callback: Callable[[BleakClient], None] | None = None, max_attempts: int = 4, cached_services: BleakGATTServiceCollection | None = None, ble_device_callback: Callable[[], BLEDevice] | None = None, use_services_cache: bool = True, pair: bool = False, **kwargs: Any ) -> BleakClient ``` ### Parameters - **client_class**: The BleakClient class to use (typically `BleakClientWithServiceCache`) - **device**: The BLE device to connect to - **name**: A descriptive name for the device (used in logging) - **disconnected_callback**: Optional callback when device disconnects unexpectedly - **max_attempts**: Maximum connection attempts before giving up (default: 4) - **cached_services**: Pre-cached services to use (deprecated, use `use_services_cache`) - **ble_device_callback**: Callback to get updated device info if it changes - **use_services_cache**: Whether to use service caching (default: True) - **pair**: Whether to pair with the device on connect (default: False) - **kwargs**: Additional arguments passed to the client class constructor ### Return Value Returns the connected client instance of the specified `client_class`. ### Exceptions `establish_connection` can raise the following exceptions after exhausting retry attempts: - **BleakNotFoundError**: Device was not found or disappeared - Raised when the device cannot be found - Raised on `asyncio.TimeoutError` after all retries - Raised when `BleakDeviceNotFoundError` occurs - Raised when device is missing from the adapter - **BleakOutOfConnectionSlotsError**: Adapter/proxy has no available connection slots - Raised when local Bluetooth adapters or ESP32 proxies are out of connection slots - Common with errors containing "ESP_GATT_CONN_CONN_CANCEL", "connection slot", or "available connection" - For local adapters: disconnect unused devices or use a different adapter - For ESP32 proxies: add more proxies or disconnect other devices - **BleakAbortedError**: Connection was aborted due to interference or range issues - Raised for transient connection failures that suggest environmental issues - Common with errors like "le-connection-abort-by-local", "br-connection-canceled" - Indicates interference, range problems, or USB 3.0 port interference - **BleakConnectionError**: General connection failure after all retries - Raised for any other connection errors that don't fit the above categories - The fallback exception when connection cannot be established ### Basic Example ```python from bleak_retry_connector import establish_connection, BleakClientWithServiceCache from bleak.backends.device import BLEDevice async def connect_to_device(device: BLEDevice): # Simple connection with retry client = await establish_connection( BleakClientWithServiceCache, device, name=device.name or device.address ) # Use the client services = client.services # Disconnect when done await client.disconnect() return client ``` ### Example with Disconnection Callback ```python async def connect_with_callback(device: BLEDevice): def on_disconnect(client): print(f"Device {device.address} disconnected unexpectedly") client = await establish_connection( BleakClientWithServiceCache, device, name=device.name or device.address, disconnected_callback=on_disconnect, max_attempts=5 # Try up to 5 times ) return client ``` ### Example with Device Callback Use a device callback when the device information might change (e.g., path changes on Linux): ```python class DeviceTracker: def __init__(self, initial_device: BLEDevice): self.device = initial_device def get_device(self) -> BLEDevice: return self.device def update_device(self, new_device: BLEDevice): self.device = new_device async def connect_with_device_tracking(tracker: DeviceTracker): client = await establish_connection( BleakClientWithServiceCache, tracker.device, name="TrackedDevice", ble_device_callback=tracker.get_device ) return client ``` ### Example with Custom Client Class ```python from bleak import BleakClient class CustomClient(BleakClient): async def custom_method(self): # Custom functionality pass async def connect_with_custom_client(device: BLEDevice): client = await establish_connection( CustomClient, device, name=device.name, max_attempts=3 ) # Use custom methods await client.custom_method() return client ``` ### Error Handling Example ```python from bleak_retry_connector import ( establish_connection, BleakClientWithServiceCache, BleakNotFoundError, BleakOutOfConnectionSlotsError, BleakAbortedError, BleakConnectionError ) async def connect_with_error_handling(device: BLEDevice): try: client = await establish_connection( BleakClientWithServiceCache, device, name=device.name ) return client except BleakNotFoundError: print("Device not found - it may have moved out of range") return None except BleakOutOfConnectionSlotsError: print("No connection slots available - try disconnecting other devices") return None except BleakAbortedError: print("Connection aborted - check for interference or move closer") return None except BleakConnectionError as e: print(f"Connection failed: {e}") return None ``` ### Example with Cache Clearing on Missing Characteristic When a device's firmware changes or services are updated, you might encounter missing characteristics. Here's how to handle this scenario by clearing the cache and retrying: ```python from bleak_retry_connector import establish_connection, BleakClientWithServiceCache from bleak.exc import BleakError class CharacteristicMissingError(Exception): """Raised when a required characteristic is missing.""" pass async def connect_and_validate_services(device: BLEDevice): """Connect and validate required characteristics exist.""" client = await establish_connection( BleakClientWithServiceCache, device, name=device.name or device.address, use_services_cache=True ) try: # Check for required characteristics required_service_uuid = "cba20d00-224d-11e6-9fb8-0002a5d5c51b" required_char_uuid = "cba20002-224d-11e6-9fb8-0002a5d5c51b" service = client.services.get_service(required_service_uuid) if not service: raise CharacteristicMissingError(f"Service {required_service_uuid} not found") char = service.get_characteristic(required_char_uuid) if not char: raise CharacteristicMissingError(f"Characteristic {required_char_uuid} not found") except (CharacteristicMissingError, KeyError) as ex: # Services might have changed, clear cache and reconnect print(f"Characteristic missing, clearing cache: {ex}") await client.clear_cache() await client.disconnect() # Reconnect without cache client = await establish_connection( BleakClientWithServiceCache, device, name=device.name or device.address, use_services_cache=False # Force fresh service discovery ) # Validate again service = client.services.get_service(required_service_uuid) if not service: await client.disconnect() raise CharacteristicMissingError(f"Service {required_service_uuid} still not found after cache clear") char = service.get_characteristic(required_char_uuid) if not char: await client.disconnect() raise CharacteristicMissingError(f"Characteristic {required_char_uuid} still not found after cache clear") return client ``` ### Advanced Configuration ```python async def connect_with_full_options(device: BLEDevice): client = await establish_connection( BleakClientWithServiceCache, device, name="MyDevice", disconnected_callback=lambda c: print("Disconnected"), max_attempts=6, # More attempts for difficult devices use_services_cache=True, # Use caching for faster reconnects timeout=30.0 # Pass additional kwargs to BleakClient ) return client ``` ## Complete Working Example ```python import asyncio from bleak import BleakScanner from bleak_retry_connector import ( establish_connection, BleakClientWithServiceCache, BleakNotFoundError, BleakOutOfConnectionSlotsError, BleakAbortedError, BleakConnectionError ) async def main(): # Scan for devices print("Scanning for devices...") devices = await BleakScanner.discover() if not devices: print("No devices found") return # Connect to the first device found device = devices[0] print(f"Connecting to {device.name or device.address}...") try: # Establish connection with retry client = await establish_connection( BleakClientWithServiceCache, device, name=device.name or device.address, max_attempts=4 ) print("Connected successfully!") # List services for service in client.services: print(f" Service: {service.uuid}") for char in service.characteristics: print(f" Characteristic: {char.uuid}") # Disconnect await client.disconnect() print("Disconnected") except (BleakNotFoundError, BleakOutOfConnectionSlotsError, BleakAbortedError, BleakConnectionError) as e: print(f"Failed to connect: {e}") if __name__ == "__main__": asyncio.run(main()) ``` ## retry_bluetooth_connection_error A decorator that wraps an async function and retries it on transient Bleak errors. Useful for short GATT operations (reads, writes, notifications) that can be disconnected mid-flight by the device. ### Function Signature ```python def retry_bluetooth_connection_error( attempts: int = 2, ) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]] ``` ### Parameters - **attempts**: Number of times to attempt the wrapped call before re-raising the underlying error (default: 2). The decorator catches the same `BLEAK_EXCEPTIONS` group used internally by `establish_connection` and backs off with `calculate_backoff_time()` between attempts. After the final attempt fails, the original exception propagates. ### Example ```python from bleak_retry_connector import ( establish_connection, BleakClientWithServiceCache, retry_bluetooth_connection_error, ) @retry_bluetooth_connection_error(attempts=3) async def read_battery(client: BleakClientWithServiceCache) -> int: data = await client.read_gatt_char("00002a19-0000-1000-8000-00805f9b34fb") return data[0] async def main(device): client = await establish_connection( BleakClientWithServiceCache, device, name=device.name ) try: level = await read_battery(client) print(f"Battery: {level}%") finally: await client.disconnect() ``` ## close_stale_connections On Linux/BlueZ, BlueZ may report a device as connected even when another adapter or a crashed process owns the connection. `close_stale_connections` disconnects those existing connections so a fresh `establish_connection` attempt can proceed. Two variants are exported: ```python async def close_stale_connections( device: BLEDevice, only_other_adapters: bool = False ) -> None async def close_stale_connections_by_address( address: str, only_other_adapters: bool = False ) -> None ``` - **device** / **address**: The target device or its MAC address. - **only_other_adapters**: If `True`, only disconnect instances on adapters different from the one the supplied `device` is on. Useful when you want to keep your own active connection alive while clearing duplicates that appeared on another adapter. Both functions are no-ops on non-Linux platforms. ### Example ```python from bleak_retry_connector import ( close_stale_connections_by_address, establish_connection, BleakClientWithServiceCache, ) # Before reconnecting after a service restart, clear stale BlueZ state: await close_stale_connections_by_address("AA:BB:CC:DD:EE:FF") client = await establish_connection( BleakClientWithServiceCache, device, name=device.name ) ``` ## clear_cache Removes a device from BlueZ via the `RemoveDevice` D-Bus method. This clears cached GATT services and any stale `Connected=True` state BlueZ may be holding for the address. ```python async def clear_cache(address: str) -> bool ``` - **address**: The MAC address of the device to remove. - **Returns**: `True` if the device was removed, `False` otherwise (including on non-Linux platforms). `clear_cache()` is safe to call unconditionally — it suppresses all errors internally and returns `False` rather than raising. There is also an instance method `BleakClientWithServiceCache.clear_cache()` (documented above) which clears only the bleak-level service cache for an already-connected client; the module-level `clear_cache(address)` operates on BlueZ directly and does not require a client. ### Example ```python from bleak_retry_connector import clear_cache # After a firmware update, force BlueZ to forget cached services: await clear_cache("AA:BB:CC:DD:EE:FF") ``` ## restore_discoveries On Linux/BlueZ, advertisement data tracked by BlueZ can be lost when a scanner is recreated. `restore_discoveries` re-seeds a freshly created `BleakScanner` with the devices BlueZ already knows about, so callers don't have to wait for the next advertisement to see existing devices. ```python async def restore_discoveries(scanner: BleakScanner, adapter: str) -> None ``` - **scanner**: The newly created `BleakScanner` instance. - **adapter**: The HCI adapter name (e.g. `"hci0"`). No-op on non-Linux platforms. ## get_device / get_device_by_adapter Look up a `BLEDevice` by MAC address against BlueZ's current view of the bus. Useful when a caller has lost its `BLEDevice` handle (e.g. after a scanner restart) but still knows the address. ```python async def get_device(address: str) -> BLEDevice | None async def get_device_by_adapter(address: str, adapter: str) -> BLEDevice | None ``` - **address**: The MAC address of the device. - **adapter** (`get_device_by_adapter` only): The HCI adapter name (e.g. `"hci0"`) to restrict the lookup to a single controller. `get_device` searches every adapter and returns the device with the strongest RSSI; `get_device_by_adapter` only inspects the BlueZ object at `/org/bluez//dev_` and returns `None` if no device exists on that adapter. Both return `None` on non-Linux platforms and when BlueZ has no matching object. ```python from bleak_retry_connector import get_device, establish_connection, BleakClientWithServiceCache device = await get_device("AA:BB:CC:DD:EE:FF") if device is None: raise RuntimeError("device not currently known to BlueZ") client = await establish_connection( BleakClientWithServiceCache, device, name=device.name ) ``` ## device_source Return the `source` tag from a `BLEDevice`'s `details` mapping, or `None` if the tag is absent. The source is set by the scanner that produced the advertisement — for example, ESPHome Bluetooth proxies tag their devices with the proxy name. Native BlueZ devices typically have no source. ```python def device_source(device: BLEDevice) -> str | None ``` ```python from bleak_retry_connector import device_source if device_source(device) is None: # Local adapter device — slot management applies. ... else: # Came from an ESPHome proxy — handle ESP-specific errors. ... ``` ## ble_device_description Format a `BLEDevice` into a short, log-friendly string of the form `
- -> `. Used by `establish_connection` internally for log lines; exported so callers can produce the same format in their own diagnostics. ```python def ble_device_description(device: BLEDevice) -> str ``` The trailing `-> ...` is only appended when the device's `details` carry a BlueZ `path` (truncated to 15 characters) or a `source` tag. Devices with neither are described as `
- ` (or just `
` when the name equals the address). ## BleakSlotManager `BleakSlotManager` tracks how many BLE connection slots each local BlueZ adapter has free and which addresses currently hold a slot. It is intended for callers that orchestrate multiple connections across multiple adapters (e.g. Home Assistant) and need to make scheduling decisions before calling `establish_connection`. ```python from bleak_retry_connector import BleakSlotManager manager = BleakSlotManager() await manager.async_setup() # Tell the manager about each adapter and its slot capacity: manager.register_adapter("hci0", slots=5) manager.register_adapter("hci1", slots=5) allocations = manager.get_allocations("hci0") print(allocations.free, allocations.allocated) ``` Key methods: - **`async_setup()`** — Attach to the global BlueZ manager. Must be awaited before any other call. - **`register_adapter(adapter, slots)`** / **`remove_adapter(adapter)`** — Declare or forget an adapter and its slot capacity. On registration, devices that BlueZ already reports as connected on the adapter are pre-allocated. - **`get_allocations(adapter)`** — Return an `Allocations` dataclass describing the adapter (`slots`, `free`, list of allocated addresses). - **`release_slot(device)`** — Manually release a slot held by `device`. Normally unnecessary: the manager watches BlueZ's `Connected` property and releases automatically on disconnect. - **`register_allocation_callback(callback)`** — Subscribe to `AllocationChangeEvent`s (allocated / released). Returns an unsubscribe callable. - **`diagnostics()`** — Return a JSON-friendly snapshot for logging. `BleakSlotManager` only sees BlueZ adapters; ESPHome proxy slots are tracked by the proxy itself and reported through habluetooth. On non-Linux platforms the manager can be constructed but `async_setup()` will not find a BlueZ manager to attach to. ## Constants - **`BLEAK_RETRY_EXCEPTIONS`**: A tuple of exception classes that `establish_connection` and `retry_bluetooth_connection_error` treat as transient and retryable: `AttributeError`, `BleakError`, `EOFError`, `BrokenPipeError`, and `asyncio.TimeoutError`. Re-exported so callers layering their own retry logic on top can match the same set. - **`NO_RSSI_VALUE`** (`-127`): Sentinel value used internally when an advertisement carries no RSSI. Exported so callers ranking devices by signal strength can use the same floor. - **`RSSI_SWITCH_THRESHOLD`** (`5`): Minimum RSSI delta in dBm that `establish_connection` requires before switching to a stronger advertised path mid-retry. Exposed for callers that want to apply the same hysteresis to their own adapter-selection logic. Bluetooth-Devices-bleak-retry-connector-2326a9d/poetry.lock000066400000000000000000003713111520367436400240160ustar00rootroot00000000000000# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "aiooui" version = "0.1.9" description = "Async OUI lookups" optional = false python-versions = "<4.0,>=3.9" groups = ["main"] markers = "platform_system == \"Linux\"" files = [ {file = "aiooui-0.1.9-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:64d904b43f14dd1d8d9fcf1684d9e2f558bc5e0bd68dc10023c93355c9027907"}, {file = "aiooui-0.1.9-py3-none-any.whl", hash = "sha256:737a5e62d8726540218c2b70e5f966d9912121e4644f3d490daf8f3c18b182e5"}, {file = "aiooui-0.1.9.tar.gz", hash = "sha256:e8c8bc59ab352419e0747628b4cce7c4e04d492574c1971e223401126389c5d8"}, ] [[package]] name = "alabaster" version = "1.0.0" description = "A light, configurable Sphinx theme" optional = false python-versions = ">=3.10" groups = ["docs"] files = [ {file = "alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b"}, {file = "alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e"}, ] [[package]] name = "async-timeout" version = "5.0.1" description = "Timeout context manager for asyncio programs" optional = false python-versions = ">=3.8" groups = ["main"] markers = "python_version == \"3.10\"" files = [ {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, ] [[package]] name = "babel" version = "2.17.0" description = "Internationalization utilities" optional = false python-versions = ">=3.8" groups = ["docs"] files = [ {file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"}, {file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"}, ] [package.extras] dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] [[package]] name = "backports-asyncio-runner" version = "1.2.0" description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." optional = false python-versions = "<3.11,>=3.8" groups = ["dev"] markers = "python_version == \"3.10\"" files = [ {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, ] [[package]] name = "bleak" version = "2.1.1" description = "Bluetooth Low Energy platform Agnostic Klient" optional = false python-versions = ">=3.10" groups = ["main"] files = [ {file = "bleak-2.1.1-py3-none-any.whl", hash = "sha256:61ac1925073b580c896a92a8c404088c5e5ec9dc3c5bd6fc17554a15779d83de"}, {file = "bleak-2.1.1.tar.gz", hash = "sha256:4600cc5852f2392ce886547e127623f188e689489c5946d422172adf80635cf9"}, ] [package.dependencies] async-timeout = {version = ">=3.0.0", markers = "python_version < \"3.11\""} dbus-fast = {version = ">=1.83.0", markers = "platform_system == \"Linux\""} pyobjc-core = {version = ">=10.3", markers = "platform_system == \"Darwin\""} pyobjc-framework-CoreBluetooth = {version = ">=10.3", markers = "platform_system == \"Darwin\""} pyobjc-framework-libdispatch = {version = ">=10.3", markers = "platform_system == \"Darwin\""} typing-extensions = {version = ">=4.7.0", markers = "python_version < \"3.12\""} winrt-runtime = {version = ">=3.1", markers = "platform_system == \"Windows\""} "winrt-Windows.Devices.Bluetooth" = {version = ">=3.1", markers = "platform_system == \"Windows\""} "winrt-Windows.Devices.Bluetooth.Advertisement" = {version = ">=3.1", markers = "platform_system == \"Windows\""} "winrt-Windows.Devices.Bluetooth.GenericAttributeProfile" = {version = ">=3.1", markers = "platform_system == \"Windows\""} "winrt-Windows.Devices.Enumeration" = {version = ">=3.1", markers = "platform_system == \"Windows\""} "winrt-Windows.Devices.Radios" = {version = ">=3.1", markers = "platform_system == \"Windows\""} "winrt-Windows.Foundation" = {version = ">=3.1", markers = "platform_system == \"Windows\""} "winrt-Windows.Foundation.Collections" = {version = ">=3.1", markers = "platform_system == \"Windows\""} "winrt-Windows.Storage.Streams" = {version = ">=3.1", markers = "platform_system == \"Windows\""} [package.extras] pythonista = ["bleak-pythonista (>=0.1.1)"] [[package]] name = "blockbuster" version = "1.5.26" description = "Utility to detect blocking calls in the async event loop" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "blockbuster-1.5.26-py3-none-any.whl", hash = "sha256:f8e53fb2dd4b6c6ec2f04907ddbd063ca7cd1ef587d24448ef4e50e81e3a79bb"}, {file = "blockbuster-1.5.26.tar.gz", hash = "sha256:cc3ce8c70fa852a97ee3411155f31e4ad2665cd1c6c7d2f8bb1851dab61dc629"}, ] [package.dependencies] forbiddenfruit = {version = ">=0.1.4", markers = "implementation_name == \"cpython\""} [[package]] name = "bluetooth-adapters" version = "2.1.1" description = "Tools to enumerate and find Bluetooth Adapters" optional = false python-versions = ">=3.9" groups = ["main"] markers = "platform_system == \"Linux\"" files = [ {file = "bluetooth_adapters-2.1.1-py3-none-any.whl", hash = "sha256:1f93026e530dcb2f4515a92955fa6f85934f928b009a181ee57edc8b4affd25c"}, {file = "bluetooth_adapters-2.1.1.tar.gz", hash = "sha256:f289e0f08814f74252a28862f488283680584744430d7eac45820f9c20ba041a"}, ] [package.dependencies] aiooui = ">=0.1.1" async-timeout = {version = ">=3.0.0", markers = "python_version < \"3.11\""} bleak = ">=1" dbus-fast = {version = ">=1.21.0", markers = "platform_system == \"Linux\""} uart-devices = ">=0.1.0" usb-devices = ">=0.4.5" [package.extras] docs = ["Sphinx (>=5,<8)", "myst-parser (>=0.18,<3.1)", "sphinx-rtd-theme (>=1,<4)"] [[package]] name = "certifi" version = "2025.8.3" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["docs"] files = [ {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, ] [[package]] name = "charset-normalizer" version = "3.4.3" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" groups = ["docs"] files = [ {file = "charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72"}, {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe"}, {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601"}, {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c"}, {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2"}, {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0"}, {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0"}, {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0"}, {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a"}, {file = "charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f"}, {file = "charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669"}, {file = "charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b"}, {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64"}, {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91"}, {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f"}, {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07"}, {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30"}, {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14"}, {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c"}, {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae"}, {file = "charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849"}, {file = "charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c"}, {file = "charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1"}, {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884"}, {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018"}, {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392"}, {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f"}, {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154"}, {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491"}, {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93"}, {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f"}, {file = "charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37"}, {file = "charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc"}, {file = "charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe"}, {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8"}, {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9"}, {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31"}, {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f"}, {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927"}, {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9"}, {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5"}, {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc"}, {file = "charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce"}, {file = "charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef"}, {file = "charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15"}, {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db"}, {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d"}, {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096"}, {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa"}, {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049"}, {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0"}, {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92"}, {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16"}, {file = "charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce"}, {file = "charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c"}, {file = "charset_normalizer-3.4.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c"}, {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b"}, {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4"}, {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b"}, {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9"}, {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb"}, {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a"}, {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942"}, {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b"}, {file = "charset_normalizer-3.4.3-cp38-cp38-win32.whl", hash = "sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557"}, {file = "charset_normalizer-3.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40"}, {file = "charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05"}, {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e"}, {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99"}, {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7"}, {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7"}, {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19"}, {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312"}, {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc"}, {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34"}, {file = "charset_normalizer-3.4.3-cp39-cp39-win32.whl", hash = "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432"}, {file = "charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca"}, {file = "charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a"}, {file = "charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14"}, ] [[package]] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["dev", "docs"] markers = "sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] [[package]] name = "coverage" version = "7.13.2" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ {file = "coverage-7.13.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4af3b01763909f477ea17c962e2cca8f39b350a4e46e3a30838b2c12e31b81b"}, {file = "coverage-7.13.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:36393bd2841fa0b59498f75466ee9bdec4f770d3254f031f23e8fd8e140ffdd2"}, {file = "coverage-7.13.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9cc7573518b7e2186bd229b1a0fe24a807273798832c27032c4510f47ffdb896"}, {file = "coverage-7.13.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca9566769b69a5e216a4e176d54b9df88f29d750c5b78dbb899e379b4e14b30c"}, {file = "coverage-7.13.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c9bdea644e94fd66d75a6f7e9a97bb822371e1fe7eadae2cacd50fcbc28e4dc"}, {file = "coverage-7.13.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5bd447332ec4f45838c1ad42268ce21ca87c40deb86eabd59888859b66be22a5"}, {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7c79ad5c28a16a1277e1187cf83ea8dafdcc689a784228a7d390f19776db7c31"}, {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:76e06ccacd1fb6ada5d076ed98a8c6f66e2e6acd3df02819e2ee29fd637b76ad"}, {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:49d49e9a5e9f4dc3d3dac95278a020afa6d6bdd41f63608a76fa05a719d5b66f"}, {file = "coverage-7.13.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed2bce0e7bfa53f7b0b01c722da289ef6ad4c18ebd52b1f93704c21f116360c8"}, {file = "coverage-7.13.2-cp310-cp310-win32.whl", hash = "sha256:1574983178b35b9af4db4a9f7328a18a14a0a0ce76ffaa1c1bacb4cc82089a7c"}, {file = "coverage-7.13.2-cp310-cp310-win_amd64.whl", hash = "sha256:a360a8baeb038928ceb996f5623a4cd508728f8f13e08d4e96ce161702f3dd99"}, {file = "coverage-7.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:060ebf6f2c51aff5ba38e1f43a2095e087389b1c69d559fde6049a4b0001320e"}, {file = "coverage-7.13.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1ea8ca9db5e7469cd364552985e15911548ea5b69c48a17291f0cac70484b2e"}, {file = "coverage-7.13.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b780090d15fd58f07cf2011943e25a5f0c1c894384b13a216b6c86c8a8a7c508"}, {file = "coverage-7.13.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:88a800258d83acb803c38175b4495d293656d5fac48659c953c18e5f539a274b"}, {file = "coverage-7.13.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6326e18e9a553e674d948536a04a80d850a5eeefe2aae2e6d7cf05d54046c01b"}, {file = "coverage-7.13.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59562de3f797979e1ff07c587e2ac36ba60ca59d16c211eceaa579c266c5022f"}, {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:27ba1ed6f66b0e2d61bfa78874dffd4f8c3a12f8e2b5410e515ab345ba7bc9c3"}, {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8be48da4d47cc68754ce643ea50b3234557cbefe47c2f120495e7bd0a2756f2b"}, {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2a47a4223d3361b91176aedd9d4e05844ca67d7188456227b6bf5e436630c9a1"}, {file = "coverage-7.13.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6f141b468740197d6bd38f2b26ade124363228cc3f9858bd9924ab059e00059"}, {file = "coverage-7.13.2-cp311-cp311-win32.whl", hash = "sha256:89567798404af067604246e01a49ef907d112edf2b75ef814b1364d5ce267031"}, {file = "coverage-7.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:21dd57941804ae2ac7e921771a5e21bbf9aabec317a041d164853ad0a96ce31e"}, {file = "coverage-7.13.2-cp311-cp311-win_arm64.whl", hash = "sha256:10758e0586c134a0bafa28f2d37dd2cdb5e4a90de25c0fc0c77dabbad46eca28"}, {file = "coverage-7.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f106b2af193f965d0d3234f3f83fc35278c7fb935dfbde56ae2da3dd2c03b84d"}, {file = "coverage-7.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f45d21dc4d5d6bd29323f0320089ef7eae16e4bef712dff79d184fa7330af3"}, {file = "coverage-7.13.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fae91dfecd816444c74531a9c3d6ded17a504767e97aa674d44f638107265b99"}, {file = "coverage-7.13.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:264657171406c114787b441484de620e03d8f7202f113d62fcd3d9688baa3e6f"}, {file = "coverage-7.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae47d8dcd3ded0155afbb59c62bd8ab07ea0fd4902e1c40567439e6db9dcaf2f"}, {file = "coverage-7.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a0b33e9fd838220b007ce8f299114d406c1e8edb21336af4c97a26ecfd185aa"}, {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3becbea7f3ce9a2d4d430f223ec15888e4deb31395840a79e916368d6004cce"}, {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f819c727a6e6eeb8711e4ce63d78c620f69630a2e9d53bc95ca5379f57b6ba94"}, {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4f7b71757a3ab19f7ba286e04c181004c1d61be921795ee8ba6970fd0ec91da5"}, {file = "coverage-7.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b7fc50d2afd2e6b4f6f2f403b70103d280a8e0cb35320cbbe6debcda02a1030b"}, {file = "coverage-7.13.2-cp312-cp312-win32.whl", hash = "sha256:292250282cf9bcf206b543d7608bda17ca6fc151f4cbae949fc7e115112fbd41"}, {file = "coverage-7.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:eeea10169fac01549a7921d27a3e517194ae254b542102267bef7a93ed38c40e"}, {file = "coverage-7.13.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a5b567f0b635b592c917f96b9a9cb3dbd4c320d03f4bf94e9084e494f2e8894"}, {file = "coverage-7.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed75de7d1217cf3b99365d110975f83af0528c849ef5180a12fd91b5064df9d6"}, {file = "coverage-7.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97e596de8fa9bada4d88fde64a3f4d37f1b6131e4faa32bad7808abc79887ddc"}, {file = "coverage-7.13.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:68c86173562ed4413345410c9480a8d64864ac5e54a5cda236748031e094229f"}, {file = "coverage-7.13.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7be4d613638d678b2b3773b8f687537b284d7074695a43fe2fbbfc0e31ceaed1"}, {file = "coverage-7.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7f63ce526a96acd0e16c4af8b50b64334239550402fb1607ce6a584a6d62ce9"}, {file = "coverage-7.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:406821f37f864f968e29ac14c3fccae0fec9fdeba48327f0341decf4daf92d7c"}, {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ee68e5a4e3e5443623406b905db447dceddffee0dceb39f4e0cd9ec2a35004b5"}, {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2ee0e58cca0c17dd9c6c1cdde02bb705c7b3fbfa5f3b0b5afeda20d4ebff8ef4"}, {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e5bbb5018bf76a56aabdb64246b5288d5ae1b7d0dd4d0534fe86df2c2992d1c"}, {file = "coverage-7.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a55516c68ef3e08e134e818d5e308ffa6b1337cc8b092b69b24287bf07d38e31"}, {file = "coverage-7.13.2-cp313-cp313-win32.whl", hash = "sha256:5b20211c47a8abf4abc3319d8ce2464864fa9f30c5fcaf958a3eed92f4f1fef8"}, {file = "coverage-7.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:14f500232e521201cf031549fb1ebdfc0a40f401cf519157f76c397e586c3beb"}, {file = "coverage-7.13.2-cp313-cp313-win_arm64.whl", hash = "sha256:9779310cb5a9778a60c899f075a8514c89fa6d10131445c2207fc893e0b14557"}, {file = "coverage-7.13.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5a1e41ce5df6b547cbc3d3699381c9e2c2c369c67837e716ed0f549d48e"}, {file = "coverage-7.13.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b01899e82a04085b6561eb233fd688474f57455e8ad35cd82286463ba06332b7"}, {file = "coverage-7.13.2-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:838943bea48be0e2768b0cf7819544cdedc1bbb2f28427eabb6eb8c9eb2285d3"}, {file = "coverage-7.13.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:93d1d25ec2b27e90bcfef7012992d1f5121b51161b8bffcda756a816cf13c2c3"}, {file = "coverage-7.13.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93b57142f9621b0d12349c43fc7741fe578e4bc914c1e5a54142856cfc0bf421"}, {file = "coverage-7.13.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f06799ae1bdfff7ccb8665d75f8291c69110ba9585253de254688aa8a1ccc6c5"}, {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f9405ab4f81d490811b1d91c7a20361135a2df4c170e7f0b747a794da5b7f23"}, {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f9ab1d5b86f8fbc97a5b3cd6280a3fd85fef3b028689d8a2c00918f0d82c728c"}, {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:f674f59712d67e841525b99e5e2b595250e39b529c3bda14764e4f625a3fa01f"}, {file = "coverage-7.13.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c6cadac7b8ace1ba9144feb1ae3cb787a6065ba6d23ffc59a934b16406c26573"}, {file = "coverage-7.13.2-cp313-cp313t-win32.whl", hash = "sha256:14ae4146465f8e6e6253eba0cccd57423e598a4cb925958b240c805300918343"}, {file = "coverage-7.13.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9074896edd705a05769e3de0eac0a8388484b503b68863dd06d5e473f874fd47"}, {file = "coverage-7.13.2-cp313-cp313t-win_arm64.whl", hash = "sha256:69e526e14f3f854eda573d3cf40cffd29a1a91c684743d904c33dbdcd0e0f3e7"}, {file = "coverage-7.13.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:387a825f43d680e7310e6f325b2167dd093bc8ffd933b83e9aa0983cf6e0a2ef"}, {file = "coverage-7.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f0d7fea9d8e5d778cd5a9e8fc38308ad688f02040e883cdc13311ef2748cb40f"}, {file = "coverage-7.13.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080afb413be106c95c4ee96b4fffdc9e2fa56a8bbf90b5c0918e5c4449412f5"}, {file = "coverage-7.13.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7fc042ba3c7ce25b8a9f097eb0f32a5ce1ccdb639d9eec114e26def98e1f8a4"}, {file = "coverage-7.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0ba505e021557f7f8173ee8cd6b926373d8653e5ff7581ae2efce1b11ef4c27"}, {file = "coverage-7.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7de326f80e3451bd5cc7239ab46c73ddb658fe0b7649476bc7413572d36cd548"}, {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abaea04f1e7e34841d4a7b343904a3f59481f62f9df39e2cd399d69a187a9660"}, {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9f93959ee0c604bccd8e0697be21de0887b1f73efcc3aa73a3ec0fd13feace92"}, {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:13fe81ead04e34e105bf1b3c9f9cdf32ce31736ee5d90a8d2de02b9d3e1bcb82"}, {file = "coverage-7.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d6d16b0f71120e365741bca2cb473ca6fe38930bc5431c5e850ba949f708f892"}, {file = "coverage-7.13.2-cp314-cp314-win32.whl", hash = "sha256:9b2f4714bb7d99ba3790ee095b3b4ac94767e1347fe424278a0b10acb3ff04fe"}, {file = "coverage-7.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4121a90823a063d717a96e0a0529c727fb31ea889369a0ee3ec00ed99bf6859"}, {file = "coverage-7.13.2-cp314-cp314-win_arm64.whl", hash = "sha256:6873f0271b4a15a33e7590f338d823f6f66f91ed147a03938d7ce26efd04eee6"}, {file = "coverage-7.13.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f61d349f5b7cd95c34017f1927ee379bfbe9884300d74e07cf630ccf7a610c1b"}, {file = "coverage-7.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a43d34ce714f4ca674c0d90beb760eb05aad906f2c47580ccee9da8fe8bfb417"}, {file = "coverage-7.13.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bff1b04cb9d4900ce5c56c4942f047dc7efe57e2608cb7c3c8936e9970ccdbee"}, {file = "coverage-7.13.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6ae99e4560963ad8e163e819e5d77d413d331fd00566c1e0856aa252303552c1"}, {file = "coverage-7.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e79a8c7d461820257d9aa43716c4efc55366d7b292e46b5b37165be1d377405d"}, {file = "coverage-7.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:060ee84f6a769d40c492711911a76811b4befb6fba50abb450371abb720f5bd6"}, {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bca209d001fd03ea2d978f8a4985093240a355c93078aee3f799852c23f561a"}, {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6b8092aa38d72f091db61ef83cb66076f18f02da3e1a75039a4f218629600e04"}, {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4a3158dc2dcce5200d91ec28cd315c999eebff355437d2765840555d765a6e5f"}, {file = "coverage-7.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3973f353b2d70bd9796cc12f532a05945232ccae966456c8ed7034cb96bbfd6f"}, {file = "coverage-7.13.2-cp314-cp314t-win32.whl", hash = "sha256:79f6506a678a59d4ded048dc72f1859ebede8ec2b9a2d509ebe161f01c2879d3"}, {file = "coverage-7.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:196bfeabdccc5a020a57d5a368c681e3a6ceb0447d153aeccc1ab4d70a5032ba"}, {file = "coverage-7.13.2-cp314-cp314t-win_arm64.whl", hash = "sha256:69269ab58783e090bfbf5b916ab3d188126e22d6070bbfc93098fdd474ef937c"}, {file = "coverage-7.13.2-py3-none-any.whl", hash = "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5"}, {file = "coverage-7.13.2.tar.gz", hash = "sha256:044c6951ec37146b72a50cc81ef02217d27d4c3640efd2640311393cbbf143d3"}, ] [package.dependencies] tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "dbus-fast" version = "4.3.0" description = "A faster version of dbus-next" optional = false python-versions = ">=3.10" groups = ["main", "dev"] files = [ {file = "dbus_fast-4.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd94d0f9d12f71c616f79968c56ce1dafb67380c0402f54269af84d93ed87f75"}, {file = "dbus_fast-4.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0e6ede48936d6fe0b2f2b75bd9f81eaa5219edda2e172b48fdf1694c8841d6"}, {file = "dbus_fast-4.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8b5fe04a4f5ffbb24f87aad8b4ab958d521d394c800bbea0d3d5ab983c01b48"}, {file = "dbus_fast-4.3.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7338734bdb84047385cf9fe69d986786763a7af07a019acf0565f24cdc054a0f"}, {file = "dbus_fast-4.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:497c05b132170b3dbd1d7a983f1260b469513dcf80ba5938aa0bd9fd2b33a1ea"}, {file = "dbus_fast-4.3.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ba2fefd1dd19cae20e3c2e463e9278e25e9496b59138b6b662f2d76a195ce915"}, {file = "dbus_fast-4.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e41e94f00e89dba0c384960b14606cbbaaec478f4b5e447f06df50fcbd876c90"}, {file = "dbus_fast-4.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0509c01f37894c9519393a6a7035d907d60f2873a55bcb99df5ac4ea11962d8a"}, {file = "dbus_fast-4.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:847e5c6c27cb7577d2791089b2bf876d8632d5a5431c191d7f14ef1a0f9a1461"}, {file = "dbus_fast-4.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1efed41d1a384944efb8990dc493570bf0a76a28fee66d42f0e414be952880b9"}, {file = "dbus_fast-4.3.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7f3e97ff766c8c209a36b4e80bda3b5f653bc940cc8e3a46d2fee60559248f2"}, {file = "dbus_fast-4.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:369741b1ab624fe060d3f8cefb03b57f3d1e1b88b0e22722e24fbd34ad5e701e"}, {file = "dbus_fast-4.3.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f6b5f6baa0e40cf0ad295077627e65d96a0fc8f563472629c7826ca0c70363d2"}, {file = "dbus_fast-4.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9406b88d315bf816f09362ac748287e8cdfde3b3438f7c5cb1bcfc4eea5d004"}, {file = "dbus_fast-4.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:42eef3b78d75cc634d4c03e932f280d06229992d21932c72e728623ea486e6a8"}, {file = "dbus_fast-4.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aa92f4e077bd28be01c9157decbada72518adb6c6ff7ceb55428dea17a1186e"}, {file = "dbus_fast-4.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6b032b8c79fda2614e5784ab0c45bb6aaa77b86b71ce3e4c6a1fbdc3661a91c"}, {file = "dbus_fast-4.3.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:652a4ef08a2fdc5171fc29859e55ffd2865d61d924420d176eaa686fb7bdb8bb"}, {file = "dbus_fast-4.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:365dfbf4a61bc6b6c6ebb1dee8a118413b4cd2d78130cd0384bbc8cbdb25047d"}, {file = "dbus_fast-4.3.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3e5178fd58a3981aab8475bcbc063eaa3f3395e2ca9fd5c3b2ce6cba35a428c8"}, {file = "dbus_fast-4.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be2c65d0af605548931aea1abd1130562f5efe6f215113fcd93b50f0921a849"}, {file = "dbus_fast-4.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bb41085b151dd4041a3a7c785c2cfcfa2591c8694e9f23ec9a02bf5122a66dcd"}, {file = "dbus_fast-4.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5e3a44aed8aa161d7b1483c6488542ac5ae7849226abf981102bcf7584ebfbc"}, {file = "dbus_fast-4.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f694bc28cab83845d7c86db5510d1fa8532f950836dd14d2869d11c52b48646"}, {file = "dbus_fast-4.3.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:45d0d66d5e859cec447e982b17a24b93b3902586afbf6c2b1b3497405d15d766"}, {file = "dbus_fast-4.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f5ff3bccd715b2c4a7d7754aba85dbbac1ccc50a326777b07b8d2b35b2eca62"}, {file = "dbus_fast-4.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bcdaf77dc2bac75b9fdea3e7261f45dee7fe377ef45650317d506169422763c2"}, {file = "dbus_fast-4.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:16b7e2199a19057dc450ab80cc7f0a2908bfbb88280e3f95aa026e58deef08a9"}, {file = "dbus_fast-4.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:be71635cd436f102410aa6335515b86b1f9059769bf3d8c0e6f9666d29f37319"}, {file = "dbus_fast-4.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f5501283bc2d1d632ddc2456f551eeae9eb733934e0ddc97faaceec98fceb83"}, {file = "dbus_fast-4.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb34eaf7fad43ef93b2f9a25b4140d0508df5a08b29dea571e3b952abfa24607"}, {file = "dbus_fast-4.3.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:490912ef523ef4be5a7664456af725db1045b8e7d445314fc555c439e291c42b"}, {file = "dbus_fast-4.3.0-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:7df297a41dc19c28ee673fadbec9cb241c79b58434cd78648812048ebe7b870e"}, {file = "dbus_fast-4.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:be05f23b12c057d9e3e3b2395279cd1f222ad16097578ab4c2fece929b80f4da"}, {file = "dbus_fast-4.3.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9bb3810d91edbb806bc651d5253136175d70d4ce1850a9573d8f5cef3f1514e5"}, {file = "dbus_fast-4.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e63e655c28942d45b68910ba40a5b03a2430634292743ada79f181977c57bb5f"}, {file = "dbus_fast-4.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e220c2f4e8bc463424754890f21756ed5fcd1c28e8c7887608027ba6386db7be"}, {file = "dbus_fast-4.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f97721b14d4df7d80fb231b4841eaf95fe31e81c5dd85570f48f0ce74029b2"}, {file = "dbus_fast-4.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:08d35beed96cb0c7c55944ad6d607633b1fa38e6e7236a02ae8dce35e94c6bd0"}, {file = "dbus_fast-4.3.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e679aa106595d0cd4688eca2506d34e43421d8b545165c9c1677350da844ed36"}, {file = "dbus_fast-4.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4ef25aa6a928d0909b51db0316892ec8d22ef7f024fca5b2ff146b3b9e19e452"}, {file = "dbus_fast-4.3.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:713f92b5abc391b15feb4765e6e2a80b0dace96c576498aa0c832be4f84b110d"}, {file = "dbus_fast-4.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6b7ed03fa3c2b91ea92c43c291e3a56f576ea87c4a56cc3495b7745c6e375248"}, {file = "dbus_fast-4.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cbffff848ce2f1e45def0ecaa1d51c3a953fdb17c70f395cd33a67643b08023b"}, {file = "dbus_fast-4.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e8b5f008cf8368b2c2529664d0efaeaed7500478a29869bad98fe5a781c18f0"}, {file = "dbus_fast-4.3.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c88bee8c3fd94614f03a85af4b4cb7667c6f3a3cc8d7ac3cb0ff2762c8227c2c"}, {file = "dbus_fast-4.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8d7c4d3538f0e53adaf57e6e10cce295e7a8a460c4db7643938efcf5f7db407b"}, {file = "dbus_fast-4.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7bd91f661ea5d25993e8407a13b0a55f66687e3dd07982e00a92ee767b236779"}, {file = "dbus_fast-4.3.0.tar.gz", hash = "sha256:c70fdb7208902849bef74fff72d34e2c07582f47715cc6f6d87dcc089437186f"}, ] [[package]] name = "docutils" version = "0.21.2" description = "Docutils -- Python Documentation Utilities" optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"}, {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, ] [[package]] name = "exceptiongroup" version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" groups = ["dev"] markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, ] [package.dependencies] typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] [[package]] name = "forbiddenfruit" version = "0.1.4" description = "Patch python built-in objects" optional = false python-versions = "*" groups = ["dev"] markers = "implementation_name == \"cpython\"" files = [ {file = "forbiddenfruit-0.1.4.tar.gz", hash = "sha256:e3f7e66561a29ae129aac139a85d610dbf3dd896128187ed5454b6421f624253"}, ] [[package]] name = "idna" version = "3.15" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" groups = ["dev", "docs"] files = [ {file = "idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8"}, {file = "idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc"}, ] [package.extras] all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] [[package]] name = "imagesize" version = "1.4.1" description = "Getting image size from png/jpeg/jpeg2000/gif file" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" groups = ["docs"] files = [ {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, ] [[package]] name = "iniconfig" version = "2.1.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] [[package]] name = "jinja2" version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" groups = ["docs"] files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, ] [package.dependencies] MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] [[package]] name = "markdown-it-py" version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.8" groups = ["docs"] files = [ {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, ] [package.dependencies] mdurl = ">=0.1,<1.0" [package.extras] benchmarking = ["psutil", "pytest", "pytest-benchmark"] code-style = ["pre-commit (>=3.0,<4.0)"] compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] linkify = ["linkify-it-py (>=1,<3)"] plugins = ["mdit-py-plugins"] profiling = ["gprof2dot"] rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] [[package]] name = "markupsafe" version = "3.0.2" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, ] [[package]] name = "mdit-py-plugins" version = "0.5.0" description = "Collection of plugins for markdown-it-py" optional = false python-versions = ">=3.10" groups = ["docs"] files = [ {file = "mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f"}, {file = "mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6"}, ] [package.dependencies] markdown-it-py = ">=2.0.0,<5.0.0" [package.extras] code-style = ["pre-commit"] rtd = ["myst-parser", "sphinx-book-theme"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] [[package]] name = "mdurl" version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" groups = ["docs"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] [[package]] name = "myst-parser" version = "4.0.1" description = "An extended [CommonMark](https://spec.commonmark.org/) compliant parser," optional = false python-versions = ">=3.10" groups = ["docs"] files = [ {file = "myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d"}, {file = "myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4"}, ] [package.dependencies] docutils = ">=0.19,<0.22" jinja2 = "*" markdown-it-py = ">=3.0,<4.0" mdit-py-plugins = ">=0.4.1,<1.0" pyyaml = "*" sphinx = ">=7,<9" [package.extras] code-style = ["pre-commit (>=4.0,<5.0)"] linkify = ["linkify-it-py (>=2.0,<3.0)"] rtd = ["ipython", "sphinx (>=7)", "sphinx-autodoc2 (>=0.5.0,<0.6.0)", "sphinx-book-theme (>=1.1,<2.0)", "sphinx-copybutton", "sphinx-design", "sphinx-pyscript", "sphinx-tippy (>=0.4.3)", "sphinx-togglebutton", "sphinxext-opengraph (>=0.9.0,<0.10.0)", "sphinxext-rediraffe (>=0.2.7,<0.3.0)"] testing = ["beautifulsoup4", "coverage[toml]", "defusedxml", "pygments (<2.19)", "pytest (>=8,<9)", "pytest-cov", "pytest-param-files (>=0.6.0,<0.7.0)", "pytest-regressions", "sphinx-pytest"] testing-docutils = ["pygments", "pytest (>=8,<9)", "pytest-param-files (>=0.6.0,<0.7.0)"] [[package]] name = "packaging" version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" groups = ["dev", "docs"] files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] [[package]] name = "pluggy" version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, ] [package.extras] dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "pygments" version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.9" groups = ["dev", "docs"] files = [ {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, ] [package.extras] windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyobjc-core" version = "11.1" description = "Python<->ObjC Interoperability Module" optional = false python-versions = ">=3.8" groups = ["main"] markers = "platform_system == \"Darwin\"" files = [ {file = "pyobjc_core-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4c7536f3e94de0a3eae6bb382d75f1219280aa867cdf37beef39d9e7d580173c"}, {file = "pyobjc_core-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ec36680b5c14e2f73d432b03ba7c1457dc6ca70fa59fd7daea1073f2b4157d33"}, {file = "pyobjc_core-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:765b97dea6b87ec4612b3212258024d8496ea23517c95a1c5f0735f96b7fd529"}, {file = "pyobjc_core-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:18986f83998fbd5d3f56d8a8428b2f3e0754fd15cef3ef786ca0d29619024f2c"}, {file = "pyobjc_core-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8849e78cfe6595c4911fbba29683decfb0bf57a350aed8a43316976ba6f659d2"}, {file = "pyobjc_core-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cb9ed17a8d84a312a6e8b665dd22393d48336ea1d8277e7ad20c19a38edf731"}, {file = "pyobjc_core-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:f2455683e807f8541f0d83fbba0f5d9a46128ab0d5cc83ea208f0bec759b7f96"}, {file = "pyobjc_core-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4a99e6558b48b8e47c092051e7b3be05df1c8d0617b62f6fa6a316c01902d157"}, {file = "pyobjc_core-11.1.tar.gz", hash = "sha256:b63d4d90c5df7e762f34739b39cc55bc63dbcf9fb2fb3f2671e528488c7a87fe"}, ] [[package]] name = "pyobjc-framework-cocoa" version = "11.1" description = "Wrappers for the Cocoa frameworks on macOS" optional = false python-versions = ">=3.9" groups = ["main"] markers = "platform_system == \"Darwin\"" files = [ {file = "pyobjc_framework_cocoa-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b27a5bdb3ab6cdeb998443ff3fce194ffae5f518c6a079b832dbafc4426937f9"}, {file = "pyobjc_framework_cocoa-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7b9a9b8ba07f5bf84866399e3de2aa311ed1c34d5d2788a995bdbe82cc36cfa0"}, {file = "pyobjc_framework_cocoa-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806de56f06dfba8f301a244cce289d54877c36b4b19818e3b53150eb7c2424d0"}, {file = "pyobjc_framework_cocoa-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54e93e1d9b0fc41c032582a6f0834befe1d418d73893968f3f450281b11603da"}, {file = "pyobjc_framework_cocoa-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:fd5245ee1997d93e78b72703be1289d75d88ff6490af94462b564892e9266350"}, {file = "pyobjc_framework_cocoa-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:aede53a1afc5433e1e7d66568cc52acceeb171b0a6005407a42e8e82580b4fc0"}, {file = "pyobjc_framework_cocoa-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:1b5de4e1757bb65689d6dc1f8d8717de9ec8587eb0c4831c134f13aba29f9b71"}, {file = "pyobjc_framework_cocoa-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bbee71eeb93b1b31ffbac8560b59a0524a8a4b90846a260d2c4f2188f3d4c721"}, {file = "pyobjc_framework_cocoa-11.1.tar.gz", hash = "sha256:87df76b9b73e7ca699a828ff112564b59251bb9bbe72e610e670a4dc9940d038"}, ] [package.dependencies] pyobjc-core = ">=11.1" [[package]] name = "pyobjc-framework-corebluetooth" version = "11.1" description = "Wrappers for the framework CoreBluetooth on macOS" optional = false python-versions = ">=3.9" groups = ["main"] markers = "platform_system == \"Darwin\"" files = [ {file = "pyobjc_framework_corebluetooth-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ab509994503a5f0ec0f446a7ccc9f9a672d5a427d40dba4563dd00e8e17dfb06"}, {file = "pyobjc_framework_corebluetooth-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:433b8593eb1ea8b6262b243ec903e1de4434b768ce103ebe15aac249b890cc2a"}, {file = "pyobjc_framework_corebluetooth-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:36bef95a822c68b72f505cf909913affd61a15b56eeaeafea7302d35a82f4f05"}, {file = "pyobjc_framework_corebluetooth-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:992404b03033ecf637e9174caed70cb22fd1be2a98c16faa699217678e62a5c7"}, {file = "pyobjc_framework_corebluetooth-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ebb8648f5e33d98446eb1d6c4654ba4fcc15d62bfcb47fa3bbd5596f6ecdb37c"}, {file = "pyobjc_framework_corebluetooth-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:e84cbf52006a93d937b90421ada0bc4a146d6d348eb40ae10d5bd2256cc92206"}, {file = "pyobjc_framework_corebluetooth-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:4da1106265d7efd3f726bacdf13ba9528cc380fb534b5af38b22a397e6908291"}, {file = "pyobjc_framework_corebluetooth-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e9fa3781fea20a31b3bb809deaeeab3bdc7b86602a1fd829f0e86db11d7aa577"}, {file = "pyobjc_framework_corebluetooth-11.1.tar.gz", hash = "sha256:1deba46e3fcaf5e1c314f4bbafb77d9fe49ec248c493ad00d8aff2df212d6190"}, ] [package.dependencies] pyobjc-core = ">=11.1" pyobjc-framework-Cocoa = ">=11.1" [[package]] name = "pyobjc-framework-libdispatch" version = "11.1" description = "Wrappers for libdispatch on macOS" optional = false python-versions = ">=3.9" groups = ["main"] markers = "platform_system == \"Darwin\"" files = [ {file = "pyobjc_framework_libdispatch-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9c598c073a541b5956b5457b94bd33b9ce19ef8d867235439a0fad22d6beab49"}, {file = "pyobjc_framework_libdispatch-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2ddca472c2cbc6bb192e05b8b501d528ce49333abe7ef0eef28df3133a8e18b7"}, {file = "pyobjc_framework_libdispatch-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dc9a7b8c2e8a63789b7cf69563bb7247bde15353208ef1353fff0af61b281684"}, {file = "pyobjc_framework_libdispatch-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c4e219849f5426745eb429f3aee58342a59f81e3144b37aa20e81dacc6177de1"}, {file = "pyobjc_framework_libdispatch-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a9357736cb47b4a789f59f8fab9b0d10b0a9c84f9876367c398718d3de085888"}, {file = "pyobjc_framework_libdispatch-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:cd08f32ea7724906ef504a0fd40a32e2a0be4d64b9239530a31767ca9ccfc921"}, {file = "pyobjc_framework_libdispatch-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:5d9985b0e050cae72bf2c6a1cc8180ff4fa3a812cd63b2dc59e09c6f7f6263a1"}, {file = "pyobjc_framework_libdispatch-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cfe515f4c3ea66c13fce4a527230027517b8b779b40bbcb220ff7cdf3ad20bc4"}, {file = "pyobjc_framework_libdispatch-11.1.tar.gz", hash = "sha256:11a704e50a0b7dbfb01552b7d686473ffa63b5254100fdb271a1fe368dd08e87"}, ] [package.dependencies] pyobjc-core = ">=11.1" pyobjc-framework-Cocoa = ">=11.1" [[package]] name = "pytest" version = "9.0.3" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ {file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"}, {file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"}, ] [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} iniconfig = ">=1.0.1" packaging = ">=22" pluggy = ">=1.5,<2" pygments = ">=2.7.2" tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" version = "1.3.0" description = "Pytest support for asyncio" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ {file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"}, {file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"}, ] [package.dependencies] backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} pytest = ">=8.2,<10" typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-cov" version = "7.1.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678"}, {file = "pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2"}, ] [package.dependencies] coverage = {version = ">=7.10.6", extras = ["toml"]} pluggy = ">=1.2" pytest = ">=7" [package.extras] testing = ["process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pyyaml" version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" groups = ["docs"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] [[package]] name = "requests" version = "2.33.0" description = "Python HTTP for Humans." optional = false python-versions = ">=3.10" groups = ["docs"] files = [ {file = "requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b"}, {file = "requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652"}, ] [package.dependencies] certifi = ">=2023.5.7" charset_normalizer = ">=2,<4" idna = ">=2.5,<4" urllib3 = ">=1.26,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] test = ["PySocks (>=1.5.6,!=1.5.7)", "pytest (>=3)", "pytest-cov", "pytest-httpbin (==2.1.0)", "pytest-mock", "pytest-xdist"] use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] [[package]] name = "snowballstemmer" version = "3.0.1" description = "This package provides 32 stemmers for 30 languages generated from Snowball algorithms." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*" groups = ["docs"] files = [ {file = "snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064"}, {file = "snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895"}, ] [[package]] name = "sphinx" version = "8.1.3" description = "Python documentation generator" optional = false python-versions = ">=3.10" groups = ["docs"] files = [ {file = "sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2"}, {file = "sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927"}, ] [package.dependencies] alabaster = ">=0.7.14" babel = ">=2.13" colorama = {version = ">=0.4.6", markers = "sys_platform == \"win32\""} docutils = ">=0.20,<0.22" imagesize = ">=1.3" Jinja2 = ">=3.1" packaging = ">=23.0" Pygments = ">=2.17" requests = ">=2.30.0" snowballstemmer = ">=2.2" sphinxcontrib-applehelp = ">=1.0.7" sphinxcontrib-devhelp = ">=1.0.6" sphinxcontrib-htmlhelp = ">=2.0.6" sphinxcontrib-jsmath = ">=1.0.1" sphinxcontrib-qthelp = ">=1.0.6" sphinxcontrib-serializinghtml = ">=1.1.9" tomli = {version = ">=2", markers = "python_version < \"3.11\""} [package.extras] docs = ["sphinxcontrib-websupport"] lint = ["flake8 (>=6.0)", "mypy (==1.11.1)", "pyright (==1.1.384)", "pytest (>=6.0)", "ruff (==0.6.9)", "sphinx-lint (>=0.9)", "tomli (>=2)", "types-Pillow (==10.2.0.20240822)", "types-Pygments (==2.18.0.20240506)", "types-colorama (==0.4.15.20240311)", "types-defusedxml (==0.7.0.20240218)", "types-docutils (==0.21.0.20241005)", "types-requests (==2.32.0.20240914)", "types-urllib3 (==1.26.25.14)"] test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=8.0)", "setuptools (>=70.0)", "typing_extensions (>=4.9)"] [[package]] name = "sphinx-rtd-theme" version = "3.1.0" description = "Read the Docs theme for Sphinx" optional = false python-versions = ">=3.8" groups = ["docs"] files = [ {file = "sphinx_rtd_theme-3.1.0-py2.py3-none-any.whl", hash = "sha256:1785824ae8e6632060490f67cf3a72d404a85d2d9fc26bce3619944de5682b89"}, {file = "sphinx_rtd_theme-3.1.0.tar.gz", hash = "sha256:b44276f2c276e909239a4f6c955aa667aaafeb78597923b1c60babc76db78e4c"}, ] [package.dependencies] docutils = ">0.18,<0.23" sphinx = ">=6,<10" sphinxcontrib-jquery = ">=4,<5" [package.extras] dev = ["bump2version", "transifex-client", "twine", "wheel"] [[package]] name = "sphinxcontrib-applehelp" version = "2.0.0" description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"}, {file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"}, ] [package.extras] lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] standalone = ["Sphinx (>=5)"] test = ["pytest"] [[package]] name = "sphinxcontrib-devhelp" version = "2.0.0" description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents" optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"}, {file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"}, ] [package.extras] lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] standalone = ["Sphinx (>=5)"] test = ["pytest"] [[package]] name = "sphinxcontrib-htmlhelp" version = "2.1.0" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"}, {file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"}, ] [package.extras] lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] standalone = ["Sphinx (>=5)"] test = ["html5lib", "pytest"] [[package]] name = "sphinxcontrib-jquery" version = "4.1" description = "Extension to include jQuery on newer Sphinx releases" optional = false python-versions = ">=2.7" groups = ["docs"] files = [ {file = "sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a"}, {file = "sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae"}, ] [package.dependencies] Sphinx = ">=1.8" [[package]] name = "sphinxcontrib-jsmath" version = "1.0.1" description = "A sphinx extension which renders display math in HTML via JavaScript" optional = false python-versions = ">=3.5" groups = ["docs"] files = [ {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, ] [package.extras] test = ["flake8", "mypy", "pytest"] [[package]] name = "sphinxcontrib-qthelp" version = "2.0.0" description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"}, {file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"}, ] [package.extras] lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] standalone = ["Sphinx (>=5)"] test = ["defusedxml (>=0.7.1)", "pytest"] [[package]] name = "sphinxcontrib-serializinghtml" version = "2.0.0" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)" optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"}, {file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"}, ] [package.extras] lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] standalone = ["Sphinx (>=5)"] test = ["pytest"] [[package]] name = "tomli" version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" groups = ["dev", "docs"] files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] markers = {dev = "python_full_version <= \"3.11.0a6\"", docs = "python_version == \"3.10\""} [[package]] name = "typing-extensions" version = "4.14.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ {file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"}, {file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"}, ] markers = {main = "python_version < \"3.12\" or platform_system == \"Windows\"", dev = "python_version < \"3.13\""} [[package]] name = "uart-devices" version = "0.1.1" description = "UART Devices for Linux" optional = false python-versions = "<4.0,>=3.9" groups = ["main"] markers = "platform_system == \"Linux\"" files = [ {file = "uart_devices-0.1.1-py3-none-any.whl", hash = "sha256:55bc8cce66465e90b298f0910e5c496bc7be021341c5455954cf61c6253dc123"}, {file = "uart_devices-0.1.1.tar.gz", hash = "sha256:3a52c4ae0f5f7400ebe1ae5f6e2a2d40cc0b7f18a50e895236535c4e53c6ed34"}, ] [[package]] name = "urllib3" version = "2.7.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.10" groups = ["docs"] files = [ {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, ] [package.extras] brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "usb-devices" version = "0.4.5" description = "Tools for mapping, describing, and resetting USB devices" optional = false python-versions = ">=3.9,<4.0" groups = ["main"] markers = "platform_system == \"Linux\"" files = [ {file = "usb_devices-0.4.5-py3-none-any.whl", hash = "sha256:8a415219ef1395e25aa0bddcad484c88edf9673acdeae8a07223ca7222a01dcf"}, {file = "usb_devices-0.4.5.tar.gz", hash = "sha256:9b5c7606df2bc791c6c45b7f76244a0cbed83cb6fa4c68791a143c03345e195d"}, ] [[package]] name = "winrt-runtime" version = "3.2.1" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = ">=3.9" groups = ["main"] markers = "platform_system == \"Windows\"" files = [ {file = "winrt_runtime-3.2.1-cp310-cp310-win32.whl", hash = "sha256:25a2d1e2b45423742319f7e10fa8ca2e7063f01284b6e85e99d805c4b50bbfb3"}, {file = "winrt_runtime-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:dc81d5fb736bf1ddecf743928622253dce4d0aac9a57faad776d7a3834e13257"}, {file = "winrt_runtime-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:363f584b1e9fcb601e3e178636d8877e6f0537ac3c96ce4a96f06066f8ff0eae"}, {file = "winrt_runtime-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9e9b64f1ba631cc4b9fe60b8ff16fef3f32c7ce2fcc84735a63129ff8b15c022"}, {file = "winrt_runtime-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c0a9046ae416808420a358c51705af8ae100acd40bc578be57ddfdd51cbb0f9c"}, {file = "winrt_runtime-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:e94f3cb40ea2d723c44c82c16d715c03c6b3bd977d135b49535fdd5415fd9130"}, {file = "winrt_runtime-3.2.1-cp312-cp312-win32.whl", hash = "sha256:762b3d972a2f7037f7db3acbaf379dd6d8f6cda505f71f66c6b425d1a1eae2f1"}, {file = "winrt_runtime-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:06510db215d4f0dc45c00fbb1251c6544e91742a0ad928011db33b30677e1576"}, {file = "winrt_runtime-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:14562c29a087ccad38e379e585fef333e5c94166c807bdde67b508a6261aa195"}, {file = "winrt_runtime-3.2.1-cp313-cp313-win32.whl", hash = "sha256:44e2733bc709b76c554aee6c7fe079443b8306b2e661e82eecfebe8b9d71e4d1"}, {file = "winrt_runtime-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:3c1fdcaeedeb2920dc3b9039db64089a6093cad2be56a3e64acc938849245a6d"}, {file = "winrt_runtime-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:28f3dab083412625ff4d2b46e81246932e6bebddf67bea7f05e01712f54e6159"}, {file = "winrt_runtime-3.2.1-cp314-cp314-win32.whl", hash = "sha256:9b6298375468ac2f6815d0c008a059fc16508c8f587e824c7936ed9216480dad"}, {file = "winrt_runtime-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:e36e587ab5fd681ee472cd9a5995743f75107a1a84d749c64f7e490bc86bc814"}, {file = "winrt_runtime-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:35d6241a2ebd5598e4788e69768b8890ee1eee401a819865767a1fbdd3e9a650"}, {file = "winrt_runtime-3.2.1-cp39-cp39-win32.whl", hash = "sha256:07c0cb4a53a4448c2cb7597b62ae8c94343c289eeebd8f83f946eb2c817bde01"}, {file = "winrt_runtime-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:1856325ca3354b45e0789cf279be9a882134085d34214946db76110d98391efa"}, {file = "winrt_runtime-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:cf237858de1d62e4c9b132c66b52028a7a3e8534e8ab90b0e29a68f24f7be39d"}, {file = "winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea"}, ] [package.dependencies] typing_extensions = ">=4.12.2" [[package]] name = "winrt-windows-devices-bluetooth" version = "3.2.1" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = ">=3.9" groups = ["main"] markers = "platform_system == \"Windows\"" files = [ {file = "winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win32.whl", hash = "sha256:49489351037094a088a08fbdf0f99c94e3299b574edb211f717c4c727770af78"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:20f6a21029034c18ea6a6b6df399671813b071102a0d6d8355bb78cf4f547cdb"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:69c523814eab795bc1bf913292309cb1025ef0a67d5fc33863a98788995e551d"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win32.whl", hash = "sha256:f4082a00b834c1e34b961e0612f3e581356bdb38c5798bd6842f88ec02e5152b"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:44277a3f2cc5ac32ce9b4b2d96c5c5f601d394ac5f02cc71bcd551f738660e2d"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:0803a417403a7d225316b9b0c4fe3f8446579d6a22f2f729a2c21f4befc74a80"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win32.whl", hash = "sha256:18c833ec49e7076127463679e85efc59f61785ade0dc185c852586b21be1f31c"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:9b6702c462b216c91e32388023a74d0f87210cef6fd5d93b7191e9427ce2faca"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:419fd1078c7749119f6b4bbf6be4e586e03a0ed544c03b83178f1d85f1b3d148"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win32.whl", hash = "sha256:12b0a16fb36ce0b42243ca81f22a6b53fbb344ed7ea07a6eeec294604f0505e4"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6703dfbe444ee22426738830fb305c96a728ea9ccce905acfdf811d81045fdb3"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2cf8a0bfc9103e32dc7237af15f84be06c791f37711984abdca761f6318bbdb2"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win32.whl", hash = "sha256:de36ded53ca3ba12fc6dd4deb14b779acc391447726543815df4800348aad63a"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3295d932cc93259d5ccb23a41e3a3af4c78ce5d6a6223b2b7638985f604fa34c"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1f61c178766a1bbce0669f44790c6161ff4669404c477b4aedaa576348f9e102"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp39-cp39-win32.whl", hash = "sha256:32fc355bfdc5d6b3b1875df16eaf12f9b9fc0445e01177833c27d9a4fc0d50b6"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:b886ef1fc0ed49163ae6c2422dd5cb8dd4709da7972af26c8627e211872818d0"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:8643afa53f9fb8fe3b05967227f86f0c8e1d7b822289e60a848c6368acc977d2"}, {file = "winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505"}, ] [package.dependencies] winrt-runtime = ">=3.2.1.0,<3.3.0.0" [package.extras] all = ["winrt-Windows.Devices.Bluetooth.GenericAttributeProfile[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Devices.Bluetooth.Rfcomm[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Devices.Enumeration[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Devices.Radios[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Networking[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage.Streams[all] (>=3.2.1.0,<3.3.0.0)"] [[package]] name = "winrt-windows-devices-bluetooth-advertisement" version = "3.2.1" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = ">=3.9" groups = ["main"] markers = "platform_system == \"Windows\"" files = [ {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win32.whl", hash = "sha256:a758c5f81a98cc38347fdfb024ce62720969480e8c5b98e402b89d2b09b32866"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:f982ef72e729ddd60cdb975293866e84bb838798828933012a57ee4bf12b0ea1"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:e88a72e1e09c7ccc899a9e6d2ab3fc0f43b5dd4509bcc49ec4abf65b55ab015f"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win32.whl", hash = "sha256:fe17c2cf63284646622e8b2742b064bf7970bbf53cfab02062136c67fa6b06c9"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:78e99dd48b4d89b71b7778c5085fdba64e754dd3ebc54fd09c200fe5222c6e09"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6d5d2295474deab444fc4311580c725a2ca8a814b0f3344d0779828891d75401"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win32.whl", hash = "sha256:901933cc40de5eb7e5f4188897c899dd0b0f577cb2c13eab1a63c7dfe89b08c4"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:e6c66e7d4f4ca86d2c801d30efd2b9673247b59a2b4c365d9e11650303d68d89"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:447d19defd8982d39944642eb7ebe89e4e20259ec9734116cf88879fb2c514ff"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4122348ea525a914e85615647a0b54ae8b2f42f92cdbf89c5a12eea53ef6ed90"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:b66410c04b8dae634a7e4b615c3b7f8adda9c7d4d6902bcad5b253da1a684943"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:07af19b1d252ddb9dd3eb2965118bc2b7cabff4dda6e499341b765e5038ca61d"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win32.whl", hash = "sha256:2985565c265b3f9eab625361b0e40e88c94b03d89f5171f36146f2e88b3ee214"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d102f3fac64fde32332e370969dfbc6f37b405d8cc055d9da30d14d07449a3c2"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:ffeb5e946cd42c32c6999a62e240d6730c653cdfb7b49c7839afba375e20a62a"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp39-cp39-win32.whl", hash = "sha256:6c4747d2e5b0e2ef24e9b84a848cf8fc50fb5b268a2086b5ee8680206d1e0197"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:18d4c5d8b80ee2d29cc13c2fc1353fdb3c0f620c8083701c9b9ecf5e6c503c8d"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:75dd856611d847299078d56aee60e319df52975b931c992cd1d32ad5143fe772"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc"}, ] [package.dependencies] winrt-runtime = ">=3.2.1.0,<3.3.0.0" [package.extras] all = ["winrt-Windows.Devices.Bluetooth[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage.Streams[all] (>=3.2.1.0,<3.3.0.0)"] [[package]] name = "winrt-windows-devices-bluetooth-genericattributeprofile" version = "3.2.1" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = ">=3.9" groups = ["main"] markers = "platform_system == \"Windows\"" files = [ {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win32.whl", hash = "sha256:af4914d7b30b49232092cd3b934e3ed6f5d3b1715ba47238541408ee595b7f46"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:0e557dd52fc80392b8bd7c237e1153a50a164b3983838b4ac674551072efc9ed"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:64cff62baa6b7aadd6c206e61d149113fdcda17360feb6e9d05bc8bbda4b9fde"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win32.whl", hash = "sha256:832cf65d035a11e6dbfef4fd66abdcc46be7e911ec96e2e72e98e12d8d5b9d3c"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:8179638a6c721b0bbf04ba251ef98d5e02d9a17f0cce377398e42c4fbb441415"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:70b7edfca3190b89ae38bf60972b11978311b6d933d3142ae45560c955dbf5c7"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win32.whl", hash = "sha256:ef894d21e0a805f3e114940254636a8045335fa9de766c7022af5d127dfad557"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:db05de95cd1b24a51abb69cb936a8b17e9214e015757d0b37e3a5e207ddceb3d"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d4e131cf3d15fc5ad81c1bcde3509ac171298217381abed6bdf687f29871984"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win32.whl", hash = "sha256:b1879c8dcf46bd2110b9ad4b0b185f4e2a5f95170d014539203a5fee2b2115f0"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d8d89f01e9b6931fb48217847caac3227a0aeb38a5b7782af71c2e7b262ec30"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:4e71207bb89798016b1795bb15daf78afe45529f2939b3b9e78894cfe650b383"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win32.whl", hash = "sha256:d5f83739ca370f0baf52b0400aebd6240ab80150081fbfba60fd6e7b2e7b4c5f"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:13786a5853a933de140d456cd818696e1121c7c296ae7b7af262fc5d2cffb851"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:5140682da2860f6a55eb6faf9e980724dc457c2e4b4b35a10e1cebd8fc97d892"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp39-cp39-win32.whl", hash = "sha256:963339a0161f9970b577a6193924be783978d11693da48b41a025f61b3c5562a"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:d43615c5dfa939dd30fe80dc0649434a13cc7cf0294ad0d7283d5a9f48c6ce86"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:8e70fa970997e2e67a8a4172bc00b0b2a79b5ff5bb2668f79cf10b3fd63d3974"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71"}, ] [package.dependencies] winrt-runtime = ">=3.2.1.0,<3.3.0.0" [package.extras] all = ["winrt-Windows.Devices.Bluetooth[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Devices.Enumeration[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage.Streams[all] (>=3.2.1.0,<3.3.0.0)"] [[package]] name = "winrt-windows-devices-enumeration" version = "3.2.1" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = ">=3.9" groups = ["main"] markers = "platform_system == \"Windows\"" files = [ {file = "winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win32.whl", hash = "sha256:40dac777d8f45b41449f3ff1ae70f0d457f1ede53f53962a6e2521b651533db5"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:a101ec3e0ad0a0783032fdcd5dc48e7cd68ee034cbde4f903a8c7b391532c71a"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:3296a3863ac086928ff3f3dc872b2a2fb971dab728817424264f3ca547504e9e"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9f29465a6c6b0456e4330d4ad09eccdd53a17e1e97695c2e57db0d4666cc0011"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2a725d04b4cb43aa0e2af035f73a60d16a6c0ff165fcb6b763383e4e33a975fd"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6365ef5978d4add26678827286034acf474b6b133aa4054e76567d12194e6817"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win32.whl", hash = "sha256:1db22b0292b93b0688d11ad932ad1f3629d4f471310281a2fbfe187530c2c1f3"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:a73bc88d7f510af454f2b392985501c96f39b89fd987140708ccaec1588ceebc"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:2853d687803f0dd76ae1afe3648abc0453e09dff0e7eddbb84b792eddb0473ca"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win32.whl", hash = "sha256:14a71cdcc84f624c209cbb846ed6bd9767a9a9437b2bf26b48ac9a91599da6e9"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6ca40d334734829e178ad46375275c4f7b5d6d2d4fc2e8879690452cbfb36015"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2d14d187f43e4409c7814b7d1693c03a270e77489b710d92fcbbaeca5de260d4"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win32.whl", hash = "sha256:e087364273ed7c717cd0191fed4be9def6fdf229fe9b536a4b8d0228f7814106"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:0da1ddb8285d97a6775c36265d7157acf1bbcb88bcc9a7ce9a4549906c822472"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:09bf07e74e897e97a49a9275d0a647819254ddb74142806bbbcf4777ed240a22"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp39-cp39-win32.whl", hash = "sha256:986e8d651b769a0e60d2834834bdd3f6959f6a88caa0c9acb917797e6b43a588"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:10da7d403ac4afd385fe13bd5808c9a5dd616a8ef31ca5c64cea3f87673661c1"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:679e471d21ac22cb50de1bf4dfc4c0c3f5da9f3e3fbc7f08dcacfe9de9d6dd58"}, {file = "winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf"}, ] [package.dependencies] winrt-runtime = ">=3.2.1.0,<3.3.0.0" [package.extras] all = ["winrt-Windows.ApplicationModel.Background[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Security.Credentials[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage.Streams[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.UI.Popups[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.UI[all] (>=3.2.1.0,<3.3.0.0)"] [[package]] name = "winrt-windows-devices-radios" version = "3.2.1" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = ">=3.9" groups = ["main"] markers = "platform_system == \"Windows\"" files = [ {file = "winrt_windows_devices_radios-3.2.1-cp310-cp310-win32.whl", hash = "sha256:f97766fd551d06c102155d51b2922f96663dee045e1f8d57177def0a2149cb78"}, {file = "winrt_windows_devices_radios-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:104b737fa1279a3b6a88ba3c6236157afc1de03c472657c45e5176ad7a209e23"}, {file = "winrt_windows_devices_radios-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:55b02877d2de06ca6f0f6140611a9af9d0c65710e28f1afdeaac1040433b1837"}, {file = "winrt_windows_devices_radios-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7c02790472414b6cda00d24a8cd23bca18e4b7474ddad4f9264f4484b891807e"}, {file = "winrt_windows_devices_radios-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f87745486d313ba1e7562ca97f25ad436ec01ad4b3b9ea349fb6b6f25cb41104"}, {file = "winrt_windows_devices_radios-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6cee6f946ff3a3571850d1ca745edaee7c331d06ca321873e650779654effc4a"}, {file = "winrt_windows_devices_radios-3.2.1-cp312-cp312-win32.whl", hash = "sha256:c3e683ce682338a5a5ed465f735e223ba7a22f16d0bbea2d070962bc7657edbb"}, {file = "winrt_windows_devices_radios-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:a116e552a3f38607b9be558fb2e7de9b4450d1f9080069944d74d80cdda1873e"}, {file = "winrt_windows_devices_radios-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4c28822f9251c9d547324f596b5c2581f050254ded05e5b786c650a3502744c1"}, {file = "winrt_windows_devices_radios-3.2.1-cp313-cp313-win32.whl", hash = "sha256:ae4a0065927fcd2d10215223f8a46be6fb89bad71cb4edd25dae3d01c137b3a8"}, {file = "winrt_windows_devices_radios-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:bf1a975f46a2aa271ffea1340be0c7e64985050d07433e701343dddc22a72290"}, {file = "winrt_windows_devices_radios-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:10b298ed154c5824cea2de174afce1694ed2aabfb58826de814074027ffef96f"}, {file = "winrt_windows_devices_radios-3.2.1-cp314-cp314-win32.whl", hash = "sha256:21452e1cae50e44cd1d5e78159e1b9986ac3389b66458ad89caa196ce5eca2d6"}, {file = "winrt_windows_devices_radios-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:6a8413e586fe597c6849607885cca7e0549da33ae5699165d11f7911534c6eaf"}, {file = "winrt_windows_devices_radios-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:39129fd9d09103adb003575f59881c1a5a70a43310547850150b46c6f4020312"}, {file = "winrt_windows_devices_radios-3.2.1-cp39-cp39-win32.whl", hash = "sha256:59b868d45ff22afad21b0b0d1466ec43e54543c4e4c6f1efcc2d4adc77053bd5"}, {file = "winrt_windows_devices_radios-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:dbfcbb977f60f19c852204987ace0cd6f7a432d735882a45b3074fdbfd3fdb5a"}, {file = "winrt_windows_devices_radios-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:659e07e6aa5542587ccfc4d4e2cc6e1ef0869606c867a3e95fc82cc8aeaf1f81"}, {file = "winrt_windows_devices_radios-3.2.1.tar.gz", hash = "sha256:4dc9b9d1501846049eb79428d64ec698d6476c27a357999b78a8331072e18a0b"}, ] [package.dependencies] winrt-runtime = ">=3.2.1.0,<3.3.0.0" [package.extras] all = ["winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)"] [[package]] name = "winrt-windows-foundation" version = "3.2.1" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = ">=3.9" groups = ["main"] markers = "platform_system == \"Windows\"" files = [ {file = "winrt_windows_foundation-3.2.1-cp310-cp310-win32.whl", hash = "sha256:677e98165dcbbf7a2367f905bc61090ef2c568b6e465f87cf7276df4734f3b0b"}, {file = "winrt_windows_foundation-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:a8f27b4f0fdb73ccc4a3e24bc8010a6607b2bdd722fa799eafce7daa87d19d39"}, {file = "winrt_windows_foundation-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:d900c6165fab4ea589811efa2feed27b532e1b6f505f63bf63e2052b8cb6bdc4"}, {file = "winrt_windows_foundation-3.2.1-cp311-cp311-win32.whl", hash = "sha256:d1b5970241ccd61428f7330d099be75f4f52f25e510d82c84dbbdaadd625e437"}, {file = "winrt_windows_foundation-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f3762be2f6e0f2aedf83a0742fd727290b397ffe3463d963d29211e4ebb53a7e"}, {file = "winrt_windows_foundation-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:806c77818217b3476e6c617293b3d5b0ff8a9901549dc3417586f6799938d671"}, {file = "winrt_windows_foundation-3.2.1-cp312-cp312-win32.whl", hash = "sha256:867642ccf629611733db482c4288e17b7919f743a5873450efb6d69ae09fdc2b"}, {file = "winrt_windows_foundation-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:45550c5b6c2125cde495c409633e6b1ea5aa1677724e3b95eb8140bfccbe30c9"}, {file = "winrt_windows_foundation-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:94f4661d71cb35ebc52be7af112f2eeabdfa02cb05e0243bf9d6bd2cafaa6f37"}, {file = "winrt_windows_foundation-3.2.1-cp313-cp313-win32.whl", hash = "sha256:3998dc58ed50ecbdbabace1cdef3a12920b725e32a5806d648ad3f4829d5ba46"}, {file = "winrt_windows_foundation-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6e98617c1e46665c7a56ce3f5d28e252798416d1ebfee3201267a644a4e3c479"}, {file = "winrt_windows_foundation-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2a8c1204db5c352f6a563130a5a41d25b887aff7897bb677d4ff0b660315aad4"}, {file = "winrt_windows_foundation-3.2.1-cp314-cp314-win32.whl", hash = "sha256:35e973ab3c77c2a943e139302256c040e017fd6ff1a75911c102964603bba1da"}, {file = "winrt_windows_foundation-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:a22a7ebcec0d262e60119cff728f32962a02df60471ded8b2735a655eccc0ef5"}, {file = "winrt_windows_foundation-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3be7fbae829b98a6a946db4fbaf356b11db1fbcbb5d4f37e7a73ac6b25de8b87"}, {file = "winrt_windows_foundation-3.2.1-cp39-cp39-win32.whl", hash = "sha256:14d5191725301498e4feb744d91f5b46ce317bf3d28370efda407d5c87f4423b"}, {file = "winrt_windows_foundation-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:de5e4f61d253a91ba05019dbf4338c43f962bdad935721ced5e7997933994af5"}, {file = "winrt_windows_foundation-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:ebbf6e8168398c9ed0c72c8bdde95a406b9fbb9a23e3705d4f0fe28e5a209705"}, {file = "winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656"}, ] [package.dependencies] winrt-runtime = ">=3.2.1.0,<3.3.0.0" [package.extras] all = ["winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)"] [[package]] name = "winrt-windows-foundation-collections" version = "3.2.1" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = ">=3.9" groups = ["main"] markers = "platform_system == \"Windows\"" files = [ {file = "winrt_windows_foundation_collections-3.2.1-cp310-cp310-win32.whl", hash = "sha256:46948484addfc4db981dab35688d4457533ceb54d4954922af41503fddaa8389"}, {file = "winrt_windows_foundation_collections-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:899eaa3a93c35bfb1857d649e8dd60c38b978dda7cedd9725fcdbcebba156fd6"}, {file = "winrt_windows_foundation_collections-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:c36eb49ad1eba1b32134df768bb47af13cabb9b59f974a3cea37843e2d80e0e6"}, {file = "winrt_windows_foundation_collections-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9b272d9936e7db4840881c5dcf921eb26789ae4ef23fb6ec15e13e19a16254e7"}, {file = "winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c646a5d442dd6540ade50890081ca118b41f073356e19032d0a5d7d0d38fbc89"}, {file = "winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:2c4630027c93cdd518b0cf4cc726b8fbdbc3388e36d02aa1de190a0fc18ca523"}, {file = "winrt_windows_foundation_collections-3.2.1-cp312-cp312-win32.whl", hash = "sha256:15704eef3125788f846f269cf54a3d89656fa09a1dc8428b70871f717d595ad6"}, {file = "winrt_windows_foundation_collections-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:550dfb8c82fe74d9e0728a2a16a9175cc9e34ca2b8ef758d69b2a398894b698b"}, {file = "winrt_windows_foundation_collections-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:810ad4bd11ab4a74fdbcd3ed33b597ef7c0b03af73fc9d7986c22bcf3bd24f84"}, {file = "winrt_windows_foundation_collections-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4267a711b63476d36d39227883aeb3fb19ac92b88a9fc9973e66fbce1fd4aed9"}, {file = "winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:5e12a6e75036ee90484c33e204b85fb6785fcc9e7c8066ad65097301f48cdd10"}, {file = "winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:34b556255562f1b36d07fba933c2bcd9f0db167fa96727a6cbb4717b152ad7a2"}, {file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win32.whl", hash = "sha256:33188ed2d63e844c8adfbb82d1d3d461d64aaf78d225ce9c5930421b413c45ab"}, {file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d4cfece7e9c0ead2941e55a1da82f20d2b9c8003bb7a8853bb7f999b539f80a4"}, {file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3884146fea13727510458f6a14040b7632d5d90127028b9bfd503c6c655d0c01"}, {file = "winrt_windows_foundation_collections-3.2.1-cp39-cp39-win32.whl", hash = "sha256:20610f098b84c87765018cbc71471092197881f3b92e5d06158fad3bfcea2563"}, {file = "winrt_windows_foundation_collections-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:e9739775320ac4c0238e1775d94a54e886d621f9995977e65d4feb8b3778c111"}, {file = "winrt_windows_foundation_collections-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:e4c6bddb1359d5014ceb45fe2ecd838d4afeb1184f2ea202c2d21037af0d08a3"}, {file = "winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5"}, ] [package.dependencies] winrt-runtime = ">=3.2.1.0,<3.3.0.0" [package.extras] all = ["winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)"] [[package]] name = "winrt-windows-storage-streams" version = "3.2.1" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = ">=3.9" groups = ["main"] markers = "platform_system == \"Windows\"" files = [ {file = "winrt_windows_storage_streams-3.2.1-cp310-cp310-win32.whl", hash = "sha256:89bb2d667ebed6861af36ed2710757456e12921ee56347946540320dacf6c003"}, {file = "winrt_windows_storage_streams-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:48a78e5dc7d3488eb77e449c278bc6d6ac28abcdda7df298462c4112d7635d00"}, {file = "winrt_windows_storage_streams-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:da71231d4a554f9f15f1249b4990c6431176f6dfb0e3385c7caa7896f4ca24d6"}, {file = "winrt_windows_storage_streams-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7dace2f9e364422255d0e2f335f741bfe7abb1f4d4f6003622b2450b87c91e69"}, {file = "winrt_windows_storage_streams-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:b02fa251a7eef6081eca1a5f64ecf349cfd1ac0ac0c5a5a30be52897d060bed5"}, {file = "winrt_windows_storage_streams-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:efdf250140340a75647e8e8ad002782d91308e9fdd1e19470a5b9cc969ae4780"}, {file = "winrt_windows_storage_streams-3.2.1-cp312-cp312-win32.whl", hash = "sha256:77c1f0e004b84347b5bd705e8f0fc63be8cd29a6093be13f1d0869d0d97b7d78"}, {file = "winrt_windows_storage_streams-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:e4508ee135af53e4fc142876abbf4bc7c2a95edfc7d19f52b291a8499cacd6dc"}, {file = "winrt_windows_storage_streams-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:040cb94e6fb26b0d00a00e8b88b06fadf29dfe18cf24ed6cb3e69709c3613307"}, {file = "winrt_windows_storage_streams-3.2.1-cp313-cp313-win32.whl", hash = "sha256:401bb44371720dc43bd1e78662615a2124372e7d5d9d65dfa8f77877bbcb8163"}, {file = "winrt_windows_storage_streams-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:202c5875606398b8bfaa2a290831458bb55f2196a39c1d4e5fa88a03d65ef915"}, {file = "winrt_windows_storage_streams-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:ca3c5ec0aab60895006bf61053a1aca6418bc7f9a27a34791ba3443b789d230d"}, {file = "winrt_windows_storage_streams-3.2.1-cp314-cp314-win32.whl", hash = "sha256:5cd0dbad86fcc860366f6515fce97177b7eaa7069da261057be4813819ba37ee"}, {file = "winrt_windows_storage_streams-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3c5bf41d725369b9986e6d64bad7079372b95c329897d684f955d7028c7f27a0"}, {file = "winrt_windows_storage_streams-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:293e09825559d0929bbe5de01e1e115f7a6283d8996ab55652e5af365f032987"}, {file = "winrt_windows_storage_streams-3.2.1-cp39-cp39-win32.whl", hash = "sha256:1c630cfdece58fcf82e4ed86c826326123529836d6d4d855ae8e9ceeff67b627"}, {file = "winrt_windows_storage_streams-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:d7ff22434a4829d616a04b068a191ac79e008f6c27541bb178c1f6f1fe7a1657"}, {file = "winrt_windows_storage_streams-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:fa90244191108f85f6f7afb43a11d365aca4e0722fe8adc62fb4d2c678d0993d"}, {file = "winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f"}, ] [package.dependencies] winrt-runtime = ">=3.2.1.0,<3.3.0.0" [package.extras] all = ["winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.System[all] (>=3.2.1.0,<3.3.0.0)"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" content-hash = "57c448255294b3fabd2b2a0c075266bb2b6af0f48bfea24bed77bd561debe8fd" Bluetooth-Devices-bleak-retry-connector-2326a9d/pyproject.toml000066400000000000000000000052651520367436400245400ustar00rootroot00000000000000[project] name = "bleak-retry-connector" version = "4.6.1" description = "A connector for Bleak Clients that handles transient connection failures" authors = [{ name = "J. Nick Koston", email = "nick@koston.org" }] license = "MIT" readme = "README.md" requires-python = ">=3.10" dynamic = ["classifiers", "dependencies"] [project.urls] "Documentation" = "https://bleak-retry-connector.readthedocs.io" "Repository" = "https://github.com/bluetooth-devices/bleak-retry-connector" "Bug Tracker" = "https://github.com/bluetooth-devices/bleak-retry-connector/issues" "Changelog" = "https://github.com/bluetooth-devices/bleak-retry-connector/blob/main/CHANGELOG.md" [tool.poetry] classifiers = [ "Development Status :: 2 - Pre-Alpha", "Intended Audience :: Developers", "Natural Language :: English", "Operating System :: OS Independent", "Topic :: Software Development :: Libraries", ] packages = [ { include = "bleak_retry_connector", from = "src" }, ] [tool.poetry.dependencies] python = ">=3.10,<4.0" bleak = ">=2" async-timeout = {version = ">=3.0.0", python = "<3.11"} dbus-fast = {version = ">=4.3.0", markers = "platform_system == \"Linux\""} bluetooth-adapters = {version = ">=0.15.2", markers = "platform_system == \"Linux\""} [tool.poetry.group.dev.dependencies] dbus-fast = ">=4.3.0" pytest = "^9.0" pytest-cov = "^7.1" pytest-asyncio = "^1.3.0" blockbuster = ">=1.5.23,<1.6" [tool.poetry.group.docs.dependencies] myst-parser = ">=4.0.1" sphinx-rtd-theme = ">=3.0.2" sphinx = ">=8" [tool.semantic_release] branch = "main" version_toml = ["pyproject.toml:project.version"] version_variables = ["src/bleak_retry_connector/__init__.py:__version__"] build_command = "pip install poetry && poetry build" [tool.pytest.ini_options] addopts = "-v -Wdefault --cov=bleak_retry_connector --cov-report=term-missing:skip-covered" pythonpath = ["src"] [tool.coverage.run] branch = true [tool.coverage.report] exclude_lines = [ "pragma: no cover", "@overload", "if TYPE_CHECKING", "raise NotImplementedError", ] [tool.ruff] line-length = 88 target-version = "py310" [tool.ruff.lint] select = ["I"] [tool.ruff.lint.isort] known-first-party = ["bleak_retry_connector", "tests"] [tool.mypy] check_untyped_defs = true disallow_any_generics = true disallow_incomplete_defs = true disallow_untyped_defs = true mypy_path = "src/" no_implicit_optional = true show_error_codes = true warn_unreachable = true warn_unused_ignores = true exclude = [ 'docs/.*', 'setup.py', ] [[tool.mypy.overrides]] module = "tests.*" allow_untyped_defs = true [[tool.mypy.overrides]] module = "docs.*" ignore_errors = true [build-system] requires = ["poetry-core>=2.0.0"] build-backend = "poetry.core.masonry.api" Bluetooth-Devices-bleak-retry-connector-2326a9d/renovate.json000066400000000000000000000001011520367436400243220ustar00rootroot00000000000000{ "extends": ["github>browniebroke/renovate-configs:python"] } Bluetooth-Devices-bleak-retry-connector-2326a9d/setup.py000066400000000000000000000003731520367436400233310ustar00rootroot00000000000000#!/usr/bin/env python # This is a shim to allow GitHub to detect the package, build is done with poetry # Taken from https://github.com/Textualize/rich import setuptools if __name__ == "__main__": setuptools.setup(name="bleak-retry-connector") Bluetooth-Devices-bleak-retry-connector-2326a9d/src/000077500000000000000000000000001520367436400224035ustar00rootroot00000000000000Bluetooth-Devices-bleak-retry-connector-2326a9d/src/bleak_retry_connector/000077500000000000000000000000001520367436400267605ustar00rootroot00000000000000Bluetooth-Devices-bleak-retry-connector-2326a9d/src/bleak_retry_connector/__init__.py000066400000000000000000000555221520367436400311020ustar00rootroot00000000000000from __future__ import annotations __version__ = "4.6.1" import asyncio import logging from collections.abc import Awaitable, Callable from typing import Any, ParamSpec, TypeVar from bleak import BleakClient, BleakScanner from bleak.backends.device import BLEDevice from bleak.backends.service import BleakGATTServiceCollection from bleak.exc import BleakDBusError, BleakDeviceNotFoundError, BleakError from .bluez import ( # noqa: F401 AllocationChange, AllocationChangeEvent, Allocations, BleakSlotManager, _get_properties, _get_services_cache, clear_cache, device_source, get_connected_devices, get_device, get_device_by_adapter, path_from_ble_device, wait_for_device_to_reappear, wait_for_disconnect, ) from .const import DISCONNECT_TIMEOUT, IS_LINUX, NO_RSSI_VALUE, RSSI_SWITCH_THRESHOLD from .util import asyncio_timeout DEFAULT_ATTEMPTS = 2 if IS_LINUX: from bluetooth_adapters import load_history_from_managed_objects from .dbus import disconnect_devices else: load_history_from_managed_objects = None disconnect_devices = None # type: ignore[assignment] # Make sure bleak and dbus-fast have time # to run their cleanup callbacks or the # retry call will just fail in the same way. BLEAK_TRANSIENT_BACKOFF_TIME = 0.25 BLEAK_TRANSIENT_MEDIUM_BACKOFF_TIME = 0.50 BLEAK_TRANSIENT_LONG_BACKOFF_TIME = 1.0 BLEAK_DBUS_BACKOFF_TIME = 0.25 BLEAK_OUT_OF_SLOTS_BACKOFF_TIME = 4.00 BLEAK_BACKOFF_TIME = 0.1 # Expected disconnect or ran out of slots # after checking, don't backoff since we # want to retry immediately. BLEAK_DISCONNECTED_BACKOFF_TIME = 0.0 __all__ = [ "BleakSlotManager", # Currently only possible for BlueZ, for MacOS we have no of knowing "ble_device_description", "establish_connection", "close_stale_connections", "close_stale_connections_by_address", "clear_cache", "get_device", "get_device_by_adapter", "device_source", "restore_discoveries", "retry_bluetooth_connection_error", "BleakClientWithServiceCache", "BleakAbortedError", "BleakConnectionError", "BleakNotFoundError", "BleakOutOfConnectionSlotsError", "BLEAK_RETRY_EXCEPTIONS", "DISCONNECT_TIMEOUT", "RSSI_SWITCH_THRESHOLD", "NO_RSSI_VALUE", ] BLEAK_EXCEPTIONS = (AttributeError, BleakError) BLEAK_RETRY_EXCEPTIONS = ( *BLEAK_EXCEPTIONS, EOFError, BrokenPipeError, asyncio.TimeoutError, ) _LOGGER = logging.getLogger(__name__) MAX_TRANSIENT_ERRORS = 9 # Shorter time outs and more attempts # seems to be better for dbus, and corebluetooth # is happy either way. Ideally we want everything # to finish in < 60s or declare we cannot connect MAX_CONNECT_ATTEMPTS = 4 BLEAK_TIMEOUT = 20.0 # Bleak may not always timeout # since the dbus connection can stall # so we have an additional timeout to # be sure we do not block forever # This is likely fixed in https://github.com/hbldh/bleak/pull/1092 # # This also accounts for the time it # takes for the esp32s to disconnect # BLEAK_SAFETY_TIMEOUT = 60.0 TRANSIENT_ERRORS_LONG_BACKOFF = { "ESP_GATT_ERROR", } TRANSIENT_ERRORS_MEDIUM_BACKOFF = { "ESP_GATT_CONN_TIMEOUT", "ESP_GATT_CONN_FAIL_ESTABLISH", } DEVICE_MISSING_ERRORS = {"org.freedesktop.DBus.Error.UnknownObject"} # ESP_GATT_CONN_CONN_CANCEL (0x100) indicates the ESP32 rejected the connection due to # limited resources (HCI error 0x0d). This happens when ESPHome incorrectly marks a # connection slot as free at ESP_GATTC_DISCONNECT_EVT instead of waiting for # ESP_GATTC_CLOSE_EVT, causing a race where we try to use a slot that isn't actually # available yet. The 4-second backoff gives time for the slot to truly become available # and for the state to sync with Home Assistant. # See: https://github.com/espressif/esp-idf/issues/17452 OUT_OF_SLOTS_ERRORS = { "available connection", "connection slot", "ESP_GATT_CONN_CONN_CANCEL", } TRANSIENT_ERRORS = { "le-connection-abort-by-local", "br-connection-canceled", "ESP_GATT_CONN_FAIL_ESTABLISH", "ESP_GATT_CONN_TERMINATE_PEER_USER", "ESP_GATT_CONN_TERMINATE_LOCAL_HOST", } | OUT_OF_SLOTS_ERRORS # Currently the same as transient error ABORT_ERRORS = ( TRANSIENT_ERRORS | TRANSIENT_ERRORS_MEDIUM_BACKOFF | TRANSIENT_ERRORS_LONG_BACKOFF ) ABORT_ADVICE = ( "Interference/range; " "External Bluetooth adapter w/extension may help; " "Extension cables reduce USB 3 port interference" ) DEVICE_MISSING_ADVICE = ( "The device disappeared; Try restarting the scanner or moving the device closer" ) OUT_OF_SLOTS_ADVICE = ( "The proxy/adapter is out of connection slots or the device is no longer reachable; " "Add additional proxies (https://esphome.github.io/bluetooth-proxies/) near this device" ) NORMAL_DISCONNECT = "Disconnected" class BleakNotFoundError(BleakError): """The device was not found.""" class BleakConnectionError(BleakError): """General connection failure after all retries.""" class BleakAbortedError(BleakError): """The connection was aborted.""" class BleakOutOfConnectionSlotsError(BleakError): """The proxy/adapter is out of connection slots.""" class BleakClientWithServiceCache(BleakClient): """A BleakClient that implements service caching.""" def set_cached_services(self, services: BleakGATTServiceCollection | None) -> None: """Set the cached services. No longer used since bleak 0.17+ has service caching built-in. This was only kept for backwards compatibility. """ async def clear_cache(self) -> bool: """Clear the cached services.""" if hasattr(super(), "clear_cache"): return await super().clear_cache() _LOGGER.warning("clear_cache not implemented in bleak version") return False async def set_connection_params( self, min_interval: int, max_interval: int, latency: int, timeout: int, ) -> None: """Set BLE connection parameters.""" if hasattr(super(), "set_connection_params"): await super().set_connection_params( min_interval, max_interval, latency, timeout ) return _LOGGER.warning("set_connection_params not implemented in bleak version") def ble_device_has_changed(original: BLEDevice, new: BLEDevice) -> bool: """Check if the device has changed.""" return bool( original.address != new.address or ( isinstance(original.details, dict) and isinstance(new.details, dict) and "path" in original.details and "path" in new.details and original.details["path"] != new.details["path"] ) ) def ble_device_description(device: BLEDevice) -> str: """Get the device description.""" details = device.details address = device.address name = device.name if name != address: base_name = f"{address} - {name}" else: base_name = address if isinstance(details, dict): if path := details.get("path"): # /org/bluez/hci2 return f"{base_name} -> {path[0:15]}" if source := details.get("source"): return f"{base_name} -> {source}" return base_name async def _has_valid_services_in_cache(device: BLEDevice) -> bool: """Check if the device has valid services in cache. For non-Linux platforms (macOS, ESPHome proxies), the cache is always valid and we return True to allow using cached services. For Linux/BlueZ, this function validates that cached services are still valid by checking if they exist in the BlueZ manager's properties. Due to race conditions, the properties and services cache can become out of sync. If properties have disappeared but the services cache still contains them, the cache is stale and should not be used. Returns: - True for non-Linux platforms (cache always valid) - True for Linux if all cached services are still present in D-Bus properties - False for Linux if cache is stale or unavailable """ if not IS_LINUX: # Cache is always valid on macOS and ESPHome proxies return True # Check if this is a BlueZ device if not (device_path := path_from_ble_device(device)): # Not a BlueZ device (might be ESPHome proxy), cache is valid return True # Get the services cache if not (services_cache := await _get_services_cache()): _LOGGER.debug( "%s - %s: No services cache available, cannot validate", device.name or "Unknown", device.address, ) return False # Check if services are cached for this device if not (cached_services := services_cache.get(device_path)): _LOGGER.debug( "%s - %s: No cached services found for device path %s", device.name or "Unknown", device.address, device_path, ) return False # Get current properties to check if cached services are still present if not (properties := await _get_properties()): _LOGGER.debug( "%s - %s: Could not get properties to validate cache", device.name or "Unknown", device.address, ) return False service_count = len(cached_services.services) if not service_count: _LOGGER.debug( "%s - %s: No cached services found for device path %s", device.name or "Unknown", device.address, device_path, ) return False # Check if all cached services are still present in properties # The cached_services is a BleakGATTServiceCollection object for service in cached_services: if service.obj[0] not in properties: # Service is in cache but not in properties (not on the bus) _LOGGER.debug( "%s - %s: Cached service %s not found in properties, cache invalid", device.name or "Unknown", device.address, service, ) return False _LOGGER.debug( "%s - %s: All %d cached services are valid and present in properties", device.name or "Unknown", device.address, service_count, ) return True def calculate_backoff_time(exc: Exception) -> float: """Calculate the backoff time based on the exception.""" if isinstance( exc, (BleakDBusError, EOFError, asyncio.TimeoutError, BrokenPipeError) ): return BLEAK_DBUS_BACKOFF_TIME # If the adapter runs out of slots can get a BleakDeviceNotFoundError # since the device is no longer visible on the adapter. Almost none of # the adapters document how many connection slots they have so we cannot # know if we are out of slots or not. We can only guess based on the # error message and backoff. if isinstance(exc, (BleakDeviceNotFoundError, BleakNotFoundError)): return BLEAK_OUT_OF_SLOTS_BACKOFF_TIME if isinstance(exc, BleakError): bleak_error = str(exc) if any(error in bleak_error for error in OUT_OF_SLOTS_ERRORS): return BLEAK_OUT_OF_SLOTS_BACKOFF_TIME if any(error in bleak_error for error in TRANSIENT_ERRORS_MEDIUM_BACKOFF): return BLEAK_TRANSIENT_MEDIUM_BACKOFF_TIME if any(error in bleak_error for error in TRANSIENT_ERRORS_LONG_BACKOFF): return BLEAK_TRANSIENT_LONG_BACKOFF_TIME if any(error in bleak_error for error in TRANSIENT_ERRORS): return BLEAK_TRANSIENT_BACKOFF_TIME if NORMAL_DISCONNECT in bleak_error: return BLEAK_DISCONNECTED_BACKOFF_TIME return BLEAK_BACKOFF_TIME async def _disconnect_devices(devices: list[BLEDevice]) -> None: """Disconnect the devices.""" if IS_LINUX: await disconnect_devices(devices) async def close_stale_connections_by_address( address: str, only_other_adapters: bool = False ) -> None: """Close stale connections by address.""" if not IS_LINUX or not (device := await get_device(address)): return await close_stale_connections(device, only_other_adapters) async def close_stale_connections( device: BLEDevice, only_other_adapters: bool = False ) -> None: """Close stale connections.""" if not IS_LINUX or not (devices := await get_connected_devices(device)): return to_disconnect: list[BLEDevice] = [] for connected_device in devices: if only_other_adapters and not ble_device_has_changed(connected_device, device): _LOGGER.debug( "%s - %s: unexpectedly connected, not disconnecting since only_other_adapters is set", connected_device.name, connected_device.address, ) else: _LOGGER.debug( "%s - %s: unexpectedly connected, disconnecting", connected_device.name, connected_device.address, ) to_disconnect.append(connected_device) if not to_disconnect: return await _disconnect_devices(to_disconnect) AnyBleakClient = TypeVar("AnyBleakClient", bound=BleakClient) async def establish_connection( client_class: type[AnyBleakClient], device: BLEDevice, name: str, disconnected_callback: Callable[[AnyBleakClient], None] | None = None, max_attempts: int = MAX_CONNECT_ATTEMPTS, cached_services: BleakGATTServiceCollection | None = None, ble_device_callback: Callable[[], BLEDevice] | None = None, use_services_cache: bool = True, pair: bool = False, **kwargs: Any, ) -> AnyBleakClient: """Establish a connection to the device.""" timeouts = 0 connect_errors = 0 transient_errors = 0 attempt = 0 def _raise_if_needed(name: str, description: str, exc: Exception) -> None: """Raise if we reach the max attempts.""" if ( timeouts + connect_errors < max_attempts and transient_errors < MAX_TRANSIENT_ERRORS ): return msg = ( f"{name} - {description}: Failed to connect after " f"{attempt} attempt(s): {str(exc) or type(exc).__name__}" ) # Sure would be nice if bleak gave us typed exceptions if isinstance(exc, asyncio.TimeoutError): raise BleakNotFoundError(msg) from exc if isinstance(exc, BleakDeviceNotFoundError) or "not found" in str(exc): raise BleakNotFoundError(f"{msg}: {DEVICE_MISSING_ADVICE}") from exc if isinstance(exc, BleakError): if any(error in str(exc) for error in OUT_OF_SLOTS_ERRORS): raise BleakOutOfConnectionSlotsError( f"{msg}: {OUT_OF_SLOTS_ADVICE}" ) from exc if any(error in str(exc) for error in ABORT_ERRORS): raise BleakAbortedError(f"{msg}: {ABORT_ADVICE}") from exc if any(error in str(exc) for error in DEVICE_MISSING_ERRORS): raise BleakNotFoundError(f"{msg}: {DEVICE_MISSING_ADVICE}") from exc raise BleakConnectionError(msg) from exc debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) if IS_LINUX and (devices := await get_connected_devices(device)): # Bleak 0.17 will handle already connected devices for us so # if we are already connected we swap the device to the connected # device. device = devices[0] client = client_class( device, disconnected_callback=disconnected_callback, pair=pair, _is_retry_client=True, **kwargs, ) while True: attempt += 1 if debug_enabled: _LOGGER.debug( "%s - %s: Connection attempt: %s", name, device.address, attempt, ) try: async with asyncio_timeout(BLEAK_SAFETY_TIMEOUT): # Only use cache if we have valid services in the cache should_use_cache = use_services_cache or bool(cached_services) if should_use_cache: should_use_cache = await _has_valid_services_in_cache(device) await client.connect( timeout=BLEAK_TIMEOUT, dangerous_use_bleak_cache=should_use_cache, ) if debug_enabled: _LOGGER.debug( "%s - %s: Connected after %s attempts", name, device.address, attempt, ) except asyncio.TimeoutError as exc: timeouts += 1 if debug_enabled: _LOGGER.debug( "%s - %s: Timed out trying to connect (attempt: %s)", name, device.address, attempt, ) backoff_time = calculate_backoff_time(exc) await wait_for_disconnect(device, backoff_time) _raise_if_needed(name, device.address, exc) except KeyError as exc: # Likely: KeyError: 'org.bluez.GattService1' from bleak # ideally we would get a better error from bleak, but this is # better than nothing. # self._properties[service_path][defs.GATT_SERVICE_INTERFACE] transient_errors += 1 if debug_enabled: _LOGGER.debug( "%s - %s: Failed to connect due to services changes: %s (attempt: %s)", name, device.address, str(exc), attempt, ) if isinstance(client, BleakClientWithServiceCache): await client.clear_cache() await client.disconnect() backoff_time = calculate_backoff_time(exc) await wait_for_disconnect(device, backoff_time) _raise_if_needed(name, device.address, exc) except BrokenPipeError as exc: # BrokenPipeError is raised by dbus-next when the device disconnects # # bleak.exc.BleakDBusError: [org.bluez.Error] le-connection-abort-by-local # During handling of the above exception, another exception occurred: # Traceback (most recent call last): # File "bleak/backends/bluezdbus/client.py", line 177, in connect # reply = await self._bus.call( # File "dbus_next/aio/message_bus.py", line 63, in write_callback # self.offset += self.sock.send(self.buf[self.offset:]) # BrokenPipeError: [Errno 32] Broken pipe transient_errors += 1 if debug_enabled: _LOGGER.debug( "%s - %s: Failed to connect: %s (attempt: %s)", name, device.address, str(exc), attempt, ) _raise_if_needed(name, device.address, exc) except EOFError as exc: transient_errors += 1 backoff_time = calculate_backoff_time(exc) if debug_enabled: _LOGGER.debug( "%s - %s: Failed to connect: %s, backing off: %s (attempt: %s)", name, device.address, str(exc), backoff_time, attempt, ) await wait_for_disconnect(device, backoff_time) _raise_if_needed(name, device.address, exc) except BLEAK_EXCEPTIONS as exc: bleak_error = str(exc) # BleakDeviceNotFoundError can mean that the adapter has run out of # connection slots. device_missing = isinstance( exc, (BleakNotFoundError, BleakDeviceNotFoundError) ) if device_missing or any( error in bleak_error for error in TRANSIENT_ERRORS ): transient_errors += 1 else: connect_errors += 1 backoff_time = calculate_backoff_time(exc) if debug_enabled: _LOGGER.debug( "%s - %s: Failed to connect: %s, device_missing: %s, backing off: %s (attempt: %s)", name, device.address, bleak_error, device_missing, backoff_time, attempt, ) await wait_for_disconnect(device, backoff_time) _raise_if_needed(name, device.address, exc) else: return client # Ensure the disconnect callback # has a chance to run before we try to reconnect await asyncio.sleep(0) raise RuntimeError("This should never happen") P = ParamSpec("P") T = TypeVar("T") def retry_bluetooth_connection_error( attempts: int = DEFAULT_ATTEMPTS, ) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]: """Define a wrapper to retry on bluetooth connection error.""" def _decorator_retry_bluetooth_connection_error( func: Callable[P, Awaitable[T]], ) -> Callable[P, Awaitable[T]]: """Define a wrapper to retry on bleak error. The accessory is allowed to disconnect us any time so we need to retry the operation. """ async def _async_wrap_bluetooth_connection_error_retry( # type: ignore[return] *args: P.args, **kwargs: P.kwargs ) -> T: for attempt in range(attempts): try: return await func(*args, **kwargs) except BLEAK_EXCEPTIONS as ex: backoff_time = calculate_backoff_time(ex) if attempt == attempts - 1: raise _LOGGER.debug( "Bleak error calling %s, backing off: %s, retrying...", func, backoff_time, exc_info=True, ) await asyncio.sleep(backoff_time) return _async_wrap_bluetooth_connection_error_retry return _decorator_retry_bluetooth_connection_error async def restore_discoveries(scanner: BleakScanner, adapter: str) -> None: """Restore discoveries from the bus.""" if not IS_LINUX: # This is only supported on Linux return if not (properties := await _get_properties()): _LOGGER.debug("Failed to restore discoveries for %s", adapter) return backend = scanner._backend before = len(backend.seen_devices) details: dict[str, Any] backend.seen_devices.update( { path: (device, history.advertisement_data) for history in load_history_from_managed_objects( properties, adapter ).values() if (device := history.device) and (details := device.details) and (path := details.get("path")) } ) _LOGGER.debug( "Restored %s discoveries for %s", len(backend.seen_devices) - before, adapter ) Bluetooth-Devices-bleak-retry-connector-2326a9d/src/bleak_retry_connector/bleak_manager.py000066400000000000000000000043641520367436400321110ustar00rootroot00000000000000from __future__ import annotations import asyncio import contextlib import logging from .const import DBUS_CONNECT_TIMEOUT, IS_LINUX from .util import asyncio_timeout _LOGGER = logging.getLogger(__name__) _global_instances: dict[asyncio.AbstractEventLoop, BlueZManager] | None = None if IS_LINUX: with contextlib.suppress(ImportError): # pragma: no cover from bleak.backends.bluezdbus.manager import ( # pragma: no cover BlueZManager, get_global_bluez_manager, ) with contextlib.suppress(ImportError): # pragma: no cover from bleak.backends.bluezdbus.manager import ( # type: ignore[no-redef] # pragma: no cover _global_instances, ) async def get_global_bluez_manager_with_timeout() -> BlueZManager | None: """Get the properties.""" if not IS_LINUX: return None loop = asyncio.get_running_loop() if _global_instances and (manager := _global_instances.get(loop)): return manager if ( getattr(get_global_bluez_manager_with_timeout, "_has_dbus_socket", None) is False ): # We are not running on a system with DBus do don't # keep trying to call get_global_bluez_manager as it # waits for a bit trying to connect to DBus. return None try: async with asyncio_timeout(DBUS_CONNECT_TIMEOUT): return await get_global_bluez_manager() except FileNotFoundError as ex: setattr(get_global_bluez_manager_with_timeout, "_has_dbus_socket", False) _LOGGER.debug( "Dbus socket at %s not found, will not try again until next restart: %s", ex.filename, ex, ) except asyncio.TimeoutError: setattr(get_global_bluez_manager_with_timeout, "_has_dbus_socket", False) _LOGGER.debug( "Timed out trying to connect to DBus; will not try again until next restart" ) except Exception as ex: # pylint: disable=broad-except _LOGGER.debug( "get_global_bluez_manager_with_timeout failed: %s", ex, exc_info=True ) return None def _reset_dbus_socket_cache() -> None: """Reset the dbus socket cache.""" setattr(get_global_bluez_manager_with_timeout, "_has_dbus_socket", None) Bluetooth-Devices-bleak-retry-connector-2326a9d/src/bleak_retry_connector/bluez.py000066400000000000000000000473651520367436400304720ustar00rootroot00000000000000from __future__ import annotations import asyncio import contextlib import logging import time from collections.abc import Callable, Generator from dataclasses import dataclass from enum import Enum from functools import partial from typing import Any from bleak import BleakGATTServiceCollection from bleak.backends.device import BLEDevice from bleak.exc import BleakError from .bleak_manager import get_global_bluez_manager_with_timeout from .const import ( DISCONNECT_TIMEOUT, IS_LINUX, NO_RSSI_VALUE, REAPPEAR_WAIT_INTERVAL, RSSI_SWITCH_THRESHOLD, ) from .util import asyncio_timeout if IS_LINUX: from dbus_fast.message import Message _LOGGER = logging.getLogger(__name__) if IS_LINUX: with contextlib.suppress(ImportError): # pragma: no cover from bleak.backends.bluezdbus import defs # pragma: no cover from bleak.backends.bluezdbus.manager import ( # pragma: no cover BlueZManager, DeviceWatcher, ) class AllocationChange(Enum): """Allocation change.""" ALLOCATED = 1 RELEASED = 2 @dataclass(slots=True) class AllocationChangeEvent: change: AllocationChange path: str | None # D-Bus object path of the device adapter: str # Adapter/Controller (hciX) address: str # Address of the remote BLE device @dataclass(slots=True) class Allocations: adapter: str # Adapter/Controller (hciX) slots: int # Number of slots free: int # Number of free slots allocated: list[str] # Addresses of connected devices def device_source(device: BLEDevice) -> str | None: """Return the device source.""" return _device_details_value_or_none(device, "source") def _device_details_value_or_none(device: BLEDevice, key: str) -> Any | None: """Return a value from device details or None.""" details = device.details if not isinstance(details, dict) or key not in details: return None key_value: str = device.details[key] return key_value def adapter_from_path(path: str) -> str: """Get the adapter from a ble device path.""" return path.split("/")[3] def address_from_path(path: str) -> str: """Get the address from a ble device path.""" return path.split("/")[-1].removeprefix("dev_").replace("_", ":").upper() def path_from_ble_device(device: BLEDevice) -> str | None: """Get the adapter from a ble device.""" return _device_details_value_or_none(device, "path") def _on_characteristic_value_changed(*args: Any, **kwargs: Any) -> None: """Dummy callback for registering characteristic value changed.""" class BleakSlotManager: """A class to manage the connection slots.""" def __init__(self) -> None: """Initialize the class.""" self._adapter_slots: dict[str, int] = {} self._allocations_by_adapter: dict[str, dict[str, DeviceWatcher]] = {} self._manager: BlueZManager | None = None self._callbacks: set[Callable[[AllocationChangeEvent], None]] = set() async def async_setup(self) -> None: """Set up the class.""" self._manager = await get_global_bluez_manager_with_timeout() def diagnostics(self) -> dict[str, Any]: """Return diagnostics.""" return { "manager": self._manager is not None, "adapter_slots": self._adapter_slots, "allocations_by_adapter": { adapter: self._get_allocations(adapter) for adapter in self._adapter_slots }, } def get_allocations(self, adapter: str) -> Allocations: """Get the allocations.""" slots = self._adapter_slots.get(adapter, 0) allocated: list[str] = [] if adapter in self._allocations_by_adapter: allocated = [ address_from_path(path) for path in self._allocations_by_adapter[adapter] ] free = slots - len(allocated) return Allocations(adapter, slots, free, allocated) def _get_allocations(self, adapter: str) -> list[str]: """Get connected path allocations.""" if self._manager is None or adapter not in self._allocations_by_adapter: return [] return list(self._allocations_by_adapter[adapter]) def remove_adapter(self, adapter: str) -> None: """Remove an adapter.""" del self._adapter_slots[adapter] watchers = self._allocations_by_adapter[adapter] if self._manager is None: return for watcher in watchers.values(): self._manager.remove_device_watcher(watcher) del self._allocations_by_adapter[adapter] def register_allocation_callback( self, callback: Callable[[AllocationChangeEvent], None] ) -> Callable[[], None]: """Register a callback for when allocations change.""" self._callbacks.add(callback) return partial(self.unregister_allocation_callback, callback) def unregister_allocation_callback( self, callback: Callable[[AllocationChangeEvent], None] ) -> None: """Unregister a callback.""" self._callbacks.discard(callback) def register_adapter(self, adapter: str, slots: int) -> None: """Register an adapter.""" self._allocations_by_adapter[adapter] = {} self._adapter_slots[adapter] = slots if self._manager is None: return for path, device in self._manager._properties.items(): if ( defs.DEVICE_INTERFACE in device and device[defs.DEVICE_INTERFACE].get("Connected") and adapter_from_path(path) == adapter ): self._allocate_and_watch_slot(path) def _allocate_and_watch_slot(self, path: str) -> None: """Setup a device watcher.""" assert self._manager is not None # nosec adapter = adapter_from_path(path) allocations = self._allocations_by_adapter[adapter] def _on_device_connected_changed(connected: bool) -> None: if not connected: self._release_slot(path) allocations[path] = self._manager.add_device_watcher( path, on_connected_changed=_on_device_connected_changed, on_characteristic_value_changed=_on_characteristic_value_changed, ) self._call_callbacks(AllocationChange.ALLOCATED, path) def release_slot(self, device: BLEDevice) -> None: """Release a slot.""" if ( self._manager is None or not (path := path_from_ble_device(device)) or self._manager.is_connected(path) ): return self._release_slot(path) def _release_slot(self, path: str) -> None: """Unconditional release of the slot.""" assert self._manager is not None # nosec adapter = adapter_from_path(path) if adapter not in self._allocations_by_adapter: # Adapter was already removed (e.g., unplugged) _LOGGER.debug( "Cannot release slot for %s: adapter %s not found", path, adapter ) return allocations = self._allocations_by_adapter[adapter] if watcher := allocations.pop(path, None): self._manager.remove_device_watcher(watcher) self._call_callbacks(AllocationChange.RELEASED, path) def _call_callbacks(self, change: AllocationChange, path: str) -> None: """Call the callbacks.""" for callback_ in self._callbacks: try: callback_( AllocationChangeEvent( change, path, adapter_from_path(path), address_from_path(path) ) ) except Exception: # pylint _LOGGER.exception("Error in callback") def allocate_slot(self, device: BLEDevice) -> bool: """Allocate a slot.""" if ( self._manager is None or not (path := path_from_ble_device(device)) or not (adapter := adapter_from_path(path)) or adapter not in self._allocations_by_adapter ): return True allocations = self._allocations_by_adapter[adapter] if path in allocations: # Already connected return True if len(allocations) >= self._adapter_slots[adapter]: _LOGGER.debug( "No slots available for %s (used by: %s)", path, self._get_allocations(adapter), ) return False self._allocate_and_watch_slot(path) return True async def _get_properties() -> dict[str, dict[str, dict[str, Any]]] | None: """Get the properties.""" if bluez_manager := await get_global_bluez_manager_with_timeout(): return bluez_manager._properties # pylint: disable=protected-access return None async def _get_services_cache() -> dict[str, BleakGATTServiceCollection] | None: """Get the services cache.""" if bluez_manager := await get_global_bluez_manager_with_timeout(): return bluez_manager._services_cache # pylint: disable=protected-access return None async def clear_cache(address: str) -> bool: """Clear the cache for a device.""" if not IS_LINUX: return False caches_cleared: list[str] = [] with contextlib.suppress(Exception): if not await get_device(address): return False if (services_cache := await _get_services_cache()) is None: _LOGGER.warning( "Failed to clear cache for %s because no services cache", address ) return False if not (manager := await get_global_bluez_manager_with_timeout()): _LOGGER.warning("Failed to clear cache for %s because no manager", address) return False bluez_path = address_to_bluez_path(address) for path in _get_possible_paths(bluez_path): if services_cache.pop(path, None): caches_cleared.append(path) _LOGGER.debug("Cleared cache for %s: %s", address, caches_cleared) async with asyncio_timeout(DISCONNECT_TIMEOUT): for device_path in caches_cleared: # Send since we are going to ignore errors # in case the device is already gone await manager._bus.send( Message( destination=defs.BLUEZ_SERVICE, path=adapter_path_from_device_path(device_path), interface=defs.ADAPTER_INTERFACE, member="RemoveDevice", signature="o", body=[device_path], ) ) return bool(caches_cleared) async def stop_discovery(adapter_name: str) -> None: """Stop discovery on an adapter. :param adapter_name: The adapter name (hciX). """ if manager := await get_global_bluez_manager_with_timeout(): adapter_path = f"/org/bluez/{adapter_name}" await manager._bus.send( Message( destination=defs.BLUEZ_SERVICE, path=adapter_path, interface=defs.ADAPTER_INTERFACE, member="StopDiscovery", ) ) else: _LOGGER.error( "Failed to stop discovery for %s because no manager", adapter_name ) def adapter_path_from_device_path(device_path: str) -> str: """ Scrape the adapter path from a D-Bus device path. Args: device_path: The D-Bus object path of the device. Returns: A D-Bus object path of the adapter. """ # /org/bluez/hci1/dev_FA_23_9D_AA_45_46 return device_path[:15] async def wait_for_device_to_reappear(device: BLEDevice, wait_timeout: float) -> bool: """Wait for a device to reappear on the bus.""" await asyncio.sleep(0) if ( not IS_LINUX or not isinstance(device.details, dict) or "path" not in device.details or not (properties := await _get_properties()) ): await asyncio.sleep(wait_timeout) return False debug = _LOGGER.isEnabledFor(logging.DEBUG) device_path = address_to_bluez_path(device.address) for i in range(int(wait_timeout / REAPPEAR_WAIT_INTERVAL)): for path in _get_possible_paths(device_path): if path in properties and properties[path].get(defs.DEVICE_INTERFACE): if debug: _LOGGER.debug( "%s - %s: Device re-appeared on bus after %s seconds as %s", device.name, device.address, i * REAPPEAR_WAIT_INTERVAL, path, ) return True if debug: _LOGGER.debug( "%s - %s: Waiting %s/%s for device to re-appear on bus", device.name, device.address, (i + 1) * REAPPEAR_WAIT_INTERVAL, wait_timeout, ) await asyncio.sleep(REAPPEAR_WAIT_INTERVAL) if debug: _LOGGER.debug( "%s - %s: Device did not re-appear on bus after %s seconds", device.name, device.address, wait_timeout, ) return False async def wait_for_disconnect(device: BLEDevice, min_wait_time: float) -> None: """Wait for the device to disconnect. After a connection failure, the device may not have had time to disconnect so we wait for it to do so. If we do not wait, we may end up connecting to the same device again before it has had time to disconnect. """ if ( not IS_LINUX or not isinstance(device.details, dict) or "path" not in device.details ): await asyncio.sleep(min_wait_time) return device_path = device.details["path"] start = time.monotonic() if min_wait_time else 0 try: if not (manager := await get_global_bluez_manager_with_timeout()): _LOGGER.debug( "%s - %s: Failed to wait for disconnect because no manager", device.name, device.address, ) return async with asyncio_timeout(DISCONNECT_TIMEOUT): await manager._wait_condition(device_path, "Connected", False) end = time.monotonic() if min_wait_time else 0 waited = end - start _LOGGER.debug( "%s - %s: Waited %s seconds to disconnect", device.name, device.address, waited, ) if min_wait_time and waited < min_wait_time: await asyncio.sleep(min_wait_time - waited) except (BleakError, KeyError) as ex: # Device was removed from bus # # In testing it was found that most of the CSR adapters # only support 5 slots and the broadcom only support 7 slots. # # When they run out of slots the device they are trying to # connect to disappears from the bus so we must backoff _LOGGER.debug( "%s - %s: Device was removed from bus at %s, waiting %s for it to re-appear: (%s) %s", device.name, device.address, device_path, min_wait_time, type(ex), ex, ) await wait_for_device_to_reappear(device, min_wait_time) except Exception: # pylint: disable=broad-except _LOGGER.debug( "%s - %s: Failed waiting for disconnect at %s", device.name, device.address, device_path, exc_info=True, ) async def get_device_by_adapter(address: str, adapter: str) -> BLEDevice | None: """Get the device by adapter and address.""" if not IS_LINUX: return None if not (properties := await _get_properties()): return None device_path = address_to_bluez_path(address, adapter) if device_path in properties and ( device_props := properties[device_path].get(defs.DEVICE_INTERFACE) ): return ble_device_from_properties(device_path, device_props) return None async def get_bluez_device( name: str, path: str, rssi: int | None = None, _log_disappearance: bool = True ) -> BLEDevice | None: """Get a BLEDevice object for a BlueZ DBus path.""" best_path = device_path = path rssi_to_beat: int = rssi or NO_RSSI_VALUE if not (properties := await _get_properties()): return None if ( device_path not in properties or defs.DEVICE_INTERFACE not in properties[device_path] ): # device has disappeared so take # anything over the current path if _log_disappearance: _LOGGER.debug("%s - %s: Device has disappeared", name, device_path) rssi_to_beat = NO_RSSI_VALUE for path in _get_possible_paths(device_path): if path not in properties or not ( device_props := properties[path].get(defs.DEVICE_INTERFACE) ): continue if device_props.get("Connected"): # device is connected so take it _LOGGER.debug("%s - %s: Device is already connected", name, path) if path == device_path: # device is connected to the path we were given # so we can just return None so it will be used return None return ble_device_from_properties(path, device_props) if path == device_path: # Device is not connected and is the original path # so no need to check it since returning None will # cause the device to be used anyways. continue alternate_device_rssi: int = device_props.get("RSSI") or NO_RSSI_VALUE if ( rssi_to_beat != NO_RSSI_VALUE and alternate_device_rssi - RSSI_SWITCH_THRESHOLD < rssi_to_beat ): continue best_path = path _LOGGER.debug( "%s - %s: Found path %s with better RSSI %s > %s", name, device_path, path, alternate_device_rssi, rssi_to_beat, ) rssi_to_beat = alternate_device_rssi if best_path == device_path: return None return ble_device_from_properties( best_path, properties[best_path][defs.DEVICE_INTERFACE] ) async def get_connected_devices(device: BLEDevice) -> list[BLEDevice]: """Check if the device is connected.""" connected: list[BLEDevice] = [] if not isinstance(device.details, dict) or "path" not in device.details: return connected if not (properties := await _get_properties()): return connected device_path = device.details["path"] for path in _get_possible_paths(device_path): if path not in properties or defs.DEVICE_INTERFACE not in properties[path]: continue props = properties[path][defs.DEVICE_INTERFACE] if props.get("Connected"): connected.append(ble_device_from_properties(path, props)) return connected async def get_device(address: str) -> BLEDevice | None: """Get the device.""" if not IS_LINUX: return None return await get_bluez_device( address, address_to_bluez_path(address), _log_disappearance=False ) def address_to_bluez_path(address: str, adapter: str | None = None) -> str: """Convert an address to a BlueZ path.""" return f"/org/bluez/{adapter or 'hciX'}/dev_{address.upper().replace(':', '_')}" def _get_possible_paths(path: str) -> Generator[str]: """Get the possible paths.""" # The path is deterministic so we splice up the string # /org/bluez/hci2/dev_FA_23_9D_AA_45_46 for i in range(0, 9): yield f"{path[0:14]}{i}{path[15:]}" def ble_device_from_properties(path: str, props: dict[str, Any]) -> BLEDevice: """Get a BLEDevice from a dict of properties.""" return BLEDevice( props["Address"], props["Alias"], {"path": path, "props": props}, ) Bluetooth-Devices-bleak-retry-connector-2326a9d/src/bleak_retry_connector/const.py000066400000000000000000000003331520367436400304570ustar00rootroot00000000000000from __future__ import annotations import platform IS_LINUX = platform.system() == "Linux" NO_RSSI_VALUE = -127 RSSI_SWITCH_THRESHOLD = 5 DISCONNECT_TIMEOUT = 5 REAPPEAR_WAIT_INTERVAL = 0.5 DBUS_CONNECT_TIMEOUT = 8.5 Bluetooth-Devices-bleak-retry-connector-2326a9d/src/bleak_retry_connector/dbus.py000066400000000000000000000025311520367436400302700ustar00rootroot00000000000000from __future__ import annotations import contextlib from bleak.backends.bluezdbus import defs from bleak.backends.device import BLEDevice from dbus_fast.message import Message from .bleak_manager import get_global_bluez_manager_with_timeout from .const import DISCONNECT_TIMEOUT from .util import asyncio_timeout async def disconnect_devices(devices: list[BLEDevice]) -> None: """Disconnect a list of devices.""" valid_devices = [ device for device in devices if isinstance(device.details, dict) and "path" in device.details ] if not valid_devices: return if not (bluez_manager := await get_global_bluez_manager_with_timeout()): return bus = bluez_manager._bus for device in valid_devices: # https://bleak.readthedocs.io/en/latest/troubleshooting.html#id4 # Try to remove the device as well in the hope that it will # clear the disk cache of the device. with contextlib.suppress(Exception): async with asyncio_timeout(DISCONNECT_TIMEOUT): await bus.call( Message( destination=defs.BLUEZ_SERVICE, path=device.details["path"], interface=defs.DEVICE_INTERFACE, member="Disconnect", ) ) Bluetooth-Devices-bleak-retry-connector-2326a9d/src/bleak_retry_connector/py.typed000066400000000000000000000000001520367436400304450ustar00rootroot00000000000000Bluetooth-Devices-bleak-retry-connector-2326a9d/src/bleak_retry_connector/util.py000066400000000000000000000004301520367436400303040ustar00rootroot00000000000000from __future__ import annotations import sys if sys.version_info[:2] < (3, 11): from async_timeout import ( # noqa: F401 # pragma: no cover timeout as asyncio_timeout, ) else: from asyncio import timeout as asyncio_timeout # noqa: F401 # pragma: no cover Bluetooth-Devices-bleak-retry-connector-2326a9d/tests/000077500000000000000000000000001520367436400227565ustar00rootroot00000000000000Bluetooth-Devices-bleak-retry-connector-2326a9d/tests/__init__.py000066400000000000000000000000001520367436400250550ustar00rootroot00000000000000Bluetooth-Devices-bleak-retry-connector-2326a9d/tests/conftest.py000066400000000000000000000020541520367436400251560ustar00rootroot00000000000000import logging from collections.abc import Iterator from unittest.mock import patch import pytest from blockbuster import BlockBuster, blockbuster_ctx import bleak_retry_connector @pytest.fixture(autouse=True) def configure_test_logging(caplog): caplog.set_level(logging.DEBUG) @pytest.fixture(autouse=True) def blockbuster() -> Iterator[BlockBuster]: with blockbuster_ctx("bleak_retry_connector") as bb: yield bb @pytest.fixture() def mock_linux(): with ( patch.object(bleak_retry_connector, "IS_LINUX", True), patch.object(bleak_retry_connector.bluez, "IS_LINUX", True), patch.object(bleak_retry_connector.bleak_manager, "IS_LINUX", True), patch("bleak.backends.platform.system", return_value="Linux"), ): yield @pytest.fixture() def mock_macos(): with ( patch.object(bleak_retry_connector, "IS_LINUX", False), patch.object(bleak_retry_connector.bluez, "IS_LINUX", False), patch.object(bleak_retry_connector.bleak_manager, "IS_LINUX", False), ): yield Bluetooth-Devices-bleak-retry-connector-2326a9d/tests/test_bleak_manager.py000066400000000000000000000124071520367436400271430ustar00rootroot00000000000000"""Tests for the bleak_manager helpers (DBus socket cache + global manager getter).""" from __future__ import annotations import asyncio from unittest.mock import AsyncMock import pytest import bleak_retry_connector from bleak_retry_connector.bleak_manager import ( _reset_dbus_socket_cache, get_global_bluez_manager_with_timeout, ) pytestmark = pytest.mark.asyncio @pytest.fixture(autouse=True) def _clear_dbus_socket_cache(): """Ensure the _has_dbus_socket attribute is reset around every test.""" _reset_dbus_socket_cache() yield _reset_dbus_socket_cache() async def test_returns_none_on_non_linux(mock_macos): """On non-Linux platforms the helper returns None without touching DBus.""" assert await get_global_bluez_manager_with_timeout() is None async def test_returns_cached_manager_when_loop_already_registered( mock_linux, monkeypatch ): """If the loop is already registered in _global_instances, return that manager.""" sentinel_manager = object() loop = asyncio.get_running_loop() monkeypatch.setattr( bleak_retry_connector.bleak_manager, "_global_instances", {loop: sentinel_manager}, ) # If we hit this, get_global_bluez_manager should never be called. mock_get = AsyncMock(side_effect=AssertionError("should not be called")) monkeypatch.setattr( bleak_retry_connector.bleak_manager, "get_global_bluez_manager", mock_get ) assert await get_global_bluez_manager_with_timeout() is sentinel_manager mock_get.assert_not_called() async def test_short_circuits_after_filenotfound_is_cached(mock_linux, monkeypatch): """A FileNotFoundError marks the socket missing and skips subsequent calls.""" monkeypatch.setattr(bleak_retry_connector.bleak_manager, "_global_instances", {}) mock_get = AsyncMock(side_effect=FileNotFoundError(2, "no such file", "/run/dbus")) monkeypatch.setattr( bleak_retry_connector.bleak_manager, "get_global_bluez_manager", mock_get ) assert await get_global_bluez_manager_with_timeout() is None assert mock_get.call_count == 1 # Second call must not retry — the cache short-circuits to None. assert await get_global_bluez_manager_with_timeout() is None assert mock_get.call_count == 1 async def test_short_circuits_after_timeout_is_cached(mock_linux, monkeypatch): """An asyncio.TimeoutError also flips the cache to False and skips retries.""" monkeypatch.setattr(bleak_retry_connector.bleak_manager, "_global_instances", {}) mock_get = AsyncMock(side_effect=asyncio.TimeoutError()) monkeypatch.setattr( bleak_retry_connector.bleak_manager, "get_global_bluez_manager", mock_get ) assert await get_global_bluez_manager_with_timeout() is None assert mock_get.call_count == 1 # Second call must not retry — the cache short-circuits to None. assert await get_global_bluez_manager_with_timeout() is None assert mock_get.call_count == 1 async def test_generic_exception_returns_none_but_does_not_cache( mock_linux, monkeypatch ): """A generic exception logs and returns None, but does NOT poison the cache.""" monkeypatch.setattr(bleak_retry_connector.bleak_manager, "_global_instances", {}) mock_get = AsyncMock(side_effect=RuntimeError("boom")) monkeypatch.setattr( bleak_retry_connector.bleak_manager, "get_global_bluez_manager", mock_get ) assert await get_global_bluez_manager_with_timeout() is None assert mock_get.call_count == 1 # A generic exception (not FileNotFoundError / TimeoutError) does NOT flip the # _has_dbus_socket cache to False, so a subsequent call still tries again. assert await get_global_bluez_manager_with_timeout() is None assert mock_get.call_count == 2 async def test_returns_manager_on_success(mock_linux, monkeypatch): """When get_global_bluez_manager succeeds, its return value is propagated.""" sentinel_manager = object() monkeypatch.setattr(bleak_retry_connector.bleak_manager, "_global_instances", {}) mock_get = AsyncMock(return_value=sentinel_manager) monkeypatch.setattr( bleak_retry_connector.bleak_manager, "get_global_bluez_manager", mock_get ) assert await get_global_bluez_manager_with_timeout() is sentinel_manager mock_get.assert_awaited_once() async def test_reset_dbus_socket_cache_re_enables_retries(mock_linux, monkeypatch): """_reset_dbus_socket_cache() lets the helper retry after a cached failure.""" monkeypatch.setattr(bleak_retry_connector.bleak_manager, "_global_instances", {}) sentinel_manager = object() mock_get = AsyncMock( side_effect=[ FileNotFoundError(2, "no such file", "/run/dbus"), sentinel_manager, ] ) monkeypatch.setattr( bleak_retry_connector.bleak_manager, "get_global_bluez_manager", mock_get ) # First call: cache flips to False. assert await get_global_bluez_manager_with_timeout() is None # Second call would short-circuit without the reset. assert await get_global_bluez_manager_with_timeout() is None assert mock_get.call_count == 1 _reset_dbus_socket_cache() # After reset, the helper tries again and gets the manager. assert await get_global_bluez_manager_with_timeout() is sentinel_manager assert mock_get.call_count == 2 Bluetooth-Devices-bleak-retry-connector-2326a9d/tests/test_bluez.py000066400000000000000000001370621520367436400255210ustar00rootroot00000000000000from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from bleak.backends.bluezdbus import defs from bleak.backends.bluezdbus.manager import DeviceWatcher from bleak.backends.device import BLEDevice from bleak.exc import BleakError import bleak_retry_connector from bleak_retry_connector import ( AllocationChange, AllocationChangeEvent, Allocations, BleakSlotManager, device_source, ) from bleak_retry_connector.bluez import ( adapter_path_from_device_path, ble_device_from_properties, clear_cache, get_bluez_device, get_connected_devices, get_device_by_adapter, path_from_ble_device, stop_discovery, wait_for_device_to_reappear, wait_for_disconnect, ) pytestmark = pytest.mark.asyncio async def test_slot_manager(mock_linux): """Test the slot manager""" class FakeBluezManager: def __init__(self): self.watchers: set[DeviceWatcher] = set() self._properties = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -30, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci1/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Connected": True, "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -79, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci2/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Connected": True, "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -80, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci3/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -31, }, defs.GATT_SERVICE_INTERFACE: True, }, } def add_device_watcher(self, path: str, **kwargs: Any) -> DeviceWatcher: """Add a watcher for device changes.""" watcher = DeviceWatcher(path, **kwargs) self.watchers.add(watcher) return watcher def remove_device_watcher(self, watcher: DeviceWatcher) -> None: """Remove a watcher for device changes.""" self.watchers.remove(watcher) def is_connected(self, path: str) -> bool: """Check if device is connected.""" return False bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=FakeBluezManager() ) bleak_retry_connector.bluez.defs = defs slot_manager = BleakSlotManager() await slot_manager.async_setup() slot_manager.register_adapter("hci0", 1) slot_manager.register_adapter("hci1", 2) slot_manager.register_adapter("hci2", 1) changes = [] def _failing_allocation_callback(event: AllocationChangeEvent) -> None: raise Exception("Test") def _allocation_callback(event: AllocationChangeEvent) -> None: change = event.change path = event.path adapter = event.adapter address = event.address changes.append((change, path, adapter, address)) cancel_fail = slot_manager.register_allocation_callback( _failing_allocation_callback ) cancel = slot_manager.register_allocation_callback(_allocation_callback) ble_device_hci2 = ble_device_from_properties( "/org/bluez/hci2/dev_FA_23_9D_AA_45_45", { "Address": "FA:23:9D:AA:45:45", "Alias": "FA:23:9D:AA:45:45", "RSSI": -30, }, ) ble_device_hci2_already_connected = ble_device_from_properties( "/org/bluez/hci2/dev_FA_23_9D_AA_45_46", { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -30, }, ) ble_device_hci0 = ble_device_from_properties( "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -30, }, ) ble_device_hci0_2 = ble_device_from_properties( "/org/bluez/hci0/dev_FA_23_9D_AA_45_47", { "Address": "FA:23:9D:AA:45:47", "Alias": "FA:23:9D:AA:45:47", "RSSI": -30, }, ) assert slot_manager.allocate_slot(ble_device_hci2) is False assert not changes # Make sure we can allocate an already connected device # since there is always a race condition between the # slot manager and the device connecting assert slot_manager.allocate_slot(ble_device_hci2_already_connected) is True assert not changes assert slot_manager.allocate_slot(ble_device_hci0) is True assert changes == [ ( AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ) ] assert slot_manager._get_allocations("hci0") == [ "/org/bluez/hci0/dev_FA_23_9D_AA_45_46" ] assert slot_manager.get_allocations("hci0") == Allocations( "hci0", 1, 0, ["FA:23:9D:AA:45:46"], ) # Make sure we can allocate the same device again assert slot_manager.allocate_slot(ble_device_hci0) is True assert changes == [ ( AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ] assert slot_manager._get_allocations("hci0") == [ "/org/bluez/hci0/dev_FA_23_9D_AA_45_46" ] assert slot_manager.get_allocations("hci0") == Allocations( "hci0", 1, 0, ["FA:23:9D:AA:45:46"], ) assert slot_manager.allocate_slot(ble_device_hci0_2) is False assert changes == [ ( AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ] assert slot_manager._get_allocations("hci0") == [ "/org/bluez/hci0/dev_FA_23_9D_AA_45_46" ] assert slot_manager.get_allocations("hci0") == Allocations( "hci0", 1, 0, ["FA:23:9D:AA:45:46"], ) watcher: DeviceWatcher = slot_manager._allocations_by_adapter["hci0"][ "/org/bluez/hci0/dev_FA_23_9D_AA_45_46" ] watcher.on_connected_changed(True) assert slot_manager._get_allocations("hci0") == [ "/org/bluez/hci0/dev_FA_23_9D_AA_45_46" ] assert slot_manager.get_allocations("hci0") == Allocations( "hci0", 1, 0, ["FA:23:9D:AA:45:46"], ) assert changes == [ ( AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ] watcher.on_connected_changed(False) assert changes == [ ( AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ( AllocationChange.RELEASED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ] assert slot_manager._get_allocations("hci0") == [] assert slot_manager.get_allocations("hci0") == Allocations( "hci0", 1, 1, [], ) assert slot_manager.allocate_slot(ble_device_hci0) is True assert slot_manager._get_allocations("hci0") == [ "/org/bluez/hci0/dev_FA_23_9D_AA_45_46" ] assert slot_manager.get_allocations("hci0") == Allocations( "hci0", 1, 0, ["FA:23:9D:AA:45:46"], ) assert changes == [ ( AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ( AllocationChange.RELEASED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ( AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ] assert slot_manager.diagnostics() == { "adapter_slots": {"hci0": 1, "hci1": 2, "hci2": 1}, "allocations_by_adapter": { "hci0": ["/org/bluez/hci0/dev_FA_23_9D_AA_45_46"], "hci1": ["/org/bluez/hci1/dev_FA_23_9D_AA_45_46"], "hci2": ["/org/bluez/hci2/dev_FA_23_9D_AA_45_46"], }, "manager": True, } slot_manager.release_slot(ble_device_hci0) assert changes == [ ( AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ( AllocationChange.RELEASED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ( AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ( AllocationChange.RELEASED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ] assert slot_manager._get_allocations("hci0") == [] assert slot_manager.get_allocations("hci0") == Allocations( "hci0", 1, 1, [], ) assert slot_manager.allocate_slot(ble_device_hci0) is True assert changes == [ ( AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ( AllocationChange.RELEASED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ( AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ( AllocationChange.RELEASED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ( AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ] assert slot_manager._get_allocations("hci0") == [ "/org/bluez/hci0/dev_FA_23_9D_AA_45_46" ] assert slot_manager.get_allocations("hci0") == Allocations( "hci0", 1, 0, ["FA:23:9D:AA:45:46"], ) slot_manager.remove_adapter("hci0") assert changes == [ ( AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ( AllocationChange.RELEASED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ( AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ( AllocationChange.RELEASED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ( AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ] assert slot_manager.allocate_slot(ble_device_hci0) is True assert changes == [ ( AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ( AllocationChange.RELEASED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ( AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ( AllocationChange.RELEASED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ( AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ] assert slot_manager.allocate_slot(ble_device_hci0_2) is True assert changes == [ ( AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ( AllocationChange.RELEASED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ( AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ( AllocationChange.RELEASED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ( AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "hci0", "FA:23:9D:AA:45:46", ), ] cancel_fail() cancel() async def test_slot_manager_adapter_removal_during_disconnect(mock_linux): """Test that adapter removal during disconnect doesn't cause KeyError.""" class FakeBluezManager: def __init__(self): self.watchers: set[DeviceWatcher] = set() self._properties = { "/org/bluez/hci1/dev_FA_23_9D_AA_45_46": { defs.DEVICE_INTERFACE: { "Connected": True, "Address": "FA:23:9D:AA:45:46", "Alias": "Test Device", "RSSI": -60, }, }, } def add_device_watcher(self, path: str, **kwargs: Any) -> DeviceWatcher: """Add a watcher for device changes.""" watcher = DeviceWatcher(path, **kwargs) self.watchers.add(watcher) return watcher def remove_device_watcher(self, watcher: DeviceWatcher) -> None: """Remove a watcher for device changes.""" self.watchers.discard(watcher) def is_connected(self, path: str) -> bool: """Check if device is connected.""" return False bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=FakeBluezManager() ) bleak_retry_connector.bluez.defs = defs slot_manager = BleakSlotManager() await slot_manager.async_setup() # Register adapter and allocate a slot slot_manager.register_adapter("hci1", 5) ble_device = ble_device_from_properties( "/org/bluez/hci1/dev_FA_23_9D_AA_45_46", { "Address": "FA:23:9D:AA:45:46", "Alias": "Test Device", "RSSI": -60, }, ) # Allocate the slot assert slot_manager.allocate_slot(ble_device) is True # Store the watcher to simulate disconnect event later watcher: DeviceWatcher = slot_manager._allocations_by_adapter["hci1"][ "/org/bluez/hci1/dev_FA_23_9D_AA_45_46" ] # Simulate adapter removal (e.g., adapter unplugged) slot_manager.remove_adapter("hci1") # Now simulate the disconnect event firing after adapter removal # This should not raise a KeyError watcher.on_connected_changed(False) # Verify the adapter is gone and methods handle it gracefully assert slot_manager._get_allocations("hci1") == [] assert slot_manager.get_allocations("hci1") == Allocations("hci1", 0, 0, []) async def test_slot_manager_mac_os(): """Test the slot manager""" bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=None ) bleak_retry_connector.bluez.defs = defs slot_manager = BleakSlotManager() await slot_manager.async_setup() slot_manager.register_adapter("hci0", 1) slot_manager.register_adapter("hci1", 2) slot_manager.register_adapter("hci2", 1) ble_device_hci0 = ble_device_from_properties( "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -30, }, ) ble_device_hci0_2 = ble_device_from_properties( "/org/bluez/hci0/dev_FA_23_9D_AA_45_47", { "Address": "FA:23:9D:AA:45:47", "Alias": "FA:23:9D:AA:45:47", "RSSI": -30, }, ) assert slot_manager.allocate_slot(ble_device_hci0) is True assert slot_manager._get_allocations("hci0") == [] assert slot_manager.allocate_slot(ble_device_hci0_2) is True assert slot_manager._get_allocations("hci0") == [] assert slot_manager.allocate_slot(ble_device_hci0) is True assert slot_manager._get_allocations("hci0") == [] slot_manager.release_slot(ble_device_hci0) assert slot_manager._get_allocations("hci0") == [] slot_manager.remove_adapter("hci0") async def test_device_source(): ble_device_hci0_2 = BLEDevice( "FA:23:9D:AA:45:46", "FA:23:9D:AA:45:46", { "source": "aa:bb:cc:dd:ee:ff", "path": "/org/bluez/hci0/dev_FA_23_9D_AA_45_47", "props": {}, }, ) assert device_source(ble_device_hci0_2) == "aa:bb:cc:dd:ee:ff" async def test_path_from_ble_device(): ble_device_hci0_2 = BLEDevice( "FA:23:9D:AA:45:46", "FA:23:9D:AA:45:46", { "source": "aa:bb:cc:dd:ee:ff", "path": "/org/bluez/hci0/dev_FA_23_9D_AA_45_47", "props": {}, }, ) assert ( path_from_ble_device(ble_device_hci0_2) == "/org/bluez/hci0/dev_FA_23_9D_AA_45_47" ) async def test_wait_for_device_to_reappear(mock_linux): class FakeBluezManager: def __init__(self): self.watchers: set[DeviceWatcher] = set() self._properties = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -30, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci1/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Connected": True, "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -79, }, defs.GATT_SERVICE_INTERFACE: True, }, } def add_device_watcher(self, path: str, **kwargs: Any) -> DeviceWatcher: """Add a watcher for device changes.""" watcher = DeviceWatcher(path, **kwargs) self.watchers.add(watcher) return watcher def remove_device_watcher(self, watcher: DeviceWatcher) -> None: """Remove a watcher for device changes.""" self.watchers.remove(watcher) def is_connected(self, path: str) -> bool: """Check if device is connected.""" return False bluez_manager = FakeBluezManager() bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=bluez_manager ) bleak_retry_connector.bluez.defs = defs ble_device_hci0 = BLEDevice( "FA:23:9D:AA:45:46", "FA:23:9D:AA:45:46", { "source": "aa:bb:cc:dd:ee:ff", "path": "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "props": {}, }, ) assert await wait_for_device_to_reappear(ble_device_hci0, 1) is True del bluez_manager._properties["/org/bluez/hci0/dev_FA_23_9D_AA_45_46"] assert await wait_for_device_to_reappear(ble_device_hci0, 1) is True del bluez_manager._properties["/org/bluez/hci1/dev_FA_23_9D_AA_45_46"] with patch.object(bleak_retry_connector.bluez, "REAPPEAR_WAIT_INTERVAL", 0.025): assert await wait_for_device_to_reappear(ble_device_hci0, 0.1) is False async def test_adapter_path_from_device_path(mock_linux): assert ( adapter_path_from_device_path("/org/bluez/hci1/dev_FA_23_9D_AA_45_46") == "/org/bluez/hci1" ) async def test_stop_discovery(mock_linux): """Test stopping discovery""" class FakeBluezManager: def __init__(self) -> None: """Mock initializer.""" self._bus = MagicMock(send=AsyncMock()) manager = FakeBluezManager() bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=manager ) bleak_retry_connector.bluez.defs = defs bleak_retry_connector.bluez.Message = MagicMock() await stop_discovery("hci0") assert manager._bus.send.called async def test_stop_discovery_no_manager( mock_linux: None, caplog: pytest.LogCaptureFixture ) -> None: """Test stopping discovery no manager.""" bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=None ) bleak_retry_connector.bluez.defs = defs bleak_retry_connector.bluez.Message = MagicMock() await stop_discovery("hci0") assert "Failed to stop discovery" in caplog.text async def test_wait_for_disconnect_not_linux(mock_macos): """Non-Linux platforms fall back to a plain sleep.""" device = BLEDevice("AA:BB:CC:DD:EE:FF", "name", {"path": "/org/bluez/hci0/dev_x"}) sleeps: list[float] = [] async def fake_sleep(delay: float) -> None: sleeps.append(delay) with patch("bleak_retry_connector.bluez.asyncio.sleep", side_effect=fake_sleep): await wait_for_disconnect(device, 0.25) assert sleeps == [0.25] async def test_wait_for_disconnect_no_path(mock_linux): """Devices without a 'path' entry in details fall back to a plain sleep.""" device = BLEDevice("AA:BB:CC:DD:EE:FF", "name", {"source": "aa:bb:cc:dd:ee:ff"}) sleeps: list[float] = [] async def fake_sleep(delay: float) -> None: sleeps.append(delay) with patch("bleak_retry_connector.bluez.asyncio.sleep", side_effect=fake_sleep): await wait_for_disconnect(device, 0.1) assert sleeps == [0.1] async def test_wait_for_disconnect_no_manager( mock_linux: None, caplog: pytest.LogCaptureFixture ) -> None: """If no BlueZ manager is available, log and return without sleeping.""" device = BLEDevice( "FA:23:9D:AA:45:46", "FA:23:9D:AA:45:46", {"path": "/org/bluez/hci0/dev_FA_23_9D_AA_45_46"}, ) with patch( "bleak_retry_connector.bluez.get_global_bluez_manager_with_timeout", AsyncMock(return_value=None), ): await wait_for_disconnect(device, 1.0) assert "Failed to wait for disconnect because no manager" in caplog.text async def test_wait_for_disconnect_waits_remaining_min_wait_time(mock_linux): """When the device disconnects sooner than min_wait_time, sleep the remainder.""" device = BLEDevice( "FA:23:9D:AA:45:46", "FA:23:9D:AA:45:46", {"path": "/org/bluez/hci0/dev_FA_23_9D_AA_45_46"}, ) manager = MagicMock() manager._wait_condition = AsyncMock() # Only wait_for_disconnect-issued sleeps land here (we don't patch global sleep). sleeps: list[float] = [] async def fake_sleep(delay: float) -> None: sleeps.append(delay) # Patch the time module bound on bluez to return start=100.0, end=100.2 # without affecting asyncio internals that read the real clock. fake_time = MagicMock() fake_time.monotonic = MagicMock(side_effect=[100.0, 100.2]) with ( patch( "bleak_retry_connector.bluez.get_global_bluez_manager_with_timeout", AsyncMock(return_value=manager), ), patch.object(bleak_retry_connector.bluez, "time", fake_time), patch("bleak_retry_connector.bluez.asyncio.sleep", side_effect=fake_sleep), ): await wait_for_disconnect(device, 1.0) manager._wait_condition.assert_awaited_once_with( "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", "Connected", False ) # waited = 0.2, min_wait_time = 1.0, so remaining sleep should be 0.8. assert sleeps == [pytest.approx(0.8)] async def test_wait_for_disconnect_skips_extra_sleep_when_already_waited( mock_linux: None, ) -> None: """If we already waited long enough, no extra sleep is issued.""" device = BLEDevice( "FA:23:9D:AA:45:46", "FA:23:9D:AA:45:46", {"path": "/org/bluez/hci0/dev_FA_23_9D_AA_45_46"}, ) manager = MagicMock() manager._wait_condition = AsyncMock() sleeps: list[float] = [] async def fake_sleep(delay: float) -> None: sleeps.append(delay) fake_time = MagicMock() fake_time.monotonic = MagicMock(side_effect=[100.0, 105.0]) with ( patch( "bleak_retry_connector.bluez.get_global_bluez_manager_with_timeout", AsyncMock(return_value=manager), ), patch.object(bleak_retry_connector.bluez, "time", fake_time), patch("bleak_retry_connector.bluez.asyncio.sleep", side_effect=fake_sleep), ): await wait_for_disconnect(device, 1.0) assert sleeps == [] async def test_wait_for_disconnect_zero_min_wait_time(mock_linux): """min_wait_time=0 bypasses the timing branch entirely.""" device = BLEDevice( "FA:23:9D:AA:45:46", "FA:23:9D:AA:45:46", {"path": "/org/bluez/hci0/dev_FA_23_9D_AA_45_46"}, ) manager = MagicMock() manager._wait_condition = AsyncMock() sleeps: list[float] = [] async def fake_sleep(delay: float) -> None: sleeps.append(delay) with ( patch( "bleak_retry_connector.bluez.get_global_bluez_manager_with_timeout", AsyncMock(return_value=manager), ), patch("bleak_retry_connector.bluez.asyncio.sleep", side_effect=fake_sleep), ): await wait_for_disconnect(device, 0) manager._wait_condition.assert_awaited_once() assert sleeps == [] async def test_wait_for_disconnect_bleak_error_triggers_reappear( mock_linux: None, caplog: pytest.LogCaptureFixture ) -> None: """A BleakError from _wait_condition routes through wait_for_device_to_reappear.""" device = BLEDevice( "FA:23:9D:AA:45:46", "FA:23:9D:AA:45:46", {"path": "/org/bluez/hci0/dev_FA_23_9D_AA_45_46"}, ) manager = MagicMock() manager._wait_condition = AsyncMock(side_effect=BleakError("gone")) reappear_calls: list[tuple[BLEDevice, float]] = [] async def fake_reappear(d, t): reappear_calls.append((d, t)) return True with ( patch( "bleak_retry_connector.bluez.get_global_bluez_manager_with_timeout", AsyncMock(return_value=manager), ), patch( "bleak_retry_connector.bluez.wait_for_device_to_reappear", side_effect=fake_reappear, ), ): await wait_for_disconnect(device, 0.5) assert reappear_calls == [(device, 0.5)] assert "Device was removed from bus" in caplog.text async def test_wait_for_disconnect_key_error_triggers_reappear(mock_linux): """KeyError is treated the same as BleakError.""" device = BLEDevice( "FA:23:9D:AA:45:46", "FA:23:9D:AA:45:46", {"path": "/org/bluez/hci0/dev_FA_23_9D_AA_45_46"}, ) manager = MagicMock() manager._wait_condition = AsyncMock(side_effect=KeyError("missing")) called = False async def fake_reappear(d, t): nonlocal called called = True return False with ( patch( "bleak_retry_connector.bluez.get_global_bluez_manager_with_timeout", AsyncMock(return_value=manager), ), patch( "bleak_retry_connector.bluez.wait_for_device_to_reappear", side_effect=fake_reappear, ), ): await wait_for_disconnect(device, 0.1) assert called async def test_wait_for_disconnect_unexpected_exception_swallowed( mock_linux: None, caplog: pytest.LogCaptureFixture ) -> None: """Any other exception is logged and swallowed — no reappear fallback.""" device = BLEDevice( "FA:23:9D:AA:45:46", "FA:23:9D:AA:45:46", {"path": "/org/bluez/hci0/dev_FA_23_9D_AA_45_46"}, ) manager = MagicMock() manager._wait_condition = AsyncMock(side_effect=RuntimeError("boom")) reappear_called = False async def fake_reappear(d, t): nonlocal reappear_called reappear_called = True with ( patch( "bleak_retry_connector.bluez.get_global_bluez_manager_with_timeout", AsyncMock(return_value=manager), ), patch( "bleak_retry_connector.bluez.wait_for_device_to_reappear", side_effect=fake_reappear, ), ): await wait_for_disconnect(device, 0.1) assert reappear_called is False assert "Failed waiting for disconnect" in caplog.text async def test_wait_for_device_to_reappear_debug_logging(mock_linux, caplog): """Debug-level branches in wait_for_device_to_reappear log per iteration.""" import logging caplog.set_level(logging.DEBUG, logger="bleak_retry_connector.bluez") class BluezManager: # Non-empty so _get_properties() returns truthy, but the device path # is intentionally absent so the loop exhausts without finding it. _properties: dict[str, dict[str, dict[str, str]]] = { "/org/bluez/hci0/dev_OTHER": {defs.DEVICE_INTERFACE: {"Address": "x"}} } def is_connected(self, path): return False bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=BluezManager() ) bleak_retry_connector.bluez.defs = defs device = BLEDevice( "FA:23:9D:AA:45:46", "FA:23:9D:AA:45:46", {"path": "/org/bluez/hci0/dev_FA_23_9D_AA_45_46"}, ) # Force the function-local `debug` flag True even if the logger level # isn't picked up via caplog (matches how real callers see it). with ( patch.object(bleak_retry_connector.bluez, "REAPPEAR_WAIT_INTERVAL", 0.01), patch.object( bleak_retry_connector.bluez._LOGGER, "isEnabledFor", return_value=True ), ): result = await wait_for_device_to_reappear(device, 0.03) assert result is False assert "Waiting" in caplog.text assert "did not re-appear" in caplog.text async def test_wait_for_device_to_reappear_no_debug_found( mock_linux: None, ) -> None: """With debug disabled, the success path returns True without logging.""" class BluezManager: _properties: dict[str, dict[str, dict[str, str]]] = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": { defs.DEVICE_INTERFACE: {"Address": "FA:23:9D:AA:45:46"} } } def is_connected(self, path: str) -> bool: return False bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=BluezManager() ) bleak_retry_connector.bluez.defs = defs device = BLEDevice( "FA:23:9D:AA:45:46", "FA:23:9D:AA:45:46", {"path": "/org/bluez/hci0/dev_FA_23_9D_AA_45_46"}, ) with patch.object( bleak_retry_connector.bluez._LOGGER, "isEnabledFor", return_value=False ): assert await wait_for_device_to_reappear(device, 1) is True async def test_wait_for_device_to_reappear_no_debug_not_found( mock_linux: None, ) -> None: """With debug disabled, the loop exhausts and returns False silently.""" class BluezManager: _properties: dict[str, dict[str, dict[str, str]]] = { "/org/bluez/hci0/dev_OTHER": {defs.DEVICE_INTERFACE: {"Address": "x"}} } def is_connected(self, path: str) -> bool: return False bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=BluezManager() ) bleak_retry_connector.bluez.defs = defs device = BLEDevice( "FA:23:9D:AA:45:46", "FA:23:9D:AA:45:46", {"path": "/org/bluez/hci0/dev_FA_23_9D_AA_45_46"}, ) with ( patch.object(bleak_retry_connector.bluez, "REAPPEAR_WAIT_INTERVAL", 0.01), patch.object( bleak_retry_connector.bluez._LOGGER, "isEnabledFor", return_value=False ), ): assert await wait_for_device_to_reappear(device, 0.03) is False async def test_get_services_cache_returns_none_when_no_manager( mock_linux: None, monkeypatch: pytest.MonkeyPatch ) -> None: """When the bluez manager fetch returns None, _get_services_cache returns None.""" from bleak_retry_connector.bluez import _get_services_cache monkeypatch.setattr( bleak_retry_connector.bluez, "get_global_bluez_manager_with_timeout", AsyncMock(return_value=None), ) assert await _get_services_cache() is None async def test_clear_cache_not_linux(mock_macos: None) -> None: """Non-Linux short-circuits and returns False.""" assert await clear_cache("FA:23:9D:AA:45:46") is False async def test_clear_cache_no_device( mock_linux: None, monkeypatch: pytest.MonkeyPatch ) -> None: """If get_device returns None, clear_cache returns False without touching cache.""" monkeypatch.setattr( bleak_retry_connector.bleak_manager, "get_global_bluez_manager", AsyncMock(return_value=None), ) monkeypatch.setattr( bleak_retry_connector.bleak_manager, "get_global_bluez_manager_with_timeout", AsyncMock(return_value=None), ) assert await clear_cache("FA:23:9D:AA:45:46") is False async def test_clear_cache_no_services_cache( mock_linux: None, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """get_device succeeds but services cache is unavailable → warn + False.""" class FakeBluezManager: def __init__(self) -> None: self._properties = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": { defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", }, }, } manager = FakeBluezManager() monkeypatch.setattr( bleak_retry_connector.bleak_manager, "get_global_bluez_manager", AsyncMock(return_value=manager), ) monkeypatch.setattr( bleak_retry_connector.bleak_manager, "get_global_bluez_manager_with_timeout", AsyncMock(return_value=manager), ) monkeypatch.setattr(bleak_retry_connector.bluez, "defs", defs) monkeypatch.setattr( bleak_retry_connector.bluez, "_get_services_cache", AsyncMock(return_value=None), ) assert await clear_cache("FA:23:9D:AA:45:46") is False assert "no services cache" in caplog.text async def test_clear_cache_no_manager( mock_linux: None, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """services cache returns dict but the manager fetch fails → warn + False.""" fake_device = BLEDevice( "FA:23:9D:AA:45:46", "FA:23:9D:AA:45:46", {"path": "/org/bluez/hci0/dev_FA_23_9D_AA_45_46"}, ) monkeypatch.setattr( bleak_retry_connector.bluez, "get_device", AsyncMock(return_value=fake_device), ) monkeypatch.setattr( bleak_retry_connector.bluez, "_get_services_cache", AsyncMock(return_value={}), ) monkeypatch.setattr( bleak_retry_connector.bluez, "get_global_bluez_manager_with_timeout", AsyncMock(return_value=None), ) assert await clear_cache("FA:23:9D:AA:45:46") is False assert "no manager" in caplog.text async def test_clear_cache_sends_remove_device( mock_linux: None, monkeypatch: pytest.MonkeyPatch ) -> None: """Happy path: matching paths are popped from cache and RemoveDevice is sent.""" sent_kwargs: list[dict[str, Any]] = [] class FakeBus: async def send(self, message: Any) -> None: return None class FakeBluezManager: def __init__(self) -> None: self._bus = FakeBus() self._services_cache = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": "svc0", "/org/bluez/hci1/dev_FA_23_9D_AA_45_46": "svc1", } self._properties = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": { defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -30, }, }, "/org/bluez/hci1/dev_FA_23_9D_AA_45_46": { defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -60, }, }, } manager = FakeBluezManager() def _record_message(**kw: Any) -> dict[str, Any]: sent_kwargs.append(kw) return kw fake_message = MagicMock(side_effect=_record_message) monkeypatch.setattr( bleak_retry_connector.bleak_manager, "get_global_bluez_manager", AsyncMock(return_value=manager), ) monkeypatch.setattr( bleak_retry_connector.bleak_manager, "get_global_bluez_manager_with_timeout", AsyncMock(return_value=manager), ) monkeypatch.setattr(bleak_retry_connector.bluez, "defs", defs) monkeypatch.setattr(bleak_retry_connector.bluez, "Message", fake_message) assert await clear_cache("FA:23:9D:AA:45:46") is True assert manager._services_cache == {} assert len(sent_kwargs) == 2 assert {kw["path"] for kw in sent_kwargs} == {"/org/bluez/hci0", "/org/bluez/hci1"} assert all(kw["member"] == "RemoveDevice" for kw in sent_kwargs) async def test_clear_cache_swallows_get_device_exception( mock_linux: None, monkeypatch: pytest.MonkeyPatch ) -> None: """A raising get_device must not propagate out of clear_cache. Callers (e.g. establish_connection's retry loop) rely on clear_cache being safe to call from failure-handling paths where D-Bus may itself be flaky. """ monkeypatch.setattr( bleak_retry_connector.bluez, "get_device", AsyncMock(side_effect=RuntimeError("dbus exploded")), ) assert await clear_cache("FA:23:9D:AA:45:46") is False async def test_get_device_by_adapter_not_linux(mock_macos: None) -> None: """Non-Linux returns None immediately.""" assert await get_device_by_adapter("FA:23:9D:AA:45:46", "hci0") is None async def test_get_device_by_adapter_no_properties( mock_linux: None, monkeypatch: pytest.MonkeyPatch ) -> None: """No bluez manager yields no properties → returns None.""" monkeypatch.setattr( bleak_retry_connector.bleak_manager, "get_global_bluez_manager", AsyncMock(return_value=None), ) assert await get_device_by_adapter("FA:23:9D:AA:45:46", "hci0") is None async def test_get_device_by_adapter_path_missing( mock_linux: None, monkeypatch: pytest.MonkeyPatch ) -> None: """Path absent from properties → returns None.""" class FakeBluezManager: def __init__(self) -> None: self._properties = { "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF": { defs.DEVICE_INTERFACE: { "Address": "AA:BB:CC:DD:EE:FF", "Alias": "AA:BB:CC:DD:EE:FF", }, }, } manager = FakeBluezManager() monkeypatch.setattr( bleak_retry_connector.bleak_manager, "get_global_bluez_manager", AsyncMock(return_value=manager), ) monkeypatch.setattr(bleak_retry_connector.bluez, "defs", defs) assert await get_device_by_adapter("FA:23:9D:AA:45:46", "hci0") is None async def test_get_device_by_adapter_returns_device( mock_linux: None, monkeypatch: pytest.MonkeyPatch ) -> None: """Matching adapter+address returns the BLEDevice.""" class FakeBluezManager: def __init__(self) -> None: self._properties = { "/org/bluez/hci1/dev_FA_23_9D_AA_45_46": { defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "Test Device", "RSSI": -60, }, }, } manager = FakeBluezManager() monkeypatch.setattr( bleak_retry_connector.bleak_manager, "get_global_bluez_manager", AsyncMock(return_value=manager), ) monkeypatch.setattr(bleak_retry_connector.bluez, "defs", defs) device = await get_device_by_adapter("FA:23:9D:AA:45:46", "hci1") assert device is not None assert device.details["path"] == "/org/bluez/hci1/dev_FA_23_9D_AA_45_46" async def test_get_bluez_device_no_properties( mock_linux: None, monkeypatch: pytest.MonkeyPatch ) -> None: """No properties → returns None early.""" monkeypatch.setattr( bleak_retry_connector.bleak_manager, "get_global_bluez_manager", AsyncMock(return_value=None), ) assert ( await get_bluez_device("Test", "/org/bluez/hci0/dev_FA_23_9D_AA_45_46") is None ) async def test_get_bluez_device_disappeared_logs( mock_linux: None, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: """Device path missing from props logs the disappearance and still scans alternates.""" class FakeBluezManager: def __init__(self) -> None: self._properties = { "/org/bluez/hci1/dev_FA_23_9D_AA_45_46": { defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "Test Device", "RSSI": -60, }, }, } manager = FakeBluezManager() monkeypatch.setattr( bleak_retry_connector.bleak_manager, "get_global_bluez_manager", AsyncMock(return_value=manager), ) monkeypatch.setattr(bleak_retry_connector.bluez, "defs", defs) device = await get_bluez_device("Test", "/org/bluez/hci0/dev_FA_23_9D_AA_45_46") assert device is not None assert device.details["path"] == "/org/bluez/hci1/dev_FA_23_9D_AA_45_46" assert "Device has disappeared" in caplog.text async def test_get_bluez_device_disappeared_silent_when_flag_false( mock_linux: None, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: """`_log_disappearance=False` suppresses the disappearance log.""" class FakeBluezManager: def __init__(self) -> None: self._properties = { "/org/bluez/hci1/dev_FA_23_9D_AA_45_46": { defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "Test Device", "RSSI": -60, }, }, } manager = FakeBluezManager() monkeypatch.setattr( bleak_retry_connector.bleak_manager, "get_global_bluez_manager", AsyncMock(return_value=manager), ) monkeypatch.setattr(bleak_retry_connector.bluez, "defs", defs) caplog.clear() await get_bluez_device( "Test", "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", _log_disappearance=False, ) assert "Device has disappeared" not in caplog.text async def test_get_bluez_device_connected_at_original_path( mock_linux: None, monkeypatch: pytest.MonkeyPatch ) -> None: """Device already connected at the requested path → returns None (use original).""" class FakeBluezManager: def __init__(self) -> None: self._properties = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": { defs.DEVICE_INTERFACE: { "Connected": True, "Address": "FA:23:9D:AA:45:46", "Alias": "Test Device", "RSSI": -30, }, }, } manager = FakeBluezManager() monkeypatch.setattr( bleak_retry_connector.bleak_manager, "get_global_bluez_manager", AsyncMock(return_value=manager), ) monkeypatch.setattr(bleak_retry_connector.bluez, "defs", defs) assert ( await get_bluez_device("Test", "/org/bluez/hci0/dev_FA_23_9D_AA_45_46") is None ) async def test_get_bluez_device_skips_unconnected_original_path( mock_linux: None, monkeypatch: pytest.MonkeyPatch ) -> None: """The original path is skipped during alternate scoring when not connected.""" class FakeBluezManager: def __init__(self) -> None: self._properties = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": { defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "Test Device", "RSSI": -80, }, }, "/org/bluez/hci1/dev_FA_23_9D_AA_45_46": { defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "Test Device", "RSSI": -30, }, }, } manager = FakeBluezManager() monkeypatch.setattr( bleak_retry_connector.bleak_manager, "get_global_bluez_manager", AsyncMock(return_value=manager), ) monkeypatch.setattr(bleak_retry_connector.bluez, "defs", defs) device = await get_bluez_device( "Test", "/org/bluez/hci0/dev_FA_23_9D_AA_45_46", rssi=-90 ) assert device is not None assert device.details["path"] == "/org/bluez/hci1/dev_FA_23_9D_AA_45_46" async def test_get_connected_devices_no_properties( mock_linux: None, monkeypatch: pytest.MonkeyPatch ) -> None: """No properties → returns empty list.""" monkeypatch.setattr( bleak_retry_connector.bleak_manager, "get_global_bluez_manager", AsyncMock(return_value=None), ) device = BLEDevice( "FA:23:9D:AA:45:46", "Test", {"path": "/org/bluez/hci0/dev_FA_23_9D_AA_45_46"}, ) assert await get_connected_devices(device) == [] Bluetooth-Devices-bleak-retry-connector-2326a9d/tests/test_dbus.py000066400000000000000000000117161520367436400253320ustar00rootroot00000000000000import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest from bleak.backends.bluezdbus import defs from bleak.backends.device import BLEDevice from bleak_retry_connector.dbus import disconnect_devices pytestmark = pytest.mark.asyncio @pytest.fixture(scope="module", autouse=True) def _prime_dbus_coverage() -> None: # On Python 3.14, coverage's sysmon backend lazily calls os.stat to read # source the first time a branch fires in a tracked file; the autouse # blockbuster fixture in conftest then flags that stat as a blocking # call. Module-scoped fixtures run before the function-scoped blockbuster, # so executing the trivial path here warms the per-file source cache. loop = asyncio.new_event_loop() try: loop.run_until_complete(disconnect_devices([])) finally: loop.close() def _device(path: str | None = "/org/bluez/hci0/dev_FA_23_9D_AA_45_46") -> BLEDevice: details: dict[str, str] = {"source": "aa:bb:cc:dd:ee:ff"} if path is not None: details["path"] = path return BLEDevice("FA:23:9D:AA:45:46", "FA:23:9D:AA:45:46", details) async def test_disconnect_devices_empty_list() -> None: """Empty list returns early without touching the bluez manager.""" with patch( "bleak_retry_connector.dbus.get_global_bluez_manager_with_timeout", new=AsyncMock(), ) as mock_manager: await disconnect_devices([]) mock_manager.assert_not_called() async def test_disconnect_devices_filters_invalid_devices() -> None: """Devices without dict details or a 'path' key are skipped.""" no_dict_details = BLEDevice("AA:BB", "AA:BB", "not-a-dict") no_path = BLEDevice("CC:DD", "CC:DD", {"source": "xx"}) with patch( "bleak_retry_connector.dbus.get_global_bluez_manager_with_timeout", new=AsyncMock(), ) as mock_manager: await disconnect_devices([no_dict_details, no_path]) mock_manager.assert_not_called() async def test_disconnect_devices_no_bluez_manager() -> None: """If the bluez manager times out (returns None), do nothing.""" with patch( "bleak_retry_connector.dbus.get_global_bluez_manager_with_timeout", new=AsyncMock(return_value=None), ): await disconnect_devices([_device()]) async def test_disconnect_devices_calls_bus_for_each_valid_device() -> None: """Each valid device gets a BlueZ Disconnect call with the right path.""" bus = MagicMock() bus.call = AsyncMock() bluez_manager = MagicMock() bluez_manager._bus = bus device_a = _device("/org/bluez/hci0/dev_AA") device_b = _device("/org/bluez/hci0/dev_BB") invalid = BLEDevice("EE:FF", "EE:FF", {"source": "x"}) fake_message_cls = MagicMock() with ( patch( "bleak_retry_connector.dbus.get_global_bluez_manager_with_timeout", new=AsyncMock(return_value=bluez_manager), ), patch("bleak_retry_connector.dbus.Message", new=fake_message_cls), ): await disconnect_devices([device_a, invalid, device_b]) assert bus.call.await_count == 2 paths = [ kwargs["path"] if "path" in kwargs else args[0] for args, kwargs in [ (call.args, call.kwargs) for call in fake_message_cls.call_args_list ] ] assert paths == ["/org/bluez/hci0/dev_AA", "/org/bluez/hci0/dev_BB"] for call in fake_message_cls.call_args_list: assert call.kwargs["destination"] == defs.BLUEZ_SERVICE assert call.kwargs["interface"] == defs.DEVICE_INTERFACE assert call.kwargs["member"] == "Disconnect" async def test_disconnect_devices_suppresses_exceptions() -> None: """An exception from bus.call must not stop the loop.""" bus = MagicMock() bus.call = AsyncMock(side_effect=RuntimeError("boom")) bluez_manager = MagicMock() bluez_manager._bus = bus devices = [_device("/org/bluez/hci0/dev_A"), _device("/org/bluez/hci0/dev_B")] with ( patch( "bleak_retry_connector.dbus.get_global_bluez_manager_with_timeout", new=AsyncMock(return_value=bluez_manager), ), patch("bleak_retry_connector.dbus.Message", new=MagicMock()), ): await disconnect_devices(devices) assert bus.call.await_count == 2 async def test_disconnect_devices_suppresses_timeout() -> None: """A timeout from the inner asyncio_timeout context must be suppressed.""" import asyncio async def slow(*args, **kwargs): await asyncio.sleep(10) bus = MagicMock() bus.call = AsyncMock(side_effect=slow) bluez_manager = MagicMock() bluez_manager._bus = bus with ( patch( "bleak_retry_connector.dbus.get_global_bluez_manager_with_timeout", new=AsyncMock(return_value=bluez_manager), ), patch("bleak_retry_connector.dbus.Message", new=MagicMock()), patch("bleak_retry_connector.dbus.DISCONNECT_TIMEOUT", 0.01), ): await disconnect_devices([_device()]) assert bus.call.await_count == 1 Bluetooth-Devices-bleak-retry-connector-2326a9d/tests/test_init.py000066400000000000000000002767671520367436400253630ustar00rootroot00000000000000from __future__ import annotations import asyncio from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch import bleak import pytest from bleak import BleakClient, BleakError from bleak.backends.bluezdbus import defs from bleak.backends.bluezdbus.manager import DeviceWatcher from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from bleak.backends.service import BleakGATTService, BleakGATTServiceCollection from bleak.exc import BleakDBusError, BleakDeviceNotFoundError import bleak_retry_connector from bleak_retry_connector import ( BLEAK_BACKOFF_TIME, BLEAK_DBUS_BACKOFF_TIME, BLEAK_DISCONNECTED_BACKOFF_TIME, BLEAK_OUT_OF_SLOTS_BACKOFF_TIME, BLEAK_TRANSIENT_BACKOFF_TIME, BLEAK_TRANSIENT_LONG_BACKOFF_TIME, BLEAK_TRANSIENT_MEDIUM_BACKOFF_TIME, MAX_TRANSIENT_ERRORS, BleakAbortedError, BleakClientWithServiceCache, BleakConnectionError, BleakNotFoundError, BleakOutOfConnectionSlotsError, ble_device_description, ble_device_has_changed, calculate_backoff_time, clear_cache, close_stale_connections, close_stale_connections_by_address, establish_connection, get_connected_devices, get_device, get_device_by_adapter, restore_discoveries, retry_bluetooth_connection_error, ) from bleak_retry_connector.bleak_manager import _reset_dbus_socket_cache def make_scripted_client( script: list[BaseException | None], ) -> tuple[type[BleakClient], dict[str, int]]: """Build a ``BleakClient`` subclass whose ``connect()`` replays ``script``. Each call pops the next entry from ``script`` (by index): ``None`` means a successful connect, an exception instance is raised. Returns the class paired with an ``attempts`` counter dict that callers can read to assert on how many times ``connect()`` was invoked. """ attempts = {"n": 0} class _ScriptedClient(BleakClient): def __init__(self, *args: Any, **kwargs: Any) -> None: pass async def connect(self, *args: Any, **kwargs: Any) -> None: exc = script[attempts["n"]] attempts["n"] += 1 if exc is not None: raise exc async def disconnect(self, *args: Any, **kwargs: Any) -> None: pass return _ScriptedClient, attempts @pytest.mark.asyncio async def test_establish_connection_works_first_time(): class FakeBleakClient(BleakClient): async def connect(self, *args, **kwargs): pass async def disconnect(self, *args, **kwargs): pass client = await establish_connection( FakeBleakClient, MagicMock(), "test", disconnected_callback=MagicMock() ) assert isinstance(client, FakeBleakClient) @pytest.mark.asyncio async def test_establish_connection_passes_retry_client_flag(): """Test that establish_connection passes _is_retry_client=True to the client.""" received_kwargs = {} class FakeBleakClient(BleakClient): def __init__(self, *args, **kwargs): # Capture the kwargs passed to __init__ received_kwargs.update(kwargs) # Remove _is_retry_client before calling super() if it exists # since the base BleakClient doesn't expect it kwargs.pop("_is_retry_client", None) # Don't call super().__init__ to avoid platform-specific initialization self._device_path = None self._device_info = None self._backend = None async def connect(self, *args, **kwargs): pass async def disconnect(self, *args, **kwargs): pass device = MagicMock(spec=BLEDevice) device.address = "00:00:00:00:00:01" client = await establish_connection( FakeBleakClient, device, "test", disconnected_callback=MagicMock() ) assert isinstance(client, FakeBleakClient) assert "_is_retry_client" in received_kwargs assert received_kwargs["_is_retry_client"] is True @pytest.mark.asyncio async def test_establish_connection_passes_pair_flag(): """Test that establish_connection passes pair=True to the client.""" received_kwargs = {} class FakeBleakClient(BleakClient): def __init__(self, *args, **kwargs): # Capture the kwargs passed to __init__ received_kwargs.update(kwargs) # Remove kwargs that base BleakClient doesn't expect kwargs.pop("_is_retry_client", None) # Don't call super().__init__ to avoid platform-specific initialization self._device_path = None self._device_info = None self._backend = None async def connect(self, *args, **kwargs): pass async def disconnect(self, *args, **kwargs): pass device = MagicMock(spec=BLEDevice) device.address = "00:00:00:00:00:01" client = await establish_connection( FakeBleakClient, device, "test", disconnected_callback=MagicMock(), pair=True ) assert isinstance(client, FakeBleakClient) assert "pair" in received_kwargs assert received_kwargs["pair"] is True @pytest.mark.asyncio async def test_establish_connection_with_cached_services(): class FakeBleakClient(BleakClient): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._device_path = "/dev/test" async def connect(self, *args, **kwargs): return True async def disconnect(self, *args, **kwargs): pass async def get_services(self, *args, **kwargs): return [] class FakeBleakClientWithServiceCache(BleakClientWithServiceCache, FakeBleakClient): """Fake BleakClientWithServiceCache.""" async def get_services(self, *args, **kwargs): return [] collection = BleakGATTServiceCollection() class FakeBluezManager: def __init__(self): self._services_cache = {} self._properties = { "/dev/test/service/1": { "UUID": "service", "Primary": True, "Characteristics": [], defs.GATT_SERVICE_INTERFACE: True, }, } bluez_manager = FakeBluezManager() bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=bluez_manager ) bleak_retry_connector.bleak_manager.get_global_bluez_manager_with_timeout = ( AsyncMock(return_value=bluez_manager) ) bleak_retry_connector.bluez.defs = defs client = await establish_connection( FakeBleakClientWithServiceCache, MagicMock(), "test", disconnected_callback=MagicMock(), cached_services=collection, ) assert isinstance(client, FakeBleakClientWithServiceCache) await client.get_services() is collection @pytest.mark.asyncio async def test_establish_connection_with_cached_services_that_have_vanished(): class FakeBleakClient(BleakClient): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._device_path = "/dev/test" async def connect(self, *args, **kwargs): return True async def disconnect(self, *args, **kwargs): pass async def get_services(self, *args, **kwargs): return [] class FakeBleakClientWithServiceCache(BleakClientWithServiceCache, FakeBleakClient): """Fake BleakClientWithServiceCache.""" async def get_services(self, *args, **kwargs): return [] collection = BleakGATTServiceCollection() class FakeBluezManager: def __init__(self): self._services_cache = {} self._properties = {} bluez_manager = FakeBluezManager() bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=bluez_manager ) bleak_retry_connector.bleak_manager.get_global_bluez_manager_with_timeout = ( AsyncMock(return_value=bluez_manager) ) bleak_retry_connector.bluez.defs = defs client = await establish_connection( FakeBleakClientWithServiceCache, MagicMock(), "test", disconnected_callback=MagicMock(), cached_services=collection, ) assert isinstance(client, FakeBleakClientWithServiceCache) await client.get_services() is collection @pytest.mark.asyncio async def test_establish_connection_can_cache_services_always_patched(): class FakeBleakClient(BleakClient): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._device_path = "/dev/test" async def connect(self, *args, **kwargs): return True async def disconnect(self, *args, **kwargs): pass async def get_services(self, *args, **kwargs): return [] class FakeBleakClientWithServiceCache(BleakClientWithServiceCache, FakeBleakClient): """Fake BleakClientWithServiceCache.""" collection = BleakGATTServiceCollection() class FakeBluezManager: def __init__(self): self._services_cache = {} self._properties = { "/dev/test/service/1": { "UUID": "service", "Primary": True, "Characteristics": [], defs.GATT_SERVICE_INTERFACE: True, }, } bluez_manager = FakeBluezManager() bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=bluez_manager ) bleak_retry_connector.bleak_manager.get_global_bluez_manager_with_timeout = ( AsyncMock(return_value=bluez_manager) ) bleak_retry_connector.bluez.defs = defs client = await establish_connection( FakeBleakClientWithServiceCache, MagicMock(), "test", disconnected_callback=MagicMock(), cached_services=collection, ) assert isinstance(client, FakeBleakClientWithServiceCache) await client.get_services() is collection @pytest.mark.asyncio async def test_establish_connection_can_cache_services_services_missing(): class FakeBleakClient(BleakClient): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._device_path = "/dev/test" async def connect(self, *args, **kwargs): return True async def disconnect(self, *args, **kwargs): pass async def get_services(self, *args, **kwargs): return [] class FakeBleakClientWithServiceCache(BleakClientWithServiceCache, FakeBleakClient): """Fake BleakClientWithServiceCache.""" collection = BleakGATTServiceCollection() class FakeBluezManager: def __init__(self): self._services_cache = {} self._properties = { "/dev/test2/service/1": { "UUID": "service", "Primary": True, "Characteristics": [], }, } bluez_manager = FakeBluezManager() bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=bluez_manager ) bleak_retry_connector.bleak_manager.get_global_bluez_manager_with_timeout = ( AsyncMock(return_value=bluez_manager) ) bleak_retry_connector.bluez.defs = defs client = await establish_connection( FakeBleakClientWithServiceCache, MagicMock(), "test", disconnected_callback=MagicMock(), cached_services=collection, ) assert isinstance(client, FakeBleakClientWithServiceCache) await client.get_services() is collection @pytest.mark.asyncio async def test_establish_connection_can_cache_services_newer_bleak(): class FakeBleakClient(BleakClient): async def connect(self, *args, **kwargs): return True async def disconnect(self, *args, **kwargs): pass async def get_services(self, *args, **kwargs): return [] class FakeBleakClientWithServiceCache(BleakClientWithServiceCache, FakeBleakClient): """Fake BleakClientWithServiceCache.""" collection = BleakGATTServiceCollection() client = await establish_connection( FakeBleakClientWithServiceCache, MagicMock(), "test", disconnected_callback=MagicMock(), cached_services=collection, ) assert isinstance(client, FakeBleakClientWithServiceCache) await client.get_services() is collection @pytest.mark.asyncio async def test_establish_connection_with_dangerous_use_cached_services(): class FakeBleakClient(BleakClient): async def connect(self, *args, **kwargs): return True async def disconnect(self, *args, **kwargs): pass async def get_services(self, *args, **kwargs): return [] class FakeBleakClientWithServiceCache(BleakClientWithServiceCache, FakeBleakClient): """Fake BleakClientWithServiceCache.""" client = await establish_connection( FakeBleakClientWithServiceCache, MagicMock(), "test", disconnected_callback=MagicMock(), ) assert isinstance(client, FakeBleakClientWithServiceCache) @pytest.mark.asyncio async def test_establish_connection_without_dangerous_use_cached_services(): class FakeBleakClient(BleakClient): async def connect(self, *args, **kwargs): return True async def disconnect(self, *args, **kwargs): pass async def get_services(self, *args, **kwargs): return [] class FakeBleakClientWithServiceCache(BleakClientWithServiceCache, FakeBleakClient): """Fake BleakClientWithServiceCache.""" client = await establish_connection( FakeBleakClientWithServiceCache, MagicMock(), "test", disconnected_callback=MagicMock(), ) assert isinstance(client, FakeBleakClientWithServiceCache) @pytest.mark.asyncio async def test_establish_connection_fails(): class FakeBleakClient(BleakClient): def __init__(self, *args, **kwargs): pass async def connect(self, *args, **kwargs): raise BleakError("test") async def disconnect(self, *args, **kwargs): pass with ( patch("bleak_retry_connector.calculate_backoff_time", return_value=0), pytest.raises(BleakConnectionError), ): await establish_connection(FakeBleakClient, MagicMock(), "test") @pytest.mark.asyncio async def test_establish_connection_times_out(): class FakeBleakClient(BleakClient): def __init__(self, *args, **kwargs): pass async def connect(self, *args, **kwargs): raise asyncio.TimeoutError() async def disconnect(self, *args, **kwargs): pass with ( patch("bleak_retry_connector.calculate_backoff_time", return_value=0), pytest.raises(BleakNotFoundError), ): await establish_connection(FakeBleakClient, MagicMock(), "test") @pytest.mark.asyncio async def test_establish_connection_has_transient_error(): attempts = 0 class FakeBleakClient(BleakClient): def __init__(self, *args, **kwargs): pass async def connect(self, *args, **kwargs): nonlocal attempts attempts += 1 if attempts < MAX_TRANSIENT_ERRORS: raise BleakError("le-connection-abort-by-local") pass async def disconnect(self, *args, **kwargs): pass with patch("bleak_retry_connector.calculate_backoff_time", return_value=0): client = await establish_connection(FakeBleakClient, MagicMock(), "test") assert isinstance(client, FakeBleakClient) assert attempts == 9 @pytest.mark.asyncio async def test_establish_connection_has_transient_broken_pipe_error(): attempts = 0 class FakeBleakClient(BleakClient): def __init__(self, *args, **kwargs): pass async def connect(self, *args, **kwargs): nonlocal attempts attempts += 1 if attempts < MAX_TRANSIENT_ERRORS: raise BrokenPipeError pass async def disconnect(self, *args, **kwargs): pass client = await establish_connection(FakeBleakClient, MagicMock(), "test") assert isinstance(client, FakeBleakClient) assert attempts == 9 @pytest.mark.asyncio async def test_establish_connection_has_transient_eof_error(): """EOFError raised during connect is treated as transient and retried.""" attempts = 0 wait_calls: list[float] = [] class FakeBleakClient(BleakClient): def __init__(self, *args, **kwargs): pass async def connect(self, *args, **kwargs): nonlocal attempts attempts += 1 if attempts < MAX_TRANSIENT_ERRORS: raise EOFError async def disconnect(self, *args, **kwargs): pass async def fake_wait_for_disconnect(device, backoff_time): wait_calls.append(backoff_time) with ( patch( "bleak_retry_connector.wait_for_disconnect", side_effect=fake_wait_for_disconnect, ), patch("bleak_retry_connector.calculate_backoff_time", return_value=0), ): client = await establish_connection(FakeBleakClient, MagicMock(), "test") assert isinstance(client, FakeBleakClient) assert attempts == 9 assert wait_calls == [0] * 8 @pytest.mark.asyncio async def test_establish_connection_eof_error_exhausts_retries( caplog: pytest.LogCaptureFixture, ) -> None: """EOFError exceeding MAX_TRANSIENT_ERRORS raises BleakConnectionError and logs.""" attempts = 0 class FakeBleakClient(BleakClient): def __init__(self, *args, **kwargs): pass async def connect(self, *args, **kwargs): nonlocal attempts attempts += 1 raise EOFError("broken bus") async def disconnect(self, *args, **kwargs): pass with ( patch("bleak_retry_connector.wait_for_disconnect", AsyncMock()), patch("bleak_retry_connector.calculate_backoff_time", return_value=0), ): with pytest.raises(BleakConnectionError): await establish_connection(FakeBleakClient, MagicMock(), "test") assert attempts == MAX_TRANSIENT_ERRORS assert "broken bus" in caplog.text assert "backing off" in caplog.text @pytest.mark.asyncio async def test_establish_connection_services_changed(): attempts = 0 disconnect_calls = 0 clear_cache_calls = 0 class FakeBleakClient(BleakClientWithServiceCache): def __init__(self, *args, **kwargs): pass async def connect(self, *args, **kwargs): nonlocal attempts attempts += 1 if attempts < MAX_TRANSIENT_ERRORS: raise KeyError async def disconnect(self, *args, **kwargs): nonlocal disconnect_calls disconnect_calls += 1 async def clear_cache(self) -> bool: nonlocal clear_cache_calls clear_cache_calls += 1 return True client = await establish_connection(FakeBleakClient, MagicMock(), "test") assert isinstance(client, FakeBleakClient) assert attempts == 9 assert disconnect_calls == 8 assert clear_cache_calls == 8 @pytest.mark.asyncio async def test_establish_connection_has_transient_error_had_advice(): class FakeBleakClient(BleakClient): def __init__(self, *args, **kwargs): pass async def connect(self, *args, **kwargs): raise BleakError("le-connection-abort-by-local") async def disconnect(self, *args, **kwargs): pass with patch("bleak_retry_connector.calculate_backoff_time", return_value=0): try: await establish_connection( FakeBleakClient, BLEDevice( "aa:bb:cc:dd:ee:ff", "name", {"path": "/org/bluez/hci2/dev_FA_23_9D_AA_45_46"}, ), "test", ) except BleakError as e: exc = e assert isinstance(exc, BleakAbortedError) assert str(exc) == ( "test - aa:bb:cc:dd:ee:ff: " "Failed to connect after 9 attempt(s): " "le-connection-abort-by-local: " "Interference/range; " "External Bluetooth adapter w/extension may help; " "Extension cables reduce USB 3 port interference" ) @pytest.mark.asyncio async def test_establish_connection_out_of_slots_advice(): class FakeBleakClient(BleakClient): def __init__(self, *args, **kwargs): pass async def connect(self, *args, **kwargs): raise BleakError("out of connection slots") async def disconnect(self, *args, **kwargs): pass with patch("bleak_retry_connector.calculate_backoff_time", return_value=0): try: await establish_connection( FakeBleakClient, BLEDevice("aa:bb:cc:dd:ee:ff", "name", {"source": "esphome_proxy_1"}), "test", ) except BleakError as e: exc = e assert isinstance(exc, BleakOutOfConnectionSlotsError) assert str(exc) == ( "test - aa:bb:cc:dd:ee:ff: Failed to connect after 9 attempt(s): " "out of connection slots: The proxy/adapter is " "out of connection slots or the device is no " "longer reachable; Add additional proxies " "(https://esphome.github.io/bluetooth-proxies/) near this device" ) @pytest.mark.asyncio async def test_establish_connection_esp_gatt_conn_conn_cancel_out_of_slots(): """Test ESP_GATT_CONN_CONN_CANCEL is treated as out of slots error.""" class FakeBleakClient(BleakClient): def __init__(self, *args, **kwargs): pass async def connect(self, *args, **kwargs): raise BleakError("ESP_GATT_CONN_CONN_CANCEL") async def disconnect(self, *args, **kwargs): pass with patch("bleak_retry_connector.calculate_backoff_time", return_value=0): try: await establish_connection( FakeBleakClient, BLEDevice("aa:bb:cc:dd:ee:ff", "name", {"source": "esphome_proxy_1"}), "test", ) except BleakError as e: exc = e assert isinstance(exc, BleakOutOfConnectionSlotsError) assert str(exc) == ( "test - aa:bb:cc:dd:ee:ff: Failed to connect after 9 attempt(s): " "ESP_GATT_CONN_CONN_CANCEL: The proxy/adapter is " "out of connection slots or the device is no " "longer reachable; Add additional proxies " "(https://esphome.github.io/bluetooth-proxies/) near this device" ) @pytest.mark.asyncio async def test_device_disappeared_error(): class FakeBleakClient(BleakClient): def __init__(self, *args, **kwargs): pass async def connect(self, *args, **kwargs): raise BleakError( '[org.freedesktop.DBus.Error.UnknownObject] Method "Connect" with ' 'signature "" on interface ' '"org.bluez.Device1" ' "doesn't exist" ) async def disconnect(self, *args, **kwargs): pass with patch("bleak_retry_connector.calculate_backoff_time", return_value=0): try: await establish_connection( FakeBleakClient, BLEDevice( "aa:bb:cc:dd:ee:ff", "name", {"path": "/org/bluez/hci2/dev_FA_23_9D_AA_45_46"}, ), "test", ) except BleakError as e: exc = e assert isinstance(exc, BleakNotFoundError) assert str(exc) == ( "test - aa:bb:cc:dd:ee:ff: " "Failed to connect after 4 attempt(s): " "[org.freedesktop.DBus.Error.UnknownObject] " 'Method "Connect" with signature "" on interface "org.bluez.Device1" ' "doesn't exist: The device disappeared; " "Try restarting the scanner or moving the device closer" ) @pytest.mark.asyncio @patch.object(bleak_retry_connector.bluez, "IS_LINUX", True) async def test_device_disappeared_and_reappears(): class FakeBluezManager: def __init__(self): self._services_cache = {} self.watchers: set[DeviceWatcher] = set() self._properties = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -30, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci1/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Connected": True, "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -79, }, defs.GATT_SERVICE_INTERFACE: True, }, } def add_device_watcher(self, path: str, **kwargs: Any) -> DeviceWatcher: """Add a watcher for device changes.""" watcher = DeviceWatcher(path, **kwargs) self.watchers.add(watcher) return watcher async def _wait_condition(self, *args: Any, **kwargs: Any) -> None: """Wait for a condition to be met.""" raise KeyError def remove_device_watcher(self, watcher: DeviceWatcher) -> None: """Remove a watcher for device changes.""" self.watchers.remove(watcher) def is_connected(self, path: str) -> bool: """Check if device is connected.""" return False bluez_manager = FakeBluezManager() bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=bluez_manager ) bleak_retry_connector.bleak_manager.get_global_bluez_manager_with_timeout = ( AsyncMock(return_value=bluez_manager) ) bleak_retry_connector.bluez.defs = defs class FakeBleakClient(BleakClient): def __init__(self, *args, **kwargs): pass async def connect(self, *args, **kwargs): raise BleakDeviceNotFoundError( '[org.freedesktop.DBus.Error.UnknownObject] Method "Connect" with ' 'signature "" on interface ' '"org.bluez.Device1" ' "doesn't exist" ) async def disconnect(self, *args, **kwargs): pass with ( patch("bleak_retry_connector.calculate_backoff_time", return_value=0.01), patch.object(bleak_retry_connector.bluez, "REAPPEAR_WAIT_INTERVAL", 0.0025), ): try: await establish_connection( FakeBleakClient, BLEDevice( "FA:23:9D:AA:45:46", "name", {"path": "/org/bluez/hci2/dev_FA_23_9D_AA_45_46"}, ), "test", ) except BleakError as e: exc = e assert isinstance(exc, BleakNotFoundError) assert str(exc) == ( "test - FA:23:9D:AA:45:46: " "Failed to connect after 9 attempt(s): " "BleakDeviceNotFoundError: " "The device disappeared; " "Try restarting the scanner or moving the device closer" ) @pytest.mark.asyncio async def test_establish_connection_has_one_unknown_error(): attempts = 0 class FakeBleakClient(BleakClient): def __init__(self, *args, **kwargs): pass async def connect(self, *args, **kwargs): nonlocal attempts attempts += 1 if attempts == 1: raise BleakError("unknown") pass async def disconnect(self, *args, **kwargs): pass client = await establish_connection(FakeBleakClient, MagicMock(), "test") assert isinstance(client, FakeBleakClient) assert attempts == 2 @pytest.mark.asyncio async def test_establish_connection_has_one_many_error(): attempts = 0 class FakeBleakClient(BleakClient): def __init__(self, *args, **kwargs): pass async def connect(self, *args, **kwargs): nonlocal attempts attempts += 1 if attempts < 10: raise BleakError("unknown") pass async def disconnect(self, *args, **kwargs): pass with ( patch("bleak_retry_connector.calculate_backoff_time", return_value=0), pytest.raises(BleakConnectionError), ): await establish_connection(FakeBleakClient, MagicMock(), "test") @pytest.mark.asyncio async def test_bleak_connect_overruns_timeout(): class FakeBleakClient(BleakClient): def __init__(self, *args, **kwargs): pass async def connect(self, *args, **kwargs): await asyncio.sleep(40) async def disconnect(self, *args, **kwargs): pass with ( patch("bleak_retry_connector.calculate_backoff_time", return_value=0), patch.object(bleak_retry_connector, "BLEAK_SAFETY_TIMEOUT", 0), pytest.raises(BleakNotFoundError), ): await establish_connection(FakeBleakClient, MagicMock(), "test") def test_ble_device_has_changed(): """Test that the BLEDevice has changed when the underlying device has changed.""" assert not ble_device_has_changed( BLEDevice("aa:bb:cc:dd:ee:ff", "name", {"path": "/dev/1"}), BLEDevice("aa:bb:cc:dd:ee:ff", "name", {"path": "/dev/1"}), ) assert ble_device_has_changed( BLEDevice("aa:bb:cc:dd:ee:ff", "name", {"path": "/dev/1"}), BLEDevice("ab:bb:cc:dd:ee:ff", "name", {"path": "/dev/1"}), ) assert ble_device_has_changed( BLEDevice("aa:bb:cc:dd:ee:ff", "name", {"path": "/dev/1"}), BLEDevice("aa:bb:cc:dd:ee:ff", "name", {"path": "/dev/2"}), ) @pytest.mark.asyncio async def test_establish_connection_other_adapter_already_connected(mock_linux): device: BLEDevice | None = None class FakeBleakClient(BleakClient): def __init__(self, ble_device_or_address, *args, **kwargs): ble_device_or_address.details["delegate"] = 0 super().__init__(ble_device_or_address, *args, **kwargs) nonlocal device device = ble_device_or_address self._device_path = "/org/bluez/hci2/dev_FA_23_9D_AA_45_46" async def connect(self, *args, **kwargs): return True async def disconnect(self, *args, **kwargs): pass async def get_services(self, *args, **kwargs): return [] class FakeBleakClientWithServiceCache(BleakClientWithServiceCache, FakeBleakClient): """Fake BleakClientWithServiceCache.""" async def get_services(self, *args, **kwargs): return [] collection = BleakGATTServiceCollection() class FakeBluezManager: def __init__(self): self._services_cache = {} self._properties = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D_AA:45:46", "RSSI": -30, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci1/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "Connected": True, "RSSI": -79, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci2/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "Connected": False, "RSSI": -80, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci3/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -31, }, defs.GATT_SERVICE_INTERFACE: True, }, } bluez_manager = FakeBluezManager() bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=bluez_manager ) bleak_retry_connector.bleak_manager.get_global_bluez_manager_with_timeout = ( AsyncMock(return_value=bluez_manager) ) bleak_retry_connector.bluez.defs = defs client = await establish_connection( FakeBleakClientWithServiceCache, BLEDevice( "aa:bb:cc:dd:ee:ff", "name", {"path": "/org/bluez/hci2/dev_FA_23_9D_AA_45_46"}, ), "test", disconnected_callback=MagicMock(), cached_services=collection, ) assert isinstance(client, FakeBleakClientWithServiceCache) await client.get_services() is collection assert device is not None assert device.details["path"] == "/org/bluez/hci1/dev_FA_23_9D_AA_45_46" @pytest.mark.asyncio async def test_establish_connection_device_disappeared(mock_linux): class FakeBleakClient(BleakClient): def __init__(self, ble_device_or_address, *args, **kwargs): ble_device_or_address.details["delegate"] = 0 super().__init__(ble_device_or_address, *args, **kwargs) self._device_path = "/org/bluez/hci2/dev_FA_23_9D_AA_45_46" async def connect(self, *args, **kwargs): return True async def disconnect(self, *args, **kwargs): pass async def get_services(self, *args, **kwargs): return [] class FakeBleakClientWithServiceCache(BleakClientWithServiceCache, FakeBleakClient): """Fake BleakClientWithServiceCache.""" async def get_services(self, *args, **kwargs): return [] collection = BleakGATTServiceCollection() class FakeBluezManager: def __init__(self): self._services_cache = {} self._properties = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "bob", "RSSI": -30, }, defs.GATT_SERVICE_INTERFACE: True, }, } bluez_manager = FakeBluezManager() bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=bluez_manager ) bleak_retry_connector.bleak_manager.get_global_bluez_manager_with_timeout = ( AsyncMock(return_value=bluez_manager) ) bleak_retry_connector.bluez.defs = defs with patch("bleak_retry_connector.calculate_backoff_time", return_value=0): client = await establish_connection( FakeBleakClientWithServiceCache, BLEDevice( "aa:bb:cc:dd:ee:ff", "name", {"path": "/org/bluez/hci2/dev_FA_23_9D_AA_45_46"}, ), "test", disconnected_callback=MagicMock(), cached_services=collection, ) assert isinstance(client, FakeBleakClientWithServiceCache) await client.get_services() is collection @pytest.mark.asyncio async def test_get_device(mock_linux): class FakeBluezManager: def __init__(self): self._services_cache = {} self._properties = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -30, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci1/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -79, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci2/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -80, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci3/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -31, }, defs.GATT_SERVICE_INTERFACE: True, }, } bluez_manager = FakeBluezManager() bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=bluez_manager ) bleak_retry_connector.bleak_manager.get_global_bluez_manager_with_timeout = ( AsyncMock(return_value=bluez_manager) ) bleak_retry_connector.bluez.defs = defs device = await get_device("FA:23:9D:AA:45:46") assert device is not None assert device.details["path"] == "/org/bluez/hci0/dev_FA_23_9D_AA_45_46" @pytest.mark.asyncio async def test_clear_cache(mock_linux): class FakeBluezManager: def __init__(self): self._services_cache = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": "test", "/org/bluez/hci1/dev_FA_23_9D_AA_45_46": "test", } self._properties = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -30, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci1/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -79, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci2/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -80, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci3/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -31, }, defs.GATT_SERVICE_INTERFACE: True, }, } bluez_manager = FakeBluezManager() bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=bluez_manager ) bleak_retry_connector.bleak_manager.get_global_bluez_manager_with_timeout = ( AsyncMock(return_value=bluez_manager) ) bleak_retry_connector.bluez.defs = defs device = await get_device("FA:23:9D:AA:45:46") assert device is not None assert device.details["path"] == "/org/bluez/hci0/dev_FA_23_9D_AA_45_46" assert await clear_cache("FA:23:9D:AA:45:46") assert bluez_manager._services_cache == {} @pytest.mark.asyncio async def test_get_device_mac_os(mock_macos): class FakeBluezManager: def __init__(self): self._services_cache = {} self._properties = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -30, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci1/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -79, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci2/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -80, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci3/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -31, }, defs.GATT_SERVICE_INTERFACE: True, }, } bluez_manager = FakeBluezManager() bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=bluez_manager ) bleak_retry_connector.bleak_manager.get_global_bluez_manager_with_timeout = ( AsyncMock(return_value=bluez_manager) ) bleak_retry_connector.bluez.defs = defs device = await get_device("FA:23:9D:AA:45:46") assert device is None @pytest.mark.asyncio async def test_get_device_already_connected(mock_linux): class FakeBluezManager: def __init__(self): self._services_cache = {} self._properties = { "/org/bluez/hci1/dev_BD_24_6F_85_AA_61": { "org.freedesktop.DBus.Introspectable": {}, "org.bluez.Device1": { "Address": "BD:24:6F:85:AA:61", "AddressType": "public", "Name": "Dream~BD246F85AA61", "Alias": "Dream~BD246F85AA61", "Appearance": 962, "Icon": "input-mouse", "Paired": False, "Trusted": False, "Blocked": False, "LegacyPairing": False, "Connected": True, "UUIDs": [ "00001800-0000-1000-8000-00805f9b34fb", "00001801-0000-1000-8000-00805f9b34fb", "0000180a-0000-1000-8000-00805f9b34fb", "0000ffd0-0000-1000-8000-00805f9b34fb", "0000ffd5-0000-1000-8000-00805f9b34fb", ], "Modalias": "usb:v045Ep0040d0300", "Adapter": "/org/bluez/hci1", "ManufacturerData": {20808: bytearray(b"364656")}, "ServicesResolved": True, }, "org.freedesktop.DBus.Properties": {}, } } bluez_manager = FakeBluezManager() bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=bluez_manager ) bleak_retry_connector.bleak_manager.get_global_bluez_manager_with_timeout = ( AsyncMock(return_value=bluez_manager) ) bleak_retry_connector.bluez.defs = defs device = await get_device("BD:24:6F:85:AA:61") assert device is not None assert device.details["path"] == "/org/bluez/hci1/dev_BD_24_6F_85_AA_61" connected = await get_connected_devices(device) assert len(connected) == 1 assert isinstance(connected[0], BLEDevice) assert connected[0].details["path"] == "/org/bluez/hci1/dev_BD_24_6F_85_AA_61" @pytest.mark.asyncio async def test_get_device_not_there(): class FakeBluezManager: def __init__(self): self._services_cache = {} self._properties = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -30, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci1/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -79, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci2/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -80, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci3/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -31, }, defs.GATT_SERVICE_INTERFACE: True, }, } bluez_manager = FakeBluezManager() bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=bluez_manager ) bleak_retry_connector.bleak_manager.get_global_bluez_manager_with_timeout = ( AsyncMock(return_value=bluez_manager) ) bleak_retry_connector.bluez.defs = defs with patch.object(bleak_retry_connector.const, "IS_LINUX", True): device = await get_device("00:00:00:00:00:00") assert device is None @pytest.mark.asyncio async def test_establish_connection_better_rssi_available_already_connected_supported_different_adapter( mock_linux, ): device: BLEDevice | None = None class FakeBleakClient(BleakClient): def __init__(self, ble_device_or_address, *args, **kwargs): ble_device_or_address.details["delegate"] = 0 super().__init__(ble_device_or_address, *args, **kwargs) nonlocal device device = ble_device_or_address self._device_path = "/org/bluez/hci2/dev_FA_23_9D_AA_45_46" async def connect(self, *args, **kwargs): return True async def disconnect(self, *args, **kwargs): pass async def get_services(self, *args, **kwargs): return [] class FakeBleakClientWithServiceCache(BleakClientWithServiceCache, FakeBleakClient): """Fake BleakClientWithServiceCache.""" async def get_services(self, *args, **kwargs): return [] collection = BleakGATTServiceCollection() class FakeBluezManager: def __init__(self): self._services_cache = {} self._properties = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -30, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci1/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Connected": True, "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -79, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci2/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Connected": True, "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -80, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci3/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -31, }, defs.GATT_SERVICE_INTERFACE: True, }, } bluez_manager = FakeBluezManager() bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=bluez_manager ) bleak_retry_connector.bleak_manager.get_global_bluez_manager_with_timeout = ( AsyncMock(return_value=bluez_manager) ) bleak_retry_connector.bluez.defs = defs mock_device = BLEDevice( "aa:bb:cc:dd:ee:ff", "name", {"path": "/org/bluez/hci2/dev_FA_23_9D_AA_45_46"}, ) connected = await get_connected_devices(mock_device) assert len(connected) == 2 assert isinstance(connected[0], BLEDevice) assert connected[0].details["path"] == "/org/bluez/hci1/dev_FA_23_9D_AA_45_46" assert connected[1].details["path"] == "/org/bluez/hci2/dev_FA_23_9D_AA_45_46" with patch("bleak_retry_connector._disconnect_devices") as mock_disconnect_device: client = await establish_connection( FakeBleakClientWithServiceCache, BLEDevice( "FA:23:9D:AA:45:46", "name", {"path": "/org/bluez/hci2/dev_FA_23_9D_AA_45_46"}, ), "test", disconnected_callback=MagicMock(), cached_services=collection, ) assert isinstance(client, FakeBleakClientWithServiceCache) await client.get_services() is collection assert device is not None assert device.details["path"] == "/org/bluez/hci1/dev_FA_23_9D_AA_45_46" assert not mock_disconnect_device.mock_calls @pytest.mark.asyncio async def test_establish_connection_better_rssi_available_already_connected_supported_same_adapter( mock_linux, ): device: BLEDevice | None = None class FakeBleakClient(BleakClient): def __init__(self, ble_device_or_address, *args, **kwargs): ble_device_or_address.details["delegate"] = 0 super().__init__(ble_device_or_address, *args, **kwargs) nonlocal device device = ble_device_or_address self._device_path = "/org/bluez/hci2/dev_FA_23_9D_AA_45_46" async def connect(self, *args, **kwargs): return True async def disconnect(self, *args, **kwargs): pass async def get_services(self, *args, **kwargs): return [] class FakeBleakClientWithServiceCache(BleakClientWithServiceCache, FakeBleakClient): """Fake BleakClientWithServiceCache.""" async def get_services(self, *args, **kwargs): return [] collection = BleakGATTServiceCollection() class FakeBluezManager: def __init__(self): self._services_cache = {} self._properties = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -30, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci1/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Connected": True, "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -79, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci2/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Connected": True, "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -80, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci3/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -31, }, defs.GATT_SERVICE_INTERFACE: True, }, } bluez_manager = FakeBluezManager() bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=bluez_manager ) bleak_retry_connector.bleak_manager.get_global_bluez_manager_with_timeout = ( AsyncMock(return_value=bluez_manager) ) bleak_retry_connector.bluez.defs = defs mock_device = BLEDevice( "aa:bb:cc:dd:ee:ff", "name", {"path": "/org/bluez/hci2/dev_FA_23_9D_AA_45_46"}, ) connected = await get_connected_devices(mock_device) assert len(connected) == 2 assert isinstance(connected[0], BLEDevice) assert connected[0].details["path"] == "/org/bluez/hci1/dev_FA_23_9D_AA_45_46" assert connected[1].details["path"] == "/org/bluez/hci2/dev_FA_23_9D_AA_45_46" backend_info = bleak.get_platform_client_backend_type() with ( patch("bleak_retry_connector._disconnect_devices") as mock_disconnect_device, patch("bleak.get_platform_client_backend_type", return_value=backend_info), ): client = await establish_connection( FakeBleakClientWithServiceCache, BLEDevice( "FA:23:9D:AA:45:46", "name", {"path": "/org/bluez/hci1/dev_FA_23_9D_AA_45_46"}, ), "test", disconnected_callback=MagicMock(), cached_services=collection, ) assert isinstance(client, FakeBleakClientWithServiceCache) await client.get_services() is collection assert device is not None assert device.details["path"] == "/org/bluez/hci1/dev_FA_23_9D_AA_45_46" assert not mock_disconnect_device.mock_calls @pytest.mark.asyncio async def test_get_device_by_adapter(mock_linux): class FakeBluezManager: def __init__(self): self._services_cache = {} self._properties = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -30, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci1/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -79, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci2/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -80, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci3/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -31, }, defs.GATT_SERVICE_INTERFACE: True, }, } bluez_manager = FakeBluezManager() bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=bluez_manager ) bleak_retry_connector.bleak_manager.get_global_bluez_manager_with_timeout = ( AsyncMock(return_value=bluez_manager) ) bleak_retry_connector.bluez.defs = defs device_hci0 = await get_device_by_adapter("FA:23:9D:AA:45:46", "hci0") device_hci1 = await get_device_by_adapter("FA:23:9D:AA:45:46", "hci1") assert device_hci0 is not None assert device_hci0.details["path"] == "/org/bluez/hci0/dev_FA_23_9D_AA_45_46" assert device_hci1 is not None assert device_hci1.details["path"] == "/org/bluez/hci1/dev_FA_23_9D_AA_45_46" def test_calculate_backoff_time(): """Test that the backoff time is calculated correctly.""" assert calculate_backoff_time(Exception()) == BLEAK_BACKOFF_TIME assert ( calculate_backoff_time(BleakDBusError(MagicMock(), MagicMock())) == BLEAK_DBUS_BACKOFF_TIME ) assert ( calculate_backoff_time( BleakError( "No backend with an available connection slot that can reach address EB:4A:D4:93:68:EF was found" ) ) == BLEAK_OUT_OF_SLOTS_BACKOFF_TIME ) assert ( calculate_backoff_time(BleakError("ESP_GATT_CONN_TERMINATE_PEER_USER")) == BLEAK_TRANSIENT_BACKOFF_TIME ) assert ( calculate_backoff_time(BleakError("ESP_GATT_CONN_FAIL_ESTABLISH")) == BLEAK_TRANSIENT_MEDIUM_BACKOFF_TIME ) assert ( calculate_backoff_time(BleakError("ESP_GATT_ERROR")) == BLEAK_TRANSIENT_LONG_BACKOFF_TIME ) assert ( calculate_backoff_time(BleakDeviceNotFoundError("Out of slots")) == BLEAK_OUT_OF_SLOTS_BACKOFF_TIME ) assert ( calculate_backoff_time(BleakError("ESP_GATT_CONN_CONN_CANCEL")) == BLEAK_OUT_OF_SLOTS_BACKOFF_TIME ) assert calculate_backoff_time(EOFError()) == BLEAK_DBUS_BACKOFF_TIME assert calculate_backoff_time(BrokenPipeError()) == BLEAK_DBUS_BACKOFF_TIME assert calculate_backoff_time(asyncio.TimeoutError()) == BLEAK_DBUS_BACKOFF_TIME assert ( calculate_backoff_time(BleakNotFoundError("not found")) == BLEAK_OUT_OF_SLOTS_BACKOFF_TIME ) assert ( calculate_backoff_time(BleakError("Disconnected")) == BLEAK_DISCONNECTED_BACKOFF_TIME ) @pytest.mark.asyncio async def test_retry_bluetooth_connection_error(): """Test that the retry_bluetooth_connection_error decorator works correctly.""" @retry_bluetooth_connection_error() async def test_function(): raise BleakDBusError(MagicMock(), MagicMock()) with patch( "bleak_retry_connector.calculate_backoff_time" ) as mock_calculate_backoff_time: mock_calculate_backoff_time.return_value = 0 with pytest.raises(BleakDBusError): await test_function() assert mock_calculate_backoff_time.call_count == 2 @pytest.mark.asyncio async def test_retry_bluetooth_connection_error_non_default_max_attempts(): """Test that the retry_bluetooth_connection_error decorator works correctly with a different number of retries.""" @retry_bluetooth_connection_error(4) async def test_function(): raise BleakDBusError(MagicMock(), MagicMock()) with patch( "bleak_retry_connector.calculate_backoff_time" ) as mock_calculate_backoff_time: mock_calculate_backoff_time.return_value = 0 with pytest.raises(BleakDBusError): await test_function() assert mock_calculate_backoff_time.call_count == 4 @pytest.mark.asyncio async def test_dbus_is_missing(mock_linux): """Test getting a device when dbus is missing.""" bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( side_effect=FileNotFoundError("dbus not here") ) bleak_retry_connector.bleak_manager.get_global_bluez_manager_with_timeout = ( AsyncMock(side_effect=FileNotFoundError("dbus not here")) ) bleak_retry_connector.bluez.defs = defs with patch.object(bleak_retry_connector.const, "IS_LINUX", True): device = await get_device("FA:23:9D:AA:45:46") assert device is None class FakeBluezManager: def __init__(self): self._services_cache = {} self._properties = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -30, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci1/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -79, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci2/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -80, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci3/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -31, }, defs.GATT_SERVICE_INTERFACE: True, }, } bluez_manager = FakeBluezManager() bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=bluez_manager ) bleak_retry_connector.bleak_manager.get_global_bluez_manager_with_timeout = ( AsyncMock(return_value=bluez_manager) ) device = await get_device("FA:23:9D:AA:45:46") assert device is not None _reset_dbus_socket_cache() device = await get_device("FA:23:9D:AA:45:46") assert device is not None @pytest.mark.asyncio async def test_ble_device_description(): device = BLEDevice( "aa:bb:cc:dd:ee:ff", "name", {"path": "/org/bluez/hci2/dev_FA_23_9D_AA_45_46"}, ) assert ( ble_device_description(device) == "aa:bb:cc:dd:ee:ff - name -> /org/bluez/hci2" ) device2 = BLEDevice( "aa:bb:cc:dd:ee:ff", "name", {"path": "/org/bluez/hci2/dev_FA_23_9D_AA_45_46"}, ) assert ( ble_device_description(device2) == "aa:bb:cc:dd:ee:ff - name -> /org/bluez/hci2" ) device3 = BLEDevice("aa:bb:cc:dd:ee:ff", "name", {"source": "esphome_proxy_1"}) assert ( ble_device_description(device3) == "aa:bb:cc:dd:ee:ff - name -> esphome_proxy_1" ) @pytest.mark.asyncio @pytest.mark.skipif("not bleak_retry_connector.const.IS_LINUX") async def test_restore_discoveries(mock_linux): class FakeBluezManager: def __init__(self): self._services_cache = {} self._properties = { "/org/bluez/hci1/dev_BD_24_6F_85_AA_61": { "org.freedesktop.DBus.Introspectable": {}, "org.bluez.Device1": { "Address": "BD:24:6F:85:AA:61", "AddressType": "public", "Name": "Dream~BD246F85AA61", "Alias": "Dream~BD246F85AA61", "Appearance": 962, "Icon": "input-mouse", "Paired": False, "Trusted": False, "Blocked": False, "LegacyPairing": False, "Connected": True, "UUIDs": [ "00001800-0000-1000-8000-00805f9b34fb", "00001801-0000-1000-8000-00805f9b34fb", "0000180a-0000-1000-8000-00805f9b34fb", "0000ffd0-0000-1000-8000-00805f9b34fb", "0000ffd5-0000-1000-8000-00805f9b34fb", ], "Modalias": "usb:v045Ep0040d0300", "Adapter": "/org/bluez/hci1", "ManufacturerData": {20808: bytearray(b"364656")}, "ServicesResolved": True, }, "org.freedesktop.DBus.Properties": {}, }, "/org/bluez/hci5/dev_BE_24_6F_85_AA_61": { "org.freedesktop.DBus.Introspectable": {}, "org.bluez.Device1": { "Address": "BE:24:6F:85:AA:61", "AddressType": "public", "Name": "Dream~BD246F85AA61", "Alias": "Dream~BD246F85AA61", "Appearance": 962, "Icon": "input-mouse", "Paired": False, "Trusted": False, "Blocked": False, "LegacyPairing": False, "Connected": True, "UUIDs": [ "00001800-0000-1000-8000-00805f9b34fb", "00001801-0000-1000-8000-00805f9b34fb", "0000180a-0000-1000-8000-00805f9b34fb", "0000ffd0-0000-1000-8000-00805f9b34fb", "0000ffd5-0000-1000-8000-00805f9b34fb", ], "Modalias": "usb:v045Ep0040d0300", "Adapter": "/org/bluez/hci1", "ManufacturerData": {20808: bytearray(b"364656")}, "ServicesResolved": True, }, "org.freedesktop.DBus.Properties": {}, }, } bluez_manager = FakeBluezManager() bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=bluez_manager ) bleak_retry_connector.bleak_manager.get_global_bluez_manager_with_timeout = ( AsyncMock(return_value=bluez_manager) ) from bluetooth_adapters.history import load_history_from_managed_objects bleak_retry_connector.load_history_from_managed_objects = ( load_history_from_managed_objects ) bleak_retry_connector.bluez.defs = defs seen_devices: dict[str, tuple[BLEDevice, AdvertisementData]] = {} mock_backend = Mock(seen_devices=seen_devices) mock_scanner = Mock(_backend=mock_backend) await restore_discoveries(mock_scanner, "hci1") assert len(seen_devices) == 1 @pytest.mark.asyncio async def test_close_stale_connections_by_address(mock_linux): class FakeBluezManager: def __init__(self): self._services_cache = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": "test", "/org/bluez/hci1/dev_FA_23_9D_AA_45_46": "test", } self._properties = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -30, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci1/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -79, "Connected": True, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci2/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -80, }, defs.GATT_SERVICE_INTERFACE: True, }, "/org/bluez/hci3/dev_FA_23_9D_AA_45_46": { "UUID": "service", "Primary": True, "Characteristics": [], defs.DEVICE_INTERFACE: { "Address": "FA:23:9D:AA:45:46", "Alias": "FA:23:9D:AA:45:46", "RSSI": -31, }, defs.GATT_SERVICE_INTERFACE: True, }, } bluez_manager = FakeBluezManager() bleak_retry_connector.bleak_manager.get_global_bluez_manager = AsyncMock( return_value=bluez_manager ) bleak_retry_connector.bleak_manager.get_global_bluez_manager_with_timeout = ( AsyncMock(return_value=bluez_manager) ) bleak_retry_connector.bluez.defs = defs with patch.object( bleak_retry_connector, "disconnect_devices", AsyncMock() ) as mock_disconnect_devices: await close_stale_connections_by_address("FA:23:9D:AA:45:46") assert len(mock_disconnect_devices.mock_calls) == 1 @pytest.mark.asyncio async def test_has_valid_services_in_cache_success(mock_linux): """Test successful validation when all cached services are present in properties.""" class FakeBleakClient(BleakClient): """Fake BleakClient.""" async def connect(self, **kwargs): """Connect.""" async def disconnect(self): """Disconnect.""" async def get_services(self): """Get services.""" return [] # Create a proper BleakGATTServiceCollection with services collection = BleakGATTServiceCollection() # Add a service that will be present in properties service_path = "/org/bluez/hci0/dev_FA_23_9D_AA_45_46/service0001" service_props = { "UUID": "0000180a-0000-1000-8000-00805f9b34fb", "Primary": True, "Characteristics": [], } service = BleakGATTService( obj=(service_path, service_props), handle=1, uuid="0000180a-0000-1000-8000-00805f9b34fb", ) collection.add_service(service) # Add another service service_path2 = "/org/bluez/hci0/dev_FA_23_9D_AA_45_46/service0002" service_props2 = { "UUID": "0000180f-0000-1000-8000-00805f9b34fb", "Primary": True, "Characteristics": [], } service2 = BleakGATTService( obj=(service_path2, service_props2), handle=2, uuid="0000180f-0000-1000-8000-00805f9b34fb", ) collection.add_service(service2) class FakeBluezManager: def __init__(self): # Services cache contains our collection for the device self._services_cache = {"/org/bluez/hci0/dev_FA_23_9D_AA_45_46": collection} # Properties contain both service paths self._properties = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": { "org.bluez.Device1": { "Address": "FA:23:9D:AA:45:46", "Connected": False, } }, # Both services are present in properties service_path: service_props, service_path2: service_props2, } bluez_manager = FakeBluezManager() bleak_retry_connector.bluez.get_global_bluez_manager_with_timeout = AsyncMock( return_value=bluez_manager ) bleak_retry_connector.bluez.defs = defs device = BLEDevice( address="FA:23:9D:AA:45:46", name="Test Device", details={"path": "/org/bluez/hci0/dev_FA_23_9D_AA_45_46"}, ) # Capture the log to verify the success message with patch.object( bleak_retry_connector, "_has_valid_services_in_cache", wraps=bleak_retry_connector._has_valid_services_in_cache, ) as mock_validate: client = await bleak_retry_connector.establish_connection( FakeBleakClient, device, "Test Device", use_services_cache=True, ) # Verify the validation was called mock_validate.assert_called_once() # Call the wrapped function directly to verify it returns True result = await bleak_retry_connector._has_valid_services_in_cache(device) assert result is True assert client is not None @pytest.mark.asyncio async def test_has_valid_services_in_cache_service_missing(mock_linux): """Test validation fails when a cached service is not in properties.""" class FakeBleakClient(BleakClient): """Fake BleakClient.""" async def connect(self, **kwargs): """Connect.""" async def disconnect(self): """Disconnect.""" async def get_services(self): """Get services.""" return [] # Create a proper BleakGATTServiceCollection with services collection = BleakGATTServiceCollection() # Add a service that will NOT be present in properties service_path = "/org/bluez/hci0/dev_FA_23_9D_AA_45_46/service0001" service_props = { "UUID": "0000180a-0000-1000-8000-00805f9b34fb", "Primary": True, "Characteristics": [], } service = BleakGATTService( obj=(service_path, service_props), handle=1, uuid="0000180a-0000-1000-8000-00805f9b34fb", ) collection.add_service(service) class FakeBluezManager: def __init__(self): # Services cache contains our collection for the device self._services_cache = {"/org/bluez/hci0/dev_FA_23_9D_AA_45_46": collection} # Properties do NOT contain the service path - service is missing self._properties = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": { "org.bluez.Device1": { "Address": "FA:23:9D:AA:45:46", "Connected": False, } }, # service_path is NOT in properties } bluez_manager = FakeBluezManager() bleak_retry_connector.bluez.get_global_bluez_manager_with_timeout = AsyncMock( return_value=bluez_manager ) bleak_retry_connector.bluez.defs = defs device = BLEDevice( address="FA:23:9D:AA:45:46", name="Test Device", details={"path": "/org/bluez/hci0/dev_FA_23_9D_AA_45_46"}, ) # Call the function directly to verify it returns False result = await bleak_retry_connector._has_valid_services_in_cache(device) assert result is False # Verify that the cache is not used when validation fails client = await bleak_retry_connector.establish_connection( FakeBleakClient, device, "Test Device", use_services_cache=True, ) assert client is not None @pytest.mark.asyncio async def test_has_valid_services_in_cache_no_services(mock_linux): """Test validation returns False when there are no services in the collection.""" class FakeBleakClient(BleakClient): """Fake BleakClient.""" async def connect(self, **kwargs): """Connect.""" async def disconnect(self): """Disconnect.""" async def get_services(self): """Get services.""" return [] # Create an empty BleakGATTServiceCollection (no services) collection = BleakGATTServiceCollection() class FakeBluezManager: def __init__(self): # Services cache contains an empty collection for the device self._services_cache = {"/org/bluez/hci0/dev_FA_23_9D_AA_45_46": collection} # Properties exist but collection has no services self._properties = { "/org/bluez/hci0/dev_FA_23_9D_AA_45_46": { "org.bluez.Device1": { "Address": "FA:23:9D:AA:45:46", "Connected": False, } }, } bluez_manager = FakeBluezManager() bleak_retry_connector.bluez.get_global_bluez_manager_with_timeout = AsyncMock( return_value=bluez_manager ) bleak_retry_connector.bluez.defs = defs device = BLEDevice( address="FA:23:9D:AA:45:46", name="Test Device", details={"path": "/org/bluez/hci0/dev_FA_23_9D_AA_45_46"}, ) # Call the function directly to verify it returns False result = await bleak_retry_connector._has_valid_services_in_cache(device) assert result is False # Verify that the cache is not used when there are no services client = await bleak_retry_connector.establish_connection( FakeBleakClient, device, "Test Device", use_services_cache=True, ) assert client is not None @pytest.mark.asyncio async def test_has_valid_services_in_cache_non_linux(mock_macos): """Test that cache is always valid on non-Linux platforms.""" device = BLEDevice( address="FA:23:9D:AA:45:46", name="Test Device", details={"path": "/some/macos/path"}, ) # Should return True on non-Linux platforms result = await bleak_retry_connector._has_valid_services_in_cache(device) assert result is True @pytest.mark.asyncio async def test_has_valid_services_in_cache_esphome_proxy(mock_linux): """Test that cache is always valid for ESPHome proxy devices.""" # Device without a BlueZ path (ESPHome proxy device) device = BLEDevice( address="FA:23:9D:AA:45:46", name="Test Device", details={"source": "192.168.1.100"}, # ESPHome proxy source ) # Should return True for non-BlueZ devices (ESPHome proxy) result = await bleak_retry_connector._has_valid_services_in_cache(device) assert result is True @pytest.mark.asyncio async def test_set_connection_params_delegates_to_super(): """Test that set_connection_params delegates to super when available.""" set_connection_params_mock = AsyncMock() class FakeBleakClientWithSetConnectionParams(BleakClient): def __init__(self, *args, **kwargs): pass async def connect(self, *args, **kwargs): pass async def disconnect(self, *args, **kwargs): pass async def set_connection_params( self, min_interval, max_interval, latency, timeout ): await set_connection_params_mock( min_interval, max_interval, latency, timeout ) class FakeClientWithCache( BleakClientWithServiceCache, FakeBleakClientWithSetConnectionParams ): """Fake BleakClientWithServiceCache with set_connection_params on parent.""" client = FakeClientWithCache(MagicMock()) await client.set_connection_params(10, 20, 3, 400) set_connection_params_mock.assert_called_once_with(10, 20, 3, 400) @pytest.mark.asyncio async def test_set_connection_params_warns_when_not_available(caplog): """Test that set_connection_params logs a warning when not available on parent.""" import logging class FakeBleakClientNoSetConnectionParams(BleakClient): def __init__(self, *args, **kwargs): pass async def connect(self, *args, **kwargs): pass async def disconnect(self, *args, **kwargs): pass class FakeClientWithCache( BleakClientWithServiceCache, FakeBleakClientNoSetConnectionParams ): """Fake BleakClientWithServiceCache without set_connection_params on parent.""" client = FakeClientWithCache(MagicMock()) with caplog.at_level(logging.WARNING): await client.set_connection_params(10, 20, 3, 400) assert "set_connection_params not implemented in bleak version" in caplog.text @pytest.mark.asyncio async def test_clear_cache_delegates_to_super(): """clear_cache should call super().clear_cache when present and return its result.""" clear_cache_mock = AsyncMock(return_value=True) class FakeBleakClientWithClearCache(BleakClient): def __init__(self, *args, **kwargs): pass async def connect(self, *args, **kwargs): pass async def disconnect(self, *args, **kwargs): pass async def clear_cache(self) -> bool: return await clear_cache_mock() class FakeClientWithCache( BleakClientWithServiceCache, FakeBleakClientWithClearCache ): """Fake BleakClientWithServiceCache with clear_cache on parent.""" client = FakeClientWithCache(MagicMock()) assert await client.clear_cache() is True clear_cache_mock.assert_called_once() @pytest.mark.asyncio async def test_clear_cache_warns_when_not_available(caplog): """clear_cache should warn and return False when parent has no clear_cache.""" import logging class FakeBleakClientNoClearCache(BleakClient): def __init__(self, *args, **kwargs): pass async def connect(self, *args, **kwargs): pass async def disconnect(self, *args, **kwargs): pass class FakeClientWithCache(BleakClientWithServiceCache, FakeBleakClientNoClearCache): """Fake BleakClientWithServiceCache without clear_cache on parent.""" # Sanity: this bleak version has no parent clear_cache so the else branch runs. parent_for_super = FakeBleakClientNoClearCache assert not hasattr(parent_for_super, "clear_cache") client = FakeClientWithCache(MagicMock()) with caplog.at_level(logging.WARNING): result = await client.clear_cache() assert result is False assert "clear_cache not implemented in bleak version" in caplog.text @pytest.mark.asyncio async def test_ble_device_description_address_only(): """Name equal to address and no path/source should return address only.""" device = BLEDevice("AA:BB:CC:DD:EE:FF", "AA:BB:CC:DD:EE:FF", {}) assert ble_device_description(device) == "AA:BB:CC:DD:EE:FF" @pytest.mark.asyncio async def test_ble_device_description_non_dict_details(): """Non-dict details should skip the path/source branches.""" device = BLEDevice("AA:BB:CC:DD:EE:FF", "name", "not-a-dict") assert ble_device_description(device) == "AA:BB:CC:DD:EE:FF - name" @pytest.mark.asyncio async def test_has_valid_services_in_cache_no_cached_services_for_path(mock_linux): """Services cache exists but has no entry for the device path.""" class FakeBluezManager: def __init__(self): self._services_cache = {"/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF": "other"} self._properties = {} bluez_manager = FakeBluezManager() bleak_retry_connector.bluez.get_global_bluez_manager_with_timeout = AsyncMock( return_value=bluez_manager ) bleak_retry_connector.bluez.defs = defs device = BLEDevice( address="FA:23:9D:AA:45:46", name="Test Device", details={"path": "/org/bluez/hci0/dev_FA_23_9D_AA_45_46"}, ) result = await bleak_retry_connector._has_valid_services_in_cache(device) assert result is False @pytest.mark.asyncio async def test_has_valid_services_in_cache_no_properties(mock_linux): """Services cache has the device but properties are unavailable.""" collection = BleakGATTServiceCollection() service_path = "/org/bluez/hci0/dev_FA_23_9D_AA_45_46/service0001" service_props = { "UUID": "0000180a-0000-1000-8000-00805f9b34fb", "Primary": True, "Characteristics": [], } collection.add_service( BleakGATTService( obj=(service_path, service_props), handle=1, uuid="0000180a-0000-1000-8000-00805f9b34fb", ) ) class FakeBluezManager: def __init__(self): self._services_cache = {"/org/bluez/hci0/dev_FA_23_9D_AA_45_46": collection} # Empty properties → _get_properties returns falsy self._properties = {} bluez_manager = FakeBluezManager() bleak_retry_connector.bluez.get_global_bluez_manager_with_timeout = AsyncMock( return_value=bluez_manager ) bleak_retry_connector.bluez.defs = defs device = BLEDevice( address="FA:23:9D:AA:45:46", name="Test Device", details={"path": "/org/bluez/hci0/dev_FA_23_9D_AA_45_46"}, ) result = await bleak_retry_connector._has_valid_services_in_cache(device) assert result is False @pytest.mark.asyncio async def test_close_stale_connections_by_address_non_linux(mock_macos): """On non-Linux, close_stale_connections_by_address returns without doing anything.""" with patch.object( bleak_retry_connector, "disconnect_devices", AsyncMock() ) as mock_disconnect_devices: await close_stale_connections_by_address("FA:23:9D:AA:45:46") assert mock_disconnect_devices.mock_calls == [] @pytest.mark.asyncio async def test_close_stale_connections_by_address_device_not_found(mock_linux): """If get_device returns None, no disconnect should happen.""" with ( patch.object(bleak_retry_connector, "get_device", AsyncMock(return_value=None)), patch.object( bleak_retry_connector, "disconnect_devices", AsyncMock() ) as mock_disconnect_devices, ): await close_stale_connections_by_address("FA:23:9D:AA:45:46") assert mock_disconnect_devices.mock_calls == [] @pytest.mark.asyncio async def test_close_stale_connections_non_linux(mock_macos): """On non-Linux, close_stale_connections returns immediately.""" device = BLEDevice( address="FA:23:9D:AA:45:46", name="Test Device", details={"path": "/org/bluez/hci0/dev_FA_23_9D_AA_45_46"}, ) with patch.object( bleak_retry_connector, "disconnect_devices", AsyncMock() ) as mock_disconnect_devices: await close_stale_connections(device) assert mock_disconnect_devices.mock_calls == [] @pytest.mark.asyncio async def test_close_stale_connections_no_connected_devices(mock_linux): """If get_connected_devices returns nothing, no disconnect should happen.""" device = BLEDevice( address="FA:23:9D:AA:45:46", name="Test Device", details={"path": "/org/bluez/hci0/dev_FA_23_9D_AA_45_46"}, ) with ( patch.object( bleak_retry_connector, "get_connected_devices", AsyncMock(return_value=[]), ), patch.object( bleak_retry_connector, "disconnect_devices", AsyncMock() ) as mock_disconnect_devices, ): await close_stale_connections(device) assert mock_disconnect_devices.mock_calls == [] @pytest.mark.asyncio async def test_close_stale_connections_only_other_adapters_skips_same(mock_linux): """With only_other_adapters=True, devices on the same adapter are skipped.""" device = BLEDevice( address="FA:23:9D:AA:45:46", name="Test Device", details={"path": "/org/bluez/hci0/dev_FA_23_9D_AA_45_46"}, ) # Connected device has the same path → ble_device_has_changed returns False same_adapter_device = BLEDevice( address="FA:23:9D:AA:45:46", name="Test Device", details={"path": "/org/bluez/hci0/dev_FA_23_9D_AA_45_46"}, ) with ( patch.object( bleak_retry_connector, "get_connected_devices", AsyncMock(return_value=[same_adapter_device]), ), patch.object( bleak_retry_connector, "disconnect_devices", AsyncMock() ) as mock_disconnect_devices, ): await close_stale_connections(device, only_other_adapters=True) assert mock_disconnect_devices.mock_calls == [] @pytest.mark.asyncio async def test_restore_discoveries_non_linux(mock_macos: None) -> None: """restore_discoveries is a no-op on non-Linux platforms.""" mock_backend = Mock(seen_devices={}) mock_scanner = Mock(_backend=mock_backend) get_props = AsyncMock() with patch.object(bleak_retry_connector, "_get_properties", get_props): await restore_discoveries(mock_scanner, "hci0") get_props.assert_not_called() assert mock_backend.seen_devices == {} @pytest.mark.asyncio async def test_restore_discoveries_no_properties(mock_linux: None) -> None: """restore_discoveries returns early when properties are unavailable.""" mock_backend = Mock(seen_devices={}) mock_scanner = Mock(_backend=mock_backend) with patch.object( bleak_retry_connector, "_get_properties", AsyncMock(return_value=None), ): await restore_discoveries(mock_scanner, "hci0") assert mock_backend.seen_devices == {} @pytest.mark.asyncio async def test_establish_connection_debug_disabled_cycles_all_exception_paths() -> None: """Cycle through every retryable exception class with debug logging off. Exercises the falsy ``if debug_enabled:`` branches inside each ``except`` handler in ``establish_connection``, the ``should_use_cache=False`` skip around the services-cache restore, and the non-cache-client fork of the ``KeyError`` handler that skips ``wait_for_disconnect``. """ wait_calls: list[float] = [] scripted, attempts = make_scripted_client( [ asyncio.TimeoutError(), KeyError("org.bluez.GattService1"), BrokenPipeError(32, "Broken pipe"), EOFError(), BleakError("le-connection-abort-by-local"), None, ] ) async def fake_wait_for_disconnect(device: Any, backoff_time: float) -> None: wait_calls.append(backoff_time) with ( patch.object(bleak_retry_connector._LOGGER, "isEnabledFor", return_value=False), patch( "bleak_retry_connector.wait_for_disconnect", side_effect=fake_wait_for_disconnect, ), patch("bleak_retry_connector.calculate_backoff_time", return_value=0), ): client = await establish_connection( scripted, MagicMock(), "test", use_services_cache=False, ) assert isinstance(client, scripted) assert attempts["n"] == 6 # TimeoutError, EOFError, BLEAK_EXCEPTIONS all call wait_for_disconnect. # KeyError on a non-cache client skips the wait. BrokenPipeError skips too. assert wait_calls == [0, 0, 0] @pytest.mark.asyncio async def test_retry_bluetooth_connection_error_zero_attempts_returns_none() -> None: """A decorator with ``attempts=0`` never enters the retry loop. Exercises the loop-exit branch of ``for attempt in range(attempts):`` inside ``retry_bluetooth_connection_error`` when the caller asks for zero attempts — the wrapped coroutine must not run. """ call_count = 0 @retry_bluetooth_connection_error(attempts=0) async def never_called() -> None: nonlocal call_count call_count += 1 await never_called() assert call_count == 0