pax_global_header00006660000000000000000000000064150702471610014515gustar00rootroot0000000000000052 comment=c411f9192079dfacdb39b15032575ce28434bfc4 Bluetooth-Devices-habluetooth-21accde/000077500000000000000000000000001507024716100201225ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-21accde/.all-contributorsrc000066400000000000000000000004621507024716100237550ustar00rootroot00000000000000{ "projectName": "habluetooth", "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-habluetooth-21accde/.copier-answers.yml000066400000000000000000000010531507024716100236630ustar00rootroot00000000000000# Changes here will be overwritten by Copier _commit: 0b42cfd _src_path: gh:browniebroke/pypackage-template add_me_as_contributor: false copyright_year: '2023' documentation: true email: bluetooth@koston.org full_name: J. Nick Koston github_username: bluetooth-devices has_cli: false initial_commit: true open_source_license: Apache Software License 2.0 package_name: habluetooth project_name: habluetooth project_short_description: High availability Bluetooth project_slug: habluetooth run_poetry_install: true setup_github: true setup_pre_commit: true Bluetooth-Devices-habluetooth-21accde/.editorconfig000066400000000000000000000004441507024716100226010ustar00rootroot00000000000000# 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-habluetooth-21accde/.github/000077500000000000000000000000001507024716100214625ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-21accde/.github/ISSUE_TEMPLATE/000077500000000000000000000000001507024716100236455ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-21accde/.github/ISSUE_TEMPLATE/1-bug_report.md000066400000000000000000000004221507024716100264730ustar00rootroot00000000000000--- 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-habluetooth-21accde/.github/ISSUE_TEMPLATE/2-feature-request.md000066400000000000000000000006721507024716100274540ustar00rootroot00000000000000--- 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-habluetooth-21accde/.github/dependabot.yml000066400000000000000000000013441507024716100243140ustar00rootroot00000000000000# 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: "monthly" 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-habluetooth-21accde/.github/labels.toml000066400000000000000000000035151507024716100236250ustar00rootroot00000000000000[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-habluetooth-21accde/.github/workflows/000077500000000000000000000000001507024716100235175ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-21accde/.github/workflows/ci.yml000066400000000000000000000213321507024716100246360ustar00rootroot00000000000000name: 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4 - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v5 with: python-version: 3.13 - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4 with: fetch-depth: 0 - uses: wagoid/commitlint-github-action@b948419dd99f3fd78a6548d48f94e3df7f6bf3ed # v6.2.1 test: strategy: fail-fast: false matrix: python-version: - "3.11" - "3.12" - "3.13" - "3.14" - "3.14t" os: - ubuntu-latest - macOS-latest - windows-latest extension: - "skip_cython" - "use_cython" runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4 - name: Install poetry run: pipx install poetry - name: Set up Python uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v5 with: python-version: ${{ matrix.python-version }} cache: "poetry" allow-prereleases: true - name: Install Dependencies run: | if [ "${{ matrix.extension }}" = "skip_cython" ]; then SKIP_CYTHON=1 poetry install --only=main,dev else REQUIRE_CYTHON=1 poetry install --only=main,dev fi shell: bash - name: Test with Pytest run: poetry run pytest --cov-report=xml shell: bash - name: Upload coverage to Codecov uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5 with: token: ${{ secrets.CODECOV_TOKEN }} benchmark: runs-on: ubuntu-latest steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4 - name: Install poetry run: pipx install poetry - name: Setup Python 3.13 uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v5 with: python-version: 3.13 cache: "poetry" - name: Install Dependencies run: | REQUIRE_CYTHON=1 poetry install --only=main,dev shell: bash - name: Run benchmarks uses: CodSpeedHQ/action@653fdc30e6c40ffd9739e40c8a0576f4f4523ca1 # v3 with: token: ${{ secrets.CODSPEED_TOKEN }} run: poetry run pytest --no-cov -vvvvv --codspeed mode: instrumentation release: needs: - test - lint - commitlint runs-on: ubuntu-latest environment: release concurrency: release permissions: id-token: write contents: write outputs: released: ${{ steps.release.outputs.released }} newest_release_tag: ${{ steps.release.outputs.tag }} steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4 with: fetch-depth: 0 ref: ${{ github.head_ref || github.ref_name }} # Do a dry run of PSR - name: Test release uses: python-semantic-release/python-semantic-release@4d4cb0ab842247caea1963132c242c62aab1e4d5 # v10.4.1 if: github.ref_name != 'main' with: no_operation_mode: true # On main branch: actual PSR + upload to PyPI & GitHub - name: Release uses: python-semantic-release/python-semantic-release@4d4cb0ab842247caea1963132c242c62aab1e4d5 # v10.4.1 id: release if: github.ref_name == 'main' with: github_token: ${{ secrets.GITHUB_TOKEN }} - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1 if: steps.release.outputs.released == 'true' - name: Publish package distributions to GitHub Releases uses: python-semantic-release/upload-to-gh-release@0a92b5d7ebfc15a84f9801ebd1bf706343d43711 # main if: steps.release.outputs.released == 'true' with: github_token: ${{ secrets.GITHUB_TOKEN }} build_wheels: needs: [release] if: needs.release.outputs.released == 'true' name: Wheels for ${{ matrix.os }} (${{ matrix.musl == 'musllinux' && 'musllinux' || 'manylinux' }}) ${{ matrix.qemu }} ${{ matrix.pyver }} runs-on: ${{ matrix.os }} strategy: matrix: os: [ windows-latest, ubuntu-24.04-arm, ubuntu-latest, macos-13, macos-latest, ] qemu: [""] musl: [""] pyver: [""] include: - os: ubuntu-latest musl: "musllinux" - os: ubuntu-24.04-arm musl: "musllinux" # qemu is slow, make a single # runner per Python version - os: ubuntu-latest qemu: armv7l musl: "musllinux" pyver: cp311 - os: ubuntu-latest qemu: armv7l musl: "musllinux" pyver: cp312 - os: ubuntu-latest qemu: armv7l musl: "musllinux" pyver: cp313 - os: ubuntu-latest qemu: armv7l musl: "musllinux" pyver: cp314 - os: ubuntu-latest qemu: armv7l musl: "musllinux" pyver: cp314t # qemu is slow, make a single # runner per Python version - os: ubuntu-latest qemu: armv7l musl: "" pyver: cp311 - os: ubuntu-latest qemu: armv7l musl: "" pyver: cp312 - os: ubuntu-latest qemu: armv7l musl: "" pyver: cp313 - os: ubuntu-latest qemu: armv7l musl: "" pyver: cp314 - os: ubuntu-latest qemu: armv7l musl: "" pyver: cp314t steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4 with: ref: ${{ needs.release.outputs.newest_release_tag }} fetch-depth: 0 # Used to host cibuildwheel - name: Set up Python uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v5 with: python-version: "3.12" - name: Set up QEMU if: ${{ matrix.qemu }} uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 with: platforms: all # This should be temporary # xref https://github.com/docker/setup-qemu-action/issues/188 # xref https://github.com/tonistiigi/binfmt/issues/215 image: tonistiigi/binfmt:qemu-v8.1.5 id: qemu - name: Prepare emulation if: ${{ matrix.qemu }} run: | if [[ -n "${{ matrix.qemu }}" ]]; then # Build emulated architectures only if QEMU is set, # use default "auto" otherwise echo "CIBW_ARCHS_LINUX=${{ matrix.qemu }}" >> $GITHUB_ENV fi - name: Limit to a specific Python version on slow QEMU if: ${{ matrix.pyver }} run: | if [[ -n "${{ matrix.pyver }}" ]]; then echo "CIBW_BUILD=${{ matrix.pyver }}*" >> $GITHUB_ENV fi - name: Build wheels uses: pypa/cibuildwheel@7c619efba910c04005a835b110b057fc28fd6e93 # v3.2.0 env: CIBW_SKIP: cp36-* cp37-* cp38-* cp39-* cp310-* pp* ${{ matrix.musl == 'musllinux' && '*manylinux*' || '*musllinux*' }} REQUIRE_CYTHON: 1 - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: wheels-${{ matrix.os }}-${{ matrix.musl }}-${{ matrix.pyver }}-${{ matrix.qemu }} path: ./wheelhouse/*.whl upload_pypi: needs: [build_wheels] runs-on: ubuntu-latest environment: release permissions: id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v4 with: # unpacks default artifact into dist/ # if `name: artifact` is omitted, the action will create extra parent dir path: dist pattern: wheels-* merge-multiple: true - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 Bluetooth-Devices-habluetooth-21accde/.github/workflows/hacktoberfest.yml000066400000000000000000000005341507024716100270700ustar00rootroot00000000000000name: 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.3.0 with: github_token: ${{ secrets.GH_PAT }} Bluetooth-Devices-habluetooth-21accde/.github/workflows/issue-manager.yml000066400000000000000000000013401507024716100270000ustar00rootroot00000000000000name: 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.5.1 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-habluetooth-21accde/.github/workflows/poetry-upgrade.yml000066400000000000000000000003371507024716100272140ustar00rootroot00000000000000name: Upgrader on: workflow_dispatch: schedule: - cron: "1 5 23 * *" jobs: upgrade: uses: browniebroke/github-actions/.github/workflows/poetry-upgrade.yml@v1 secrets: gh_pat: ${{ secrets.GH_PAT }} Bluetooth-Devices-habluetooth-21accde/.gitignore000066400000000000000000000041141507024716100221120ustar00rootroot00000000000000# Created by .ignore support plugin (hsz.mobi) ### Python template # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so *.c # 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 {{package_name}} settings .spyderproject .spyproject # Rope {{package_name}} 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-habluetooth-21accde/.gitpod.yml000066400000000000000000000003061507024716100222100ustar00rootroot00000000000000tasks: - 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-habluetooth-21accde/.idea/000077500000000000000000000000001507024716100211025ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-21accde/.idea/habluetooth.iml000066400000000000000000000005151507024716100241240ustar00rootroot00000000000000 Bluetooth-Devices-habluetooth-21accde/.idea/watcherTasks.xml000066400000000000000000000052531507024716100242740ustar00rootroot00000000000000 Bluetooth-Devices-habluetooth-21accde/.idea/workspace.xml000066400000000000000000000027361507024716100236320ustar00rootroot00000000000000 Bluetooth-Devices-habluetooth-21accde/.pre-commit-config.yaml000066400000000000000000000030701507024716100244030ustar00rootroot00000000000000# See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks exclude: "CHANGELOG.md|.copier-answers.yml|.all-contributorsrc" 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.9.1 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 - repo: https://github.com/python-poetry/poetry rev: 2.2.1 hooks: - id: poetry-check - repo: https://github.com/pre-commit/mirrors-prettier rev: v4.0.0-alpha.8 hooks: - id: prettier args: ["--tab-width", "2"] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.13.2 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - repo: https://github.com/psf/black rev: 25.9.0 hooks: - id: black - repo: https://github.com/codespell-project/codespell rev: v2.4.1 hooks: - id: codespell - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.18.2 hooks: - id: mypy additional_dependencies: [] Bluetooth-Devices-habluetooth-21accde/.readthedocs.yml000066400000000000000000000010511507024716100232050ustar00rootroot00000000000000# Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details # Required version: 2 # Set the version of Python and other tools you might need build: os: ubuntu-20.04 tools: python: "3.12" jobs: post_create_environment: # Install poetry - pip install poetry post_install: # Install dependencies - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --with docs # Build documentation in the docs directory with Sphinx sphinx: configuration: docs/conf.py Bluetooth-Devices-habluetooth-21accde/CHANGELOG.md000066400000000000000000000646351507024716100217510ustar00rootroot00000000000000# Changelog ## v5.7.0 (2025-10-04) ### Features - Python 3.14 support ([`3ef7243`](https://github.com/Bluetooth-Devices/habluetooth/commit/3ef7243b2fe996970a3bd18a279a550f10233ede)) ## v5.6.4 (2025-09-13) ### Bug fixes - Workaround kernel abi inconsistency in bluetooth mgmt socket send behavior ([`affc097`](https://github.com/Bluetooth-Devices/habluetooth/commit/affc0971edf310bfd6f5a9f880fa488b4ed5a215)) ### Unknown ## v5.6.3 (2025-09-13) ### Bug fixes - High cpu usage by replacing async context manager with setup/cleanup pattern to avoid cython bug ([`8aa021a`](https://github.com/Bluetooth-Devices/habluetooth/commit/8aa021aee970fc01ccdb3d7f3242eee7fc3802cf)) ## v5.6.2 (2025-09-09) ### Bug fixes - Resolve crash when compiled with cython 3.1 ([`bac6dcf`](https://github.com/Bluetooth-Devices/habluetooth/commit/bac6dcffaba3940ff7331739866801729eee2032)) ## v5.6.1 (2025-09-09) ### Bug fixes - Rebuild wheels ([`bcc77be`](https://github.com/Bluetooth-Devices/habluetooth/commit/bcc77be98b6d3da33b8d74f1dda899e9823aa652)) ## v5.6.0 (2025-09-08) ### Features - Callback on scanner start success ([`782b717`](https://github.com/Bluetooth-Devices/habluetooth/commit/782b717044beff9758774c54cecbe3af9520e78b)) ## v5.5.1 (2025-09-08) ### Bug fixes - Handle case where two scanners have the exact same rssi ([`f51f700`](https://github.com/Bluetooth-Devices/habluetooth/commit/f51f700bdf41d508072c9ba1109a4bf7c7e5c57f)) ## v5.5.0 (2025-09-08) ### Features - Log slots when connecting ([`a3881c4`](https://github.com/Bluetooth-Devices/habluetooth/commit/a3881c4cccac52d668518dc8632104688dbf2788)) ## v5.4.0 (2025-09-08) ### Features - Consider connection slots when selecting connection path ([`fb938fc`](https://github.com/Bluetooth-Devices/habluetooth/commit/fb938fc608fd9f846c3eedd70ff245d5b299b93c)) ## v5.3.1 (2025-09-06) ### Bug fixes - Detect missing net_admin/net_raw capabilities and fallback to bluez-only mode ([`1cf17c0`](https://github.com/Bluetooth-Devices/habluetooth/commit/1cf17c094fc2bce65e703d230259f3c2c66720e7)) ## v5.3.0 (2025-08-31) ### Features - Include scanner type in details ([`0b558f1`](https://github.com/Bluetooth-Devices/habluetooth/commit/0b558f155e650b26a1b8c996d7292f62906cc3b4)) ## v5.2.1 (2025-08-29) ### Bug fixes - Incorrect advertising interval calculation when scanner pauses for connections ([`1c8db59`](https://github.com/Bluetooth-Devices/habluetooth/commit/1c8db59b8da69439d253bc356fe7be5321c7fb23)) ## v5.2.0 (2025-08-28) ### Features - Add methods to set current and requested mode to scanner ([`3cf872c`](https://github.com/Bluetooth-Devices/habluetooth/commit/3cf872c899beadd9594a5749a5ce433d943852f4)) ## v5.1.0 (2025-08-19) ### Features - Warn when connections are established without bleak-retry-connector ([`ba23681`](https://github.com/Bluetooth-Devices/habluetooth/commit/ba23681de0a4056da130ce7d2e8d7c25dbb18ad0)) ### Unknown ## v5.0.2 (2025-08-12) ### Bug fixes - Solve performance regression while connecting in linux ([`47b5b7e`](https://github.com/Bluetooth-Devices/habluetooth/commit/47b5b7e4e00b2be7922a2101a690e37de0970e5e)) ## v5.0.1 (2025-08-09) ### Bug fixes - Ensure connect works without debug logging ([`4da091d`](https://github.com/Bluetooth-Devices/habluetooth/commit/4da091dc3ee512d499c82d179420f7fbc0539457)) ## v5.0.0 (2025-08-09) ### Features - Add bt management side channel ([`e345308`](https://github.com/Bluetooth-Devices/habluetooth/commit/e3453089f510548ec7605fe83aa2078028ef2468)) ## v4.0.2 (2025-08-06) ### Bug fixes - Add clear error when only passive bluetooth adapters are available ([`bbb494c`](https://github.com/Bluetooth-Devices/habluetooth/commit/bbb494c99bd87e8e090a922a28cf7e083b191bc4)) ## v4.0.1 (2025-07-03) ### Bug fixes - Small cleanups for bleak 1.x support ([`3cf74f4`](https://github.com/Bluetooth-Devices/habluetooth/commit/3cf74f492354c452469907fb84a74cac9c7edcd3)) ## v4.0.0 (2025-07-03) ### Features - Support bleak 1.x ([`a739199`](https://github.com/Bluetooth-Devices/habluetooth/commit/a739199cf6d56f5db316b149134c11eabfab9f1c)) ## v3.49.0 (2025-06-03) ### Features - Add raw_advertisement_data to diagnostics ([`a77933b`](https://github.com/Bluetooth-Devices/habluetooth/commit/a77933b8dd0195ab827907ea918c572cd1686750)) ## v3.48.2 (2025-05-03) ### Bug fixes - Remove duplicate _connecting slot from basehascanner ([`230bb03`](https://github.com/Bluetooth-Devices/habluetooth/commit/230bb038eea8ae07a3fe798ec15792489d06cd66)) ## v3.48.1 (2025-05-03) ### Bug fixes - Pin cython to <3.1 ([`21dc734`](https://github.com/Bluetooth-Devices/habluetooth/commit/21dc7340c548713c4539d8d8a067a2a574623906)) ## v3.48.0 (2025-05-03) ### Features - Refactor scanner history to live on the scanner itself ([`ea0d2fc`](https://github.com/Bluetooth-Devices/habluetooth/commit/ea0d2fc088832a1b3f8c7859c82e2e05bf1261f9)) ## v3.47.1 (2025-05-03) ### Bug fixes - Ensure logging does not fail when there is only a single scanner ([`d81378e`](https://github.com/Bluetooth-Devices/habluetooth/commit/d81378e6b4adedead6d04ab23be7b655cd3785fb)) ## v3.47.0 (2025-05-03) ### Bug fixes - Require bluetooth-auto-recovery >= 1.5.1 ([`8164ce5`](https://github.com/Bluetooth-Devices/habluetooth/commit/8164ce512084fe898cb80c5e44f664dde4751113)) ### Features - Avoid thundering heard of connections ([`943cc20`](https://github.com/Bluetooth-Devices/habluetooth/commit/943cc2043731f8d6fbb541f4d7ffcd37d8c6b4f3)) ## v3.46.0 (2025-05-03) ### Features - Improve recovery when adapter has gone silent and needs a usb reset ([`a4dd395`](https://github.com/Bluetooth-Devices/habluetooth/commit/a4dd395b7a8e70cb0ae94d97422d35eb638daaa5)) ## v3.45.0 (2025-04-29) ### Features - Improve performance of _async_on_advertisement_internal ([`be0b5a6`](https://github.com/Bluetooth-Devices/habluetooth/commit/be0b5a6d0da07f2f881984c92c0c7671117d3e5a)) ## v3.44.0 (2025-04-28) ### Features - Save the raw data in storage ([`eaf4107`](https://github.com/Bluetooth-Devices/habluetooth/commit/eaf41072ecc915b2de23ad3c9a03148f4b313f17)) ## v3.43.0 (2025-04-28) ### Features - Migrate storage code from bluetooth_adapters ([`5d671f9`](https://github.com/Bluetooth-Devices/habluetooth/commit/5d671f95b9a7964bfa871c7b42061a71a98ce80e)) ## v3.42.0 (2025-04-27) ### Features - Add raw field to bluetoothserviceinfobleak ([`343f18b`](https://github.com/Bluetooth-Devices/habluetooth/commit/343f18bfbbf3ebbee31e64beab60b2686700797f)) ## v3.41.0 (2025-04-27) ### Features - Add new _async_on_raw_advertisement base scanner api ([`fb2a487`](https://github.com/Bluetooth-Devices/habluetooth/commit/fb2a487c06cf102c17509410f916b5c06728df98)) ## v3.40.0 (2025-04-27) ### Features - Require bluetooth-data-tools 1.28.0 or later ([`e154136`](https://github.com/Bluetooth-Devices/habluetooth/commit/e154136db9f15d33c6de3d89bf9e4e53e03c690a)) ## v3.39.0 (2025-04-17) ### Features - Improve performance of _async_on_advertisement ([`0fc0500`](https://github.com/Bluetooth-Devices/habluetooth/commit/0fc0500d74cdc3d320111df979bef784a51a2eac)) ## v3.38.1 (2025-04-14) ### Bug fixes - Add missing dbus-fast dep on linux ([`5746448`](https://github.com/Bluetooth-Devices/habluetooth/commit/57464488482626577e9f84c42ab1ff100b7857b3)) ## v3.38.0 (2025-03-22) ### Bug fixes - Use project.license key ([`1decf97`](https://github.com/Bluetooth-Devices/habluetooth/commit/1decf9704f7db33bc8094880651321c1b58420c8)) ### Features - Improve performance of previous source checks ([`8d96528`](https://github.com/Bluetooth-Devices/habluetooth/commit/8d96528f605231f3089319c789390d784c45b4c5)) ## v3.37.0 (2025-03-21) ### Features - Improve performance of _prefer_previous_adv_from_different_source ([`73ec210`](https://github.com/Bluetooth-Devices/habluetooth/commit/73ec2107375be217ffb0310194be8c3d4f20e150)) ## v3.36.0 (2025-03-21) ### Features - Improve performance of filtering apple data ([`9f56840`](https://github.com/Bluetooth-Devices/habluetooth/commit/9f568405ae987de0fb3953d6ae7b39eabacde9ef)) ## v3.35.0 (2025-03-21) ### Features - Optimize previous local name matching ([`fadb722`](https://github.com/Bluetooth-Devices/habluetooth/commit/fadb722b8ded2bc15bd56b641a963d4c4d19838e)) ## v3.34.1 (2025-03-21) ### Bug fixes - Revert adding _async_on_advertisements ([`4bc3cb8`](https://github.com/Bluetooth-Devices/habluetooth/commit/4bc3cb89baf52570deec4f27ed3cd935249525ec)) ## v3.34.0 (2025-03-21) ### Features - Rename _async_on_raw_advertisement to _async_on_raw_advertisements ([`b3acb88`](https://github.com/Bluetooth-Devices/habluetooth/commit/b3acb882d888a33567ece3e7f9d0fa1d2b4c6acd)) ## v3.33.0 (2025-03-21) ### Features - Add _async_on_raw_advertisement ([`24d128f`](https://github.com/Bluetooth-Devices/habluetooth/commit/24d128fe4854135647e9a41c7eeaf1784fbda0bf)) ## v3.32.0 (2025-03-15) ### Features - Improve performance of dispatching discovery info to subclasses ([`d0fae7d`](https://github.com/Bluetooth-Devices/habluetooth/commit/d0fae7ddd9158903f6621888cc4c75480822ae35)) ## v3.31.0 (2025-03-15) ### Features - Avoid building on demand advertisementdata if there are no bleak callbacks ([`ae977b9`](https://github.com/Bluetooth-Devices/habluetooth/commit/ae977b9d53c29c581ff6394a2078d2a2b01066dd)) ## v3.30.0 (2025-03-15) ### Features - Improve performance of on demand advertisementdata construction ([`ab005cb`](https://github.com/Bluetooth-Devices/habluetooth/commit/ab005cbef5e2ece74a0facd502fca7173ba2b1fc)) ## v3.29.0 (2025-03-15) ### Features - Improve performance for device with large manufacturer data history ([`ec1f6aa`](https://github.com/Bluetooth-Devices/habluetooth/commit/ec1f6aa7989cea2a589029362461dba4f7a8f0db)) ## v3.28.0 (2025-03-15) ### Features - Improve performance of local name checks ([`9f57d2f`](https://github.com/Bluetooth-Devices/habluetooth/commit/9f57d2fcc23595b376d5785162c21633514f44bd)) ## v3.27.0 (2025-03-14) ### Features - Improve performance of base_scanner ([`5b8c59c`](https://github.com/Bluetooth-Devices/habluetooth/commit/5b8c59c7ffadead5997fa457b07ff37ec8ec31b5)) ## v3.26.0 (2025-03-14) ### Features - Improve manager performance ([`e0bdace`](https://github.com/Bluetooth-Devices/habluetooth/commit/e0bdace8180ff3ac450447be99f700fd647fb659)) ## v3.25.1 (2025-03-13) ### Bug fixes - Downgrade scanner gone quiet logger to debug ([`d450ffc`](https://github.com/Bluetooth-Devices/habluetooth/commit/d450ffca38dec015f44b5be08af484fe8ca09866)) ## v3.25.0 (2025-03-05) ### Bug fixes - Use trusted publishing for wheels ([`c726687`](https://github.com/Bluetooth-Devices/habluetooth/commit/c726687affb0025037676b76cf4ecefdef0da23f)) ### Features - Add armv7l to wheel builds ([`e394707`](https://github.com/Bluetooth-Devices/habluetooth/commit/e394707b6b7ffc54e6dc5b8c038a08c5404f1777)) - Reduce wheel sizes ([`5e6b644`](https://github.com/Bluetooth-Devices/habluetooth/commit/5e6b64476ff2db7a215d1b0d58ef01c04b839d34)) ## v3.24.1 (2025-02-27) ### Bug fixes - Update scanner discover signature for newer bleak ([`a071cb8`](https://github.com/Bluetooth-Devices/habluetooth/commit/a071cb8e3f921da30055b94a74a4b0aa339e53de)) ## v3.24.0 (2025-02-22) ### Features - Improve logging of scanner failures and time_since_last_detection ([`f0ff045`](https://github.com/Bluetooth-Devices/habluetooth/commit/f0ff04586849bda3933fbe98e8e1335c308999c4)) ## v3.23.0 (2025-02-21) ### Features - Add debug logging for connection paths ([`562d469`](https://github.com/Bluetooth-Devices/habluetooth/commit/562d46912e7596febc3ebcc0301280e6f334172b)) ## v3.22.1 (2025-02-20) ### Bug fixes - Try to force stop discovery if its stuck on ([`e28d836`](https://github.com/Bluetooth-Devices/habluetooth/commit/e28d836d28f0b8062831ee209ba54a7735c4d5ae)) ## v3.22.0 (2025-02-18) ### Features - Allow remote scanners to set current and requested mode ([`a39ba18`](https://github.com/Bluetooth-Devices/habluetooth/commit/a39ba184e0d01f983133534e4fd7c1b6202210fb)) ## v3.21.1 (2025-02-04) ### Bug fixes - Update poetry to v2 ([`aefe36e`](https://github.com/Bluetooth-Devices/habluetooth/commit/aefe36e2507566224267f371511c1f1c748a37a9)) ## v3.21.0 (2025-02-01) ### Features - Reduce remote scanner adv processing overhead ([`7bf302b`](https://github.com/Bluetooth-Devices/habluetooth/commit/7bf302bac3855cf7e229dd2744acce513b2e2ee4)) ## v3.20.1 (2025-02-01) ### Bug fixes - Remove unused centralbluetoothmanager in models ([`7466034`](https://github.com/Bluetooth-Devices/habluetooth/commit/74660343b30fec50b927fdddd92e72eacb4da6cf)) - Precision loss when comparing advs from different sources ([`02279a9`](https://github.com/Bluetooth-Devices/habluetooth/commit/02279a95ca5b590768bd631bf39ee507a64db7ad)) ## v3.20.0 (2025-02-01) ### Features - Reduce adv tracker overhead ([`69168a6`](https://github.com/Bluetooth-Devices/habluetooth/commit/69168a64572ab3fba696d2afedeb015953afb0cc)) ## v3.19.0 (2025-02-01) ### Features - Reduce overhead to convert non-connectable bluetoothserviceinfobleak to connectable ([`37fc839`](https://github.com/Bluetooth-Devices/habluetooth/commit/37fc839d5fc73ff6f784ec8041606be82d58322b)) ## v3.18.0 (2025-02-01) ### Features - Refactor scanner_adv_received to reduce ref counting ([`a1945ce`](https://github.com/Bluetooth-Devices/habluetooth/commit/a1945cedc2373082814e8f4b4426a50c79788305)) ## v3.17.1 (2025-01-31) ### Bug fixes - Ensure allocations are available if the adapter never makes any connections ([`b3dfa48`](https://github.com/Bluetooth-Devices/habluetooth/commit/b3dfa48dba2482c16f61fceaf9a0f58ea55df982)) ## v3.17.0 (2025-01-31) ### Features - Remove the need to call set_manager to set up ([`1312bf7`](https://github.com/Bluetooth-Devices/habluetooth/commit/1312bf7d978ff585e66d99bde766e85773fce006)) ## v3.16.0 (2025-01-31) ### Features - Allow bluetoothmanager to be created with defaults ([`70b2f69`](https://github.com/Bluetooth-Devices/habluetooth/commit/70b2f6952fbd3ecd499a4c66ec305869158a428e)) ## v3.15.0 (2025-01-31) ### Features - Include findmy packets in wanted adverts ([`5217850`](https://github.com/Bluetooth-Devices/habluetooth/commit/5217850934bfed5d8e70f8b43c84cd97cf53cdac)) ## v3.14.0 (2025-01-29) ### Features - Add allocations to diagnostics ([`aa41088`](https://github.com/Bluetooth-Devices/habluetooth/commit/aa4108872478720ab4cbcf52c5add015441fe72d)) ## v3.13.0 (2025-01-28) ### Features - Add async_register_scanner_registration_callback and async_current_scanners to the manager ([`99fcb46`](https://github.com/Bluetooth-Devices/habluetooth/commit/99fcb46a73ea6cb8f01817263d01a342365be78f)) ## v3.12.0 (2025-01-22) ### Features - Add support for connection allocations for non-connectable scanners ([`d76b7c9`](https://github.com/Bluetooth-Devices/habluetooth/commit/d76b7c9624b6c4e6beedc1bd56dd1a3c0df70eec)) ## v3.11.2 (2025-01-22) ### Bug fixes - Re-release again for failed arm runners ([`af2bb50`](https://github.com/Bluetooth-Devices/habluetooth/commit/af2bb50879713378a32339e490a57b56083a4fa7)) ## v3.11.1 (2025-01-22) ### Bug fixes - Re-release due to failed github action ([`90e2192`](https://github.com/Bluetooth-Devices/habluetooth/commit/90e2192ff75c13ccf610fd06a61e64d60dfd1a18)) ## v3.11.0 (2025-01-22) ### Features - Add api for getting current slot allocations ([`0a9bef9`](https://github.com/Bluetooth-Devices/habluetooth/commit/0a9bef927c5f29c3e724fb60aa06706b6d896f82)) ## v3.10.0 (2025-01-21) ### Features - Add support for getting callbacks when adapter allocations change ([`c6fd2ba`](https://github.com/Bluetooth-Devices/habluetooth/commit/c6fd2babf0c6438ff85220edef95df3d3b4fae9c)) ## v3.9.2 (2025-01-20) ### Bug fixes - Increase rssi switch value to 16 ([`db367db`](https://github.com/Bluetooth-Devices/habluetooth/commit/db367dbef3fa883348a72cf17e29d9c26a09de53)) ## v3.9.1 (2025-01-20) ### Bug fixes - Increase rssi switch threshold for advertisements ([`297c269`](https://github.com/Bluetooth-Devices/habluetooth/commit/297c2693f9a2c007f0e70175c24416c8bb7da099)) ## v3.9.0 (2025-01-17) ### Features - Switch to native arm runners for wheel builds ([`bf7e98b`](https://github.com/Bluetooth-Devices/habluetooth/commit/bf7e98b099597916bb7566eb03472023f8acef97)) ## v3.8.0 (2025-01-10) ### Features - Add async_register_disappeared_callback ([`ec1d445`](https://github.com/Bluetooth-Devices/habluetooth/commit/ec1d4456ca15c6fca3248f2e5d73fcb1ba9d36c6)) ## v3.7.0 (2025-01-05) ### Bug fixes - Publish workflow ([`341c8a4`](https://github.com/Bluetooth-Devices/habluetooth/commit/341c8a4b72fb2818a3bed44632048d8570fc3b67)) ### Features - Start building wheels for python 3.13 ([`26dd831`](https://github.com/Bluetooth-Devices/habluetooth/commit/26dd831c28f3c0dfe0745769749e795e7937c7df)) - Add codspeed benchmarks ([`5905fbd`](https://github.com/Bluetooth-Devices/habluetooth/commit/5905fbd2c54adea04c0e55fe8a299f771e6f31ed)) ### Unknown ## v3.6.0 (2024-10-20) ### Features - Speed up creation of advertisementdata namedtuple ([`28f7e60`](https://github.com/Bluetooth-Devices/habluetooth/commit/28f7e6093c3985da16e537bc9d989d839ad80c56)) ## v3.5.0 (2024-10-05) ### Features - Add support for python 3.13 ([`b8a4783`](https://github.com/Bluetooth-Devices/habluetooth/commit/b8a4783a43f6e771321974d2c085e5e0dda9e195)) ## v3.4.1 (2024-09-22) ### Bug fixes - Ensure build system required cython 3 ([`dc85d2f`](https://github.com/Bluetooth-Devices/habluetooth/commit/dc85d2fd1b8c8e4d8eb4515aa60af06782fc8722)) ## v3.4.0 (2024-09-02) ### Features - Add a fast cython init path for bluetoothserviceinfobleak ([`f532ed2`](https://github.com/Bluetooth-Devices/habluetooth/commit/f532ed215b429f0bbd14dacc30f87c53f22af245)) ## v3.3.2 (2024-08-20) ### Bug fixes - Disable 3.13 wheels ([`9e8bbff`](https://github.com/Bluetooth-Devices/habluetooth/commit/9e8bbff6179e08bd6e05341ff48fff3adc5c6157)) ## v3.3.1 (2024-08-20) ### Bug fixes - Bump cibuildwheel to fix wheel builds ([`68d838a`](https://github.com/Bluetooth-Devices/habluetooth/commit/68d838a1d2adab9efe1fb5eba65e81b5dcc9a351)) ## v3.3.0 (2024-08-20) ### Bug fixes - Cleanup advertisementmonitor mapper ([`7d3483d`](https://github.com/Bluetooth-Devices/habluetooth/commit/7d3483d87d3e03c19cf528a1838acce5b194533e)) ### Features - Override devicefound and devicelost for passive monitoring ([`a802859`](https://github.com/Bluetooth-Devices/habluetooth/commit/a8028596bf3576a35750ae8575f173c75f918f28)) ## v3.2.0 (2024-07-27) ### Features - Small speed ups to scanner detection callback ([`7a5129a`](https://github.com/Bluetooth-Devices/habluetooth/commit/7a5129a40a12382c089453880210c41bb0f28a32)) ## v3.1.3 (2024-06-24) ### Bug fixes - Wheel builds ([`b9a8eec`](https://github.com/Bluetooth-Devices/habluetooth/commit/b9a8eec4f79c2098c0ec318b6b1ff7e3376febf2)) ## v3.1.2 (2024-06-24) ### Bug fixes - Fix license classifier ([`04aaaa1`](https://github.com/Bluetooth-Devices/habluetooth/commit/04aaaa186c755b869c8d75678f563f6a9c089829)) ## v3.1.1 (2024-05-23) ### Bug fixes - Missing classmethod decorator on find_device_by_address ([`aa08b13`](https://github.com/Bluetooth-Devices/habluetooth/commit/aa08b136660cddea7c356274c21f20b6d0eef1fa)) ## v3.1.0 (2024-05-22) ### Features - Speed up dispatching bleak callbacks ([`cbc8b26`](https://github.com/Bluetooth-Devices/habluetooth/commit/cbc8b26f90b9ea4f2a8569c0625b527dd37ef180)) ## v3.0.1 (2024-05-03) ### Bug fixes - Ensure lazy advertisement uses none when name is not present ([`c300f73`](https://github.com/Bluetooth-Devices/habluetooth/commit/c300f73ba82d3549ea4c156ef11023e9478c8b6c)) ## v3.0.0 (2024-05-02) ### Features - Make generation of advertisementdata lazy ([`25f8437`](https://github.com/Bluetooth-Devices/habluetooth/commit/25f843795927ad663a1d5ef1fa9472ec366b9da5)) ## v2.8.1 (2024-05-02) ### Bug fixes - Add missing find_device_by_address mapping ([`cc8e57e`](https://github.com/Bluetooth-Devices/habluetooth/commit/cc8e57eef7b97a6f2a30488a64d156cb5023c6c6)) ## v2.8.0 (2024-04-17) ### Features - Add support for recovering failed adapters after reboot ([`04948c3`](https://github.com/Bluetooth-Devices/habluetooth/commit/04948c337adf0f7b291e4e33618a7eae6dc4ebc2)) ## v2.7.0 (2024-04-17) ### Features - Improve fallback to passive mode when active mode fails ([`17ecc01`](https://github.com/Bluetooth-Devices/habluetooth/commit/17ecc012e096bec0113efea9ceb6a21bb50023fe)) ## v2.6.0 (2024-04-17) ### Features - Speed up stopping the scanner when its stuck setting up ([`bba8b51`](https://github.com/Bluetooth-Devices/habluetooth/commit/bba8b514490d98dca1020bbfefd9dc1e6a79af5f)) ## v2.5.3 (2024-04-17) ### Bug fixes - Ensure scanner is stopped on cancellation ([`a21d70a`](https://github.com/Bluetooth-Devices/habluetooth/commit/a21d70a1ac88135eade61c0abc8912c5b04a6b8b)) ## v2.5.2 (2024-04-16) ### Bug fixes - Ensure discovered_devices returns an empty list for offline scanners ([`2350543`](https://github.com/Bluetooth-Devices/habluetooth/commit/23505437c98529f692ab2dc0f5c3bdb5c9b7e3bd)) ## v2.5.1 (2024-04-16) ### Bug fixes - Wheel builds ([`5bd671a`](https://github.com/Bluetooth-Devices/habluetooth/commit/5bd671a159292dffe30a69639411926d0bc28123)) ## v2.5.0 (2024-04-16) ### Features - Fallback to passive scanning if active cannot start ([`3fae981`](https://github.com/Bluetooth-Devices/habluetooth/commit/3fae98162e6b0279375823a3b6e60ee51b87c1bb)) ## v2.4.2 (2024-02-29) ### Bug fixes - Android beacons in passive mode with flags 0x02 ([`8330e18`](https://github.com/Bluetooth-Devices/habluetooth/commit/8330e187550ec00ed415d3650a2c231921fb8ae7)) ## v2.4.1 (2024-02-23) ### Bug fixes - Avoid concurrent refreshes of adapters ([`d355b17`](https://github.com/Bluetooth-Devices/habluetooth/commit/d355b1768705706dec7062ad5d6267089d87a88e)) ## v2.4.0 (2024-01-22) ### Features - Improve error reporting resolution suggestions ([`afff5ba`](https://github.com/Bluetooth-Devices/habluetooth/commit/afff5ba4dfd8a5582174b367ae5ed9c9953b81e9)) ## v2.3.1 (2024-01-22) ### Bug fixes - Ensure unavailable callbacks can be removed from fired callbacks ([`65e7706`](https://github.com/Bluetooth-Devices/habluetooth/commit/65e7706ef4cdb99f9df5a00f666ab1d30e92e3b1)) ## v2.3.0 (2024-01-22) ### Features - Reduce overhead to remove callbacks by using sets to store callbacks ([`05ceb85`](https://github.com/Bluetooth-Devices/habluetooth/commit/05ceb85901b17f72988068997c7f39bc0179dca2)) ## v2.2.0 (2024-01-14) ### Features - Improve remote scanner performance ([`c549b1c`](https://github.com/Bluetooth-Devices/habluetooth/commit/c549b1cf9bbbda0c39dfce92d2888d5b990211da)) ## v2.1.0 (2024-01-10) ### Features - Add support for windows ([`788dd77`](https://github.com/Bluetooth-Devices/habluetooth/commit/788dd77ffac6664083821d5ba8b264725a3baaff)) ## v2.0.2 (2024-01-04) ### Bug fixes - Handle subclassed str in the client wrapper ([`f18a30e`](https://github.com/Bluetooth-Devices/habluetooth/commit/f18a30e48fe064993dc64f3af01c5d64b676a82f)) ## v2.0.1 (2023-12-31) ### Bug fixes - Switching scanners too quickly ([`bd53685`](https://github.com/Bluetooth-Devices/habluetooth/commit/bd536854457bd8b27f9e91921965b88b0ff798c3)) ## v2.0.0 (2023-12-21) ### Features - Simplify async_register_scanner by removing connectable argument ([`10ac6da`](https://github.com/Bluetooth-Devices/habluetooth/commit/10ac6da0672c121b5f0246ed688e98111adc7339)) ## v1.0.0 (2023-12-12) ### Features - Eliminate the need to pass the new_info_callback ([`65c54a6`](https://github.com/Bluetooth-Devices/habluetooth/commit/65c54a68500be6053677511ffd21ce3dca4b6991)) ## v0.11.1 (2023-12-11) ### Bug fixes - Do not schedule an expire when restoring devices ([`144cf15`](https://github.com/Bluetooth-Devices/habluetooth/commit/144cf15050a68cca66e7a2e24a5ddc7b87c32e41)) ## v0.11.0 (2023-12-11) ### Features - Relocate bluetoothserviceinfobleak ([`4f4f32d`](https://github.com/Bluetooth-Devices/habluetooth/commit/4f4f32d78d6abe21e28171f54ff5f3b17c8fb702)) ## v0.10.0 (2023-12-07) ### Features - Small speed ups to base_scanner ([`e1ff7e9`](https://github.com/Bluetooth-Devices/habluetooth/commit/e1ff7e9fb91a274b1a4bf6943a26e2a3f19780e7)) ## v0.9.0 (2023-12-06) ### Features - Speed up processing incoming service infos ([`55f6522`](https://github.com/Bluetooth-Devices/habluetooth/commit/55f6522ffc2adaf7e203ff4d2c1b13adc5d8c6a2)) ## v0.8.0 (2023-12-06) ### Features - Auto build the cythonized manager ([`c3441e5`](https://github.com/Bluetooth-Devices/habluetooth/commit/c3441e5095d62e6e70c2c879c4b5c109a87f463c)) - Add cython implementation for manager ([`266a602`](https://github.com/Bluetooth-Devices/habluetooth/commit/266a6022fb433ef9399f72e87b18b86897524784)) ## v0.7.0 (2023-12-05) ### Features - Port bluetooth manager from ha ([`757640a`](https://github.com/Bluetooth-Devices/habluetooth/commit/757640a7b7f60072588168501148ba750316f170)) ## v0.6.1 (2023-12-04) ### Bug fixes - Add missing cythonize for the adv tracker ([`8140195`](https://github.com/Bluetooth-Devices/habluetooth/commit/8140195a27ef83ea89ca643a5899d80839e574ae)) ## v0.6.0 (2023-12-04) ### Features - Port advertisement_tracker ([`378667b`](https://github.com/Bluetooth-Devices/habluetooth/commit/378667bce851b5076ee79ff223a72501c5575325)) ## v0.5.1 (2023-12-04) ### Bug fixes - Remove slots to keep hascanner patchable ([`d068f48`](https://github.com/Bluetooth-Devices/habluetooth/commit/d068f480d292619a1fc49a1256be98bdc6efadd6)) ## v0.5.0 (2023-12-03) ### Features - Port local scanner support from ha ([`1b1d0e4`](https://github.com/Bluetooth-Devices/habluetooth/commit/1b1d0e4bc17a44a1b20382da6ae28ea8e50e80b7)) ## v0.4.0 (2023-12-03) ### Features - Add more typing for incoming bluetooth data ([`de590e5`](https://github.com/Bluetooth-Devices/habluetooth/commit/de590e5c886801ff4a87f99c118be8855f337bd0)) ## v0.3.0 (2023-12-03) ### Features - Refactor to be able to use __pyx_pyobject_fastcall ([`e15074b`](https://github.com/Bluetooth-Devices/habluetooth/commit/e15074b172242f44f641e5232ebdf6297537a2b8)) - Add basic pxd ([`fd97d07`](https://github.com/Bluetooth-Devices/habluetooth/commit/fd97d07db7c0e8e0e877e1544fd0e392d14448b3)) ## v0.2.0 (2023-12-03) ### Features - Add cython pxd for base_scanner ([`0195710`](https://github.com/Bluetooth-Devices/habluetooth/commit/0195710bc25c8c3cc68b17a8f31cf281494fdc22)) ## v0.1.0 (2023-12-03) ### Features - Port base scanner from ha ([`e01a57b`](https://github.com/Bluetooth-Devices/habluetooth/commit/e01a57b6e0003ea8fe64b8e6e11ce09a35c1ada2)) ## v0.0.1 (2023-12-02) ### Bug fixes - Reserve name ([`5493984`](https://github.com/Bluetooth-Devices/habluetooth/commit/5493984483902039ca396498122e6094524bbae6)) Bluetooth-Devices-habluetooth-21accde/CONTRIBUTING.md000066400000000000000000000074321507024716100223610ustar00rootroot00000000000000# 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 habluetooth could always use more documentation, whether as part of the official habluetooth 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/habluetooth.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/habluetooth/issues Bluetooth-Devices-habluetooth-21accde/LICENSE000066400000000000000000000261211507024716100211310ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2023 J. Nick Koston Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. Bluetooth-Devices-habluetooth-21accde/README.md000066400000000000000000000076041507024716100214100ustar00rootroot00000000000000# habluetooth

CI Status Documentation Status Test coverage percentage CodSpeed Badge

Poetry black pre-commit

PyPI Version Supported Python versions License

--- **Documentation**: https://habluetooth.readthedocs.io **Source Code**: https://github.com/bluetooth-devices/habluetooth --- High availability Bluetooth ## Installation Install this via pip (or your favourite package manager): `pip install habluetooth` ## 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 [Copier](https://copier.readthedocs.io/) and the [browniebroke/pypackage-template](https://github.com/browniebroke/pypackage-template) project template. Bluetooth-Devices-habluetooth-21accde/build_ext.py000066400000000000000000000033501507024716100224540ustar00rootroot00000000000000"""Build optional cython modules.""" import logging import os from distutils.command.build_ext import build_ext from typing import Any try: from setuptools import Extension except ImportError: from distutils.core import Extension _LOGGER = logging.getLogger(__name__) TO_CYTHONIZE = [ "src/habluetooth/advertisement_tracker.py", "src/habluetooth/base_scanner.py", "src/habluetooth/manager.py", "src/habluetooth/models.py", "src/habluetooth/scanner.py", "src/habluetooth/channels/bluez.py", ] EXTENSIONS = [ Extension( ext.removeprefix("src/").removesuffix(".py").replace("/", "."), [ext], language="c", extra_compile_args=["-O3", "-g0"], ) for ext in TO_CYTHONIZE ] class BuildExt(build_ext): """Build extension.""" def build_extensions(self) -> None: """Build extensions.""" try: super().build_extensions() except Exception as ex: # nosec _LOGGER.debug("Failed to build extensions: %s", ex, exc_info=True) pass def build(setup_kwargs: Any) -> None: """Build optional cython modules.""" if os.environ.get("SKIP_CYTHON", False): return try: from Cython.Build import cythonize setup_kwargs.update( { "ext_modules": cythonize( EXTENSIONS, compiler_directives={"language_level": "3"}, # Python 3 ), "cmdclass": {"build_ext": BuildExt}, } ) setup_kwargs["exclude_package_data"] = { pkg: ["*.c"] for pkg in setup_kwargs["packages"] } except Exception: if os.environ.get("REQUIRE_CYTHON"): raise pass Bluetooth-Devices-habluetooth-21accde/commitlint.config.mjs000066400000000000000000000003621507024716100242610ustar00rootroot00000000000000export 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-habluetooth-21accde/docs/000077500000000000000000000000001507024716100210525ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-21accde/docs/Makefile000066400000000000000000000013721507024716100225150ustar00rootroot00000000000000# 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 = . BUILDDIR = _build .PHONY: help livehtml Makefile # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) # Build, watch and serve docs with live reload livehtml: sphinx-autobuild -b html -c . $(SOURCEDIR) $(BUILDDIR)/html # 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-habluetooth-21accde/docs/_static/000077500000000000000000000000001507024716100225005ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-21accde/docs/_static/.gitkeep000066400000000000000000000000001507024716100241170ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-21accde/docs/changelog.md000066400000000000000000000000601507024716100233170ustar00rootroot00000000000000(changelog)= ```{include} ../CHANGELOG.md ``` Bluetooth-Devices-habluetooth-21accde/docs/conf.py000066400000000000000000000012171507024716100223520ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # Project information project = "habluetooth" copyright = "2023, J. Nick Koston" author = "J. Nick Koston" release = "5.7.0" # General configuration extensions = [ "myst_parser", ] # The suffix of source filenames. source_suffix = [ ".rst", ".md", ] templates_path = [ "_templates", ] exclude_patterns = [ "_build", "Thumbs.db", ".DS_Store", ] # Options for HTML output html_theme = "furo" html_static_path = ["_static"] Bluetooth-Devices-habluetooth-21accde/docs/contributing.md000066400000000000000000000000661507024716100241050ustar00rootroot00000000000000(contributing)= ```{include} ../CONTRIBUTING.md ``` Bluetooth-Devices-habluetooth-21accde/docs/index.md000066400000000000000000000003501507024716100225010ustar00rootroot00000000000000# Welcome to habluetooth documentation! ```{toctree} :caption: Installation & Usage :maxdepth: 2 installation usage ``` ```{toctree} :caption: Project Info :maxdepth: 2 changelog contributing ``` ```{include} ../README.md ``` Bluetooth-Devices-habluetooth-21accde/docs/installation.md000066400000000000000000000004151507024716100240750ustar00rootroot00000000000000(installation)= # Installation The package is published on [PyPI](https://pypi.org/project/habluetooth/) and can be installed with `pip` (or any equivalent): ```bash pip install habluetooth ``` Next, see the {ref}`section about usage ` to see how to use it. Bluetooth-Devices-habluetooth-21accde/docs/make.bat000066400000000000000000000013751507024716100224650ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build %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.https://www.sphinx-doc.org/ exit /b 1 ) if "%1" == "" goto help %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd Bluetooth-Devices-habluetooth-21accde/docs/usage.md000066400000000000000000000003261507024716100225010ustar00rootroot00000000000000(usage)= # Usage Assuming that you've followed the {ref}`installations steps `, you're now ready to use this package. Start by importing it: ```python import habluetooth ``` TODO: Document usage Bluetooth-Devices-habluetooth-21accde/examples/000077500000000000000000000000001507024716100217405ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-21accde/examples/bluez_api.py000066400000000000000000000024401507024716100242640ustar00rootroot00000000000000import asyncio import logging from habluetooth import BluetoothManager, BluetoothScanningMode from habluetooth.scanner import HaScanner int_ = int class LoggingHaScanner(HaScanner): """Logging ha scanner.""" def _async_on_raw_bluez_advertisement( self, address: bytes, address_type: int_, rssi: int_, flags: int_, data: bytes, ) -> None: """Handle raw advertisement data.""" print( f"address={address!r}, address_type={address_type}, " f"rssi={rssi}, flags={flags}, data={data!r}" ) async def main() -> None: """Main function to test the Bluetooth management API.""" # Set up logging logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger("habluetooth") logger.setLevel(logging.DEBUG) manager = BluetoothManager() await manager.async_setup() # Create an instance of MGMTBluetoothCtl scanner = LoggingHaScanner( BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF" ) manager.async_register_scanner(scanner) try: await asyncio.Event().wait() finally: # Close the management interface when done manager.async_stop() if __name__ == "__main__": # Run the main function asyncio.run(main()) Bluetooth-Devices-habluetooth-21accde/poetry.lock000066400000000000000000005551261507024716100223340ustar00rootroot00000000000000# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. [[package]] name = "accessible-pygments" version = "0.0.5" description = "A collection of accessible pygments styles" optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7"}, {file = "accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872"}, ] [package.dependencies] pygments = ">=1.5" [package.extras] dev = ["pillow", "pkginfo (>=1.10)", "playwright", "pre-commit", "setuptools", "twine (>=5.0)"] tests = ["hypothesis", "pytest"] [[package]] name = "aiooui" version = "0.1.9" description = "Async OUI lookups" optional = false python-versions = "<4.0,>=3.9" groups = ["main"] 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 = "anyio" version = "4.9.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, ] [package.dependencies] idna = ">=2.8" sniffio = ">=1.1" typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] trio = ["trio (>=0.26.1)"] [[package]] name = "async-interrupt" version = "1.2.2" description = "Context manager to raise an exception when a future is done" optional = false python-versions = ">=3.9" groups = ["main"] files = [ {file = "async_interrupt-1.2.2-py3-none-any.whl", hash = "sha256:0a8deb884acfb5fe55188a693ae8a4381bbbd2cb6e670dac83869489513eec2c"}, {file = "async_interrupt-1.2.2.tar.gz", hash = "sha256:be4331a029b8625777905376a6dc1370984c8c810f30b79703f3ee039d262bf7"}, ] [[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 = "beautifulsoup4" version = "4.13.4" description = "Screen-scraping library" optional = false python-versions = ">=3.7.0" groups = ["docs"] files = [ {file = "beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b"}, {file = "beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195"}, ] [package.dependencies] soupsieve = ">1.2" typing-extensions = ">=4.0.0" [package.extras] cchardet = ["cchardet"] chardet = ["chardet"] charset-normalizer = ["charset-normalizer"] html5lib = ["html5lib"] lxml = ["lxml"] [[package]] name = "bleak" version = "1.1.1" description = "Bluetooth Low Energy platform Agnostic Klient" optional = false python-versions = ">=3.9" groups = ["main"] files = [ {file = "bleak-1.1.1-py3-none-any.whl", hash = "sha256:e601371396e357d95ee3c256db65b7da624c94ef6f051d47dfce93ea8361c22e"}, {file = "bleak-1.1.1.tar.gz", hash = "sha256:eeef18053eb3bd569a25bff62cd4eb9ee56be4d84f5321023a7c4920943e6ccb"}, ] [package.dependencies] 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.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 = "bleak-retry-connector" version = "4.4.3" description = "A connector for Bleak Clients that handles transient connection failures" optional = false python-versions = ">=3.10" groups = ["main"] files = [ {file = "bleak_retry_connector-4.4.3-py3-none-any.whl", hash = "sha256:17a478d525706488973b181fc789e960bc3fb4bcd94ccb0eee7b7b682442577b"}, {file = "bleak_retry_connector-4.4.3.tar.gz", hash = "sha256:70aa305dbd26eaf0586dd24723daac93ee3dd6a465e9782bf02b711fcbc4a527"}, ] [package.dependencies] bleak = {version = ">=1", markers = "python_version >= \"3.10\" and python_version < \"3.14\""} bluetooth-adapters = {version = ">=0.15.2", markers = "python_version >= \"3.10\" and python_version < \"3.14\" and platform_system == \"Linux\""} dbus-fast = {version = ">=1.14.0", markers = "platform_system == \"Linux\""} [[package]] name = "bluetooth-adapters" version = "2.1.1" description = "Tools to enumerate and find Bluetooth Adapters" optional = false python-versions = ">=3.9" groups = ["main"] 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" 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 = "bluetooth-auto-recovery" version = "1.5.3" description = "Recover bluetooth adapters that are in an stuck state" optional = false python-versions = ">=3.9" groups = ["main"] files = [ {file = "bluetooth_auto_recovery-1.5.3-py3-none-any.whl", hash = "sha256:5d66b859a54ef20fdf1bd3cf6762f153e86651babe716836770da9d9c47b01c4"}, {file = "bluetooth_auto_recovery-1.5.3.tar.gz", hash = "sha256:0b36aa6be84474fff81c1ce328f016a6553272ac47050b1fa60f03e36a8db46d"}, ] [package.dependencies] bluetooth-adapters = ">=0.16.0" btsocket = ">=0.2.0" PyRIC = ">=0.1.6.3" usb-devices = ">=0.4.1" [package.extras] docs = ["Sphinx (>=5,<8)", "myst-parser (>=0.18,<3.1)", "sphinx-rtd-theme (>=1,<4)"] [[package]] name = "bluetooth-data-tools" version = "1.28.2" description = "Tools for converting bluetooth data and packets" optional = false python-versions = ">=3.10" groups = ["main"] files = [ {file = "bluetooth_data_tools-1.28.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:59de191b17a6d7ab23f00b375638667556424c53f08efd288fc9694cf347edb8"}, {file = "bluetooth_data_tools-1.28.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:12498ee9387680205aaa856f964d70517a1ac2d4d64bbb78c35cb1f152137bf1"}, {file = "bluetooth_data_tools-1.28.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9a559839fc1fd5f84439608b3df598ed583354a671306ff2cd4ef9e667cba855"}, {file = "bluetooth_data_tools-1.28.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:191b8e40b35cfd4201f01d7638bd9e845cb42479a6cc6943fd3cbcd2c978d434"}, {file = "bluetooth_data_tools-1.28.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52b7bb1268d5e56a658efd666242a0b7b407f460a3257512b77a2fd3ed0bd9f7"}, {file = "bluetooth_data_tools-1.28.2-cp310-cp310-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04d2d0310eae1577554edf2b3306f9ed18b9621edff57e153204430cfa83a541"}, {file = "bluetooth_data_tools-1.28.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:82283c317e465dd998af4ddb5553f79992213b4c1055d0ed1977e6786a5401d8"}, {file = "bluetooth_data_tools-1.28.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d26a2398886c37771e35fa4f2c36e965da771fe3e984e76c078a98bcc5eb3733"}, {file = "bluetooth_data_tools-1.28.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ab975c1567aa3fd20e787c8ddc9d55984d0883a251c7d65c55dc3a173f9ef796"}, {file = "bluetooth_data_tools-1.28.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0c8454962e005d48e23af3fe240235e16c81590805dfe0b27b08b82337df16a8"}, {file = "bluetooth_data_tools-1.28.2-cp310-cp310-win32.whl", hash = "sha256:8e492b786ac561f628bf964f522d669259b608d1f434102d003afdc46dcab473"}, {file = "bluetooth_data_tools-1.28.2-cp310-cp310-win_amd64.whl", hash = "sha256:567dae42c2e1d7da5aeca1ef181bd660db8fc2f3fe3af7e999e82a030a1a422d"}, {file = "bluetooth_data_tools-1.28.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4042b36f1c50bcdc0afb6be78a0bac11a8be6a73a3284825502ce6e82661423e"}, {file = "bluetooth_data_tools-1.28.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:728cfb9fd366b01eec9eaa226353cdd43e099288974f6e9273dc90a577fc970a"}, {file = "bluetooth_data_tools-1.28.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e3a2c9fba041b8486f1a4d4098b40241168a2d79ab0f5cdbccafadac7e41747"}, {file = "bluetooth_data_tools-1.28.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5d3cd8bb926742f01ced40b635a860c1cf5a6b82c3ce88e8431ee77e1062b7d6"}, {file = "bluetooth_data_tools-1.28.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a791a07d60ae70e2fc174688abd1e28fdc54a0c337b6306ce6c6a66c06e41db7"}, {file = "bluetooth_data_tools-1.28.2-cp311-cp311-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2049946c4a489db86c7f5cc32ef50894952244be07a5919a58f8b4b03f742b1"}, {file = "bluetooth_data_tools-1.28.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b3344421a012641f21ffbc7e3d5955dbc19150ad48d800b4b61a7a2cb4ee0d87"}, {file = "bluetooth_data_tools-1.28.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:9e068aa7b0c147f8bae735298c041058693e8ee3763d769ca6cbd938210ed715"}, {file = "bluetooth_data_tools-1.28.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0eed475d3dfe8af519348047f150b52dcaa5aa84c5a5bae023a6df3005fe95b1"}, {file = "bluetooth_data_tools-1.28.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dfffdaab077173886c64c5c89329d00c7140302fd7f537e3f7bd00d8b851c9d8"}, {file = "bluetooth_data_tools-1.28.2-cp311-cp311-win32.whl", hash = "sha256:02045a0dc566122f3e30e4ac5861f35c5749cd52eda4abd5a3918c27bfff28fb"}, {file = "bluetooth_data_tools-1.28.2-cp311-cp311-win_amd64.whl", hash = "sha256:ed619bb4fd4a3794c9c11c140c59a727bb1f63d0888e135da8382d2dbc8c18f9"}, {file = "bluetooth_data_tools-1.28.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8eef61057a754293e2ca54e153ba804bc313257881bd8360de091524b3dffc0"}, {file = "bluetooth_data_tools-1.28.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c1567e45ed6b4987db1e70e7f59d7499a57f8eec2e4a38131eeedd8c5b52bc81"}, {file = "bluetooth_data_tools-1.28.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:094a6a4aeaf82a6e939318f716294fa7d65a8f025f8b10471e2145320a57b412"}, {file = "bluetooth_data_tools-1.28.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:59398ab013e33539a08cc0dcf19f0fc4a7dc6311d5dc5bfb7af03906fc3a902f"}, {file = "bluetooth_data_tools-1.28.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:010564351a0b269371b5f5dc4ed1084b9524028f106cce49208db5b1bc236e84"}, {file = "bluetooth_data_tools-1.28.2-cp312-cp312-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:639f89a57936fb65a881ea7dd60dc6d8bc5b859e740d2c4e699f28f28f3da992"}, {file = "bluetooth_data_tools-1.28.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60dac54c9fe308a9dc4b7bc5c94d174c6e93931ac27cdfba8b2cd5c19d73f126"}, {file = "bluetooth_data_tools-1.28.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:50493246908a5aaa305d00df86a83d915a00cc1a783e05d6666ea3ff293cc137"}, {file = "bluetooth_data_tools-1.28.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7fcbf974dc3a7d1a00cebacc424dcf1d18d4eac4bccecba1d8ff5a1e8eb0b348"}, {file = "bluetooth_data_tools-1.28.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a5e9eb874c9823d330dd0eadca9176a8a713395908f865c19156a233da7b5c2"}, {file = "bluetooth_data_tools-1.28.2-cp312-cp312-win32.whl", hash = "sha256:d3346ef76577f5060955a80c46ae16004f76673d844f8c44a416bd8177d6695c"}, {file = "bluetooth_data_tools-1.28.2-cp312-cp312-win_amd64.whl", hash = "sha256:872c31ba9042ed614aad459fd0358893554c67e2fa5c2d78d525cd96af3e31da"}, {file = "bluetooth_data_tools-1.28.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:71df3e6221ee472cb38fd625cecc6e0a8733e093e40c08e80638e9387349b43b"}, {file = "bluetooth_data_tools-1.28.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b2925335caf40bb9872a8733d823bb8e97bac2bc7ce988a695452e4a39507e29"}, {file = "bluetooth_data_tools-1.28.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:535c037b3ccd86a5df890b338b901eea3e974692ae07b591c1f99e787d629170"}, {file = "bluetooth_data_tools-1.28.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:080668765dc7d04d6b78a7bc0feaffd14b45ccee58b5c005a22b78e3730934fd"}, {file = "bluetooth_data_tools-1.28.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c2947f86112fc308973df735f030ede800473dd61f9e32d62d55bfb5c00748"}, {file = "bluetooth_data_tools-1.28.2-cp313-cp313-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d74c6b9187b444e548cd01ce56c74eb0c1ba592043b9a1f48a9c2ed19a8a236a"}, {file = "bluetooth_data_tools-1.28.2-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:ad09f0dbc343e51c34f32672aa877373d747eebe956c640117ce9472c86f1cb2"}, {file = "bluetooth_data_tools-1.28.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c833481774fe319ef239351bb8a028cc2efe44ad7cf23681bd2cd2a4dfb71599"}, {file = "bluetooth_data_tools-1.28.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a989a4a5e8e4d70410fd9bba7b03f970bed7b8f79531087565931314437420be"}, {file = "bluetooth_data_tools-1.28.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6f30e619ca3b46716a7f8c2bde35776d36e6b98e1922f0642034618e1056b3b3"}, {file = "bluetooth_data_tools-1.28.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cf3714c9e27aaa7db0800816bf766919cd1ac18080bac0102c2ad466db02f47a"}, {file = "bluetooth_data_tools-1.28.2-cp313-cp313-win32.whl", hash = "sha256:8f28eeee5fecaebeb9fc1012e4220bc3c1ee6ee82bf8a17b9183995933f6d938"}, {file = "bluetooth_data_tools-1.28.2-cp313-cp313-win_amd64.whl", hash = "sha256:e748587be85a8133b0a43e34e2c6f65dbf5113765a03d4f89c26039b8289decb"}, {file = "bluetooth_data_tools-1.28.2.tar.gz", hash = "sha256:2afa97695fc61c8d55d19ffa9485a498051410f399a183852d1bf29f675c3537"}, ] [package.dependencies] cryptography = ">=41.0.3" [package.extras] docs = ["Sphinx (>=5,<9)", "myst-parser (>=0.18,<4.1)", "sphinx-rtd-theme (>=1,<4)"] [[package]] name = "btsocket" version = "0.3.0" description = "Python library for BlueZ Bluetooth Management API" optional = false python-versions = "*" groups = ["main"] files = [ {file = "btsocket-0.3.0-py2.py3-none-any.whl", hash = "sha256:949821c1b580a88e73804ad610f5173d6ae258e7b4e389da4f94d614344f1a9c"}, {file = "btsocket-0.3.0.tar.gz", hash = "sha256:7ea495de0ff883f0d9f8eea59c72ca7fed492994df668fe476b84d814a147a0d"}, ] [package.extras] dev = ["bumpversion", "coverage", "pycodestyle", "pygments", "sphinx", "sphinx-rtd-theme", "twine"] docs = ["pygments", "sphinx", "sphinx-rtd-theme"] rel = ["bumpversion", "twine"] test = ["coverage", "pycodestyle"] [[package]] name = "certifi" version = "2025.6.15" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["docs"] files = [ {file = "certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057"}, {file = "certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b"}, ] [[package]] name = "cffi" version = "1.17.1" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.8" groups = ["main", "dev"] files = [ {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, ] markers = {main = "platform_python_implementation != \"PyPy\""} [package.dependencies] pycparser = "*" [[package]] name = "charset-normalizer" version = "3.4.2" 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.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6"}, {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d"}, {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86"}, {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c"}, {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0"}, {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef"}, {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6"}, {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366"}, {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db"}, {file = "charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a"}, {file = "charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509"}, {file = "charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2"}, {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645"}, {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd"}, {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8"}, {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f"}, {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7"}, {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9"}, {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544"}, {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82"}, {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0"}, {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5"}, {file = "charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a"}, {file = "charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28"}, {file = "charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7"}, {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3"}, {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a"}, {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214"}, {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a"}, {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd"}, {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981"}, {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c"}, {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b"}, {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d"}, {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f"}, {file = "charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c"}, {file = "charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e"}, {file = "charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0"}, {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf"}, {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e"}, {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1"}, {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c"}, {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691"}, {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0"}, {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b"}, {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff"}, {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b"}, {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148"}, {file = "charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7"}, {file = "charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980"}, {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184"}, {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa"}, {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344"}, {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da"}, {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02"}, {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d"}, {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4"}, {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f"}, {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64"}, {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f"}, {file = "charset_normalizer-3.4.2-cp37-cp37m-win32.whl", hash = "sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58"}, {file = "charset_normalizer-3.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2"}, {file = "charset_normalizer-3.4.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb"}, {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a"}, {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45"}, {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5"}, {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1"}, {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027"}, {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b"}, {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455"}, {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01"}, {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58"}, {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681"}, {file = "charset_normalizer-3.4.2-cp38-cp38-win32.whl", hash = "sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7"}, {file = "charset_normalizer-3.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a"}, {file = "charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4"}, {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7"}, {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836"}, {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597"}, {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7"}, {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f"}, {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba"}, {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12"}, {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518"}, {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5"}, {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3"}, {file = "charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471"}, {file = "charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e"}, {file = "charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0"}, {file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"}, ] [[package]] name = "click" version = "8.2.1" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" groups = ["docs"] files = [ {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, ] [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} [[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"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] markers = {dev = "sys_platform == \"win32\""} [[package]] name = "coverage" version = "7.10.6" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "coverage-7.10.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70e7bfbd57126b5554aa482691145f798d7df77489a177a6bef80de78860a356"}, {file = "coverage-7.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301"}, {file = "coverage-7.10.6-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c61fc91ab80b23f5fddbee342d19662f3d3328173229caded831aa0bd7595460"}, {file = "coverage-7.10.6-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10356fdd33a7cc06e8051413140bbdc6f972137508a3572e3f59f805cd2832fd"}, {file = "coverage-7.10.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80b1695cf7c5ebe7b44bf2521221b9bb8cdf69b1f24231149a7e3eb1ae5fa2fb"}, {file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2e4c33e6378b9d52d3454bd08847a8651f4ed23ddbb4a0520227bd346382bbc6"}, {file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c8a3ec16e34ef980a46f60dc6ad86ec60f763c3f2fa0db6d261e6e754f72e945"}, {file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7d79dabc0a56f5af990cc6da9ad1e40766e82773c075f09cc571e2076fef882e"}, {file = "coverage-7.10.6-cp310-cp310-win32.whl", hash = "sha256:86b9b59f2b16e981906e9d6383eb6446d5b46c278460ae2c36487667717eccf1"}, {file = "coverage-7.10.6-cp310-cp310-win_amd64.whl", hash = "sha256:e132b9152749bd33534e5bd8565c7576f135f157b4029b975e15ee184325f528"}, {file = "coverage-7.10.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c706db3cabb7ceef779de68270150665e710b46d56372455cd741184f3868d8f"}, {file = "coverage-7.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e0c38dc289e0508ef68ec95834cb5d2e96fdbe792eaccaa1bccac3966bbadcc"}, {file = "coverage-7.10.6-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:752a3005a1ded28f2f3a6e8787e24f28d6abe176ca64677bcd8d53d6fe2ec08a"}, {file = "coverage-7.10.6-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:689920ecfd60f992cafca4f5477d55720466ad2c7fa29bb56ac8d44a1ac2b47a"}, {file = "coverage-7.10.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec98435796d2624d6905820a42f82149ee9fc4f2d45c2c5bc5a44481cc50db62"}, {file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b37201ce4a458c7a758ecc4efa92fa8ed783c66e0fa3c42ae19fc454a0792153"}, {file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2904271c80898663c810a6b067920a61dd8d38341244a3605bd31ab55250dad5"}, {file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5aea98383463d6e1fa4e95416d8de66f2d0cb588774ee20ae1b28df826bcb619"}, {file = "coverage-7.10.6-cp311-cp311-win32.whl", hash = "sha256:e3fb1fa01d3598002777dd259c0c2e6d9d5e10e7222976fc8e03992f972a2cba"}, {file = "coverage-7.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:f35ed9d945bece26553d5b4c8630453169672bea0050a564456eb88bdffd927e"}, {file = "coverage-7.10.6-cp311-cp311-win_arm64.whl", hash = "sha256:99e1a305c7765631d74b98bf7dbf54eeea931f975e80f115437d23848ee8c27c"}, {file = "coverage-7.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b2dd6059938063a2c9fee1af729d4f2af28fd1a545e9b7652861f0d752ebcea"}, {file = "coverage-7.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:388d80e56191bf846c485c14ae2bc8898aa3124d9d35903fef7d907780477634"}, {file = "coverage-7.10.6-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:90cb5b1a4670662719591aa92d0095bb41714970c0b065b02a2610172dbf0af6"}, {file = "coverage-7.10.6-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:961834e2f2b863a0e14260a9a273aff07ff7818ab6e66d2addf5628590c628f9"}, {file = "coverage-7.10.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf9a19f5012dab774628491659646335b1928cfc931bf8d97b0d5918dd58033c"}, {file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:99c4283e2a0e147b9c9cc6bc9c96124de9419d6044837e9799763a0e29a7321a"}, {file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:282b1b20f45df57cc508c1e033403f02283adfb67d4c9c35a90281d81e5c52c5"}, {file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cdbe264f11afd69841bd8c0d83ca10b5b32853263ee62e6ac6a0ab63895f972"}, {file = "coverage-7.10.6-cp312-cp312-win32.whl", hash = "sha256:a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d"}, {file = "coverage-7.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:856986eadf41f52b214176d894a7de05331117f6035a28ac0016c0f63d887629"}, {file = "coverage-7.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80"}, {file = "coverage-7.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6"}, {file = "coverage-7.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80"}, {file = "coverage-7.10.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003"}, {file = "coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27"}, {file = "coverage-7.10.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4"}, {file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d"}, {file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc"}, {file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc"}, {file = "coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e"}, {file = "coverage-7.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32"}, {file = "coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2"}, {file = "coverage-7.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b"}, {file = "coverage-7.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393"}, {file = "coverage-7.10.6-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27"}, {file = "coverage-7.10.6-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df"}, {file = "coverage-7.10.6-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb"}, {file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282"}, {file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4"}, {file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21"}, {file = "coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0"}, {file = "coverage-7.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5"}, {file = "coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b"}, {file = "coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e"}, {file = "coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb"}, {file = "coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034"}, {file = "coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1"}, {file = "coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a"}, {file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb"}, {file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d"}, {file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747"}, {file = "coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5"}, {file = "coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713"}, {file = "coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32"}, {file = "coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65"}, {file = "coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6"}, {file = "coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0"}, {file = "coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e"}, {file = "coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5"}, {file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7"}, {file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5"}, {file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0"}, {file = "coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7"}, {file = "coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930"}, {file = "coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b"}, {file = "coverage-7.10.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90558c35af64971d65fbd935c32010f9a2f52776103a259f1dee865fe8259352"}, {file = "coverage-7.10.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8953746d371e5695405806c46d705a3cd170b9cc2b9f93953ad838f6c1e58612"}, {file = "coverage-7.10.6-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c83f6afb480eae0313114297d29d7c295670a41c11b274e6bca0c64540c1ce7b"}, {file = "coverage-7.10.6-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7eb68d356ba0cc158ca535ce1381dbf2037fa8cb5b1ae5ddfc302e7317d04144"}, {file = "coverage-7.10.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b15a87265e96307482746d86995f4bff282f14b027db75469c446da6127433b"}, {file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fc53ba868875bfbb66ee447d64d6413c2db91fddcfca57025a0e7ab5b07d5862"}, {file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:efeda443000aa23f276f4df973cb82beca682fd800bb119d19e80504ffe53ec2"}, {file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9702b59d582ff1e184945d8b501ffdd08d2cee38d93a2206aa5f1365ce0b8d78"}, {file = "coverage-7.10.6-cp39-cp39-win32.whl", hash = "sha256:2195f8e16ba1a44651ca684db2ea2b2d4b5345da12f07d9c22a395202a05b23c"}, {file = "coverage-7.10.6-cp39-cp39-win_amd64.whl", hash = "sha256:f32ff80e7ef6a5b5b606ea69a36e97b219cd9dc799bcf2963018a4d8f788cfbf"}, {file = "coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3"}, {file = "coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90"}, ] [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" version = "45.0.5" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.7" groups = ["main"] files = [ {file = "cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8"}, {file = "cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d"}, {file = "cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5"}, {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57"}, {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e6c00130ed423201c5bc5544c23359141660b07999ad82e34e7bb8f882bb78e0"}, {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:dd420e577921c8c2d31289536c386aaa30140b473835e97f83bc71ea9d2baf2d"}, {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d05a38884db2ba215218745f0781775806bde4f32e07b135348355fe8e4991d9"}, {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27"}, {file = "cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e"}, {file = "cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174"}, {file = "cryptography-45.0.5-cp311-abi3-win32.whl", hash = "sha256:926c3ea71a6043921050eaa639137e13dbe7b4ab25800932a8498364fc1abec9"}, {file = "cryptography-45.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:b85980d1e345fe769cfc57c57db2b59cff5464ee0c045d52c0df087e926fbe63"}, {file = "cryptography-45.0.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3562c2f23c612f2e4a6964a61d942f891d29ee320edb62ff48ffb99f3de9ae8"}, {file = "cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd"}, {file = "cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e"}, {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0"}, {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2089cc8f70a6e454601525e5bf2779e665d7865af002a5dec8d14e561002e135"}, {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0027d566d65a38497bc37e0dd7c2f8ceda73597d2ac9ba93810204f56f52ebc7"}, {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:be97d3a19c16a9be00edf79dca949c8fa7eff621763666a145f9f9535a5d7f42"}, {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492"}, {file = "cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0"}, {file = "cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a"}, {file = "cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f"}, {file = "cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97"}, {file = "cryptography-45.0.5-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:206210d03c1193f4e1ff681d22885181d47efa1ab3018766a7b32a7b3d6e6afd"}, {file = "cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c648025b6840fe62e57107e0a25f604db740e728bd67da4f6f060f03017d5097"}, {file = "cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b8fa8b0a35a9982a3c60ec79905ba5bb090fc0b9addcfd3dc2dd04267e45f25e"}, {file = "cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:14d96584701a887763384f3c47f0ca7c1cce322aa1c31172680eb596b890ec30"}, {file = "cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57c816dfbd1659a367831baca4b775b2a5b43c003daf52e9d57e1d30bc2e1b0e"}, {file = "cryptography-45.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b9e38e0a83cd51e07f5a48ff9691cae95a79bea28fe4ded168a8e5c6c77e819d"}, {file = "cryptography-45.0.5-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8c4a6ff8a30e9e3d38ac0539e9a9e02540ab3f827a3394f8852432f6b0ea152e"}, {file = "cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bd4c45986472694e5121084c6ebbd112aa919a25e783b87eb95953c9573906d6"}, {file = "cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:982518cd64c54fcada9d7e5cf28eabd3ee76bd03ab18e08a48cad7e8b6f31b18"}, {file = "cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:12e55281d993a793b0e883066f590c1ae1e802e3acb67f8b442e721e475e6463"}, {file = "cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:5aa1e32983d4443e310f726ee4b071ab7569f58eedfdd65e9675484a4eb67bd1"}, {file = "cryptography-45.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e357286c1b76403dd384d938f93c46b2b058ed4dfcdce64a770f0537ed3feb6f"}, {file = "cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a"}, ] [package.dependencies] cffi = {version = ">=1.14", markers = "platform_python_implementation != \"PyPy\""} [package.extras] docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs ; python_full_version >= \"3.8.0\"", "sphinx-rtd-theme (>=3.0.0) ; python_full_version >= \"3.8.0\""] docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_full_version >= \"3.8.0\""] pep8test = ["check-sdist ; python_full_version >= \"3.8.0\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] test = ["certifi (>=2024)", "cryptography-vectors (==45.0.5)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] test-randomorder = ["pytest-randomly"] [[package]] name = "dbus-fast" version = "2.44.3" description = "A faster version of dbus-next" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ {file = "dbus_fast-2.44.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:644880d8db53a6d92e88015f6ac6e0d9a5c1bfdacbc5356de816212cca33c629"}, {file = "dbus_fast-2.44.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be7e2e39bc6a5e0fe758d9d7abb19f91a7540e3b45124764f318147b74c9b2e6"}, {file = "dbus_fast-2.44.3-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:049236a2cacddc6f1f8583371d8fa54d0a01e2081c8f1311a6ad71b27b1512aa"}, {file = "dbus_fast-2.44.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69bb4820259e0969ae79585ffc98409bf781589c138a90d4799d5751c83ed04a"}, {file = "dbus_fast-2.44.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:86350a3fc4304f50c56730b64bd3d709458648fa1b23f8e9449dfcce206defe4"}, {file = "dbus_fast-2.44.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:89c418c8f18fff8eb17143184d4e0f68216c4d702f16cba4323a6b6be6aaab2a"}, {file = "dbus_fast-2.44.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c700cdb06e74a6c462d180eff146105fe08f0dc4a8f1f8ff93022175c8e6fe76"}, {file = "dbus_fast-2.44.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9018568987b878e577bc3e692f2eef6b7a4482490a373ec00098578fa919076c"}, {file = "dbus_fast-2.44.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3879fb6d6e9260b310fed33457835e11b83e96144bfcf2cbb9abcd3e740c2836"}, {file = "dbus_fast-2.44.3-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:0c68f14d5a329bd494a2da561da961ddfb3f3351d41225dcf0e59106f32bf5d6"}, {file = "dbus_fast-2.44.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3f10ee6ba45f37d067775c0719d072bc4a7e0bdc9a0411f5c7c93af0bfd9958"}, {file = "dbus_fast-2.44.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bec6cb61d9ce56715410e17e6e6d935df6d39bc01e0aae691135229a0d69072"}, {file = "dbus_fast-2.44.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94ae76e470c5cf6eb507e2a92e698a9183b3558e3a09efcb7fe2152b92dd300b"}, {file = "dbus_fast-2.44.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3f1df8582723ee1b1689243663f4e93fc406f0966ff3e9c26a21cb498de3b9ca"}, {file = "dbus_fast-2.44.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:861352c19f57087e9b2ff7e16a1bab0cfb2e7dc982ce0249aad2a36e1af8f110"}, {file = "dbus_fast-2.44.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5aafa42df91e17023885c508539df2f6312abb9d050f56e39345175cef05bfbb"}, {file = "dbus_fast-2.44.3-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:4e5c2515bdc159eaa9ac9e99115016af65261cb4d1d237162295966ad1d8cac0"}, {file = "dbus_fast-2.44.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3dab3b4802e1c518b8f3d98bfefe1f696125c00016faf1b6f1fd5170efc06d7e"}, {file = "dbus_fast-2.44.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:42842e8f396be5d938c60cb449600df811373efd57dc630bb40d6d36f4e710a4"}, {file = "dbus_fast-2.44.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:93ea055c644bdfd7c70614f7c860db9f5234736a15992df9e4a723fa55ef7622"}, {file = "dbus_fast-2.44.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9764e4188e21ad4a9f65856f3adacfc83d583a950d4dabc5ec5856db387784b"}, {file = "dbus_fast-2.44.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d967a751cc2dd530d5b756a22bf67a603ebeca13c6f72d8b1cb8575b872caa16"}, {file = "dbus_fast-2.44.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da0910f813350b951efe4964a19d7f4aaf253b6c1021b0d68340160a990dc2fc"}, {file = "dbus_fast-2.44.3-cp313-cp313-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:253ad2417b0651ba32325661bb559228ceaedea9fb75d238972087a5f66551fd"}, {file = "dbus_fast-2.44.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebb4c56bef8f69e4e2606eb29a5c137ba448cf7d6958f4f2fba263d74623bd06"}, {file = "dbus_fast-2.44.3-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:6e0a6a27a1f53b32259d0789bca6f53decd88dec52722cac9a93327f8b7670c3"}, {file = "dbus_fast-2.44.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a990390c5d019e8e4d41268a3ead0eb6e48e977173d7685b0f5b5b3d0695c2f"}, {file = "dbus_fast-2.44.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5aca3c940eddb99f19bd3f0c6c50cd566fd98396dd9516d35dbf12af25b7a2c6"}, {file = "dbus_fast-2.44.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0046e74c25b79ffb6ea5b07f33b5da0bdc2a75ad6aede3f7836654485239121d"}, {file = "dbus_fast-2.44.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fce364e03b98a6acb4694f1c24b05bfc33d10045af1469378a25ffe4fa046f40"}, {file = "dbus_fast-2.44.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd955b153622df80cc420fe53c265cd43b7c559100a9e52c83ab0425bc083604"}, {file = "dbus_fast-2.44.3-cp39-cp39-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:6b00eef5437d27917d55d04b3edea60c12a3e2a94fd82e81b396311ff7bb1c88"}, {file = "dbus_fast-2.44.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8468df924e5a3870b1e23377ea573e4b43a22ab1730084eab1b838fd18c9a589"}, {file = "dbus_fast-2.44.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e4dd64813f175403fac894b5f6f6ff028127ea3c6ca8eda41770f39ba9815572"}, {file = "dbus_fast-2.44.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f36183af2c6d3a00bd555e7d871d8c3214bb91c42439428dfcf7cc664081182a"}, {file = "dbus_fast-2.44.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0bb0dfc386ae246def7ee64ce058d099b1bc8c35cd5325e6cd80f57b8115fec7"}, {file = "dbus_fast-2.44.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b853f75e10b34bb2ba76706d10fdab5ba0cef9ebc1faec1969c84e5b155b3b8"}, {file = "dbus_fast-2.44.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de00d3d7731b2f915ac3f4ed2119442f3054efeb84c5bdd21717b92241b68f82"}, {file = "dbus_fast-2.44.3-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:92377b4f274e3e70b9fcffd9a0e37a9808748f8df4b9d510a81f36b9e8c0f42f"}, {file = "dbus_fast-2.44.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6ee1dc3e05b47e89b6be5b45d345b57a85b822f3a55299b569766384e74d0f9"}, {file = "dbus_fast-2.44.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:780c960c546fe509dd2b7a8c7f5eeef3a88f99cdea77225a400a47411b9aea17"}, {file = "dbus_fast-2.44.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d4200a3c33684df692a545b16f72f52e70ecd68e8226273e828fc12fbcdde88"}, {file = "dbus_fast-2.44.3-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:e1643f9d47450e29fd14e62c583c71f332337dc157e9536692e5c0cd5e70ec53"}, {file = "dbus_fast-2.44.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1f3c673c40a3f82388b377d492aa31f9ba66c20ba1183f1bcd8f9b64eda599c"}, {file = "dbus_fast-2.44.3.tar.gz", hash = "sha256:962b36abbe885159e31135c57a7d9659997c61a13d55ecb070a61dc502dbd87e"}, ] [[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 = "freezegun" version = "1.5.5" description = "Let your Python tests travel through time" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2"}, {file = "freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a"}, ] [package.dependencies] python-dateutil = ">=2.7" [[package]] name = "furo" version = "2025.9.25" description = "A clean customisable Sphinx documentation theme." optional = false python-versions = ">=3.8" groups = ["docs"] files = [ {file = "furo-2025.9.25-py3-none-any.whl", hash = "sha256:2937f68e823b8e37b410c972c371bc2b1d88026709534927158e0cb3fac95afe"}, {file = "furo-2025.9.25.tar.gz", hash = "sha256:3eac05582768fdbbc2bdfa1cdbcdd5d33cfc8b4bd2051729ff4e026a1d7e0a98"}, ] [package.dependencies] accessible-pygments = ">=0.0.5" beautifulsoup4 = "*" pygments = ">=2.7" sphinx = ">=6.0,<9.0" sphinx-basic-ng = ">=1.0.0.beta2" [[package]] name = "h11" version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.8" groups = ["docs"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, ] [[package]] name = "idna" version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" groups = ["docs"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, ] [package.extras] all = ["flake8 (>=7.1.1)", "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 = ["dev", "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.4.2" description = "Collection of plugins for markdown-it-py" optional = false python-versions = ">=3.8" groups = ["docs"] files = [ {file = "mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636"}, {file = "mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5"}, ] [package.dependencies] markdown-it-py = ">=1.0.0,<4.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 = ["dev", "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 = "pycparser" version = "2.22" description = "C parser in Python" optional = false python-versions = ">=3.8" groups = ["main", "dev"] files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, ] markers = {main = "platform_python_implementation != \"PyPy\""} [[package]] name = "pygments" version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" groups = ["dev", "docs"] files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, ] [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 = "pyric" version = "0.1.6.3" description = "Python Wireless Library" optional = false python-versions = "*" groups = ["main"] files = [ {file = "PyRIC-0.1.6.3.tar.gz", hash = "sha256:b539b01cafebd2406c00097f94525ea0f8ecd1dd92f7731f43eac0ef16c2ccc9"}, ] [[package]] name = "pytest" version = "8.4.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, ] [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} iniconfig = ">=1" packaging = ">=20" pluggy = ">=1.5,<2" pygments = ">=2.7.2" [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" version = "1.2.0" description = "Pytest support for asyncio" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99"}, {file = "pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57"}, ] [package.dependencies] pytest = ">=8.2,<9" 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-codspeed" version = "4.0.0" description = "Pytest plugin to create CodSpeed benchmarks" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pytest_codspeed-4.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2517731b20a6aa9fe61d04822b802e1637ee67fd865189485b384a9d5897117f"}, {file = "pytest_codspeed-4.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e5076bb5119d4f8248822b5cd6b768f70a18c7e1a7fbcd96a99cd4a6430096e"}, {file = "pytest_codspeed-4.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:06b324acdfe2076a0c97a9d31e8645f820822d6f0e766c73426767ff887a9381"}, {file = "pytest_codspeed-4.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ebdac1a4d6138e1ca4f5391e7e3cafad6e3aa6d5660d1b243871b691bc1396c"}, {file = "pytest_codspeed-4.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f3def79d4072867d038a33e7f35bc7fb1a2a75236a624b3a690c5540017cb38"}, {file = "pytest_codspeed-4.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01d29d4538c2d111c0034f71811bcce577304506d22af4dd65df87fadf3ab495"}, {file = "pytest_codspeed-4.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90894c93c9e23f12487b7fdf16c28da8f6275d565056772072beb41a72a54cf9"}, {file = "pytest_codspeed-4.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79e9c40852fa7fc76776db4f1d290eceaeee2d6c5d2dc95a66c7cc690d83889e"}, {file = "pytest_codspeed-4.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7330b6eadd6a729d4dba95d26496ee1c6f1649d552f515ef537b14a43908eb67"}, {file = "pytest_codspeed-4.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1271cd28e895132b20d12875554a544ee041f7acfb8112af8a5c3cb201f2fc8"}, {file = "pytest_codspeed-4.0.0-py3-none-any.whl", hash = "sha256:c5debd4b127dc1c507397a8304776f52cabbfa53aad6f51eae329a5489df1e06"}, {file = "pytest_codspeed-4.0.0.tar.gz", hash = "sha256:0e9af08ca93ad897b376771db92693a81aa8990eecc2a778740412e00a6f6eaf"}, ] [package.dependencies] cffi = ">=1.17.1" pytest = ">=3.8" rich = ">=13.8.1" [package.extras] compat = ["pytest-benchmark (>=5.0.0,<5.1.0)", "pytest-xdist (>=3.6.1,<3.7.0)"] lint = ["mypy (>=1.11.2,<1.12.0)", "ruff (>=0.11.12,<0.12.0)"] test = ["pytest (>=7.0,<8.0)", "pytest-cov (>=4.0.0,<4.1.0)"] [[package]] name = "pytest-cov" version = "7.0.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, ] [package.dependencies] coverage = {version = ">=7.10.6", extras = ["toml"]} pluggy = ">=1.2" pytest = ">=7" [package.extras] testing = ["process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "python-dateutil" version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" groups = ["dev"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, ] [package.dependencies] six = ">=1.5" [[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.32.4" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" groups = ["docs"] files = [ {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, ] [package.dependencies] certifi = ">=2017.4.17" charset_normalizer = ">=2,<4" idna = ">=2.5,<4" urllib3 = ">=1.21.1,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "rich" version = "14.0.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" groups = ["dev"] files = [ {file = "rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0"}, {file = "rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725"}, ] [package.dependencies] markdown-it-py = ">=2.2.0" pygments = ">=2.13.0,<3.0.0" [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "roman-numerals-py" version = "3.1.0" description = "Manipulate well-formed Roman numerals" optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c"}, {file = "roman_numerals_py-3.1.0.tar.gz", hash = "sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d"}, ] [package.extras] lint = ["mypy (==1.15.0)", "pyright (==1.1.394)", "ruff (==0.9.7)"] test = ["pytest (>=8)"] [[package]] name = "six" version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" groups = ["dev"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, ] [[package]] name = "sniffio" version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" groups = ["docs"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] [[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 = "soupsieve" version = "2.7" description = "A modern CSS selector implementation for Beautiful Soup." optional = false python-versions = ">=3.8" groups = ["docs"] files = [ {file = "soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4"}, {file = "soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a"}, ] [[package]] name = "sphinx" version = "8.2.3" description = "Python documentation generator" optional = false python-versions = ">=3.11" groups = ["docs"] files = [ {file = "sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3"}, {file = "sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348"}, ] [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" roman-numerals-py = ">=1.0.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" [package.extras] docs = ["sphinxcontrib-websupport"] lint = ["betterproto (==2.0.0b6)", "mypy (==1.15.0)", "pypi-attestations (==0.0.21)", "pyright (==1.1.395)", "pytest (>=8.0)", "ruff (==0.9.9)", "sphinx-lint (>=0.9)", "types-Pillow (==10.2.0.20240822)", "types-Pygments (==2.19.0.20250219)", "types-colorama (==0.4.15.20240311)", "types-defusedxml (==0.7.0.20240218)", "types-docutils (==0.21.0.20241128)", "types-requests (==2.32.0.20241016)", "types-urllib3 (==1.26.25.14)"] test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=8.0)", "pytest-xdist[psutil] (>=3.4)", "setuptools (>=70.0)", "typing_extensions (>=4.9)"] [[package]] name = "sphinx-autobuild" version = "2025.8.25" description = "Rebuild Sphinx documentation on changes, with hot reloading in the browser." optional = false python-versions = ">=3.11" groups = ["docs"] files = [ {file = "sphinx_autobuild-2025.8.25-py3-none-any.whl", hash = "sha256:b750ac7d5a18603e4665294323fd20f6dcc0a984117026d1986704fa68f0379a"}, {file = "sphinx_autobuild-2025.8.25.tar.gz", hash = "sha256:9cf5aab32853c8c31af572e4fecdc09c997e2b8be5a07daf2a389e270e85b213"}, ] [package.dependencies] colorama = ">=0.4.6" Sphinx = "*" starlette = ">=0.35" uvicorn = ">=0.25" watchfiles = ">=0.20" websockets = ">=11" [package.extras] test = ["httpx", "pytest (>=6)"] [[package]] name = "sphinx-basic-ng" version = "1.0.0b2" description = "A modern skeleton for Sphinx themes." optional = false python-versions = ">=3.7" groups = ["docs"] files = [ {file = "sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b"}, {file = "sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9"}, ] [package.dependencies] sphinx = ">=4.0" [package.extras] docs = ["furo", "ipython", "myst-parser", "sphinx-copybutton", "sphinx-inline-tabs"] [[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-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 = "starlette" version = "0.47.2" description = "The little ASGI library that shines." optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b"}, {file = "starlette-0.47.2.tar.gz", hash = "sha256:6ae9aa5db235e4846decc1e7b79c4f346adf41e9777aebeb49dfd09bbd7023d8"}, ] [package.dependencies] anyio = ">=3.6.2,<5" typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} [package.extras] full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] [[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", "docs"] 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.11\" 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"] 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.5.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, ] [package.extras] brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] [[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"] 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 = "uvicorn" version = "0.35.0" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"}, {file = "uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01"}, ] [package.dependencies] click = ">=7.0" h11 = ">=0.8" [package.extras] standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] [[package]] name = "watchfiles" version = "1.1.0" description = "Simple, modern and high performance file watching and code reload in python." optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "watchfiles-1.1.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:27f30e14aa1c1e91cb653f03a63445739919aef84c8d2517997a83155e7a2fcc"}, {file = "watchfiles-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3366f56c272232860ab45c77c3ca7b74ee819c8e1f6f35a7125556b198bbc6df"}, {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8412eacef34cae2836d891836a7fff7b754d6bcac61f6c12ba5ca9bc7e427b68"}, {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df670918eb7dd719642e05979fc84704af913d563fd17ed636f7c4783003fdcc"}, {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7642b9bc4827b5518ebdb3b82698ada8c14c7661ddec5fe719f3e56ccd13c97"}, {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:199207b2d3eeaeb80ef4411875a6243d9ad8bc35b07fc42daa6b801cc39cc41c"}, {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a479466da6db5c1e8754caee6c262cd373e6e6c363172d74394f4bff3d84d7b5"}, {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:935f9edd022ec13e447e5723a7d14456c8af254544cefbc533f6dd276c9aa0d9"}, {file = "watchfiles-1.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8076a5769d6bdf5f673a19d51da05fc79e2bbf25e9fe755c47595785c06a8c72"}, {file = "watchfiles-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:86b1e28d4c37e89220e924305cd9f82866bb0ace666943a6e4196c5df4d58dcc"}, {file = "watchfiles-1.1.0-cp310-cp310-win32.whl", hash = "sha256:d1caf40c1c657b27858f9774d5c0e232089bca9cb8ee17ce7478c6e9264d2587"}, {file = "watchfiles-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:a89c75a5b9bc329131115a409d0acc16e8da8dfd5867ba59f1dd66ae7ea8fa82"}, {file = "watchfiles-1.1.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c9649dfc57cc1f9835551deb17689e8d44666315f2e82d337b9f07bd76ae3aa2"}, {file = "watchfiles-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:406520216186b99374cdb58bc48e34bb74535adec160c8459894884c983a149c"}, {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb45350fd1dc75cd68d3d72c47f5b513cb0578da716df5fba02fff31c69d5f2d"}, {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11ee4444250fcbeb47459a877e5e80ed994ce8e8d20283857fc128be1715dac7"}, {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bda8136e6a80bdea23e5e74e09df0362744d24ffb8cd59c4a95a6ce3d142f79c"}, {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b915daeb2d8c1f5cee4b970f2e2c988ce6514aace3c9296e58dd64dc9aa5d575"}, {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed8fc66786de8d0376f9f913c09e963c66e90ced9aa11997f93bdb30f7c872a8"}, {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe4371595edf78c41ef8ac8df20df3943e13defd0efcb732b2e393b5a8a7a71f"}, {file = "watchfiles-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b7c5f6fe273291f4d414d55b2c80d33c457b8a42677ad14b4b47ff025d0893e4"}, {file = "watchfiles-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7738027989881e70e3723c75921f1efa45225084228788fc59ea8c6d732eb30d"}, {file = "watchfiles-1.1.0-cp311-cp311-win32.whl", hash = "sha256:622d6b2c06be19f6e89b1d951485a232e3b59618def88dbeda575ed8f0d8dbf2"}, {file = "watchfiles-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:48aa25e5992b61debc908a61ab4d3f216b64f44fdaa71eb082d8b2de846b7d12"}, {file = "watchfiles-1.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:00645eb79a3faa70d9cb15c8d4187bb72970b2470e938670240c7998dad9f13a"}, {file = "watchfiles-1.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9dc001c3e10de4725c749d4c2f2bdc6ae24de5a88a339c4bce32300a31ede179"}, {file = "watchfiles-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9ba68ec283153dead62cbe81872d28e053745f12335d037de9cbd14bd1877f5"}, {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130fc497b8ee68dce163e4254d9b0356411d1490e868bd8790028bc46c5cc297"}, {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50a51a90610d0845a5931a780d8e51d7bd7f309ebc25132ba975aca016b576a0"}, {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc44678a72ac0910bac46fa6a0de6af9ba1355669b3dfaf1ce5f05ca7a74364e"}, {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a543492513a93b001975ae283a51f4b67973662a375a403ae82f420d2c7205ee"}, {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ac164e20d17cc285f2b94dc31c384bc3aa3dd5e7490473b3db043dd70fbccfd"}, {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7590d5a455321e53857892ab8879dce62d1f4b04748769f5adf2e707afb9d4f"}, {file = "watchfiles-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:37d3d3f7defb13f62ece99e9be912afe9dd8a0077b7c45ee5a57c74811d581a4"}, {file = "watchfiles-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7080c4bb3efd70a07b1cc2df99a7aa51d98685be56be6038c3169199d0a1c69f"}, {file = "watchfiles-1.1.0-cp312-cp312-win32.whl", hash = "sha256:cbcf8630ef4afb05dc30107bfa17f16c0896bb30ee48fc24bf64c1f970f3b1fd"}, {file = "watchfiles-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:cbd949bdd87567b0ad183d7676feb98136cde5bb9025403794a4c0db28ed3a47"}, {file = "watchfiles-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0a7d40b77f07be87c6faa93d0951a0fcd8cbca1ddff60a1b65d741bac6f3a9f6"}, {file = "watchfiles-1.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30"}, {file = "watchfiles-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a"}, {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc"}, {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b"}, {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895"}, {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a"}, {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b"}, {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c"}, {file = "watchfiles-1.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b"}, {file = "watchfiles-1.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb"}, {file = "watchfiles-1.1.0-cp313-cp313-win32.whl", hash = "sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9"}, {file = "watchfiles-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7"}, {file = "watchfiles-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5"}, {file = "watchfiles-1.1.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1"}, {file = "watchfiles-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339"}, {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633"}, {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011"}, {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670"}, {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf"}, {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4"}, {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20"}, {file = "watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef"}, {file = "watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb"}, {file = "watchfiles-1.1.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9974d2f7dc561cce3bb88dfa8eb309dab64c729de85fba32e98d75cf24b66297"}, {file = "watchfiles-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c68e9f1fcb4d43798ad8814c4c1b61547b014b667216cb754e606bfade587018"}, {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95ab1594377effac17110e1352989bdd7bdfca9ff0e5eeccd8c69c5389b826d0"}, {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fba9b62da882c1be1280a7584ec4515d0a6006a94d6e5819730ec2eab60ffe12"}, {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3434e401f3ce0ed6b42569128b3d1e3af773d7ec18751b918b89cd49c14eaafb"}, {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa257a4d0d21fcbca5b5fcba9dca5a78011cb93c0323fb8855c6d2dfbc76eb77"}, {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd1b3879a578a8ec2076c7961076df540b9af317123f84569f5a9ddee64ce92"}, {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62cc7a30eeb0e20ecc5f4bd113cd69dcdb745a07c68c0370cea919f373f65d9e"}, {file = "watchfiles-1.1.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:891c69e027748b4a73847335d208e374ce54ca3c335907d381fde4e41661b13b"}, {file = "watchfiles-1.1.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:12fe8eaffaf0faa7906895b4f8bb88264035b3f0243275e0bf24af0436b27259"}, {file = "watchfiles-1.1.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bfe3c517c283e484843cb2e357dd57ba009cff351edf45fb455b5fbd1f45b15f"}, {file = "watchfiles-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9ccbf1f129480ed3044f540c0fdbc4ee556f7175e5ab40fe077ff6baf286d4e"}, {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba0e3255b0396cac3cc7bbace76404dd72b5438bf0d8e7cefa2f79a7f3649caa"}, {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4281cd9fce9fc0a9dbf0fc1217f39bf9cf2b4d315d9626ef1d4e87b84699e7e8"}, {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d2404af8db1329f9a3c9b79ff63e0ae7131986446901582067d9304ae8aaf7f"}, {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e78b6ed8165996013165eeabd875c5dfc19d41b54f94b40e9fff0eb3193e5e8e"}, {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:249590eb75ccc117f488e2fabd1bfa33c580e24b96f00658ad88e38844a040bb"}, {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05686b5487cfa2e2c28ff1aa370ea3e6c5accfe6435944ddea1e10d93872147"}, {file = "watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d0e10e6f8f6dc5762adee7dece33b722282e1f59aa6a55da5d493a97282fedd8"}, {file = "watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db"}, {file = "watchfiles-1.1.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:865c8e95713744cf5ae261f3067861e9da5f1370ba91fc536431e29b418676fa"}, {file = "watchfiles-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42f92befc848bb7a19658f21f3e7bae80d7d005d13891c62c2cd4d4d0abb3433"}, {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa0cc8365ab29487eb4f9979fd41b22549853389e22d5de3f134a6796e1b05a4"}, {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:90ebb429e933645f3da534c89b29b665e285048973b4d2b6946526888c3eb2c7"}, {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c588c45da9b08ab3da81d08d7987dae6d2a3badd63acdb3e206a42dbfa7cb76f"}, {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c55b0f9f68590115c25272b06e63f0824f03d4fc7d6deed43d8ad5660cabdbf"}, {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd17a1e489f02ce9117b0de3c0b1fab1c3e2eedc82311b299ee6b6faf6c23a29"}, {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da71945c9ace018d8634822f16cbc2a78323ef6c876b1d34bbf5d5222fd6a72e"}, {file = "watchfiles-1.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:51556d5004887045dba3acdd1fdf61dddea2be0a7e18048b5e853dcd37149b86"}, {file = "watchfiles-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04e4ed5d1cd3eae68c89bcc1a485a109f39f2fd8de05f705e98af6b5f1861f1f"}, {file = "watchfiles-1.1.0-cp39-cp39-win32.whl", hash = "sha256:c600e85f2ffd9f1035222b1a312aff85fd11ea39baff1d705b9b047aad2ce267"}, {file = "watchfiles-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:3aba215958d88182e8d2acba0fdaf687745180974946609119953c0e112397dc"}, {file = "watchfiles-1.1.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a6fd40bbb50d24976eb275ccb55cd1951dfb63dbc27cae3066a6ca5f4beabd5"}, {file = "watchfiles-1.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9f811079d2f9795b5d48b55a37aa7773680a5659afe34b54cc1d86590a51507d"}, {file = "watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2726d7bfd9f76158c84c10a409b77a320426540df8c35be172444394b17f7ea"}, {file = "watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df32d59cb9780f66d165a9a7a26f19df2c7d24e3bd58713108b41d0ff4f929c6"}, {file = "watchfiles-1.1.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0ece16b563b17ab26eaa2d52230c9a7ae46cf01759621f4fbbca280e438267b3"}, {file = "watchfiles-1.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:51b81e55d40c4b4aa8658427a3ee7ea847c591ae9e8b81ef94a90b668999353c"}, {file = "watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2bcdc54ea267fe72bfc7d83c041e4eb58d7d8dc6f578dfddb52f037ce62f432"}, {file = "watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:923fec6e5461c42bd7e3fd5ec37492c6f3468be0499bc0707b4bbbc16ac21792"}, {file = "watchfiles-1.1.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7b3443f4ec3ba5aa00b0e9fa90cf31d98321cbff8b925a7c7b84161619870bc9"}, {file = "watchfiles-1.1.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7049e52167fc75fc3cc418fc13d39a8e520cbb60ca08b47f6cedb85e181d2f2a"}, {file = "watchfiles-1.1.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54062ef956807ba806559b3c3d52105ae1827a0d4ab47b621b31132b6b7e2866"}, {file = "watchfiles-1.1.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a7bd57a1bb02f9d5c398c0c1675384e7ab1dd39da0ca50b7f09af45fa435277"}, {file = "watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575"}, ] [package.dependencies] anyio = ">=3.0.0" [[package]] name = "websockets" version = "15.0.1" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b"}, {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205"}, {file = "websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a"}, {file = "websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e"}, {file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf"}, {file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb"}, {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d"}, {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9"}, {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c"}, {file = "websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256"}, {file = "websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41"}, {file = "websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431"}, {file = "websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57"}, {file = "websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905"}, {file = "websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562"}, {file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792"}, {file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413"}, {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8"}, {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3"}, {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf"}, {file = "websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85"}, {file = "websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065"}, {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3"}, {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665"}, {file = "websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2"}, {file = "websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215"}, {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5"}, {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65"}, {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe"}, {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4"}, {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597"}, {file = "websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9"}, {file = "websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7"}, {file = "websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931"}, {file = "websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675"}, {file = "websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151"}, {file = "websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22"}, {file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f"}, {file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8"}, {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375"}, {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d"}, {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4"}, {file = "websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa"}, {file = "websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561"}, {file = "websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5"}, {file = "websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a"}, {file = "websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b"}, {file = "websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770"}, {file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb"}, {file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054"}, {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee"}, {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed"}, {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880"}, {file = "websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411"}, {file = "websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4"}, {file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3"}, {file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1"}, {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475"}, {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9"}, {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04"}, {file = "websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122"}, {file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940"}, {file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e"}, {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9"}, {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b"}, {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f"}, {file = "websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123"}, {file = "websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f"}, {file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"}, ] [[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-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.11,<3.14" content-hash = "7e059ac8fdc292fe66a9e24050870b4f780f9f8ac01fc0b572aca849df52f4f0" Bluetooth-Devices-habluetooth-21accde/pyproject.toml000066400000000000000000000100231507024716100230320ustar00rootroot00000000000000[build-system] requires = ['setuptools>=77.0', 'Cython>=3', "poetry-core>=2.0.0"] build-backend = "poetry.core.masonry.api" [project] name = "habluetooth" version = "5.7.0" license = "Apache-2.0" description = "High availability Bluetooth" authors = [{ name = "J. Nick Koston", email = "bluetooth@koston.org" }] readme = "README.md" requires-python = ">=3.11" [project.urls] "Repository" = "https://github.com/bluetooth-devices/habluetooth" "Documentation" = "https://habluetooth.readthedocs.io" "Bug Tracker" = "https://github.com/bluetooth-devices/habluetooth/issues" "Changelog" = "https://github.com/bluetooth-devices/habluetooth/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 = "habluetooth", from = "src" }, ] [tool.poetry.build] generate-setup-file = true script = "build_ext.py" [tool.poetry.dependencies] python = ">=3.11,<3.14" bleak = ">=1.0.1" bleak-retry-connector = ">=4.2.0" bluetooth-data-tools = ">=1.28.0" bluetooth-adapters = ">=2.1.0" bluetooth-auto-recovery = ">=1.5.1" async-interrupt = ">=1.1.1" dbus-fast = { version = ">=2.30.2", markers = "platform_system == 'Linux'" } btsocket = ">=0.3.0" [tool.poetry.group.dev.dependencies] pytest = ">=7,<9" pytest-cov = ">=3,<8" pytest-asyncio = ">=0.23.6,<1.3.0" pytest-codspeed = ">=2.2.1,<5.0.0" freezegun = "^1.5.5" dbus-fast = ">=2.30.2" [tool.poetry.group.docs] optional = true [tool.poetry.group.docs.dependencies] myst-parser = ">=0.16" sphinx = ">=4.0" furo = ">=2023.5.20" sphinx-autobuild = ">=2021.3.14" [tool.semantic_release] version_toml = ["pyproject.toml:project.version"] version_variables = [ "src/habluetooth/__init__.py:__version__", "docs/conf.py:release", ] build_command = "pip install poetry && poetry build" [tool.semantic_release.changelog] exclude_commit_patterns = [ "chore*", "ci*", ] [tool.semantic_release.changelog.environment] keep_trailing_newline = true [tool.semantic_release.branches.main] match = "main" [tool.semantic_release.branches.noop] match = "(?!main$)" prerelease = true [tool.pytest.ini_options] addopts = "-v -Wdefault --cov=habluetooth --cov-report=term-missing:skip-covered" pythonpath = ["src"] log_cli="true" log_level="NOTSET" [tool.coverage.run] branch = true [tool.coverage.report] exclude_lines = [ "pragma: no cover", "@overload", "if TYPE_CHECKING", "raise NotImplementedError", 'if __name__ == "__main__":', ] [tool.ruff] target-version = "py311" line-length = 88 [tool.ruff.lint] ignore = [ "E721", # type checks for cython "D203", # 1 blank line required before class docstring "D212", # Multi-line docstring summary should start at the first line "D100", # Missing docstring in public module "D104", # Missing docstring in public package "D107", # Missing docstring in `__init__` "D401", # First line of docstring should be in imperative mood ] select = [ "B", # flake8-bugbear "D", # flake8-docstrings "C4", # flake8-comprehensions "S", # flake8-bandit "F", # pyflake "E", # pycodestyle "W", # pycodestyle "UP", # pyupgrade "I", # isort "RUF", # ruff specific "RET", # return "SIM", # simplify ] [tool.ruff.lint.per-file-ignores] "tests/**/*" = [ "D100", "D101", "D102", "D103", "D104", "S101", ] "setup.py" = ["D100"] "conftest.py" = ["D100"] "docs/conf.py" = ["D100"] [tool.ruff.lint.isort] known-first-party = ["habluetooth", "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 Bluetooth-Devices-habluetooth-21accde/renovate.json000066400000000000000000000001011507024716100226300ustar00rootroot00000000000000{ "extends": ["github>browniebroke/renovate-configs:python"] } Bluetooth-Devices-habluetooth-21accde/src/000077500000000000000000000000001507024716100207115ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-21accde/src/habluetooth/000077500000000000000000000000001507024716100232275ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-21accde/src/habluetooth/__init__.py000066400000000000000000000046001507024716100253400ustar00rootroot00000000000000__version__ = "5.7.0" from bleak_retry_connector import Allocations from .advertisement_tracker import ( TRACKER_BUFFERING_WOBBLE_SECONDS, AdvertisementTracker, ) from .base_scanner import BaseHaRemoteScanner, BaseHaScanner from .central_manager import get_manager, set_manager from .const import ( CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, SCANNER_WATCHDOG_INTERVAL, SCANNER_WATCHDOG_TIMEOUT, UNAVAILABLE_TRACK_SECONDS, ) from .manager import BluetoothManager from .models import ( BluetoothServiceInfo, BluetoothServiceInfoBleak, HaBluetoothConnector, HaBluetoothSlotAllocations, HaScannerDetails, HaScannerModeChange, HaScannerRegistration, HaScannerRegistrationEvent, HaScannerType, ) from .scanner import BluetoothScanningMode, HaScanner, ScannerStartError from .scanner_device import BluetoothScannerDevice from .storage import ( DiscoveredDeviceAdvertisementData, DiscoveredDeviceAdvertisementDataDict, DiscoveryStorageType, discovered_device_advertisement_data_from_dict, discovered_device_advertisement_data_to_dict, expire_stale_scanner_discovered_device_advertisement_data, ) from .wrappers import HaBleakClientWrapper, HaBleakScannerWrapper __all__ = [ "CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS", "FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS", "SCANNER_WATCHDOG_INTERVAL", "SCANNER_WATCHDOG_TIMEOUT", "TRACKER_BUFFERING_WOBBLE_SECONDS", "UNAVAILABLE_TRACK_SECONDS", "AdvertisementTracker", "Allocations", "BaseHaRemoteScanner", "BaseHaScanner", "BluetoothManager", "BluetoothScannerDevice", "BluetoothScanningMode", "BluetoothServiceInfo", "BluetoothServiceInfoBleak", "DiscoveredDeviceAdvertisementData", "DiscoveredDeviceAdvertisementDataDict", "DiscoveryStorageType", "HaBleakClientWrapper", "HaBleakScannerWrapper", "HaBluetoothConnector", "HaBluetoothSlotAllocations", "HaScanner", "HaScannerDetails", "HaScannerModeChange", "HaScannerRegistration", "HaScannerRegistrationEvent", "HaScannerType", "ScannerStartError", "discovered_device_advertisement_data_from_dict", "discovered_device_advertisement_data_to_dict", "expire_stale_scanner_discovered_device_advertisement_data", "get_manager", "set_manager", ] Bluetooth-Devices-habluetooth-21accde/src/habluetooth/advertisement_tracker.pxd000066400000000000000000000007571507024716100303420ustar00rootroot00000000000000import cython from .models cimport BluetoothServiceInfoBleak cdef unsigned int _ADVERTISING_TIMES_NEEDED cdef class AdvertisementTracker: cdef public dict intervals cdef public dict fallback_intervals cdef public dict sources cdef public dict _timings @cython.locals(timings=list) cpdef void async_collect(self, BluetoothServiceInfoBleak service_info) cpdef void async_remove_address(self, object address) cpdef void async_scanner_paused(self, str source) Bluetooth-Devices-habluetooth-21accde/src/habluetooth/advertisement_tracker.py000066400000000000000000000072071507024716100301740ustar00rootroot00000000000000"""The advertisement tracker.""" from __future__ import annotations from typing import Any from .models import BluetoothServiceInfoBleak ADVERTISING_TIMES_NEEDED = 16 _ADVERTISING_TIMES_NEEDED = ADVERTISING_TIMES_NEEDED # Each scanner may buffer incoming packets so # we need to give a bit of leeway before we # mark a device unavailable TRACKER_BUFFERING_WOBBLE_SECONDS = 5 _str = str class AdvertisementTracker: """Tracker to determine the interval that a device is advertising.""" __slots__ = ("_timings", "fallback_intervals", "intervals", "sources") def __init__(self) -> None: """Initialize the tracker.""" self.intervals: dict[str, float] = {} self.fallback_intervals: dict[str, float] = {} self.sources: dict[str, str] = {} self._timings: dict[str, list[float]] = {} def async_diagnostics(self) -> dict[str, dict[str, Any]]: """Return diagnostics.""" return { "intervals": self.intervals, "fallback_intervals": self.fallback_intervals, "sources": self.sources, "timings": self._timings, } def async_collect(self, service_info: BluetoothServiceInfoBleak) -> None: """ Collect timings for the tracker. For performance reasons, it is the responsibility of the caller to check if the device already has an interval set or the source has changed before calling this function. """ self.sources[service_info.address] = service_info.source if not (timings := self._timings.get(service_info.address)): self._timings[service_info.address] = [service_info.time] return timings.append(service_info.time) if len(timings) != _ADVERTISING_TIMES_NEEDED: return max_time_between_advertisements = timings[1] - timings[0] for i in range(2, len(timings)): time_between_advertisements = timings[i] - timings[i - 1] if time_between_advertisements > max_time_between_advertisements: max_time_between_advertisements = time_between_advertisements # We now know the maximum time between advertisements self.intervals[service_info.address] = max_time_between_advertisements del self._timings[service_info.address] def async_remove_address(self, address: _str) -> None: """Remove the tracker.""" self.intervals.pop(address, None) self.sources.pop(address, None) self._timings.pop(address, None) def async_remove_fallback_interval(self, address: str) -> None: """Remove fallback interval.""" self.fallback_intervals.pop(address, None) def async_remove_source(self, source: str) -> None: """Remove the tracker.""" for address, tracked_source in list(self.sources.items()): if tracked_source == source: self.async_remove_address(address) def async_scanner_paused(self, source: str) -> None: """ Clear timing collection data when scanner is paused. When a scanner pauses to establish a connection, it stops listening for advertisements. If we don't clear the timing data, the next advertisement after the connection attempt will create an incorrectly large interval measurement (time_after_connection - time_before_connection) which doesn't represent the actual advertising interval of the device. """ # Only iterate through timing data (typically much smaller than sources) for address in list(self._timings): if self.sources.get(address) == source: del self._timings[address] Bluetooth-Devices-habluetooth-21accde/src/habluetooth/base_scanner.pxd000066400000000000000000000073221507024716100263730ustar00rootroot00000000000000 import cython from .models cimport BluetoothServiceInfoBleak from .manager cimport BluetoothManager cdef object parse_advertisement_data_bytes cdef object NO_RSSI_VALUE cdef object BluetoothServiceInfoBleak cdef object AdvertisementData cdef object BLEDevice cdef bint TYPE_CHECKING cdef class BaseHaScanner: cdef public str adapter cdef public bint connectable cdef public str source cdef public object connector cdef public unsigned int _connecting cdef public str name cdef public bint scanning cdef public double _last_detection cdef public object _start_time cdef public object _cancel_watchdog cdef public object _loop cdef BluetoothManager _manager cdef public object details cdef public object current_mode cdef public object requested_mode cdef public dict _previous_service_info cdef public double _expire_seconds cdef public dict _details cdef public object _cancel_track cdef public dict _connect_failures cdef public dict _connect_in_progress cpdef void _clear_connection_history(self) except * cpdef void _finished_connecting(self, str address, bint connected) except * cdef void _increase_count(self, dict target, str address) except * cdef void _add_connect_failure(self, str address) except * cpdef void _add_connecting(self, str address) except * cdef void _remove_connecting(self, str address) except * cdef void _clear_connect_failure(self, str address) except * @cython.locals( in_progress=Py_ssize_t, count=Py_ssize_t ) cpdef _connections_in_progress(self) cpdef _connection_failures(self, str address) @cython.locals( score=double, scanner_connections_in_progress=Py_ssize_t, previous_failures=Py_ssize_t ) cpdef _score_connection_paths(self, int rssi_diff, object scanner_device) cpdef tuple get_discovered_device_advertisement_data(self, str address) cpdef float time_since_last_detection(self) @cython.locals(info=BluetoothServiceInfoBleak) cdef dict _build_discovered_device_advertisement_datas(self) @cython.locals(info=BluetoothServiceInfoBleak) cdef dict _build_discovered_device_timestamps(self) @cython.locals(parsed=tuple) cpdef void _async_on_raw_advertisement( self, str address, int rssi, bytes raw, dict details, double advertisement_monotonic_time ) @cython.locals( prev_name=str, prev_discovery=tuple, has_local_name=bint, has_manufacturer_data=bint, has_service_data=bint, has_service_uuids=bint, sub_value=bytes, super_value=bytes, info=BluetoothServiceInfoBleak, prev_info=BluetoothServiceInfoBleak ) cdef void _async_on_advertisement_internal( self, str address, int rssi, str local_name, list service_uuids, dict service_data, dict manufacturer_data, object tx_power, dict details, double advertisement_monotonic_time, bytes raw ) cpdef void _async_on_advertisement( self, str address, int rssi, str local_name, list service_uuids, dict service_data, dict manufacturer_data, object tx_power, dict details, double advertisement_monotonic_time ) @cython.locals(now=double, timestamp=double, info=BluetoothServiceInfoBleak) cpdef void _async_expire_devices(self) cpdef void _schedule_expire_devices(self) cdef class BaseHaRemoteScanner(BaseHaScanner): @cython.locals(info=BluetoothServiceInfoBleak) cpdef tuple get_discovered_device_advertisement_data(self, str address) Bluetooth-Devices-habluetooth-21accde/src/habluetooth/base_scanner.py000066400000000000000000000645111507024716100262330ustar00rootroot00000000000000"""Base classes for HA Bluetooth scanners for bluetooth.""" from __future__ import annotations import asyncio import logging import warnings from collections.abc import Generator, Iterable from contextlib import contextmanager from typing import TYPE_CHECKING, Any, Final, final from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from bleak_retry_connector import NO_RSSI_VALUE, Allocations from bluetooth_adapters import adapter_human_name from bluetooth_data_tools import monotonic_time_coarse, parse_advertisement_data_bytes from .central_manager import get_manager from .const import ( CALLBACK_TYPE, CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, SCANNER_WATCHDOG_INTERVAL, SCANNER_WATCHDOG_TIMEOUT, ) from .models import ( BluetoothScanningMode, BluetoothServiceInfoBleak, HaBluetoothConnector, HaScannerDetails, HaScannerType, ) from .scanner_device import BluetoothScannerDevice from .storage import DiscoveredDeviceAdvertisementData SCANNER_WATCHDOG_INTERVAL_SECONDS: Final = SCANNER_WATCHDOG_INTERVAL.total_seconds() _LOGGER = logging.getLogger(__name__) _bytes = bytes _float = float _int = int _str = str class BaseHaScanner: """Base class for high availability BLE scanners.""" __slots__ = ( "_cancel_track", "_cancel_watchdog", "_connect_failures", "_connect_in_progress", "_connecting", "_details", "_expire_seconds", "_last_detection", "_loop", "_manager", "_previous_service_info", "_start_time", "adapter", "connectable", "connector", "current_mode", "details", "name", "requested_mode", "scanning", "source", ) def __init__( self, source: str, adapter: str, connector: HaBluetoothConnector | None = None, connectable: bool = False, requested_mode: BluetoothScanningMode | None = None, current_mode: BluetoothScanningMode | None = None, ) -> None: """Initialize the scanner.""" self.connectable = connectable self.source = source self.connector = connector self._connecting = 0 self.adapter = adapter self.name = adapter_human_name(adapter, source) if adapter != source else source self.scanning: bool = True self.requested_mode = requested_mode self.current_mode = current_mode self._last_detection = 0.0 self._start_time = 0.0 self._cancel_watchdog: asyncio.TimerHandle | None = None self._loop: asyncio.AbstractEventLoop | None = None self._manager = get_manager() # Determine scanner type based on class type scanner_type = HaScannerType.UNKNOWN if isinstance(self, BaseHaRemoteScanner): scanner_type = HaScannerType.REMOTE # Try to get adapter type from manager's cached adapters elif ( (adapters := self._manager.get_cached_bluetooth_adapters()) and (adapter_details := adapters.get(adapter)) and (adapter_type := adapter_details.get("adapter_type")) ): if adapter_type == "usb": scanner_type = HaScannerType.USB elif adapter_type == "uart": scanner_type = HaScannerType.UART self.details = HaScannerDetails( source=self.source, connectable=self.connectable, name=self.name, adapter=self.adapter, scanner_type=scanner_type, ) self._previous_service_info: dict[str, BluetoothServiceInfoBleak] = {} # Scanners only care about connectable devices. The manager # will handle taking care of availability for non-connectable devices self._expire_seconds = CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS self._details: dict[str, str | HaBluetoothConnector] = {"source": source} self._cancel_track: asyncio.TimerHandle | None = None self._connect_failures: dict[str, int] = {} self._connect_in_progress: dict[str, int] = {} def _on_start_success(self) -> None: """ Called when the scanner successfully starts. Notifies the manager that this scanner has started. """ if self._manager: self._manager.on_scanner_start(self) def _clear_connection_history(self) -> None: """Clear the connection history for a scanner.""" self._connect_failures.clear() self._connect_in_progress.clear() def _finished_connecting(self, address: str, connected: bool) -> None: """Finished connecting.""" self._remove_connecting(address) if connected: self._clear_connect_failure(address) else: self._add_connect_failure(address) def _increase_count(self, target: dict[str, int], address: str) -> None: """Increase the reference count.""" if address in target: target[address] += 1 else: target[address] = 1 def _add_connect_failure(self, address: str) -> None: """Add a connect failure.""" self._increase_count(self._connect_failures, address) def _add_connecting(self, address: str) -> None: """Add a connecting.""" self._increase_count(self._connect_in_progress, address) # Clear timing collection data when scanner pauses for connection # to prevent collecting invalid advertising interval data self._manager._advertisement_tracker.async_scanner_paused(self.source) def _remove_connecting(self, address: str) -> None: """Remove a connecting.""" if address not in self._connect_in_progress: _LOGGER.warning( "Removing a non-existing connecting %s %s", self.name, address ) return self._connect_in_progress[address] -= 1 if not self._connect_in_progress[address]: del self._connect_in_progress[address] def _clear_connect_failure(self, address: str) -> None: """Clear a connect failure.""" self._connect_failures.pop(address, None) def get_allocations(self) -> Allocations | None: """ Get current connection slot allocations for this scanner. Returns: Allocations object with free/limit/allocated info, or None if not available. Note: Subclasses should override this method to provide their allocation info. For local adapters, this will be overridden in HaScanner to query BleakSlotManager. For remote scanners, they should override to return their own tracking. """ return None def _score_connection_paths( self, rssi_diff: _int, scanner_device: BluetoothScannerDevice ) -> float: """Score the connection paths considering slot availability.""" address = scanner_device.ble_device.address score = scanner_device.advertisement.rssi or NO_RSSI_VALUE scanner_connections_in_progress = len(self._connect_in_progress) previous_failures = self._connect_failures.get(address, 0) # Use a minimum rssi_diff of 1 to ensure penalties are meaningful # even when scanners have identical RSSI effective_rssi_diff = max(rssi_diff, 1) # Penalize scanners with connections in progress if scanner_connections_in_progress: # Very large penalty for multiple connections in progress # to avoid overloading the adapter score -= effective_rssi_diff * scanner_connections_in_progress * 1.01 # Penalize based on previous failures if previous_failures: score -= effective_rssi_diff * previous_failures * 0.51 # Consider connection slot availability allocation = self.get_allocations() if allocation and allocation.slots > 0: if allocation.free == 0: # No slots available - return NO_RSSI_VALUE to indicate unavailable return NO_RSSI_VALUE if allocation.free == 1: # Last slot available - small penalty to prefer adapters with more slots score -= effective_rssi_diff * 0.76 return score def _connections_in_progress(self) -> int: """Return if the connection is in progress.""" in_progress = 0 for count in self._connect_in_progress.values(): in_progress += count return in_progress def _connection_failures(self, address: str) -> int: """Return the number of failures.""" return self._connect_failures.get(address, 0) def time_since_last_detection(self) -> float: """Return the time since the last detection.""" return monotonic_time_coarse() - self._last_detection @property def adapter_idx(self) -> int | None: """Return the adapter index if this is an hci adapter, None otherwise.""" if self.adapter and self.adapter.startswith("hci"): return int(self.adapter.removeprefix("hci")) return None def async_setup(self) -> CALLBACK_TYPE: """Set up the scanner.""" self._loop = asyncio.get_running_loop() self._schedule_expire_devices() return self._unsetup def _async_stop_scanner_watchdog(self) -> None: """Stop the scanner watchdog.""" if self._cancel_watchdog: self._cancel_watchdog.cancel() self._cancel_watchdog = None def _async_setup_scanner_watchdog(self) -> None: """If something has restarted or updated, we need to restart the scanner.""" self._start_time = self._last_detection = monotonic_time_coarse() if not self._cancel_watchdog: self._schedule_watchdog() def _schedule_watchdog(self) -> None: """Schedule the watchdog.""" loop = self._loop if TYPE_CHECKING: assert loop is not None self._cancel_watchdog = loop.call_at( loop.time() + SCANNER_WATCHDOG_INTERVAL_SECONDS, self._async_call_scanner_watchdog, ) @final def _async_call_scanner_watchdog(self) -> None: """Call the scanner watchdog and schedule the next one.""" self._async_scanner_watchdog() self._schedule_watchdog() def _async_watchdog_triggered(self) -> bool: """Check if the watchdog has been triggered.""" time_since_last_detection = self.time_since_last_detection() _LOGGER.debug( "%s: Scanner watchdog time_since_last_detection: %s", self.name, time_since_last_detection, ) return time_since_last_detection > SCANNER_WATCHDOG_TIMEOUT def _async_scanner_watchdog(self) -> None: """ Check if the scanner is running. Override this method if you need to do something else when the watchdog is triggered. """ if self._async_watchdog_triggered(): _LOGGER.debug( ( "%s: Bluetooth scanner has gone quiet for %ss, check logs on the" " scanner device for more information" ), self.name, self.time_since_last_detection(), ) self.scanning = False return self.scanning = not self._connecting def _unsetup(self) -> None: """Unset up the scanner.""" self._cancel_expire_devices() @contextmanager def connecting(self) -> Generator[None, None, None]: """Context manager to track connecting state.""" self._connecting += 1 self.scanning = not self._connecting try: yield finally: self._connecting -= 1 self.scanning = not self._connecting @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" raise NotImplementedError @property def discovered_devices_and_advertisement_data( self, ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: """Return a list of discovered devices and their advertisement data.""" raise NotImplementedError @property def discovered_addresses(self) -> Iterable[str]: """Return an iterable of discovered devices.""" raise NotImplementedError def get_discovered_device_advertisement_data( self, address: str ) -> tuple[BLEDevice, AdvertisementData] | None: """Return the advertisement data for a discovered device.""" raise NotImplementedError async def async_diagnostics(self) -> dict[str, Any]: """Return diagnostic information about the scanner.""" device_adv_datas = self.discovered_devices_and_advertisement_data.values() return { "name": self.name, "connectable": self.connectable, "start_time": self._start_time, "source": self.source, "scanning": self.scanning, "requested_mode": self.requested_mode, "current_mode": self.current_mode, "type": self.__class__.__name__, "last_detection": self._last_detection, "monotonic_time": monotonic_time_coarse(), "discovered_devices_and_advertisement_data": [ { "name": device.name, "address": device.address, "rssi": advertisement_data.rssi, "advertisement_data": advertisement_data, "details": device.details, } for device, advertisement_data in device_adv_datas ], } def restore_discovered_devices( self, history: DiscoveredDeviceAdvertisementData ) -> None: """Restore discovered devices from a previous run.""" discovered_device_timestamps = history.discovered_device_timestamps self._previous_service_info = { address: BluetoothServiceInfoBleak( device.name or address, address, adv.rssi, adv.manufacturer_data, adv.service_data, adv.service_uuids, self.source, device, adv, self.connectable, discovered_device_timestamps[address], adv.tx_power, history.discovered_device_raw.get(address), ) for address, ( device, adv, ) in history.discovered_device_advertisement_datas.items() } # Expire anything that is too old self._async_expire_devices() def serialize_discovered_devices( self, ) -> DiscoveredDeviceAdvertisementData: """Serialize discovered devices to be stored.""" return DiscoveredDeviceAdvertisementData( self.connectable, self._expire_seconds, self._build_discovered_device_advertisement_datas(), self._build_discovered_device_timestamps(), self._build_discovered_device_raw(), ) @property def _discovered_device_timestamps(self) -> dict[str, float]: """Return a dict of discovered device timestamps.""" warnings.warn( "BaseHaRemoteScanner._discovered_device_timestamps is deprecated " "and will be removed in a future version of habluetooth, use " "BaseHaRemoteScanner.discovered_device_timestamps instead", FutureWarning, stacklevel=2, ) return self._build_discovered_device_timestamps() @property def discovered_device_timestamps(self) -> dict[str, float]: """Return a dict of discovered device timestamps.""" return self._build_discovered_device_timestamps() def _build_discovered_device_advertisement_datas( self, ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: """Return a list of discovered devices and advertisement data.""" return { address: (info.device, info._advertisement_internal()) for address, info in self._previous_service_info.items() } def _build_discovered_device_timestamps(self) -> dict[str, float]: """Return a dict of discovered device timestamps.""" return { address: info.time for address, info in self._previous_service_info.items() } def _build_discovered_device_raw(self) -> dict[str, bytes | None]: """Return a dict of discovered device raw advertisement data.""" return { address: info.raw for address, info in self._previous_service_info.items() } def _async_on_raw_advertisement( self, address: _str, rssi: _int, raw: _bytes, details: dict[str, Any], advertisement_monotonic_time: _float, ) -> None: parsed = parse_advertisement_data_bytes(raw) self._async_on_advertisement_internal( address, rssi, parsed[0], parsed[1], parsed[2], parsed[3], parsed[4], details, advertisement_monotonic_time, raw, ) def _async_on_advertisement( self, address: _str, rssi: _int, local_name: _str | None, service_uuids: list[str], service_data: dict[str, bytes], manufacturer_data: dict[int, bytes], tx_power: _int | None, details: dict[Any, Any], advertisement_monotonic_time: _float, ) -> None: self._async_on_advertisement_internal( address, rssi, local_name, service_uuids, service_data, manufacturer_data, tx_power, details, advertisement_monotonic_time, None, ) def _async_on_advertisement_internal( self, address: _str, rssi: _int, local_name: _str | None, service_uuids: list[str], service_data: dict[str, bytes], manufacturer_data: dict[int, bytes], tx_power: _int | None, details: dict[Any, Any], advertisement_monotonic_time: _float, raw: _bytes | None, ) -> None: """Call the registered callback.""" self.scanning = not self._connecting self._last_detection = advertisement_monotonic_time info = BluetoothServiceInfoBleak.__new__(BluetoothServiceInfoBleak) if (prev_info := self._previous_service_info.get(address)) is None: # We expect this is the rare case and since py3.11+ has # near zero cost try on success, and we can avoid .get() # which is slower than [] we use the try/except pattern. info.device = BLEDevice( address, local_name, {**self._details, **details}, ) info.manufacturer_data = manufacturer_data info.service_data = service_data info.service_uuids = service_uuids info.name = local_name or address else: # Merge the new data with the old data # to function the same as BlueZ which # merges the dicts on PropertiesChanged info.device = prev_info.device prev_name = prev_info.device.name # # Bleak updates the BLEDevice via create_or_update_device. # We need to do the same to ensure integrations that already # have the BLEDevice object get the updated details when they # change. # # https://github.com/hbldh/bleak/blob/222618b7747f0467dbb32bd3679f8cfaa19b1668/bleak/backends/scanner.py#L203 if prev_name is not None and ( prev_name is local_name or not local_name or len(prev_name) > len(local_name) ): info.name = prev_name else: info.device.name = local_name info.name = local_name if local_name else address has_service_uuids = bool(service_uuids) if ( has_service_uuids and service_uuids is not prev_info.service_uuids and service_uuids != prev_info.service_uuids ): info.service_uuids = list({*service_uuids, *prev_info.service_uuids}) elif not has_service_uuids: info.service_uuids = prev_info.service_uuids else: info.service_uuids = service_uuids has_service_data = bool(service_data) if has_service_data and service_data is not prev_info.service_data: for uuid, sub_value in service_data.items(): if ( super_value := prev_info.service_data.get(uuid) ) is None or super_value != sub_value: info.service_data = { **prev_info.service_data, **service_data, } break else: info.service_data = prev_info.service_data elif not has_service_data: info.service_data = prev_info.service_data else: info.service_data = service_data has_manufacturer_data = bool(manufacturer_data) if ( has_manufacturer_data and manufacturer_data is not prev_info.manufacturer_data ): for id_, sub_value in manufacturer_data.items(): if ( super_value := prev_info.manufacturer_data.get(id_) ) is None or super_value != sub_value: info.manufacturer_data = { **prev_info.manufacturer_data, **manufacturer_data, } break else: info.manufacturer_data = prev_info.manufacturer_data elif not has_manufacturer_data: info.manufacturer_data = prev_info.manufacturer_data else: info.manufacturer_data = manufacturer_data info.address = address info.rssi = rssi info.source = self.source info._advertisement = None info.connectable = self.connectable info.time = advertisement_monotonic_time info.tx_power = tx_power info.raw = raw self._previous_service_info[address] = info self._manager.scanner_adv_received(info) def _async_expire_devices(self) -> None: """Expire old devices.""" now = monotonic_time_coarse() expired = [ address for address, info in self._previous_service_info.items() if now - info.time > self._expire_seconds ] for address in expired: del self._previous_service_info[address] def _cancel_expire_devices(self) -> None: """Cancel the expiration of old devices.""" if self._cancel_track: self._cancel_track.cancel() self._cancel_track = None def _schedule_expire_devices(self) -> None: """Schedule the expiration of old devices.""" loop = self._loop if TYPE_CHECKING: assert loop is not None self._cancel_expire_devices() self._cancel_track = loop.call_at( loop.time() + 30, self._async_expire_devices_schedule_next ) def _async_expire_devices_schedule_next(self) -> None: """Expire old devices and schedule the next expiration.""" self._async_expire_devices() self._schedule_expire_devices() def set_requested_mode(self, mode: BluetoothScanningMode | None) -> None: """Set the requested scanning mode and notify the manager.""" if self.requested_mode != mode: self.requested_mode = mode self._manager.scanner_mode_changed(self) def set_current_mode(self, mode: BluetoothScanningMode | None) -> None: """Set the current scanning mode and notify the manager.""" if self.current_mode != mode: self.current_mode = mode self._manager.scanner_mode_changed(self) class BaseHaRemoteScanner(BaseHaScanner): """Base class for a high availability remote BLE scanner.""" def _unsetup(self) -> None: """Unset up the scanner.""" super()._unsetup() self._async_stop_scanner_watchdog() def async_setup(self) -> CALLBACK_TYPE: """Set up the scanner.""" super().async_setup() self._async_setup_scanner_watchdog() return self._unsetup @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" infos = self._previous_service_info.values() return [device_advertisement_data.device for device_advertisement_data in infos] @property def discovered_devices_and_advertisement_data( self, ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: """Return a list of discovered devices and advertisement data.""" return self._build_discovered_device_advertisement_datas() @property def discovered_addresses(self) -> Iterable[str]: """Return an iterable of discovered devices.""" return self._previous_service_info def get_discovered_device_advertisement_data( self, address: str ) -> tuple[BLEDevice, AdvertisementData] | None: """Return the advertisement data for a discovered device.""" if (info := self._previous_service_info.get(address)) is not None: return info.device, info.advertisement return None async def async_diagnostics(self) -> dict[str, Any]: """Return diagnostic information about the scanner.""" now = monotonic_time_coarse() discovered_device_timestamps = self._build_discovered_device_timestamps() return await super().async_diagnostics() | { "discovered_device_timestamps": discovered_device_timestamps, "raw_advertisement_data": { address: info.raw for address, info in self._previous_service_info.items() }, "time_since_last_device_detection": { address: now - timestamp for address, timestamp in discovered_device_timestamps.items() }, } Bluetooth-Devices-habluetooth-21accde/src/habluetooth/central_manager.py000066400000000000000000000012131507024716100267200ustar00rootroot00000000000000"""Central manager for bluetooth.""" from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: from .manager import BluetoothManager class CentralBluetoothManager: """Central Bluetooth Manager.""" manager: BluetoothManager | None = None def get_manager() -> BluetoothManager: """Get the BluetoothManager.""" if CentralBluetoothManager.manager is None: raise RuntimeError("BluetoothManager has not been set") return CentralBluetoothManager.manager def set_manager(manager: BluetoothManager) -> None: """Set the BluetoothManager.""" CentralBluetoothManager.manager = manager Bluetooth-Devices-habluetooth-21accde/src/habluetooth/channels/000077500000000000000000000000001507024716100250225ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-21accde/src/habluetooth/channels/bluez.pxd000066400000000000000000000027521507024716100266660ustar00rootroot00000000000000 import cython from ..scanner cimport HaScanner cdef bint TYPE_CHECKING cdef unsigned short DEVICE_FOUND cdef unsigned short ADV_MONITOR_DEVICE_FOUND cdef unsigned short MGMT_OP_GET_CONNECTIONS cdef unsigned short MGMT_OP_LOAD_CONN_PARAM cdef unsigned short MGMT_EV_CMD_COMPLETE cdef unsigned short MGMT_EV_CMD_STATUS cdef class BluetoothMGMTProtocol: cdef public object transport cdef object connection_made_future cdef bytes _buffer cdef unsigned int _buffer_len cdef unsigned int _pos cdef dict _scanners cdef object _on_connection_lost cdef object _is_shutting_down cdef dict _pending_commands @cython.locals(bytes_data=bytes) cdef void _add_to_buffer(self, object data) except * @cython.locals(end_of_frame_pos="unsigned int", cstr="const unsigned char *") cdef void _remove_from_buffer(self) except * @cython.locals( header="const unsigned char *", event_code="unsigned short", controller_idx="unsigned short", param_len="unsigned short", rssi="short", flags="unsigned int", data="bytes", parse_offset="unsigned short", scanner=HaScanner, opcode="unsigned short", status="unsigned char", param_offset="unsigned short", param_count="unsigned short" ) cpdef void data_received(self, object data) except * cdef void _handle_load_conn_param_response( self, unsigned char status, unsigned short controller_idx ) except * Bluetooth-Devices-habluetooth-21accde/src/habluetooth/channels/bluez.py000066400000000000000000000510471507024716100265240ustar00rootroot00000000000000from __future__ import annotations import asyncio import logging import socket from asyncio import timeout as asyncio_timeout from collections.abc import AsyncIterator, Callable from contextlib import asynccontextmanager from struct import Struct from typing import TYPE_CHECKING, cast from btsocket import btmgmt_socket from btsocket.btmgmt_socket import BluetoothSocketError from ..const import ( FAST_CONN_LATENCY, FAST_CONN_TIMEOUT, FAST_MAX_CONN_INTERVAL, FAST_MIN_CONN_INTERVAL, MEDIUM_CONN_LATENCY, MEDIUM_CONN_TIMEOUT, MEDIUM_MAX_CONN_INTERVAL, MEDIUM_MIN_CONN_INTERVAL, ConnectParams, ) from ..scanner import HaScanner _LOGGER = logging.getLogger(__name__) _int = int _bytes = bytes # Everything is little endian HEADER_SIZE = 6 # Header is event_code (2 bytes), controller_idx (2 bytes), param_len (2 bytes) DEVICE_FOUND = 0x0012 ADV_MONITOR_DEVICE_FOUND = 0x002F # Management commands MGMT_OP_GET_CONNECTIONS = 0x0015 MGMT_OP_LOAD_CONN_PARAM = 0x0035 # Management events MGMT_EV_CMD_COMPLETE = 0x0001 MGMT_EV_CMD_STATUS = 0x0002 # Pre-compiled struct formats for performance COMMAND_HEADER = Struct(" None: """Set the future result if not done.""" if future is not None and not future.done(): future.set_result(None) class BluetoothMGMTProtocol: """Bluetooth MGMT protocol.""" def __init__( self, connection_made_future: asyncio.Future[None], scanners: dict[int, HaScanner], on_connection_lost: Callable[[], None], is_shutting_down: Callable[[], bool], sock: socket.socket, ) -> None: """Initialize the protocol.""" self.transport: asyncio.Transport | None = None self.connection_made_future = connection_made_future self._buffer: bytes | None = None self._buffer_len = 0 self._pos = 0 self._scanners = scanners self._on_connection_lost = on_connection_lost self._is_shutting_down = is_shutting_down self._pending_commands: dict[int, asyncio.Future[tuple[int, bytes]]] = {} self._sock = sock def connection_made(self, transport: asyncio.BaseTransport) -> None: """Handle connection made.""" _set_future_if_not_done(self.connection_made_future) self.transport = cast(asyncio.Transport, transport) def _write_to_socket(self, data: bytes) -> None: """ Write data directly to the socket, bypassing asyncio transport. This works around a kernel bug where sendto() on Bluetooth management sockets returns 0 instead of the number of bytes sent on some platforms (e.g., Odroid M1 with kernel 6.12.43). When asyncio sees 0, it thinks the send failed and retries forever. Since mgmt sockets are SOCK_RAW, sends are atomic - either the entire packet is sent or nothing is sent. """ try: n = self._sock.send(data) # On buggy kernels, n might be 0 even though the data was sent # We treat 0 as success for mgmt sockets if n == 0 and len(data) > 0: # Kernel bug: returned 0 but data was actually sent _LOGGER.debug( "Bluetooth mgmt socket returned 0 for %d bytes (kernel bug fix)", len(data), ) except Exception as exc: _LOGGER.error("Failed to write to mgmt socket: %s", exc) raise @asynccontextmanager async def command_response( self, opcode: int ) -> AsyncIterator[asyncio.Future[tuple[int, bytes]]]: """ Context manager for handling command responses. Usage: async with protocol.command_response(opcode) as future: transport.write(command) status, data = await future """ future: asyncio.Future[tuple[int, bytes]] = ( asyncio.get_running_loop().create_future() ) self._pending_commands[opcode] = future try: yield future finally: # Clean up if the future wasn't resolved self._pending_commands.pop(opcode, None) def _add_to_buffer(self, data: bytes | bytearray | memoryview) -> None: """Add data to the buffer.""" # Protractor sends a bytearray, so we need to convert it to bytes # https://github.com/esphome/issues/issues/5117 # type(data) should not be isinstance(data, bytes) because we want to # to explicitly check for bytes and not for subclasses of bytes bytes_data = bytes(data) if type(data) is not bytes else data if self._buffer_len == 0: # This is the best case scenario, we don't have to copy the data # and can just use the buffer directly. This is the most common # case as well. self._buffer = bytes_data else: if TYPE_CHECKING: assert self._buffer is not None, "Buffer should be set" # This is the worst case scenario, we have to copy the bytes_data # and can't just use the buffer directly. This is also very # uncommon since we usually read the entire frame at once. self._buffer += bytes_data self._buffer_len += len(bytes_data) def _remove_from_buffer(self) -> None: """Remove data from the buffer.""" end_of_frame_pos = self._pos self._buffer_len -= end_of_frame_pos if self._buffer_len == 0: # This is the best case scenario, we can just set the buffer to None # and don't have to copy the data. This is the most common case as well. self._buffer = None return if TYPE_CHECKING: assert self._buffer is not None, "Buffer should be set" # This is the worst case scenario, we have to copy the data # and can't just use the buffer directly. This should only happen # when we read multiple frames at once because the event loop # is blocked and we cannot pull the data out of the buffer fast enough. cstr = self._buffer # Important: we must use the explicit length for the slice # since Cython will stop at any '\0' character if we don't self._buffer = cstr[end_of_frame_pos : self._buffer_len + end_of_frame_pos] def data_received(self, data: _bytes) -> None: """Handle data received.""" self._add_to_buffer(data) while self._buffer_len >= 6: if TYPE_CHECKING: assert self._buffer is not None, "Buffer should be set" self._pos = 6 header = self._buffer event_code = header[0] | (header[1] << 8) controller_idx = header[2] | (header[3] << 8) param_len = header[4] | (header[5] << 8) if self._buffer_len < self._pos + param_len: # We don't have the entire frame yet, so we need to wait # for more data to arrive. return self._pos += param_len if event_code == DEVICE_FOUND: parse_offset = 6 elif event_code == ADV_MONITOR_DEVICE_FOUND: parse_offset = 8 elif event_code in {MGMT_EV_CMD_COMPLETE, MGMT_EV_CMD_STATUS}: # Handle management command responses if param_len >= 3: opcode = header[6] | (header[7] << 8) status = header[8] if opcode == MGMT_OP_LOAD_CONN_PARAM: self._handle_load_conn_param_response(status, controller_idx) elif ( opcode == MGMT_OP_GET_CONNECTIONS and opcode in self._pending_commands ): # Handle GET_CONNECTIONS response for capability check future = self._pending_commands.pop(opcode) if not future.done(): # Return status and any response data response_data = ( header[9 : self._pos] if param_len > 3 else b"" ) future.set_result((status, response_data)) self._remove_from_buffer() continue else: self._remove_from_buffer() continue address = header[parse_offset : parse_offset + 6] address_type = header[parse_offset + 6] rssi = header[parse_offset + 7] if rssi > 128: rssi -= 256 flags = ( header[parse_offset + 8] | (header[parse_offset + 9] << 8) | (header[parse_offset + 10] << 16) | (header[parse_offset + 11] << 24) ) # Skip AD_Data_Length (2 bytes) at parse_offset+12 and +13 data = header[parse_offset + 14 : self._pos] self._remove_from_buffer() if (scanner := self._scanners.get(controller_idx)) is not None: # We have a scanner for this controller, so we can # pass the data to it. scanner._async_on_raw_bluez_advertisement( address, address_type, rssi, flags, data, ) def _handle_load_conn_param_response( self, status: _int, controller_idx: _int ) -> None: """Handle MGMT_OP_LOAD_CONN_PARAM response.""" if status != 0: _LOGGER.warning( "hci%u: Failed to load conn params: status=%d", controller_idx, status, ) else: _LOGGER.debug( "hci%u: Connection parameters loaded successfully", controller_idx, ) def connection_lost(self, exc: Exception | None) -> None: """Handle connection lost.""" # Only suppress warnings during shutdown, not info messages if exc: if not self._is_shutting_down(): _LOGGER.warning("Bluetooth management socket connection lost: %s", exc) else: _LOGGER.info("Bluetooth management socket connection closed") self.transport = None self._on_connection_lost() class MGMTBluetoothCtl: """Class to control interfaces using the BlueZ management API.""" def __init__(self, timeout: float, scanners: dict[int, HaScanner]) -> None: """Initialize the control class.""" # Internal state self.timeout = timeout self.protocol: BluetoothMGMTProtocol | None = None self.sock: socket.socket | None = None self.scanners = scanners self._reconnect_task: asyncio.Task[None] | None = None self._on_connection_lost_future: asyncio.Future[None] | None = None self._shutting_down = False def close(self) -> None: """Close the management interface.""" self._shutting_down = True if self._reconnect_task: self._reconnect_task.cancel() if self.protocol and self.protocol.transport: self.protocol.transport.close() self.protocol = None btmgmt_socket.close(self.sock) def _on_connection_lost(self) -> None: """Handle connection lost.""" if self._shutting_down: _LOGGER.debug("Bluetooth management socket connection lost during shutdown") else: _LOGGER.debug("Bluetooth management socket connection lost, reconnecting") _set_future_if_not_done(self._on_connection_lost_future) self._on_connection_lost_future = None async def reconnect_task(self) -> None: """Monitor the connection and reconnect if needed.""" while not self._shutting_down: if self._on_connection_lost_future: await self._on_connection_lost_future if self._shutting_down: break # type: ignore[unreachable] _LOGGER.debug("Reconnecting to Bluetooth management socket") try: await self._establish_connection() except CONNECTION_ERRORS: _LOGGER.debug("Bluetooth management socket connection timed out") # If we get a timeout, we should try to reconnect # after a short delay await asyncio.sleep(1) async def _establish_connection(self) -> None: """Establish a connection to the Bluetooth management socket.""" _LOGGER.debug("Establishing Bluetooth management socket connection") self.sock = btmgmt_socket.open() loop = asyncio.get_running_loop() connection_made_future: asyncio.Future[None] = loop.create_future() try: async with asyncio_timeout(self.timeout): # _create_connection_transport accessed # directly to avoid SOCK_STREAM check # see https://bugs.python.org/issue38285 _, protocol = await loop._create_connection_transport( # type: ignore[attr-defined] self.sock, lambda: BluetoothMGMTProtocol( connection_made_future, self.scanners, self._on_connection_lost, lambda: self._shutting_down, self.sock, ), None, None, ) await connection_made_future except TimeoutError: btmgmt_socket.close(self.sock) raise _LOGGER.debug("Bluetooth management socket connection established") self.protocol = cast(BluetoothMGMTProtocol, protocol) self._on_connection_lost_future = loop.create_future() def _has_mgmt_capabilities_from_status(self, status: int) -> bool: """ Check if a MGMT command status indicates we have capabilities. Returns True if we have capabilities, False otherwise. Status codes: - 0x00 = Success (we have permissions) - 0x01 = Unknown Command (might happen if kernel is too old) - 0x0D = Invalid Parameters - 0x10 = Not Powered (for some operations) - 0x11 = Invalid Index (adapter doesn't exist but we have permissions) - 0x14 = Permission Denied (missing NET_ADMIN/NET_RAW) """ if status == 0x14: # Permission denied _LOGGER.debug( "MGMT capability check failed with permission denied - " "missing NET_ADMIN/NET_RAW" ) return False if status in (0x00, 0x11): # Success or Invalid Index _LOGGER.debug("MGMT capability check passed (status: %#x)", status) return True # Unknown status - log it and assume no permissions to be safe _LOGGER.debug( "MGMT capability check returned unexpected status %#x - " "assuming missing permissions", status, ) return False async def _check_capabilities(self) -> bool: """ Check if we have the necessary capabilities to use MGMT. Returns True if we have capabilities, False otherwise. """ if not self.protocol or not self.protocol.transport: return False # Try GET_CONNECTIONS for adapter 0 - this is a read-only command # that requires NET_ADMIN privileges but doesn't change any state header = COMMAND_HEADER_PACK( MGMT_OP_GET_CONNECTIONS, # opcode 0, # controller index 0 (hci0) 0, # no parameters ) try: return await self._do_mgmt_op_get_connections(header) except (TimeoutError, OSError) as ex: _LOGGER.debug( "MGMT capability check failed: %s - " "likely missing NET_ADMIN/NET_RAW", ex, ) return False async def _do_mgmt_op_get_connections(self, header: bytes) -> bool: """Send a MGMT_OP_GET_CONNECTIONS command and check capabilities.""" if TYPE_CHECKING: assert self.protocol is not None assert self.protocol.transport is not None async with self.protocol.command_response( MGMT_OP_GET_CONNECTIONS ) as response_future: self.protocol._write_to_socket(header) # Wait for response with timeout async with asyncio_timeout(5.0): status, _ = await response_future return self._has_mgmt_capabilities_from_status(status) async def setup(self) -> None: """Set up management interface.""" await self._establish_connection() # Check if we actually have the capabilities to use MGMT if not await self._check_capabilities(): # Mark as shutting down to prevent reconnection attempts self._shutting_down = True # Close the connection and raise an error to trigger fallback if self.protocol and self.protocol.transport: self.protocol.transport.close() btmgmt_socket.close(self.sock) raise PermissionError( "Missing NET_ADMIN/NET_RAW capabilities for Bluetooth management" ) self._reconnect_task = asyncio.create_task(self.reconnect_task()) def load_conn_params( self, adapter_idx: int, address: str, address_type: int, params: ConnectParams, ) -> bool: """ Load connection parameters for a specific device. Args: adapter_idx: Adapter index (e.g., 0 for hci0) address: Device MAC address (e.g., "AA:BB:CC:DD:EE:FF") address_type: BDADDR_LE_PUBLIC (1) or BDADDR_LE_RANDOM (2) params: Connection parameters to load (ConnectParams.FAST or ConnectParams.MEDIUM) Returns: True if command was sent successfully """ if not self.protocol or not self.protocol.transport: _LOGGER.error("Cannot load conn params: no connection") return False # Parse MAC address addr_bytes = bytes.fromhex(address.replace(":", "")) if len(addr_bytes) != 6: _LOGGER.error("Invalid MAC address: %s", address) return False # Build command structure # struct mgmt_cp_load_conn_param { # uint16_t param_count; # struct mgmt_conn_param params[0]; # } # struct mgmt_conn_param { # struct mgmt_addr_info addr; # uint16_t min_interval; # uint16_t max_interval; # uint16_t latency; # uint16_t timeout; # } # struct mgmt_addr_info { # bdaddr_t bdaddr; # uint8_t type; # } # Get the appropriate connection parameters based on the enum if params is ConnectParams.FAST: min_interval = FAST_MIN_CONN_INTERVAL max_interval = FAST_MAX_CONN_INTERVAL latency = FAST_CONN_LATENCY timeout = FAST_CONN_TIMEOUT else: # params is ConnectParams.MEDIUM: min_interval = MEDIUM_MIN_CONN_INTERVAL max_interval = MEDIUM_MAX_CONN_INTERVAL latency = MEDIUM_CONN_LATENCY timeout = MEDIUM_CONN_TIMEOUT # Pack the command cmd_data = CONN_PARAM_PACK( 1, # param_count = 1 addr_bytes[::-1], # bdaddr (reversed for little endian) address_type, # address type min_interval, # min_interval max_interval, # max_interval latency, # latency timeout, # timeout ) # Send the command try: header = COMMAND_HEADER_PACK( MGMT_OP_LOAD_CONN_PARAM, # opcode adapter_idx, # controller index len(cmd_data), # parameter length ) self.protocol._write_to_socket(header + cmd_data) _LOGGER.debug( "Loaded conn params for %s: interval=%d-%d, latency=%d, timeout=%d", address, min_interval, max_interval, latency, timeout, ) return True except Exception: _LOGGER.exception("Failed to load conn params") return False Bluetooth-Devices-habluetooth-21accde/src/habluetooth/const.py000066400000000000000000000056721507024716100247410ustar00rootroot00000000000000"""Constants.""" from __future__ import annotations from collections.abc import Callable from datetime import timedelta from enum import Enum from typing import Final CALLBACK_TYPE = Callable[[], None] SOURCE_LOCAL: Final = "local" START_TIMEOUT = 15 STOP_TIMEOUT = 5 # The maximum time between advertisements for a device to be considered # stale when the advertisement tracker cannot determine the interval. # # We have to set this quite high as we don't know # when devices fall out of the ESPHome device (and other non-local scanners)'s # stack like we do with BlueZ so its safer to assume its available # since if it does go out of range and it is in range # of another device the timeout is much shorter and it will # switch over to using that adapter anyways. # FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 60 * 15 # The maximum time between advertisements for a device to be considered # stale when the advertisement tracker can determine the interval for # connectable devices. # # BlueZ uses 180 seconds by default but we give it a bit more time # to account for the esp32's bluetooth stack being a bit slower # than BlueZ's. CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 195 # We must recover before we hit the 180s mark # where the device is removed from the stack # or the devices will go unavailable. Since # we only check every 30s, we need this number # to be # 180s Time when device is removed from stack # - 30s check interval # - 30s scanner restart time * 2 # SCANNER_WATCHDOG_TIMEOUT: Final = 90 # How often to check if the scanner has reached # the SCANNER_WATCHDOG_TIMEOUT without seeing anything SCANNER_WATCHDOG_INTERVAL: Final = timedelta(seconds=30) UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5 FAILED_ADAPTER_MAC = "00:00:00:00:00:00" ADV_RSSI_SWITCH_THRESHOLD: Final = 16 # The switch threshold for the rssi value # to switch to a different adapter for advertisements # Note that this does not affect the connection # selection that uses RSSI_SWITCH_THRESHOLD from # bleak_retry_connector # Connection parameter constants (units of 1.25ms for intervals) # Fast connection parameters for initial connection and service discovery FAST_MIN_CONN_INTERVAL: Final = 0x06 # 6 * 1.25ms = 7.5ms (BLE minimum) FAST_MAX_CONN_INTERVAL: Final = 0x06 # 6 * 1.25ms = 7.5ms FAST_CONN_LATENCY: Final = 0 # No latency for fast response FAST_CONN_TIMEOUT: Final = 1000 # 1000 * 10ms = 10s # Medium connection parameters for standard operation # Balanced for stability with WiFi-based BLE proxies MEDIUM_MIN_CONN_INTERVAL: Final = 0x07 # 7 * 1.25ms = 8.75ms MEDIUM_MAX_CONN_INTERVAL: Final = 0x09 # 9 * 1.25ms = 11.25ms MEDIUM_CONN_LATENCY: Final = 0 # No latency MEDIUM_CONN_TIMEOUT: Final = 800 # 800 * 10ms = 8s # Bluetooth address types BDADDR_BREDR: Final = 0x00 BDADDR_LE_PUBLIC: Final = 0x01 BDADDR_LE_RANDOM: Final = 0x02 class ConnectParams(Enum): """Connection parameter presets.""" FAST = "fast" MEDIUM = "medium" Bluetooth-Devices-habluetooth-21accde/src/habluetooth/manager.pxd000066400000000000000000000056261507024716100253670ustar00rootroot00000000000000import cython from .advertisement_tracker cimport AdvertisementTracker from .base_scanner cimport BaseHaScanner from .models cimport BluetoothServiceInfoBleak cdef int NO_RSSI_VALUE cdef int ADV_RSSI_SWITCH_THRESHOLD cdef double TRACKER_BUFFERING_WOBBLE_SECONDS cdef double FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS cdef object FILTER_UUIDS cdef object AdvertisementData cdef object BLEDevice cdef bint TYPE_CHECKING cdef set APPLE_START_BYTES_WANTED cdef unsigned char APPLE_IBEACON_START_BYTE cdef unsigned char APPLE_HOMEKIT_START_BYTE cdef unsigned char APPLE_HOMEKIT_NOTIFY_START_BYTE cdef unsigned char APPLE_DEVICE_ID_START_BYTE cdef unsigned char APPLE_FINDMY_START_BYTE cdef object APPLE_MFR_ID @cython.locals(uuids=set) cdef _dispatch_bleak_callback( BleakCallback bleak_callback, object device, object advertisement_data ) cdef class BleakCallback: cdef public object callback cdef public dict filters cdef class BluetoothManager: cdef public object _cancel_unavailable_tracking cdef public AdvertisementTracker _advertisement_tracker cdef public dict _fallback_intervals cdef public dict _intervals cdef public dict _unavailable_callbacks cdef public dict _connectable_unavailable_callbacks cdef public set _bleak_callbacks cdef public dict _all_history cdef public dict _connectable_history cdef public set _non_connectable_scanners cdef public set _connectable_scanners cdef public dict _adapters cdef public dict _sources cdef public object _bluetooth_adapters cdef public object slot_manager cdef public bint _debug cdef public bint shutdown cdef public object _loop cdef public object _adapter_refresh_future cdef public object _recovery_lock cdef public set _disappeared_callbacks cdef public dict _allocations_callbacks cdef public object _cancel_allocation_callbacks cdef public dict _adapter_sources cdef public dict _allocations cdef public dict _scanner_registration_callbacks cdef public dict _scanner_mode_change_callbacks cdef public object _subclass_discover_info cdef public bint has_advertising_side_channel cdef public dict _side_channel_scanners cdef public object _mgmt_ctl @cython.locals(stale_seconds=double) cdef bint _prefer_previous_adv_from_different_source( self, BluetoothServiceInfoBleak old, BluetoothServiceInfoBleak new ) @cython.locals( old_service_info=BluetoothServiceInfoBleak, old_connectable_service_info=BluetoothServiceInfoBleak, source=str, connectable=bint, scanner=BaseHaScanner, connectable_scanner=BaseHaScanner, apple_cstr="const unsigned char *", bleak_callback=BleakCallback ) cpdef void scanner_adv_received(self, BluetoothServiceInfoBleak service_info) cpdef _async_describe_source(self, BluetoothServiceInfoBleak service_info) Bluetooth-Devices-habluetooth-21accde/src/habluetooth/manager.py000066400000000000000000001210001507024716100252050ustar00rootroot00000000000000"""The bluetooth integration.""" from __future__ import annotations import asyncio import itertools import logging import platform from collections.abc import Callable, Iterable from dataclasses import asdict from functools import partial from typing import TYPE_CHECKING, Any, Final from bleak.backends.scanner import AdvertisementDataCallback from bleak_retry_connector import ( NO_RSSI_VALUE, AllocationChangeEvent, Allocations, BleakSlotManager, ) from bluetooth_adapters import ( ADAPTER_ADDRESS, ADAPTER_PASSIVE_SCAN, AdapterDetails, BluetoothAdapters, get_adapters, ) from bluetooth_data_tools import monotonic_time_coarse from .advertisement_tracker import ( TRACKER_BUFFERING_WOBBLE_SECONDS, AdvertisementTracker, ) from .channels.bluez import CONNECTION_ERRORS, MGMTBluetoothCtl from .const import ( ADV_RSSI_SWITCH_THRESHOLD, CALLBACK_TYPE, FAILED_ADAPTER_MAC, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, UNAVAILABLE_TRACK_SECONDS, ) from .models import ( BluetoothServiceInfoBleak, HaBluetoothSlotAllocations, HaScannerModeChange, HaScannerRegistration, HaScannerRegistrationEvent, ) from .scanner_device import BluetoothScannerDevice from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher from .util import async_reset_adapter if TYPE_CHECKING: from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from .base_scanner import BaseHaScanner from .scanner import HaScanner SYSTEM = platform.system() IS_LINUX = SYSTEM == "Linux" FILTER_UUIDS: Final = "UUIDs" APPLE_MFR_ID: Final = 76 APPLE_IBEACON_START_BYTE: Final = 0x02 # iBeacon (tilt_ble) APPLE_HOMEKIT_START_BYTE: Final = 0x06 # homekit_controller APPLE_DEVICE_ID_START_BYTE: Final = 0x10 # bluetooth_le_tracker APPLE_HOMEKIT_NOTIFY_START_BYTE: Final = 0x11 # homekit_controller APPLE_FINDMY_START_BYTE: Final = 0x12 # FindMy network advertisements _str = str _int = int _LOGGER = logging.getLogger(__name__) def _dispatch_bleak_callback( bleak_callback: BleakCallback, device: BLEDevice, advertisement_data: AdvertisementData, ) -> None: """Dispatch the callback.""" if ( uuids := bleak_callback.filters.get(FILTER_UUIDS) ) is not None and not uuids.intersection(advertisement_data.service_uuids): return try: bleak_callback.callback(device, advertisement_data) except Exception: # pylint: disable=broad-except _LOGGER.exception("Error in callback: %s", bleak_callback.callback) class BleakCallback: """Bleak callback.""" __slots__ = ("callback", "filters") def __init__( self, callback: AdvertisementDataCallback, filters: dict[str, set[str]] ) -> None: """Init bleak callback.""" self.callback = callback self.filters = filters class BluetoothManager: """Manage Bluetooth.""" __slots__ = ( "_adapter_refresh_future", "_adapter_sources", "_adapters", "_advertisement_tracker", "_all_history", "_allocations", "_allocations_callbacks", "_bleak_callbacks", "_bluetooth_adapters", "_cancel_allocation_callbacks", "_cancel_unavailable_tracking", "_connectable_history", "_connectable_scanners", "_connectable_unavailable_callbacks", "_connection_history", "_debug", "_disappeared_callbacks", "_fallback_intervals", "_intervals", "_loop", "_mgmt_ctl", "_non_connectable_scanners", "_recovery_lock", "_scanner_mode_change_callbacks", "_scanner_registration_callbacks", "_side_channel_scanners", "_sources", "_subclass_discover_info", "_unavailable_callbacks", "has_advertising_side_channel", "shutdown", "slot_manager", ) def __init__( self, bluetooth_adapters: BluetoothAdapters | None = None, slot_manager: BleakSlotManager | None = None, ) -> None: """Init bluetooth manager.""" self._cancel_unavailable_tracking: asyncio.TimerHandle | None = None self._advertisement_tracker = AdvertisementTracker() self._fallback_intervals = self._advertisement_tracker.fallback_intervals self._intervals = self._advertisement_tracker.intervals self._unavailable_callbacks: dict[ str, set[Callable[[BluetoothServiceInfoBleak], None]] ] = {} self._connectable_unavailable_callbacks: dict[ str, set[Callable[[BluetoothServiceInfoBleak], None]] ] = {} self._bleak_callbacks: set[BleakCallback] = set() self._all_history: dict[str, BluetoothServiceInfoBleak] = {} self._connectable_history: dict[str, BluetoothServiceInfoBleak] = {} self._non_connectable_scanners: set[BaseHaScanner] = set() self._connectable_scanners: set[BaseHaScanner] = set() self._adapters: dict[str, AdapterDetails] = {} self._adapter_sources: dict[str, str] = {} self._allocations: dict[str, HaBluetoothSlotAllocations] = {} self._sources: dict[str, BaseHaScanner] = {} self._bluetooth_adapters = bluetooth_adapters or get_adapters() self.slot_manager = slot_manager or BleakSlotManager() self._cancel_allocation_callbacks = ( self.slot_manager.register_allocation_callback( self._async_slot_manager_changed ) ) self._debug = _LOGGER.isEnabledFor(logging.DEBUG) self.shutdown = False self.has_advertising_side_channel = False self._side_channel_scanners: dict[int, HaScanner] = {} self._loop: asyncio.AbstractEventLoop | None = None self._adapter_refresh_future: asyncio.Future[None] | None = None self._recovery_lock: asyncio.Lock = asyncio.Lock() self._disappeared_callbacks: set[Callable[[str], None]] = set() self._allocations_callbacks: dict[ str | None, set[Callable[[HaBluetoothSlotAllocations], None]] ] = {} self._scanner_registration_callbacks: dict[ str | None, set[Callable[[HaScannerRegistration], None]] ] = {} self._scanner_mode_change_callbacks: dict[ str | None, set[Callable[[HaScannerModeChange], None]] ] = {} self._subclass_discover_info = self._discover_service_info self._mgmt_ctl: MGMTBluetoothCtl | None = None if ( self._discover_service_info.__func__ # type: ignore[attr-defined] is BluetoothManager._discover_service_info ): _LOGGER.warning( "%s: does not implement _discover_service_info, " "subclasses must implement this method to consume " "discovery data", type(self).__name__, ) @property def supports_passive_scan(self) -> bool: """Return if passive scan is supported.""" return any(adapter[ADAPTER_PASSIVE_SCAN] for adapter in self._adapters.values()) def is_operating_degraded(self) -> bool: """ Return if the manager is operating in degraded mode. On Linux, we're in degraded mode if mgmt control is not available. This typically means we don't have NET_ADMIN/NET_RAW capabilities. """ return IS_LINUX and self._mgmt_ctl is None def on_scanner_start(self, scanner: BaseHaScanner) -> None: """ Called when a scanner starts. Subclasses can override this to perform custom actions when a scanner starts. """ def async_scanner_count(self, connectable: bool = True) -> int: """Return the number of scanners.""" if connectable: return len(self._connectable_scanners) return len(self._connectable_scanners) + len(self._non_connectable_scanners) async def async_diagnostics(self) -> dict[str, Any]: """Diagnostics for the manager.""" scanner_diagnostics = await asyncio.gather( *[ scanner.async_diagnostics() for scanner in itertools.chain( self._non_connectable_scanners, self._connectable_scanners ) ] ) return { "adapters": self._adapters, "slot_manager": self.slot_manager.diagnostics(), "allocations": { source: asdict(allocations) for source, allocations in self._allocations.items() }, "scanners": scanner_diagnostics, "connectable_history": [ service_info.as_dict() for service_info in self._connectable_history.values() ], "all_history": [ service_info.as_dict() for service_info in self._all_history.values() ], "advertisement_tracker": self._advertisement_tracker.async_diagnostics(), } def _find_adapter_by_address(self, address: str) -> str | None: for adapter, details in self._adapters.items(): if details[ADAPTER_ADDRESS] == address: return adapter return None def async_scanner_by_source(self, source: str) -> BaseHaScanner | None: """Return the scanner for a source.""" return self._sources.get(source) def async_register_disappeared_callback( self, callback: Callable[[str], None] ) -> CALLBACK_TYPE: """Register a callback to be called when an address disappears.""" self._disappeared_callbacks.add(callback) return partial(self._disappeared_callbacks.discard, callback) async def _async_refresh_adapters(self) -> None: """Refresh the adapters.""" if self._adapter_refresh_future: await self._adapter_refresh_future return if TYPE_CHECKING: assert self._loop is not None self._adapter_refresh_future = self._loop.create_future() try: await self._bluetooth_adapters.refresh() self._adapters = self._bluetooth_adapters.adapters finally: self._adapter_refresh_future.set_result(None) self._adapter_refresh_future = None def get_cached_bluetooth_adapters(self) -> dict[str, AdapterDetails] | None: """Get cached bluetooth adapters synchronously.""" return self._adapters async def async_get_bluetooth_adapters( self, cached: bool = True ) -> dict[str, AdapterDetails]: """Get bluetooth adapters.""" if not self._adapters or not cached: if not cached: await self._async_refresh_adapters() self._adapters = self._bluetooth_adapters.adapters return self._adapters async def async_get_adapter_from_address(self, address: str) -> str | None: """Get adapter from address.""" if adapter := self._find_adapter_by_address(address): return adapter await self._async_refresh_adapters() return self._find_adapter_by_address(address) async def async_get_adapter_from_address_or_recover( self, address: str ) -> str | None: """Get adapter from address or recover.""" if adapter := self._find_adapter_by_address(address): return adapter await self._async_recover_failed_adapters() return self._find_adapter_by_address(address) async def _async_recover_failed_adapters(self) -> None: """Recover failed adapters.""" if self._recovery_lock.locked(): # Already recovering, no need to # start another recovery return async with self._recovery_lock: adapters = await self.async_get_bluetooth_adapters() for adapter in [ adapter for adapter, details in adapters.items() if details[ADAPTER_ADDRESS] == FAILED_ADAPTER_MAC ]: await async_reset_adapter(adapter, FAILED_ADAPTER_MAC, False) await self._async_refresh_adapters() async def async_setup(self) -> None: """Set up the bluetooth manager.""" from .central_manager import CentralBluetoothManager if CentralBluetoothManager.manager is None: CentralBluetoothManager.manager = self self._loop = asyncio.get_running_loop() await self._async_refresh_adapters() install_multiple_bleak_catcher() self.async_setup_unavailable_tracking() if not IS_LINUX: return self._mgmt_ctl = MGMTBluetoothCtl(10.0, self._side_channel_scanners) try: await self._mgmt_ctl.setup() except PermissionError as ex: _LOGGER.error( "Missing required permissions for Bluetooth management: %s. " "Automatic adapter recovery is unavailable. " "Add NET_ADMIN and NET_RAW capabilities to the container to enable it", ex, ) self._mgmt_ctl = None except CONNECTION_ERRORS as ex: _LOGGER.debug("Cannot start Bluetooth Management API: %s", ex) self._mgmt_ctl = None else: self.has_advertising_side_channel = True def async_stop(self) -> None: """Stop the Bluetooth integration at shutdown.""" _LOGGER.debug("Stopping bluetooth manager") self.shutdown = True if self._cancel_unavailable_tracking: self._cancel_unavailable_tracking.cancel() self._cancel_unavailable_tracking = None uninstall_multiple_bleak_catcher() self._cancel_allocation_callbacks() if self._mgmt_ctl: self._mgmt_ctl.close() self._mgmt_ctl = None def async_scanner_devices_by_address( self, address: str, connectable: bool ) -> list[BluetoothScannerDevice]: """Get BluetoothScannerDevice by address.""" if not connectable: scanners: Iterable[BaseHaScanner] = itertools.chain( self._connectable_scanners, self._non_connectable_scanners ) else: scanners = self._connectable_scanners return [ BluetoothScannerDevice(scanner, *device_adv) for scanner in scanners if (device_adv := scanner.get_discovered_device_advertisement_data(address)) ] def _async_all_discovered_addresses(self, connectable: bool) -> Iterable[str]: """ Return all of discovered addresses. Include addresses from all the scanners including duplicates. """ yield from itertools.chain.from_iterable( scanner.discovered_addresses for scanner in self._connectable_scanners ) if not connectable: yield from itertools.chain.from_iterable( scanner.discovered_addresses for scanner in self._non_connectable_scanners ) def async_discovered_devices(self, connectable: bool) -> list[BLEDevice]: """Return all of combined best path to discovered from all the scanners.""" histories = self._connectable_history if connectable else self._all_history return [history.device for history in histories.values()] def async_setup_unavailable_tracking(self) -> None: """Set up the unavailable tracking.""" self._schedule_unavailable_tracking() def _schedule_unavailable_tracking(self) -> None: """Schedule the unavailable tracking.""" if TYPE_CHECKING: assert self._loop is not None loop = self._loop self._cancel_unavailable_tracking = loop.call_at( loop.time() + UNAVAILABLE_TRACK_SECONDS, self._async_check_unavailable ) def _async_check_unavailable(self) -> None: """Watch for unavailable devices and cleanup state history.""" monotonic_now = monotonic_time_coarse() connectable_history = self._connectable_history all_history = self._all_history tracker = self._advertisement_tracker intervals = tracker.intervals for connectable in (True, False): if connectable: unavailable_callbacks = self._connectable_unavailable_callbacks else: unavailable_callbacks = self._unavailable_callbacks history = connectable_history if connectable else all_history disappeared = set(history).difference( self._async_all_discovered_addresses(connectable) ) for address in disappeared: if not connectable: # # For non-connectable devices we also check the device has exceeded # the advertising interval before we mark it as unavailable # since it may have gone to sleep and since we do not need an active # connection to it we can only determine its availability # by the lack of advertisements if advertising_interval := ( intervals.get(address) or self._fallback_intervals.get(address) ): advertising_interval += TRACKER_BUFFERING_WOBBLE_SECONDS else: advertising_interval = ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS ) time_since_seen = monotonic_now - all_history[address].time if time_since_seen <= advertising_interval: continue # The second loop (connectable=False) is responsible for removing # the device from all the interval tracking since it is no longer # available for both connectable and non-connectable tracker.async_remove_fallback_interval(address) tracker.async_remove_address(address) for disappear_callback in self._disappeared_callbacks: try: disappear_callback(address) except Exception: _LOGGER.exception("Error in disappeared callback") self._address_disappeared(address) service_info = history.pop(address) if not (callbacks := unavailable_callbacks.get(address)): continue for callback in callbacks.copy(): try: callback(service_info) except Exception: # pylint: disable=broad-except _LOGGER.exception("Error in unavailable callback") self._schedule_unavailable_tracking() def _address_disappeared(self, address: str) -> None: """ Call when an address disappears from the stack. This method is intended to be overridden by subclasses. """ def _prefer_previous_adv_from_different_source( self, old: BluetoothServiceInfoBleak, new: BluetoothServiceInfoBleak, ) -> bool: """Prefer previous advertisement from a different source if it is better.""" if stale_seconds := self._intervals.get( new.address, self._fallback_intervals.get(new.address, 0) ): stale_seconds += TRACKER_BUFFERING_WOBBLE_SECONDS else: stale_seconds = FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS if new.time - old.time > stale_seconds: # If the old advertisement is stale, any new advertisement is preferred if self._debug: _LOGGER.debug( "%s (%s): Switching from %s to %s (time elapsed:%s > stale" " seconds:%s)", new.name, new.address, self._async_describe_source(old), self._async_describe_source(new), new.time - old.time, stale_seconds, ) return False if (new.rssi or NO_RSSI_VALUE) - ADV_RSSI_SWITCH_THRESHOLD > ( old.rssi or NO_RSSI_VALUE ): # If new advertisement is ADV_RSSI_SWITCH_THRESHOLD more, # the new one is preferred. if self._debug: _LOGGER.debug( "%s (%s): Switching from %s to %s (new rssi:%s - threshold:%s >" " old rssi:%s)", new.name, new.address, self._async_describe_source(old), self._async_describe_source(new), new.rssi, ADV_RSSI_SWITCH_THRESHOLD, old.rssi, ) return False return True def get_bluez_mgmt_ctl(self) -> MGMTBluetoothCtl | None: """ Get the BlueZ management controller if available. Returns: The MGMTBluetoothCtl instance or None if not available """ return self._mgmt_ctl def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None: """ Handle a new advertisement from any scanner. Callbacks from all the scanners arrive here. """ # Pre-filter noisy apple devices as they can account for 20-35% of the # traffic on a typical network. if ( not service_info.service_data and len(service_info.manufacturer_data) == 1 and (apple_data := service_info.manufacturer_data.get(APPLE_MFR_ID)) ): apple_cstr = apple_data if apple_cstr[0] not in { APPLE_IBEACON_START_BYTE, APPLE_HOMEKIT_START_BYTE, APPLE_HOMEKIT_NOTIFY_START_BYTE, APPLE_DEVICE_ID_START_BYTE, APPLE_FINDMY_START_BYTE, }: return if service_info.connectable: old_connectable_service_info = self._connectable_history.get( service_info.address ) else: old_connectable_service_info = None # This logic is complex due to the many combinations of scanners # that are supported. # # We need to handle multiple connectable and non-connectable scanners # and we need to handle the case where a device is connectable on one scanner # but not on another. # # The device may also be connectable only by a scanner that has worse # signal strength than a non-connectable scanner. # # all_history - the history of all advertisements from all scanners with the # best advertisement from each scanner # connectable_history - the history of all connectable advertisements from all # scanners with the best advertisement from each # connectable scanner # if ( (old_service_info := self._all_history.get(service_info.address)) is not None and service_info.source is not old_service_info.source and service_info.source != old_service_info.source and (scanner := self._sources.get(old_service_info.source)) is not None and scanner.scanning and self._prefer_previous_adv_from_different_source( old_service_info, service_info ) ): # If we are rejecting the new advertisement and the device is connectable # but not in the connectable history or the connectable source is the same # as the new source, we need to add it to the connectable history if service_info.connectable: if old_connectable_service_info is not None and ( # If its the same as the preferred source, we are done # as we know we prefer the old advertisement # from the check above (old_connectable_service_info is old_service_info) # If the old connectable source is different from the preferred # source, we need to check it as well to see if we prefer # the old connectable advertisement or ( old_connectable_service_info.source is not service_info.source and old_connectable_service_info.source != service_info.source and ( connectable_scanner := self._sources.get( old_connectable_service_info.source ) ) is not None and connectable_scanner.scanning and self._prefer_previous_adv_from_different_source( old_connectable_service_info, service_info, ) ) ): return self._connectable_history[service_info.address] = service_info return if service_info.connectable: self._connectable_history[service_info.address] = service_info self._all_history[service_info.address] = service_info # Track advertisement intervals to determine when we need to # switch adapters or mark a device as unavailable if ( ( last_source := self._advertisement_tracker.sources.get( service_info.address ) ) is not None and last_source is not service_info.source and last_source != service_info.source ): # Source changed, remove the old address from the tracker self._advertisement_tracker.async_remove_address(service_info.address) if service_info.address not in self._advertisement_tracker.intervals: self._advertisement_tracker.async_collect(service_info) # If the advertisement data is the same as the last time we saw it, we # don't need to do anything else unless its connectable and we are missing # connectable history for the device so we can make it available again # after unavailable callbacks. if ( # Ensure its not a connectable device missing from connectable history not (service_info.connectable and old_connectable_service_info is None) # Than check if advertisement data is the same and old_service_info is not None # This is a bit complex because we want to skip all the # PyObject_RichCompare overhead as its can be upwards of # 65% of the time spent in this method. The common case # is that its the same object for remote scanners. and not ( ( service_info.manufacturer_data is not old_service_info.manufacturer_data and service_info.manufacturer_data != old_service_info.manufacturer_data ) or ( service_info.service_data is not old_service_info.service_data and service_info.service_data != old_service_info.service_data ) or ( service_info.service_uuids is not old_service_info.service_uuids and service_info.service_uuids != old_service_info.service_uuids ) or ( service_info.name is not old_service_info.name and service_info.name != old_service_info.name ) ) ): return if not service_info.connectable and old_connectable_service_info is not None: # Since we have a connectable path and our BleakClient will # route any connection attempts to the connectable path, we # mark the service_info as connectable so that the callbacks # will be called and the device can be discovered. service_info = service_info._as_connectable() if ( service_info.connectable or old_connectable_service_info is not None ) and self._bleak_callbacks: # Bleak callbacks must get a connectable device advertisement_data = service_info._advertisement_internal() for bleak_callback in self._bleak_callbacks: _dispatch_bleak_callback( bleak_callback, service_info.device, advertisement_data ) self._subclass_discover_info(service_info) def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None: """ Discover a new service info. This method is intended to be overridden by subclasses. """ def _async_describe_source(self, service_info: BluetoothServiceInfoBleak) -> str: """Describe a source.""" if scanner := self._sources.get(service_info.source): description = scanner.name else: description = service_info.source if service_info.connectable: description += " [connectable]" return description def _async_remove_unavailable_callback_internal( self, unavailable_callbacks: dict[ str, set[Callable[[BluetoothServiceInfoBleak], None]] ], address: str, callbacks: set[Callable[[BluetoothServiceInfoBleak], None]], callback: Callable[[BluetoothServiceInfoBleak], None], ) -> None: """Remove a callback.""" callbacks.remove(callback) if not callbacks: del unavailable_callbacks[address] def async_track_unavailable( self, callback: Callable[[BluetoothServiceInfoBleak], None], address: str, connectable: bool, ) -> Callable[[], None]: """Register a callback.""" if connectable: unavailable_callbacks = self._connectable_unavailable_callbacks else: unavailable_callbacks = self._unavailable_callbacks callbacks = unavailable_callbacks.setdefault(address, set()) callbacks.add(callback) return partial( self._async_remove_unavailable_callback_internal, unavailable_callbacks, address, callbacks, callback, ) def async_ble_device_from_address( self, address: str, connectable: bool ) -> BLEDevice | None: """Return the BLEDevice if present.""" histories = self._connectable_history if connectable else self._all_history if history := histories.get(address): return history.device return None def async_address_present(self, address: str, connectable: bool) -> bool: """Return if the address is present.""" histories = self._connectable_history if connectable else self._all_history return address in histories def async_discovered_service_info( self, connectable: bool ) -> Iterable[BluetoothServiceInfoBleak]: """Return all the discovered services info.""" histories = self._connectable_history if connectable else self._all_history return histories.values() def async_last_service_info( self, address: str, connectable: bool ) -> BluetoothServiceInfoBleak | None: """Return the last service info for an address.""" histories = self._connectable_history if connectable else self._all_history return histories.get(address) def _async_unregister_scanner_internal( self, scanners: set[BaseHaScanner], scanner: BaseHaScanner, connection_slots: int | None, ) -> None: """Unregister a scanner.""" _LOGGER.debug("Unregistering scanner %s", scanner.name) self._advertisement_tracker.async_remove_source(scanner.source) scanners.remove(scanner) scanner._clear_connection_history() del self._sources[scanner.source] del self._adapter_sources[scanner.adapter] self._allocations.pop(scanner.source, None) if connection_slots: self.slot_manager.remove_adapter(scanner.adapter) if (idx := scanner.adapter_idx) is not None: self._side_channel_scanners.pop(idx, None) self._async_on_scanner_registration(scanner, HaScannerRegistrationEvent.REMOVED) def async_register_scanner( self, scanner: BaseHaScanner, connection_slots: int | None = None, ) -> CALLBACK_TYPE: """Register a new scanner.""" _LOGGER.debug("Registering scanner %s", scanner.name) if scanner.connectable: scanners = self._connectable_scanners else: scanners = self._non_connectable_scanners self._allocations[scanner.source] = HaBluetoothSlotAllocations( source=scanner.source, slots=0, free=0, allocated=[] ) scanners.add(scanner) scanner._clear_connection_history() self._sources[scanner.source] = scanner self._adapter_sources[scanner.adapter] = scanner.source if (idx := scanner.adapter_idx) is not None: self._side_channel_scanners[idx] = scanner # type: ignore[assignment] if connection_slots: self.slot_manager.register_adapter(scanner.adapter, connection_slots) self.async_on_allocation_changed( self.slot_manager.get_allocations(scanner.adapter) ) self._async_on_scanner_registration(scanner, HaScannerRegistrationEvent.ADDED) return partial( self._async_unregister_scanner_internal, scanners, scanner, connection_slots ) def async_register_bleak_callback( self, callback: AdvertisementDataCallback, filters: dict[str, set[str]] ) -> CALLBACK_TYPE: """Register a callback.""" callback_entry = BleakCallback(callback, filters) self._bleak_callbacks.add(callback_entry) # Replay the history since otherwise we miss devices # that were already discovered before the callback was registered # or we are in passive mode for history in self._connectable_history.values(): _dispatch_bleak_callback( callback_entry, history.device, history.advertisement ) return partial(self._bleak_callbacks.remove, callback_entry) def async_release_connection_slot(self, device: BLEDevice) -> None: """Release a connection slot.""" self.slot_manager.release_slot(device) def async_allocate_connection_slot(self, device: BLEDevice) -> bool: """Allocate a connection slot.""" return self.slot_manager.allocate_slot(device) def async_get_learned_advertising_interval(self, address: str) -> float | None: """Get the learned advertising interval for a MAC address.""" return self._intervals.get(address) def async_get_fallback_availability_interval(self, address: str) -> float | None: """Get the fallback availability timeout for a MAC address.""" return self._fallback_intervals.get(address) def async_set_fallback_availability_interval( self, address: str, interval: float ) -> None: """Override the fallback availability timeout for a MAC address.""" self._fallback_intervals[address] = interval def _async_slot_manager_changed(self, event: AllocationChangeEvent) -> None: """Handle slot manager changes.""" self.async_on_allocation_changed( self.slot_manager.get_allocations(event.adapter) ) def async_on_allocation_changed(self, allocations: Allocations) -> None: """Call allocation callbacks.""" source = self._adapter_sources.get(allocations.adapter, allocations.adapter) ha_slot_allocations = HaBluetoothSlotAllocations( source=source, slots=allocations.slots, free=allocations.free, allocated=allocations.allocated, ) self._allocations[source] = ha_slot_allocations for source_key in (source, None): if not ( allocation_callbacks := self._allocations_callbacks.get(source_key) ): continue for callback_ in allocation_callbacks: try: callback_(ha_slot_allocations) except Exception: _LOGGER.exception("Error in allocation callback") def _async_on_scanner_registration( self, scanner: BaseHaScanner, event: HaScannerRegistrationEvent ) -> None: """Call scanner callbacks.""" for source_key in (scanner.source, None): if not ( scanner_callbacks := self._scanner_registration_callbacks.get( source_key ) ): continue for callback_ in scanner_callbacks: try: callback_(HaScannerRegistration(event, scanner)) except Exception: _LOGGER.exception("Error in scanner callback") def async_current_allocations( self, source: str | None = None ) -> list[HaBluetoothSlotAllocations] | None: """Return the current allocations.""" if source: if allocations := self._allocations.get(source): return [allocations] return [] return list(self._allocations.values()) def async_register_allocation_callback( self, callback: Callable[[HaBluetoothSlotAllocations], None], source: str | None = None, ) -> CALLBACK_TYPE: """Register a callback to be called when an allocations change.""" self._allocations_callbacks.setdefault(source, set()).add(callback) return partial(self._async_unregister_allocation_callback, callback, source) def _async_unregister_allocation_callback( self, callback: Callable[[HaBluetoothSlotAllocations], None], source: str | None ) -> None: if (callbacks := self._allocations_callbacks.get(source)) is not None: callbacks.discard(callback) if not callbacks: del self._allocations_callbacks[source] def async_register_scanner_registration_callback( self, callback: Callable[[HaScannerRegistration], None], source: str | None ) -> CALLBACK_TYPE: """Register a callback to be called when a scanner is added or removed.""" self._scanner_registration_callbacks.setdefault(source, set()).add(callback) return partial( self._async_unregister_scanner_registration_callback, callback, source ) def _async_unregister_scanner_registration_callback( self, callback: Callable[[HaScannerRegistration], None], source: str | None ) -> None: if (callbacks := self._scanner_registration_callbacks.get(source)) is not None: callbacks.discard(callback) if not callbacks: del self._scanner_registration_callbacks[source] def async_current_scanners(self) -> list[BaseHaScanner]: """Return the current scanners.""" return list(self._sources.values()) def async_register_scanner_mode_change_callback( self, callback: Callable[[HaScannerModeChange], None], source: str | None ) -> CALLBACK_TYPE: """Register a callback to be called when a scanner mode changes.""" self._scanner_mode_change_callbacks.setdefault(source, set()).add(callback) return partial( self._async_unregister_scanner_mode_change_callback, callback, source ) def _async_unregister_scanner_mode_change_callback( self, callback: Callable[[HaScannerModeChange], None], source: str | None ) -> None: """Unregister a scanner mode change callback.""" if (callbacks := self._scanner_mode_change_callbacks.get(source)) is not None: callbacks.discard(callback) if not callbacks: del self._scanner_mode_change_callbacks[source] def scanner_mode_changed(self, scanner: BaseHaScanner) -> None: """Notify callbacks that a scanner's mode has changed.""" mode_change = HaScannerModeChange( scanner=scanner, requested_mode=scanner.requested_mode, current_mode=scanner.current_mode, ) for source_key in (scanner.source, None): if not ( mode_callbacks := self._scanner_mode_change_callbacks.get(source_key) ): continue for callback_ in mode_callbacks: try: callback_(mode_change) except Exception: # pylint: disable=broad-except _LOGGER.exception("Error in scanner mode change callback") Bluetooth-Devices-habluetooth-21accde/src/habluetooth/models.pxd000066400000000000000000000017571507024716100252410ustar00rootroot00000000000000import cython cdef object BLEDevice cdef object AdvertisementData cdef object _float cdef object _int cdef object _str cdef object _BluetoothServiceInfoBleakSelfT cdef object _BluetoothServiceInfoSelfT cdef object NO_RSSI_VALUE cdef object TUPLE_NEW cdef class BluetoothServiceInfo: """Prepared info from bluetooth entries.""" cdef public str name cdef public str address cdef public int rssi cdef public dict manufacturer_data cdef public dict service_data cdef public list service_uuids cdef public str source cdef class BluetoothServiceInfoBleak(BluetoothServiceInfo): """BluetoothServiceInfo with bleak data.""" cdef public object device cdef public object _advertisement cdef public bint connectable cdef public double time cdef public object tx_power cdef public bytes raw @cython.locals(new_obj=BluetoothServiceInfoBleak) cpdef BluetoothServiceInfoBleak _as_connectable(self) cdef object _advertisement_internal(self) Bluetooth-Devices-habluetooth-21accde/src/habluetooth/models.py000066400000000000000000000247031507024716100250720ustar00rootroot00000000000000"""Models for bluetooth.""" from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from enum import Enum from typing import TYPE_CHECKING, Any, Final, TypeVar from bleak import BaseBleakClient from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from bleak_retry_connector import NO_RSSI_VALUE if TYPE_CHECKING: from .base_scanner import BaseHaScanner _BluetoothServiceInfoSelfT = TypeVar( "_BluetoothServiceInfoSelfT", bound="BluetoothServiceInfo" ) _BluetoothServiceInfoBleakSelfT = TypeVar( "_BluetoothServiceInfoBleakSelfT", bound="BluetoothServiceInfoBleak" ) SOURCE_LOCAL: Final = "local" TUPLE_NEW: Final = tuple.__new__ _float = float # avoid cython conversion since we always want a pyfloat _str = str # avoid cython conversion since we always want a pystr _int = int # avoid cython conversion since we always want a pyint @dataclass(slots=True, frozen=True) class HaBluetoothSlotAllocations: """Data for how to allocate slots for BLEDevice connections.""" source: str # Adapter MAC slots: int # Number of slots free: int # Number of free slots allocated: list[str] # Addresses of connected devices class HaScannerRegistrationEvent(Enum): """Events for scanner registration.""" ADDED = "added" REMOVED = "removed" UPDATED = "updated" @dataclass(slots=True, frozen=True) class HaScannerRegistration: """Data for a scanner event.""" event: HaScannerRegistrationEvent scanner: BaseHaScanner @dataclass(slots=True, frozen=True) class HaScannerModeChange: """Data for a scanner mode change event.""" scanner: BaseHaScanner requested_mode: BluetoothScanningMode | None current_mode: BluetoothScanningMode | None @dataclass(slots=True) class HaBluetoothConnector: """Data for how to connect a BLEDevice from a given scanner.""" client: type[BaseBleakClient] source: str can_connect: Callable[[], bool] @dataclass(slots=True, frozen=True) class HaScannerDetails: """Details for a scanner.""" source: str connectable: bool name: str adapter: str scanner_type: HaScannerType class HaScannerType(Enum): """The type of scanner.""" USB = "usb" UART = "uart" REMOTE = "remote" UNKNOWN = "unknown" class BluetoothScanningMode(Enum): """The mode of scanning for bluetooth devices.""" PASSIVE = "passive" ACTIVE = "active" class BluetoothServiceInfo: """Prepared info from bluetooth entries.""" __slots__ = ( "address", "manufacturer_data", "name", "rssi", "service_data", "service_uuids", "source", ) def __init__( self, name: _str, # may be a pyobjc object address: _str, # may be a pyobjc object rssi: _int, # may be a pyobjc object manufacturer_data: dict[_int, bytes], service_data: dict[_str, bytes], service_uuids: list[_str], source: _str, ) -> None: """Initialize a bluetooth service info.""" self.name = name self.address = address self.rssi = rssi self.manufacturer_data = manufacturer_data self.service_data = service_data self.service_uuids = service_uuids self.source = source @classmethod def from_advertisement( cls: type[_BluetoothServiceInfoSelfT], device: BLEDevice, advertisement_data: AdvertisementData, source: str, ) -> _BluetoothServiceInfoSelfT: """Create a BluetoothServiceInfo from an advertisement.""" return cls( advertisement_data.local_name or device.name or device.address, device.address, advertisement_data.rssi, advertisement_data.manufacturer_data, advertisement_data.service_data, advertisement_data.service_uuids, source, ) @property def manufacturer(self) -> str | None: """Convert manufacturer data to a string.""" from bleak.backends._manufacturers import ( MANUFACTURERS, # pylint: disable=import-outside-toplevel ) for manufacturer in self.manufacturer_data: if manufacturer in MANUFACTURERS: name: str = MANUFACTURERS[manufacturer] return name return None @property def manufacturer_id(self) -> int | None: """Get the first manufacturer id.""" for manufacturer in self.manufacturer_data: return manufacturer return None class BluetoothServiceInfoBleak(BluetoothServiceInfo): """ BluetoothServiceInfo with bleak data. Integrations may need BLEDevice and AdvertisementData to connect to the device without having bleak trigger another scan to translate the address to the system's internal details. """ __slots__ = ("_advertisement", "connectable", "device", "raw", "time", "tx_power") def __init__( self, name: _str, # may be a pyobjc object address: _str, # may be a pyobjc object rssi: _int, # may be a pyobjc object manufacturer_data: dict[_int, bytes], service_data: dict[_str, bytes], service_uuids: list[_str], source: _str, device: BLEDevice, advertisement: AdvertisementData | None, connectable: bool, time: _float, tx_power: _int | None, raw: bytes | None = None, ) -> None: self.name = name self.address = address self.rssi = rssi self.manufacturer_data = manufacturer_data self.service_data = service_data self.service_uuids = service_uuids self.source = source self.device = device self._advertisement = advertisement self.connectable = connectable self.time = time self.tx_power = tx_power self.raw = raw def __repr__(self) -> str: """Return the representation of the object.""" return ( f"<{self.__class__.__name__} " f"name={self.name} " f"address={self.address} " f"rssi={self.rssi} " f"manufacturer_data={self.manufacturer_data} " f"service_data={self.service_data} " f"service_uuids={self.service_uuids} " f"source={self.source} " f"connectable={self.connectable} " f"time={self.time} " f"tx_power={self.tx_power} " f"raw={self.raw!r}>" ) def _advertisement_internal(self) -> AdvertisementData: """ Get the advertisement data. Internal method only to be used by this library. """ if self._advertisement is None: self._advertisement = TUPLE_NEW( AdvertisementData, ( None if self.name == "" or self.name == self.address else self.name, self.manufacturer_data, self.service_data, self.service_uuids, NO_RSSI_VALUE if self.tx_power is None else self.tx_power, self.rssi, (), ), ) return self._advertisement @property def advertisement(self) -> AdvertisementData: """Get the advertisement data.""" return self._advertisement_internal() def as_dict(self) -> dict[str, Any]: """ Return as dict. The dataclass asdict method is not used because it will try to deepcopy pyobjc data which will fail. """ return { "name": self.name, "address": self.address, "rssi": self.rssi, "manufacturer_data": self.manufacturer_data, "service_data": self.service_data, "service_uuids": self.service_uuids, "source": self.source, "advertisement": self.advertisement, "device": self.device, "connectable": self.connectable, "time": self.time, "tx_power": self.tx_power, "raw": self.raw, } @classmethod def from_scan( cls: type[_BluetoothServiceInfoBleakSelfT], source: str, device: BLEDevice, advertisement_data: AdvertisementData, monotonic_time: _float, connectable: bool, ) -> _BluetoothServiceInfoBleakSelfT: """Create a BluetoothServiceInfoBleak from a scanner.""" return cls( advertisement_data.local_name or device.name or device.address, device.address, advertisement_data.rssi, advertisement_data.manufacturer_data, advertisement_data.service_data, advertisement_data.service_uuids, source, device, advertisement_data, connectable, monotonic_time, advertisement_data.tx_power, ) @classmethod def from_device_and_advertisement_data( cls: type[_BluetoothServiceInfoBleakSelfT], device: BLEDevice, advertisement_data: AdvertisementData, source: str, time: _float, connectable: bool, ) -> _BluetoothServiceInfoBleakSelfT: """Create a BluetoothServiceInfoBleak from a device and advertisement.""" return cls( advertisement_data.local_name or device.name or device.address, device.address, advertisement_data.rssi, advertisement_data.manufacturer_data, advertisement_data.service_data, advertisement_data.service_uuids, source, device, advertisement_data, connectable, time, advertisement_data.tx_power, ) def _as_connectable(self) -> BluetoothServiceInfoBleak: """Return a connectable version of this object.""" new_obj = BluetoothServiceInfoBleak.__new__(BluetoothServiceInfoBleak) new_obj.name = self.name new_obj.address = self.address new_obj.rssi = self.rssi new_obj.manufacturer_data = self.manufacturer_data new_obj.service_data = self.service_data new_obj.service_uuids = self.service_uuids new_obj.source = self.source new_obj.device = self.device new_obj._advertisement = self._advertisement new_obj.connectable = True new_obj.time = self.time new_obj.tx_power = self.tx_power new_obj.raw = self.raw return new_obj Bluetooth-Devices-habluetooth-21accde/src/habluetooth/py.typed000066400000000000000000000000001507024716100247140ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-21accde/src/habluetooth/scanner.pxd000066400000000000000000000015211507024716100253740ustar00rootroot00000000000000import cython from .base_scanner cimport BaseHaScanner from .models cimport BluetoothServiceInfoBleak cdef object NO_RSSI_VALUE cdef object AdvertisementData cdef object BLEDevice cdef bint TYPE_CHECKING cdef object _NEW_SERVICE_INFO cdef class HaScanner(BaseHaScanner): cdef public object mac_address cdef public object _start_stop_lock cdef public object _background_tasks cdef public object scanner cdef public object _start_future @cython.locals(service_info=BluetoothServiceInfoBleak) cpdef void _async_detection_callback( self, object device, object advertisement_data ) cpdef void _async_on_raw_bluez_advertisement( self, bytes address, unsigned short address_type, short rssi, unsigned int flags, bytes data, ) except * Bluetooth-Devices-habluetooth-21accde/src/habluetooth/scanner.py000066400000000000000000000567451507024716100252530ustar00rootroot00000000000000"""A local bleak scanner.""" from __future__ import annotations import asyncio import logging import platform from collections.abc import Coroutine, Iterable from functools import lru_cache from typing import Any, no_type_check import async_interrupt import bleak from bleak import BleakError from bleak.assigned_numbers import AdvertisementDataType from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback from bleak_retry_connector import Allocations, restore_discoveries from bleak_retry_connector.bluez import stop_discovery from bluetooth_adapters import DEFAULT_ADDRESS from bluetooth_data_tools import monotonic_time_coarse from .base_scanner import BaseHaScanner from .const import ( CALLBACK_TYPE, SCANNER_WATCHDOG_INTERVAL, SCANNER_WATCHDOG_TIMEOUT, SOURCE_LOCAL, START_TIMEOUT, STOP_TIMEOUT, ) from .models import BluetoothScanningMode, BluetoothServiceInfoBleak from .util import async_reset_adapter, is_docker_env int_ = int SYSTEM = platform.system() IS_LINUX = SYSTEM == "Linux" IS_MACOS = SYSTEM == "Darwin" if IS_LINUX: from bleak.args.bluez import BlueZScannerArgs, OrPattern from bleak.backends.bluezdbus.advertisement_monitor import ( AdvertisementMonitor, ) from dbus_fast import InvalidMessageError from dbus_fast.service import method # or_patterns is a workaround for the fact that passive scanning # needs at least one matcher to be set. The below matcher # will match all devices. PASSIVE_SCANNER_ARGS = BlueZScannerArgs( or_patterns=[ OrPattern(0, AdvertisementDataType.FLAGS, b"\x02"), OrPattern(0, AdvertisementDataType.FLAGS, b"\x06"), OrPattern(0, AdvertisementDataType.FLAGS, b"\x1a"), ] ) class HaAdvertisementMonitor(AdvertisementMonitor): """Implementation of the org.bluez.AdvertisementMonitor1 D-Bus interface.""" @method() @no_type_check def DeviceFound(self, device: o): # noqa: F821 """Device found.""" @method() @no_type_check def DeviceLost(self, device: o): # noqa: F821 """Device lost.""" AdvertisementMonitor.DeviceFound = HaAdvertisementMonitor.DeviceFound AdvertisementMonitor.DeviceLost = HaAdvertisementMonitor.DeviceLost else: class InvalidMessageError(Exception): # type: ignore[no-redef] """Invalid message error.""" OriginalBleakScanner = bleak.BleakScanner _LOGGER = logging.getLogger(__name__) IN_PROGRESS_ERROR = "org.bluez.Error.InProgress" # If the adapter is in a stuck state the following errors are raised: NEED_RESET_ERRORS = [ "org.bluez.Error.Failed", IN_PROGRESS_ERROR, "org.bluez.Error.NotReady", "not found", ] # When the adapter is still initializing, the scanner will raise an exception # with org.freedesktop.DBus.Error.UnknownObject WAIT_FOR_ADAPTER_TO_INIT_ERRORS = ["org.freedesktop.DBus.Error.UnknownObject"] ADAPTER_INIT_TIME = 1.5 START_ATTEMPTS = 4 SCANNING_MODE_TO_BLEAK = { BluetoothScanningMode.ACTIVE: "active", BluetoothScanningMode.PASSIVE: "passive", } # The minimum number of seconds to know # the adapter has not had advertisements # and we already tried to restart the scanner # without success when the first time the watch # dog hit the failure path. SCANNER_WATCHDOG_MULTIPLE = ( SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds() ) class _AbortStartError(Exception): """Error to indicate that the start should be aborted.""" class ScannerStartError(Exception): """Error to indicate that the scanner failed to start.""" def create_bleak_scanner( detection_callback: AdvertisementDataCallback | None, scanning_mode: BluetoothScanningMode, adapter: str | None, ) -> bleak.BleakScanner: """Create a Bleak scanner.""" scanner_kwargs: dict[str, Any] = { "scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode], } if detection_callback: scanner_kwargs["detection_callback"] = detection_callback if IS_LINUX: # Only Linux supports multiple adapters if adapter: scanner_kwargs["adapter"] = adapter if scanning_mode == BluetoothScanningMode.PASSIVE: scanner_kwargs["bluez"] = PASSIVE_SCANNER_ARGS elif IS_MACOS: # We want mac address on macOS scanner_kwargs["cb"] = {"use_bdaddr": True} _LOGGER.debug("Initializing bluetooth scanner with %s", scanner_kwargs) try: return OriginalBleakScanner(**scanner_kwargs) except (FileNotFoundError, BleakError) as ex: raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex def _error_indicates_reset_needed(error_str: str) -> bool: """Return if the error indicates a reset is needed.""" return any( needs_reset_error in error_str for needs_reset_error in NEED_RESET_ERRORS ) def _error_indicates_wait_for_adapter_to_init(error_str: str) -> bool: """Return if the error indicates the adapter is still initializing.""" return any( wait_error in error_str for wait_error in WAIT_FOR_ADAPTER_TO_INIT_ERRORS ) @lru_cache(maxsize=512) def bytes_mac_to_str(mac: bytes) -> str: """Convert a MAC address in bytes to a string in big-endian (MSB-first) order.""" return ":".join(f"{b:02X}" for b in reversed(mac)) @lru_cache(maxsize=512) def make_bluez_details(address: str, adapter: str) -> dict[str, Any]: """Make the details for a bluez advertisement.""" base_path = f"/org/bluez/{adapter}" return { "path": f"{base_path}/dev_{address.replace(':', '_')}", "props": { "Adapter": base_path, }, } class HaScanner(BaseHaScanner): """ Operate and automatically recover a BleakScanner. Multiple BleakScanner can be used at the same time if there are multiple adapters. This is only useful if the adapters are not located physically next to each other. Example use cases are usbip, a long extension cable, usb to bluetooth over ethernet, usb over ethernet, etc. """ __slots__ = ( "_background_tasks", "_start_future", "_start_stop_lock", "mac_address", "scanner", ) def __init__( self, mode: BluetoothScanningMode, adapter: str, address: str, ) -> None: """Init bluetooth discovery.""" self.mac_address = address source = address if address != DEFAULT_ADDRESS else adapter or SOURCE_LOCAL super().__init__(source, adapter, requested_mode=mode) self.connectable = True self._start_stop_lock = asyncio.Lock() self.scanning = False self._background_tasks: set[asyncio.Task[Any]] = set() self.scanner: bleak.BleakScanner | None = None self._start_future: asyncio.Future[None] | None = None def _create_background_task(self, coro: Coroutine[Any, Any, None]) -> None: """Create a background task and add it to the background tasks set.""" task = asyncio.create_task(coro) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" if not self.scanner: return [] return self.scanner.discovered_devices @property def discovered_devices_and_advertisement_data( self, ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: """Return a list of discovered devices and advertisement data.""" if not self.scanner: return {} return self.scanner.discovered_devices_and_advertisement_data @property def discovered_addresses(self) -> Iterable[str]: """Return an iterable of discovered devices.""" return self.discovered_devices_and_advertisement_data def get_discovered_device_advertisement_data( self, address: str ) -> tuple[BLEDevice, AdvertisementData] | None: """Return the advertisement data for a discovered device.""" return self.discovered_devices_and_advertisement_data.get(address) def get_allocations(self) -> Allocations | None: """Get current connection slot allocations from BleakSlotManager.""" if self._manager and self._manager.slot_manager: return self._manager.slot_manager.get_allocations(self.adapter) return None def async_setup(self) -> CALLBACK_TYPE: """Set up the scanner.""" super().async_setup() return self._unsetup async def async_diagnostics(self) -> dict[str, Any]: """Return diagnostic information about the scanner.""" base_diag = await super().async_diagnostics() return base_diag | {"adapter": self.adapter} def _async_on_raw_bluez_advertisement( self, address: bytes, address_type: int_, rssi: int_, flags: int_, data: bytes, ) -> None: """Handle raw advertisement data.""" address_str = bytes_mac_to_str(address) self._async_on_raw_advertisement( address_str, rssi, data, make_bluez_details(address_str, self.adapter), monotonic_time_coarse(), ) def _async_detection_callback( self, device: BLEDevice, advertisement_data: AdvertisementData, ) -> None: """ Call the callback when an advertisement is received. Currently this is used to feed the callbacks into the central manager. """ callback_time = monotonic_time_coarse() address = device.address local_name = advertisement_data.local_name manufacturer_data = advertisement_data.manufacturer_data service_data = advertisement_data.service_data service_uuids = advertisement_data.service_uuids if local_name or manufacturer_data or service_data or service_uuids: # Don't count empty advertisements # as the adapter is in a failure # state if all the data is empty. self._last_detection = callback_time name = local_name or device.name or address if name is not None and type(name) is not str: name = str(name) tx_power = advertisement_data.tx_power if tx_power is not None and type(tx_power) is not int: tx_power = int(tx_power) service_info = BluetoothServiceInfoBleak.__new__(BluetoothServiceInfoBleak) service_info.name = name service_info.address = address service_info.rssi = advertisement_data.rssi service_info.manufacturer_data = manufacturer_data service_info.service_data = service_data service_info.service_uuids = service_uuids service_info.source = self.source service_info.device = device service_info._advertisement = advertisement_data service_info.connectable = True service_info.time = callback_time service_info.tx_power = tx_power service_info.raw = None # not available in bleak. self._manager.scanner_adv_received(service_info) async def async_start(self) -> None: """Start bluetooth scanner.""" async with self._start_stop_lock: await self._async_start() async def _async_start(self) -> None: """Start bluetooth scanner under the lock.""" for attempt in range(1, START_ATTEMPTS + 1): if await self._async_start_attempt(attempt): # Everything is fine, break out of the loop break await self._async_on_successful_start() async def _async_on_successful_start(self) -> None: """Run when the scanner has successfully started.""" self.scanning = True self._async_setup_scanner_watchdog() await restore_discoveries(self.scanner, self.adapter) async def _async_start_attempt(self, attempt: int) -> bool: """Start the scanner and handle errors.""" assert ( # noqa: S101 self._loop is not None ), "Loop is not set, call async_setup first" self.set_current_mode(self.requested_mode) # 1st attempt - no auto reset # 2nd attempt - try to reset the adapter and wait a bit # 3th attempt - no auto reset # 4th attempt - fallback to passive if available if ( IS_LINUX and attempt == START_ATTEMPTS and self.requested_mode is BluetoothScanningMode.ACTIVE ): _LOGGER.debug( "%s: Falling back to passive scanning mode " "after active scanning failed (%s/%s)", self.name, attempt, START_ATTEMPTS, ) self.set_current_mode(BluetoothScanningMode.PASSIVE) assert self.current_mode is not None # noqa: S101 self.scanner = create_bleak_scanner( ( None if self._manager.has_advertising_side_channel else self._async_detection_callback ), self.current_mode, self.adapter, ) # If the scanner is already running, trying to start it again # can result in a deadlock. So we need to stop it first. # hci0: Opcode 0x200b failed: -110 # hci0: start background scanning failed: -110 # hci0: Controller not accepting commands anymore: ncmd = 0 # hci0: Injecting HCI hardware error event # hci0: hardware error 0x00 await self._async_force_stop_discovery() self._log_start_attempt(attempt) self._start_future = self._loop.create_future() try: async with ( asyncio.timeout(START_TIMEOUT), async_interrupt.interrupt(self._start_future, _AbortStartError, None), ): await self.scanner.start() except _AbortStartError as ex: await self._async_stop_scanner() self._raise_for_abort_start(ex) except InvalidMessageError as ex: await self._async_stop_scanner() self._raise_for_invalid_dbus_message(ex) except BrokenPipeError as ex: await self._async_stop_scanner() self._raise_for_broken_pipe_error(ex) except FileNotFoundError as ex: await self._async_stop_scanner() self._raise_for_file_not_found_error(ex) except TimeoutError as ex: await self._async_stop_scanner() if attempt == 2: await self._async_reset_adapter(False) if attempt < START_ATTEMPTS: self._log_start_timeout(attempt) return False raise ScannerStartError( f"{self.name}: Timed out starting Bluetooth after" f" {START_TIMEOUT} seconds; " "Try power cycling the Bluetooth hardware." ) from ex except BleakError as ex: await self._async_stop_scanner() error_str = str(ex) if IN_PROGRESS_ERROR in error_str: # If discovery is stuck on, try to force stop it await self._async_force_stop_discovery() if attempt == 2 and _error_indicates_reset_needed(error_str): await self._async_reset_adapter(False) elif ( attempt != START_ATTEMPTS and _error_indicates_wait_for_adapter_to_init(error_str) ): # If we are not out of retry attempts, and the # adapter is still initializing, wait a bit and try again. self._log_adapter_init_wait(attempt) await asyncio.sleep(ADAPTER_INIT_TIME) if attempt < START_ATTEMPTS: self._log_start_failed(ex, attempt) return False raise ScannerStartError( f"{self.name}: Failed to start Bluetooth: {ex}; " "Try power cycling the Bluetooth hardware." ) from ex except BaseException: await self._async_stop_scanner() raise finally: self._start_future = None self._log_start_success(attempt) self._on_start_success() return True def _log_adapter_init_wait(self, attempt: int) -> None: _LOGGER.debug( "%s: Waiting for adapter to initialize; attempt (%s/%s)", self.name, attempt, START_ATTEMPTS, ) def _log_start_success(self, attempt: int) -> None: if self.current_mode is not self.requested_mode: _LOGGER.warning( "%s: Successful fall-back to passive scanning mode " "after active scanning failed (%s/%s)", self.name, attempt, START_ATTEMPTS, ) _LOGGER.debug( "%s: Success while starting bluetooth; attempt: (%s/%s)", self.name, attempt, START_ATTEMPTS, ) def _log_start_timeout(self, attempt: int) -> None: _LOGGER.debug( "%s: TimeoutError while starting bluetooth; attempt: (%s/%s)", self.name, attempt, START_ATTEMPTS, ) def _log_start_failed(self, ex: BleakError, attempt: int) -> None: _LOGGER.debug( "%s: BleakError while starting bluetooth; attempt: (%s/%s): %s", self.name, attempt, START_ATTEMPTS, ex, exc_info=True, ) def _log_start_attempt(self, attempt: int) -> None: _LOGGER.debug( "%s: Starting bluetooth discovery attempt: (%s/%s)", self.name, attempt, START_ATTEMPTS, ) def _raise_for_abort_start(self, ex: _AbortStartError) -> None: _LOGGER.debug( "%s: Starting bluetooth scanner aborted: %s", self.name, ex, exc_info=True, ) msg = f"{self.name}: Starting bluetooth scanner aborted" raise ScannerStartError(msg) from ex def _raise_for_file_not_found_error(self, ex: FileNotFoundError) -> None: _LOGGER.debug( "%s: FileNotFoundError while starting bluetooth: %s", self.name, ex, exc_info=True, ) if is_docker_env(): raise ScannerStartError( f"{self.name}: DBus service not found; docker config may " "be missing `-v /run/dbus:/run/dbus:ro`: {ex}" ) from ex raise ScannerStartError( f"{self.name}: DBus service not found; make sure the DBus socket " f"is available: {ex}" ) from ex def _raise_for_broken_pipe_error(self, ex: BrokenPipeError) -> None: """Raise a ScannerStartError for a BrokenPipeError.""" _LOGGER.debug("%s: DBus connection broken: %s", self.name, ex, exc_info=True) if is_docker_env(): msg = ( f"{self.name}: DBus connection broken: {ex}; try restarting " "`bluetooth`, `dbus`, and finally the docker container" ) else: msg = ( f"{self.name}: DBus connection broken: {ex}; try restarting " "`bluetooth` and `dbus`" ) raise ScannerStartError(msg) from ex def _raise_for_invalid_dbus_message(self, ex: InvalidMessageError) -> None: """Raise a ScannerStartError for an InvalidMessageError.""" _LOGGER.debug( "%s: Invalid DBus message received: %s", self.name, ex, exc_info=True, ) msg = ( f"{self.name}: Invalid DBus message received: {ex}; " "try restarting `dbus`" ) raise ScannerStartError(msg) from ex def _async_scanner_watchdog(self) -> None: """Check if the scanner is running.""" if not self._async_watchdog_triggered(): return if self._start_stop_lock.locked(): _LOGGER.debug( "%s: Scanner is already restarting, deferring restart", self.name, ) return _LOGGER.debug( "%s: Bluetooth scanner has gone quiet for %ss, restarting", self.name, self.time_since_last_detection(), ) # Immediately mark the scanner as not scanning # since the restart task will have to wait for the lock self.scanning = False self._create_background_task(self._async_restart_scanner()) async def _async_restart_scanner(self) -> None: """Restart the scanner.""" async with self._start_stop_lock: # Stop the scanner but not the watchdog # since we want to try again later if it's still quiet await self._async_stop_scanner() # If there have not been any valid advertisements, # or the watchdog has hit the failure path multiple times, # do the reset. if ( self._start_time == self._last_detection or self.time_since_last_detection() > SCANNER_WATCHDOG_MULTIPLE ): await self._async_reset_adapter(True) try: await self._async_start() except ScannerStartError as ex: _LOGGER.exception( "%s: Failed to restart Bluetooth scanner: %s", self.name, ex, ) async def _async_reset_adapter(self, gone_silent: bool) -> None: """Reset the adapter.""" # There is currently nothing the user can do to fix this # so we log at debug level. If we later come up with a repair # strategy, we will change this to raise a repair issue as well. _LOGGER.debug("%s: adapter stopped responding; executing reset", self.name) result = await async_reset_adapter(self.adapter, self.mac_address, gone_silent) _LOGGER.debug("%s: adapter reset result: %s", self.name, result) async def async_stop(self) -> None: """Stop bluetooth scanner.""" if self._start_future is not None and not self._start_future.done(): self._start_future.set_exception(_AbortStartError()) async with self._start_stop_lock: self._async_stop_scanner_watchdog() await self._async_stop_scanner() async def _async_stop_scanner(self) -> None: """Stop bluetooth discovery under the lock.""" self.scanning = False if self.scanner is None: _LOGGER.debug("%s: Scanner is already stopped", self.name) return _LOGGER.debug("%s: Stopping bluetooth discovery", self.name) try: async with asyncio.timeout(STOP_TIMEOUT): await self.scanner.stop() except (TimeoutError, BleakError) as ex: # This is not fatal, and they may want to reload # the config entry to restart the scanner if they # change the bluetooth dongle. _LOGGER.error("%s: Error stopping scanner: %s", self.name, ex) self.scanner = None async def _async_force_stop_discovery(self) -> None: """Force stop discovery.""" _LOGGER.debug("%s: Force stopping bluetooth discovery", self.name) try: async with asyncio.timeout(STOP_TIMEOUT): await stop_discovery(self.adapter) except TimeoutError as ex: _LOGGER.error("%s: Timeout force stopping scanner: %s", self.name, ex) except Exception as ex: _LOGGER.error("%s: Failed to force stop scanner: %s", self.name, ex) Bluetooth-Devices-habluetooth-21accde/src/habluetooth/scanner_device.py000066400000000000000000000013341507024716100265520ustar00rootroot00000000000000"""Base classes for HA Bluetooth scanners for bluetooth.""" from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData if TYPE_CHECKING: from .base_scanner import BaseHaScanner @dataclass(slots=True) class BluetoothScannerDevice: """Data for a bluetooth device from a given scanner.""" scanner: BaseHaScanner ble_device: BLEDevice advertisement: AdvertisementData def score_connection_path(self, rssi_diff: int) -> float: """Return a score for the connection path to this device.""" return self.scanner._score_connection_paths(rssi_diff, self) Bluetooth-Devices-habluetooth-21accde/src/habluetooth/storage.py000066400000000000000000000250241507024716100252500ustar00rootroot00000000000000"""Serialize/Deserialize bluetooth adapter discoveries.""" from __future__ import annotations import logging import time from dataclasses import dataclass, field from typing import Any, Final, TypedDict from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData _LOGGER = logging.getLogger(__name__) @dataclass class DiscoveredDeviceAdvertisementData: """Discovered device advertisement data deserialized from storage.""" connectable: bool expire_seconds: float discovered_device_advertisement_datas: dict[ str, tuple[BLEDevice, AdvertisementData] ] discovered_device_timestamps: dict[str, float] discovered_device_raw: dict[str, bytes | None] = field(default_factory=dict) CONNECTABLE: Final = "connectable" EXPIRE_SECONDS: Final = "expire_seconds" DISCOVERED_DEVICE_ADVERTISEMENT_DATAS: Final = "discovered_device_advertisement_datas" DISCOVERED_DEVICE_TIMESTAMPS: Final = "discovered_device_timestamps" DISCOVERED_DEVICE_RAW: Final = "discovered_device_raw" class DiscoveredDeviceAdvertisementDataDict(TypedDict): """Discovered device advertisement data dict in storage.""" connectable: bool expire_seconds: float discovered_device_advertisement_datas: dict[str, DiscoveredDeviceDict] discovered_device_timestamps: dict[str, float] discovered_device_raw: dict[str, str | None] ADDRESS: Final = "address" NAME: Final = "name" RSSI: Final = "rssi" DETAILS: Final = "details" class BLEDeviceDict(TypedDict): """BLEDevice dict.""" address: str name: str | None rssi: int | None # Kept for backward compatibility details: dict[str, Any] LOCAL_NAME: Final = "local_name" MANUFACTURER_DATA: Final = "manufacturer_data" SERVICE_DATA: Final = "service_data" SERVICE_UUIDS: Final = "service_uuids" TX_POWER: Final = "tx_power" PLATFORM_DATA: Final = "platform_data" class AdvertisementDataDict(TypedDict): """AdvertisementData dict.""" local_name: str | None manufacturer_data: dict[str, str] service_data: dict[str, str] service_uuids: list[str] rssi: int tx_power: int | None platform_data: list[Any] class DiscoveredDeviceDict(TypedDict): """Discovered device dict.""" device: BLEDeviceDict advertisement_data: AdvertisementDataDict def expire_stale_scanner_discovered_device_advertisement_data( data_by_scanner: dict[str, DiscoveredDeviceAdvertisementDataDict], ) -> None: """Expire stale discovered device advertisement data.""" now = time.time() expired_scanners: list[str] = [] for scanner, data in data_by_scanner.items(): expire: list[str] = [] expire_seconds = data[EXPIRE_SECONDS] timestamps = data[DISCOVERED_DEVICE_TIMESTAMPS] discovered_device_advertisement_datas = data[ DISCOVERED_DEVICE_ADVERTISEMENT_DATAS ] discovered_device_raw = data.get(DISCOVERED_DEVICE_RAW, {}) for address, timestamp in timestamps.items(): time_diff = now - timestamp if time_diff > expire_seconds: expire.append(address) elif time_diff < 0: _LOGGER.warning( "Discarding timestamp %s for %s on " "scanner %s as it is the future (now = %s)", timestamp, address, scanner, now, ) expire.append(address) for address in expire: del timestamps[address] del discovered_device_advertisement_datas[address] discovered_device_raw.pop(address, None) if not timestamps: expired_scanners.append(scanner) _LOGGER.debug( "Loaded %s fresh discovered devices for %s", len(timestamps), scanner ) for scanner in expired_scanners: del data_by_scanner[scanner] def discovered_device_advertisement_data_from_dict( data: DiscoveredDeviceAdvertisementDataDict, ) -> DiscoveredDeviceAdvertisementData | None: """Build discovered_device_advertisement_data dict.""" try: return DiscoveredDeviceAdvertisementData( data[CONNECTABLE], data[EXPIRE_SECONDS], _deserialize_discovered_device_advertisement_datas( data[DISCOVERED_DEVICE_ADVERTISEMENT_DATAS] ), _deserialize_discovered_device_timestamps( data[DISCOVERED_DEVICE_TIMESTAMPS] ), _deserialize_discovered_device_raw(data.get(DISCOVERED_DEVICE_RAW, {})), ) except Exception as err: # pylint: disable=broad-except _LOGGER.exception( "Error deserializing discovered_device_advertisement_data" ", adapter startup will be slow: %s", err, ) return None def discovered_device_advertisement_data_to_dict( data: DiscoveredDeviceAdvertisementData, ) -> DiscoveredDeviceAdvertisementDataDict: """Build discovered_device_advertisement_data dict.""" return DiscoveredDeviceAdvertisementDataDict( connectable=data.connectable, expire_seconds=data.expire_seconds, discovered_device_advertisement_datas=_serialize_discovered_device_advertisement_datas( data.discovered_device_advertisement_datas ), discovered_device_timestamps=_serialize_discovered_device_timestamps( data.discovered_device_timestamps ), discovered_device_raw=_serialize_discovered_device_raw( data.discovered_device_raw ), ) def _serialize_discovered_device_advertisement_datas( discovered_device_advertisement_datas: dict[ str, tuple[BLEDevice, AdvertisementData] ], ) -> dict[str, DiscoveredDeviceDict]: """Serialize discovered_device_advertisement_datas.""" return { address: DiscoveredDeviceDict( device=_ble_device_to_dict(device, advertisement_data), advertisement_data=_advertisement_data_to_dict(advertisement_data), ) for ( address, (device, advertisement_data), ) in discovered_device_advertisement_datas.items() } def _deserialize_discovered_device_advertisement_datas( discovered_device_advertisement_datas: dict[str, DiscoveredDeviceDict], ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: """Deserialize discovered_device_advertisement_datas.""" return { address: ( _ble_device_from_dict(device_advertisement_data["device"]), _advertisement_data_from_dict( device_advertisement_data["advertisement_data"] ), ) for ( address, device_advertisement_data, ) in discovered_device_advertisement_datas.items() } def _ble_device_from_dict(device_dict: BLEDeviceDict) -> BLEDevice: """Deserialize BLEDevice from dict, handling backward compatibility.""" # Remove rssi from dict as BLEDevice no longer accepts it in bleak 1.x device_data = device_dict.copy() device_data.pop("rssi", None) # type: ignore[misc] # Remove rssi if present (backward compatibility) return BLEDevice(**device_data) def _ble_device_to_dict( ble_device: BLEDevice, advertisement_data: AdvertisementData ) -> BLEDeviceDict: """Serialize ble_device.""" return BLEDeviceDict( address=ble_device.address, name=ble_device.name, rssi=advertisement_data.rssi, # For backward compatibility details=ble_device.details, ) def _advertisement_data_from_dict( advertisement_data: AdvertisementDataDict, ) -> AdvertisementData: """Deserialize advertisement_data.""" return AdvertisementData( local_name=advertisement_data[LOCAL_NAME], manufacturer_data={ int(manufacturer_id): bytes.fromhex(manufacturer_data) for manufacturer_id, manufacturer_data in advertisement_data[ MANUFACTURER_DATA ].items() }, service_data={ service_uuid: bytes.fromhex(service_data) for service_uuid, service_data in advertisement_data[SERVICE_DATA].items() }, service_uuids=advertisement_data[SERVICE_UUIDS], rssi=advertisement_data[RSSI], tx_power=advertisement_data[TX_POWER], platform_data=tuple(advertisement_data[PLATFORM_DATA]), ) def _advertisement_data_to_dict( advertisement_data: AdvertisementData, ) -> AdvertisementDataDict: """Serialize advertisement_data.""" return AdvertisementDataDict( local_name=advertisement_data.local_name, manufacturer_data={ str(manufacturer_id): manufacturer_data.hex() for manufacturer_id, manufacturer_data in advertisement_data.manufacturer_data.items() # noqa: E501 }, service_data={ service_uuid: service_data.hex() for service_uuid, service_data in advertisement_data.service_data.items() }, service_uuids=advertisement_data.service_uuids, rssi=advertisement_data.rssi, tx_power=advertisement_data.tx_power, platform_data=list(advertisement_data.platform_data), ) def _get_monotonic_time_diff() -> float: """Get monotonic time diff.""" return time.time() - time.monotonic() def _deserialize_discovered_device_timestamps( discovered_device_timestamps: dict[str, float], ) -> dict[str, float]: """Deserialize discovered_device_timestamps.""" time_diff = _get_monotonic_time_diff() return { address: unix_time - time_diff for address, unix_time in discovered_device_timestamps.items() } def _serialize_discovered_device_timestamps( discovered_device_timestamps: dict[str, float], ) -> dict[str, float]: """Serialize discovered_device_timestamps.""" time_diff = _get_monotonic_time_diff() return { address: monotonic_time + time_diff for address, monotonic_time in discovered_device_timestamps.items() } def _deserialize_discovered_device_raw( discovered_device_raw: dict[str, str | None], ) -> dict[str, bytes | None]: """Deserialize discovered_device_timestamps.""" return { address: None if raw is None else bytes.fromhex(raw) for address, raw in discovered_device_raw.items() } def _serialize_discovered_device_raw( discovered_device_raw: dict[str, bytes | None], ) -> dict[str, str | None]: """Serialize discovered_device_timestamps.""" return { address: None if raw is None else raw.hex() for address, raw in discovered_device_raw.items() } DiscoveryStorageType = dict[str, DiscoveredDeviceAdvertisementDataDict] Bluetooth-Devices-habluetooth-21accde/src/habluetooth/usage.py000066400000000000000000000033051507024716100247060ustar00rootroot00000000000000"""bluetooth usage utility to handle multiple instances.""" from __future__ import annotations import bleak import bleak_retry_connector from bleak.backends.service import BleakGATTServiceCollection from .wrappers import HaBleakClientWrapper, HaBleakScannerWrapper ORIGINAL_BLEAK_SCANNER = bleak.BleakScanner ORIGINAL_BLEAK_CLIENT = bleak.BleakClient ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT_WITH_SERVICE_CACHE = ( bleak_retry_connector.BleakClientWithServiceCache ) ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT = bleak_retry_connector.BleakClient def install_multiple_bleak_catcher() -> None: """ Wrap the bleak classes to return the shared instance. In case multiple instances are detected. """ bleak.BleakScanner = HaBleakScannerWrapper bleak.BleakClient = HaBleakClientWrapper bleak_retry_connector.BleakClientWithServiceCache = HaBleakClientWithServiceCache bleak_retry_connector.BleakClient = HaBleakClientWrapper def uninstall_multiple_bleak_catcher() -> None: """Unwrap the bleak classes.""" bleak.BleakScanner = ORIGINAL_BLEAK_SCANNER bleak.BleakClient = ORIGINAL_BLEAK_CLIENT bleak_retry_connector.BleakClientWithServiceCache = ( ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT_WITH_SERVICE_CACHE ) bleak_retry_connector.BleakClient = ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT class HaBleakClientWithServiceCache(HaBleakClientWrapper): """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. """ Bluetooth-Devices-habluetooth-21accde/src/habluetooth/util.py000066400000000000000000000011061507024716100245540ustar00rootroot00000000000000"""The bluetooth utilities.""" from functools import cache from pathlib import Path from bluetooth_auto_recovery import recover_adapter async def async_reset_adapter( adapter: str | None, mac_address: str, gone_silent: bool ) -> bool | None: """Reset the adapter.""" if adapter and adapter.startswith("hci"): adapter_id = int(adapter[3:]) return await recover_adapter(adapter_id, mac_address, gone_silent) return False @cache def is_docker_env() -> bool: """Return True if we run in a docker env.""" return Path("/.dockerenv").exists() Bluetooth-Devices-habluetooth-21accde/src/habluetooth/wrappers.py000066400000000000000000000444521507024716100254550ustar00rootroot00000000000000"""Bleak wrappers for bluetooth.""" from __future__ import annotations import asyncio import contextlib import inspect import logging from collections.abc import Callable from dataclasses import dataclass from functools import partial from typing import TYPE_CHECKING, Any, Final, Literal, overload from bleak import BleakClient, BleakError, normalize_uuid_str from bleak.backends.client import BaseBleakClient, get_platform_client_backend_type from bleak.backends.device import BLEDevice from bleak.backends.scanner import ( AdvertisementData, AdvertisementDataCallback, BaseBleakScanner, ) from bleak_retry_connector import ( ble_device_description, clear_cache, device_source, ) from .central_manager import get_manager from .const import BDADDR_LE_PUBLIC, BDADDR_LE_RANDOM, CALLBACK_TYPE, ConnectParams FILTER_UUIDS: Final = "UUIDs" _LOGGER = logging.getLogger(__name__) def _get_device_address_type(device: BLEDevice) -> int: """ Get the address type for a BLE device. Returns: BDADDR_LE_RANDOM if the device has a random address, BDADDR_LE_PUBLIC otherwise """ details: dict[str, dict[str, Any]] = device.details return ( BDADDR_LE_RANDOM if details.get("props", {}).get("AddressType") == "random" else BDADDR_LE_PUBLIC ) if TYPE_CHECKING: from .base_scanner import BaseHaScanner from .manager import BluetoothManager @dataclass(slots=True) class _HaWrappedBleakBackend: """Wrap bleak backend to make it usable by Home Assistant.""" device: BLEDevice scanner: BaseHaScanner client: type[BaseBleakClient] source: str | None class HaBleakScannerWrapper(BaseBleakScanner): """A wrapper that uses the single instance.""" def __init__( self, *args: Any, detection_callback: AdvertisementDataCallback | None = None, service_uuids: list[str] | None = None, **kwargs: Any, ) -> None: """Initialize the BleakScanner.""" self._detection_cancel: CALLBACK_TYPE | None = None self._mapped_filters: dict[str, set[str]] = {} self._advertisement_data_callback: AdvertisementDataCallback | None = None self._background_tasks: set[asyncio.Task[Any]] = set() remapped_kwargs = { "detection_callback": detection_callback, "service_uuids": service_uuids or [], **kwargs, } self._map_filters(*args, **remapped_kwargs) super().__init__( detection_callback=detection_callback, service_uuids=service_uuids or [] ) @classmethod async def find_device_by_address( cls, device_identifier: str, timeout: float = 10.0, **kwargs: Any ) -> BLEDevice | None: """Find a device by address.""" manager = get_manager() return manager.async_ble_device_from_address( device_identifier, True ) or manager.async_ble_device_from_address(device_identifier, False) @overload @classmethod async def discover( cls, timeout: float = 5.0, *, return_adv: Literal[False] = False, **kwargs: Any ) -> list[BLEDevice]: ... @overload @classmethod async def discover( cls, timeout: float = 5.0, *, return_adv: Literal[True], **kwargs: Any ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: ... @classmethod async def discover( cls, timeout: float = 5.0, *, return_adv: bool = False, **kwargs: Any ) -> list[BLEDevice] | dict[str, tuple[BLEDevice, AdvertisementData]]: """Discover devices.""" infos = get_manager().async_discovered_service_info(True) if return_adv: return {info.address: (info.device, info.advertisement) for info in infos} return [info.device for info in infos] async def stop(self, *args: Any, **kwargs: Any) -> None: """Stop scanning for devices.""" async def start(self, *args: Any, **kwargs: Any) -> None: """Start scanning for devices.""" def _map_filters(self, *args: Any, **kwargs: Any) -> bool: """Map the filters.""" mapped_filters = {} if filters := kwargs.get("filters"): if filter_uuids := filters.get(FILTER_UUIDS): mapped_filters[FILTER_UUIDS] = set(filter_uuids) else: _LOGGER.warning("Only %s filters are supported", FILTER_UUIDS) if service_uuids := kwargs.get("service_uuids"): mapped_filters[FILTER_UUIDS] = set(service_uuids) if mapped_filters == self._mapped_filters: return False self._mapped_filters = mapped_filters return True def set_scanning_filter(self, *args: Any, **kwargs: Any) -> None: """Set the filters to use.""" if self._map_filters(*args, **kwargs): self._setup_detection_callback() def _cancel_callback(self) -> None: """Cancel callback.""" if self._detection_cancel: self._detection_cancel() self._detection_cancel = None @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" return list(get_manager().async_discovered_devices(True)) def register_detection_callback( self, callback: AdvertisementDataCallback | None ) -> Callable[[], None]: """ Register a detection callback. The callback is called when a device is discovered or has a property changed. This method takes the callback and registers it with the long running scanner. """ self._advertisement_data_callback = callback self._setup_detection_callback() if TYPE_CHECKING: assert self._detection_cancel is not None return self._detection_cancel def _setup_detection_callback(self) -> None: """Set up the detection callback.""" if self._advertisement_data_callback is None: return callback = self._advertisement_data_callback self._cancel_callback() super().register_detection_callback(self._advertisement_data_callback) manager = get_manager() if not inspect.iscoroutinefunction(callback): detection_callback = callback else: def detection_callback( ble_device: BLEDevice, advertisement_data: AdvertisementData ) -> None: task = asyncio.create_task(callback(ble_device, advertisement_data)) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) self._detection_cancel = manager.async_register_bleak_callback( detection_callback, self._mapped_filters ) def __del__(self) -> None: """Delete the BleakScanner.""" if self._detection_cancel: # Nothing to do if event loop is already closed with contextlib.suppress(RuntimeError): asyncio.get_running_loop().call_soon_threadsafe(self._detection_cancel) class HaBleakClientWrapper(BleakClient): """ Wrap the BleakClient to ensure it does not shutdown our scanner. If an address is passed into BleakClient instead of a BLEDevice, bleak will quietly start a new scanner under the hood to resolve the address. This can cause a conflict with our scanner. We need to handle translating the address to the BLEDevice in this case to avoid the whole stack from getting stuck in an in progress state when an integration does this. """ def __init__( # pylint: disable=super-init-not-called self, address_or_ble_device: str | BLEDevice, disconnected_callback: Callable[[BleakClient], None] | None = None, services: list[str] | None = None, *, timeout: float = 10.0, pair: bool = False, **kwargs: Any, ) -> None: """Initialize the BleakClient.""" if isinstance(address_or_ble_device, BLEDevice): self.__address = address_or_ble_device.address else: # If we are passed an address we need to make sure # its not a subclassed str self.__address = str(address_or_ble_device) self.__disconnected_callback = disconnected_callback self.__manager = get_manager() self.__timeout = timeout self.__services = services self._backend: BaseBleakClient | None = None self._pair_before_connect = pair # Check if this client is being created through establish_connection # by checking for the '_is_retry_client' marker in kwargs self._is_retry_client = kwargs.pop("_is_retry_client", False) @property def is_connected(self) -> bool: """Return True if the client is connected to a device.""" return self._backend is not None and self._backend.is_connected async def clear_cache(self) -> bool: """Clear the GATT cache.""" if self._backend is not None and hasattr(self._backend, "clear_cache"): return await self._backend.clear_cache() return await clear_cache(self.__address) def set_disconnected_callback( self, callback: Callable[[BleakClient], None] | None, **kwargs: Any, ) -> None: """Set the disconnect callback.""" self.__disconnected_callback = callback if self._backend: self._backend.set_disconnected_callback( self._make_disconnected_callback(callback), **kwargs, ) def _make_disconnected_callback( self, callback: Callable[[BleakClient], None] | None ) -> Callable[[], None] | None: """ Make the disconnected callback. https://github.com/hbldh/bleak/pull/1256 The disconnected callback needs to get the top level BleakClientWrapper instance, not the backend instance. The signature of the callback for the backend is: Callable[[], None] To make this work we need to wrap the callback in a partial that passes the BleakClientWrapper instance as the first argument. """ return None if callback is None else partial(callback, self) async def connect(self, **kwargs: Any) -> None: """Connect to the specified GATT server.""" if self.is_connected: return # Warn if not using bleak-retry-connector's establish_connection if not self._is_retry_client: _LOGGER.warning( "%s: BleakClient.connect() called without bleak-retry-connector. " "For reliable connection establishment, use " "bleak_retry_connector.establish_connection(). " "See https://github.com/Bluetooth-Devices/bleak-retry-connector", self.__address, ) manager = self.__manager if manager.shutdown: raise BleakError("Bluetooth is already shutdown") if debug_logging := _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug("%s: Looking for backend to connect", self.__address) wrapped_backend = self._async_get_best_available_backend_and_device(manager) device = wrapped_backend.device scanner = wrapped_backend.scanner self._backend = wrapped_backend.client( device, disconnected_callback=self._make_disconnected_callback( self.__disconnected_callback ), services=( None if self.__services is None else set(map(normalize_uuid_str, self.__services)) ), timeout=self.__timeout, ) description = "" rssi = None if debug_logging: # Only lookup the description if we are going to log it description = ble_device_description(device) device_adv = scanner.get_discovered_device_advertisement_data( device.address ) if TYPE_CHECKING: assert device_adv is not None adv = device_adv[1] rssi = adv.rssi _LOGGER.debug( "%s: Connecting via %s (last rssi: %s)", description, scanner.name, rssi ) # Load fast connection parameters before connecting if mgmt API is available self._load_conn_params( scanner, device, ConnectParams.FAST, debug_logging, description, ) connected = False address = device.address try: scanner._add_connecting(address) await super().connect(**kwargs) connected = True except Exception: # Connection failed, ensure we clean up self._backend = None raise finally: scanner._finished_connecting(address, connected) # If we failed to connect and its a local adapter (no source) # we release the connection slot if not connected and not wrapped_backend.source: manager.async_release_connection_slot(device) # Load medium connection parameters after successful connection if connected: self._load_conn_params( scanner, device, ConnectParams.MEDIUM, debug_logging, description, ) if debug_logging: _LOGGER.debug( "%s: %s via %s (last rssi: %s)", description, "Connected" if connected else "Failed to connect", scanner.name, rssi, ) return def _load_conn_params( self, scanner: BaseHaScanner, device: BLEDevice, params: ConnectParams, debug_logging: bool, description: str, ) -> None: """Load connection parameters for a device.""" if ( (adapter_idx := scanner.adapter_idx) is not None and (mgmt_ctl := self.__manager.get_bluez_mgmt_ctl()) and mgmt_ctl.load_conn_params( adapter_idx, device.address, _get_device_address_type(device), params, ) and debug_logging ): _LOGGER.debug("%s: Loaded %s connection parameters", description, params) def _async_get_backend_for_ble_device( self, manager: BluetoothManager, scanner: BaseHaScanner, ble_device: BLEDevice ) -> _HaWrappedBleakBackend | None: """Get the backend for a BLEDevice.""" if not (source := device_source(ble_device)): # If client is not defined in details # its the client for this platform if not manager.async_allocate_connection_slot(ble_device): return None cls = get_platform_client_backend_type() return _HaWrappedBleakBackend(ble_device, scanner, cls, source) # Make sure the backend can connect to the device # as some backends have connection limits if not scanner.connector or not scanner.connector.can_connect(): return None return _HaWrappedBleakBackend( ble_device, scanner, scanner.connector.client, source ) def _async_get_best_available_backend_and_device( self, manager: BluetoothManager ) -> _HaWrappedBleakBackend: """ Get a best available backend and device for the given address. This method will return the backend with the best rssi that has a free connection slot. """ address = self.__address sorted_devices = sorted( manager.async_scanner_devices_by_address(self.__address, True), key=lambda x: x.advertisement.rssi, reverse=True, ) rssi_diff = 0 # Default when there's only one device if len(sorted_devices) > 1: rssi_diff = ( sorted_devices[0].advertisement.rssi - sorted_devices[1].advertisement.rssi ) sorted_devices = sorted( sorted_devices, key=lambda device: device.score_connection_path(rssi_diff), reverse=True, ) if sorted_devices and _LOGGER.isEnabledFor(logging.INFO): _LOGGER.info( "%s - %s: Found %s connection path(s), preferred order: %s", address, sorted_devices[0].ble_device.name, len(sorted_devices), ", ".join( ( f"{device.scanner.name} " f"(RSSI={device.advertisement.rssi}) " f"(failures={device.scanner._connection_failures(address)}) " f"(in_progress={device.scanner._connections_in_progress()}) " + ( f"(slots={allocations.free}/{allocations.slots} free) " if (allocations := device.scanner.get_allocations()) else "" ) + f"(score={device.score_connection_path(rssi_diff)})" ) for device in sorted_devices ), ) for device in sorted_devices: if backend := self._async_get_backend_for_ble_device( manager, device.scanner, device.ble_device ): return backend # Check if all registered scanners are passive-only if scanners := manager.async_current_scanners(): has_active_capable_scanner = any( scanner.connectable for scanner in scanners ) if not has_active_capable_scanner: scanner_names = [scanner.name for scanner in scanners] raise BleakError( f"{address}: No connectable Bluetooth adapters. " f"Shelly devices are passive-only and cannot connect. " f"Need local Bluetooth adapter or ESPHome proxy. " f"Available: {', '.join(scanner_names)}" ) raise BleakError( "No backend with an available connection slot that can reach address" f" {address} was found" ) async def disconnect(self) -> None: """Disconnect from the device.""" if self._backend is None: return await self._backend.disconnect() Bluetooth-Devices-habluetooth-21accde/templates/000077500000000000000000000000001507024716100221205ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-21accde/templates/CHANGELOG.md.j2000066400000000000000000000012351507024716100242440ustar00rootroot00000000000000# Changelog {%- for version, release in context.history.released.items() %} ## {{ version.as_tag() }} ({{ release.tagged_date.strftime("%Y-%m-%d") }}) {%- for category, commits in release["elements"].items() %} {# Category title: Breaking, Fix, Documentation #} ### {{ category | capitalize }} {# List actual changes in the category #} {%- for commit in commits %} {% if commit is not none and commit.descriptions is defined %} - {{ commit.descriptions[0] | capitalize }} ([`{{ commit.short_hash }}`]({{ commit.hexsha | commit_hash_url }})) {% endif %} {%- endfor %}{# for commit #} {%- endfor %}{# for category, commits #} {%- endfor %}{# for version, release #} Bluetooth-Devices-habluetooth-21accde/tests/000077500000000000000000000000001507024716100212645ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-21accde/tests/__init__.py000066400000000000000000000116601507024716100234010ustar00rootroot00000000000000import asyncio import time from collections.abc import Generator from contextlib import contextmanager from datetime import UTC, datetime from functools import partial from typing import Any from unittest.mock import MagicMock, patch from bleak.backends.scanner import AdvertisementData, BLEDevice from habluetooth import get_manager from habluetooth.models import BluetoothServiceInfoBleak utcnow = partial(datetime.now, UTC) HCI0_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:00" HCI1_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:11" NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:FF" _MONOTONIC_RESOLUTION = time.get_clock_info("monotonic").resolution ADVERTISEMENT_DATA_DEFAULTS = { "local_name": "Unknown", "manufacturer_data": {}, "service_data": {}, "service_uuids": [], "rssi": -127, "platform_data": ((),), "tx_power": -127, } BLE_DEVICE_DEFAULTS = { "name": None, "details": None, } def generate_advertisement_data(**kwargs: Any) -> AdvertisementData: """Generate advertisement data with defaults.""" new = kwargs.copy() for key, value in ADVERTISEMENT_DATA_DEFAULTS.items(): new.setdefault(key, value) return AdvertisementData(**new) def generate_ble_device( address: str | None = None, name: str | None = None, details: Any | None = None, **kwargs: Any, ) -> BLEDevice: """Generate a BLEDevice with defaults.""" new = kwargs.copy() if address is not None: new["address"] = address if name is not None: new["name"] = name if details is not None: new["details"] = details for key, value in BLE_DEVICE_DEFAULTS.items(): new.setdefault(key, value) return BLEDevice(**new) @contextmanager def patch_bluetooth_time(mock_time: float) -> Generator[Any, None, None]: """Patch the bluetooth time.""" with ( patch("habluetooth.base_scanner.monotonic_time_coarse", return_value=mock_time), patch("habluetooth.manager.monotonic_time_coarse", return_value=mock_time), patch("habluetooth.scanner.monotonic_time_coarse", return_value=mock_time), ): yield def async_fire_time_changed(utc_datetime: datetime) -> None: timestamp = utc_datetime.timestamp() loop = asyncio.get_running_loop() for task in list(loop._scheduled): # type: ignore[attr-defined] if not isinstance(task, asyncio.TimerHandle): continue if task.cancelled(): continue mock_seconds_into_future = timestamp - time.time() future_seconds = task.when() - (loop.time() + _MONOTONIC_RESOLUTION) if mock_seconds_into_future >= future_seconds: task._run() task.cancel() class MockBleakClient: pass def inject_advertisement(device: BLEDevice, adv: AdvertisementData) -> None: """Inject an advertisement into the manager.""" return inject_advertisement_with_source(device, adv, "local") def inject_advertisement_with_source( device: BLEDevice, adv: AdvertisementData, source: str ) -> None: """Inject an advertisement into the manager from a specific source.""" inject_advertisement_with_time_and_source(device, adv, time.monotonic(), source) def inject_advertisement_with_time_and_source( device: BLEDevice, adv: AdvertisementData, time: float, source: str, ) -> None: """Inject an advertisement into the manager from a specific source at a time.""" inject_advertisement_with_time_and_source_connectable( device, adv, time, source, True ) def inject_advertisement_with_time_and_source_connectable( device: BLEDevice, adv: AdvertisementData, time: float, source: str, connectable: bool, ) -> None: """ Inject an advertisement into the manager from a specific source at a time. As well as and connectable status. """ manager = get_manager() manager.scanner_adv_received( BluetoothServiceInfoBleak( name=adv.local_name or device.name or device.address, address=device.address, rssi=adv.rssi, manufacturer_data=adv.manufacturer_data, service_data=adv.service_data, service_uuids=adv.service_uuids, source=source, device=device, advertisement=adv, connectable=connectable, time=time, tx_power=adv.tx_power, ) ) @contextmanager def patch_discovered_devices( mock_discovered: list[BLEDevice], ) -> Generator[None, None, None]: """Mock the combined best path to discovered devices from all the scanners.""" manager = get_manager() original_all_history = manager._all_history original_connectable_history = manager._connectable_history manager._connectable_history = {} manager._all_history = { device.address: MagicMock(device=device) for device in mock_discovered } yield manager._all_history = original_all_history manager._connectable_history = original_connectable_history Bluetooth-Devices-habluetooth-21accde/tests/channels/000077500000000000000000000000001507024716100230575ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-21accde/tests/channels/__init__.py000066400000000000000000000000001507024716100251560ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-21accde/tests/channels/test_bluez.py000066400000000000000000001426201507024716100256160ustar00rootroot00000000000000"""Tests for the BlueZ management API module.""" from __future__ import annotations import asyncio import logging from typing import Any from unittest.mock import Mock, patch import pytest from btsocket.btmgmt_socket import BluetoothSocketError from habluetooth.channels.bluez import ( BluetoothMGMTProtocol, MGMTBluetoothCtl, ) from habluetooth.const import ( BDADDR_LE_PUBLIC, BDADDR_LE_RANDOM, FAST_CONN_LATENCY, FAST_CONN_TIMEOUT, FAST_MAX_CONN_INTERVAL, FAST_MIN_CONN_INTERVAL, MEDIUM_CONN_LATENCY, MEDIUM_CONN_TIMEOUT, MEDIUM_MAX_CONN_INTERVAL, MEDIUM_MIN_CONN_INTERVAL, ConnectParams, ) from habluetooth.scanner import HaScanner class MockHaScanner(HaScanner): """Mock HaScanner for testing with Cython.""" def __init__(self): """Initialize without calling parent __init__ to avoid BleakScanner setup.""" self.source = "test" self.connectable = True # Mock the method that will be called self._async_on_raw_bluez_advertisement: Any = Mock() @pytest.fixture def event_loop(): """Create and manage event loop for tests.""" loop = asyncio.new_event_loop() yield loop loop.close() @pytest.fixture def mock_scanner() -> MockHaScanner: """Create a mock scanner for testing.""" return MockHaScanner() @pytest.fixture def mock_transport() -> Mock: """Create a mock transport.""" transport = Mock() transport.write = Mock() # Create a mock socket for direct writes mock_socket = Mock() mock_socket.send = Mock(return_value=6) # Default to successful send transport.get_extra_info = Mock(return_value=mock_socket) return transport def test_connection_made( event_loop: asyncio.AbstractEventLoop, mock_transport: Mock ) -> None: """Test connection_made sets up the protocol correctly.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) protocol.connection_made(mock_transport) assert protocol.transport is mock_transport assert future.done() assert future.result() is None def test_connection_lost( event_loop: asyncio.AbstractEventLoop, mock_transport: Mock ) -> None: """Test connection_lost handles disconnection.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) protocol.connection_made(mock_transport) # Test with exception protocol.connection_lost(Exception("Test error")) assert protocol.transport is None on_connection_lost.assert_called_once() def test_connection_lost_no_exception( event_loop: asyncio.AbstractEventLoop, mock_transport: Mock, caplog: pytest.LogCaptureFixture, ) -> None: """Test connection_lost without exception.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) protocol.connection_made(mock_transport) # Test without exception protocol.connection_lost(None) assert "Bluetooth management socket connection closed" in caplog.text def test_data_received_device_found( event_loop: asyncio.AbstractEventLoop, mock_scanner: MockHaScanner ) -> None: """Test data_received handles DEVICE_FOUND event.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {0: mock_scanner} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a DEVICE_FOUND event (event_code 0x0012) # Header: event_code (2), controller_idx (2), param_len (2) # Params: address (6), address_type (1), rssi (1), flags (4), # ad_data_len (2), ad_data ad_data = b"\x02\x01\x06" # Simple advertisement data param_len = 6 + 1 + 1 + 4 + 2 + len(ad_data) header = b"\x12\x00" # DEVICE_FOUND header += b"\x00\x00" # controller_idx = 0 header += param_len.to_bytes(2, "little") params = b"\xaa\xbb\xcc\xdd\xee\xff" # address (reversed) params += b"\x01" # address_type params += b"\xc8" # rssi = -56 (200 - 256) params += b"\x00\x00\x00\x00" # flags params += len(ad_data).to_bytes(2, "little") # ad_data_len params += ad_data protocol.data_received(header + params) mock_scanner._async_on_raw_bluez_advertisement.assert_called_once_with( b"\xaa\xbb\xcc\xdd\xee\xff", 1, -56, 0, ad_data, ) def test_data_received_adv_monitor_device_found( event_loop: asyncio.AbstractEventLoop, mock_scanner: MockHaScanner ) -> None: """Test data_received handles ADV_MONITOR_DEVICE_FOUND event.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {0: mock_scanner} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create an ADV_MONITOR_DEVICE_FOUND event (event_code 0x002F) # Has 2 extra bytes at the beginning of params ad_data = b"\x02\x01\x06" param_len = 2 + 6 + 1 + 1 + 4 + 2 + len(ad_data) header = b"\x2f\x00" # ADV_MONITOR_DEVICE_FOUND header += b"\x00\x00" # controller_idx = 0 header += param_len.to_bytes(2, "little") params = b"\x00\x00" # 2 extra bytes params += b"\xaa\xbb\xcc\xdd\xee\xff" # address params += b"\x02" # address_type params += b"\x64" # rssi = 100 (positive, no conversion needed) params += b"\x00\x00\x00\x00" # flags params += len(ad_data).to_bytes(2, "little") params += ad_data protocol.data_received(header + params) mock_scanner._async_on_raw_bluez_advertisement.assert_called_once_with( b"\xaa\xbb\xcc\xdd\xee\xff", 2, 100, 0, ad_data, ) def test_data_received_cmd_complete_success( event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture, ) -> None: """Test data_received handles successful MGMT_EV_CMD_COMPLETE.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a CMD_COMPLETE event for LOAD_CONN_PARAM header = b"\x01\x00" # MGMT_EV_CMD_COMPLETE header += b"\x00\x00" # controller_idx = 0 header += b"\x03\x00" # param_len = 3 params = b"\x35\x00" # opcode = MGMT_OP_LOAD_CONN_PARAM params += b"\x00" # status = 0 (success) protocol.data_received(header + params) assert "Connection parameters loaded successfully" in caplog.text def test_data_received_cmd_complete_failure( event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture, ) -> None: """Test data_received handles failed MGMT_EV_CMD_COMPLETE.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a CMD_COMPLETE event with failure header = b"\x01\x00" # MGMT_EV_CMD_COMPLETE header += b"\x01\x00" # controller_idx = 1 header += b"\x03\x00" # param_len = 3 params = b"\x35\x00" # opcode = MGMT_OP_LOAD_CONN_PARAM params += b"\x0c" # status = 12 (Not Supported) protocol.data_received(header + params) assert "Failed to load conn params: status=12" in caplog.text def test_data_received_cmd_status( event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture ) -> None: """Test data_received handles MGMT_EV_CMD_STATUS.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a CMD_STATUS event header = b"\x02\x00" # MGMT_EV_CMD_STATUS header += b"\x00\x00" # controller_idx = 0 header += b"\x03\x00" # param_len = 3 params = b"\x35\x00" # opcode = MGMT_OP_LOAD_CONN_PARAM params += b"\x01" # status = 1 (Unknown Command) protocol.data_received(header + params) assert "Failed to load conn params: status=1" in caplog.text def test_data_received_partial_data( event_loop: asyncio.AbstractEventLoop, mock_scanner: MockHaScanner ) -> None: """Test data_received handles partial data correctly.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {0: mock_scanner} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a DEVICE_FOUND event but send it in chunks ad_data = b"\x02\x01\x06" param_len = 6 + 1 + 1 + 4 + 2 + len(ad_data) full_data = b"\x12\x00\x00\x00" + param_len.to_bytes(2, "little") full_data += b"\xaa\xbb\xcc\xdd\xee\xff\x01\xc8\x00\x00\x00\x00" full_data += len(ad_data).to_bytes(2, "little") + ad_data # Send header first protocol.data_received(full_data[:6]) mock_scanner._async_on_raw_bluez_advertisement.assert_not_called() # Send rest of data protocol.data_received(full_data[6:]) mock_scanner._async_on_raw_bluez_advertisement.assert_called_once() def test_data_received_partial_data_split_in_params( event_loop: asyncio.AbstractEventLoop, mock_scanner: MockHaScanner ) -> None: """Test data_received handles data split in the middle of params.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {0: mock_scanner} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a DEVICE_FOUND event ad_data = b"\x02\x01\x06\x03\xff\x00\x01" # Longer ad data param_len = 6 + 1 + 1 + 4 + 2 + len(ad_data) full_data = b"\x12\x00\x00\x00" + param_len.to_bytes(2, "little") full_data += b"\xaa\xbb\xcc\xdd\xee\xff\x01\xc8\x00\x00\x00\x00" full_data += len(ad_data).to_bytes(2, "little") + ad_data # Split in the middle of the address protocol.data_received(full_data[:10]) # Header + part of address mock_scanner._async_on_raw_bluez_advertisement.assert_not_called() # Send rest of data protocol.data_received(full_data[10:]) mock_scanner._async_on_raw_bluez_advertisement.assert_called_once_with( b"\xaa\xbb\xcc\xdd\xee\xff", 1, -56, 0, ad_data, ) def test_data_received_multiple_small_chunks( event_loop: asyncio.AbstractEventLoop, mock_scanner: MockHaScanner ) -> None: """Test data_received handles data sent in many small chunks.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {0: mock_scanner} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a DEVICE_FOUND event ad_data = b"\x02\x01\x06" param_len = 6 + 1 + 1 + 4 + 2 + len(ad_data) full_data = b"\x12\x00\x00\x00" + param_len.to_bytes(2, "little") full_data += b"\xaa\xbb\xcc\xdd\xee\xff\x01\xc8\x00\x00\x00\x00" full_data += len(ad_data).to_bytes(2, "little") + ad_data # Send data byte by byte for i in range(len(full_data)): protocol.data_received(full_data[i : i + 1]) if i < len(full_data) - 1: mock_scanner._async_on_raw_bluez_advertisement.assert_not_called() # After all bytes are sent, callback should be called once mock_scanner._async_on_raw_bluez_advertisement.assert_called_once() def test_data_received_multiple_events_in_one_chunk( event_loop: asyncio.AbstractEventLoop, mock_scanner: Mock, caplog: pytest.LogCaptureFixture, ) -> None: """Test data_received handles multiple events in one data chunk.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {0: mock_scanner} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create two events: a DEVICE_FOUND and a CMD_COMPLETE ad_data = b"\x02\x01\x06" param_len1 = 6 + 1 + 1 + 4 + 2 + len(ad_data) event1 = b"\x12\x00\x00\x00" + param_len1.to_bytes(2, "little") event1 += b"\xaa\xbb\xcc\xdd\xee\xff\x01\xc8\x00\x00\x00\x00" event1 += len(ad_data).to_bytes(2, "little") + ad_data event2 = b"\x01\x00\x00\x00\x03\x00" # CMD_COMPLETE header event2 += b"\x35\x00\x00" # LOAD_CONN_PARAM success # Send both events in one chunk protocol.data_received(event1 + event2) # Both events should be processed mock_scanner._async_on_raw_bluez_advertisement.assert_called_once() assert "Connection parameters loaded successfully" in caplog.text def test_data_received_partial_then_multiple_events( event_loop: asyncio.AbstractEventLoop, mock_scanner: MockHaScanner ) -> None: """Test partial data followed by multiple complete events.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {0: mock_scanner} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # First event (DEVICE_FOUND) ad_data1 = b"\x02\x01\x06" param_len1 = 6 + 1 + 1 + 4 + 2 + len(ad_data1) event1 = b"\x12\x00\x00\x00" + param_len1.to_bytes(2, "little") event1 += b"\x11\x22\x33\x44\x55\x66\x01\xc8\x00\x00\x00\x00" event1 += len(ad_data1).to_bytes(2, "little") + ad_data1 # Second event (ADV_MONITOR_DEVICE_FOUND) ad_data2 = b"\x03\xff\x00\x01" param_len2 = 2 + 6 + 1 + 1 + 4 + 2 + len(ad_data2) event2 = b"\x2f\x00\x00\x00" + param_len2.to_bytes(2, "little") event2 += b"\x00\x00" # Extra 2 bytes event2 += b"\x77\x88\x99\xaa\xbb\xcc\x02\x64\x00\x00\x00\x00" event2 += len(ad_data2).to_bytes(2, "little") + ad_data2 # Send partial first event protocol.data_received(event1[:15]) mock_scanner._async_on_raw_bluez_advertisement.assert_not_called() # Send rest of first event + second event protocol.data_received(event1[15:] + event2) # Both callbacks should be called assert mock_scanner._async_on_raw_bluez_advertisement.call_count == 2 calls = mock_scanner._async_on_raw_bluez_advertisement.call_args_list # First call assert calls[0][0] == ( b"\x11\x22\x33\x44\x55\x66", 1, -56, 0, ad_data1, ) # Second call assert calls[1][0] == ( b"\x77\x88\x99\xaa\xbb\xcc", 2, 100, 0, ad_data2, ) def test_data_received_cmd_complete_different_opcode( event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture ) -> None: """Test data_received handles CMD_COMPLETE for different opcodes.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a CMD_COMPLETE event for a different opcode (e.g., 0x0004 - Add UUID) header = b"\x01\x00" # MGMT_EV_CMD_COMPLETE header += b"\x00\x00" # controller_idx = 0 header += b"\x03\x00" # param_len = 3 params = b"\x04\x00" # opcode = 0x0004 (not MGMT_OP_LOAD_CONN_PARAM) params += b"\x00" # status = 0 (success) protocol.data_received(header + params) # Should not log anything about connection parameters assert "Connection parameters" not in caplog.text def test_data_received_cmd_status_different_opcode( event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture ) -> None: """Test data_received handles CMD_STATUS for different opcodes.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a CMD_STATUS event for a different opcode header = b"\x02\x00" # MGMT_EV_CMD_STATUS header += b"\x00\x00" # controller_idx = 0 header += b"\x03\x00" # param_len = 3 params = b"\x05\x00" # opcode = 0x0005 (not MGMT_OP_LOAD_CONN_PARAM) params += b"\x01" # status = 1 (failure) protocol.data_received(header + params) # Should not log anything about connection parameters assert "conn params" not in caplog.text def test_data_received_cmd_complete_short_params( event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture ) -> None: """Test data_received handles CMD_COMPLETE with param_len < 3.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a CMD_COMPLETE event with param_len < 3 header = b"\x01\x00" # MGMT_EV_CMD_COMPLETE header += b"\x00\x00" # controller_idx = 0 header += b"\x02\x00" # param_len = 2 (too short to contain opcode + status) params = b"\x00\x00" # Just 2 bytes protocol.data_received(header + params) # Should not log anything (no opcode to check) assert "conn params" not in caplog.text def test_data_received_cmd_status_param_len_1( event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture ) -> None: """Test data_received handles CMD_STATUS with param_len = 1.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a CMD_STATUS event with param_len = 1 header = b"\x02\x00" # MGMT_EV_CMD_STATUS header += b"\x00\x00" # controller_idx = 0 header += b"\x01\x00" # param_len = 1 (too short) params = b"\x00" # Just 1 byte protocol.data_received(header + params) # Should not log anything (no opcode to check) assert "conn params" not in caplog.text def test_data_received_cmd_complete_param_len_0( event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture ) -> None: """Test data_received handles CMD_COMPLETE with param_len = 0.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a CMD_COMPLETE event with param_len = 0 header = b"\x01\x00" # MGMT_EV_CMD_COMPLETE header += b"\x00\x00" # controller_idx = 0 header += b"\x00\x00" # param_len = 0 (no params at all) protocol.data_received(header) # Should not log anything (no opcode to check) assert "conn params" not in caplog.text def test_data_received_unknown_event(event_loop: asyncio.AbstractEventLoop) -> None: """Test data_received ignores unknown events.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create an unknown event header = b"\xff\x00" # Unknown event code header += b"\x00\x00" # controller_idx = 0 header += b"\x04\x00" # param_len = 4 params = b"\x00\x00\x00\x00" # Should not raise any exception protocol.data_received(header + params) def test_data_received_no_scanner_for_controller( event_loop: asyncio.AbstractEventLoop, ) -> None: """Test data_received handles missing scanner gracefully.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} # No scanner for controller 0 on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a DEVICE_FOUND event for controller 0 ad_data = b"\x02\x01\x06" param_len = 6 + 1 + 1 + 4 + 2 + len(ad_data) header = b"\x12\x00\x00\x00" + param_len.to_bytes(2, "little") params = b"\xaa\xbb\xcc\xdd\xee\xff\x01\xc8\x00\x00\x00\x00" params += len(ad_data).to_bytes(2, "little") + ad_data # Should not raise any exception protocol.data_received(header + params) @pytest.mark.asyncio async def test_setup_success() -> None: """Test successful setup.""" mock_sock = Mock() mock_sock.fileno.return_value = 1 # Mock socket file descriptor mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport # Mock the future that gets created and set loop = asyncio.get_running_loop() mock_future = loop.create_future() async def mock_create_connection(*args, **kwargs): # Set the future result to simulate connection made mock_future.set_result(None) return mock_transport, mock_protocol with ( patch("habluetooth.channels.bluez.btmgmt_socket.open", return_value=mock_sock), patch.object( asyncio.get_running_loop(), "_create_connection_transport", side_effect=mock_create_connection, ), patch.object( asyncio.get_running_loop(), "create_future", side_effect=[ mock_future, loop.create_future(), ], # First for connection, second for on_connection_lost ), patch.object( MGMTBluetoothCtl, "_check_capabilities", return_value=True, # Mock successful capability check ), ): ctl = MGMTBluetoothCtl(5.0, {}) await ctl.setup() assert ctl.sock is mock_sock assert ctl.protocol is mock_protocol assert ctl._reconnect_task is not None # Clean up ctl._reconnect_task.cancel() with pytest.raises(asyncio.CancelledError): await ctl._reconnect_task @pytest.mark.asyncio async def test_setup_timeout() -> None: """Test setup timeout.""" mock_sock = Mock() async def slow_connect(*args, **kwargs): await asyncio.sleep(10) with ( patch("habluetooth.channels.bluez.btmgmt_socket.open", return_value=mock_sock), patch.object( asyncio.get_running_loop(), "_create_connection_transport", side_effect=slow_connect, ), patch("habluetooth.channels.bluez.btmgmt_socket.close") as mock_close, ): ctl = MGMTBluetoothCtl(0.1, {}) with pytest.raises(TimeoutError): await ctl.setup() mock_close.assert_called_once_with(mock_sock) @pytest.mark.asyncio async def test_load_conn_params_fast() -> None: """Test loading fast connection parameters.""" mock_sock = Mock() mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport # Mock the _write_to_socket method mock_protocol._write_to_socket = Mock() ctl = MGMTBluetoothCtl(5.0, {}) ctl.protocol = mock_protocol ctl.sock = mock_sock result = ctl.load_conn_params( 0, # adapter_idx "AA:BB:CC:DD:EE:FF", # address BDADDR_LE_PUBLIC, # address_type ConnectParams.FAST, ) assert result is True # Verify the command was sent mock_protocol._write_to_socket.assert_called_once() call_args = mock_protocol._write_to_socket.call_args[0][0] # Check header (6 bytes) assert call_args[0:2] == b"\x35\x00" # MGMT_OP_LOAD_CONN_PARAM assert call_args[2:4] == b"\x00\x00" # adapter_idx = 0 assert call_args[4:6] == b"\x11\x00" # param_len = 17 (2 + 15) # Check command data assert call_args[6:8] == b"\x01\x00" # param_count = 1 assert call_args[8:14] == b"\xff\xee\xdd\xcc\xbb\xaa" # address (reversed) assert call_args[14] == BDADDR_LE_PUBLIC # address_type assert call_args[15:17] == FAST_MIN_CONN_INTERVAL.to_bytes(2, "little") assert call_args[17:19] == FAST_MAX_CONN_INTERVAL.to_bytes(2, "little") assert call_args[19:21] == FAST_CONN_LATENCY.to_bytes(2, "little") assert call_args[21:23] == FAST_CONN_TIMEOUT.to_bytes(2, "little") @pytest.mark.asyncio async def test_load_conn_params_medium() -> None: """Test loading medium connection parameters.""" mock_sock = Mock() mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport # Mock the _write_to_socket method mock_protocol._write_to_socket = Mock() ctl = MGMTBluetoothCtl(5.0, {}) ctl.protocol = mock_protocol ctl.sock = mock_sock result = ctl.load_conn_params( 1, # adapter_idx "11:22:33:44:55:66", # address BDADDR_LE_RANDOM, # address_type ConnectParams.MEDIUM, ) assert result is True # Verify the command was sent mock_protocol._write_to_socket.assert_called_once() call_args = mock_protocol._write_to_socket.call_args[0][0] # Check header assert call_args[0:2] == b"\x35\x00" # MGMT_OP_LOAD_CONN_PARAM assert call_args[2:4] == b"\x01\x00" # adapter_idx = 1 # Check parameters assert call_args[8:14] == b"\x66\x55\x44\x33\x22\x11" # address (reversed) assert call_args[14] == BDADDR_LE_RANDOM # address_type assert call_args[15:17] == MEDIUM_MIN_CONN_INTERVAL.to_bytes(2, "little") assert call_args[17:19] == MEDIUM_MAX_CONN_INTERVAL.to_bytes(2, "little") assert call_args[19:21] == MEDIUM_CONN_LATENCY.to_bytes(2, "little") assert call_args[21:23] == MEDIUM_CONN_TIMEOUT.to_bytes(2, "little") def test_load_conn_params_no_protocol(caplog: pytest.LogCaptureFixture) -> None: """Test load_conn_params when protocol is not connected.""" ctl = MGMTBluetoothCtl(5.0, {}) result = ctl.load_conn_params( 0, "AA:BB:CC:DD:EE:FF", BDADDR_LE_PUBLIC, ConnectParams.FAST, ) assert result is False assert "Cannot load conn params: no connection" in caplog.text def test_load_conn_params_invalid_address(caplog: pytest.LogCaptureFixture) -> None: """Test load_conn_params with invalid MAC address.""" mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport ctl = MGMTBluetoothCtl(5.0, {}) ctl.protocol = mock_protocol # Test with too short address result = ctl.load_conn_params( 0, "AA:BB", BDADDR_LE_PUBLIC, ConnectParams.FAST, ) assert result is False assert "Invalid MAC address: AA:BB" in caplog.text def test_load_conn_params_transport_error(caplog: pytest.LogCaptureFixture) -> None: """Test load_conn_params with transport write error.""" mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_socket = Mock() mock_socket.send.side_effect = Exception("Transport error") mock_transport.get_extra_info = Mock(return_value=mock_socket) mock_protocol.transport = mock_transport mock_protocol._sock = mock_socket mock_protocol._write_to_socket = Mock(side_effect=Exception("Transport error")) ctl = MGMTBluetoothCtl(5.0, {}) ctl.protocol = mock_protocol result = ctl.load_conn_params( 0, "AA:BB:CC:DD:EE:FF", BDADDR_LE_PUBLIC, ConnectParams.FAST, ) assert result is False assert "Failed to load conn params" in caplog.text def test_kernel_bug_workaround_send_returns_zero( event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture ) -> None: """Test that the kernel bug workaround handles send returning 0.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) # Create a mock socket that returns 0 (kernel bug behavior) mock_socket = Mock() mock_socket.send = Mock(return_value=0) protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_socket ) # Send some data test_data = b"\x25\x00\x00\x00\x00\x00" with caplog.at_level(logging.DEBUG): protocol._write_to_socket(test_data) # Verify the send was called and the workaround logged mock_socket.send.assert_called_once_with(test_data) assert "kernel bug fix" in caplog.text def test_kernel_bug_workaround_send_raises_exception( event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture ) -> None: """Test that _write_to_socket handles and re-raises exceptions.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) # Create a mock socket that raises an exception mock_socket = Mock() mock_socket.send = Mock(side_effect=OSError("Socket error")) protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_socket ) # Send some data and expect the exception to be re-raised test_data = b"\x25\x00\x00\x00\x00\x00" with pytest.raises(OSError, match="Socket error"): protocol._write_to_socket(test_data) # Verify the error was logged assert "Failed to write to mgmt socket: Socket error" in caplog.text mock_socket.send.assert_called_once_with(test_data) def test_close() -> None: """Test close method.""" mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport mock_sock = Mock() mock_reconnect_task = Mock() ctl = MGMTBluetoothCtl(5.0, {}) ctl.protocol = mock_protocol ctl.sock = mock_sock ctl._reconnect_task = mock_reconnect_task with patch("habluetooth.channels.bluez.btmgmt_socket.close") as mock_close: ctl.close() mock_reconnect_task.cancel.assert_called_once() mock_transport.close.assert_called_once() mock_close.assert_called_once_with(mock_sock) assert ctl.protocol is None def test_close_no_protocol() -> None: """Test close when protocol is not set.""" ctl = MGMTBluetoothCtl(5.0, {}) # Should not raise any exception with patch("habluetooth.channels.bluez.btmgmt_socket.close"): ctl.close() @pytest.mark.asyncio async def test_on_connection_lost() -> None: """Test _on_connection_lost callback.""" ctl = MGMTBluetoothCtl(5.0, {}) loop = asyncio.get_running_loop() ctl._on_connection_lost_future = loop.create_future() ctl._on_connection_lost() # _on_connection_lost sets the future to None after setting result assert ctl._on_connection_lost_future is None @pytest.mark.asyncio async def test_on_connection_lost_during_shutdown( caplog: pytest.LogCaptureFixture, ) -> None: """Test _on_connection_lost callback during shutdown.""" ctl = MGMTBluetoothCtl(5.0, {}) loop = asyncio.get_running_loop() ctl._on_connection_lost_future = loop.create_future() ctl._shutting_down = True with caplog.at_level(logging.DEBUG): ctl._on_connection_lost() # Should log shutdown message assert "Bluetooth management socket connection lost during shutdown" in caplog.text # Should not log reconnecting message assert "reconnecting" not in caplog.text # _on_connection_lost sets the future to None after setting result assert ctl._on_connection_lost_future is None @pytest.mark.asyncio async def test_reconnect_task() -> None: """Test reconnect_task behavior.""" mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport establish_count = 0 ctl = MGMTBluetoothCtl(5.0, {}) async def mock_establish_connection() -> None: nonlocal establish_count establish_count += 1 if establish_count == 1: # First call succeeds ctl.protocol = mock_protocol ctl._on_connection_lost_future = asyncio.get_running_loop().create_future() elif establish_count == 2: # Second call fails raise BluetoothSocketError("Test error") else: # Stop the test raise asyncio.CancelledError() with patch.object( ctl, "_establish_connection", side_effect=mock_establish_connection ): # Start the reconnect task task = asyncio.create_task(ctl.reconnect_task()) # Trigger reconnection by calling _on_connection_lost await asyncio.sleep(0.1) ctl._on_connection_lost() # Wait for reconnection attempt await asyncio.sleep(1.5) # Cancel the task task.cancel() with pytest.raises(asyncio.CancelledError): await task assert establish_count >= 2 @pytest.mark.asyncio async def test_reconnect_task_timeout() -> None: """Test reconnect_task with connection timeout.""" async def mock_establish_connection() -> None: raise TimeoutError("Connection timeout") ctl = MGMTBluetoothCtl(5.0, {}) ctl._on_connection_lost_future = None with patch.object( ctl, "_establish_connection", side_effect=mock_establish_connection ): # Run reconnect_task briefly task = asyncio.create_task(ctl.reconnect_task()) await asyncio.sleep(0.1) # Cancel the task task.cancel() with pytest.raises(asyncio.CancelledError): await task @pytest.mark.asyncio async def test_reconnect_task_shutdown() -> None: """Test reconnect_task exits when shutting down.""" ctl = MGMTBluetoothCtl(5.0, {}) loop = asyncio.get_running_loop() establish_called = False async def mock_establish_connection() -> None: nonlocal establish_called establish_called = True # Should not be called since we're shutting down raise Exception("Should not be called") with patch.object( ctl, "_establish_connection", side_effect=mock_establish_connection ): # Set up connection lost future ctl._on_connection_lost_future = loop.create_future() # Start the reconnect task task = asyncio.create_task(ctl.reconnect_task()) # Give it a moment to start await asyncio.sleep(0) # Simulate shutdown ctl._shutting_down = True # Trigger the future to wake up the task ctl._on_connection_lost_future.set_result(None) # Task should exit cleanly await task # _establish_connection should not have been called assert not establish_called @pytest.mark.asyncio async def test_command_response_context_manager() -> None: """Test the command_response context manager.""" future = asyncio.get_running_loop().create_future() future.set_result(None) # Mark connection as made scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Test successful command response opcode = 0x0015 # MGMT_OP_GET_CONNECTIONS async with protocol.command_response(opcode) as response_future: # Verify we got a future assert response_future is not None assert isinstance(response_future, asyncio.Future) # Simulate receiving a response response_data = ( b"\x01\x00" # MGMT_EV_CMD_COMPLETE + b"\x00\x00" # controller index + b"\x03\x00" # param_len (3 bytes: opcode=2 + status=1) + opcode.to_bytes(2, "little") # opcode + b"\x00" # status (success) ) protocol.data_received(response_data) # Get the result status, _data = await response_future assert status == 0 # Success # After context exits, future should be resolved assert response_future.done() @pytest.mark.asyncio async def test_command_response_cleanup_on_exception() -> None: """Test that command_response cleans up even if an exception occurs.""" future = asyncio.get_running_loop().create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) opcode = 0x0015 # MGMT_OP_GET_CONNECTIONS # Test cleanup on exception with pytest.raises(ValueError): async with protocol.command_response(opcode) as response_future: # Verify we got a future assert response_future is not None raise ValueError("Test exception") # The future should still exist after exception # (cleanup just removes it from internal tracking) @pytest.mark.asyncio async def test_get_connections_response_handling() -> None: """Test handling of GET_CONNECTIONS command response.""" future = asyncio.get_running_loop().create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) opcode = 0x0015 # MGMT_OP_GET_CONNECTIONS # Use the command_response context manager to register the command async with protocol.command_response(opcode) as response_future: # Test with permission denied status (0x14) response_data = ( b"\x01\x00" # MGMT_EV_CMD_COMPLETE + b"\x00\x00" # controller index + b"\x03\x00" # param_len + opcode.to_bytes(2, "little") # opcode + b"\x14" # status (permission denied) ) protocol.data_received(response_data) # Verify the future was resolved with the status status, data = await response_future assert status == 0x14 # Permission denied assert data == b"" # No additional data for param_len <= 3 @pytest.mark.asyncio async def test_get_connections_response_with_data() -> None: """Test GET_CONNECTIONS response with additional data.""" future = asyncio.get_running_loop().create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) opcode = 0x0015 # MGMT_OP_GET_CONNECTIONS # Use the command_response context manager to register the command async with protocol.command_response(opcode) as response_future: # Test with success status and additional data extra_data = b"\x01\x02\x03\x04" response_data = ( b"\x01\x00" # MGMT_EV_CMD_COMPLETE + b"\x00\x00" # controller index + (3 + len(extra_data)).to_bytes( 2, "little" ) # param_len (opcode=2 + status=1 + extra_data) + opcode.to_bytes(2, "little") # opcode + b"\x00" # status (success) + extra_data # additional response data ) protocol.data_received(response_data) # Verify the future was resolved with status and data status, data = await response_future assert status == 0 # Success assert data == extra_data @pytest.mark.asyncio async def test_has_mgmt_capabilities_from_status() -> None: """Test _has_mgmt_capabilities_from_status helper function.""" mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={}) # Test permission denied assert mgmt_ctl._has_mgmt_capabilities_from_status(0x14) is False # Test success assert mgmt_ctl._has_mgmt_capabilities_from_status(0x00) is True # Test invalid index (still has permissions) assert mgmt_ctl._has_mgmt_capabilities_from_status(0x11) is True # Test unknown status (assumes no permissions) assert mgmt_ctl._has_mgmt_capabilities_from_status(0xFF) is False assert mgmt_ctl._has_mgmt_capabilities_from_status(0x01) is False assert mgmt_ctl._has_mgmt_capabilities_from_status(0x0D) is False @pytest.mark.asyncio async def test_check_capabilities_success() -> None: """Test _check_capabilities when permissions are available.""" mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={}) # Mock the protocol and transport mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport mgmt_ctl.protocol = mock_protocol # Mock command_response to return success def mock_command_response(opcode: int) -> object: future = asyncio.get_running_loop().create_future() future.set_result((0x00, b"")) # Success status class MockContext: async def __aenter__(self) -> asyncio.Future[tuple[int, bytes]]: return future async def __aexit__(self, *args: object) -> None: pass return MockContext() mock_protocol.command_response = mock_command_response # Mock the _write_to_socket method mock_protocol._write_to_socket = Mock() # Test capability check result = await mgmt_ctl._check_capabilities() assert result is True # Verify the command was sent mock_protocol._write_to_socket.assert_called_once() sent_data = mock_protocol._write_to_socket.call_args[0][0] # Check that it's a GET_CONNECTIONS command (opcode at bytes 0-1) assert sent_data[0:2] == b"\x15\x00" # MGMT_OP_GET_CONNECTIONS little-endian @pytest.mark.asyncio async def test_check_capabilities_permission_denied() -> None: """Test _check_capabilities when permissions are denied.""" mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={}) # Mock the protocol and transport mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport mgmt_ctl.protocol = mock_protocol # Mock command_response to return permission denied def mock_command_response(opcode: int) -> object: future = asyncio.get_running_loop().create_future() future.set_result((0x14, b"")) # Permission denied status class MockContext: async def __aenter__(self) -> asyncio.Future[tuple[int, bytes]]: return future async def __aexit__(self, *args: object) -> None: pass return MockContext() mock_protocol.command_response = mock_command_response # Test capability check result = await mgmt_ctl._check_capabilities() assert result is False @pytest.mark.asyncio async def test_check_capabilities_invalid_index() -> None: """Test _check_capabilities with invalid adapter index (still has permissions).""" mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={}) # Mock the protocol and transport mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport mgmt_ctl.protocol = mock_protocol # Mock command_response to return invalid index def mock_command_response(opcode: int) -> object: future = asyncio.get_running_loop().create_future() future.set_result((0x11, b"")) # Invalid index class MockContext: async def __aenter__(self) -> asyncio.Future[tuple[int, bytes]]: return future async def __aexit__(self, *args: object) -> None: pass return MockContext() mock_protocol.command_response = mock_command_response # Test capability check - invalid index means adapter doesn't exist # but we still have permissions result = await mgmt_ctl._check_capabilities() assert result is True @pytest.mark.asyncio async def test_check_capabilities_unknown_status() -> None: """Test _check_capabilities with unknown status code.""" mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={}) # Mock the protocol and transport mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport mgmt_ctl.protocol = mock_protocol # Mock command_response to return unknown status def mock_command_response(opcode: int) -> object: future = asyncio.get_running_loop().create_future() future.set_result((0xFF, b"")) # Unknown status class MockContext: async def __aenter__(self) -> asyncio.Future[tuple[int, bytes]]: return future async def __aexit__(self, *args: object) -> None: pass return MockContext() mock_protocol.command_response = mock_command_response # Test capability check - unknown status assumes no permissions result = await mgmt_ctl._check_capabilities() assert result is False @pytest.mark.asyncio async def test_check_capabilities_timeout() -> None: """Test _check_capabilities when command times out.""" mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={}) # Mock the protocol and transport mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport mgmt_ctl.protocol = mock_protocol # Mock command_response to timeout def mock_command_response(opcode: int) -> object: future = asyncio.get_running_loop().create_future() # Never resolve the future class MockContext: async def __aenter__(self) -> asyncio.Future[tuple[int, bytes]]: return future async def __aexit__(self, *args: object) -> None: pass return MockContext() mock_protocol.command_response = mock_command_response # Test capability check with a very short timeout with patch("habluetooth.channels.bluez.asyncio_timeout") as mock_timeout: # Make timeout raise immediately mock_timeout.side_effect = TimeoutError("Test timeout") result = await mgmt_ctl._check_capabilities() assert result is False @pytest.mark.asyncio async def test_check_capabilities_no_protocol() -> None: """Test _check_capabilities when protocol is not set.""" mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={}) # No protocol set mgmt_ctl.protocol = None result = await mgmt_ctl._check_capabilities() assert result is False @pytest.mark.asyncio async def test_check_capabilities_no_transport() -> None: """Test _check_capabilities when transport is not set.""" mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={}) # Mock protocol with no transport mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_protocol.transport = None mgmt_ctl.protocol = mock_protocol result = await mgmt_ctl._check_capabilities() assert result is False @pytest.mark.asyncio async def test_setup_with_failed_capabilities() -> None: """Test setup raises PermissionError when capabilities check fails.""" with ( patch("habluetooth.channels.bluez.btmgmt_socket") as mock_btmgmt, patch.object(MGMTBluetoothCtl, "_establish_connection") as mock_establish, patch.object(MGMTBluetoothCtl, "_check_capabilities", return_value=False), ): mock_socket = Mock() mock_socket.fileno.return_value = 99 mock_btmgmt.open.return_value = mock_socket mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={}) # Mock successful connection establishment mock_establish.return_value = None # Set the socket on mgmt_ctl mgmt_ctl.sock = mock_socket # Mock protocol for close operation mock_protocol = Mock() mock_transport = Mock() mock_protocol.transport = mock_transport mgmt_ctl.protocol = mock_protocol # Setup should raise PermissionError with pytest.raises(PermissionError) as exc_info: await mgmt_ctl.setup() assert "Missing NET_ADMIN/NET_RAW capabilities" in str(exc_info.value) # Verify cleanup assert mgmt_ctl._shutting_down is True mock_transport.close.assert_called_once() mock_btmgmt.close.assert_called_once_with(mock_socket) Bluetooth-Devices-habluetooth-21accde/tests/conftest.py000066400000000000000000000220511507024716100234630ustar00rootroot00000000000000from collections.abc import AsyncGenerator, Generator, Iterable from unittest.mock import AsyncMock, MagicMock, patch import pytest import pytest_asyncio from bleak.backends.scanner import AdvertisementData, BLEDevice from bleak_retry_connector import BleakSlotManager from bluetooth_adapters import AdapterDetails, BluetoothAdapters from habluetooth import ( BaseHaRemoteScanner, BaseHaScanner, BluetoothManager, get_manager, set_manager, ) from habluetooth import scanner as bluetooth_scanner class FakeBluetoothAdapters(BluetoothAdapters): @property def adapters(self) -> dict[str, AdapterDetails]: return {} class FakeScannerMixin: def get_discovered_device_advertisement_data( self, address: str ) -> tuple[BLEDevice, AdvertisementData] | None: """Return the advertisement data for a discovered device.""" return self.discovered_devices_and_advertisement_data.get(address) # type: ignore[attr-defined] @property def discovered_addresses(self) -> Iterable[str]: """Return an iterable of discovered devices.""" return self.discovered_devices_and_advertisement_data # type: ignore[attr-defined] class FakeScanner(FakeScannerMixin, BaseHaScanner): """Fake scanner.""" @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" return [] @property def discovered_devices_and_advertisement_data( self, ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: """Return a list of discovered devices and their advertisement data.""" return {} class PatchableBluetoothManager(BluetoothManager): """Patchable Bluetooth Manager for testing.""" @pytest_asyncio.fixture(autouse=True) async def manager() -> AsyncGenerator[None, None]: slot_manager = BleakSlotManager() bluetooth_adapters = FakeBluetoothAdapters() manager = PatchableBluetoothManager(bluetooth_adapters, slot_manager) set_manager(manager) await manager.async_setup() yield manager.async_stop() @pytest_asyncio.fixture(name="enable_bluetooth") async def mock_enable_bluetooth( mock_bleak_scanner_start: MagicMock, mock_bluetooth_adapters: None, ) -> AsyncGenerator[None, None]: """Fixture to mock starting the bleak scanner.""" manager = get_manager() assert manager._bluetooth_adapters is not None await manager.async_setup() yield manager._all_history.clear() manager._connectable_history.clear() manager._unavailable_callbacks.clear() manager._connectable_unavailable_callbacks.clear() manager._bleak_callbacks.clear() manager._fallback_intervals.clear() manager._intervals.clear() manager._adapter_sources.clear() manager._adapters.clear() manager._sources.clear() manager._allocations.clear() manager._non_connectable_scanners.clear() manager._connectable_scanners.clear() @pytest.fixture(scope="session") def mock_bluetooth_adapters() -> Generator[None, None, None]: """Fixture to mock bluetooth adapters.""" with ( patch("bluetooth_auto_recovery.recover_adapter"), patch("bluetooth_adapters.systems.platform.system", return_value="Linux"), patch("bluetooth_adapters.systems.linux.LinuxAdapters.refresh"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.adapters", { "hci0": { "address": "00:00:00:00:00:01", "hw_version": "usb:v1D6Bp0246d053F", "passive_scan": False, "sw_version": "homeassistant", "manufacturer": "ACME", "product": "Bluetooth Adapter 5.0", "product_id": "aa01", "vendor_id": "cc01", }, }, ), ): yield @pytest.fixture def mock_bleak_scanner_start() -> Generator[MagicMock, None, None]: """Fixture to mock starting the bleak scanner.""" bluetooth_scanner.OriginalBleakScanner.stop = AsyncMock() with ( patch.object( bluetooth_scanner.OriginalBleakScanner, "start", ) as mock_bleak_scanner_start, patch.object(bluetooth_scanner, "HaScanner"), ): yield mock_bleak_scanner_start @pytest.fixture(name="two_adapters") def two_adapters_fixture(): """Fixture that mocks two adapters on Linux.""" with ( patch( "habluetooth.scanner.platform.system", return_value="Linux", ), patch("bluetooth_adapters.systems.platform.system", return_value="Linux"), patch("bluetooth_adapters.systems.linux.LinuxAdapters.refresh"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.adapters", { "hci0": { "address": "00:00:00:00:00:01", "hw_version": "usb:v1D6Bp0246d053F", "passive_scan": False, "sw_version": "homeassistant", "manufacturer": "ACME", "product": "Bluetooth Adapter 5.0", "product_id": "aa01", "vendor_id": "cc01", "connection_slots": 1, }, "hci1": { "address": "00:00:00:00:00:02", "hw_version": "usb:v1D6Bp0246d053F", "passive_scan": True, "sw_version": "homeassistant", "manufacturer": "ACME", "product": "Bluetooth Adapter 5.0", "product_id": "aa01", "vendor_id": "cc01", "connection_slots": 2, }, }, ), ): yield @pytest.fixture(name="macos_adapter") def macos_adapter() -> Generator[None, None, None]: """Fixture that mocks the macos adapter.""" with ( patch("bleak.get_platform_scanner_backend_type"), patch( "habluetooth.scanner.platform.system", return_value="Darwin", ), patch( "bluetooth_adapters.systems.platform.system", return_value="Darwin", ), patch("habluetooth.scanner.SYSTEM", "Darwin"), ): yield @pytest.fixture def register_hci0_scanner() -> Generator[None, None, None]: """Register an hci0 scanner.""" hci0_scanner = FakeScanner("AA:BB:CC:DD:EE:00", "hci0") hci0_scanner.connectable = True manager = get_manager() cancel = manager.async_register_scanner(hci0_scanner, connection_slots=5) yield cancel() @pytest.fixture def register_hci1_scanner() -> Generator[None, None, None]: """Register an hci1 scanner.""" hci1_scanner = FakeScanner("AA:BB:CC:DD:EE:11", "hci1") hci1_scanner.connectable = True manager = get_manager() cancel = manager.async_register_scanner(hci1_scanner, connection_slots=5) yield cancel() @pytest.fixture def register_non_connectable_scanner() -> Generator[None, None, None]: """Register an non connectable remote scanner.""" remote_scanner = BaseHaRemoteScanner( "AA:BB:CC:DD:EE:FF", "non connectable", None, False ) manager = get_manager() cancel = manager.async_register_scanner(remote_scanner) yield cancel() class MockBluetoothManagerWithCallbacks(BluetoothManager): """Mock bluetooth manager that tracks scanner start callbacks.""" def __init__(self, *args, **kwargs): """Initialize the mock manager.""" super().__init__(*args, **kwargs) self.scanner_start_calls = [] def on_scanner_start(self, scanner): """Track scanner start calls.""" self.scanner_start_calls.append(scanner) super().on_scanner_start(scanner) @pytest.fixture def mock_manager_with_scanner_callbacks() -> ( Generator[MockBluetoothManagerWithCallbacks, None, None] ): """Provide a mock BluetoothManager that tracks scanner start callbacks.""" mock_bluetooth_adapters = FakeBluetoothAdapters() manager = MockBluetoothManagerWithCallbacks( mock_bluetooth_adapters, slot_manager=MagicMock(), ) # Save the original manager original_manager = get_manager() # Set our mock manager as the global manager set_manager(manager) try: yield manager finally: # Restore the original manager set_manager(original_manager) @pytest_asyncio.fixture async def async_mock_manager_with_scanner_callbacks() -> ( AsyncGenerator[MockBluetoothManagerWithCallbacks, None] ): """Provide an async mock BluetoothManager that tracks scanner start callbacks.""" mock_bluetooth_adapters = FakeBluetoothAdapters() manager = MockBluetoothManagerWithCallbacks( mock_bluetooth_adapters, slot_manager=MagicMock(), ) # Setup the manager await manager.async_setup() # Save the original manager original_manager = get_manager() # Set our mock manager as the global manager set_manager(manager) try: yield manager finally: # Restore the original manager set_manager(original_manager) Bluetooth-Devices-habluetooth-21accde/tests/test_advertisement_tracker.py000066400000000000000000000061251507024716100272660ustar00rootroot00000000000000"""Test that advertising interval tracking is properly cleared when scanner pauses.""" import pytest from habluetooth.advertisement_tracker import AdvertisementTracker from habluetooth.base_scanner import BaseHaScanner from habluetooth.central_manager import get_manager @pytest.mark.asyncio async def test_scanner_paused_clears_timing_data(): """Test timing data is cleared when scanner pauses but intervals are preserved.""" tracker = AdvertisementTracker() source = "test_scanner" address = "AA:BB:CC:DD:EE:FF" # Simulate collecting timing data tracker.sources[address] = source tracker._timings[address] = [1.0, 2.0, 3.0] # Some timing data tracker.intervals[address] = 10.0 # Already learned interval # Call async_scanner_paused tracker.async_scanner_paused(source) # Check that timing data is cleared but interval is preserved assert address not in tracker._timings assert tracker.intervals[address] == 10.0 # Interval should still be there assert tracker.sources[address] == source # Source mapping should still be there @pytest.mark.asyncio async def test_scanner_paused_only_affects_matching_source(): """Test that pausing only affects devices from the matching source.""" tracker = AdvertisementTracker() source1 = "scanner1" source2 = "scanner2" address1 = "AA:BB:CC:DD:EE:01" address2 = "AA:BB:CC:DD:EE:02" # Set up data for two sources tracker.sources[address1] = source1 tracker.sources[address2] = source2 tracker._timings[address1] = [1.0, 2.0] tracker._timings[address2] = [1.0, 2.0] tracker.intervals[address1] = 5.0 tracker.intervals[address2] = 6.0 # Pause only source1 tracker.async_scanner_paused(source1) # Check that only source1 timing is cleared assert address1 not in tracker._timings assert address2 in tracker._timings # source2 should still have timing data assert tracker.intervals[address1] == 5.0 # Intervals preserved assert tracker.intervals[address2] == 6.0 @pytest.mark.asyncio async def test_connection_clears_timing_data(): """Test that timing data is cleared when a connection is initiated.""" # Get the manager that was set up by the fixture test_manager = get_manager() # Create actual BaseHaScanner to test the method real_scanner = BaseHaScanner( source="test_scanner", adapter="hci0", connectable=True ) # BaseHaScanner gets the manager internally via get_manager() # Set up some timing data address = "AA:BB:CC:DD:EE:FF" test_manager._advertisement_tracker.sources[address] = real_scanner.source test_manager._advertisement_tracker._timings[address] = [1.0, 2.0, 3.0] test_manager._advertisement_tracker.intervals[address] = 10.0 # Call _add_connecting which should clear timing data real_scanner._add_connecting(address) # Verify timing data was cleared but interval preserved assert address not in test_manager._advertisement_tracker._timings assert test_manager._advertisement_tracker.intervals.get(address) == 10.0 assert address in real_scanner._connect_in_progress Bluetooth-Devices-habluetooth-21accde/tests/test_base_scanner.py000066400000000000000000001176041507024716100253310ustar00rootroot00000000000000"""Tests for the Bluetooth base scanner models.""" from __future__ import annotations import asyncio import time from datetime import timedelta from typing import Any from unittest.mock import ANY, MagicMock, Mock, patch import pytest from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from bleak_retry_connector import Allocations from bluetooth_data_tools import monotonic_time_coarse from habluetooth import ( BaseHaRemoteScanner, BaseHaScanner, BluetoothManager, BluetoothScannerDevice, BluetoothScanningMode, HaBluetoothConnector, HaScannerDetails, HaScannerModeChange, HaScannerType, get_manager, set_manager, ) from habluetooth.const import ( CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, SCANNER_WATCHDOG_INTERVAL, SCANNER_WATCHDOG_TIMEOUT, ) from habluetooth.storage import ( DiscoveredDeviceAdvertisementData, ) from . import ( HCI0_SOURCE_ADDRESS, MockBleakClient, async_fire_time_changed, generate_advertisement_data, generate_ble_device, patch_bluetooth_time, utcnow, ) from .conftest import FakeBluetoothAdapters, MockBluetoothManagerWithCallbacks class FakeScanner(BaseHaRemoteScanner): """Fake scanner.""" def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData, now: float | None = None, ) -> None: """Inject an advertisement.""" self._async_on_advertisement( device.address, advertisement_data.rssi, device.name, advertisement_data.service_uuids, advertisement_data.service_data, advertisement_data.manufacturer_data, advertisement_data.tx_power, {"scanner_specific_data": "test"}, now or monotonic_time_coarse(), ) def inject_raw_advertisement( self, address: str, rssi: int, adv: bytes, now: float | None = None, ) -> None: """Inject a raw advertisement.""" self._async_on_raw_advertisement( address, rssi, adv, {"scanner_specific_data": "test"}, now or monotonic_time_coarse(), ) @pytest.mark.parametrize("name_2", [None, "w"]) @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_remote_scanner(name_2: str | None) -> None: """Test the remote scanner base class merges advertisement_data.""" manager = get_manager() switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"], service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"}, manufacturer_data={1: b"\x01"}, rssi=-100, ) switchbot_device_2 = generate_ble_device( "44:44:33:11:23:45", name_2, {}, rssi=-100, ) switchbot_device_adv_2 = generate_advertisement_data( local_name=name_2, service_uuids=["00000001-0000-1000-8000-00805f9b34fb"], service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"}, manufacturer_data={1: b"\x01", 2: b"\x02"}, rssi=-100, ) switchbot_device_3 = generate_ble_device( "44:44:33:11:23:45", "wohandlonger", {}, rssi=-100, ) switchbot_device_adv_3 = generate_advertisement_data( local_name="wohandlonger", service_uuids=["00000001-0000-1000-8000-00805f9b34fb"], service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"}, manufacturer_data={1: b"\x01", 2: b"\x02"}, rssi=-100, ) switchbot_device_adv_4 = generate_advertisement_data( local_name="wohandlonger", service_uuids=["00000001-0000-1000-8000-00805f9b34fb"], service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"}, manufacturer_data={1: b"\x04", 2: b"\x02", 3: b"\x03"}, rssi=-100, ) switchbot_device_adv_5 = generate_advertisement_data( local_name="wohandlonger", service_uuids=["00000001-0000-1000-8000-00805f9b34fb"], service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"}, manufacturer_data={1: b"\x04", 2: b"\x01"}, rssi=-100, ) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = FakeScanner("esp32", "esp32", connector, True) details = scanner.details assert details == HaScannerDetails( source=scanner.source, connectable=scanner.connectable, name=scanner.name, adapter=scanner.adapter, scanner_type=HaScannerType.REMOTE, ) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) scanner.inject_advertisement(switchbot_device, switchbot_device_adv) data = scanner.discovered_devices_and_advertisement_data discovered_device, discovered_adv_data = data[switchbot_device.address] assert discovered_device.address == switchbot_device.address assert discovered_device.name == switchbot_device.name assert ( discovered_adv_data.manufacturer_data == switchbot_device_adv.manufacturer_data ) assert discovered_adv_data.service_data == switchbot_device_adv.service_data assert discovered_adv_data.service_uuids == switchbot_device_adv.service_uuids scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_2) data = scanner.discovered_devices_and_advertisement_data discovered_device, discovered_adv_data = data[switchbot_device.address] assert discovered_device.address == switchbot_device.address assert discovered_device.name == switchbot_device.name assert discovered_adv_data.manufacturer_data == {1: b"\x01", 2: b"\x02"} assert discovered_adv_data.service_data == { "050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff", "00000001-0000-1000-8000-00805f9b34fb": b"\n\xff", } assert set(discovered_adv_data.service_uuids) == { "050a021a-0000-1000-8000-00805f9b34fb", "00000001-0000-1000-8000-00805f9b34fb", } # The longer name should be used scanner.inject_advertisement(switchbot_device_3, switchbot_device_adv_3) assert discovered_device.name == switchbot_device_3.name # Inject the shorter name / None again to make # sure we always keep the longer name scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_2) assert discovered_device.name == switchbot_device_3.name scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_4) assert scanner.discovered_devices_and_advertisement_data[ switchbot_device_2.address ][1].manufacturer_data == {1: b"\x04", 2: b"\x02", 3: b"\x03"} scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_5) assert scanner.discovered_devices_and_advertisement_data[ switchbot_device_2.address ][1].manufacturer_data == {1: b"\x04", 2: b"\x01", 3: b"\x03"} assert ( "00090401-0052-036b-3206-ff0a050a021a" not in scanner.discovered_devices_and_advertisement_data[ switchbot_device_2.address ][1].service_data ) scanner.inject_raw_advertisement( switchbot_device_2.address, 0, b"\x12\x21\x1a\x02\n\x05\n\xff\x062k\x03R\x00\x01\x04\t\x00\x04", ) assert ( "00090401-0052-036b-3206-ff0a050a021a" in scanner.discovered_devices_and_advertisement_data[ switchbot_device_2.address ][1].service_data ) assert scanner.serialize_discovered_devices() == DiscoveredDeviceAdvertisementData( connectable=True, expire_seconds=195, discovered_device_advertisement_datas={"44:44:33:11:23:45": ANY}, discovered_device_timestamps={"44:44:33:11:23:45": ANY}, discovered_device_raw={ "44:44:33:11:23:45": b"\x12!\x1a\x02" b"\n\x05\n\xff" b"\x062k\x03" b"R\x00\x01\x04" b"\t\x00\x04" }, ) cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_remote_scanner_expires_connectable() -> None: """Test the remote scanner expires stale connectable data.""" manager = get_manager() switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-100, ) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) start_time_monotonic = time.monotonic() scanner.inject_advertisement(switchbot_device, switchbot_device_adv) devices = scanner.discovered_devices assert len(scanner.discovered_devices) == 1 assert len(scanner.discovered_devices_and_advertisement_data) == 1 assert devices[0].name == "wohand" expire_monotonic = ( start_time_monotonic + CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) expire_utc = utcnow() + timedelta( seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) with patch_bluetooth_time(expire_monotonic): async_fire_time_changed(expire_utc) await asyncio.sleep(0) devices = scanner.discovered_devices assert len(scanner.discovered_devices) == 0 assert len(scanner.discovered_devices_and_advertisement_data) == 0 cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_remote_scanner_expires_non_connectable() -> None: """Test the remote scanner expires stale non connectable data.""" manager = get_manager() switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-100, ) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) start_time_monotonic = time.monotonic() scanner.inject_advertisement(switchbot_device, switchbot_device_adv) devices = scanner.discovered_devices assert len(scanner.discovered_devices) == 1 assert len(scanner.discovered_devices_and_advertisement_data) == 1 assert len(scanner.discovered_device_timestamps) == 1 assert len(scanner._discovered_device_timestamps) == 1 dev_adv = scanner.get_discovered_device_advertisement_data(switchbot_device.address) assert dev_adv is not None dev, adv = dev_adv assert dev.name == "wohand" assert adv.local_name == "wohand" assert adv.manufacturer_data == switchbot_device_adv.manufacturer_data assert devices[0].name == "wohand" assert ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS > CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS ) # The connectable timeout is used for all devices # as the manager takes care of availability and the scanner # if only concerned about making a connection expire_monotonic = ( start_time_monotonic + CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) expire_utc = utcnow() + timedelta( seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) with patch_bluetooth_time(expire_monotonic): async_fire_time_changed(expire_utc) await asyncio.sleep(0) assert len(scanner.discovered_devices) == 0 assert len(scanner.discovered_devices_and_advertisement_data) == 0 assert len(scanner.discovered_device_timestamps) == 0 assert len(scanner._discovered_device_timestamps) == 0 assert ( scanner.get_discovered_device_advertisement_data(switchbot_device.address) is None ) expire_monotonic = ( start_time_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) expire_utc = utcnow() + timedelta( seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) with patch_bluetooth_time(expire_monotonic): async_fire_time_changed(expire_utc) await asyncio.sleep(0) assert len(scanner.discovered_devices) == 0 assert len(scanner.discovered_devices_and_advertisement_data) == 0 assert len(scanner.discovered_device_timestamps) == 0 assert len(scanner._discovered_device_timestamps) == 0 assert ( scanner.get_discovered_device_advertisement_data(switchbot_device.address) is None ) cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_base_scanner_connecting_behavior() -> None: """Test the default behavior is to mark the scanner as not scanning on connect.""" manager = get_manager() switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-100, ) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) with scanner.connecting(): assert scanner.scanning is False # We should still accept new advertisements while connecting # since advertisements are delivered asynchronously and # we don't want to miss any even when we are willing to # accept advertisements from another scanner in the brief window # between when we start connecting and when we stop scanning scanner.inject_advertisement(switchbot_device, switchbot_device_adv) devices = scanner.discovered_devices assert len(scanner.discovered_devices) == 1 assert len(scanner.discovered_devices_and_advertisement_data) == 1 assert devices[0].name == "wohand" cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_scanner_stops_responding() -> None: """Test we mark a scanner are not scanning when it stops responding.""" manager = get_manager() connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = FakeScanner( "esp32", "esp32", connector, True, current_mode=BluetoothScanningMode.ACTIVE, requested_mode=BluetoothScanningMode.ACTIVE, ) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) start_time_monotonic = time.monotonic() assert scanner.scanning is True failure_reached_time = ( start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds() ) # We hit the timer with no detections, # so we reset the adapter and restart the scanner with patch_bluetooth_time(failure_reached_time): async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL) await asyncio.sleep(0) assert scanner.scanning is False bparasite_device = generate_ble_device( # type: ignore[unreachable] "44:44:33:11:23:45", "bparasite", {}, rssi=-100, ) bparasite_device_adv = generate_advertisement_data( local_name="bparasite", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-100, ) failure_reached_time += 1 with patch_bluetooth_time(failure_reached_time): scanner.inject_advertisement( bparasite_device, bparasite_device_adv, failure_reached_time ) # As soon as we get a detection, we know the scanner is working again assert scanner.scanning is True assert scanner.requested_mode == BluetoothScanningMode.ACTIVE assert scanner.current_mode == BluetoothScanningMode.ACTIVE cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_merge_manufacturer_data_history_existing() -> None: """Test merging manufacturer data history.""" manager = get_manager() sensor_push_device = generate_ble_device( "44:44:33:11:23:45", "", {}, rssi=-60, ) sensor_push_device_adv = generate_advertisement_data( local_name="", rssi=-60, manufacturer_data={ 64256: b"B\r.\xa9\xb6", 31488: b"\x98\xfa\xb6\x91\xb6", }, service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"], service_data={}, ) sensor_push_adv_2 = generate_advertisement_data( local_name="", service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"], service_data={}, manufacturer_data={ 31488: b"\x98\xfa\xb6\x91\xb6", }, rssi=-100, ) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = FakeScanner("esp32", "esp32", connector, True) details = scanner.details assert details == HaScannerDetails( source=scanner.source, connectable=scanner.connectable, name=scanner.name, adapter=scanner.adapter, scanner_type=HaScannerType.REMOTE, ) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) scanner.inject_advertisement(sensor_push_device, sensor_push_device_adv) data = scanner.discovered_devices_and_advertisement_data discovered_device, discovered_adv_data = data[sensor_push_device.address] assert discovered_device.address == sensor_push_device.address assert discovered_device.name == sensor_push_device.name assert ( discovered_adv_data.manufacturer_data == sensor_push_device_adv.manufacturer_data ) assert discovered_adv_data.service_data == sensor_push_device_adv.service_data assert discovered_adv_data.service_uuids == sensor_push_device_adv.service_uuids scanner.inject_advertisement(sensor_push_device, sensor_push_adv_2) data = scanner.discovered_devices_and_advertisement_data discovered_device, discovered_adv_data = data[sensor_push_device.address] assert discovered_device.address == sensor_push_device.address assert discovered_device.name == sensor_push_device.name assert discovered_adv_data.manufacturer_data == { **sensor_push_device_adv.manufacturer_data, **sensor_push_adv_2.manufacturer_data, } assert discovered_adv_data.service_data == {} assert set(discovered_adv_data.service_uuids) == { *sensor_push_device_adv.service_uuids } cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_merge_manufacturer_data_history_new() -> None: """Test merging manufacturer data history.""" manager = get_manager() sensor_push_device = generate_ble_device( "44:44:33:11:23:45", "", {}, rssi=-60, ) sensor_push_device_adv = generate_advertisement_data( local_name="", rssi=-60, manufacturer_data={ 64256: b"B\r.\xa9\xb6", 31488: b"\x98\xfa\xb6\x91\xb6", }, service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"], service_data={}, ) sensor_push_adv_2 = generate_advertisement_data( local_name="", service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"], service_data={}, manufacturer_data={ 21248: b"\xb9\xe9\xe1\xb9\xb6", }, rssi=-100, ) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = FakeScanner("esp32", "esp32", connector, True) details = scanner.details assert details == HaScannerDetails( source=scanner.source, connectable=scanner.connectable, name=scanner.name, adapter=scanner.adapter, scanner_type=HaScannerType.REMOTE, ) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) scanner.inject_advertisement(sensor_push_device, sensor_push_device_adv) data = scanner.discovered_devices_and_advertisement_data discovered_device, discovered_adv_data = data[sensor_push_device.address] assert discovered_device.address == sensor_push_device.address assert discovered_device.name == sensor_push_device.name assert ( discovered_adv_data.manufacturer_data == sensor_push_device_adv.manufacturer_data ) assert discovered_adv_data.service_data == sensor_push_device_adv.service_data assert discovered_adv_data.service_uuids == sensor_push_device_adv.service_uuids scanner.inject_advertisement(sensor_push_device, sensor_push_adv_2) data = scanner.discovered_devices_and_advertisement_data discovered_device, discovered_adv_data = data[sensor_push_device.address] assert discovered_device.address == sensor_push_device.address assert discovered_device.name == sensor_push_device.name assert discovered_adv_data.manufacturer_data == { **sensor_push_device_adv.manufacturer_data, **sensor_push_adv_2.manufacturer_data, } assert discovered_adv_data.service_data == {} assert set(discovered_adv_data.service_uuids) == { *sensor_push_device_adv.service_uuids } cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_filter_apple_data() -> None: """Test filtering apple data accepts bytes that start with 01.""" manager = get_manager() device = generate_ble_device( "44:44:33:11:23:45", "", {}, rssi=-60, ) device_adv = generate_advertisement_data( local_name="", rssi=-60, manufacturer_data={ 76: b"\x01\r.\xa9\xb6", }, service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"], service_data={}, ) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = FakeScanner("esp32", "esp32", connector, True) details = scanner.details assert details == HaScannerDetails( source=scanner.source, connectable=scanner.connectable, name=scanner.name, adapter=scanner.adapter, scanner_type=HaScannerType.REMOTE, ) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) scanner.inject_advertisement(device, device_adv) data = scanner.discovered_devices_and_advertisement_data discovered_device, discovered_adv_data = data[device.address] assert discovered_device.address == device.address assert discovered_device.name == device.name assert discovered_adv_data.manufacturer_data == device_adv.manufacturer_data unsetup() cancel() @pytest.mark.usefixtures("register_hci0_scanner") def test_connection_history_count_in_progress() -> None: """Test connection history in process counting.""" manager = get_manager() device1_address = "44:44:33:11:23:12" device2_address = "44:44:33:11:23:13" hci0_scanner = manager.async_scanner_by_source(HCI0_SOURCE_ADDRESS) assert hci0_scanner is not None hci0_scanner._add_connecting(device1_address) assert hci0_scanner._connections_in_progress() == 1 hci0_scanner._add_connecting(device1_address) hci0_scanner._add_connecting(device2_address) assert hci0_scanner._connections_in_progress() == 3 hci0_scanner._finished_connecting(device1_address, True) assert hci0_scanner._connections_in_progress() == 2 hci0_scanner._finished_connecting(device1_address, False) assert hci0_scanner._connections_in_progress() == 1 hci0_scanner._finished_connecting(device2_address, False) assert hci0_scanner._connections_in_progress() == 0 @pytest.mark.usefixtures("register_hci0_scanner") def test_connection_history_failure_count(caplog: pytest.LogCaptureFixture) -> None: """Test connection history failure count.""" manager = get_manager() device1_address = "44:44:33:11:23:12" device2_address = "44:44:33:11:23:13" hci0_scanner = manager.async_scanner_by_source(HCI0_SOURCE_ADDRESS) assert hci0_scanner is not None hci0_scanner._add_connecting(device1_address) hci0_scanner._finished_connecting(device1_address, False) assert hci0_scanner._connection_failures(device1_address) == 1 hci0_scanner._add_connecting(device1_address) hci0_scanner._add_connecting(device2_address) hci0_scanner._finished_connecting(device1_address, False) assert hci0_scanner._connection_failures(device1_address) == 2 hci0_scanner._finished_connecting(device2_address, False) assert hci0_scanner._connection_failures(device2_address) == 1 hci0_scanner._add_connecting(device1_address) hci0_scanner._finished_connecting(device1_address, True) # On success, we should reset the failure count assert hci0_scanner._connection_failures(device1_address) == 0 assert "Removing a non-existing connecting" not in caplog.text hci0_scanner._finished_connecting(device1_address, True) assert "Removing a non-existing connecting" in caplog.text @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_scanner_mode_changes() -> None: """Test scanner mode change methods notify the manager.""" manager = get_manager() # Track mode changes mode_changes: list[HaScannerModeChange] = [] def mode_callback(change: HaScannerModeChange) -> None: """Track mode changes.""" mode_changes.append(change) cancel = manager.async_register_scanner_mode_change_callback(mode_callback, None) # Create a scanner with initial modes scanner = FakeScanner( HCI0_SOURCE_ADDRESS, "hci0", connectable=True, requested_mode=BluetoothScanningMode.PASSIVE, current_mode=BluetoothScanningMode.PASSIVE, ) # Set up the scanner unsetup = scanner.async_setup() # Test changing requested mode scanner.set_requested_mode(BluetoothScanningMode.ACTIVE) assert len(mode_changes) == 1 assert mode_changes[0].scanner == scanner assert mode_changes[0].requested_mode == BluetoothScanningMode.ACTIVE assert mode_changes[0].current_mode == BluetoothScanningMode.PASSIVE assert scanner.requested_mode == BluetoothScanningMode.ACTIVE # Test changing current mode scanner.set_current_mode(BluetoothScanningMode.ACTIVE) assert len(mode_changes) == 2 assert mode_changes[1].scanner == scanner assert mode_changes[1].requested_mode == BluetoothScanningMode.ACTIVE assert mode_changes[1].current_mode == BluetoothScanningMode.ACTIVE assert scanner.current_mode == BluetoothScanningMode.ACTIVE # Test no notification when mode doesn't change scanner.set_current_mode(BluetoothScanningMode.ACTIVE) assert len(mode_changes) == 2 # No new notification # Test setting to None scanner.set_requested_mode(None) assert len(mode_changes) == 3 assert mode_changes[2].requested_mode is None assert scanner.requested_mode is None scanner.set_current_mode(None) # type: ignore[unreachable] assert len(mode_changes) == 4 assert mode_changes[3].current_mode is None assert scanner.current_mode is None # Clean up unsetup() cancel() def test_remote_scanner_type() -> None: """Test that remote scanners have REMOTE type.""" class TestRemoteScanner(BaseHaRemoteScanner): """Test remote scanner implementation.""" pass scanner = TestRemoteScanner("test_source", "test_adapter") assert scanner.details.scanner_type is HaScannerType.REMOTE def test_base_scanner_with_connector() -> None: """Test BaseHaScanner with connector and adapter type.""" manager = get_manager() mock_adapters: dict[str, dict[str, Any]] = { "test_adapter": { "address": "00:1A:7D:DA:71:04", "adapter_type": "usb", } } connector = HaBluetoothConnector( client=MagicMock, source="test_source", can_connect=lambda: True ) with patch.object(manager, "_adapters", mock_adapters): scanner = BaseHaScanner( source="test_source", adapter="test_adapter", connector=connector, connectable=True, ) assert scanner.details.scanner_type is HaScannerType.USB class TestScanner(BaseHaScanner): """Test scanner without slots for mocking.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._test_allocations = None def get_allocations(self): """Override to return test allocations.""" return self._test_allocations def test_score_with_no_allocations(): """Test scoring when no allocation info is available.""" scanner = TestScanner( source="test_source", adapter="test_adapter", connectable=True, ) ble_device = BLEDevice( address="00:11:22:33:44:55", name="Test Device", details={}, rssi=-50, ) advertisement = AdvertisementData( local_name="Test Device", manufacturer_data={}, service_data={}, service_uuids=[], rssi=-50, platform_data=(), tx_power=None, ) scanner_device = BluetoothScannerDevice( scanner=scanner, ble_device=ble_device, advertisement=advertisement, ) # No allocation info available, should just use RSSI score = scanner._score_connection_paths(10, scanner_device) assert score == -50 def test_score_with_all_slots_free(): """Test scoring when all slots are free.""" scanner = TestScanner( source="test_source", adapter="test_adapter", connectable=True, ) # Set test allocations to return all slots free scanner._test_allocations = Allocations( adapter="test_adapter", slots=5, free=5, allocated=[], ) ble_device = BLEDevice( address="00:11:22:33:44:55", name="Test Device", details={}, rssi=-50, ) advertisement = AdvertisementData( local_name="Test Device", manufacturer_data={}, service_data={}, service_uuids=[], rssi=-50, platform_data=(), tx_power=None, ) scanner_device = BluetoothScannerDevice( scanner=scanner, ble_device=ble_device, advertisement=advertisement, ) # All slots free, no penalty score = scanner._score_connection_paths(10, scanner_device) assert score == -50 def test_score_with_one_slot_remaining(): """Test scoring when only one slot remains.""" scanner = TestScanner( source="test_source", adapter="test_adapter", connectable=True, ) # Set test allocations to return only 1 slot free scanner._test_allocations = Allocations( adapter="test_adapter", slots=5, free=1, allocated=[ "AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02", "AA:BB:CC:DD:EE:03", "AA:BB:CC:DD:EE:04", ], ) ble_device = BLEDevice( address="00:11:22:33:44:55", name="Test Device", details={}, rssi=-50, ) advertisement = AdvertisementData( local_name="Test Device", manufacturer_data={}, service_data={}, service_uuids=[], rssi=-50, platform_data=(), tx_power=None, ) scanner_device = BluetoothScannerDevice( scanner=scanner, ble_device=ble_device, advertisement=advertisement, ) # One slot remaining, small penalty (rssi_diff * 0.76) score = scanner._score_connection_paths(10, scanner_device) assert score == -50 - (10 * 0.76) assert score == -57.6 def test_score_with_no_slots_available(): """Test scoring when no slots are available.""" scanner = TestScanner( source="test_source", adapter="test_adapter", connectable=True, ) # Set test allocations to return no slots free scanner._test_allocations = Allocations( adapter="test_adapter", slots=5, free=0, allocated=[ "AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02", "AA:BB:CC:DD:EE:03", "AA:BB:CC:DD:EE:04", "AA:BB:CC:DD:EE:05", ], ) ble_device = BLEDevice( address="00:11:22:33:44:55", name="Test Device", details={}, rssi=-50, ) advertisement = AdvertisementData( local_name="Test Device", manufacturer_data={}, service_data={}, service_uuids=[], rssi=-50, platform_data=(), tx_power=None, ) scanner_device = BluetoothScannerDevice( scanner=scanner, ble_device=ble_device, advertisement=advertisement, ) # No slots available, returns NO_RSSI_VALUE (-127) score = scanner._score_connection_paths(10, scanner_device) assert score == -127 def test_score_comparison_with_different_slot_availability(): """Test that scanners with more free slots score better.""" # Scanner with all slots free scanner_all_free = TestScanner( source="adapter1", adapter="adapter1", connectable=True, ) # Scanner with one slot remaining scanner_one_slot = TestScanner( source="adapter2", adapter="adapter2", connectable=True, ) # Scanner with no slots scanner_no_slots = TestScanner( source="adapter3", adapter="adapter3", connectable=True, ) ble_device = BLEDevice( address="00:11:22:33:44:55", name="Test Device", details={}, rssi=-50, ) advertisement = AdvertisementData( local_name="Test Device", manufacturer_data={}, service_data={}, service_uuids=[], rssi=-50, platform_data=(), tx_power=None, ) # Create scanner devices for each scanner device_all_free = BluetoothScannerDevice( scanner=scanner_all_free, ble_device=ble_device, advertisement=advertisement, ) device_one_slot = BluetoothScannerDevice( scanner=scanner_one_slot, ble_device=ble_device, advertisement=advertisement, ) device_no_slots = BluetoothScannerDevice( scanner=scanner_no_slots, ble_device=ble_device, advertisement=advertisement, ) # Set allocations for each scanner scanner_all_free._test_allocations = Allocations( adapter="adapter1", slots=5, free=5, allocated=[], ) scanner_one_slot._test_allocations = Allocations( adapter="adapter2", slots=5, free=1, allocated=[ "AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02", "AA:BB:CC:DD:EE:03", "AA:BB:CC:DD:EE:04", ], ) scanner_no_slots._test_allocations = Allocations( adapter="adapter3", slots=5, free=0, allocated=[ "AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02", "AA:BB:CC:DD:EE:03", "AA:BB:CC:DD:EE:04", "AA:BB:CC:DD:EE:05", ], ) score_all_free = scanner_all_free._score_connection_paths(10, device_all_free) score_one_slot = scanner_one_slot._score_connection_paths(10, device_one_slot) score_no_slots = scanner_no_slots._score_connection_paths(10, device_no_slots) # Verify the scoring order: all_free > one_slot > no_slots assert score_all_free > score_one_slot assert score_one_slot > score_no_slots # Verify specific values assert score_all_free == -50 assert score_one_slot == -57.6 assert score_no_slots == -127 # NO_RSSI_VALUE def test_score_with_connections_in_progress_and_slots(): """Test that both connection progress and slot availability are considered.""" scanner = TestScanner( source="test_source", adapter="test_adapter", connectable=True, ) # Add a connection in progress scanner._add_connecting("FF:EE:DD:CC:BB:AA") # Set test allocations to return only 1 slot free scanner._test_allocations = Allocations( adapter="test_adapter", slots=5, free=1, allocated=[ "AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02", "AA:BB:CC:DD:EE:03", "AA:BB:CC:DD:EE:04", ], ) ble_device = BLEDevice( address="00:11:22:33:44:55", name="Test Device", details={}, rssi=-50, ) advertisement = AdvertisementData( local_name="Test Device", manufacturer_data={}, service_data={}, service_uuids=[], rssi=-50, platform_data=(), tx_power=None, ) scanner_device = BluetoothScannerDevice( scanner=scanner, ble_device=ble_device, advertisement=advertisement, ) # Both penalties should apply score = scanner._score_connection_paths(10, scanner_device) # -50 (RSSI) - 10.1 (connection in progress) - 7.6 (last slot) assert score == -50 - 10.1 - 7.6 assert score == -67.7 @pytest.mark.asyncio async def test_on_scanner_start_callback_remote_scanner( async_mock_manager_with_scanner_callbacks: MockBluetoothManagerWithCallbacks, ) -> None: """Test that on_scanner_start is called when a remote scanner starts.""" manager = async_mock_manager_with_scanner_callbacks # Create a fake remote scanner scanner = FakeScanner( source="esp32_proxy", adapter="esp32_proxy", connector=None, connectable=True, ) # Simulate scanner start success scanner._on_start_success() # Verify the callback was called assert len(manager.scanner_start_calls) == 1 assert manager.scanner_start_calls[0] is scanner @pytest.mark.asyncio async def test_on_scanner_start_multiple_scanners( async_mock_manager_with_scanner_callbacks: MockBluetoothManagerWithCallbacks, ) -> None: """Test that on_scanner_start is called for multiple scanners.""" manager = async_mock_manager_with_scanner_callbacks # Create multiple scanners scanner1 = FakeScanner( source="scanner1", adapter="scanner1", connector=None, connectable=True, ) scanner2 = FakeScanner( source="scanner2", adapter="scanner2", connector=None, connectable=True, ) # Simulate both scanners starting scanner1._on_start_success() scanner2._on_start_success() # Verify both callbacks were called assert len(manager.scanner_start_calls) == 2 assert scanner1 in manager.scanner_start_calls assert scanner2 in manager.scanner_start_calls @pytest.mark.asyncio async def test_scanner_without_manager() -> None: """Test that _on_start_success handles scanner without manager gracefully.""" # Set a temporary manager for scanner creation mock_bluetooth_adapters = FakeBluetoothAdapters() temp_manager = BluetoothManager( mock_bluetooth_adapters, slot_manager=Mock(), ) original_manager = get_manager() set_manager(temp_manager) try: # Create a scanner scanner = FakeScanner( source="test", adapter="test", connector=None, connectable=True, ) # Clear the manager to simulate no manager scenario scanner._manager = None # type: ignore[assignment] # Should not raise an exception scanner._on_start_success() finally: # Restore original manager set_manager(original_manager) Bluetooth-Devices-habluetooth-21accde/tests/test_benchmark_base_scanner.py000066400000000000000000000606011507024716100273350ustar00rootroot00000000000000"""Benchmarks for the base scanner.""" from __future__ import annotations import pytest from bleak.backends.scanner import AdvertisementData from bluetooth_data_tools import monotonic_time_coarse from pytest_codspeed import BenchmarkFixture from habluetooth import BaseHaRemoteScanner, HaBluetoothConnector, get_manager from . import ( MockBleakClient, generate_advertisement_data, generate_ble_device, ) @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_inject_100_simple_advertisements(benchmark: BenchmarkFixture) -> None: """Test injecting 100 simple advertisements.""" manager = get_manager() switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"], service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"}, manufacturer_data={1: b"\x01"}, rssi=-100, ) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) _address = switchbot_device.address _name = switchbot_device.name _service_uuids = switchbot_device_adv.service_uuids _service_data = switchbot_device_adv.service_data _manufacturer_data = switchbot_device_adv.manufacturer_data _tx_power = switchbot_device_adv.tx_power _details = {"scanner_specific_data": "test"} _now = monotonic_time_coarse() @benchmark def run(): for _ in range(100): scanner._async_on_advertisement( _address, -100, # rssi _name, _service_uuids, _service_data, _manufacturer_data, _tx_power, _details, _now, ) cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_inject_100_complex_advertisements(benchmark: BenchmarkFixture) -> None: """Test injecting 100 complex advertisements.""" manager = get_manager() switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"], service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"}, manufacturer_data=dict.fromkeys(range(100), b"\x01"), rssi=-100, ) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) _address = switchbot_device.address _name = switchbot_device.name _service_uuids = switchbot_device_adv.service_uuids _service_data = switchbot_device_adv.service_data _manufacturer_data = switchbot_device_adv.manufacturer_data _tx_power = switchbot_device_adv.tx_power _details = {"scanner_specific_data": "test"} _now = monotonic_time_coarse() @benchmark def run(): for _ in range(100): scanner._async_on_advertisement( _address, -100, # rssi _name, _service_uuids, _service_data, _manufacturer_data, _tx_power, _details, _now, ) cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_inject_100_different_advertisements(benchmark: BenchmarkFixture) -> None: """Test injecting 100 different advertisements.""" manager = get_manager() switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, rssi=-100, ) advs: list[AdvertisementData] = [] for i in range(100): switchbot_device_adv = generate_advertisement_data( local_name="wohand", service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"], service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"}, manufacturer_data={i: b"\x01"}, rssi=-100, ) advs.append(switchbot_device_adv) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) _address = switchbot_device.address _name = switchbot_device.name _service_uuids = switchbot_device_adv.service_uuids _service_data = switchbot_device_adv.service_data _tx_power = switchbot_device_adv.tx_power _details = {"scanner_specific_data": "test"} _now = monotonic_time_coarse() @benchmark def run(): for adv in advs: scanner._async_on_advertisement( _address, -100, # rssi _name, _service_uuids, _service_data, adv.manufacturer_data, _tx_power, _details, _now, ) cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_inject_100_different_manufacturer_data( benchmark: BenchmarkFixture, ) -> None: """Test injecting 100 different manufacturer_data.""" manager = get_manager() switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, rssi=-100, ) advs: list[AdvertisementData] = [] for i in range(100): switchbot_device_adv = generate_advertisement_data( local_name="wohand", service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"], service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"}, manufacturer_data={1: b"\x01", 3: bytes((i,) * 20)}, rssi=-100, ) advs.append(switchbot_device_adv) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) _address = switchbot_device.address _name = switchbot_device.name _service_uuids = switchbot_device_adv.service_uuids _service_data = switchbot_device_adv.service_data _tx_power = switchbot_device_adv.tx_power _details = {"scanner_specific_data": "test"} _now = monotonic_time_coarse() @benchmark def run(): for adv in advs: scanner._async_on_advertisement( _address, -100, # rssi _name, _service_uuids, _service_data, adv.manufacturer_data, _tx_power, _details, _now, ) cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_inject_100_different_service_data( benchmark: BenchmarkFixture, ) -> None: """Test injecting 100 different service_data.""" manager = get_manager() switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, rssi=-100, ) advs: list[AdvertisementData] = [] for i in range(100): switchbot_device_adv = generate_advertisement_data( local_name="wohand", service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"], service_data={"050a021a-0000-1000-8000-00805f9b34fb": bytes((i,) * 20)}, manufacturer_data={1: b"\x01"}, rssi=-100, ) advs.append(switchbot_device_adv) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) _address = switchbot_device.address _name = switchbot_device.name _service_uuids = switchbot_device_adv.service_uuids _service_data = switchbot_device_adv.service_data _tx_power = switchbot_device_adv.tx_power _details = {"scanner_specific_data": "test"} _now = monotonic_time_coarse() @benchmark def run(): for adv in advs: scanner._async_on_advertisement( _address, -100, # rssi _name, _service_uuids, _service_data, adv.manufacturer_data, _tx_power, _details, _now, ) cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_inject_100_rotating_manufacturer_data( benchmark: BenchmarkFixture, ) -> None: """Test injecting 100 different manufacturer_data to mimic a sensor push device.""" manager = get_manager() sensor_push_device = generate_ble_device( "44:44:33:11:23:45", "", {}, rssi=-60, ) sensor_push_device_adv = generate_advertisement_data( local_name="", rssi=-60, manufacturer_data={ 17667: b"\xad\x00\x01\x00\x00", 1280: b"\xe7\xb4\xe1\xaf\xb6", 2304: b"7\xe1:\xb7\xb6", 55552: b"#\xc7$\xad\xb6", 58624: b";\x01%\x9d\xb6", 44288: b"\xa2|'x\xb6", 64000: b";\xad\xdc\xa7\xb6", 28672: b"\xdb\xe8\\\xa2\xb6", 7168: b"\xb5\xbd\xe0\xaf\xb6", 11264: b"\x00S}\xae\xb6", 4096: b"\xe9\xef\x8e\xba\xb6", 44800: b"\x85\xa2=\xb5\xb6", 32768: b"\x86b\xe9\xc1\xb6", 37376: b"\x8bS<\xc1\xb6", 25344: b"\xb4\xb2\xe7\xbb\xb6", 51200: b"\xae\xdc\xc8\x97\xb6", 49152: b"O\x80O\xc7\xb6", 17664: b"\x0e\xb7q\xa0\xb6", 34816: b"\x9a\xf6\xf8\xc3\xb6", 21760: b"G\xd9\xd6\xa7\xb6", 512: b"\xaa\x14M\xc5\xb6", 41984: b"\xfd\xb4\xd7\xa5\xb6", 16640: b"\x9b\xdd\xd9\xa5\xb6", 33024: b"\x99\xdbB\xb9\xb6", 25088: b"\xee\xec\xea\xbf\xb6", 24576: b"\xc3G\x16\x99\xb6", 50176: b"\x88Q\x9d\xc4\xb6", 57856: b"~\x1a\xb0\x87\xb6", 2816: b"08\xa2\xc4\xb6", 19712: b",\xf1u\x9a\xb6", 26880: b"\x8f\x0f(\xa9\xb6", 54528: b"U\xbe\x1c\x9b\xb6", 7936: b"\x01\x1e\x93\xbc\xb6", 52992: b"R\x19\xb9\x91\xb6", 9472: b"\x0f\xb9\x9a\x87\xb6", 47360: b"A\xe16\xb5\xb6", 14080: b"r\x82S\xc7\xb6", 60416: b"#A\xc5v\xb6", 19968: b"\xf5=\x80\xa0\xb6", 30976: b"\r\x99\x13\x91\xb6", 9216: b'\x08">\xbd\xb6', 16896: b'"\x94L\xc7\xb6', 54784: b"\xae\xce%\x9d\xb6", 21248: b"\xb9\xe9\xe1\xb9\xb6", 40960: b"\x15}\xda\xbb\xb6", 16128: b"s\xe9\xf7\xc5\xb6", 36608: b"\xad\xd6\x8f\xc0\xb6", 1536: b"\x1a\xd1\x8c\xb0\xb6", 30720: b"\xf4`\x93\xb4\xb6", 17920: b"mIi\xae\xb6", 30464: b"\x8c}\x19\x99\xb6", 61952: b"\xb4{\xec\xbd\xb6", 30208: b'\xa8\xac"\x9b\xb6', 27904: b"D\xcb8\xb5\xb6", 45568: b"\xfc\xb5\xdf\xa9\xb6", 12288: b"\xe9\x11\xa7\x8f\xb6", 6400: b"\\\xcf\xe0\xb7\xb6", 10496: b"P_\xe1\xbb\xb6", 52736: b"fv\xd3\xa1\xb6", 37888: b"\xb1\x7f'\xaf\xb6", 6656: b"\x80Wh\x90\xb6", 15872: b"\xd7\x91\xe0\xb7\xb6", 28160: b"P<\xc5\x95\xb6", 37632: b"NN\xc7x\xb6", 11776: b"\x03z0\xab\xb6", 48896: b"B\x9e\xaa\xc8\xb6", 65280: b"w\xb1\xee\xb9\xb6", 56320: b"\xb1\xfa\x1f\x99\xb6", 59136: b"_\xd5\x1c\x97\xb6", 26368: b"\xbe\x82\xbd\x93\xb6", 7424: b"A\xc8\x19\x99\xb6", 49408: b"\xef\xda\x91\xb4\xb6", 24832: b"l\xc03\xbd\xb6", 48128: b"Vs4\xa9\xb6", 48384: b"\xack;\xbb\xb6", 20224: b"\xd8O\xe5\xb9\xb6", 35840: b"Nj\xe1\xbb\xb6", 51712: b"\x96\xba\xcc\x9b\xb6", 23296: b"\xda\\v\x9c\xb6", 39168: b"0j\xe3\xb3\xb6", 29440: b"\xf9\xc9J\xc3\xb6", 54016: b"\xe9\x1c\x88\xa6\xb6", 62208: b"\x1b\x0f\xe3\xbf\xb6", 33280: b"\xc2s\x83\xa2\xb6", 20480: b"\xa9\xc5\xc4\x95\xb6", 50688: b"\xd5O\xe5\xb7\xb6", 19456: b"T }\x9e\xb6", 27136: b"\xd3\n\xda\xbb\xb6", 34304: b"\x10\x164\xb7\xb6", 3328: b'"\xb1\x1c\x99\xb6', 50944: b"it\xbf\x91\xb6", 29952: b"\xd7\xc5\xb8\x93\xb6", 46592: b"\x14-\xbc\x95\xb6", 60928: b"|\xcd\xb8\x8d\xb6", 16384: b"4\x95\xce\x9b\xb6", 23040: b"\x99\xca\x9f\x8d\xb6", 58112: b"P\xcc;\xb7\xb6", 22784: b"\x8a4L\xc5\xb6", 12800: b"el\xe0\xad\xb6", 8960: b"xe\x8e\xb8\xb6", 13568: b"\xec2\x8f\xb8\xb6", 36864: b"\r\xde1\xb5\xb6", 64512: b"\xf7\xf8\x17n\xb6", 39424: b"?\xbc\x87\xa8\xb6", 8448: b"\xfa\x8c\xa6\x8f\xb6", 53760: b"\xf3\x92\xdd\xb3\xb6", 23552: b"A\xb5A\xc3\xb6", 51968: b"\xb6\xc9\xa5^\xb6", 9728: b"\xff\xa1\x7f\xa6\xb6", 18944: b"\xc0\xddI\xc1\xb6", 46848: b"\x05t\xea\xb9\xb6", 33792: b"\xdb\xa8\xd9\xa3\xb6", 6144: b"+\xcb?\xb9\xb6", 10752: b";\x93:\xb9\xb6", 40704: b"\x8e\x85p\x96\xb6", 58368: b"\x91\xf2\xd0\x9d\xb6", 32512: b"\xec\x80\x85\xa4\xb6", 55808: b"-\x98\x80\xb0\xb6", 25856: b"\x90\xd5\x85\xaa\xb6", 58880: b":J\x81\xba\xb6", 31232: b"\x80\xfe\xdd\xa5\xb6", 55040: b"o13\xa9\xb6", 50432: b"t\xe5I\xc3\xb6", 37120: b"\xd3\x05\x89\xa6\xb6", 12544: b"\x06\x00<\xbd\xb6", 59904: b"\xddb\xbe\x93\xb6", 27392: b"OB\x0f\x8f\xb6", 61696: b"\x1d\xe8\x18\x97\xb6", 29696: b"#\xcc\xde\xbd\xb6", 32000: b"X}3\xb5\xb6", 44544: b"\xb8\xa1\x1e\x99\xb6", 7680: b"\xe7Qr\x98\xb6", 45312: b"\xfbI\x10\x8f\xb6", 63488: b"\xc6\xda(\xa5\xb6", 25600: b"O\xda\xe2\xb7\xb6", 24320: b"r\x14n\x98\xb6", 62464: b"\xb0\x87\xf4\xc1\xb6", 63744: b"\x96\xd6\x14\x95\xb6", 21504: b"[\x85\x0f\x93\xb6", 8192: b"\xb7\x84\xd3\xa5\xb6", 29184: b"\xbf\xdfg\x90\xb6", 64768: b"\xa2\x84\xe5\xbf\xb6", 57088: b"9\t\x8a\xb4\xb6", 22272: b"r~(\x9f\xb6", 55296: b".\x03\xc6\x97\xb6", 34560: b"\xb5r\x7f\xa0\xb6", 52224: b"\xe2\xc3\x1c\xa3\xb6", 13824: b"8>\xe6\xb5\xb6", 46080: b"Y\x7f@\xb9\xb6", 34048: b"/_k\x96\xb6", 4608: b"9\x95K\xc3\xb6", 62720: b"K-;\x84\xb6", 44032: b"\xd5\xd0\xa7\xc6\xb6", 35584: b"?}D\xcd\xb6", 43008: b"2\x8f\x8a\xae\xb6", 47104: b"\xa1\xff\xe6\xbb\xb6", 61184: b"\xa3\x7f%\xa5\xb6", 59648: b"\xf1\xb8\x8d\xb4\xb6", 57344: b"\x88\xee2\xb7\xb6", 36096: b"\\\xd5\x9c\xc0\xb6", 38912: b"n\x12_\x90\xb6", 56832: b"$gG\xc3\xb6", 18176: b"\xf9\x96\xfc\xc5\xb6", 18432: b"b\xcdA\xbf\xb6", 57600: b":\x19@\xbf\xb6", 18688: b"$\xe2\xcb\x99\xb6", 38656: b"\x0cA-\xb9\xb6", 48640: b"V\x8c\xda\xab\xb6", 46336: b'"WL]\xb6', 9984: b"\xa8\xab\xa2\xd2\xb6", 42496: b"4\x0b\x1f\x9b\xb6", 41216: b")M%\xab\xb6", 49664: b"M%6\xa9\xb6", 42240: b",\x1e\x86\xb6\xb6", 20992: b"\xab\x052\xbd\xb6", 53504: b"\x8a\xf6\x84\xaa\xb6", 56064: b"\xda\xbf\xa4`\xb6", 53248: b"\x18:\xc8\x99\xb6", 19200: b"\xb1\xb4\x89\xbc\xb6", 38400: b"\xba=\x1f\x99\xb6", 41728: b"\xe4\xa3+\xb1\xb6", 5376: b"z\xd6\x94\xb2\xb6", 47616: b"\x88\x1f\xe3\xb9\xb6", 60672: b"\x9c\x85{\xb4\xb6", 3584: b"\xe7\xdc\xa8\xc6\xb6", 28416: b"\xdc\xddT\x90\xb6", 14336: b"\x87\xa6\xf2\xc5\xb6", 43776: b"9y\x8a\xae\xb6", 39936: b"\xe2\x8cSa\xb6", 5632: b"\xa5_0\xab\xb6", 14592: b"\xbf\xa9\x80\xae\xb6", 63232: b"\xd6A\xc5\x99\xb6", 13312: b"=\xcdL\xc3\xb6", 8704: b"\xf9\xd1'\xa1\xb6", 11008: b"\xdc\xed\xf6\xc5\xb6", 26624: b"\x9b\x81\xc2\x99\xb6", 13056: b"\x88@\xda\xab\xb6", 5888: b"p\xea\x85\xaa\xb6", 12032: b"L\xdb\xe9\xb9\xb6", 3072: b"$\x1e\x83\xac\xb6", 31744: b"\xcb\xe60\xad\xb6", 14848: b"\xee\x9d\xe8\xc9\xb6", 45824: b"Mo\x8e\xb2\xb6", 768: b"\xa6\x8d=\xb5\xb6", 56576: b"\x02\xba\x8d\xb0\xb6", 49920: b"\xa3\xac$\x9d\xb6", 41472: b"\x9dM\xe0\xab\xb6", 65024: b"\x89!\xf2\xbf\xb6", 1024: b"\x89\xbf\x8d\xb4\xb6", 0: b"\xb6\xc5\xc2\x97\xb6", 61440: b"\xad\xa8s\x98\xb6", 17408: b"\xc2\x99?\xbb\xb6", 42752: b"R\xf81\xa9\xb6", 38144: b'\x83\x89"\x9d\xb6', 43520: b"\xb7\xa2'\x9f\xb6", 35328: b";d\xa2\xd0\xb6", 51456: b"\xa4\x85h\x90\xb6", 35072: b"\xfb\x90@\xbf\xb6", 39680: b"\xf5\xcb\x04\xa1\xb6", 4352: b"j\xd0e\x92\xb6", 32256: b"\xcc\x99\xbf\x95\xb6", 3840: b"\xd0\xdd\xc7\x99\xb6", 45056: b"U\xf2\xf0\xc3\xb6", 47872: b'\xdc\x07"\x9b\xb6', 60160: b"0\x8a\xdf\xbb\xb6", 28928: b"\xe7\xa8\xdc\xaf\xb6", 54272: b"c\x15\x85\xb4\xb6", 17152: b"\xc0Q\x7f\xa0\xb6", 5120: b"B@w\x9a\xb6", 43264: b"rC\x85\xaa\xb6", 23808: b"[\xe3=\xb7\xb6", 256: b"\x9c\x9f\x90\xb2\xb6", 6912: b"\xf2\x18\xdc\xab\xb6", 15616: b"\x1b,/\xb5\xb6", 15104: b"{=\xbf\x91\xb6", 4864: b"g?\xe3\xb9\xb6", 36352: b"\x88\xac2\xad\xb6", 22016: b"O\x91\x18\x95\xb6", 52480: b"q\x8dS\xc9\xb6", 62976: b"ZX\x7f\xa2\xb6", 59392: b"\xef\xdc\xa5\xc4\xb6", 15360: b"\xd0\x9aD\xc1\xb6", 10240: b"a\x92\x92\xb0\xb6", 2048: b" /]\x9a\xb6", 20736: b"\x9d\xdek\x94\xb6", 2560: b"\xf5z=\xb3\xb6", 22528: b"j@\xe2\xad\xb6", 26112: b"\x18\x1f\xc5x\xb6", 40448: b"\xdf\xfe=\xbb\xb6", 11520: b"2\xf7<\xbb\xb6", 1792: b"$\n\x1c\x99\xb6", 40192: b"\xaa\x88\xff\xc9\xb6", 27648: b"\x87\xac\xb8\x8d\xb6", 33536: b"{:\x1b\x97\xb6", 64256: b"B\r.\xa9\xb6", 31488: b"\x98\xfa\xb6\x91\xb6", }, service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"], service_data={}, ) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) scanner._async_on_advertisement( sensor_push_device.address, -100, # rssi sensor_push_device.name, sensor_push_device_adv.service_uuids, sensor_push_device_adv.service_data, sensor_push_device_adv.manufacturer_data, sensor_push_device_adv.tx_power, {"scanner_specific_data": "test"}, monotonic_time_coarse(), ) advs: list[AdvertisementData] = [] for i in range(100): sensorpush_device_adv = generate_advertisement_data( local_name="", service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"], service_data={}, manufacturer_data={i: bytes((i,) * 20)}, rssi=-(i), ) advs.append(sensorpush_device_adv) _address = sensor_push_device.address _name = sensor_push_device.name _service_uuids = sensorpush_device_adv.service_uuids _service_data = sensorpush_device_adv.service_data _tx_power = sensorpush_device_adv.tx_power _details = {"scanner_specific_data": "test"} _now = monotonic_time_coarse() @benchmark def run(): for adv in advs: scanner._async_on_advertisement( _address, -100, # rssi _name, _service_uuids, _service_data, adv.manufacturer_data, _tx_power, _details, _now, ) cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_filter_unwanted_apple_advs(benchmark: BenchmarkFixture) -> None: """Test filtering unwanted apple data.""" manager = get_manager() device = generate_ble_device( "44:44:33:11:23:45", "beacon", {}, rssi=-100, ) device_adv = generate_advertisement_data( local_name="beacon", service_uuids=[], service_data={}, manufacturer_data={76: b"\xff"}, rssi=-100, ) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) _address = device.address _name = device.name _service_uuids = device_adv.service_uuids _service_data = device_adv.service_data _manufacturer_data = device_adv.manufacturer_data _tx_power = device_adv.tx_power _details = {"scanner_specific_data": "test"} _now = monotonic_time_coarse() @benchmark def run(): for _ in range(100): scanner._async_on_advertisement( _address, -100, # rssi _name, _service_uuids, _service_data, _manufacturer_data, _tx_power, _details, _now, ) cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_filter_wanted_apple_advs(benchmark: BenchmarkFixture) -> None: """Test filtering wanted apple data.""" manager = get_manager() device = generate_ble_device( "44:44:33:11:23:45", "beacon", {}, rssi=-100, ) device_adv = generate_advertisement_data( local_name="beacon", service_uuids=[], service_data={}, manufacturer_data={76: b"\x02"}, rssi=-100, ) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) _address = device.address _name = device.name _service_uuids = device_adv.service_uuids _service_data = device_adv.service_data _manufacturer_data = device_adv.manufacturer_data _tx_power = device_adv.tx_power _details = {"scanner_specific_data": "test"} _now = monotonic_time_coarse() @benchmark def run(): for _ in range(100): scanner._async_on_advertisement( _address, -100, # rssi _name, _service_uuids, _service_data, _manufacturer_data, _tx_power, _details, _now, ) cancel() unsetup() Bluetooth-Devices-habluetooth-21accde/tests/test_init.py000066400000000000000000000147221507024716100236460ustar00rootroot00000000000000from unittest.mock import ANY from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from habluetooth import ( BaseHaRemoteScanner, BaseHaScanner, BluetoothScanningMode, HaBluetoothConnector, HaScanner, ) class MockBleakClient: pass def test_create_scanner(): connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True) class MockScanner(BaseHaScanner): pass @property def discovered_devices_and_advertisement_data(self): return [] @property def discovered_devices(self): return [] scanner = MockScanner("any", "any", connector) assert isinstance(scanner, BaseHaScanner) def test_create_remote_scanner(): connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True) scanner = BaseHaRemoteScanner("any", "any", connector, True) assert isinstance(scanner, BaseHaRemoteScanner) def test__async_on_advertisement(): connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True) scanner = BaseHaRemoteScanner("any", "any", connector, True) details = scanner._details | {} scanner._async_on_advertisement( "AA:BB:CC:DD:EE:FF", -88, "name", ["service_uuid"], {"service_uuid": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"}, {32: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"}, -88, details, 1.0, ) scanner._async_on_advertisement( "AA:BB:CC:DD:EE:FF", -21, "name", ["service_uuid2"], {"service_uuid2": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"}, {21: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"}, -88, details, 1.0, ) ble_device = BLEDevice( "AA:BB:CC:DD:EE:FF", "name", details, ) first_device = scanner.discovered_devices[0] assert first_device.address == ble_device.address assert first_device.details == ble_device.details assert first_device.name == ble_device.name assert "AA:BB:CC:DD:EE:FF" in scanner.discovered_devices_and_advertisement_data adv = scanner.discovered_devices_and_advertisement_data["AA:BB:CC:DD:EE:FF"][1] assert set(adv.service_data) == {"service_uuid", "service_uuid2"} assert adv == AdvertisementData( local_name="name", manufacturer_data={ 32: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b", 21: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b", }, service_data={ "service_uuid": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b", "service_uuid2": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b", }, service_uuids=ANY, tx_power=-88, rssi=-21, platform_data=(), ) assert len(scanner.discovered_devices) == 1 assert scanner.discovered_devices[0].address == "AA:BB:CC:DD:EE:FF" assert len(scanner.discovered_devices_and_advertisement_data) == 1 # BLEDevice no longer has rssi attribute in bleak 1.0+ # rssi is only available in AdvertisementData assert ( scanner.discovered_devices_and_advertisement_data["AA:BB:CC:DD:EE:FF"][1].rssi == -21 ) assert "AA:BB:CC:DD:EE:FF" in scanner.discovered_addresses device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF") assert device_adv is not None assert device_adv[1] == adv def test__async_on_advertisement_first(): connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True) scanner = BaseHaRemoteScanner("any", "any", connector, True) details = scanner._details | {} scanner._async_on_advertisement( "AA:BB:CC:DD:EE:FF", -88, "name", ["service_uuid"], {"service_uuid": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"}, {32: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"}, -88, details, 1.0, ) device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF") assert device_adv is not None device, adv = device_adv assert device is not None assert adv is not None assert device.address == "AA:BB:CC:DD:EE:FF" assert adv.rssi == -88 assert adv.service_uuids == ["service_uuid"] assert adv.service_data == { "service_uuid": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b" } assert adv.manufacturer_data == { 32: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b" } assert adv.service_uuids == ANY assert adv.tx_power == -88 assert adv.rssi == -88 assert adv.platform_data == () assert device.name == "name" assert device.details == details def test__async_on_advertisement_prefers_longest_local_name(): connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True) scanner = BaseHaRemoteScanner("any", "any", connector, True) details = scanner._details | {} scanner._async_on_advertisement( "AA:BB:CC:DD:EE:FF", -88, "shortname", [], {}, {}, -88, details, 1.0, ) device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF") assert device_adv is not None device, adv = device_adv assert device is not None assert adv is not None assert device.name == "shortname" assert adv.local_name == "shortname" scanner._async_on_advertisement( "AA:BB:CC:DD:EE:FF", -88, "tinyname", [], {}, {}, -88, details, 1.0, ) device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF") assert device_adv is not None device, adv = device_adv assert device is not None assert adv is not None assert device.name == "shortname" assert adv.local_name == "shortname" scanner._async_on_advertisement( "AA:BB:CC:DD:EE:FF", -88, "longername", [], {}, {}, -88, details, 1.0, ) device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF") assert device_adv is not None device, adv = device_adv assert device is not None assert adv is not None assert device.name == "longername" assert adv.local_name == "longername" def test_create_ha_scanner(): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") assert isinstance(scanner, HaScanner) Bluetooth-Devices-habluetooth-21accde/tests/test_manager.py000066400000000000000000001317061507024716100243170ustar00rootroot00000000000000"""Tests for the manager.""" import asyncio import time from datetime import timedelta from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch import pytest from bleak_retry_connector import AllocationChange, Allocations, BleakSlotManager from bluetooth_adapters.systems.linux import LinuxAdapters from freezegun import freeze_time from habluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, TRACKER_BUFFERING_WOBBLE_SECONDS, UNAVAILABLE_TRACK_SECONDS, BluetoothManager, BluetoothScanningMode, BluetoothServiceInfoBleak, HaBluetoothSlotAllocations, HaScannerModeChange, HaScannerRegistration, HaScannerRegistrationEvent, get_manager, set_manager, ) from . import ( HCI0_SOURCE_ADDRESS, HCI1_SOURCE_ADDRESS, async_fire_time_changed, generate_advertisement_data, generate_ble_device, inject_advertisement_with_source, inject_advertisement_with_time_and_source, inject_advertisement_with_time_and_source_connectable, patch_bluetooth_time, utcnow, ) from .conftest import FakeBluetoothAdapters, FakeScanner SOURCE_LOCAL = "local" @pytest.mark.asyncio @pytest.mark.skipif("platform.system() == 'Windows'") async def test_async_recover_failed_adapters() -> None: """Return the BluetoothManager instance.""" attempt = 0 class MockLinuxAdapters(LinuxAdapters): @property def adapters(self) -> dict[str, Any]: nonlocal attempt attempt += 1 if attempt == 1: return { "hci0": { "address": "00:00:00:00:00:01", "hw_version": "usb:v1D6Bp0246d053F", "passive_scan": False, "sw_version": "homeassistant", "manufacturer": "ACME", "product": "Bluetooth Adapter 5.0", "product_id": "aa01", "vendor_id": "cc01", }, "hci1": { "address": "00:00:00:00:00:00", "hw_version": "usb:v1D6Bp0246d053F", "passive_scan": False, "sw_version": "homeassistant", "manufacturer": "ACME", "product": "Bluetooth Adapter 5.0", "product_id": "aa01", "vendor_id": "cc01", }, "hci2": { "address": "00:00:00:00:00:00", "hw_version": "usb:v1D6Bp0246d053F", "passive_scan": False, "sw_version": "homeassistant", "manufacturer": "ACME", "product": "Bluetooth Adapter 5.0", "product_id": "aa01", "vendor_id": "cc01", }, } return { "hci0": { "address": "00:00:00:00:00:01", "hw_version": "usb:v1D6Bp0246d053F", "passive_scan": False, "sw_version": "homeassistant", "manufacturer": "ACME", "product": "Bluetooth Adapter 5.0", "product_id": "aa01", "vendor_id": "cc01", }, "hci1": { "address": "00:00:00:00:00:02", "hw_version": "usb:v1D6Bp0246d053F", "passive_scan": False, "sw_version": "homeassistant", "manufacturer": "ACME", "product": "Bluetooth Adapter 5.0", "product_id": "aa01", "vendor_id": "cc01", }, "hci2": { "address": "00:00:00:00:00:03", "hw_version": "usb:v1D6Bp0246d053F", "passive_scan": False, "sw_version": "homeassistant", "manufacturer": "ACME", "product": "Bluetooth Adapter 5.0", "product_id": "aa01", "vendor_id": "cc01", }, } with ( patch("habluetooth.manager.async_reset_adapter") as mock_async_reset_adapter, ): adapters = MockLinuxAdapters() slot_manager = BleakSlotManager() manager = BluetoothManager(adapters, slot_manager) await manager.async_setup() set_manager(manager) adapter = await manager.async_get_adapter_from_address_or_recover( "00:00:00:00:00:03" ) assert adapter == "hci2" adapter = await manager.async_get_adapter_from_address_or_recover( "00:00:00:00:00:02" ) assert adapter == "hci1" adapter = await manager.async_get_adapter_from_address_or_recover( "00:00:00:00:00:01" ) assert adapter == "hci0" assert mock_async_reset_adapter.call_count == 2 assert mock_async_reset_adapter.call_args_list == [ (("hci1", "00:00:00:00:00:00", False),), (("hci2", "00:00:00:00:00:00", False),), ] @pytest.mark.asyncio async def test_create_manager() -> None: """Return the BluetoothManager instance.""" adapters = FakeBluetoothAdapters() slot_manager = BleakSlotManager() manager = BluetoothManager(adapters, slot_manager) set_manager(manager) assert manager @pytest.mark.asyncio @pytest.mark.usefixtures("enable_bluetooth") async def test_async_register_disappeared_callback( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """Test bluetooth async_register_disappeared_callback handles failures.""" manager = get_manager() assert manager._loop is not None address = "44:44:33:11:23:12" switchbot_device_signal_100 = generate_ble_device( address, "wohand_signal_100", rssi=-100 ) switchbot_adv_signal_100 = generate_advertisement_data( local_name="wohand_signal_100", service_uuids=[] ) inject_advertisement_with_source( switchbot_device_signal_100, switchbot_adv_signal_100, "hci0" ) failed_disappeared: list[str] = [] def _failing_callback(_address: str) -> None: """Failing callback.""" failed_disappeared.append(_address) raise ValueError("This is a test") ok_disappeared: list[str] = [] def _ok_callback(_address: str) -> None: """Ok callback.""" ok_disappeared.append(_address) cancel1 = manager.async_register_disappeared_callback(_failing_callback) # Make sure the second callback still works if the first one fails and # raises an exception cancel2 = manager.async_register_disappeared_callback(_ok_callback) switchbot_adv_signal_100 = generate_advertisement_data( local_name="wohand_signal_100", manufacturer_data={123: b"abc"}, service_uuids=[], rssi=-80, ) inject_advertisement_with_source( switchbot_device_signal_100, switchbot_adv_signal_100, "hci1" ) future_time = utcnow() + timedelta(seconds=3600) future_monotonic_time = time.monotonic() + 3600 with ( freeze_time(future_time), patch( "habluetooth.manager.monotonic_time_coarse", return_value=future_monotonic_time, ), ): manager._async_check_unavailable() async_fire_time_changed(future_time) assert len(ok_disappeared) == 1 assert ok_disappeared[0] == address assert len(failed_disappeared) == 1 assert failed_disappeared[0] == address cancel1() cancel2() @pytest.mark.asyncio @pytest.mark.usefixtures("enable_bluetooth") async def test_async_register_allocation_callback( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """Test bluetooth async_register_allocation_callback handles failures.""" manager = get_manager() assert manager._loop is not None address = "44:44:33:11:23:12" switchbot_device_signal_100 = generate_ble_device( address, "wohand_signal_100", rssi=-100 ) switchbot_adv_signal_100 = generate_advertisement_data( local_name="wohand_signal_100", service_uuids=[] ) inject_advertisement_with_source( switchbot_device_signal_100, switchbot_adv_signal_100, "hci0" ) failed_allocations: list[HaBluetoothSlotAllocations] = [] def _failing_callback(allocations: HaBluetoothSlotAllocations) -> None: """Failing callback.""" failed_allocations.append(allocations) raise ValueError("This is a test") ok_allocations: list[HaBluetoothSlotAllocations] = [] def _ok_callback(allocations: HaBluetoothSlotAllocations) -> None: """Ok callback.""" ok_allocations.append(allocations) cancel1 = manager.async_register_allocation_callback(_failing_callback) # Make sure the second callback still works if the first one fails and # raises an exception cancel2 = manager.async_register_allocation_callback(_ok_callback) switchbot_adv_signal_100 = generate_advertisement_data( local_name="wohand_signal_100", manufacturer_data={123: b"abc"}, service_uuids=[], rssi=-80, ) inject_advertisement_with_source( switchbot_device_signal_100, switchbot_adv_signal_100, "hci1" ) assert manager.async_current_allocations() == [ HaBluetoothSlotAllocations( source="AA:BB:CC:DD:EE:00", slots=5, free=5, allocated=[] ), HaBluetoothSlotAllocations( source="AA:BB:CC:DD:EE:11", slots=5, free=5, allocated=[] ), ] manager.async_on_allocation_changed( Allocations( "AA:BB:CC:DD:EE:00", 5, 4, ["44:44:33:11:23:12"], ) ) assert len(ok_allocations) == 1 assert ok_allocations[0] == HaBluetoothSlotAllocations( "AA:BB:CC:DD:EE:00", 5, 4, ["44:44:33:11:23:12"], ) assert len(failed_allocations) == 1 assert failed_allocations[0] == HaBluetoothSlotAllocations( "AA:BB:CC:DD:EE:00", 5, 4, ["44:44:33:11:23:12"], ) with patch.object( manager.slot_manager, "get_allocations", return_value=Allocations( adapter="hci0", slots=5, free=4, allocated=["44:44:33:11:23:12"], ), ): manager.slot_manager._call_callbacks( AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_44_44_33_11_23_12" ) assert len(ok_allocations) == 2 assert manager.async_current_allocations() == [ HaBluetoothSlotAllocations("AA:BB:CC:DD:EE:00", 5, 4, ["44:44:33:11:23:12"]), HaBluetoothSlotAllocations( source="AA:BB:CC:DD:EE:11", slots=5, free=5, allocated=[] ), ] assert manager.async_current_allocations("AA:BB:CC:DD:EE:00") == [ HaBluetoothSlotAllocations("AA:BB:CC:DD:EE:00", 5, 4, ["44:44:33:11:23:12"]), ] cancel1() cancel2() @pytest.mark.asyncio @pytest.mark.usefixtures("enable_bluetooth") async def test_async_register_allocation_callback_non_connectable( register_non_connectable_scanner: None, ) -> None: """Test async_current_allocations for a non-connectable scanner.""" manager = get_manager() assert manager._loop is not None assert manager.async_current_allocations() == [ HaBluetoothSlotAllocations( source="AA:BB:CC:DD:EE:FF", slots=0, free=0, allocated=[], ), ] @pytest.mark.asyncio @pytest.mark.usefixtures("enable_bluetooth") async def test_async_register_scanner_registration_callback( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """Test bluetooth async_register_scanner_registration_callback handles failures.""" manager = get_manager() assert manager._loop is not None scanners = manager.async_current_scanners() assert len(scanners) == 2 sources = {scanner.source for scanner in scanners} assert sources == {"AA:BB:CC:DD:EE:00", "AA:BB:CC:DD:EE:11"} failed_scanner_callbacks: list[HaScannerRegistration] = [] def _failing_callback(scanner_registration: HaScannerRegistration) -> None: """Failing callback.""" failed_scanner_callbacks.append(scanner_registration) raise ValueError("This is a test") ok_scanner_callbacks: list[HaScannerRegistration] = [] def _ok_callback(scanner_registration: HaScannerRegistration) -> None: """Ok callback.""" ok_scanner_callbacks.append(scanner_registration) cancel1 = manager.async_register_scanner_registration_callback( _failing_callback, None ) # Make sure the second callback still works if the first one fails and # raises an exception cancel2 = manager.async_register_scanner_registration_callback(_ok_callback, None) hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3") hci3_scanner.connectable = True manager = get_manager() cancel = manager.async_register_scanner(hci3_scanner, connection_slots=5) assert len(ok_scanner_callbacks) == 1 assert ok_scanner_callbacks[0] == HaScannerRegistration( HaScannerRegistrationEvent.ADDED, hci3_scanner ) assert len(failed_scanner_callbacks) == 1 cancel() assert len(ok_scanner_callbacks) == 2 assert ok_scanner_callbacks[1] == HaScannerRegistration( HaScannerRegistrationEvent.REMOVED, hci3_scanner ) cancel1() cancel2() @pytest.mark.asyncio @pytest.mark.usefixtures("enable_bluetooth") async def test_async_register_scanner_mode_change_callback( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """Test bluetooth async_register_scanner_mode_change_callback handles failures.""" manager = get_manager() assert manager._loop is not None scanners = manager.async_current_scanners() assert len(scanners) == 2 scanner = scanners[0] failed_mode_callbacks: list[HaScannerModeChange] = [] def _failing_callback(mode_change: HaScannerModeChange) -> None: """Failing callback.""" failed_mode_callbacks.append(mode_change) raise ValueError("This is a test") ok_mode_callbacks: list[HaScannerModeChange] = [] def _ok_callback(mode_change: HaScannerModeChange) -> None: """Ok callback.""" ok_mode_callbacks.append(mode_change) cancel1 = manager.async_register_scanner_mode_change_callback( _failing_callback, None ) # Make sure the second callback still works if the first one fails and # raises an exception cancel2 = manager.async_register_scanner_mode_change_callback(_ok_callback, None) # Test specific source callback source_specific_callbacks: list[HaScannerModeChange] = [] def _source_specific_callback(mode_change: HaScannerModeChange) -> None: """Source specific callback.""" source_specific_callbacks.append(mode_change) cancel3 = manager.async_register_scanner_mode_change_callback( _source_specific_callback, scanner.source ) # Change requested mode scanner.set_requested_mode(BluetoothScanningMode.ACTIVE) assert len(ok_mode_callbacks) == 1 assert ok_mode_callbacks[0].scanner == scanner assert ok_mode_callbacks[0].requested_mode == BluetoothScanningMode.ACTIVE assert ok_mode_callbacks[0].current_mode == scanner.current_mode assert len(failed_mode_callbacks) == 1 assert len(source_specific_callbacks) == 1 # Change current mode scanner.set_current_mode(BluetoothScanningMode.ACTIVE) assert len(ok_mode_callbacks) == 2 assert ok_mode_callbacks[1].scanner == scanner assert ok_mode_callbacks[1].current_mode == BluetoothScanningMode.ACTIVE assert len(failed_mode_callbacks) == 2 assert len(source_specific_callbacks) == 2 # No change when setting the same mode scanner.set_current_mode(BluetoothScanningMode.ACTIVE) assert len(ok_mode_callbacks) == 2 cancel1() cancel2() cancel3() @pytest.mark.asyncio async def test_async_register_scanner_with_connection_slots() -> None: """Test registering a scanner with connection slots.""" manager = get_manager() assert manager._loop is not None scanners = manager.async_current_scanners() assert len(scanners) == 0 hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3") hci3_scanner.connectable = True manager = get_manager() cancel = manager.async_register_scanner(hci3_scanner, connection_slots=5) assert manager.async_current_allocations(hci3_scanner.source) == [ HaBluetoothSlotAllocations(hci3_scanner.source, 5, 5, []) ] cancel() @pytest.mark.asyncio @pytest.mark.usefixtures("enable_bluetooth") async def test_diagnostics(register_hci0_scanner: None) -> None: """Test bluetooth diagnostics.""" manager = get_manager() assert manager._loop is not None manager.async_on_allocation_changed( Allocations( "AA:BB:CC:DD:EE:00", 5, 4, ["44:44:33:11:23:12"], ) ) diagnostics = await manager.async_diagnostics() assert diagnostics == { "adapters": {}, "advertisement_tracker": ANY, "all_history": ANY, "allocations": { "AA:BB:CC:DD:EE:00": { "allocated": ["44:44:33:11:23:12"], "free": 4, "slots": 5, "source": "AA:BB:CC:DD:EE:00", } }, "connectable_history": ANY, "scanners": [ { "discovered_devices_and_advertisement_data": [], "connectable": True, "current_mode": None, "requested_mode": None, "last_detection": 0.0, "monotonic_time": ANY, "name": "hci0 (AA:BB:CC:DD:EE:00)", "scanning": True, "source": "AA:BB:CC:DD:EE:00", "start_time": 0.0, "type": "FakeScanner", } ], "slot_manager": { "adapter_slots": {"hci0": 5}, "allocations_by_adapter": {"hci0": []}, "manager": False, }, } @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_advertisements_do_not_switch_adapters_for_no_reason( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """Test we only switch adapters when needed.""" address = "44:44:33:11:23:12" switchbot_device_signal_100 = generate_ble_device( address, "wohand_signal_100", rssi=-100 ) switchbot_adv_signal_100 = generate_advertisement_data( local_name="wohand_signal_100", service_uuids=[] ) inject_advertisement_with_source( switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS ) assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_signal_100 ) switchbot_device_signal_99 = generate_ble_device( address, "wohand_signal_99", rssi=-99 ) switchbot_adv_signal_99 = generate_advertisement_data( local_name="wohand_signal_99", service_uuids=[] ) inject_advertisement_with_source( switchbot_device_signal_99, switchbot_adv_signal_99, HCI0_SOURCE_ADDRESS ) assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_signal_99 ) switchbot_device_signal_98 = generate_ble_device( address, "wohand_good_signal", rssi=-98 ) switchbot_adv_signal_98 = generate_advertisement_data( local_name="wohand_good_signal", service_uuids=[] ) inject_advertisement_with_source( switchbot_device_signal_98, switchbot_adv_signal_98, HCI1_SOURCE_ADDRESS ) # should not switch to hci1 assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_signal_99 ) @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_switching_adapters_based_on_rssi( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """Test switching adapters based on rssi.""" address = "44:44:33:11:23:45" switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal") switchbot_adv_poor_signal = generate_advertisement_data( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( switchbot_device_poor_signal, switchbot_adv_poor_signal, HCI0_SOURCE_ADDRESS, ) assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_poor_signal ) switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal") switchbot_adv_good_signal = generate_advertisement_data( local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) inject_advertisement_with_source( switchbot_device_good_signal, switchbot_adv_good_signal, HCI1_SOURCE_ADDRESS, ) assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_good_signal ) inject_advertisement_with_source( switchbot_device_good_signal, switchbot_adv_poor_signal, HCI0_SOURCE_ADDRESS, ) assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_good_signal ) # We should not switch adapters unless the signal hits the threshold switchbot_device_similar_signal = generate_ble_device( address, "wohand_similar_signal" ) switchbot_adv_similar_signal = generate_advertisement_data( local_name="wohand_similar_signal", service_uuids=[], rssi=-62 ) inject_advertisement_with_source( switchbot_device_similar_signal, switchbot_adv_similar_signal, HCI0_SOURCE_ADDRESS, ) assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_good_signal ) @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_switching_adapters_based_on_zero_rssi( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """Test switching adapters based on zero rssi.""" address = "44:44:33:11:23:45" switchbot_device_no_rssi = generate_ble_device(address, "wohand_poor_signal") switchbot_adv_no_rssi = generate_advertisement_data( local_name="wohand_no_rssi", service_uuids=[], rssi=0 ) inject_advertisement_with_source( switchbot_device_no_rssi, switchbot_adv_no_rssi, HCI0_SOURCE_ADDRESS ) assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_no_rssi ) switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal") switchbot_adv_good_signal = generate_advertisement_data( local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) inject_advertisement_with_source( switchbot_device_good_signal, switchbot_adv_good_signal, HCI1_SOURCE_ADDRESS, ) assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_good_signal ) inject_advertisement_with_source( switchbot_device_good_signal, switchbot_adv_no_rssi, HCI0_SOURCE_ADDRESS ) assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_good_signal ) # We should not switch adapters unless the signal hits the threshold switchbot_device_similar_signal = generate_ble_device( address, "wohand_similar_signal" ) switchbot_adv_similar_signal = generate_advertisement_data( local_name="wohand_similar_signal", service_uuids=[], rssi=-62 ) inject_advertisement_with_source( switchbot_device_similar_signal, switchbot_adv_similar_signal, HCI0_SOURCE_ADDRESS, ) assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_good_signal ) @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_switching_adapters_based_on_stale( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """Test switching adapters based on the previous advertisement being stale.""" address = "44:44:33:11:23:41" start_time_monotonic = 50.0 switchbot_device_poor_signal_hci0 = generate_ble_device( address, "wohand_poor_signal_hci0" ) switchbot_adv_poor_signal_hci0 = generate_advertisement_data( local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100 ) inject_advertisement_with_time_and_source( switchbot_device_poor_signal_hci0, switchbot_adv_poor_signal_hci0, start_time_monotonic, HCI0_SOURCE_ADDRESS, ) assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_poor_signal_hci0 ) switchbot_device_poor_signal_hci1 = generate_ble_device( address, "wohand_poor_signal_hci1" ) switchbot_adv_poor_signal_hci1 = generate_advertisement_data( local_name="wohand_poor_signal_hci1", service_uuids=[], rssi=-99 ) inject_advertisement_with_time_and_source( switchbot_device_poor_signal_hci1, switchbot_adv_poor_signal_hci1, start_time_monotonic, HCI1_SOURCE_ADDRESS, ) # Should not switch adapters until the advertisement is stale assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_poor_signal_hci0 ) # Should switch to hci1 since the previous advertisement is stale # even though the signal is poor because the device is now # likely unreachable via hci0 inject_advertisement_with_time_and_source( switchbot_device_poor_signal_hci1, switchbot_adv_poor_signal_hci1, start_time_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1, "hci1", ) assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_poor_signal_hci1 ) @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_switching_adapters_based_on_stale_with_discovered_interval( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """Test switching with discovered interval.""" address = "44:44:33:11:23:41" start_time_monotonic = 50.0 switchbot_device_poor_signal_hci0 = generate_ble_device( address, "wohand_poor_signal_hci0" ) switchbot_adv_poor_signal_hci0 = generate_advertisement_data( local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100 ) inject_advertisement_with_time_and_source( switchbot_device_poor_signal_hci0, switchbot_adv_poor_signal_hci0, start_time_monotonic, HCI0_SOURCE_ADDRESS, ) assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_poor_signal_hci0 ) get_manager().async_set_fallback_availability_interval(address, 10) switchbot_device_poor_signal_hci1 = generate_ble_device( address, "wohand_poor_signal_hci1" ) switchbot_adv_poor_signal_hci1 = generate_advertisement_data( local_name="wohand_poor_signal_hci1", service_uuids=[], rssi=-99 ) inject_advertisement_with_time_and_source( switchbot_device_poor_signal_hci1, switchbot_adv_poor_signal_hci1, start_time_monotonic, HCI1_SOURCE_ADDRESS, ) # Should not switch adapters until the advertisement is stale assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_poor_signal_hci0 ) inject_advertisement_with_time_and_source( switchbot_device_poor_signal_hci1, switchbot_adv_poor_signal_hci1, start_time_monotonic + 10 + 1, HCI1_SOURCE_ADDRESS, ) # Should not switch yet since we are not within the # wobble period assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_poor_signal_hci0 ) inject_advertisement_with_time_and_source( switchbot_device_poor_signal_hci1, switchbot_adv_poor_signal_hci1, start_time_monotonic + 10 + TRACKER_BUFFERING_WOBBLE_SECONDS + 1, HCI1_SOURCE_ADDRESS, ) # Should switch to hci1 since the previous advertisement is stale # even though the signal is poor because the device is now # likely unreachable via hci0 assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_poor_signal_hci1 ) @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_switching_adapters_based_on_rssi_connectable_to_non_connectable( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """Test switching adapters based on rssi from connectable to non connectable.""" address = "44:44:33:11:23:45" now = time.monotonic() switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal") switchbot_adv_poor_signal = generate_advertisement_data( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) inject_advertisement_with_time_and_source_connectable( switchbot_device_poor_signal, switchbot_adv_poor_signal, now, HCI0_SOURCE_ADDRESS, True, ) assert ( get_manager().async_ble_device_from_address(address, False) is switchbot_device_poor_signal ) assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_poor_signal ) switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal") switchbot_adv_good_signal = generate_advertisement_data( local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) inject_advertisement_with_time_and_source_connectable( switchbot_device_good_signal, switchbot_adv_good_signal, now, "hci1", False, ) assert ( get_manager().async_ble_device_from_address(address, False) is switchbot_device_good_signal ) assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_poor_signal ) inject_advertisement_with_time_and_source_connectable( switchbot_device_good_signal, switchbot_adv_poor_signal, now, "hci0", False, ) assert ( get_manager().async_ble_device_from_address(address, False) is switchbot_device_good_signal ) assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_poor_signal ) switchbot_device_excellent_signal = generate_ble_device( address, "wohand_excellent_signal" ) switchbot_adv_excellent_signal = generate_advertisement_data( local_name="wohand_excellent_signal", service_uuids=[], rssi=-25 ) inject_advertisement_with_time_and_source_connectable( switchbot_device_excellent_signal, switchbot_adv_excellent_signal, now, "hci2", False, ) assert ( get_manager().async_ble_device_from_address(address, False) is switchbot_device_excellent_signal ) assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_poor_signal ) @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_connectable_advertisement_can_be_retrieved_best_path_is_non_connectable( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """ Test we can still get a connectable BLEDevice when the best path is non-connectable. In this case the device is closer to a non-connectable scanner, but the at least one connectable scanner has the device in range. """ address = "44:44:33:11:23:45" now = time.monotonic() switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal") switchbot_adv_good_signal = generate_advertisement_data( local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) inject_advertisement_with_time_and_source_connectable( switchbot_device_good_signal, switchbot_adv_good_signal, now, HCI1_SOURCE_ADDRESS, False, ) assert ( get_manager().async_ble_device_from_address(address, False) is switchbot_device_good_signal ) assert get_manager().async_ble_device_from_address(address, True) is None switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal") switchbot_adv_poor_signal = generate_advertisement_data( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) inject_advertisement_with_time_and_source_connectable( switchbot_device_poor_signal, switchbot_adv_poor_signal, now, HCI0_SOURCE_ADDRESS, True, ) assert ( get_manager().async_ble_device_from_address(address, False) is switchbot_device_good_signal ) assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_poor_signal ) @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_switching_adapters_when_one_goes_away( register_hci0_scanner: None, ) -> None: """Test switching adapters when one goes away.""" cancel_hci2 = get_manager().async_register_scanner(FakeScanner("hci2", "hci2")) address = "44:44:33:11:23:45" switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal") switchbot_adv_good_signal = generate_advertisement_data( local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) inject_advertisement_with_source( switchbot_device_good_signal, switchbot_adv_good_signal, "hci2" ) assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_good_signal ) switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal") switchbot_adv_poor_signal = generate_advertisement_data( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( switchbot_device_poor_signal, switchbot_adv_poor_signal, HCI0_SOURCE_ADDRESS, ) # We want to prefer the good signal when we have options assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_good_signal ) cancel_hci2() inject_advertisement_with_source( switchbot_device_poor_signal, switchbot_adv_poor_signal, HCI0_SOURCE_ADDRESS, ) # Now that hci2 is gone, we should prefer the poor signal # since no poor signal is better than no signal assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_poor_signal ) @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_switching_adapters_when_one_stop_scanning( register_hci0_scanner: None, ) -> None: """Test switching adapters when stops scanning.""" hci2_scanner = FakeScanner("hci2", "hci2") cancel_hci2 = get_manager().async_register_scanner(hci2_scanner) address = "44:44:33:11:23:45" switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal") switchbot_adv_good_signal = generate_advertisement_data( local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) inject_advertisement_with_source( switchbot_device_good_signal, switchbot_adv_good_signal, "hci2" ) assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_good_signal ) switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal") switchbot_adv_poor_signal = generate_advertisement_data( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( switchbot_device_poor_signal, switchbot_adv_poor_signal, HCI0_SOURCE_ADDRESS, ) # We want to prefer the good signal when we have options assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_good_signal ) hci2_scanner.scanning = False inject_advertisement_with_source( switchbot_device_poor_signal, switchbot_adv_poor_signal, HCI0_SOURCE_ADDRESS, ) # Now that hci2 has stopped scanning, we should prefer the poor signal # since poor signal is better than no signal assert ( get_manager().async_ble_device_from_address(address, True) is switchbot_device_poor_signal ) cancel_hci2() @pytest.mark.usefixtures("enable_bluetooth", "macos_adapter") @pytest.mark.asyncio async def test_set_fallback_interval_small() -> None: """Test we can set the fallback advertisement interval.""" assert ( get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12") is None ) get_manager().async_set_fallback_availability_interval("44:44:33:11:23:12", 2.0) assert ( get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12") == 2.0 ) start_monotonic_time = time.monotonic() switchbot_device = generate_ble_device("44:44:33:11:23:12", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) switchbot_device_went_unavailable = False inject_advertisement_with_time_and_source( switchbot_device, switchbot_adv, start_monotonic_time, SOURCE_LOCAL, ) def _switchbot_device_unavailable_callback( _address: BluetoothServiceInfoBleak, ) -> None: """Switchbot device unavailable callback.""" nonlocal switchbot_device_went_unavailable switchbot_device_went_unavailable = True assert ( get_manager().async_get_learned_advertising_interval("44:44:33:11:23:12") is None ) switchbot_device_unavailable_cancel = get_manager().async_track_unavailable( _switchbot_device_unavailable_callback, switchbot_device.address, connectable=False, ) monotonic_now = start_monotonic_time + 2 with patch_bluetooth_time( monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed(utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS)) await asyncio.sleep(0) assert switchbot_device_went_unavailable is True switchbot_device_unavailable_cancel() # We should forget fallback interval after it expires assert ( get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12") is None ) @pytest.mark.usefixtures("enable_bluetooth", "macos_adapter") @pytest.mark.asyncio async def test_set_fallback_interval_big() -> None: """Test we can set the fallback advertisement interval.""" assert ( get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12") is None ) # Force the interval to be really big and check it doesn't expire using the default # timeout (900) get_manager().async_set_fallback_availability_interval( "44:44:33:11:23:12", 604800.0 ) assert ( get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12") == 604800.0 ) start_monotonic_time = time.monotonic() switchbot_device = generate_ble_device("44:44:33:11:23:12", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) switchbot_device_went_unavailable = False inject_advertisement_with_time_and_source( switchbot_device, switchbot_adv, start_monotonic_time, SOURCE_LOCAL, ) def _switchbot_device_unavailable_callback( _address: BluetoothServiceInfoBleak, ) -> None: """Switchbot device unavailable callback.""" nonlocal switchbot_device_went_unavailable switchbot_device_went_unavailable = True assert ( get_manager().async_get_learned_advertising_interval("44:44:33:11:23:12") is None ) switchbot_device_unavailable_cancel = get_manager().async_track_unavailable( _switchbot_device_unavailable_callback, switchbot_device.address, connectable=False, ) # Check that device hasn't expired after a day monotonic_now = start_monotonic_time + 86400 with patch_bluetooth_time( monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed(utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS)) await asyncio.sleep(0) assert switchbot_device_went_unavailable is False # Try again after it has expired monotonic_now = start_monotonic_time + 604800 with patch_bluetooth_time( monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed(utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS)) await asyncio.sleep(0) assert switchbot_device_went_unavailable is True switchbot_device_unavailable_cancel() # type: ignore[unreachable] # We should forget fallback interval after it expires assert ( get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12") is None ) @pytest.mark.asyncio async def test_subclassing_bluetooth_manager(caplog: pytest.LogCaptureFixture) -> None: """Test subclassing BluetoothManager.""" slot_manager = BleakSlotManager() bluetooth_adapters = FakeBluetoothAdapters() class TestBluetoothManager(BluetoothManager): """ Test class for BluetoothManager. This class implements _discover_service_info. """ def _discover_service_info( self, service_info: BluetoothServiceInfoBleak ) -> None: """ Discover a new service info. This method is intended to be overridden by subclasses. """ TestBluetoothManager(bluetooth_adapters, slot_manager) assert "does not implement _discover_service_info" not in caplog.text class TestBluetoothManager2(BluetoothManager): """ Test class for BluetoothManager. This class does not implement _discover_service_info. """ TestBluetoothManager2(bluetooth_adapters, slot_manager) assert "does not implement _discover_service_info" in caplog.text @pytest.mark.asyncio async def test_is_operating_degraded_on_linux_with_mgmt() -> None: """Test is_operating_degraded returns False on Linux with mgmt control.""" mock_bluetooth_adapters = FakeBluetoothAdapters() manager = BluetoothManager( mock_bluetooth_adapters, slot_manager=Mock(), ) with ( patch("habluetooth.manager.IS_LINUX", True), patch.object(manager, "_mgmt_ctl", Mock()), ): # Mock mgmt_ctl being available assert manager.is_operating_degraded() is False @pytest.mark.asyncio async def test_is_operating_degraded_on_linux_without_mgmt() -> None: """Test is_operating_degraded returns True on Linux without mgmt control.""" mock_bluetooth_adapters = FakeBluetoothAdapters() manager = BluetoothManager( mock_bluetooth_adapters, slot_manager=Mock(), ) with patch("habluetooth.manager.IS_LINUX", True): # mgmt_ctl is None by default assert manager._mgmt_ctl is None assert manager.is_operating_degraded() is True @pytest.mark.asyncio async def test_is_operating_degraded_on_non_linux() -> None: """Test is_operating_degraded returns False on non-Linux systems.""" mock_bluetooth_adapters = FakeBluetoothAdapters() manager = BluetoothManager( mock_bluetooth_adapters, slot_manager=Mock(), ) with patch("habluetooth.manager.IS_LINUX", False): # Should return False regardless of mgmt_ctl state assert manager.is_operating_degraded() is False # Even with mgmt_ctl set manager._mgmt_ctl = Mock() assert manager.is_operating_degraded() is False @pytest.mark.asyncio async def test_is_operating_degraded_after_permission_error() -> None: """Test is_operating_degraded after mgmt setup fails with permission error.""" mock_bluetooth_adapters = FakeBluetoothAdapters() manager = BluetoothManager( mock_bluetooth_adapters, slot_manager=Mock(), ) with ( patch("habluetooth.manager.IS_LINUX", True), patch("habluetooth.manager.MGMTBluetoothCtl") as mock_mgmt_class, ): # Make setup fail with permission error mock_mgmt_instance = Mock() mock_mgmt_instance.setup = AsyncMock( side_effect=PermissionError("No permission") ) mock_mgmt_class.return_value = mock_mgmt_instance # Setup should handle the error and set mgmt_ctl to None await manager.async_setup() # Should be in degraded mode assert manager._mgmt_ctl is None assert manager.is_operating_degraded() is True Bluetooth-Devices-habluetooth-21accde/tests/test_models.py000066400000000000000000000230051507024716100241600ustar00rootroot00000000000000from __future__ import annotations import time from habluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak from . import generate_advertisement_data, generate_ble_device SOURCE_LOCAL = "local" def test_model(): service_info = BluetoothServiceInfo( name="Test", address="00:00:00:00:00:00", rssi=0, manufacturer_data={97: b"\x00\x00\x00\x00\x00\x00"}, service_data={}, service_uuids=[], source=SOURCE_LOCAL, ) assert service_info.manufacturer == "RDA Microelectronics" assert service_info.manufacturer_id == 97 service_info = BluetoothServiceInfo( name="Test", address="00:00:00:00:00:00", rssi=0, manufacturer_data={954547: b"\x00\x00\x00\x00\x00\x00"}, service_data={}, service_uuids=[], source=SOURCE_LOCAL, ) assert service_info.manufacturer is None assert service_info.manufacturer_id == 954547 def test_model_from_bleak(): switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand", {}) switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) service_info = BluetoothServiceInfo.from_advertisement( switchbot_device, switchbot_adv, SOURCE_LOCAL ) assert service_info.service_uuids == ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] assert service_info.name == "wohand" assert service_info.source == SOURCE_LOCAL assert service_info.manufacturer is None assert service_info.manufacturer_id is None def test_model_from_scanner(): switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand", {}) switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) now = time.monotonic() service_info = BluetoothServiceInfoBleak.from_scan( SOURCE_LOCAL, switchbot_device, switchbot_adv, now, True ) assert service_info.service_uuids == ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] assert service_info.name == "wohand" assert service_info.source == SOURCE_LOCAL assert service_info.manufacturer is None assert service_info.manufacturer_id is None assert service_info.time == now assert service_info.connectable is True safe_as_dict = service_info.as_dict() assert safe_as_dict == { "address": "44:44:33:11:23:45", "advertisement": switchbot_adv, "device": switchbot_device, "connectable": True, "manufacturer_data": {}, "name": "wohand", "raw": None, "rssi": -127, "service_data": {}, "service_uuids": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], "source": "local", "time": now, "tx_power": -127, } def test_construct_service_info_bleak(): switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand", {}) switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) now = time.monotonic() service_info = BluetoothServiceInfoBleak( name="wohand", address="44:44:33:11:23:45", rssi=-127, manufacturer_data=switchbot_adv.manufacturer_data, service_data=switchbot_adv.service_data, service_uuids=switchbot_adv.service_uuids, source=SOURCE_LOCAL, device=switchbot_device, advertisement=switchbot_adv, connectable=False, time=now, tx_power=1, ) assert service_info.service_uuids == ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] assert service_info.name == "wohand" assert service_info.source == SOURCE_LOCAL assert service_info.manufacturer is None assert service_info.manufacturer_id is None assert service_info.time == now assert service_info.connectable is False safe_as_dict = service_info.as_dict() assert safe_as_dict == { "address": "44:44:33:11:23:45", "advertisement": switchbot_adv, "device": switchbot_device, "connectable": False, "raw": None, "manufacturer_data": {}, "name": "wohand", "rssi": -127, "service_data": {}, "service_uuids": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], "source": "local", "time": now, "tx_power": 1, } def test_from_device_and_advertisement_data(): """ Test creating a BluetoothServiceInfoBleak. From a BLEDevice and AdvertisementData. """ switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand", {}) switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) now_monotonic = time.monotonic() service_info = BluetoothServiceInfoBleak.from_device_and_advertisement_data( switchbot_device, switchbot_adv, SOURCE_LOCAL, now_monotonic, True ) assert service_info.service_uuids == ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] assert service_info.name == "wohand" assert service_info.source == SOURCE_LOCAL assert service_info.manufacturer is None assert service_info.manufacturer_id is None safe_as_dict = service_info.as_dict() assert safe_as_dict == { "address": "44:44:33:11:23:45", "advertisement": switchbot_adv, "device": switchbot_device, "connectable": True, "manufacturer_data": {}, "name": "wohand", "raw": None, "rssi": -127, "service_data": {}, "service_uuids": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], "source": "local", "time": now_monotonic, "tx_power": -127, } assert str(service_info) == ( "" ) def test_pyobjc_compat(): class pyobjc_str(str): pass class pyobjc_int(int): pass name = pyobjc_str("wohand") address = pyobjc_str("44:44:33:11:23:45") rssi = pyobjc_int(-127) assert name == "wohand" assert address == "44:44:33:11:23:45" assert rssi == -127 switchbot_device = generate_ble_device(address, name, {}) switchbot_adv = generate_advertisement_data( local_name=name, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) now = time.monotonic() service_info = BluetoothServiceInfoBleak( name=str(name), address=str(address), rssi=rssi, manufacturer_data=switchbot_adv.manufacturer_data, service_data=switchbot_adv.service_data, service_uuids=switchbot_adv.service_uuids, source=SOURCE_LOCAL, device=switchbot_device, advertisement=switchbot_adv, connectable=False, time=now, tx_power=1, ) assert service_info.service_uuids == ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] assert service_info.name == "wohand" assert service_info.source == SOURCE_LOCAL assert service_info.manufacturer is None assert service_info.manufacturer_id is None assert service_info.time == now assert service_info.connectable is False safe_as_dict = service_info.as_dict() assert safe_as_dict == { "address": "44:44:33:11:23:45", "advertisement": switchbot_adv, "device": switchbot_device, "connectable": False, "manufacturer_data": {}, "name": "wohand", "raw": None, "rssi": -127, "service_data": {}, "service_uuids": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], "source": "local", "time": now, "tx_power": 1, } def test_as_connectable(): switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand", {}) switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) now = time.monotonic() service_info = BluetoothServiceInfoBleak( name="wohand", address="44:44:33:11:23:45", rssi=-127, manufacturer_data=switchbot_adv.manufacturer_data, service_data=switchbot_adv.service_data, service_uuids=switchbot_adv.service_uuids, source=SOURCE_LOCAL, device=switchbot_device, advertisement=switchbot_adv, connectable=False, time=now, tx_power=1, raw=b"\x00\x00\x00\x00\x00\x00", ) connectable_service_info = service_info._as_connectable() assert connectable_service_info.connectable is True assert service_info.connectable is False assert connectable_service_info is not service_info assert service_info.name == connectable_service_info.name assert service_info.address == connectable_service_info.address assert service_info.rssi == connectable_service_info.rssi assert service_info.manufacturer_data == connectable_service_info.manufacturer_data assert service_info.service_data == connectable_service_info.service_data assert service_info.service_uuids == connectable_service_info.service_uuids assert service_info.source == connectable_service_info.source assert service_info.device == connectable_service_info.device assert service_info.advertisement == connectable_service_info.advertisement assert service_info.time == connectable_service_info.time assert service_info.tx_power == connectable_service_info.tx_power assert service_info.raw == connectable_service_info.raw Bluetooth-Devices-habluetooth-21accde/tests/test_scanner.py000066400000000000000000001537151507024716100243420ustar00rootroot00000000000000"""Tests for the Bluetooth integration scanners.""" import asyncio import platform import time from datetime import timedelta from typing import Any from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch import pytest from bleak import BleakError from bleak.backends.scanner import AdvertisementDataCallback from bleak_retry_connector import BleakSlotManager from habluetooth import ( SCANNER_WATCHDOG_INTERVAL, SCANNER_WATCHDOG_TIMEOUT, BluetoothManager, BluetoothScanningMode, BluetoothServiceInfoBleak, HaScanner, HaScannerType, ScannerStartError, get_manager, scanner, set_manager, ) from habluetooth.channels.bluez import ( BluetoothMGMTProtocol, MGMTBluetoothCtl, ) from habluetooth.scanner import ( InvalidMessageError, bytes_mac_to_str, make_bluez_details, ) from . import ( async_fire_time_changed, generate_advertisement_data, generate_ble_device, patch_bluetooth_time, utcnow, ) from .conftest import FakeBluetoothAdapters, MockBluetoothManagerWithCallbacks DEVICE_FOUND = 0x0012 ADV_MONITOR_DEVICE_FOUND = 0x002F IS_WINDOWS = 'os.name == "nt"' IS_POSIX = 'os.name == "posix"' NOT_POSIX = 'os.name != "posix"' # or_patterns is a workaround for the fact that passive scanning # needs at least one matcher to be set. The below matcher # will match all devices. if platform.system() == "Linux": # On Linux, use the real BlueZScannerArgs to avoid mocking issues from bleak.args.bluez import BlueZScannerArgs, OrPattern from bleak.assigned_numbers import AdvertisementDataType scanner.PASSIVE_SCANNER_ARGS = BlueZScannerArgs( or_patterns=[ OrPattern(0, AdvertisementDataType.FLAGS, b"\x02"), OrPattern(0, AdvertisementDataType.FLAGS, b"\x06"), OrPattern(0, AdvertisementDataType.FLAGS, b"\x1a"), ] ) else: # On other platforms, we can use a simple mock scanner.PASSIVE_SCANNER_ARGS = Mock() # If the adapter is in a stuck state the following errors are raised: NEED_RESET_ERRORS = [ "org.bluez.Error.Failed", "org.bluez.Error.InProgress", "org.bluez.Error.NotReady", "not found", ] @pytest.fixture(autouse=True, scope="module") def disable_stop_discovery(): """Disable stop discovery.""" with patch("habluetooth.scanner.stop_discovery"): yield @pytest.fixture(autouse=True, scope="module") def manager(): """Return the BluetoothManager instance.""" adapters = FakeBluetoothAdapters() slot_manager = BleakSlotManager() manager = BluetoothManager(adapters, slot_manager) set_manager(manager) return manager @pytest.fixture def mock_btmgmt_socket(): """Mock the btmgmt_socket module.""" with patch("habluetooth.channels.bluez.btmgmt_socket") as mock_btmgmt: mock_socket = Mock() # Make the socket look like a real socket with a file descriptor mock_socket.fileno.return_value = 99 mock_btmgmt.open.return_value = mock_socket yield mock_btmgmt def test_bytes_mac_to_str() -> None: """Test bytes_mac_to_str.""" assert bytes_mac_to_str(b"\xff\xee\xdd\xcc\xbb\xaa") == "AA:BB:CC:DD:EE:FF" assert bytes_mac_to_str(b"\xff\xee\xdd\xcc\xbb\xaa") == "AA:BB:CC:DD:EE:FF" def test_make_bluez_details() -> None: """Test make_bluez_details.""" assert make_bluez_details("AA:BB:CC:DD:EE:FF", "hci0") == { "path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF", "props": {"Adapter": "/org/bluez/hci0"}, } @pytest.mark.asyncio async def test_empty_data_no_scanner() -> None: """Test we handle empty data.""" scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() assert scanner.discovered_devices == [] assert scanner.discovered_devices_and_advertisement_data == {} @pytest.mark.asyncio @pytest.mark.skipif(NOT_POSIX) async def test_dbus_socket_missing_in_container( caplog: pytest.LogCaptureFixture, ) -> None: """Test we handle dbus being missing in the container.""" with ( patch("habluetooth.scanner.is_docker_env", return_value=True), patch( "habluetooth.scanner.OriginalBleakScanner.start", side_effect=FileNotFoundError, ), patch( "habluetooth.scanner.OriginalBleakScanner.stop", ) as mock_stop, pytest.raises( ScannerStartError, match="DBus service not found; docker config may be missing", ), ): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() assert mock_stop.called await scanner.async_stop() @pytest.mark.asyncio @pytest.mark.skipif(NOT_POSIX) async def test_dbus_socket_missing(caplog: pytest.LogCaptureFixture) -> None: """Test we handle dbus being missing.""" with ( patch("habluetooth.scanner.is_docker_env", return_value=False), patch( "habluetooth.scanner.OriginalBleakScanner.start", side_effect=FileNotFoundError, ), patch( "habluetooth.scanner.OriginalBleakScanner.stop", ) as mock_stop, pytest.raises( ScannerStartError, match="DBus service not found; make sure the DBus socket is available", ), ): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() assert mock_stop.called await scanner.async_stop() @pytest.mark.asyncio @pytest.mark.skipif(NOT_POSIX) async def test_handle_cancellation(caplog: pytest.LogCaptureFixture) -> None: """Test cancellation stops.""" with ( patch("habluetooth.scanner.is_docker_env", return_value=False), patch( "habluetooth.scanner.OriginalBleakScanner.start", side_effect=asyncio.CancelledError, ), patch( "habluetooth.scanner.OriginalBleakScanner.stop", ) as mock_stop, ): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() with pytest.raises(asyncio.CancelledError): await scanner.async_start() assert mock_stop.called @pytest.mark.asyncio @pytest.mark.skipif(NOT_POSIX) async def test_handle_stop_while_starting(caplog: pytest.LogCaptureFixture) -> None: """Test stop while starting.""" async def _start(*args, **kwargs): await asyncio.sleep(1000) with ( patch("habluetooth.scanner.is_docker_env", return_value=False), patch("habluetooth.scanner.OriginalBleakScanner.start", _start), patch( "habluetooth.scanner.OriginalBleakScanner.stop", ) as mock_stop, ): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() task = asyncio.create_task(scanner.async_start()) await asyncio.sleep(0) await asyncio.sleep(0) await scanner.async_stop() with pytest.raises( ScannerStartError, match="Starting bluetooth scanner aborted" ): await task assert mock_stop.called @pytest.mark.asyncio @pytest.mark.skipif(NOT_POSIX) async def test_dbus_broken_pipe_in_container(caplog: pytest.LogCaptureFixture) -> None: """Test we handle dbus broken pipe in the container.""" with ( patch("habluetooth.scanner.is_docker_env", return_value=True), patch( "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BrokenPipeError, ), patch( "habluetooth.scanner.OriginalBleakScanner.stop", ) as mock_stop, pytest.raises(ScannerStartError, match="DBus connection broken"), ): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() assert mock_stop.called await scanner.async_stop() @pytest.mark.asyncio @pytest.mark.skipif(NOT_POSIX) async def test_dbus_broken_pipe(caplog: pytest.LogCaptureFixture) -> None: """Test we handle dbus broken pipe.""" with ( patch("habluetooth.scanner.is_docker_env", return_value=False), patch( "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BrokenPipeError, ), patch( "habluetooth.scanner.OriginalBleakScanner.stop", ) as mock_stop, pytest.raises(ScannerStartError, match="DBus connection broken:"), ): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() assert mock_stop.called await scanner.async_stop() @pytest.mark.asyncio @pytest.mark.skipif(NOT_POSIX) async def test_invalid_dbus_message(caplog: pytest.LogCaptureFixture) -> None: """Test we handle invalid dbus message.""" with ( patch( "habluetooth.scanner.OriginalBleakScanner.start", side_effect=InvalidMessageError, ), pytest.raises(ScannerStartError, match="Invalid DBus message received"), ): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() await scanner.async_stop() @pytest.mark.asyncio @pytest.mark.skipif(IS_WINDOWS) @pytest.mark.parametrize("error", NEED_RESET_ERRORS) async def test_adapter_needs_reset_at_start( caplog: pytest.LogCaptureFixture, error: str ) -> None: """Test we cycle the adapter when it needs a restart.""" called_start = 0 called_stop = 0 _callback = None mock_discovered: list[Any] = [] class MockBleakScanner: async def start(self, *args, **kwargs): """Mock Start.""" nonlocal called_start called_start += 1 if called_start < 3: raise BleakError(error) async def stop(self, *args, **kwargs): """Mock Start.""" nonlocal called_stop called_stop += 1 @property def discovered_devices(self): """Mock discovered_devices.""" nonlocal mock_discovered return mock_discovered def register_detection_callback( self, callback: AdvertisementDataCallback ) -> None: """Mock Register Detection Callback.""" nonlocal _callback _callback = callback mock_scanner = MockBleakScanner() with ( patch("habluetooth.scanner.OriginalBleakScanner", return_value=mock_scanner), patch( "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter, ): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() assert len(mock_recover_adapter.mock_calls) == 1 await scanner.async_stop() @pytest.mark.asyncio @pytest.mark.skipif(IS_WINDOWS) async def test_recovery_from_dbus_restart() -> None: """Test we can recover when DBus gets restarted out from under us.""" called_start = 0 called_stop = 0 _callback = None mock_discovered: list[Any] = [] class MockBleakScanner: def __init__(self, detection_callback, *args, **kwargs): nonlocal _callback _callback = detection_callback async def start(self, *args, **kwargs): """Mock Start.""" nonlocal called_start called_start += 1 async def stop(self, *args, **kwargs): """Mock Start.""" nonlocal called_stop called_stop += 1 @property def discovered_devices(self): """Mock discovered_devices.""" nonlocal mock_discovered return mock_discovered with patch( "habluetooth.scanner.OriginalBleakScanner", MockBleakScanner, ): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() assert called_start == 1 start_time_monotonic = time.monotonic() mock_discovered = [MagicMock()] # Ensure we don't restart the scanner if we don't need to with patch_bluetooth_time( start_time_monotonic + 10, ): async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL) assert called_start == 1 # Fire a callback to reset the timer with patch_bluetooth_time( start_time_monotonic, ): _callback( # type: ignore generate_ble_device("44:44:33:11:23:42", "any_name"), generate_advertisement_data(local_name="any_name"), ) # Ensure we don't restart the scanner if we don't need to with patch_bluetooth_time( start_time_monotonic + 20, ): async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL) await asyncio.sleep(0) assert called_start == 1 # We hit the timer, so we restart the scanner with patch_bluetooth_time( start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + 20, ): async_fire_time_changed( utcnow() + SCANNER_WATCHDOG_INTERVAL + timedelta(seconds=20) ) await asyncio.sleep(0) assert called_start == 2 await scanner.async_stop() @pytest.mark.asyncio @pytest.mark.skipif(IS_WINDOWS) async def test_adapter_recovery() -> None: """Test we can recover when the adapter stops responding.""" called_start = 0 called_stop = 0 _callback = None mock_discovered: list[Any] = [] class MockBleakScanner: async def start(self, *args, **kwargs): """Mock Start.""" nonlocal called_start called_start += 1 async def stop(self, *args, **kwargs): """Mock Start.""" nonlocal called_stop called_stop += 1 @property def discovered_devices(self): """Mock discovered_devices.""" nonlocal mock_discovered return mock_discovered def register_detection_callback( self, callback: AdvertisementDataCallback ) -> None: """Mock Register Detection Callback.""" nonlocal _callback _callback = callback mock_scanner = MockBleakScanner() start_time_monotonic = time.monotonic() with ( patch_bluetooth_time( start_time_monotonic, ), patch( "habluetooth.scanner.OriginalBleakScanner", return_value=mock_scanner, ), ): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() assert called_start == 1 mock_discovered = [MagicMock()] # Ensure we don't restart the scanner if we don't need to with patch_bluetooth_time( start_time_monotonic + 10, ): async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL) await asyncio.sleep(0) assert called_start == 1 # Ensure we don't restart the scanner if we don't need to with patch_bluetooth_time( start_time_monotonic + 20, ): async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL) await asyncio.sleep(0) assert called_start == 1 # We hit the timer with no detections, so we # reset the adapter and restart the scanner with ( patch_bluetooth_time( start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter, ): async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL) await asyncio.sleep(0) assert len(mock_recover_adapter.mock_calls) == 1 assert mock_recover_adapter.call_args_list[0][0] == ( 0, "AA:BB:CC:DD:EE:FF", True, ) assert called_start == 2 await scanner.async_stop() @pytest.mark.asyncio @pytest.mark.skipif(IS_WINDOWS) async def test_adapter_scanner_fails_to_start_first_time() -> None: """ Test we can recover when the adapter stops responding. The first recovery fails. """ called_start = 0 called_stop = 0 _callback = None mock_discovered: list[Any] = [] class MockBleakScanner: async def start(self, *args, **kwargs): """Mock Start.""" nonlocal called_start called_start += 1 if called_start == 1: return # Start ok the first time if called_start < 4: raise BleakError("Failed to start") async def stop(self, *args, **kwargs): """Mock Start.""" nonlocal called_stop called_stop += 1 @property def discovered_devices(self): """Mock discovered_devices.""" nonlocal mock_discovered return mock_discovered def register_detection_callback( self, callback: AdvertisementDataCallback ) -> None: """Mock Register Detection Callback.""" nonlocal _callback _callback = callback mock_scanner = MockBleakScanner() start_time_monotonic = time.monotonic() with ( patch_bluetooth_time( start_time_monotonic, ), patch( "habluetooth.scanner.OriginalBleakScanner", return_value=mock_scanner, ), ): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() assert called_start == 1 mock_discovered = [MagicMock()] # Ensure we don't restart the scanner if we don't need to with patch_bluetooth_time( start_time_monotonic + 10, ): async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL) await asyncio.sleep(0) assert called_start == 1 # Ensure we don't restart the scanner if we don't need to with patch_bluetooth_time( start_time_monotonic + 20, ): async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL) await asyncio.sleep(0) assert called_start == 1 # We hit the timer with no detections, # so we reset the adapter and restart the scanner with ( patch_bluetooth_time( start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter, ): async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL) await asyncio.sleep(0) assert len(mock_recover_adapter.mock_calls) == 1 assert called_start == 4 assert scanner.scanning is True now_monotonic = time.monotonic() # We hit the timer again the previous start call failed, make sure # we try again with ( patch_bluetooth_time( now_monotonic + SCANNER_WATCHDOG_TIMEOUT * 2 + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter, ): async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL) await asyncio.sleep(0) assert len(mock_recover_adapter.mock_calls) == 1 assert called_start == 5 await scanner.async_stop() @pytest.mark.asyncio async def test_adapter_fails_to_start_and_takes_a_bit_to_init( caplog: pytest.LogCaptureFixture, ) -> None: """Test we can recover the adapter at startup and we wait for Dbus to init.""" called_start = 0 called_stop = 0 _callback = None mock_discovered: list[Any] = [] class MockBleakScanner: async def start(self, *args, **kwargs): """Mock Start.""" nonlocal called_start called_start += 1 if called_start == 1: raise BleakError("org.freedesktop.DBus.Error.UnknownObject") if called_start == 2: raise BleakError("org.bluez.Error.InProgress") if called_start == 3: raise BleakError("org.bluez.Error.InProgress") async def stop(self, *args, **kwargs): """Mock Start.""" nonlocal called_stop called_stop += 1 @property def discovered_devices(self): """Mock discovered_devices.""" nonlocal mock_discovered return mock_discovered def register_detection_callback( self, callback: AdvertisementDataCallback ) -> None: """Mock Register Detection Callback.""" nonlocal _callback _callback = callback mock_scanner = MockBleakScanner() start_time_monotonic = time.monotonic() with ( patch( "habluetooth.scanner.ADAPTER_INIT_TIME", 0, ), patch_bluetooth_time( start_time_monotonic, ), patch( "habluetooth.scanner.OriginalBleakScanner", return_value=mock_scanner, ), patch( "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter, ): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() assert called_start == 4 assert len(mock_recover_adapter.mock_calls) == 1 assert "Waiting for adapter to initialize" in caplog.text await scanner.async_stop() @pytest.mark.asyncio async def test_restart_takes_longer_than_watchdog_time( caplog: pytest.LogCaptureFixture, ) -> None: """ Test we do not try to recover the adapter again. If the restart is still in progress. """ release_start_event = asyncio.Event() called_start = 0 class MockBleakScanner: async def start(self, *args, **kwargs): """Mock Start.""" nonlocal called_start called_start += 1 if called_start == 1: return await release_start_event.wait() async def stop(self, *args, **kwargs): """Mock Start.""" @property def discovered_devices(self): """Mock discovered_devices.""" return [] def register_detection_callback( self, callback: AdvertisementDataCallback ) -> None: """Mock Register Detection Callback.""" mock_scanner = MockBleakScanner() start_time_monotonic = time.monotonic() with ( patch( "habluetooth.scanner.ADAPTER_INIT_TIME", 0, ), patch_bluetooth_time( start_time_monotonic, ), patch( "habluetooth.scanner.OriginalBleakScanner", return_value=mock_scanner, ), patch("habluetooth.util.recover_adapter", return_value=True), ): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() assert called_start == 1 # Now force a recover adapter 2x for _ in range(2): with patch_bluetooth_time( start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ): async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL) await asyncio.sleep(0) # Now release the start event release_start_event.set() assert "already restarting" in caplog.text await scanner.async_stop() @pytest.mark.asyncio @pytest.mark.skipif("platform.system() != 'Darwin'") async def test_setup_and_stop_macos() -> None: """Test we enable use_bdaddr on MacOS.""" init_kwargs = None class MockBleakScanner: def __init__(self, *args, **kwargs): """Init the scanner.""" nonlocal init_kwargs init_kwargs = kwargs async def start(self, *args, **kwargs): """Start the scanner.""" async def stop(self, *args, **kwargs): """Stop the scanner.""" def register_detection_callback(self, *args, **kwargs): """Register a callback.""" with patch( "habluetooth.scanner.OriginalBleakScanner", MockBleakScanner, ): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() assert init_kwargs == { "detection_callback": ANY, "scanning_mode": "active", "cb": {"use_bdaddr": True}, } await scanner.async_stop() @pytest.mark.asyncio async def test_adapter_init_fails_fallback_to_passive( caplog: pytest.LogCaptureFixture, ) -> None: """Test we fallback to passive when adapter init fails.""" called_start = 0 called_stop = 0 _callback = None mock_discovered: list[Any] = [] class MockBleakScanner: async def start(self, *args, **kwargs): """Mock Start.""" nonlocal called_start called_start += 1 if called_start == 1: raise BleakError("org.freedesktop.DBus.Error.UnknownObject") if called_start == 2: raise BleakError("org.bluez.Error.InProgress") if called_start == 3: raise BleakError("org.bluez.Error.InProgress") async def stop(self, *args, **kwargs): """Mock Start.""" nonlocal called_stop called_stop += 1 @property def discovered_devices(self): """Mock discovered_devices.""" nonlocal mock_discovered return mock_discovered def register_detection_callback( self, callback: AdvertisementDataCallback ) -> None: """Mock Register Detection Callback.""" nonlocal _callback _callback = callback @property def discovered_devices_and_advertisement_data(self) -> dict[str, Any]: """Mock discovered_devices.""" return {} mock_scanner = MockBleakScanner() start_time_monotonic = time.monotonic() with ( patch( "habluetooth.scanner.IS_LINUX", True, ), patch( "habluetooth.scanner.ADAPTER_INIT_TIME", 0, ), patch_bluetooth_time( start_time_monotonic, ), patch( "habluetooth.scanner.OriginalBleakScanner", return_value=mock_scanner, ), patch( "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter, ): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() assert called_start == 4 assert len(mock_recover_adapter.mock_calls) == 1 assert "Waiting for adapter to initialize" in caplog.text assert ( "Successful fall-back to passive scanning mode after active scanning failed" in caplog.text ) assert await scanner.async_diagnostics() == { "adapter": "hci0", "connectable": True, "current_mode": BluetoothScanningMode.PASSIVE, "discovered_devices_and_advertisement_data": [], "last_detection": ANY, "monotonic_time": ANY, "name": "hci0 (AA:BB:CC:DD:EE:FF)", "requested_mode": BluetoothScanningMode.ACTIVE, "scanning": True, "source": "AA:BB:CC:DD:EE:FF", "start_time": ANY, "type": "HaScanner", } await scanner.async_stop() assert await scanner.async_diagnostics() == { "adapter": "hci0", "connectable": True, "current_mode": BluetoothScanningMode.PASSIVE, "discovered_devices_and_advertisement_data": [], "last_detection": ANY, "monotonic_time": ANY, "name": "hci0 (AA:BB:CC:DD:EE:FF)", "requested_mode": BluetoothScanningMode.ACTIVE, "scanning": False, "source": "AA:BB:CC:DD:EE:FF", "start_time": ANY, "type": "HaScanner", } @pytest.mark.asyncio @pytest.mark.skipif(NOT_POSIX) async def test_scanner_with_bluez_mgmt_side_channel(mock_btmgmt_socket: Mock) -> None: """Test scanner receiving advertisements via BlueZ management side channel.""" # Mock capability check for the entire test with patch.object(MGMTBluetoothCtl, "_check_capabilities", return_value=True): # Create a custom manager that tracks discovered devices class TestBluetoothManager(BluetoothManager): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.discovered_infos = [] def _discover_service_info( self, service_info: BluetoothServiceInfoBleak ) -> None: """Track discovered service info.""" self.discovered_infos.append(service_info) # Create manager and setup mgmt controller adapters = FakeBluetoothAdapters() slot_manager = BleakSlotManager() manager = TestBluetoothManager(adapters, slot_manager) set_manager(manager) # Set up the manager first await manager.async_setup() # Create and setup the mgmt controller with the manager's side channel scanners mgmt_ctl = MGMTBluetoothCtl( timeout=5.0, scanners=manager._side_channel_scanners ) # Mock the protocol setup mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport async def mock_setup(): mgmt_ctl.protocol = mock_protocol mgmt_ctl._on_connection_lost_future = ( asyncio.get_running_loop().create_future() ) mgmt_ctl.setup = mock_setup # type: ignore[method-assign] # Inject mgmt controller into manager manager._mgmt_ctl = mgmt_ctl manager.has_advertising_side_channel = True # Verify get_bluez_mgmt_ctl returns our controller assert manager.get_bluez_mgmt_ctl() is mgmt_ctl # Register scanner scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() manager.async_register_scanner(scanner, connection_slots=2) # Start scanner - should be created without detection callback with patch("habluetooth.scanner.OriginalBleakScanner") as mock_scanner_class: mock_scanner = Mock() mock_scanner.start = AsyncMock() mock_scanner.stop = AsyncMock() mock_scanner.discovered_devices = [] mock_scanner_class.return_value = mock_scanner await scanner.async_start() # Verify scanner was created without detection callback # since side channel is available mock_scanner_class.assert_called_once() call_kwargs = mock_scanner_class.call_args[1] assert ( "detection_callback" not in call_kwargs or call_kwargs["detection_callback"] is None ) # Now simulate advertisement data coming through the mgmt protocol # The manager should have registered the scanner with mgmt_ctl assert 0 in mgmt_ctl.scanners # hci0 is index 0 assert mgmt_ctl.scanners[0] is scanner # Simulate the protocol calling the scanner's raw advertisement handler test_address = b"\xaa\xbb\xcc\xdd\xee\xff" test_rssi = -60 test_flags = 0x06 # Create valid advertisement data with flags # Each AD structure is: length (1 byte), type (1 byte), data test_data = ( b"\x02\x01\x06" # Length=2, Type=0x01 (Flags), Data=0x06 # Length=8, Type=0x09 (Complete Local Name), Data="TestDev" b"\x08\x09TestDev" ) # Call the method that the protocol would call scanner._async_on_raw_bluez_advertisement( test_address, 1, # address_type: BDADDR_LE_PUBLIC test_rssi, test_flags, test_data, ) # Allow time for processing await asyncio.sleep(0) # Verify the device was discovered in the base scanner assert len(scanner._previous_service_info) == 1 assert "FF:EE:DD:CC:BB:AA" in scanner._previous_service_info service_info = scanner._previous_service_info["FF:EE:DD:CC:BB:AA"] assert service_info.address == "FF:EE:DD:CC:BB:AA" assert service_info.rssi == test_rssi assert service_info.name == "TestDev" # Verify the manager also received the advertisement assert len(manager.discovered_infos) == 1 assert manager.discovered_infos[0] is service_info await scanner.async_stop() manager.async_stop() @pytest.mark.asyncio @pytest.mark.skipif(NOT_POSIX) async def test_scanner_without_bluez_mgmt_side_channel() -> None: """Test scanner uses normal detection callback when side channel unavailable.""" # Create manager without BlueZ mgmt support class TestBluetoothManager(BluetoothManager): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.discovered_infos = [] def _discover_service_info( self, service_info: BluetoothServiceInfoBleak ) -> None: """Track discovered service info.""" self.discovered_infos.append(service_info) adapters = FakeBluetoothAdapters() slot_manager = BleakSlotManager() manager = TestBluetoothManager(adapters, slot_manager) set_manager(manager) # Setup without mgmt controller await manager.async_setup() assert manager.has_advertising_side_channel is False # Register scanner scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() manager.async_register_scanner(scanner, connection_slots=2) # Start scanner - should be created with detection callback with patch("habluetooth.scanner.OriginalBleakScanner") as mock_scanner_class: mock_scanner = Mock() mock_scanner.start = AsyncMock() mock_scanner.stop = AsyncMock() mock_scanner.discovered_devices = [] mock_scanner_class.return_value = mock_scanner await scanner.async_start() # Verify scanner was created with detection callback since no side channel mock_scanner_class.assert_called_once() call_kwargs = mock_scanner_class.call_args[1] assert "detection_callback" in call_kwargs assert call_kwargs["detection_callback"] is not None assert call_kwargs["detection_callback"] == scanner._async_detection_callback await scanner.async_stop() manager.async_stop() @pytest.mark.asyncio @pytest.mark.skipif(NOT_POSIX) async def test_bluez_mgmt_protocol_data_flow(mock_btmgmt_socket: Mock) -> None: """Test data flow from BlueZ protocol through manager to scanner.""" # Mock capability check for the entire test with patch.object(MGMTBluetoothCtl, "_check_capabilities", return_value=True): # Create manager class TestBluetoothManager(BluetoothManager): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.discovered_infos = [] def _discover_service_info( self, service_info: BluetoothServiceInfoBleak ) -> None: """Track discovered service info.""" self.discovered_infos.append(service_info) adapters = FakeBluetoothAdapters() slot_manager = BleakSlotManager() manager = TestBluetoothManager(adapters, slot_manager) set_manager(manager) # Set up manager first await manager.async_setup() # Create mgmt controller with the manager's side channel scanners dictionary mgmt_ctl = MGMTBluetoothCtl( timeout=5.0, scanners=manager._side_channel_scanners ) # We'll capture the protocol when it's created captured_protocol: BluetoothMGMTProtocol | None = None async def mock_create_connection(sock, protocol_factory, *args, **kwargs): nonlocal captured_protocol captured_protocol = protocol_factory() mock_transport = Mock() captured_protocol.connection_made(mock_transport) return mock_transport, captured_protocol with patch.object( asyncio.get_running_loop(), "_create_connection_transport", mock_create_connection, ): await mgmt_ctl.setup() # Set mgmt controller on manager manager._mgmt_ctl = mgmt_ctl manager.has_advertising_side_channel = True # Register scanners for hci0 and hci1 scanner0 = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:00") scanner0.async_setup() manager.async_register_scanner(scanner0, connection_slots=2) scanner1 = HaScanner(BluetoothScanningMode.ACTIVE, "hci1", "AA:BB:CC:DD:EE:01") scanner1.async_setup() manager.async_register_scanner(scanner1, connection_slots=2) # Start scanners with patch("habluetooth.scanner.OriginalBleakScanner") as mock_scanner_class: mock_scanner = Mock() mock_scanner.start = AsyncMock() mock_scanner.stop = AsyncMock() mock_scanner.discovered_devices = [] mock_scanner_class.return_value = mock_scanner await scanner0.async_start() await scanner1.async_start() # Verify scanners are registered in mgmt_ctl assert 0 in mgmt_ctl.scanners assert 1 in mgmt_ctl.scanners assert mgmt_ctl.scanners[0] is scanner0 assert mgmt_ctl.scanners[1] is scanner1 # Test DEVICE_FOUND event for hci0 test_address = b"\x11\x22\x33\x44\x55\x66" rssi_byte = b"\xc4" # -60 in signed byte event_data = ( test_address + b"\x01" # address_type + rssi_byte + b"\x06\x00\x00\x00" # flags + b"\x03\x00" # data_len + b"\x02\x01\x06" # minimal adv data ) packet = ( DEVICE_FOUND.to_bytes(2, "little") + b"\x00\x00" # controller_idx 0 (hci0) + len(event_data).to_bytes(2, "little") + event_data ) # Feed packet to protocol assert captured_protocol is not None captured_protocol.data_received(packet) # Verify device discovered on scanner0 only assert len(scanner0._previous_service_info) == 1 assert "66:55:44:33:22:11" in scanner0._previous_service_info assert len(scanner1._previous_service_info) == 0 # Test ADV_MONITOR_DEVICE_FOUND event for hci1 test_address2 = b"\xaa\xbb\xcc\xdd\xee\x02" monitor_handle = b"\x01\x00" rssi_byte2 = b"\xba" # -70 in signed byte event_data2 = ( monitor_handle + test_address2 + b"\x02" # address_type (random) + rssi_byte2 + b"\x06\x00\x00\x00" # flags + b"\x03\x00" # data_len + b"\x02\x01\x06" # minimal adv data ) packet2 = ( ADV_MONITOR_DEVICE_FOUND.to_bytes(2, "little") + b"\x01\x00" # controller_idx 1 (hci1) + len(event_data2).to_bytes(2, "little") + event_data2 ) assert captured_protocol is not None captured_protocol.data_received(packet2) # Verify device discovered on scanner1 only assert len(scanner0._previous_service_info) == 1 # Still just the first device assert len(scanner1._previous_service_info) == 1 assert "02:EE:DD:CC:BB:AA" in scanner1._previous_service_info # Verify RSSI values info0 = scanner0._previous_service_info["66:55:44:33:22:11"] assert info0.rssi == -60 info1 = scanner1._previous_service_info["02:EE:DD:CC:BB:AA"] assert info1.rssi == -70 await scanner0.async_stop() await scanner1.async_stop() manager.async_stop() @pytest.mark.asyncio @pytest.mark.skipif(NOT_POSIX) async def test_mgmt_permission_error_fallback() -> None: """Test that permission errors in MGMT setup fall back to BlueZ-only mode.""" # Create manager class TestBluetoothManager(BluetoothManager): def _discover_service_info( self, service_info: BluetoothServiceInfoBleak ) -> None: """Track discovered service info.""" pass adapters = FakeBluetoothAdapters() slot_manager = BleakSlotManager() manager = TestBluetoothManager(adapters, slot_manager) # Mock MGMTBluetoothCtl setup to raise PermissionError with ( patch("habluetooth.manager.MGMTBluetoothCtl") as mock_mgmt_cls, patch("habluetooth.manager.IS_LINUX", True), ): mock_mgmt = Mock() mock_mgmt.setup = AsyncMock( side_effect=PermissionError( "Missing NET_ADMIN/NET_RAW capabilities for Bluetooth management" ) ) mock_mgmt_cls.return_value = mock_mgmt # Setup should complete without raising the exception await manager.async_setup() # Verify MGMT was attempted but then set to None mock_mgmt.setup.assert_called_once() assert manager._mgmt_ctl is None assert manager.has_advertising_side_channel is False def test_usb_scanner_type() -> None: """Test that USB adapters get USB scanner type.""" manager = get_manager() # Mock cached adapters with USB adapter mock_adapters: dict[str, dict[str, Any]] = { "hci0": { "address": "00:1A:7D:DA:71:04", "adapter_type": "usb", "manufacturer": "TestManufacturer", "product": "USB Bluetooth Adapter", } } with patch.object(manager, "_adapters", mock_adapters): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04") assert scanner.details.scanner_type is HaScannerType.USB def test_uart_scanner_type() -> None: """Test that UART adapters get UART scanner type.""" manager = get_manager() # Mock cached adapters with UART adapter mock_adapters: dict[str, dict[str, Any]] = { "hci0": { "address": "00:1A:7D:DA:71:04", "adapter_type": "uart", "manufacturer": "TestManufacturer", "product": "UART Bluetooth Module", } } with patch.object(manager, "_adapters", mock_adapters): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04") assert scanner.details.scanner_type is HaScannerType.UART def test_unknown_scanner_type_no_cached_adapters() -> None: """Test that scanners get UNKNOWN type when no adapter info is cached.""" manager = get_manager() # No cached adapters with patch.object(manager, "_adapters", None): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04") assert scanner.details.scanner_type is HaScannerType.UNKNOWN def test_unknown_scanner_type_adapter_not_found() -> None: """Test that scanners get UNKNOWN type when adapter is not in cache.""" manager = get_manager() # Cached adapters but not the one we're looking for mock_adapters: dict[str, dict[str, Any]] = { "hci1": { "address": "11:22:33:44:55:66", "adapter_type": "usb", } } with patch.object(manager, "_adapters", mock_adapters): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04") assert scanner.details.scanner_type is HaScannerType.UNKNOWN def test_unknown_scanner_type_no_adapter_type() -> None: """Test that scanners get UNKNOWN type when adapter_type is None.""" manager = get_manager() # Cached adapter without adapter_type field mock_adapters: dict[str, dict[str, Any]] = { "hci0": { "address": "00:1A:7D:DA:71:04", "adapter_type": None, "manufacturer": "TestManufacturer", } } with patch.object(manager, "_adapters", mock_adapters): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04") assert scanner.details.scanner_type is HaScannerType.UNKNOWN @pytest.mark.asyncio async def test_scanner_type_with_real_adapter_data() -> None: """Test scanner type detection with realistic adapter data.""" # Create a custom manager for this test manager = BluetoothManager(bluetooth_adapters=MagicMock()) set_manager(manager) # Simulate real USB adapter data from Linux usb_adapter_data: dict[str, dict[str, Any]] = { "hci0": { "address": "00:1A:7D:DA:71:04", "sw_version": "homeassistant", "hw_version": "usb:v1D6Bp0246d053F", "passive_scan": False, "manufacturer": "XTech", "product": "Bluetooth 4.0 USB Adapter", "vendor_id": "0a12", "product_id": "0001", "adapter_type": "usb", } } manager._adapters = usb_adapter_data # Create USB scanner usb_scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04") assert usb_scanner.details.scanner_type is HaScannerType.USB assert usb_scanner.details.adapter == "hci0" # Simulate real UART adapter data uart_adapter_data: dict[str, dict[str, Any]] = { "hci1": { "address": "AA:BB:CC:DD:EE:FF", "sw_version": "homeassistant", "hw_version": "uart:ttyUSB0", "passive_scan": False, "manufacturer": "cyber-blue(HK)Ltd", "product": "Bluetooth 4.0 UART Module", "vendor_id": None, "product_id": None, "adapter_type": "uart", } } manager._adapters = uart_adapter_data # Create UART scanner uart_scanner = HaScanner(BluetoothScanningMode.PASSIVE, "hci1", "AA:BB:CC:DD:EE:FF") assert uart_scanner.details.scanner_type is HaScannerType.UART assert uart_scanner.details.adapter == "hci1" # Test with macOS/Windows adapter (no adapter_type) macos_adapter_data = { "Core Bluetooth": { "address": "00:00:00:00:00:00", "passive_scan": False, "sw_version": "18.7.0", "manufacturer": "Apple", "product": "Unknown MacOS Model", "vendor_id": "Unknown", "product_id": "Unknown", "adapter_type": None, } } manager._adapters = macos_adapter_data # Create scanner with unknown adapter type macos_scanner = HaScanner( BluetoothScanningMode.ACTIVE, "Core Bluetooth", "00:00:00:00:00:00" ) assert macos_scanner.details.scanner_type is HaScannerType.UNKNOWN @pytest.mark.asyncio async def test_scanner_type_updates_after_adapter_refresh() -> None: """Test scanner type is UNKNOWN initially, determined after adapters load.""" # Create a custom manager for this test manager = BluetoothManager(bluetooth_adapters=MagicMock()) set_manager(manager) # Initially no adapters cached manager._adapters = None # type: ignore[assignment] # Create scanner - should be UNKNOWN scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04") assert scanner.details.scanner_type is HaScannerType.UNKNOWN # Now simulate adapter data becoming available manager._adapters = { "hci0": { "address": "00:1A:7D:DA:71:04", "adapter_type": "usb", "manufacturer": "TestManufacturer", } } # Create a new scanner with the same adapter - should now be USB scanner2 = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04") assert scanner2.details.scanner_type is HaScannerType.USB # Note: The first scanner still has UNKNOWN since scanner_type is set at init assert scanner.details.scanner_type is HaScannerType.UNKNOWN def test_multiple_scanner_types_simultaneously() -> None: """Test that multiple scanners can have different types at the same time.""" manager = get_manager() # Set up adapters with different types mock_adapters = { "hci0": { "address": "00:1A:7D:DA:71:04", "adapter_type": "usb", }, "hci1": { "address": "AA:BB:CC:DD:EE:FF", "adapter_type": "uart", }, "hci2": { "address": "11:22:33:44:55:66", "adapter_type": None, }, } with patch.object(manager, "_adapters", mock_adapters): # Create scanners of different types usb_scanner = HaScanner( BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04" ) uart_scanner = HaScanner( BluetoothScanningMode.ACTIVE, "hci1", "AA:BB:CC:DD:EE:FF" ) unknown_scanner = HaScanner( BluetoothScanningMode.ACTIVE, "hci2", "11:22:33:44:55:66" ) # Verify each has the correct type assert usb_scanner.details.scanner_type is HaScannerType.USB assert uart_scanner.details.scanner_type is HaScannerType.UART assert unknown_scanner.details.scanner_type is HaScannerType.UNKNOWN # Verify they all have different types types = { usb_scanner.details.scanner_type, uart_scanner.details.scanner_type, unknown_scanner.details.scanner_type, } assert len(types) == 3 # All different def test_ha_scanner_get_allocations_no_slot_manager() -> None: """Test HaScanner.get_allocations returns None when manager has no slot_manager.""" scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") manager = get_manager() # Mock slot_manager as None with patch.object(manager, "slot_manager", None): assert scanner.get_allocations() is None def test_ha_scanner_get_allocations_with_slot_manager() -> None: """Test HaScanner.get_allocations returns allocation info from BleakSlotManager.""" from bleak_retry_connector import Allocations scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") manager = get_manager() # Create mock allocations mock_allocations = Allocations( adapter="hci0", slots=5, free=3, allocated=["11:22:33:44:55:66", "AA:BB:CC:DD:EE:FF"], ) # Mock slot_manager mock_slot_manager = Mock(spec=BleakSlotManager) mock_slot_manager.get_allocations.return_value = mock_allocations with patch.object(manager, "slot_manager", mock_slot_manager): allocations = scanner.get_allocations() assert allocations is not None assert allocations == mock_allocations mock_slot_manager.get_allocations.assert_called_once_with("hci0") def test_ha_scanner_get_allocations_updates_dynamically() -> None: """Test that HaScanner.get_allocations returns current values as they change.""" from bleak_retry_connector import Allocations scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") manager = get_manager() # Mock slot_manager mock_slot_manager = Mock(spec=BleakSlotManager) # Initial state - 3 free slots mock_slot_manager.get_allocations.return_value = Allocations( adapter="hci0", slots=3, free=3, allocated=[] ) with patch.object(manager, "slot_manager", mock_slot_manager): # Check initial state allocations = scanner.get_allocations() assert allocations is not None assert allocations.free == 3 assert allocations.allocated == [] # Update mock to simulate connection made mock_slot_manager.get_allocations.return_value = Allocations( adapter="hci0", slots=3, free=2, allocated=["11:22:33:44:55:66"] ) # Check updated state allocations = scanner.get_allocations() assert allocations is not None assert allocations.free == 2 assert allocations.allocated == ["11:22:33:44:55:66"] # Update mock to simulate another connection mock_slot_manager.get_allocations.return_value = Allocations( adapter="hci0", slots=3, free=1, allocated=["11:22:33:44:55:66", "AA:BB:CC:DD:EE:FF"], ) # Check final state allocations = scanner.get_allocations() assert allocations is not None assert allocations.free == 1 assert len(allocations.allocated) == 2 @pytest.mark.asyncio async def test_on_scanner_start_callback( async_mock_manager_with_scanner_callbacks: MockBluetoothManagerWithCallbacks, ) -> None: """Test that on_scanner_start is called when a local scanner starts.""" manager = async_mock_manager_with_scanner_callbacks # Create a local scanner (it will get the manager from get_manager()) scanner = HaScanner( mode=BluetoothScanningMode.ACTIVE, adapter="hci0", address="00:00:00:00:00:00", ) # Register scanner with manager manager.async_register_scanner(scanner) # Setup the scanner scanner.async_setup() # Directly call _on_start_success to test the callback # (In real usage, this is called by HaScanner._async_start_attempt # after successful start) scanner._on_start_success() # Verify the callback was called assert len(manager.scanner_start_calls) == 1 assert manager.scanner_start_calls[0] is scanner Bluetooth-Devices-habluetooth-21accde/tests/test_storage.py000066400000000000000000000417501507024716100243500ustar00rootroot00000000000000import time from unittest.mock import ANY import pytest from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from habluetooth.storage import ( DiscoveredDeviceAdvertisementData, DiscoveredDeviceAdvertisementDataDict, discovered_device_advertisement_data_from_dict, discovered_device_advertisement_data_to_dict, expire_stale_scanner_discovered_device_advertisement_data, ) def test_discovered_device_advertisement_data_to_dict(): """Test discovered device advertisement data to dict.""" result = discovered_device_advertisement_data_to_dict( DiscoveredDeviceAdvertisementData( True, 100, { "AA:BB:CC:DD:EE:FF": ( BLEDevice( address="AA:BB:CC:DD:EE:FF", name="Test Device", details={"details": "test"}, ), AdvertisementData( local_name="Test Device", manufacturer_data={0x004C: b"\x02\x15\xaa\xbb\xcc\xdd\xee\xff"}, tx_power=50, service_data={ "0000180d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00" }, service_uuids=["0000180d-0000-1000-8000-00805f9b34fb"], platform_data=("Test Device", ""), rssi=-50, ), ) }, {"AA:BB:CC:DD:EE:FF": 100000}, ) ) assert result == { "connectable": True, "discovered_device_advertisement_datas": { "AA:BB:CC:DD:EE:FF": { "advertisement_data": { "local_name": "Test Device", "manufacturer_data": {"76": "0215aabbccddeeff"}, "rssi": -50, "service_data": { "0000180d-0000-1000-8000-00805f9b34fb": "00000000" }, "service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"], "tx_power": 50, "platform_data": ["Test Device", ""], }, "device": { "address": "AA:BB:CC:DD:EE:FF", "details": {"details": "test"}, "name": "Test Device", "rssi": -50, # Now included for backward compatibility }, } }, "discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": ANY}, "expire_seconds": 100, "discovered_device_raw": {}, } def test_discovered_device_advertisement_data_from_dict(): now = time.time() result = discovered_device_advertisement_data_from_dict( { "connectable": True, "discovered_device_advertisement_datas": { "AA:BB:CC:DD:EE:FF": { "advertisement_data": { "local_name": "Test Device", "manufacturer_data": {"76": "0215aabbccddeeff"}, "rssi": -50, "service_data": { "0000180d-0000-1000-8000-00805f9b34fb": "00000000" }, "service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"], "tx_power": 50, "platform_data": ["Test Device", ""], }, "device": { "address": "AA:BB:CC:DD:EE:FF", "details": {"details": "test"}, "name": "Test Device", }, # type: ignore[typeddict-item] } }, "discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": now}, "expire_seconds": 100, "discovered_device_raw": { "AA:BB:CC:DD:EE:FF": "0215aabbccddeeff", }, } ) expected_ble_device = BLEDevice( address="AA:BB:CC:DD:EE:FF", name="Test Device", details={"details": "test"}, ) expected_advertisement_data = AdvertisementData( local_name="Test Device", manufacturer_data={0x004C: b"\x02\x15\xaa\xbb\xcc\xdd\xee\xff"}, tx_power=50, service_data={"0000180d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00"}, service_uuids=["0000180d-0000-1000-8000-00805f9b34fb"], platform_data=("Test Device", ""), rssi=-50, ) assert result is not None out_ble_device = result.discovered_device_advertisement_datas["AA:BB:CC:DD:EE:FF"][ 0 ] out_advertisement_data = result.discovered_device_advertisement_datas[ "AA:BB:CC:DD:EE:FF" ][1] assert out_ble_device.address == expected_ble_device.address assert out_ble_device.name == expected_ble_device.name assert out_ble_device.details == expected_ble_device.details # BLEDevice no longer has rssi attribute in bleak 1.0+ # rssi is only available in AdvertisementData assert out_advertisement_data == expected_advertisement_data assert result == DiscoveredDeviceAdvertisementData( connectable=True, expire_seconds=100, discovered_device_advertisement_datas={ "AA:BB:CC:DD:EE:FF": ( ANY, expected_advertisement_data, ) }, discovered_device_timestamps={"AA:BB:CC:DD:EE:FF": ANY}, discovered_device_raw={ "AA:BB:CC:DD:EE:FF": b"\x02\x15\xaa\xbb\xcc\xdd\xee\xff" }, ) def test_expire_stale_scanner_discovered_device_advertisement_data(): """Test expire_stale_scanner_discovered_device_advertisement_data.""" now = time.time() data = { "myscanner": DiscoveredDeviceAdvertisementDataDict( { "connectable": True, "discovered_device_advertisement_datas": { "AA:BB:CC:DD:EE:FF": { "advertisement_data": { "local_name": "Test Device", "manufacturer_data": {"76": "0215aabbccddeeff"}, "rssi": -50, "service_data": { "0000180d-0000-1000-8000-00805f9b34fb": "00000000" }, "service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"], "tx_power": 50, "platform_data": ["Test Device", ""], }, "device": { "address": "AA:BB:CC:DD:EE:FF", "details": {"details": "test"}, "name": "Test Device", }, # type: ignore[typeddict-item] }, "CC:DD:EE:FF:AA:BB": { "advertisement_data": { "local_name": "Test Device Expired", "manufacturer_data": {"76": "0215aabbccddeeff"}, "rssi": -50, "service_data": { "0000180d-0000-1000-8000-00805f9b34fb": "00000000" }, "service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"], "tx_power": 50, "platform_data": ["Test Device", ""], }, "device": { "address": "CC:DD:EE:FF:AA:BB", "details": {"details": "test"}, "name": "Test Device Expired", }, # type: ignore[typeddict-item] }, }, "discovered_device_raw": {}, "discovered_device_timestamps": { "AA:BB:CC:DD:EE:FF": now, "CC:DD:EE:FF:AA:BB": now - 101, }, "expire_seconds": 100, } ), "all_expired": DiscoveredDeviceAdvertisementDataDict( { "connectable": True, "discovered_device_advertisement_datas": { "CC:DD:EE:FF:AA:BB": { "advertisement_data": { "local_name": "Test Device Expired", "manufacturer_data": {"76": "0215aabbccddeeff"}, "rssi": -50, "service_data": { "0000180d-0000-1000-8000-00805f9b34fb": "00000000" }, "service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"], "tx_power": 50, "platform_data": ["Test Device", ""], }, "device": { "address": "CC:DD:EE:FF:AA:BB", "details": {"details": "test"}, "name": "Test Device Expired", }, # type: ignore[typeddict-item] } }, "discovered_device_raw": {}, "discovered_device_timestamps": {"CC:DD:EE:FF:AA:BB": now - 101}, "expire_seconds": 100, } ), } expire_stale_scanner_discovered_device_advertisement_data(data) assert len(data["myscanner"]["discovered_device_advertisement_datas"]) == 1 assert ( "CC:DD:EE:FF:AA:BB" not in data["myscanner"]["discovered_device_advertisement_datas"] ) assert "all_expired" not in data def test_expire_future_discovered_device_advertisement_data( caplog: pytest.LogCaptureFixture, ) -> None: """Test test_expire_future_discovered_device_advertisement_data.""" now = time.time() data = { "myscanner": DiscoveredDeviceAdvertisementDataDict( { "connectable": True, "discovered_device_advertisement_datas": { "AA:BB:CC:DD:EE:FF": { "advertisement_data": { "local_name": "Test Device", "manufacturer_data": {"76": "0215aabbccddeeff"}, "rssi": -50, "service_data": { "0000180d-0000-1000-8000-00805f9b34fb": "00000000" }, "service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"], "tx_power": 50, "platform_data": ["Test Device", ""], }, "device": { "address": "AA:BB:CC:DD:EE:FF", "details": {"details": "test"}, "name": "Test Device", }, # type: ignore[typeddict-item] }, "CC:DD:EE:FF:AA:BB": { "advertisement_data": { "local_name": "Test Device Expired", "manufacturer_data": {"76": "0215aabbccddeeff"}, "rssi": -50, "service_data": { "0000180d-0000-1000-8000-00805f9b34fb": "00000000" }, "service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"], "tx_power": 50, "platform_data": ["Test Device", ""], }, "device": { "address": "CC:DD:EE:FF:AA:BB", "details": {"details": "test"}, "name": "Test Device Expired", }, # type: ignore[typeddict-item] }, }, "discovered_device_timestamps": { "AA:BB:CC:DD:EE:FF": now, "CC:DD:EE:FF:AA:BB": now - 101, }, "discovered_device_raw": {}, "expire_seconds": 100, } ), "all_future": DiscoveredDeviceAdvertisementDataDict( { "connectable": True, "discovered_device_advertisement_datas": { "CC:DD:EE:FF:AA:BB": { "advertisement_data": { "local_name": "Test Device Expired", "manufacturer_data": {"76": "0215aabbccddeeff"}, "rssi": -50, "service_data": { "0000180d-0000-1000-8000-00805f9b34fb": "00000000" }, "service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"], "tx_power": 50, "platform_data": ["Test Device", ""], }, "device": { "address": "CC:DD:EE:FF:AA:BB", "details": {"details": "test"}, "name": "Test Device Expired", }, # type: ignore[typeddict-item] } }, "discovered_device_timestamps": {"CC:DD:EE:FF:AA:BB": now + 1000000}, "discovered_device_raw": {}, "expire_seconds": 100, } ), } expire_stale_scanner_discovered_device_advertisement_data(data) assert len(data["myscanner"]["discovered_device_advertisement_datas"]) == 1 assert ( "CC:DD:EE:FF:AA:BB" not in data["myscanner"]["discovered_device_advertisement_datas"] ) assert "all_future" not in data assert ( "for CC:DD:EE:FF:AA:BB on scanner all_future as it is the future" in caplog.text ) def test_discovered_device_advertisement_data_from_dict_corrupt(caplog): """Test discovered_device_advertisement_data_from_dict with corrupt data.""" now = time.time() result = discovered_device_advertisement_data_from_dict( { "connectable": True, "discovered_device_advertisement_datas": { "AA:BB:CC:DD:EE:FF": { "advertisement_data": { "local_name": "Test Device", "manufacturer_data": {"76": "0215aabbccddeeff"}, "service_data": { "0000180d-0000-1000-8000-00805f9b34fb": "00000000" }, "service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"], }, "device": { # type: ignore[typeddict-item] "address": "AA:BB:CC:DD:EE:FF", "details": {"details": "test"}, }, } }, "discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": now}, "expire_seconds": 100, } ) assert result is None assert "Error deserializing discovered_device_advertisement_data" in caplog.text def test_backward_compatibility_rssi_in_device_dict(): """Test that devices with RSSI in storage can still be loaded.""" now = time.time() # Simulate old storage format where RSSI was stored in the device dict result = discovered_device_advertisement_data_from_dict( { "connectable": True, "discovered_device_advertisement_datas": { "AA:BB:CC:DD:EE:FF": { "advertisement_data": { "local_name": "Test Device", "manufacturer_data": {"76": "0215aabbccddeeff"}, "rssi": -50, "service_data": { "0000180d-0000-1000-8000-00805f9b34fb": "00000000" }, "service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"], "tx_power": 50, "platform_data": ["Test Device", ""], }, "device": { "address": "AA:BB:CC:DD:EE:FF", "details": {"details": "test"}, "name": "Test Device", "rssi": -50, # Old format included RSSI here }, } }, "discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": now}, "expire_seconds": 100, "discovered_device_raw": {}, } ) # Should successfully deserialize without errors assert result is not None assert result.connectable is True assert result.expire_seconds == 100 # Check that the device was properly created ble_device, adv_data = result.discovered_device_advertisement_datas[ "AA:BB:CC:DD:EE:FF" ] assert ble_device.address == "AA:BB:CC:DD:EE:FF" assert ble_device.name == "Test Device" assert adv_data.rssi == -50 Bluetooth-Devices-habluetooth-21accde/tests/test_wrappers.py000066400000000000000000002062751507024716100245540ustar00rootroot00000000000000"""Tests for bluetooth wrappers.""" from __future__ import annotations import asyncio import logging from collections.abc import Callable, Generator from contextlib import contextmanager, suppress from typing import Any from unittest.mock import Mock, patch import bleak import pytest from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from bleak.exc import BleakError from bleak_retry_connector import Allocations from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME from habluetooth import BaseHaRemoteScanner, HaBluetoothConnector from habluetooth import get_manager as _get_manager from habluetooth.manager import BluetoothManager from habluetooth.usage import ( install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher, ) from habluetooth.wrappers import HaBleakScannerWrapper from . import ( HCI0_SOURCE_ADDRESS, generate_advertisement_data, generate_ble_device, inject_advertisement, patch_discovered_devices, ) @contextmanager def mock_shutdown(manager: BluetoothManager) -> Generator[None, None, None]: """Mock shutdown of the HomeAssistantBluetoothManager.""" manager.shutdown = True yield manager.shutdown = False class FakeScanner(BaseHaRemoteScanner): """Fake scanner.""" def __init__( self, scanner_id: str, name: str, connector: Any, connectable: bool, ) -> None: """Initialize the scanner.""" super().__init__(scanner_id, name, connector, connectable) self._details: dict[str, str | HaBluetoothConnector] = {} def __repr__(self) -> str: """Return the representation.""" return f"FakeScanner({self.name})" def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData ) -> None: """Inject an advertisement.""" self._async_on_advertisement( device.address, advertisement_data.rssi, device.name, advertisement_data.service_uuids, advertisement_data.service_data, advertisement_data.manufacturer_data, advertisement_data.tx_power, device.details | {"scanner_specific_data": "test"}, MONOTONIC_TIME(), ) class BaseFakeBleakClient: """Base class for fake bleak clients.""" def __init__(self, address_or_ble_device: BLEDevice | str, **kwargs: Any) -> None: """Initialize the fake bleak client.""" self._device_path = "/dev/test" self._device = address_or_ble_device assert isinstance(address_or_ble_device, BLEDevice) self._address = address_or_ble_device.address async def disconnect(self, *args, **kwargs): """Disconnect.""" async def get_services(self, *args, **kwargs): """Get services.""" return [] class FakeBleakClient(BaseFakeBleakClient): """Fake bleak client.""" async def connect(self, *args, **kwargs): """Connect.""" return True @property def is_connected(self): return False class FakeBleakClientFailsToConnect(BaseFakeBleakClient): """Fake bleak client that fails to connect.""" async def connect(self, *args, **kwargs): """Connect.""" return @property def is_connected(self): return False class FakeBleakClientRaisesOnConnect(BaseFakeBleakClient): """Fake bleak client that raises on connect.""" async def connect(self, *args, **kwargs): """Connect.""" raise ConnectionError("Test exception") def _generate_ble_device_and_adv_data( interface: str, mac: str, rssi: int ) -> tuple[BLEDevice, AdvertisementData]: """Generate a BLE device with adv data.""" return ( generate_ble_device( mac, "any", delegate="", details={"path": f"/org/bluez/{interface}/dev_{mac}"}, ), generate_advertisement_data(rssi=rssi), ) @pytest.fixture(name="install_bleak_catcher") def install_bleak_catcher_fixture(): """Fixture that installs the bleak catcher.""" install_multiple_bleak_catcher() yield uninstall_multiple_bleak_catcher() @pytest.fixture(name="mock_platform_client") def mock_platform_client_fixture(): """Fixture that mocks the platform client.""" with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClient, ): yield @pytest.fixture(name="mock_platform_client_that_fails_to_connect") def mock_platform_client_that_fails_to_connect_fixture(): """Fixture that mocks the platform client that fails to connect.""" with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsToConnect, ): yield @pytest.fixture(name="mock_platform_client_that_raises_on_connect") def mock_platform_client_that_raises_on_connect_fixture(): """Fixture that mocks the platform client that fails to connect.""" with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientRaisesOnConnect, ): yield def _generate_scanners_with_fake_devices(): """Generate scanners with fake devices.""" manager = _get_manager() hci0_device_advs = {} for i in range(10): device, adv_data = _generate_ble_device_and_adv_data( "hci0", f"00:00:00:00:00:{i:02x}", rssi=-60 ) hci0_device_advs[device.address] = (device, adv_data) hci1_device_advs = {} for i in range(10): device, adv_data = _generate_ble_device_and_adv_data( "hci1", f"00:00:00:00:00:{i:02x}", rssi=-80 ) hci1_device_advs[device.address] = (device, adv_data) scanner_hci0 = FakeScanner("00:00:00:00:00:01", "hci0", None, True) scanner_hci1 = FakeScanner("00:00:00:00:00:02", "hci1", None, True) for device, adv_data in hci0_device_advs.values(): scanner_hci0.inject_advertisement(device, adv_data) for device, adv_data in hci1_device_advs.values(): scanner_hci1.inject_advertisement(device, adv_data) cancel_hci0 = manager.async_register_scanner(scanner_hci0, connection_slots=2) cancel_hci1 = manager.async_register_scanner(scanner_hci1, connection_slots=1) return hci0_device_advs, cancel_hci0, cancel_hci1 @pytest.mark.asyncio async def test_test_switch_adapters_when_out_of_slots( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, mock_platform_client: None, ) -> None: """Ensure we try another scanner when one runs out of slots.""" manager = _get_manager() # hci0 has an rssi of -60, hci1 has an rssi of -80 hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() # hci0 has 2 slots, hci1 has 1 slot with ( patch.object(manager.slot_manager, "release_slot") as release_slot_mock, patch.object( manager.slot_manager, "allocate_slot", return_value=True ) as allocate_slot_mock, ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] with patch.object(FakeBleakClient, "is_connected", return_value=True): client = bleak.BleakClient(ble_device) await client.connect() assert allocate_slot_mock.call_count == 1 assert release_slot_mock.call_count == 0 # All adapters are out of slots with ( patch.object(manager.slot_manager, "release_slot") as release_slot_mock, patch.object( manager.slot_manager, "allocate_slot", return_value=False ) as allocate_slot_mock, ): ble_device = hci0_device_advs["00:00:00:00:00:02"][0] client = bleak.BleakClient(ble_device) with pytest.raises(bleak.exc.BleakError): await client.connect() assert allocate_slot_mock.call_count == 2 assert release_slot_mock.call_count == 0 # When hci0 runs out of slots, we should try hci1 def _allocate_slot_mock(ble_device: BLEDevice) -> bool: return "hci1" in ble_device.details["path"] with ( patch.object(manager.slot_manager, "release_slot") as release_slot_mock, patch.object( # type: ignore manager.slot_manager, "allocate_slot", _allocate_slot_mock ) as allocate_slot_mock, ): ble_device = hci0_device_advs["00:00:00:00:00:03"][0] with patch.object(FakeBleakClient, "is_connected", return_value=True): client = bleak.BleakClient(ble_device) await client.connect() assert release_slot_mock.call_count == 0 cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_release_slot_on_connect_failure( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, mock_platform_client_that_raises_on_connect: None, ) -> None: """Ensure the slot gets released on connection failure.""" manager = _get_manager() hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() # hci0 has 2 slots, hci1 has 1 slot with ( patch.object(manager.slot_manager, "release_slot") as release_slot_mock, patch.object( manager.slot_manager, "allocate_slot", return_value=True ) as allocate_slot_mock, ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) with pytest.raises(ConnectionError): await client.connect() assert allocate_slot_mock.call_count == 1 assert release_slot_mock.call_count == 1 cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_release_slot_on_connect_exception( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, mock_platform_client_that_raises_on_connect: None, ) -> None: """Ensure the slot gets released on connection exception.""" manager = _get_manager() hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() # hci0 has 2 slots, hci1 has 1 slot with ( patch.object(manager.slot_manager, "release_slot") as release_slot_mock, patch.object( manager.slot_manager, "allocate_slot", return_value=True ) as allocate_slot_mock, ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) with pytest.raises(ConnectionError) as exc_info: await client.connect() assert str(exc_info.value) == "Test exception" assert allocate_slot_mock.call_count == 1 assert release_slot_mock.call_count == 1 cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_switch_adapters_on_failure( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """Ensure we try the next best adapter after a failure.""" hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) class FakeBleakClientFailsHCI0Only(BaseFakeBleakClient): """Fake bleak client that fails to connect on hci0.""" async def connect(self, *args: Any, **kwargs: Any) -> None: """Connect.""" assert isinstance(self._device, BLEDevice) if "/hci0/" in self._device.details["path"]: raise BleakError("Failed to connect on hci0") @property def is_connected(self) -> bool: return True class FakeBleakClientFailsHCI1Only(BaseFakeBleakClient): """Fake bleak client that fails to connect on hci1.""" async def connect(self, *args: Any, **kwargs: Any) -> None: """Connect.""" assert isinstance(self._device, BLEDevice) if "/hci1/" in self._device.details["path"]: raise BleakError("Failed to connect on hci1") @property def is_connected(self) -> bool: return True with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsHCI0Only, ): # Should try to connect to hci0 first with pytest.raises(BleakError): await client.connect() assert not client.is_connected # Should try to connect with hci0 again with pytest.raises(BleakError): await client.connect() assert not client.is_connected # After two tries we should switch to hci1 await client.connect() assert client.is_connected # ..and we remember that hci1 works as long as the client doesn't change await client.connect() assert client.is_connected # If we replace the client, we should remember hci0 is failing client = bleak.BleakClient(ble_device) await client.connect() assert client.is_connected with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsHCI1Only, ): # Should try to connect to hci1 first await client.connect() assert client.is_connected # Should work with hci0 on next attempt await client.connect() assert client.is_connected # Next attempt should also use hci0 await client.connect() assert client.is_connected cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_switch_adapters_on_connecting( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """Ensure we try the next best adapter after a failure.""" # hci0 has an rssi of -60, hci1 has an rssi of -80 hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) class FakeBleakClientSlowHCI0Connnect(BaseFakeBleakClient): """Fake bleak client that connects instantly on hci1 and slow on hci0.""" valid = False async def connect(self, *args: Any, **kwargs: Any) -> None: """Connect.""" assert isinstance(self._device, BLEDevice) if "/hci0/" in self._device.details["path"]: await asyncio.sleep(0.4) self.valid = True else: self.valid = True @property def is_connected(self) -> bool: return self.valid with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientSlowHCI0Connnect, ): task = asyncio.create_task(client.connect()) await asyncio.sleep(0.1) assert not task.done() task2 = asyncio.create_task(client.connect()) await asyncio.sleep(0.1) assert task2.done() await task2 assert client.is_connected task3 = asyncio.create_task(client.connect()) await asyncio.sleep(0.1) assert task3.done() await task3 assert client.is_connected await task assert client.is_connected cancel_hci0() cancel_hci1() @pytest.mark.asyncio @pytest.mark.usefixtures("enable_bluetooth", "install_bleak_catcher") async def test_single_adapter_connection_history( caplog: pytest.LogCaptureFixture, ) -> None: """Test connection history failure count.""" manager = _get_manager() scanner_hci0 = FakeScanner(HCI0_SOURCE_ADDRESS, "hci0", None, True) unsub_hci0 = manager.async_register_scanner(scanner_hci0, connection_slots=2) ble_device, adv_data = _generate_ble_device_and_adv_data( "hci0", "00:00:00:00:00:11", rssi=-60 ) scanner_hci0.inject_advertisement(ble_device, adv_data) service_info = manager.async_last_service_info( ble_device.address, connectable=False ) assert service_info is not None assert service_info.source == HCI0_SOURCE_ADDRESS client = bleak.BleakClient(ble_device) class FakeBleakClientFastConnect(BaseFakeBleakClient): """Fake bleak client that connects instantly on hci1 and slow on hci0.""" valid = False async def connect(self, *args: Any, **kwargs: Any) -> None: """Connect.""" assert isinstance(self._device, BLEDevice) self.valid = "/hci0/" in self._device.details["path"] @property def is_connected(self) -> bool: return self.valid with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFastConnect, ): await client.connect() unsub_hci0() @pytest.mark.asyncio async def test_passing_subclassed_str_as_address( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """Ensure the client wrapper can handle a subclassed str as the address.""" _, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() class SubclassedStr(str): __slots__ = () address = SubclassedStr("00:00:00:00:00:01") client = bleak.BleakClient(address) class FakeBleakClient(BaseFakeBleakClient): """Fake bleak client.""" async def connect(self, *args, **kwargs): """Connect.""" return @property def is_connected(self) -> bool: return True with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClient, ): await client.connect() cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_find_device_by_address( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """Ensure the client wrapper can handle a subclassed str as the address.""" _, _cancel_hci0, _cancel_hci1 = _generate_scanners_with_fake_devices() device = await bleak.BleakScanner.find_device_by_address("00:00:00:00:00:01") assert device.address == "00:00:00:00:00:01" device = await bleak.BleakScanner().find_device_by_address("00:00:00:00:00:01") assert device.address == "00:00:00:00:00:01" @pytest.mark.asyncio async def test_discover( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """Ensure the discover is implemented.""" _, _cancel_hci0, _cancel_hci1 = _generate_scanners_with_fake_devices() devices = await bleak.BleakScanner.discover() assert any(device.address == "00:00:00:00:00:01" for device in devices) devices_adv = await bleak.BleakScanner.discover(return_adv=True) assert "00:00:00:00:00:01" in devices_adv @pytest.mark.asyncio async def test_raise_after_shutdown( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, mock_platform_client_that_raises_on_connect: None, ) -> None: """Ensure the slot gets released on connection exception.""" manager = _get_manager() hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() # hci0 has 2 slots, hci1 has 1 slot with mock_shutdown(manager): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) with pytest.raises(BleakError, match="shutdown"): await client.connect() cancel_hci0() cancel_hci1() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_wrapped_instance_with_filter( register_hci0_scanner: None, ) -> None: """Test wrapped instance with a filter as if it was normal BleakScanner.""" detected = [] def _device_detected( device: BLEDevice, advertisement_data: AdvertisementData ) -> None: """Handle a detected device.""" detected.append((device, advertisement_data)) switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) switchbot_adv_2 = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None scanner = HaBleakScannerWrapper( filters={"UUIDs": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]} ) scanner.register_detection_callback(_device_detected) inject_advertisement(switchbot_device, switchbot_adv_2) await asyncio.sleep(0) discovered = await scanner.discover(timeout=0) assert len(discovered) == 1 assert discovered == [switchbot_device] assert len(detected) == 1 scanner.register_detection_callback(_device_detected) # We should get a reply from the history when we register again assert len(detected) == 2 scanner.register_detection_callback(_device_detected) # We should get a reply from the history when we register again assert len(detected) == 3 with patch_discovered_devices([]): discovered = await scanner.discover(timeout=0) assert len(discovered) == 0 assert discovered == [] inject_advertisement(switchbot_device, switchbot_adv) assert len(detected) == 4 # The filter we created in the wrapped scanner with should be respected # and we should not get another callback inject_advertisement(empty_device, empty_adv) assert len(detected) == 4 @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_wrapped_instance_with_service_uuids( register_hci0_scanner: None, ) -> None: """Test wrapped instance with a service_uuids list as normal BleakScanner.""" detected = [] def _device_detected( device: BLEDevice, advertisement_data: AdvertisementData ) -> None: """Handle a detected device.""" detected.append((device, advertisement_data)) switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) switchbot_adv_2 = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None scanner = HaBleakScannerWrapper( service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) scanner.register_detection_callback(_device_detected) inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(switchbot_device, switchbot_adv_2) await asyncio.sleep(0) assert len(detected) == 2 # The UUIDs list we created in the wrapped scanner with should be respected # and we should not get another callback inject_advertisement(empty_device, empty_adv) assert len(detected) == 2 @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_wrapped_instance_with_service_uuids_with_coro_callback( register_hci0_scanner: None, ) -> None: """ Test wrapped instance with a service_uuids list as normal BleakScanner. Verify that coro callbacks are supported. """ detected = [] async def _device_detected( device: BLEDevice, advertisement_data: AdvertisementData ) -> None: """Handle a detected device.""" detected.append((device, advertisement_data)) switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) switchbot_adv_2 = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None scanner = HaBleakScannerWrapper( service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) scanner.register_detection_callback(_device_detected) inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(switchbot_device, switchbot_adv_2) await asyncio.sleep(0) assert len(detected) == 2 # The UUIDs list we created in the wrapped scanner with should be respected # and we should not get another callback inject_advertisement(empty_device, empty_adv) assert len(detected) == 2 @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_wrapped_instance_with_broken_callbacks( register_hci0_scanner: None, ) -> None: """Test broken callbacks do not cause the scanner to fail.""" detected: list[tuple[BLEDevice, AdvertisementData]] = [] def _device_detected( device: BLEDevice, advertisement_data: AdvertisementData ) -> None: """Handle a detected device.""" if detected: raise ValueError detected.append((device, advertisement_data)) switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) assert _get_manager() is not None scanner = HaBleakScannerWrapper( service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) scanner.register_detection_callback(_device_detected) inject_advertisement(switchbot_device, switchbot_adv) await asyncio.sleep(0) inject_advertisement(switchbot_device, switchbot_adv) await asyncio.sleep(0) assert len(detected) == 1 @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_wrapped_instance_changes_uuids( register_hci0_scanner: None, ) -> None: """Test consumers can use the wrapped instance can change the uuids later.""" detected = [] def _device_detected( device: BLEDevice, advertisement_data: AdvertisementData ) -> None: """Handle a detected device.""" detected.append((device, advertisement_data)) switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) switchbot_adv_2 = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None scanner = HaBleakScannerWrapper() scanner.set_scanning_filter(service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]) scanner.register_detection_callback(_device_detected) inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(switchbot_device, switchbot_adv_2) await asyncio.sleep(0) assert len(detected) == 2 # The UUIDs list we created in the wrapped scanner with should be respected # and we should not get another callback inject_advertisement(empty_device, empty_adv) assert len(detected) == 2 @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_wrapped_instance_changes_filters( register_hci0_scanner: None, ) -> None: """Test consumers can use the wrapped instance can change the filter later.""" detected = [] def _device_detected( device: BLEDevice, advertisement_data: AdvertisementData ) -> None: """Handle a detected device.""" detected.append((device, advertisement_data)) switchbot_device = generate_ble_device("44:44:33:11:23:42", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) switchbot_adv_2 = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) empty_device = generate_ble_device("11:22:33:44:55:62", "empty") empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None scanner = HaBleakScannerWrapper() scanner.set_scanning_filter( filters={"UUIDs": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]} ) scanner.register_detection_callback(_device_detected) inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(switchbot_device, switchbot_adv_2) await asyncio.sleep(0) assert len(detected) == 2 # The UUIDs list we created in the wrapped scanner with should be respected # and we should not get another callback inject_advertisement(empty_device, empty_adv) assert len(detected) == 2 @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_wrapped_instance_unsupported_filter( register_hci0_scanner: None, caplog: pytest.LogCaptureFixture, ) -> None: """Test we want when their filter is ineffective.""" assert _get_manager() is not None scanner = HaBleakScannerWrapper() scanner.set_scanning_filter( filters={ "unsupported": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], "DuplicateData": True, } ) assert "Only UUIDs filters are supported" in caplog.text @pytest.mark.asyncio async def test_client_with_services_parameter( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, mock_platform_client: None, ) -> None: """Test that services parameter is passed correctly to the backend.""" hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() ble_device = hci0_device_advs["00:00:00:00:00:01"][0] test_services = [ "00001800-0000-1000-8000-00805f9b34fb", "00001801-0000-1000-8000-00805f9b34fb", ] # Track what services were passed to the backend services_passed_to_backend = None class FakeBleakClientTracksServices(BaseFakeBleakClient): """Fake bleak client that tracks services parameter.""" def __init__( self, address_or_ble_device: BLEDevice | str, **kwargs: Any ) -> None: """Initialize and capture services.""" super().__init__(address_or_ble_device, **kwargs) nonlocal services_passed_to_backend services_passed_to_backend = kwargs.get("services") async def connect(self, *args, **kwargs): """Connect.""" return True @property def is_connected(self): return True with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientTracksServices, ): client = bleak.BleakClient(ble_device, services=test_services) await client.connect() # Verify services were normalized and passed as a set assert services_passed_to_backend is not None assert isinstance(services_passed_to_backend, set) assert services_passed_to_backend == { "00001800-0000-1000-8000-00805f9b34fb", "00001801-0000-1000-8000-00805f9b34fb", } cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_client_with_pair_parameter( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, mock_platform_client: None, ) -> None: """Test that pair parameter is set correctly on the wrapper.""" hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() ble_device = hci0_device_advs["00:00:00:00:00:01"][0] # Test default pair=False client = bleak.BleakClient(ble_device) assert client._pair_before_connect is False # Test pair=True client = bleak.BleakClient(ble_device, pair=True) assert client._pair_before_connect is True cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_client_services_normalization( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, mock_platform_client: None, ) -> None: """Test that service UUIDs are normalized correctly.""" hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() ble_device = hci0_device_advs["00:00:00:00:00:01"][0] # Test with short UUIDs that need normalization test_services = ["1800", "1801", "CBA20D00-224D-11E6-9FB8-0002A5D5C51B"] services_passed_to_backend = None class FakeBleakClientTracksServices(BaseFakeBleakClient): """Fake bleak client that tracks services parameter.""" def __init__( self, address_or_ble_device: BLEDevice | str, **kwargs: Any ) -> None: """Initialize and capture services.""" super().__init__(address_or_ble_device, **kwargs) nonlocal services_passed_to_backend services_passed_to_backend = kwargs.get("services") async def connect(self, *args, **kwargs): """Connect.""" return True @property def is_connected(self): return True with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientTracksServices, ): client = bleak.BleakClient(ble_device, services=test_services) await client.connect() # Verify services were normalized assert services_passed_to_backend is not None assert isinstance(services_passed_to_backend, set) assert services_passed_to_backend == { "00001800-0000-1000-8000-00805f9b34fb", "00001801-0000-1000-8000-00805f9b34fb", "cba20d00-224d-11e6-9fb8-0002a5d5c51b", # Should be lowercased } cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_client_with_none_services( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, mock_platform_client: None, ) -> None: """Test that None services parameter is handled correctly.""" hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() ble_device = hci0_device_advs["00:00:00:00:00:01"][0] services_passed_to_backend = "not_set" class FakeBleakClientTracksServices(BaseFakeBleakClient): """Fake bleak client that tracks services parameter.""" def __init__( self, address_or_ble_device: BLEDevice | str, **kwargs: Any ) -> None: """Initialize and capture services.""" super().__init__(address_or_ble_device, **kwargs) nonlocal services_passed_to_backend services_passed_to_backend = kwargs.get("services", "not_set") async def connect(self, *args, **kwargs): """Connect.""" return True @property def is_connected(self): return True with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientTracksServices, ): # Test with no services parameter (default None) client = bleak.BleakClient(ble_device) await client.connect() assert services_passed_to_backend is None # Reset the captured value services_passed_to_backend = "not_set" # type: ignore[unreachable] with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientTracksServices, ): # Test with explicit None client = bleak.BleakClient(ble_device, services=None) await client.connect() assert services_passed_to_backend is None cancel_hci0() cancel_hci1() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_passive_only_scanner_error_message() -> None: """Test error message when all scanners are passive-only (like Shelly).""" manager = _get_manager() # Register a passive-only scanner (connectable=False) scanner = FakeScanner( "passive_scanner_1", "shelly_plus1pm_e86bea01020c", None, False ) cancel = manager.async_register_scanner(scanner) # Inject an advertisement from this passive scanner device = generate_ble_device( "00:00:00:00:00:01", "Test Device", {"source": "passive_scanner_1"} ) adv_data = generate_advertisement_data( local_name="Test Device", service_uuids=[], rssi=-50, ) scanner.inject_advertisement(device, adv_data) await asyncio.sleep(0) # Let the advertisement be processed # Try to connect - should fail with our custom error message client = bleak.BleakClient("00:00:00:00:00:01") with pytest.raises( BleakError, match=r"00:00:00:00:00:01: No connectable Bluetooth adapters\. " r"Shelly devices are passive-only and cannot connect\. " r"Need local Bluetooth adapter or ESPHome proxy\. " r"Available: shelly_plus1pm_e86bea01020c \(passive_scanner_1\)", ): await client.connect() cancel() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_passive_scanner_with_active_scanner() -> None: """Test normal error when there's a mix of passive and active scanners.""" manager = _get_manager() # Register a passive-only scanner passive_scanner = FakeScanner("passive_scanner", "shelly_device", None, False) cancel_passive = manager.async_register_scanner(passive_scanner) # Register an active scanner with no available slots active_scanner = FakeScanner("active_scanner", "esphome_device", None, True) cancel_active = manager.async_register_scanner(active_scanner) # Inject advertisements from both scanners device1 = generate_ble_device( "00:00:00:00:00:02", "Test Device", {"source": "passive_scanner"} ) device2 = generate_ble_device( "00:00:00:00:00:02", "Test Device", {"source": "active_scanner"} ) adv_data = generate_advertisement_data( local_name="Test Device", service_uuids=[], rssi=-50, ) passive_scanner.inject_advertisement(device1, adv_data) active_scanner.inject_advertisement(device2, adv_data) await asyncio.sleep(0) # Let the advertisements be processed # Mock the slot allocation to fail (simulating no available slots) with patch.object(manager.slot_manager, "allocate_slot", return_value=False): # Should get the normal "no available slot" error, not the passive-only error client = bleak.BleakClient("00:00:00:00:00:02") with pytest.raises( BleakError, match=( "No backend with an available connection slot that can reach " "address 00:00:00:00:00:02 was found" ), ): await client.connect() cancel_passive() cancel_active() @pytest.mark.asyncio async def test_connection_params_loading_with_bluez_mgmt( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, caplog: pytest.LogCaptureFixture, ) -> None: """Test that connection parameters are loaded when mgmt API is available.""" manager = _get_manager() hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() # Mock the bluez mgmt controller mock_mgmt_ctl = Mock() mock_mgmt_ctl.load_conn_params.return_value = True class FakeBleakClientTracksConnect(BaseFakeBleakClient): """Fake bleak client that tracks connect.""" connected = False async def connect(self, *args, **kwargs): """Connect.""" self.connected = True # Simulate service discovery await asyncio.sleep(0) @property def is_connected(self) -> bool: return self.connected # Test with debug logging enabled with ( caplog.at_level(logging.DEBUG), patch.object(manager, "get_bluez_mgmt_ctl", return_value=mock_mgmt_ctl), patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientTracksConnect, ), ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) await client.connect() # Verify load_conn_params was called twice (fast before connect, medium after) assert mock_mgmt_ctl.load_conn_params.call_count == 2 # First call should be for FAST params first_call = mock_mgmt_ctl.load_conn_params.call_args_list[0] assert first_call[0][0] == 0 # adapter_idx assert first_call[0][1] == "00:00:00:00:00:01" # address assert first_call[0][2] == 1 # BDADDR_LE_PUBLIC (default) assert first_call[0][3].value == "fast" # ConnectParams.FAST # Second call should be for MEDIUM params second_call = mock_mgmt_ctl.load_conn_params.call_args_list[1] assert second_call[0][0] == 0 # adapter_idx assert second_call[0][1] == "00:00:00:00:00:01" # address assert second_call[0][2] == 1 # BDADDR_LE_PUBLIC assert second_call[0][3].value == "medium" # ConnectParams.MEDIUM # Verify debug logging assert "Loaded ConnectParams.FAST connection parameters" in caplog.text assert "Loaded ConnectParams.MEDIUM connection parameters" in caplog.text cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_connection_params_not_loaded_without_mgmt( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, caplog: pytest.LogCaptureFixture, ) -> None: """Test that connection parameters are not loaded when mgmt API is unavailable.""" manager = _get_manager() hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() class FakeBleakClientTracksConnect(BaseFakeBleakClient): """Fake bleak client that tracks connect.""" connected = False async def connect(self, *args, **kwargs): """Connect.""" self.connected = True await asyncio.sleep(0) @property def is_connected(self) -> bool: return self.connected with ( caplog.at_level(logging.DEBUG), patch.object(manager, "get_bluez_mgmt_ctl", return_value=None), patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientTracksConnect, ), ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) await client.connect() # Verify no connection parameters were loaded assert "connection parameters" not in caplog.text cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_get_device_address_type_random( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """Test _get_device_address_type returns BDADDR_LE_RANDOM for random address.""" _hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() # Create a device with random address type device = generate_ble_device( "00:00:00:00:00:02", "Test Device", { "path": "/org/bluez/hci0/dev_00_00_00_00_00_02", "props": {"AddressType": "random"}, }, ) from habluetooth.const import BDADDR_LE_RANDOM from habluetooth.wrappers import _get_device_address_type assert _get_device_address_type(device) == BDADDR_LE_RANDOM cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_get_device_address_type_public( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """Test _get_device_address_type returns BDADDR_LE_PUBLIC for public address.""" hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() # Create a device with public address type (default) device = hci0_device_advs["00:00:00:00:00:01"][0] from habluetooth.const import BDADDR_LE_PUBLIC from habluetooth.wrappers import _get_device_address_type assert _get_device_address_type(device) == BDADDR_LE_PUBLIC cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_connection_params_loading_fails_silently( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, caplog: pytest.LogCaptureFixture, ) -> None: """Test that connection still succeeds even if loading params fails.""" manager = _get_manager() hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() # Mock the bluez mgmt controller to fail loading params mock_mgmt_ctl = Mock() mock_mgmt_ctl.load_conn_params.return_value = False class FakeBleakClientTracksConnect(BaseFakeBleakClient): """Fake bleak client that tracks connect.""" connected = False async def connect(self, *args, **kwargs): """Connect.""" self.connected = True await asyncio.sleep(0) @property def is_connected(self) -> bool: return self.connected with ( caplog.at_level(logging.DEBUG), patch.object(manager, "get_bluez_mgmt_ctl", return_value=mock_mgmt_ctl), patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientTracksConnect, ), ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) # Connection should succeed even though param loading failed await client.connect() # Verify load_conn_params was called assert mock_mgmt_ctl.load_conn_params.call_count == 2 # But no success message should be logged assert "Loaded" not in caplog.text cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_connection_params_no_adapter_idx( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, caplog: pytest.LogCaptureFixture, ) -> None: """Test that connection params are not loaded if scanner has no adapter_idx.""" manager = _get_manager() # Mock the bluez mgmt controller mock_mgmt_ctl = Mock() mock_mgmt_ctl.load_conn_params.return_value = True class FakeBleakClientTracksConnect(BaseFakeBleakClient): """Fake bleak client that tracks connect.""" connected = False async def connect(self, *args, **kwargs): """Connect.""" self.connected = True await asyncio.sleep(0) @property def is_connected(self) -> bool: return self.connected # Create a fake connector for the remote scanner fake_connector = HaBluetoothConnector( client=FakeBleakClientTracksConnect, source="any", can_connect=lambda: True ) # Create a scanner without adapter_idx (e.g., remote scanner) remote_scanner = FakeScanner( "remote_scanner", "ESPHome Device", fake_connector, True ) cancel_remote = manager.async_register_scanner(remote_scanner) # Inject advertisement device = generate_ble_device( "00:00:00:00:00:03", "Test Device", {"source": "remote_scanner"} ) adv_data = generate_advertisement_data( local_name="Test Device", service_uuids=[], rssi=-50, ) remote_scanner.inject_advertisement(device, adv_data) await asyncio.sleep(0) # Remote scanner should already have adapter_idx returning None with ( caplog.at_level(logging.DEBUG), patch.object(manager, "get_bluez_mgmt_ctl", return_value=mock_mgmt_ctl), ): client = bleak.BleakClient("00:00:00:00:00:03") await client.connect() # Verify load_conn_params was NOT called since adapter_idx is None assert mock_mgmt_ctl.load_conn_params.call_count == 0 cancel_remote() @pytest.mark.asyncio async def test_connection_path_scoring_with_slots_and_logging( caplog: pytest.LogCaptureFixture, ) -> None: """Test connection path scoring and logging reflects slot availability.""" from bleak_retry_connector import Allocations manager = _get_manager() class FakeBleakClientNoConnect(BaseFakeBleakClient): """Fake bleak client that doesn't connect.""" async def connect(self, *args, **kwargs): """Don't actually connect.""" raise BleakError("Test - connection not needed") # Create fake connectors fake_connector_1 = HaBluetoothConnector( client=FakeBleakClientNoConnect, source="scanner1", can_connect=lambda: True ) fake_connector_2 = HaBluetoothConnector( client=FakeBleakClientNoConnect, source="scanner2", can_connect=lambda: True ) fake_connector_3 = HaBluetoothConnector( client=FakeBleakClientNoConnect, source="scanner3", can_connect=lambda: True ) # Create scanners with different sources scanner1 = FakeScanner("scanner1", "Scanner 1", fake_connector_1, True) scanner2 = FakeScanner("scanner2", "Scanner 2", fake_connector_2, True) scanner3 = FakeScanner("scanner3", "Scanner 3", fake_connector_3, True) # Mock get_allocations for each scanner using patch.object with ( patch.object( scanner1, "get_allocations", return_value=Allocations( adapter="scanner1", slots=3, free=1, # Only 1 slot free - should get penalty allocated=["AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02"], ), ), patch.object( scanner2, "get_allocations", return_value=Allocations( adapter="scanner2", slots=3, free=2, # 2 slots free - no penalty allocated=["AA:BB:CC:DD:EE:03"], ), ), patch.object( scanner3, "get_allocations", return_value=Allocations( adapter="scanner3", slots=3, free=3, # All slots free - no penalty allocated=[], ), ), ): cancel1 = manager.async_register_scanner(scanner1) cancel2 = manager.async_register_scanner(scanner2) cancel3 = manager.async_register_scanner(scanner3) # Inject advertisements with different RSSI values device1 = generate_ble_device( "00:00:00:00:00:01", "Test Device", {"source": "scanner1"} ) adv_data1 = generate_advertisement_data(local_name="Test Device", rssi=-60) scanner1.inject_advertisement(device1, adv_data1) device2 = generate_ble_device( "00:00:00:00:00:01", "Test Device", {"source": "scanner2"} ) adv_data2 = generate_advertisement_data(local_name="Test Device", rssi=-65) scanner2.inject_advertisement(device2, adv_data2) device3 = generate_ble_device( "00:00:00:00:00:01", "Test Device", {"source": "scanner3"} ) adv_data3 = generate_advertisement_data(local_name="Test Device", rssi=-70) scanner3.inject_advertisement(device3, adv_data3) await asyncio.sleep(0) # Try to connect with logging enabled with caplog.at_level(logging.INFO): client = bleak.BleakClient("00:00:00:00:00:01") with suppress(BleakError): await client.connect() # Check that the log contains the connection paths with correct scoring log_text = caplog.text assert "Found 3 connection path(s)" in log_text # Extract the log line with connection paths for line in caplog.text.splitlines(): if "Found 3 connection path(s)" in line: # rssi_diff = best_rssi - second_best_rssi = -60 - (-65) = 5 # Scanner 1 has best RSSI (-60) but only 1 slot free, so with penalty: # score = -60 - (5 * 0.76) = -63.8 assert "Scanner 1" in line assert "(slots=1/3 free)" in line assert "(score=-63.8)" in line # Scanner 2 has RSSI -65 with 2 slots free, no penalty: # score = -65 assert "Scanner 2" in line assert "(slots=2/3 free)" in line # Check for both -65 and -65.0 assert ("(score=-65)" in line) or ("(score=-65.0)" in line) # Scanner 3 has RSSI -70 with all slots free, no penalty: # score = -70 assert "Scanner 3" in line assert "(slots=3/3 free)" in line # Check for both -70 and -70.0 assert ("(score=-70)" in line) or ("(score=-70.0)" in line) # Verify order: Scanner 1 should be first (best score -63.8), # then Scanner 2 (-65), then Scanner 3 (-70) scanner1_pos = line.find("Scanner 1") scanner2_pos = line.find("Scanner 2") scanner3_pos = line.find("Scanner 3") assert scanner1_pos < scanner2_pos < scanner3_pos, ( f"Expected Scanner 1 before Scanner 2 before Scanner 3, " f"but got positions {scanner1_pos}, {scanner2_pos}, {scanner3_pos}" ) break else: pytest.fail("Could not find connection path log line") cancel1() cancel2() cancel3() @pytest.mark.asyncio async def test_connection_path_scoring_no_slots_available( caplog: pytest.LogCaptureFixture, ) -> None: """Test that scanners with no free slots are excluded.""" manager = _get_manager() class FakeBleakClientNoConnect(BaseFakeBleakClient): """Fake bleak client that doesn't connect.""" async def connect(self, *args, **kwargs): """Don't actually connect.""" raise BleakError("Test - connection not needed") # Create fake connectors fake_connector_1 = HaBluetoothConnector( client=FakeBleakClientNoConnect, source="scanner1", can_connect=lambda: True ) fake_connector_2 = HaBluetoothConnector( client=FakeBleakClientNoConnect, source="scanner2", can_connect=lambda: True ) # Create scanners scanner1 = FakeScanner("scanner1", "Scanner 1", fake_connector_1, True) scanner2 = FakeScanner("scanner2", "Scanner 2", fake_connector_2, True) # Mock get_allocations - scanner1 has no free slots with ( patch.object( scanner1, "get_allocations", return_value=Allocations( adapter="scanner1", slots=3, free=0, # No slots available - should be excluded allocated=[ "AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02", "AA:BB:CC:DD:EE:03", ], ), ), patch.object( scanner2, "get_allocations", return_value=Allocations( adapter="scanner2", slots=3, free=3, allocated=[] # All slots free ), ), ): cancel1 = manager.async_register_scanner(scanner1) cancel2 = manager.async_register_scanner(scanner2) # Inject advertisements device1 = generate_ble_device( "00:00:00:00:00:02", "Test Device", {"source": "scanner1"} ) adv_data1 = generate_advertisement_data( local_name="Test Device", rssi=-50 ) # Better RSSI scanner1.inject_advertisement(device1, adv_data1) device2 = generate_ble_device( "00:00:00:00:00:02", "Test Device", {"source": "scanner2"} ) adv_data2 = generate_advertisement_data( local_name="Test Device", rssi=-70 ) # Worse RSSI scanner2.inject_advertisement(device2, adv_data2) await asyncio.sleep(0) # Try to connect with logging enabled with caplog.at_level(logging.INFO): client = bleak.BleakClient("00:00:00:00:00:02") with suppress(BleakError): await client.connect() # Check that only scanner2 is in the connection paths log_text = caplog.text assert ( "Found 1 connection path(s)" in log_text or "Found 2 connection path(s)" in log_text ) # If both are shown, scanner1 should have bad score (NO_RSSI_VALUE = -127) for line in caplog.text.splitlines(): if "connection path(s)" in line: if "Scanner 1" in line: # Scanner 1 should show 0 free slots and bad score assert "(slots=0/3 free)" in line assert "(score=-127)" in line # NO_RSSI_VALUE # Scanner 2 should be present with normal score assert "Scanner 2" in line assert "(slots=3/3 free)" in line # Check for both -70 and -70.0 assert ("(score=-70)" in line) or ("(score=-70.0)" in line) break cancel1() cancel2() @pytest.mark.asyncio async def test_thundering_herd_connection_slots() -> None: """ Test thundering herd scenario with limited connection slots. Simulates 7 devices trying to connect simultaneously to 3 proxies: - Proxy 1 & 2: Good signal (-60 RSSI), 3 slots each - Proxy 3: Bad signal (-95 RSSI), 3 slots each Expected behavior: - First 6 devices should connect to proxy1 and proxy2 (3 each) - 7th device should connect to proxy3 (bad signal) when others are full """ from bleak_retry_connector import Allocations manager = _get_manager() # Track which backend each device connected to connection_tracker = {} class FakeBleakClientThunderingHerd(BaseFakeBleakClient): """Fake bleak client for thundering herd test.""" def __init__(self, address_or_ble_device, *args, **kwargs): """Initialize with tracking.""" super().__init__(address_or_ble_device, *args, **kwargs) self._connected = False # Track the device and source if isinstance(address_or_ble_device, BLEDevice): self._address = address_or_ble_device.address self._source = address_or_ble_device.details.get("source") else: self._address = str(address_or_ble_device) self._source = None async def connect(self, *args, **kwargs): """Simulate connection and record which backend was used.""" # Small delay to simulate connection time await asyncio.sleep(0.01) self._connected = True # Record which backend this device connected to if self._address and self._source: connection_tracker[self._address] = self._source return True @property def is_connected(self) -> bool: """Return connection state.""" return self._connected # Create fake connectors for 3 proxies fake_connector_1 = HaBluetoothConnector( client=FakeBleakClientThunderingHerd, source="proxy1", can_connect=lambda: True, ) fake_connector_2 = HaBluetoothConnector( client=FakeBleakClientThunderingHerd, source="proxy2", can_connect=lambda: True, ) fake_connector_3 = HaBluetoothConnector( client=FakeBleakClientThunderingHerd, source="proxy3", can_connect=lambda: True, ) # Create 3 scanners (proxies) with 3 connection slots each proxy1 = FakeScanner("proxy1", "Proxy 1 (Good)", fake_connector_1, True) proxy2 = FakeScanner("proxy2", "Proxy 2 (Good)", fake_connector_2, True) proxy3 = FakeScanner("proxy3", "Proxy 3 (Bad)", fake_connector_3, True) # Track actual slot allocations dynamically proxy_allocations: dict[str, set[str]] = { "proxy1": set(), "proxy2": set(), "proxy3": set(), } def get_proxy_allocations(proxy_name: str) -> Allocations: """Get allocations for a specific proxy.""" allocated = proxy_allocations[proxy_name] return Allocations( adapter=proxy_name, slots=3, free=3 - len(allocated), allocated=list(allocated), ) # Mock methods to track allocations def make_add_connecting(proxy_name: str) -> Callable[[str], None]: def _add_connecting(addr: str) -> None: proxy_allocations[proxy_name].add(addr) return _add_connecting def make_finished_connecting(proxy_name: str) -> Callable[[str, bool], None]: def _finished_connecting(addr: str, success: bool) -> None: if not success: proxy_allocations[proxy_name].discard(addr) return _finished_connecting # Mock get_allocations and connection tracking with ( patch.object( proxy1, "get_allocations", lambda: get_proxy_allocations("proxy1") ), patch.object( proxy2, "get_allocations", lambda: get_proxy_allocations("proxy2") ), patch.object( proxy3, "get_allocations", lambda: get_proxy_allocations("proxy3") ), patch.object(proxy1, "_add_connecting", make_add_connecting("proxy1")), patch.object(proxy2, "_add_connecting", make_add_connecting("proxy2")), patch.object(proxy3, "_add_connecting", make_add_connecting("proxy3")), patch.object( proxy1, "_finished_connecting", make_finished_connecting("proxy1") ), patch.object( proxy2, "_finished_connecting", make_finished_connecting("proxy2") ), patch.object( proxy3, "_finished_connecting", make_finished_connecting("proxy3") ), ): cancel1 = manager.async_register_scanner(proxy1) cancel2 = manager.async_register_scanner(proxy2) cancel3 = manager.async_register_scanner(proxy3) # Create 7 devices to connect device_addresses = [f"AA:BB:CC:DD:EE:0{i}" for i in range(1, 8)] # Inject advertisements for all devices on all proxies for i, address in enumerate(device_addresses, 1): # Good signal on proxy1 device1 = generate_ble_device(address, f"Device {i}", {"source": "proxy1"}) adv_data1 = generate_advertisement_data(local_name=f"Device {i}", rssi=-60) proxy1.inject_advertisement(device1, adv_data1) # Good signal on proxy2 (exactly same as proxy1) device2 = generate_ble_device(address, f"Device {i}", {"source": "proxy2"}) adv_data2 = generate_advertisement_data(local_name=f"Device {i}", rssi=-60) proxy2.inject_advertisement(device2, adv_data2) # Bad signal on proxy3 device3 = generate_ble_device(address, f"Device {i}", {"source": "proxy3"}) adv_data3 = generate_advertisement_data(local_name=f"Device {i}", rssi=-95) proxy3.inject_advertisement(device3, adv_data3) await asyncio.sleep(0) # Clear the connection tracker before starting connection_tracker.clear() async def connect_device(address: str) -> tuple[str, str | None]: """Try to connect to a device.""" client = bleak.BleakClient(address) try: await client.connect() # The connection tracker should have recorded which backend was used return address, connection_tracker.get(address, "unknown") except BleakError: # Connection failed (no available backend) return address, None # Simulate thundering herd - all devices try to connect at once tasks = [connect_device(addr) for addr in device_addresses] results = await asyncio.gather(*tasks, return_exceptions=True) # Process results connection_results = {} for result in results: if isinstance(result, tuple): address, proxy = result connection_results[address] = proxy # Count connections per proxy proxy1_connections = [ addr for addr, p in connection_results.items() if p == "proxy1" ] proxy2_connections = [ addr for addr, p in connection_results.items() if p == "proxy2" ] proxy3_connections = [ addr for addr, p in connection_results.items() if p == "proxy3" ] failed_connections = [ addr for addr, p in connection_results.items() if p is None ] # Verify constraints # 1. No proxy should exceed its slot limit assert ( len(proxy1_connections) <= 3 ), f"Proxy1 exceeded slot limit: {len(proxy1_connections)} > 3" assert ( len(proxy2_connections) <= 3 ), f"Proxy2 exceeded slot limit: {len(proxy2_connections)} > 3" assert ( len(proxy3_connections) <= 3 ), f"Proxy3 exceeded slot limit: {len(proxy3_connections)} > 3" # 2. Good signal proxies should be preferred and fill up first good_proxy_total = len(proxy1_connections) + len(proxy2_connections) assert ( good_proxy_total == 6 ), f"Expected exactly 6 connections on good proxies, got {good_proxy_total}" # 3. All 7 devices should connect (6 to good proxies, 1 to bad proxy) total_connected = ( len(proxy1_connections) + len(proxy2_connections) + len(proxy3_connections) ) assert ( total_connected == 7 ), f"Expected all 7 devices to connect, but only {total_connected} did" # 4. The 7th device should go to proxy3 since good ones are full assert ( len(proxy3_connections) == 1 ), f"Expected exactly 1 connection on proxy3, got {len(proxy3_connections)}" # 5. Verify good distribution across proxy1 and proxy2 # Both should have roughly equal load (3 connections each) assert ( len(proxy1_connections) == 3 ), f"Expected proxy1 to have 3 connections, got {len(proxy1_connections)}" assert ( len(proxy2_connections) == 3 ), f"Expected proxy2 to have 3 connections, got {len(proxy2_connections)}" # 6. No connections should fail assert ( len(failed_connections) == 0 ), f"Expected no failed connections, but {len(failed_connections)} failed" # Clean up cancel1() cancel2() cancel3()