pax_global_header00006660000000000000000000000064150054425730014517gustar00rootroot0000000000000052 comment=76c04adc5db1e7a039e5077d5c691bf3c9699829 habluetooth-3.48.2/000077500000000000000000000000001500544257300141335ustar00rootroot00000000000000habluetooth-3.48.2/.all-contributorsrc000066400000000000000000000004621500544257300177660ustar00rootroot00000000000000{ "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 } habluetooth-3.48.2/.copier-answers.yml000066400000000000000000000010531500544257300176740ustar00rootroot00000000000000# 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 habluetooth-3.48.2/.editorconfig000066400000000000000000000004441500544257300166120ustar00rootroot00000000000000# 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 habluetooth-3.48.2/.github/000077500000000000000000000000001500544257300154735ustar00rootroot00000000000000habluetooth-3.48.2/.github/ISSUE_TEMPLATE/000077500000000000000000000000001500544257300176565ustar00rootroot00000000000000habluetooth-3.48.2/.github/ISSUE_TEMPLATE/1-bug_report.md000066400000000000000000000004221500544257300225040ustar00rootroot00000000000000--- 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. habluetooth-3.48.2/.github/ISSUE_TEMPLATE/2-feature-request.md000066400000000000000000000006721500544257300234650ustar00rootroot00000000000000--- 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. habluetooth-3.48.2/.github/dependabot.yml000066400000000000000000000013441500544257300203250ustar00rootroot00000000000000# 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" habluetooth-3.48.2/.github/labels.toml000066400000000000000000000035151500544257300176360ustar00rootroot00000000000000[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" habluetooth-3.48.2/.github/workflows/000077500000000000000000000000001500544257300175305ustar00rootroot00000000000000habluetooth-3.48.2/.github/workflows/ci.yml000066400000000000000000000203061500544257300206470ustar00rootroot00000000000000name: 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: 3.x - 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@11bd71901bbe5b1630ceea73d27597364c9af683 # 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" os: - ubuntu-latest - macOS-latest - windows-latest extension: - "skip_cython" - "use_cython" runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Install poetry run: pipx install poetry - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: ${{ matrix.python-version }} cache: "poetry" - 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@ad3126e916f78f00edff4ed0317cf185271ccc2d # v5 with: token: ${{ secrets.CODECOV_TOKEN }} benchmark: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Install poetry run: pipx install poetry - name: Setup Python 3.13 uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 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@0010eb0ca6e89b80c88e8edaaa07cfe5f3e6664d # v3 with: token: ${{ secrets.CODSPEED_TOKEN }} run: poetry run pytest --no-cov -vvvvv --codspeed 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@11bd71901bbe5b1630ceea73d27597364c9af683 # 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@26bb37cfab71a5a372e3db0f48a6eac57519a4a6 # v9.21.0 if: github.ref_name != 'main' with: root_options: --noop # On main branch: actual PSR + upload to PyPI & GitHub - name: Release uses: python-semantic-release/python-semantic-release@26bb37cfab71a5a372e3db0f48a6eac57519a4a6 # v9.21.0 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@76f52bc884231f62b9a034ebfe128415bbaabdfc # 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 # 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 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: ref: ${{ needs.release.outputs.newest_release_tag }} fetch-depth: 0 # Used to host cibuildwheel - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 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@faf86a6ed7efa889faf6996aa23820831055001a # v2.23.3 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@d3f86a106a0bac45b974a628896c90dbdf5c8093 # 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@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 habluetooth-3.48.2/.github/workflows/hacktoberfest.yml000066400000000000000000000005341500544257300231010ustar00rootroot00000000000000name: 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 }} habluetooth-3.48.2/.github/workflows/issue-manager.yml000066400000000000000000000013401500544257300230110ustar00rootroot00000000000000name: 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." } } habluetooth-3.48.2/.github/workflows/poetry-upgrade.yml000066400000000000000000000003371500544257300232250ustar00rootroot00000000000000name: 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 }} habluetooth-3.48.2/.gitignore000066400000000000000000000041101500544257300161170ustar00rootroot00000000000000# Created by .ignore support plugin (hsz.mobi) ### Python template # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder {{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/ habluetooth-3.48.2/.gitpod.yml000066400000000000000000000003061500544257300162210ustar00rootroot00000000000000tasks: - command: | pip install poetry PIP_USER=false poetry install - command: | pip install pre-commit pre-commit install PIP_USER=false pre-commit install-hooks habluetooth-3.48.2/.idea/000077500000000000000000000000001500544257300151135ustar00rootroot00000000000000habluetooth-3.48.2/.idea/habluetooth.iml000066400000000000000000000005151500544257300201350ustar00rootroot00000000000000 habluetooth-3.48.2/.idea/watcherTasks.xml000066400000000000000000000052531500544257300203050ustar00rootroot00000000000000 habluetooth-3.48.2/.idea/workspace.xml000066400000000000000000000027361500544257300176430ustar00rootroot00000000000000 habluetooth-3.48.2/.pre-commit-config.yaml000066400000000000000000000030701500544257300204140ustar00rootroot00000000000000# 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.6.0 hooks: - id: commitizen stages: [commit-msg] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.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.1.2 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.11.7 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - repo: https://github.com/psf/black rev: 25.1.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.15.0 hooks: - id: mypy additional_dependencies: [] habluetooth-3.48.2/.readthedocs.yml000066400000000000000000000010511500544257300172160ustar00rootroot00000000000000# 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 habluetooth-3.48.2/CHANGELOG.md000066400000000000000000000556351500544257300157620ustar00rootroot00000000000000# Changelog ## v3.48.2 (2025-05-03) ### Bug fixes - Remove duplicate _connecting slot from basehascanner (#229) ([`230bb03`](https://github.com/Bluetooth-Devices/habluetooth/commit/230bb038eea8ae07a3fe798ec15792489d06cd66)) ## v3.48.1 (2025-05-03) ### Bug fixes - Pin cython to <3.1 (#228) ([`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 (#227) ([`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 (#225) ([`d81378e`](https://github.com/Bluetooth-Devices/habluetooth/commit/d81378e6b4adedead6d04ab23be7b655cd3785fb)) ## v3.47.0 (2025-05-03) ### Bug fixes - Require bluetooth-auto-recovery >= 1.5.1 (#224) ([`8164ce5`](https://github.com/Bluetooth-Devices/habluetooth/commit/8164ce512084fe898cb80c5e44f664dde4751113)) ### Features - Avoid thundering heard of connections (#223) ([`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 (#222) ([`a4dd395`](https://github.com/Bluetooth-Devices/habluetooth/commit/a4dd395b7a8e70cb0ae94d97422d35eb638daaa5)) ## v3.45.0 (2025-04-29) ### Features - Improve performance of _async_on_advertisement_internal (#220) ([`be0b5a6`](https://github.com/Bluetooth-Devices/habluetooth/commit/be0b5a6d0da07f2f881984c92c0c7671117d3e5a)) ## v3.44.0 (2025-04-28) ### Features - Save the raw data in storage (#217) ([`eaf4107`](https://github.com/Bluetooth-Devices/habluetooth/commit/eaf41072ecc915b2de23ad3c9a03148f4b313f17)) ## v3.43.0 (2025-04-28) ### Features - Migrate storage code from bluetooth_adapters (#216) ([`5d671f9`](https://github.com/Bluetooth-Devices/habluetooth/commit/5d671f95b9a7964bfa871c7b42061a71a98ce80e)) ## v3.42.0 (2025-04-27) ### Features - Add raw field to bluetoothserviceinfobleak (#214) ([`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 (#213) ([`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 (#212) ([`e154136`](https://github.com/Bluetooth-Devices/habluetooth/commit/e154136db9f15d33c6de3d89bf9e4e53e03c690a)) ## v3.39.0 (2025-04-17) ### Features - Improve performance of _async_on_advertisement (#209) ([`0fc0500`](https://github.com/Bluetooth-Devices/habluetooth/commit/0fc0500d74cdc3d320111df979bef784a51a2eac)) ## v3.38.1 (2025-04-14) ### Bug fixes - Add missing dbus-fast dep on linux (#207) ([`5746448`](https://github.com/Bluetooth-Devices/habluetooth/commit/57464488482626577e9f84c42ab1ff100b7857b3)) ## v3.38.0 (2025-03-22) ### Bug fixes - Use project.license key (#196) ([`1decf97`](https://github.com/Bluetooth-Devices/habluetooth/commit/1decf9704f7db33bc8094880651321c1b58420c8)) ### Features - Improve performance of previous source checks (#192) ([`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 (#191) ([`73ec210`](https://github.com/Bluetooth-Devices/habluetooth/commit/73ec2107375be217ffb0310194be8c3d4f20e150)) ## v3.36.0 (2025-03-21) ### Features - Improve performance of filtering apple data (#188) ([`9f56840`](https://github.com/Bluetooth-Devices/habluetooth/commit/9f568405ae987de0fb3953d6ae7b39eabacde9ef)) ## v3.35.0 (2025-03-21) ### Features - Optimize previous local name matching (#187) ([`fadb722`](https://github.com/Bluetooth-Devices/habluetooth/commit/fadb722b8ded2bc15bd56b641a963d4c4d19838e)) ## v3.34.1 (2025-03-21) ### Bug fixes - Revert adding _async_on_advertisements (#185) ([`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 (#184) ([`b3acb88`](https://github.com/Bluetooth-Devices/habluetooth/commit/b3acb882d888a33567ece3e7f9d0fa1d2b4c6acd)) ## v3.33.0 (2025-03-21) ### Features - Add _async_on_raw_advertisement (#183) ([`24d128f`](https://github.com/Bluetooth-Devices/habluetooth/commit/24d128fe4854135647e9a41c7eeaf1784fbda0bf)) ## v3.32.0 (2025-03-15) ### Features - Improve performance of dispatching discovery info to subclasses (#181) ([`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 (#180) ([`ae977b9`](https://github.com/Bluetooth-Devices/habluetooth/commit/ae977b9d53c29c581ff6394a2078d2a2b01066dd)) ## v3.30.0 (2025-03-15) ### Features - Improve performance of on demand advertisementdata construction (#179) ([`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 (#170) ([`ec1f6aa`](https://github.com/Bluetooth-Devices/habluetooth/commit/ec1f6aa7989cea2a589029362461dba4f7a8f0db)) ## v3.28.0 (2025-03-15) ### Features - Improve performance of local name checks (#173) ([`9f57d2f`](https://github.com/Bluetooth-Devices/habluetooth/commit/9f57d2fcc23595b376d5785162c21633514f44bd)) ## v3.27.0 (2025-03-14) ### Features - Improve performance of base_scanner (#168) ([`5b8c59c`](https://github.com/Bluetooth-Devices/habluetooth/commit/5b8c59c7ffadead5997fa457b07ff37ec8ec31b5)) ## v3.26.0 (2025-03-14) ### Features - Improve manager performance (#167) ([`e0bdace`](https://github.com/Bluetooth-Devices/habluetooth/commit/e0bdace8180ff3ac450447be99f700fd647fb659)) ## v3.25.1 (2025-03-13) ### Bug fixes - Downgrade scanner gone quiet logger to debug (#166) ([`d450ffc`](https://github.com/Bluetooth-Devices/habluetooth/commit/d450ffca38dec015f44b5be08af484fe8ca09866)) ## v3.25.0 (2025-03-05) ### Bug fixes - Use trusted publishing for wheels (#163) ([`c726687`](https://github.com/Bluetooth-Devices/habluetooth/commit/c726687affb0025037676b76cf4ecefdef0da23f)) ### Features - Add armv7l to wheel builds (#162) ([`e394707`](https://github.com/Bluetooth-Devices/habluetooth/commit/e394707b6b7ffc54e6dc5b8c038a08c5404f1777)) - Reduce wheel sizes (#161) ([`5e6b644`](https://github.com/Bluetooth-Devices/habluetooth/commit/5e6b64476ff2db7a215d1b0d58ef01c04b839d34)) ## v3.24.1 (2025-02-27) ### Bug fixes - Update scanner discover signature for newer bleak (#157) ([`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 (#155) ([`f0ff045`](https://github.com/Bluetooth-Devices/habluetooth/commit/f0ff04586849bda3933fbe98e8e1335c308999c4)) ## v3.23.0 (2025-02-21) ### Features - Add debug logging for connection paths (#154) ([`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 (#153) ([`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 (#151) ([`a39ba18`](https://github.com/Bluetooth-Devices/habluetooth/commit/a39ba184e0d01f983133534e4fd7c1b6202210fb)) ## v3.21.1 (2025-02-04) ### Bug fixes - Update poetry to v2 (#147) ([`aefe36e`](https://github.com/Bluetooth-Devices/habluetooth/commit/aefe36e2507566224267f371511c1f1c748a37a9)) ## v3.21.0 (2025-02-01) ### Features - Reduce remote scanner adv processing overhead (#140) ([`7bf302b`](https://github.com/Bluetooth-Devices/habluetooth/commit/7bf302bac3855cf7e229dd2744acce513b2e2ee4)) ## v3.20.1 (2025-02-01) ### Bug fixes - Remove unused centralbluetoothmanager in models (#138) ([`7466034`](https://github.com/Bluetooth-Devices/habluetooth/commit/74660343b30fec50b927fdddd92e72eacb4da6cf)) - Precision loss when comparing advs from different sources (#136) ([`02279a9`](https://github.com/Bluetooth-Devices/habluetooth/commit/02279a95ca5b590768bd631bf39ee507a64db7ad)) ## v3.20.0 (2025-02-01) ### Features - Reduce adv tracker overhead (#137) ([`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 (#135) ([`37fc839`](https://github.com/Bluetooth-Devices/habluetooth/commit/37fc839d5fc73ff6f784ec8041606be82d58322b)) ## v3.18.0 (2025-02-01) ### Features - Refactor scanner_adv_received to reduce ref counting (#134) ([`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 (#131) ([`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 (#130) ([`1312bf7`](https://github.com/Bluetooth-Devices/habluetooth/commit/1312bf7d978ff585e66d99bde766e85773fce006)) ## v3.16.0 (2025-01-31) ### Features - Allow bluetoothmanager to be created with defaults (#129) ([`70b2f69`](https://github.com/Bluetooth-Devices/habluetooth/commit/70b2f6952fbd3ecd499a4c66ec305869158a428e)) ## v3.15.0 (2025-01-31) ### Features - Include findmy packets in wanted adverts (#127) ([`5217850`](https://github.com/Bluetooth-Devices/habluetooth/commit/5217850934bfed5d8e70f8b43c84cd97cf53cdac)) ## v3.14.0 (2025-01-29) ### Features - Add allocations to diagnostics (#126) ([`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 (#125) ([`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 (#120) ([`d76b7c9`](https://github.com/Bluetooth-Devices/habluetooth/commit/d76b7c9624b6c4e6beedc1bd56dd1a3c0df70eec)) ## v3.11.2 (2025-01-22) ### Bug fixes - Re-release again for failed arm runners (#119) ([`af2bb50`](https://github.com/Bluetooth-Devices/habluetooth/commit/af2bb50879713378a32339e490a57b56083a4fa7)) ## v3.11.1 (2025-01-22) ### Bug fixes - Re-release due to failed github action (#118) ([`90e2192`](https://github.com/Bluetooth-Devices/habluetooth/commit/90e2192ff75c13ccf610fd06a61e64d60dfd1a18)) ## v3.11.0 (2025-01-22) ### Features - Add api for getting current slot allocations (#116) ([`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 (#115) ([`c6fd2ba`](https://github.com/Bluetooth-Devices/habluetooth/commit/c6fd2babf0c6438ff85220edef95df3d3b4fae9c)) ## v3.9.2 (2025-01-20) ### Bug fixes - Increase rssi switch value to 16 (#111) ([`db367db`](https://github.com/Bluetooth-Devices/habluetooth/commit/db367dbef3fa883348a72cf17e29d9c26a09de53)) ## v3.9.1 (2025-01-20) ### Bug fixes - Increase rssi switch threshold for advertisements (#110) ([`297c269`](https://github.com/Bluetooth-Devices/habluetooth/commit/297c2693f9a2c007f0e70175c24416c8bb7da099)) ## v3.9.0 (2025-01-17) ### Features - Switch to native arm runners for wheel builds (#106) ([`bf7e98b`](https://github.com/Bluetooth-Devices/habluetooth/commit/bf7e98b099597916bb7566eb03472023f8acef97)) ## v3.8.0 (2025-01-10) ### Features - Add async_register_disappeared_callback (#102) ([`ec1d445`](https://github.com/Bluetooth-Devices/habluetooth/commit/ec1d4456ca15c6fca3248f2e5d73fcb1ba9d36c6)) ## v3.7.0 (2025-01-05) ### Bug fixes - Publish workflow (#99) ([`341c8a4`](https://github.com/Bluetooth-Devices/habluetooth/commit/341c8a4b72fb2818a3bed44632048d8570fc3b67)) ### Features - Start building wheels for python 3.13 (#97) ([`26dd831`](https://github.com/Bluetooth-Devices/habluetooth/commit/26dd831c28f3c0dfe0745769749e795e7937c7df)) - Add codspeed benchmarks (#79) ([`5905fbd`](https://github.com/Bluetooth-Devices/habluetooth/commit/5905fbd2c54adea04c0e55fe8a299f771e6f31ed)) ### Unknown ## v3.6.0 (2024-10-20) ### Features - Speed up creation of advertisementdata namedtuple (#75) ([`28f7e60`](https://github.com/Bluetooth-Devices/habluetooth/commit/28f7e6093c3985da16e537bc9d989d839ad80c56)) ## v3.5.0 (2024-10-05) ### Features - Add support for python 3.13 (#71) ([`b8a4783`](https://github.com/Bluetooth-Devices/habluetooth/commit/b8a4783a43f6e771321974d2c085e5e0dda9e195)) ## v3.4.1 (2024-09-22) ### Bug fixes - Ensure build system required cython 3 (#69) ([`dc85d2f`](https://github.com/Bluetooth-Devices/habluetooth/commit/dc85d2fd1b8c8e4d8eb4515aa60af06782fc8722)) ## v3.4.0 (2024-09-02) ### Features - Add a fast cython init path for bluetoothserviceinfobleak (#48) ([`f532ed2`](https://github.com/Bluetooth-Devices/habluetooth/commit/f532ed215b429f0bbd14dacc30f87c53f22af245)) ## v3.3.2 (2024-08-20) ### Bug fixes - Disable 3.13 wheels (#64) ([`9e8bbff`](https://github.com/Bluetooth-Devices/habluetooth/commit/9e8bbff6179e08bd6e05341ff48fff3adc5c6157)) ## v3.3.1 (2024-08-20) ### Bug fixes - Bump cibuildwheel to fix wheel builds (#63) ([`68d838a`](https://github.com/Bluetooth-Devices/habluetooth/commit/68d838a1d2adab9efe1fb5eba65e81b5dcc9a351)) ## v3.3.0 (2024-08-20) ### Bug fixes - Cleanup advertisementmonitor mapper (#61) ([`7d3483d`](https://github.com/Bluetooth-Devices/habluetooth/commit/7d3483d87d3e03c19cf528a1838acce5b194533e)) ### Features - Override devicefound and devicelost for passive monitoring (#60) ([`a802859`](https://github.com/Bluetooth-Devices/habluetooth/commit/a8028596bf3576a35750ae8575f173c75f918f28)) ## v3.2.0 (2024-07-27) ### Features - Small speed ups to scanner detection callback (#55) ([`7a5129a`](https://github.com/Bluetooth-Devices/habluetooth/commit/7a5129a40a12382c089453880210c41bb0f28a32)) ## v3.1.3 (2024-06-24) ### Bug fixes - Wheel builds (#50) ([`b9a8eec`](https://github.com/Bluetooth-Devices/habluetooth/commit/b9a8eec4f79c2098c0ec318b6b1ff7e3376febf2)) ## v3.1.2 (2024-06-24) ### Bug fixes - Fix license classifier (#49) ([`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 (#47) ([`aa08b13`](https://github.com/Bluetooth-Devices/habluetooth/commit/aa08b136660cddea7c356274c21f20b6d0eef1fa)) ## v3.1.0 (2024-05-22) ### Features - Speed up dispatching bleak callbacks (#46) ([`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 (#44) ([`c300f73`](https://github.com/Bluetooth-Devices/habluetooth/commit/c300f73ba82d3549ea4c156ef11023e9478c8b6c)) ## v3.0.0 (2024-05-02) ### Features - Make generation of advertisementdata lazy (#42) ([`25f8437`](https://github.com/Bluetooth-Devices/habluetooth/commit/25f843795927ad663a1d5ef1fa9472ec366b9da5)) ## v2.8.1 (2024-05-02) ### Bug fixes - Add missing find_device_by_address mapping (#43) ([`cc8e57e`](https://github.com/Bluetooth-Devices/habluetooth/commit/cc8e57eef7b97a6f2a30488a64d156cb5023c6c6)) ## v2.8.0 (2024-04-17) ### Features - Add support for recovering failed adapters after reboot (#40) ([`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 (#39) ([`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 (#37) ([`bba8b51`](https://github.com/Bluetooth-Devices/habluetooth/commit/bba8b514490d98dca1020bbfefd9dc1e6a79af5f)) ## v2.5.3 (2024-04-17) ### Bug fixes - Ensure scanner is stopped on cancellation (#36) ([`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 (#35) ([`2350543`](https://github.com/Bluetooth-Devices/habluetooth/commit/23505437c98529f692ab2dc0f5c3bdb5c9b7e3bd)) ## v2.5.1 (2024-04-16) ### Bug fixes - Wheel builds (#34) ([`5bd671a`](https://github.com/Bluetooth-Devices/habluetooth/commit/5bd671a159292dffe30a69639411926d0bc28123)) ## v2.5.0 (2024-04-16) ### Features - Fallback to passive scanning if active cannot start (#33) ([`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 (#31) ([`8330e18`](https://github.com/Bluetooth-Devices/habluetooth/commit/8330e187550ec00ed415d3650a2c231921fb8ae7)) ## v2.4.1 (2024-02-23) ### Bug fixes - Avoid concurrent refreshes of adapters (#30) ([`d355b17`](https://github.com/Bluetooth-Devices/habluetooth/commit/d355b1768705706dec7062ad5d6267089d87a88e)) ## v2.4.0 (2024-01-22) ### Features - Improve error reporting resolution suggestions (#29) ([`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 (#28) ([`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 (#27) ([`05ceb85`](https://github.com/Bluetooth-Devices/habluetooth/commit/05ceb85901b17f72988068997c7f39bc0179dca2)) ## v2.2.0 (2024-01-14) ### Features - Improve remote scanner performance (#26) ([`c549b1c`](https://github.com/Bluetooth-Devices/habluetooth/commit/c549b1cf9bbbda0c39dfce92d2888d5b990211da)) ## v2.1.0 (2024-01-10) ### Features - Add support for windows (#25) ([`788dd77`](https://github.com/Bluetooth-Devices/habluetooth/commit/788dd77ffac6664083821d5ba8b264725a3baaff)) ## v2.0.2 (2024-01-04) ### Bug fixes - Handle subclassed str in the client wrapper (#24) ([`f18a30e`](https://github.com/Bluetooth-Devices/habluetooth/commit/f18a30e48fe064993dc64f3af01c5d64b676a82f)) ## v2.0.1 (2023-12-31) ### Bug fixes - Switching scanners too quickly (#23) ([`bd53685`](https://github.com/Bluetooth-Devices/habluetooth/commit/bd536854457bd8b27f9e91921965b88b0ff798c3)) ## v2.0.0 (2023-12-21) ### Features - Simplify async_register_scanner by removing connectable argument (#22) ([`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 (#21) ([`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 (#20) ([`144cf15`](https://github.com/Bluetooth-Devices/habluetooth/commit/144cf15050a68cca66e7a2e24a5ddc7b87c32e41)) ## v0.11.0 (2023-12-11) ### Features - Relocate bluetoothserviceinfobleak (#18) ([`4f4f32d`](https://github.com/Bluetooth-Devices/habluetooth/commit/4f4f32d78d6abe21e28171f54ff5f3b17c8fb702)) ## v0.10.0 (2023-12-07) ### Features - Small speed ups to base_scanner (#17) ([`e1ff7e9`](https://github.com/Bluetooth-Devices/habluetooth/commit/e1ff7e9fb91a274b1a4bf6943a26e2a3f19780e7)) ## v0.9.0 (2023-12-06) ### Features - Speed up processing incoming service infos (#16) ([`55f6522`](https://github.com/Bluetooth-Devices/habluetooth/commit/55f6522ffc2adaf7e203ff4d2c1b13adc5d8c6a2)) ## v0.8.0 (2023-12-06) ### Features - Auto build the cythonized manager (#15) ([`c3441e5`](https://github.com/Bluetooth-Devices/habluetooth/commit/c3441e5095d62e6e70c2c879c4b5c109a87f463c)) - Add cython implementation for manager (#14) ([`266a602`](https://github.com/Bluetooth-Devices/habluetooth/commit/266a6022fb433ef9399f72e87b18b86897524784)) ## v0.7.0 (2023-12-05) ### Features - Port bluetooth manager from ha (#13) ([`757640a`](https://github.com/Bluetooth-Devices/habluetooth/commit/757640a7b7f60072588168501148ba750316f170)) ## v0.6.1 (2023-12-04) ### Bug fixes - Add missing cythonize for the adv tracker (#12) ([`8140195`](https://github.com/Bluetooth-Devices/habluetooth/commit/8140195a27ef83ea89ca643a5899d80839e574ae)) ## v0.6.0 (2023-12-04) ### Features - Port advertisement_tracker (#11) ([`378667b`](https://github.com/Bluetooth-Devices/habluetooth/commit/378667bce851b5076ee79ff223a72501c5575325)) ## v0.5.1 (2023-12-04) ### Bug fixes - Remove slots to keep hascanner patchable (#10) ([`d068f48`](https://github.com/Bluetooth-Devices/habluetooth/commit/d068f480d292619a1fc49a1256be98bdc6efadd6)) ## v0.5.0 (2023-12-03) ### Features - Port local scanner support from ha (#9) ([`1b1d0e4`](https://github.com/Bluetooth-Devices/habluetooth/commit/1b1d0e4bc17a44a1b20382da6ae28ea8e50e80b7)) ## v0.4.0 (2023-12-03) ### Features - Add more typing for incoming bluetooth data (#8) ([`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 (#7) ([`e15074b`](https://github.com/Bluetooth-Devices/habluetooth/commit/e15074b172242f44f641e5232ebdf6297537a2b8)) - Add basic pxd (#6) ([`fd97d07`](https://github.com/Bluetooth-Devices/habluetooth/commit/fd97d07db7c0e8e0e877e1544fd0e392d14448b3)) ## v0.2.0 (2023-12-03) ### Features - Add cython pxd for base_scanner (#5) ([`0195710`](https://github.com/Bluetooth-Devices/habluetooth/commit/0195710bc25c8c3cc68b17a8f31cf281494fdc22)) ## v0.1.0 (2023-12-03) ### Features - Port base scanner from ha (#2) ([`e01a57b`](https://github.com/Bluetooth-Devices/habluetooth/commit/e01a57b6e0003ea8fe64b8e6e11ce09a35c1ada2)) ## v0.0.1 (2023-12-02) ### Bug fixes - Reserve name (#1) ([`5493984`](https://github.com/Bluetooth-Devices/habluetooth/commit/5493984483902039ca396498122e6094524bbae6)) habluetooth-3.48.2/CONTRIBUTING.md000066400000000000000000000074321500544257300163720ustar00rootroot00000000000000# 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 habluetooth-3.48.2/LICENSE000066400000000000000000000261211500544257300151420ustar00rootroot00000000000000 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. habluetooth-3.48.2/README.md000066400000000000000000000076041500544257300154210ustar00rootroot00000000000000# 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. habluetooth-3.48.2/build_ext.py000066400000000000000000000032771500544257300164750ustar00rootroot00000000000000"""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", ] 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 habluetooth-3.48.2/commitlint.config.mjs000066400000000000000000000003621500544257300202720ustar00rootroot00000000000000export 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], }, }; habluetooth-3.48.2/docs/000077500000000000000000000000001500544257300150635ustar00rootroot00000000000000habluetooth-3.48.2/docs/Makefile000066400000000000000000000013721500544257300165260ustar00rootroot00000000000000# 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) habluetooth-3.48.2/docs/_static/000077500000000000000000000000001500544257300165115ustar00rootroot00000000000000habluetooth-3.48.2/docs/_static/.gitkeep000066400000000000000000000000001500544257300201300ustar00rootroot00000000000000habluetooth-3.48.2/docs/changelog.md000066400000000000000000000000601500544257300173300ustar00rootroot00000000000000(changelog)= ```{include} ../CHANGELOG.md ``` habluetooth-3.48.2/docs/conf.py000066400000000000000000000012201500544257300163550ustar00rootroot00000000000000# 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 = "3.48.2" # 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"] habluetooth-3.48.2/docs/contributing.md000066400000000000000000000000661500544257300201160ustar00rootroot00000000000000(contributing)= ```{include} ../CONTRIBUTING.md ``` habluetooth-3.48.2/docs/index.md000066400000000000000000000003501500544257300165120ustar00rootroot00000000000000# Welcome to habluetooth documentation! ```{toctree} :caption: Installation & Usage :maxdepth: 2 installation usage ``` ```{toctree} :caption: Project Info :maxdepth: 2 changelog contributing ``` ```{include} ../README.md ``` habluetooth-3.48.2/docs/installation.md000066400000000000000000000004151500544257300201060ustar00rootroot00000000000000(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. habluetooth-3.48.2/docs/make.bat000066400000000000000000000013751500544257300164760ustar00rootroot00000000000000@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 habluetooth-3.48.2/docs/usage.md000066400000000000000000000003261500544257300165120ustar00rootroot00000000000000(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 habluetooth-3.48.2/poetry.lock000066400000000000000000005226751500544257300163500ustar00rootroot00000000000000# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. [[package]] name = "aiooui" version = "0.1.9" description = "Async OUI lookups" optional = false python-versions = "<4.0,>=3.9" groups = ["main"] 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.3" description = "Screen-scraping library" optional = false python-versions = ">=3.7.0" groups = ["docs"] files = [ {file = "beautifulsoup4-4.13.3-py3-none-any.whl", hash = "sha256:99045d7d3f08f91f0d656bc9b7efbae189426cd913d830294a15eefa0ea4df16"}, {file = "beautifulsoup4-4.13.3.tar.gz", hash = "sha256:1bd32405dacc920b42b83ba01644747ed77456a65760e285fbc47633ceddaf8b"}, ] [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 = "0.22.3" description = "Bluetooth Low Energy platform Agnostic Klient" optional = false python-versions = "<3.14,>=3.8" groups = ["main"] files = [ {file = "bleak-0.22.3-py3-none-any.whl", hash = "sha256:1e62a9f5e0c184826e6c906e341d8aca53793e4596eeaf4e0b191e7aca5c461c"}, {file = "bleak-0.22.3.tar.gz", hash = "sha256:3149c3c19657e457727aa53d9d6aeb89658495822cd240afd8aeca4dd09c045c"}, ] [package.dependencies] bleak-winrt = {version = ">=1.2.0,<2.0.0", markers = "platform_system == \"Windows\" and python_version < \"3.12\""} dbus-fast = {version = ">=1.83.0,<3", markers = "platform_system == \"Linux\""} pyobjc-core = {version = ">=10.3,<11.0", markers = "platform_system == \"Darwin\""} pyobjc-framework-CoreBluetooth = {version = ">=10.3,<11.0", markers = "platform_system == \"Darwin\""} pyobjc-framework-libdispatch = {version = ">=10.3,<11.0", markers = "platform_system == \"Darwin\""} typing-extensions = {version = ">=4.7.0", markers = "python_version < \"3.12\""} winrt-runtime = {version = ">=2,<3", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""} "winrt-Windows.Devices.Bluetooth" = {version = ">=2,<3", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""} "winrt-Windows.Devices.Bluetooth.Advertisement" = {version = ">=2,<3", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""} "winrt-Windows.Devices.Bluetooth.GenericAttributeProfile" = {version = ">=2,<3", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""} "winrt-Windows.Devices.Enumeration" = {version = ">=2,<3", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""} "winrt-Windows.Foundation" = {version = ">=2,<3", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""} "winrt-Windows.Foundation.Collections" = {version = ">=2,<3", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""} "winrt-Windows.Storage.Streams" = {version = ">=2,<3", markers = "platform_system == \"Windows\" and python_version >= \"3.12\""} [[package]] name = "bleak-retry-connector" version = "3.10.0" description = "A connector for Bleak Clients that handles transient connection failures" optional = false python-versions = ">=3.10" groups = ["main"] files = [ {file = "bleak_retry_connector-3.10.0-py3-none-any.whl", hash = "sha256:caaf976320ef280f1145b557bf3b13697f71ef2c1070e1dc643709eb2d29fb1f"}, {file = "bleak_retry_connector-3.10.0.tar.gz", hash = "sha256:a95172bd56d2af677fb9e250291cde8c70d8f72381d423f64e48c828dffbc93b"}, ] [package.dependencies] bleak = {version = ">=0.21.0", 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 = "bleak-winrt" version = "1.2.0" description = "Python WinRT bindings for Bleak" optional = false python-versions = "*" groups = ["main"] markers = "platform_system == \"Windows\" and python_version < \"3.12\"" files = [ {file = "bleak-winrt-1.2.0.tar.gz", hash = "sha256:0577d070251b9354fc6c45ffac57e39341ebb08ead014b1bdbd43e211d2ce1d6"}, {file = "bleak_winrt-1.2.0-cp310-cp310-win32.whl", hash = "sha256:a2ae3054d6843ae0cfd3b94c83293a1dfd5804393977dd69bde91cb5099fc47c"}, {file = "bleak_winrt-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:677df51dc825c6657b3ae94f00bd09b8ab88422b40d6a7bdbf7972a63bc44e9a"}, {file = "bleak_winrt-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9449cdb942f22c9892bc1ada99e2ccce9bea8a8af1493e81fefb6de2cb3a7b80"}, {file = "bleak_winrt-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:98c1b5a6a6c431ac7f76aa4285b752fe14a1c626bd8a1dfa56f66173ff120bee"}, {file = "bleak_winrt-1.2.0-cp37-cp37m-win32.whl", hash = "sha256:623ac511696e1f58d83cb9c431e32f613395f2199b3db7f125a3d872cab968a4"}, {file = "bleak_winrt-1.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:13ab06dec55469cf51a2c187be7b630a7a2922e1ea9ac1998135974a7239b1e3"}, {file = "bleak_winrt-1.2.0-cp38-cp38-win32.whl", hash = "sha256:5a36ff8cd53068c01a795a75d2c13054ddc5f99ce6de62c1a97cd343fc4d0727"}, {file = "bleak_winrt-1.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:810c00726653a962256b7acd8edf81ab9e4a3c66e936a342ce4aec7dbd3a7263"}, {file = "bleak_winrt-1.2.0-cp39-cp39-win32.whl", hash = "sha256:dd740047a08925bde54bec357391fcee595d7b8ca0c74c87170a5cbc3f97aa0a"}, {file = "bleak_winrt-1.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:63130c11acfe75c504a79c01f9919e87f009f5e742bfc7b7a5c2a9c72bf591a7"}, ] [[package]] name = "bluetooth-adapters" version = "0.21.4" description = "Tools to enumerate and find Bluetooth Adapters" optional = false python-versions = ">=3.9" groups = ["main"] files = [ {file = "bluetooth_adapters-0.21.4-py3-none-any.whl", hash = "sha256:ce2e8139cc9d7b103c21654c6309507979e469aae3efebcaeee9923080b0569b"}, {file = "bluetooth_adapters-0.21.4.tar.gz", hash = "sha256:a5a809ef7ba95ee673a78704f90ce34612deb3696269d1a6fd61f98642b99dd3"}, ] [package.dependencies] aiooui = ">=0.1.1" bleak = ">=0.21.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.1" description = "Recover bluetooth adapters that are in an stuck state" optional = false python-versions = "<3.14,>=3.9" groups = ["main"] files = [ {file = "bluetooth_auto_recovery-1.5.1-py3-none-any.whl", hash = "sha256:59751902004cad9a84b5a674b051113d0a653374c1cec271945f2862b2b15c8f"}, {file = "bluetooth_auto_recovery-1.5.1.tar.gz", hash = "sha256:16eaa20e3f86cb2818ce75d107f57534559cdd82ba015b2741667bcac929d506"}, ] [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.0" description = "Tools for converting bluetooth data and packets" optional = false python-versions = ">=3.10" groups = ["main"] files = [ {file = "bluetooth_data_tools-1.28.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8904bd36c76ad5f287cc2ee1aad1d1fd683931db69d75326802caf8f1d44add1"}, {file = "bluetooth_data_tools-1.28.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5128eba3092f7bb838019af0812032286797cab30f928d779385828433156932"}, {file = "bluetooth_data_tools-1.28.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89377ef7e320c925d902f2d76aa17b81ee7802156b55d2b813e8c73cb266c186"}, {file = "bluetooth_data_tools-1.28.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79016b49c3590a2a5b1b524b785f4e9cd11ad85927af7025f678e87145e6affe"}, {file = "bluetooth_data_tools-1.28.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5b1014e9bd8924b3de4c30bd7ac8f49583c925a6eb49338ece4dbb0970a6d9af"}, {file = "bluetooth_data_tools-1.28.0-cp310-cp310-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f78b9e7cb3cda03bd51118dd648465faa973c35bcf7804546c45c55864a915d"}, {file = "bluetooth_data_tools-1.28.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:08d06fe8f574de65656d44dd21777313e776aa55379b3bf6d291f07ad6a61f74"}, {file = "bluetooth_data_tools-1.28.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:577e37a76cf23edc1e3db61edb376db17bc3e822614016a1940d901e42b17305"}, {file = "bluetooth_data_tools-1.28.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b601ddf5aa7edcfb7cc0a28653412e67b1a9b6dac392400b4d3c89a741198d0a"}, {file = "bluetooth_data_tools-1.28.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4847d9d2fc9d3cd316f8bdd48791b712a91f09965669481a0a86d2404e9d0a57"}, {file = "bluetooth_data_tools-1.28.0-cp310-cp310-win32.whl", hash = "sha256:baaba54567151504adcae49a05657d9e0593d681bfa267fc9ba3934f4b4d67a1"}, {file = "bluetooth_data_tools-1.28.0-cp310-cp310-win_amd64.whl", hash = "sha256:a9c7f95b4e473b7dd4f512f88405ba531d7fc1ef8e9eab9e510781066749c9c7"}, {file = "bluetooth_data_tools-1.28.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:68c6103620326173004aa4e2cb139cc2862140d0667e75fa8564287b9e32ae17"}, {file = "bluetooth_data_tools-1.28.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b54494d61bd3c683839c499afe6f03379b229903c6bb7f35d5b4156e9b988b40"}, {file = "bluetooth_data_tools-1.28.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:188b466850446c7b0d8ef1a23e368dbf59c78071d22028b89614ca69852a5924"}, {file = "bluetooth_data_tools-1.28.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:986c67d4ca321c84c97de3a51f0ccf97934dd31187a2c6db63a31b877de962bd"}, {file = "bluetooth_data_tools-1.28.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5206ee861fd024b4415b9af4b58ba116223ddf02cc62730b162594ea6b4da89"}, {file = "bluetooth_data_tools-1.28.0-cp311-cp311-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50f4efa2520856dffb10105f4eb0dd1b8baf35d94ec0ab5301c7526787075571"}, {file = "bluetooth_data_tools-1.28.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:64ef627fd45176ad2798cdaba430e45739018dd1b72064a7643d3fe2ceafb228"}, {file = "bluetooth_data_tools-1.28.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:4d2ee2ce16efcf5357a5c784c377a4387aa9e47deea5374c9b5822fb1b828beb"}, {file = "bluetooth_data_tools-1.28.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:974d31c5fbe5d0d1eb4ab42b08d8d02d5c1bebaaa7c8ec04329e16ebb7ead5c4"}, {file = "bluetooth_data_tools-1.28.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:772e15e78a4f8febd1d072c52ed711add7e0095d385503ae30cf16faf1d06e07"}, {file = "bluetooth_data_tools-1.28.0-cp311-cp311-win32.whl", hash = "sha256:a3b8805e214eebc28f4f874e24fc699642a4ac5d90f5704d96b5234d05a5eaef"}, {file = "bluetooth_data_tools-1.28.0-cp311-cp311-win_amd64.whl", hash = "sha256:e869f224b554e87aac864707b2617e405f18229b71a285deea3f35832f656a5c"}, {file = "bluetooth_data_tools-1.28.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c8a5e50677471623d88a4f343e7b6d652931140900287f15ad18831014d9590d"}, {file = "bluetooth_data_tools-1.28.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3e156b24c31581b3b719753b42d3b60b3f6099052e1aefa2597e5d6e8f5105f8"}, {file = "bluetooth_data_tools-1.28.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fa35b74d2dc9caf272ff1b4bad9535d0e7e1b9a802566fdb68af604bf641047"}, {file = "bluetooth_data_tools-1.28.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d6aba822495d8800c951253d07a7464b44554d96d9c3a6556a15c9d3d88d89b"}, {file = "bluetooth_data_tools-1.28.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85a26abd3265fb4940fe0813b216da62b9c071b7a838b041d7bde1c7acd22efc"}, {file = "bluetooth_data_tools-1.28.0-cp312-cp312-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c67ca909c91c6d083d20c7ce80de942769d9e79cd35c17452ac69b4017d17885"}, {file = "bluetooth_data_tools-1.28.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:85b0af53ef31c919e90d339ed10a2f028895bc40adec7643321f51e9eb9bec27"}, {file = "bluetooth_data_tools-1.28.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:36eb3d010e644646c244a1a133df08e2d6ee3d7a5e44a545f19d5de52172d694"}, {file = "bluetooth_data_tools-1.28.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a18a91101e1d516ed9dc321e0e39b8843e11bb01b1f35a6e9bff186f486b147d"}, {file = "bluetooth_data_tools-1.28.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0c4d13cf3d72920aa8505bfae21c1f97833dbbb392ed498b9031721d4ef62ee9"}, {file = "bluetooth_data_tools-1.28.0-cp312-cp312-win32.whl", hash = "sha256:99738c528e3b4683f5581c2021c1117a34c0a9257723c63dbb910204706b57a8"}, {file = "bluetooth_data_tools-1.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:0993eae6596a5a1659fa1b9a4cc06dc456579d07611d81b557648a06c64a8068"}, {file = "bluetooth_data_tools-1.28.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:59761b79523438b18d4e87ee92f6c69497289209ad81cd19b97b51f477710294"}, {file = "bluetooth_data_tools-1.28.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9822bf7020df321d9f77a1edfc8a399a187cd5ddf2b67a1fa0d831ca35003696"}, {file = "bluetooth_data_tools-1.28.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0d7d2694f3c5149fd91d890a16d66af5b58a6169fc7cd8566260f8d1167a814"}, {file = "bluetooth_data_tools-1.28.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe8594d2e7816b9201a7c17b48cd2c77eb27c93b5232e1a3e9d3bf708fa4c558"}, {file = "bluetooth_data_tools-1.28.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e73d84cfc4e15dfb9f5b9f924874f11738440789a0235e89ddd6c84f32dd2e7"}, {file = "bluetooth_data_tools-1.28.0-cp313-cp313-manylinux_2_31_armv7l.manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c29ce3be07b89a5fcc158c981e48d2d0ef716a709609d5a819a68a663a5979"}, {file = "bluetooth_data_tools-1.28.0-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:feb41dc67e94edb6b68cbe2c29a2ab4899526504019f973afb20abb3385061a3"}, {file = "bluetooth_data_tools-1.28.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f2d34bf815fc175b23cb9eddae2697ccc1fc778105a0c5ddc7d446bef008f29"}, {file = "bluetooth_data_tools-1.28.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:542ac3d039a33f7bc0e0ee390e18889165b0728d512588d0b8971e55fb0b9813"}, {file = "bluetooth_data_tools-1.28.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6d587f0657f20285c52656343e3da4516473b09cf51c1702dbe2cb6fd2d5777f"}, {file = "bluetooth_data_tools-1.28.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e37a22775c42d12c6efd79de812df1a6cc43566357d7378bd8a9b9a043001c9f"}, {file = "bluetooth_data_tools-1.28.0-cp313-cp313-win32.whl", hash = "sha256:64d2bc2519438e231e0a6043c4d8496e1e578998d6d38cec927851b65c192e93"}, {file = "bluetooth_data_tools-1.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:3153d29eda734cad41f8f5edaa098c5acaec9b16fef71b46507569986fc2be4c"}, {file = "bluetooth_data_tools-1.28.0.tar.gz", hash = "sha256:ebfb3ff006da96cc34769e8b4d7b05982db5dd85b7dd7dda7ece9fa1fc34f352"}, ] [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.1.31" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" groups = ["docs"] files = [ {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, ] [[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.1" 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.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, ] [[package]] name = "click" version = "8.1.8" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" groups = ["docs"] files = [ {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, ] [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.8.0" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "coverage-7.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2931f66991175369859b5fd58529cd4b73582461877ecfd859b6549869287ffe"}, {file = "coverage-7.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52a523153c568d2c0ef8826f6cc23031dc86cffb8c6aeab92c4ff776e7951b28"}, {file = "coverage-7.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c8a5c139aae4c35cbd7cadca1df02ea8cf28a911534fc1b0456acb0b14234f3"}, {file = "coverage-7.8.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a26c0c795c3e0b63ec7da6efded5f0bc856d7c0b24b2ac84b4d1d7bc578d676"}, {file = "coverage-7.8.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:821f7bcbaa84318287115d54becb1915eece6918136c6f91045bb84e2f88739d"}, {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a321c61477ff8ee705b8a5fed370b5710c56b3a52d17b983d9215861e37b642a"}, {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ed2144b8a78f9d94d9515963ed273d620e07846acd5d4b0a642d4849e8d91a0c"}, {file = "coverage-7.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:042e7841a26498fff7a37d6fda770d17519982f5b7d8bf5278d140b67b61095f"}, {file = "coverage-7.8.0-cp310-cp310-win32.whl", hash = "sha256:f9983d01d7705b2d1f7a95e10bbe4091fabc03a46881a256c2787637b087003f"}, {file = "coverage-7.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:5a570cd9bd20b85d1a0d7b009aaf6c110b52b5755c17be6962f8ccd65d1dbd23"}, {file = "coverage-7.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7ac22a0bb2c7c49f441f7a6d46c9c80d96e56f5a8bc6972529ed43c8b694e27"}, {file = "coverage-7.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf13d564d310c156d1c8e53877baf2993fb3073b2fc9f69790ca6a732eb4bfea"}, {file = "coverage-7.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5761c70c017c1b0d21b0815a920ffb94a670c8d5d409d9b38857874c21f70d7"}, {file = "coverage-7.8.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ff52d790c7e1628241ffbcaeb33e07d14b007b6eb00a19320c7b8a7024c040"}, {file = "coverage-7.8.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d39fc4817fd67b3915256af5dda75fd4ee10621a3d484524487e33416c6f3543"}, {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b44674870709017e4b4036e3d0d6c17f06a0e6d4436422e0ad29b882c40697d2"}, {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f99eb72bf27cbb167b636eb1726f590c00e1ad375002230607a844d9e9a2318"}, {file = "coverage-7.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b571bf5341ba8c6bc02e0baeaf3b061ab993bf372d982ae509807e7f112554e9"}, {file = "coverage-7.8.0-cp311-cp311-win32.whl", hash = "sha256:e75a2ad7b647fd8046d58c3132d7eaf31b12d8a53c0e4b21fa9c4d23d6ee6d3c"}, {file = "coverage-7.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:3043ba1c88b2139126fc72cb48574b90e2e0546d4c78b5299317f61b7f718b78"}, {file = "coverage-7.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bbb5cc845a0292e0c520656d19d7ce40e18d0e19b22cb3e0409135a575bf79fc"}, {file = "coverage-7.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4dfd9a93db9e78666d178d4f08a5408aa3f2474ad4d0e0378ed5f2ef71640cb6"}, {file = "coverage-7.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f017a61399f13aa6d1039f75cd467be388d157cd81f1a119b9d9a68ba6f2830d"}, {file = "coverage-7.8.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0915742f4c82208ebf47a2b154a5334155ed9ef9fe6190674b8a46c2fb89cb05"}, {file = "coverage-7.8.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a40fcf208e021eb14b0fac6bdb045c0e0cab53105f93ba0d03fd934c956143a"}, {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a1f406a8e0995d654b2ad87c62caf6befa767885301f3b8f6f73e6f3c31ec3a6"}, {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77af0f6447a582fdc7de5e06fa3757a3ef87769fbb0fdbdeba78c23049140a47"}, {file = "coverage-7.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f2d32f95922927186c6dbc8bc60df0d186b6edb828d299ab10898ef3f40052fe"}, {file = "coverage-7.8.0-cp312-cp312-win32.whl", hash = "sha256:769773614e676f9d8e8a0980dd7740f09a6ea386d0f383db6821df07d0f08545"}, {file = "coverage-7.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:e5d2b9be5b0693cf21eb4ce0ec8d211efb43966f6657807f6859aab3814f946b"}, {file = "coverage-7.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd"}, {file = "coverage-7.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00"}, {file = "coverage-7.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64"}, {file = "coverage-7.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067"}, {file = "coverage-7.8.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008"}, {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733"}, {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323"}, {file = "coverage-7.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3"}, {file = "coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d"}, {file = "coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487"}, {file = "coverage-7.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25"}, {file = "coverage-7.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42"}, {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502"}, {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1"}, {file = "coverage-7.8.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4"}, {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73"}, {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a"}, {file = "coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883"}, {file = "coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada"}, {file = "coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257"}, {file = "coverage-7.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa260de59dfb143af06dcf30c2be0b200bed2a73737a8a59248fcb9fa601ef0f"}, {file = "coverage-7.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:96121edfa4c2dfdda409877ea8608dd01de816a4dc4a0523356067b305e4e17a"}, {file = "coverage-7.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8af63b9afa1031c0ef05b217faa598f3069148eeee6bb24b79da9012423b82"}, {file = "coverage-7.8.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89b1f4af0d4afe495cd4787a68e00f30f1d15939f550e869de90a86efa7e0814"}, {file = "coverage-7.8.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94ec0be97723ae72d63d3aa41961a0b9a6f5a53ff599813c324548d18e3b9e8c"}, {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8a1d96e780bdb2d0cbb297325711701f7c0b6f89199a57f2049e90064c29f6bd"}, {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f1d8a2a57b47142b10374902777e798784abf400a004b14f1b0b9eaf1e528ba4"}, {file = "coverage-7.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cf60dd2696b457b710dd40bf17ad269d5f5457b96442f7f85722bdb16fa6c899"}, {file = "coverage-7.8.0-cp39-cp39-win32.whl", hash = "sha256:be945402e03de47ba1872cd5236395e0f4ad635526185a930735f66710e1bd3f"}, {file = "coverage-7.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:90e7fbc6216ecaffa5a880cdc9c77b7418c1dcb166166b78dbc630d07f278cc3"}, {file = "coverage-7.8.0-pp39.pp310.pp311-none-any.whl", hash = "sha256:b8194fb8e50d556d5849753de991d390c5a1edeeba50f68e3a9253fbd8bf8ccd"}, {file = "coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7"}, {file = "coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501"}, ] [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" version = "44.0.2" 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-44.0.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7"}, {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1"}, {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb"}, {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843"}, {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5"}, {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c"}, {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a"}, {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308"}, {file = "cryptography-44.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688"}, {file = "cryptography-44.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7"}, {file = "cryptography-44.0.2-cp37-abi3-win32.whl", hash = "sha256:51e4de3af4ec3899d6d178a8c005226491c27c4ba84101bfb59c901e10ca9f79"}, {file = "cryptography-44.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:c505d61b6176aaf982c5717ce04e87da5abc9a36a5b39ac03905c4aafe8de7aa"}, {file = "cryptography-44.0.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e0ddd63e6bf1161800592c71ac794d3fb8001f2caebe0966e77c5234fa9efc3"}, {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639"}, {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd"}, {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181"}, {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea"}, {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699"}, {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9"}, {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23"}, {file = "cryptography-44.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922"}, {file = "cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4"}, {file = "cryptography-44.0.2-cp39-abi3-win32.whl", hash = "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5"}, {file = "cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6"}, {file = "cryptography-44.0.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:af4ff3e388f2fa7bff9f7f2b31b87d5651c45731d3e8cfa0944be43dff5cfbdb"}, {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0529b1d5a0105dd3731fa65680b45ce49da4d8115ea76e9da77a875396727b41"}, {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7ca25849404be2f8e4b3c59483d9d3c51298a22c1c61a0e84415104dacaf5562"}, {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:268e4e9b177c76d569e8a145a6939eca9a5fec658c932348598818acf31ae9a5"}, {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9eb9d22b0a5d8fd9925a7764a054dca914000607dff201a24c791ff5c799e1fa"}, {file = "cryptography-44.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2bf7bf75f7df9715f810d1b038870309342bff3069c5bd8c6b96128cb158668d"}, {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:909c97ab43a9c0c0b0ada7a1281430e4e5ec0458e6d9244c0e821bbf152f061d"}, {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:96e7a5e9d6e71f9f4fca8eebfd603f8e86c5225bb18eb621b2c1e50b290a9471"}, {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d1b3031093a366ac767b3feb8bcddb596671b3aaff82d4050f984da0c248b615"}, {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:04abd71114848aa25edb28e225ab5f268096f44cf0127f3d36975bdf1bdf3390"}, {file = "cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0"}, ] [package.dependencies] cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} [package.extras] docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0) ; python_version >= \"3.8\""] docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_version >= \"3.8\""] pep8test = ["check-sdist ; python_version >= \"3.8\"", "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 (==44.0.2)", "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.1" description = "A faster version of dbus-next" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ {file = "dbus_fast-2.44.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c78a004ba43aeaf203a19169d2b4be238375905645999da30cb0da730df80cf2"}, {file = "dbus_fast-2.44.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65a634286651398f3f1326e8200fc54289d52c2c00249d29cacfc691660a5da1"}, {file = "dbus_fast-2.44.1-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:0c4a128f8b29941307fc5722f37a1bb87ddcf733188d917ab374d9da0c6e1ce7"}, {file = "dbus_fast-2.44.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:adaf459fbce22a63d3578f3ec782c6978edf975eb06d71fb5b7a690496cf6bbe"}, {file = "dbus_fast-2.44.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:de871cf722c436bdcceb96b2a3af7084e1fa468f7916ae278ec8ec49a6fa7eef"}, {file = "dbus_fast-2.44.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b40863de172031bcc02f54c6f05cccb0b882dc2e1b09e11314a8ccf38c558760"}, {file = "dbus_fast-2.44.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8b7ae16555df6b56d3befcc51e036779ef47c0e954fdb9fb0821ac25212aefe9"}, {file = "dbus_fast-2.44.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a220a28e88062a2548f0c6da9eb15fb7e3af70eae56729fc3795ce3e3fba057d"}, {file = "dbus_fast-2.44.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ec5db912bd4cfeadf7134163d6dde684271cd44cf26e3b4720107f3de406623"}, {file = "dbus_fast-2.44.1-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:6ad99f626837753b39a39e09facd2091ee4851ee1eb6ebec5fa9a9a231734254"}, {file = "dbus_fast-2.44.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7aa157f689a114bfb5367c55884d35e25d57cf25202a6590ce05010f929e7df"}, {file = "dbus_fast-2.44.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f961d8bcad80359f24c0156b3094f58a87d583d56139ee50922fe5894b6797cf"}, {file = "dbus_fast-2.44.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1f38fb5c31846c3ada8fc2b693d8d19953d376a9ea21079e3686e93faa1f8a0f"}, {file = "dbus_fast-2.44.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:35e3cde53cc9180ce95c6c84a1e8d1ded429031e4a0a182606e8d22cf57d3294"}, {file = "dbus_fast-2.44.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f30fb09f1ea13658fb4316511e27d6b94f8363b16f2d093efe73e6e289b740"}, {file = "dbus_fast-2.44.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3dd0f8d41f6ab9d4a782c116470bc319d690f9b50c97b6debc6d1fef08e4615a"}, {file = "dbus_fast-2.44.1-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:9d6e386658343db380b9e4e81b3bf4e3c17135dbb5889173b1f2582b675b9a8c"}, {file = "dbus_fast-2.44.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3bd27563c11219b6fde7a5458141d860d8445c2defb036bab360d1f9bf1dfae0"}, {file = "dbus_fast-2.44.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0272784aceac821dd63c8187a8860179061a850269617ff5c5bd25ca37bf9307"}, {file = "dbus_fast-2.44.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:eed613a909a45f0e0a415c88b373024f007a9be56b1316812ed616d69a3b9161"}, {file = "dbus_fast-2.44.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0d4288f2cba4f8309dcfd9f4392e0f4f2b5be6c796dfdb0c5e03228b1ab649b1"}, {file = "dbus_fast-2.44.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50a9a4c6921f4b7446717fb4869750f54b561ce486b25b36550cb2a910c988d9"}, {file = "dbus_fast-2.44.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89dc5db158bf9838979f732acc39e0e1ecd7e3295a09fa8adb93b09c097615a4"}, {file = "dbus_fast-2.44.1-cp313-cp313-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:f11878c0c089d278861e48c02db8002496c2233b0f605b5630ef61f0b7fb0ea3"}, {file = "dbus_fast-2.44.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd81f483b3ffb71e88478cfabccc1fab8d7154fccb1c661bfafcff9b0cfd996"}, {file = "dbus_fast-2.44.1-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:ad499de96a991287232749c98a59f2436ed260f6fd9ad4cb3b04a4b1bbbef148"}, {file = "dbus_fast-2.44.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:36c44286b11e83977cd29f9551b66b446bb6890dff04585852d975aa3a038ca2"}, {file = "dbus_fast-2.44.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:89f2f6eccbb0e464b90e5a8741deb9d6a91873eeb41a8c7b963962b39eb1e0cd"}, {file = "dbus_fast-2.44.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bb74a227b071e1a7c517bf3a3e4a5a0a2660620084162e74f15010075534c9d5"}, {file = "dbus_fast-2.44.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e3719399e687359b0ef66af1b720661dd4f12059db1c4f506e678569a2256b4"}, {file = "dbus_fast-2.44.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:806450623ef3f8df846524da7e448edc8174261a01cfd5dfda92e3df89c0de10"}, {file = "dbus_fast-2.44.1-cp39-cp39-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:55ad499b7ef08cb76fce9c9fdcdd6589d2ebfc7e53b3d261d8f40c6d97a8d901"}, {file = "dbus_fast-2.44.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55d717865219ec2ae9977b6d067c05261cdc3ef6205c687c8bb92b3437886e58"}, {file = "dbus_fast-2.44.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:39d4cc61e491e11912f76d70cc1c47387ab4f2e5b71f34bfa13eb11aa6026268"}, {file = "dbus_fast-2.44.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9b3b10151f1140f7b6dd47a89fc37edd05d6213be0a1748eadba82fc144c05c2"}, {file = "dbus_fast-2.44.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:33772c223f5cef1bacc298e83dc04b27b3a47065b245fde766fcc126e761dca7"}, {file = "dbus_fast-2.44.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:80e3f42f982af45bcfa0ff23e808f3aa54a45fe4bf43aadd3beb5ace816fba76"}, {file = "dbus_fast-2.44.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f29a81d86c9ce3020a5df8c1e5557edaa00e1e00c9804ec874d46c99d967a686"}, {file = "dbus_fast-2.44.1-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:5dec134715457601c0fa8df3040a56d319de1a152464ae4d4bfc53bbb5c02e04"}, {file = "dbus_fast-2.44.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:893509b516f2f24b4e3f09a6b1f3a30f856cf237cd773cdc505ea7ab4fa3c863"}, {file = "dbus_fast-2.44.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:db81275d708774f6a17c89f2e063398c0deb358c4d22b663a3dd99861f6683a4"}, {file = "dbus_fast-2.44.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:161a3e6fc8783c30c9feb072e09604d96ec0c465b06bd35b6acc1a0316bd2a27"}, {file = "dbus_fast-2.44.1-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:67febe6454e714d85a532bd84969001ed948bbaf1699a7e1e4c6abb5508c9522"}, {file = "dbus_fast-2.44.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:890f0fc046d5db66524ddedeca8c14b65739fbbf32d6488175c07428362bf250"}, {file = "dbus_fast-2.44.1.tar.gz", hash = "sha256:b027e96c39ed5622bb54d811dcdbbe9d9d6edec3454808a85a1ceb1867d9e25c"}, ] [[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.1" description = "Let your Python tests travel through time" optional = false python-versions = ">=3.7" groups = ["dev"] files = [ {file = "freezegun-1.5.1-py3-none-any.whl", hash = "sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1"}, {file = "freezegun-1.5.1.tar.gz", hash = "sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9"}, ] [package.dependencies] python-dateutil = ">=2.7" [[package]] name = "furo" version = "2024.8.6" description = "A clean customisable Sphinx documentation theme." optional = false python-versions = ">=3.8" groups = ["docs"] files = [ {file = "furo-2024.8.6-py3-none-any.whl", hash = "sha256:6cd97c58b47813d3619e63e9081169880fbe331f0ca883c871ff1f3f11814f5c"}, {file = "furo-2024.8.6.tar.gz", hash = "sha256:b63e4cee8abfc3136d3bc03a3d45a76a850bada4d6374d24c1716b0e01394a01"}, ] [package.dependencies] 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 = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" groups = ["dev", "docs"] files = [ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, ] [[package]] name = "pluggy" version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, ] [package.extras] dev = ["pre-commit", "tox"] testing = ["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.1" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" groups = ["dev", "docs"] files = [ {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, ] [package.extras] windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyobjc-core" version = "10.3.2" description = "Python<->ObjC Interoperability Module" optional = false python-versions = ">=3.8" groups = ["main"] markers = "platform_system == \"Darwin\"" files = [ {file = "pyobjc_core-10.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:acb40672d682851a5c7fd84e5041c4d069b62076168d72591abb5fcc871bb039"}, {file = "pyobjc_core-10.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cea5e77659619ad93c782ca07644b6efe7d7ec6f59e46128843a0a87c1af511a"}, {file = "pyobjc_core-10.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:16644a92fb9661de841ba6115e5354db06a1d193a5e239046e840013c7b3874d"}, {file = "pyobjc_core-10.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:76b8b911d94501dac89821df349b1860bb770dce102a1a293f524b5b09dd9462"}, {file = "pyobjc_core-10.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8c6288fdb210b64115760a4504efbc4daffdc390d309e9318eb0e3e3b78d2828"}, {file = "pyobjc_core-10.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:87901e9f7032f33eb4fa884e407bf2744d5a0791b379bfca783982a02be3f7fb"}, {file = "pyobjc_core-10.3.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:636971ab48a4198ca129e149fe58ccf85a7b4a9b93d27f5ae920d88eb2655431"}, {file = "pyobjc_core-10.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:48e9ac3af42b2340dae709a8b894f5ef7e5132d8546adcd1797cffcc449dabdc"}, {file = "pyobjc_core-10.3.2.tar.gz", hash = "sha256:dbf1475d864ce594288ce03e94e3a98dc7f0e4639971eb1e312bdf6661c21e0e"}, ] [[package]] name = "pyobjc-framework-cocoa" version = "10.3.2" description = "Wrappers for the Cocoa frameworks on macOS" optional = false python-versions = ">=3.8" groups = ["main"] markers = "platform_system == \"Darwin\"" files = [ {file = "pyobjc_framework_Cocoa-10.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:61f44c2adab28fdf3aa3d593c9497a2d9ceb9583ed9814adb48828c385d83ff4"}, {file = "pyobjc_framework_Cocoa-10.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7caaf8b260e81b27b7b787332846f644b9423bfc1536f6ec24edbde59ab77a87"}, {file = "pyobjc_framework_Cocoa-10.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c49e99fc4b9e613fb308651b99d52a8a9ae9916c8ef27aa2f5d585b6678a59bf"}, {file = "pyobjc_framework_Cocoa-10.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f1161b5713f9b9934c12649d73a6749617172e240f9431eff9e22175262fdfda"}, {file = "pyobjc_framework_Cocoa-10.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:08e48b9ee4eb393447b2b781d16663b954bd10a26927df74f92e924c05568d89"}, {file = "pyobjc_framework_Cocoa-10.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7faa448d2038ae0e0287a326d390002e744bb6470e45995e2dbd16c892e4495a"}, {file = "pyobjc_framework_Cocoa-10.3.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:fcd53fee2be9708576617994b107aedc2c40824b648cd51e780e8399c0a447b6"}, {file = "pyobjc_framework_Cocoa-10.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:838fcf0d10674bde9ff64a3f20c0e188f2dc5e804476d80509b81c4ac1dabc59"}, {file = "pyobjc_framework_cocoa-10.3.2.tar.gz", hash = "sha256:673968e5435845bef969bfe374f31a1a6dc660c98608d2b84d5cae6eafa5c39d"}, ] [package.dependencies] pyobjc-core = ">=10.3.2" [[package]] name = "pyobjc-framework-corebluetooth" version = "10.3.2" description = "Wrappers for the framework CoreBluetooth on macOS" optional = false python-versions = ">=3.8" groups = ["main"] markers = "platform_system == \"Darwin\"" files = [ {file = "pyobjc_framework_CoreBluetooth-10.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:af3e2f935a6a7e5b009b4cf63c64899592a7b46c3ddcbc8f2e28848842ef65f4"}, {file = "pyobjc_framework_CoreBluetooth-10.3.2-cp36-abi3-macosx_10_13_universal2.whl", hash = "sha256:973b78f47c7e2209a475e60bcc7d1b4a87be6645d39b4e8290ee82640e1cc364"}, {file = "pyobjc_framework_CoreBluetooth-10.3.2-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:4bafdf1be15eae48a4878dbbf1bf19877ce28cbbba5baa0267a9564719ee736e"}, {file = "pyobjc_framework_CoreBluetooth-10.3.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:4d7dc7494de66c850bda7b173579df7481dc97046fa229d480fe9bf90b2b9651"}, {file = "pyobjc_framework_CoreBluetooth-10.3.2-cp36-abi3-macosx_11_0_universal2.whl", hash = "sha256:62e09e730f4d98384f1b6d44718812195602b3c82d5c78e09f60e8a934e7b266"}, {file = "pyobjc_framework_corebluetooth-10.3.2.tar.gz", hash = "sha256:c0a077bc3a2466271efa382c1e024630bc43cc6f9ab8f3f97431ad08b1ad52bb"}, ] [package.dependencies] pyobjc-core = ">=10.3.2" pyobjc-framework-Cocoa = ">=10.3.2" [[package]] name = "pyobjc-framework-libdispatch" version = "10.3.2" description = "Wrappers for libdispatch on macOS" optional = false python-versions = ">=3.8" groups = ["main"] markers = "platform_system == \"Darwin\"" files = [ {file = "pyobjc_framework_libdispatch-10.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:35233a8b1135567c7696087f924e398799467c7f129200b559e8e4fa777af860"}, {file = "pyobjc_framework_libdispatch-10.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:061f6aa0f88d11d993e6546ec734303cb8979f40ae0f5cd23541236a6b426abd"}, {file = "pyobjc_framework_libdispatch-10.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:6bb528f34538f35e1b79d839dbfc398dd426990e190d9301fe2d811fddc3da62"}, {file = "pyobjc_framework_libdispatch-10.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1357729d5fded08fbf746834ebeef27bee07d6acb991f3b8366e8f4319d882c4"}, {file = "pyobjc_framework_libdispatch-10.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:210398f9e1815ceeff49b578bf51c2d6a4a30d4c33f573da322f3d7da1add121"}, {file = "pyobjc_framework_libdispatch-10.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e7ae5988ac0b369ad40ce5497af71864fac45c289fa52671009b427f03d6871f"}, {file = "pyobjc_framework_libdispatch-10.3.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:f9d51d52dff453a4b19c096171a6cd31dd5e665371c00c1d72d480e1c22cd3d4"}, {file = "pyobjc_framework_libdispatch-10.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ef755bcabff2ea8db45603a8294818e0eeae85bf0b7b9d59e42f5947a26e33b9"}, {file = "pyobjc_framework_libdispatch-10.3.2.tar.gz", hash = "sha256:e9f4311fbf8df602852557a98d2a64f37a9d363acf4d75634120251bbc7b7304"}, ] [package.dependencies] pyobjc-core = ">=10.3.2" pyobjc-framework-Cocoa = ">=10.3.2" [[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.3.5" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, ] [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} iniconfig = "*" packaging = "*" pluggy = ">=1.5,<2" [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" version = "0.26.0" description = "Pytest support for asyncio" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0"}, {file = "pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f"}, ] [package.dependencies] pytest = ">=8.2,<9" [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-codspeed" version = "3.2.0" description = "Pytest plugin to create CodSpeed benchmarks" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pytest_codspeed-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5165774424c7ab8db7e7acdb539763a0e5657996effefdf0664d7fd95158d34"}, {file = "pytest_codspeed-3.2.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bd55f92d772592c04a55209950c50880413ae46876e66bd349ef157075ca26c"}, {file = "pytest_codspeed-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf6f56067538f4892baa8d7ab5ef4e45bb59033be1ef18759a2c7fc55b32035"}, {file = "pytest_codspeed-3.2.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a687b05c3d145642061b45ea78e47e12f13ce510104d1a2cda00eee0e36f58"}, {file = "pytest_codspeed-3.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46a1afaaa1ac4c2ca5b0700d31ac46d80a27612961d031067d73c6ccbd8d3c2b"}, {file = "pytest_codspeed-3.2.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c48ce3af3dfa78413ed3d69d1924043aa1519048dbff46edccf8f35a25dab3c2"}, {file = "pytest_codspeed-3.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66692506d33453df48b36a84703448cb8b22953eea51f03fbb2eb758dc2bdc4f"}, {file = "pytest_codspeed-3.2.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:479774f80d0bdfafa16112700df4dbd31bf2a6757fac74795fd79c0a7b3c389b"}, {file = "pytest_codspeed-3.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:109f9f4dd1088019c3b3f887d003b7d65f98a7736ca1d457884f5aa293e8e81c"}, {file = "pytest_codspeed-3.2.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2f69a03b52c9bb041aec1b8ee54b7b6c37a6d0a948786effa4c71157765b6da"}, {file = "pytest_codspeed-3.2.0-py3-none-any.whl", hash = "sha256:54b5c2e986d6a28e7b0af11d610ea57bd5531cec8326abe486f1b55b09d91c39"}, {file = "pytest_codspeed-3.2.0.tar.gz", hash = "sha256:f9d1b1a3b2c69cdc0490a1e8b1ced44bffbd0e8e21d81a7160cfdd923f6e8155"}, ] [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.6.5,<0.7.0)"] test = ["pytest (>=7.0,<8.0)", "pytest-cov (>=4.0.0,<4.1.0)"] [[package]] name = "pytest-cov" version = "6.1.1" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde"}, {file = "pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a"}, ] [package.dependencies] coverage = {version = ">=7.5", extras = ["toml"]} pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "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.3" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" groups = ["docs"] files = [ {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, ] [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 = "2.2.0" description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." optional = false python-versions = "*" groups = ["docs"] files = [ {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, ] [[package]] name = "soupsieve" version = "2.6" description = "A modern CSS selector implementation for Beautiful Soup." optional = false python-versions = ">=3.8" groups = ["docs"] files = [ {file = "soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9"}, {file = "soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb"}, ] [[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 = "2024.10.3" description = "Rebuild Sphinx documentation on changes, with hot reloading in the browser." optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "sphinx_autobuild-2024.10.3-py3-none-any.whl", hash = "sha256:158e16c36f9d633e613c9aaf81c19b0fc458ca78b112533b20dafcda430d60fa"}, {file = "sphinx_autobuild-2024.10.3.tar.gz", hash = "sha256:248150f8f333e825107b6d4b86113ab28fa51750e5f9ae63b59dc339be951fb1"}, ] [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.46.2" description = "The little ASGI library that shines." optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35"}, {file = "starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5"}, ] [package.dependencies] anyio = ">=3.6.2,<5" [package.extras] full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] [[package]] name = "typing-extensions" version = "4.13.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" groups = ["main", "docs"] files = [ {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, ] markers = {main = "python_version < \"3.12\""} [[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.4.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.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813"}, {file = "urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466"}, ] [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.34.1" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "uvicorn-0.34.1-py3-none-any.whl", hash = "sha256:984c3a8c7ca18ebaad15995ee7401179212c59521e67bfc390c07fa2b8d2e065"}, {file = "uvicorn-0.34.1.tar.gz", hash = "sha256:af981725fc4b7ffc5cb3b0e9eda6258a90c4b52cb2a83ce567ae0a7ae1757afc"}, ] [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.14.0,!=0.15.0,!=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.0.5" 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.0.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:5c40fe7dd9e5f81e0847b1ea64e1f5dd79dd61afbedb57759df06767ac719b40"}, {file = "watchfiles-1.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8c0db396e6003d99bb2d7232c957b5f0b5634bbd1b24e381a5afcc880f7373fb"}, {file = "watchfiles-1.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b551d4fb482fc57d852b4541f911ba28957d051c8776e79c3b4a51eb5e2a1b11"}, {file = "watchfiles-1.0.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:830aa432ba5c491d52a15b51526c29e4a4b92bf4f92253787f9726fe01519487"}, {file = "watchfiles-1.0.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a16512051a822a416b0d477d5f8c0e67b67c1a20d9acecb0aafa3aa4d6e7d256"}, {file = "watchfiles-1.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe0cbc787770e52a96c6fda6726ace75be7f840cb327e1b08d7d54eadc3bc85"}, {file = "watchfiles-1.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d363152c5e16b29d66cbde8fa614f9e313e6f94a8204eaab268db52231fe5358"}, {file = "watchfiles-1.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ee32c9a9bee4d0b7bd7cbeb53cb185cf0b622ac761efaa2eba84006c3b3a614"}, {file = "watchfiles-1.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29c7fd632ccaf5517c16a5188e36f6612d6472ccf55382db6c7fe3fcccb7f59f"}, {file = "watchfiles-1.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8e637810586e6fe380c8bc1b3910accd7f1d3a9a7262c8a78d4c8fb3ba6a2b3d"}, {file = "watchfiles-1.0.5-cp310-cp310-win32.whl", hash = "sha256:cd47d063fbeabd4c6cae1d4bcaa38f0902f8dc5ed168072874ea11d0c7afc1ff"}, {file = "watchfiles-1.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:86c0df05b47a79d80351cd179893f2f9c1b1cae49d96e8b3290c7f4bd0ca0a92"}, {file = "watchfiles-1.0.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:237f9be419e977a0f8f6b2e7b0475ababe78ff1ab06822df95d914a945eac827"}, {file = "watchfiles-1.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0da39ff917af8b27a4bdc5a97ac577552a38aac0d260a859c1517ea3dc1a7c4"}, {file = "watchfiles-1.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cfcb3952350e95603f232a7a15f6c5f86c5375e46f0bd4ae70d43e3e063c13d"}, {file = "watchfiles-1.0.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:68b2dddba7a4e6151384e252a5632efcaa9bc5d1c4b567f3cb621306b2ca9f63"}, {file = "watchfiles-1.0.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95cf944fcfc394c5f9de794ce581914900f82ff1f855326f25ebcf24d5397418"}, {file = "watchfiles-1.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ecf6cd9f83d7c023b1aba15d13f705ca7b7d38675c121f3cc4a6e25bd0857ee9"}, {file = "watchfiles-1.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:852de68acd6212cd6d33edf21e6f9e56e5d98c6add46f48244bd479d97c967c6"}, {file = "watchfiles-1.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5730f3aa35e646103b53389d5bc77edfbf578ab6dab2e005142b5b80a35ef25"}, {file = "watchfiles-1.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:18b3bd29954bc4abeeb4e9d9cf0b30227f0f206c86657674f544cb032296acd5"}, {file = "watchfiles-1.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ba5552a1b07c8edbf197055bc9d518b8f0d98a1c6a73a293bc0726dce068ed01"}, {file = "watchfiles-1.0.5-cp311-cp311-win32.whl", hash = "sha256:2f1fefb2e90e89959447bc0420fddd1e76f625784340d64a2f7d5983ef9ad246"}, {file = "watchfiles-1.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:b6e76ceb1dd18c8e29c73f47d41866972e891fc4cc7ba014f487def72c1cf096"}, {file = "watchfiles-1.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:266710eb6fddc1f5e51843c70e3bebfb0f5e77cf4f27129278c70554104d19ed"}, {file = "watchfiles-1.0.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b5eb568c2aa6018e26da9e6c86f3ec3fd958cee7f0311b35c2630fa4217d17f2"}, {file = "watchfiles-1.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a04059f4923ce4e856b4b4e5e783a70f49d9663d22a4c3b3298165996d1377f"}, {file = "watchfiles-1.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e380c89983ce6e6fe2dd1e1921b9952fb4e6da882931abd1824c092ed495dec"}, {file = "watchfiles-1.0.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fe43139b2c0fdc4a14d4f8d5b5d967f7a2777fd3d38ecf5b1ec669b0d7e43c21"}, {file = "watchfiles-1.0.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee0822ce1b8a14fe5a066f93edd20aada932acfe348bede8aa2149f1a4489512"}, {file = "watchfiles-1.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a0dbcb1c2d8f2ab6e0a81c6699b236932bd264d4cef1ac475858d16c403de74d"}, {file = "watchfiles-1.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2014a2b18ad3ca53b1f6c23f8cd94a18ce930c1837bd891262c182640eb40a6"}, {file = "watchfiles-1.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10f6ae86d5cb647bf58f9f655fcf577f713915a5d69057a0371bc257e2553234"}, {file = "watchfiles-1.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1a7bac2bde1d661fb31f4d4e8e539e178774b76db3c2c17c4bb3e960a5de07a2"}, {file = "watchfiles-1.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ab626da2fc1ac277bbf752446470b367f84b50295264d2d313e28dc4405d663"}, {file = "watchfiles-1.0.5-cp312-cp312-win32.whl", hash = "sha256:9f4571a783914feda92018ef3901dab8caf5b029325b5fe4558c074582815249"}, {file = "watchfiles-1.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:360a398c3a19672cf93527f7e8d8b60d8275119c5d900f2e184d32483117a705"}, {file = "watchfiles-1.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:1a2902ede862969077b97523987c38db28abbe09fb19866e711485d9fbf0d417"}, {file = "watchfiles-1.0.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0b289572c33a0deae62daa57e44a25b99b783e5f7aed81b314232b3d3c81a11d"}, {file = "watchfiles-1.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a056c2f692d65bf1e99c41045e3bdcaea3cb9e6b5a53dcaf60a5f3bd95fc9763"}, {file = "watchfiles-1.0.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9dca99744991fc9850d18015c4f0438865414e50069670f5f7eee08340d8b40"}, {file = "watchfiles-1.0.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:894342d61d355446d02cd3988a7326af344143eb33a2fd5d38482a92072d9563"}, {file = "watchfiles-1.0.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab44e1580924d1ffd7b3938e02716d5ad190441965138b4aa1d1f31ea0877f04"}, {file = "watchfiles-1.0.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6f9367b132078b2ceb8d066ff6c93a970a18c3029cea37bfd7b2d3dd2e5db8f"}, {file = "watchfiles-1.0.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2e55a9b162e06e3f862fb61e399fe9f05d908d019d87bf5b496a04ef18a970a"}, {file = "watchfiles-1.0.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0125f91f70e0732a9f8ee01e49515c35d38ba48db507a50c5bdcad9503af5827"}, {file = "watchfiles-1.0.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:13bb21f8ba3248386337c9fa51c528868e6c34a707f729ab041c846d52a0c69a"}, {file = "watchfiles-1.0.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:839ebd0df4a18c5b3c1b890145b5a3f5f64063c2a0d02b13c76d78fe5de34936"}, {file = "watchfiles-1.0.5-cp313-cp313-win32.whl", hash = "sha256:4a8ec1e4e16e2d5bafc9ba82f7aaecfeec990ca7cd27e84fb6f191804ed2fcfc"}, {file = "watchfiles-1.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:f436601594f15bf406518af922a89dcaab416568edb6f65c4e5bbbad1ea45c11"}, {file = "watchfiles-1.0.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:2cfb371be97d4db374cba381b9f911dd35bb5f4c58faa7b8b7106c8853e5d225"}, {file = "watchfiles-1.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a3904d88955fda461ea2531fcf6ef73584ca921415d5cfa44457a225f4a42bc1"}, {file = "watchfiles-1.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b7a21715fb12274a71d335cff6c71fe7f676b293d322722fe708a9ec81d91f5"}, {file = "watchfiles-1.0.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dfd6ae1c385ab481766b3c61c44aca2b3cd775f6f7c0fa93d979ddec853d29d5"}, {file = "watchfiles-1.0.5-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b659576b950865fdad31fa491d31d37cf78b27113a7671d39f919828587b429b"}, {file = "watchfiles-1.0.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1909e0a9cd95251b15bff4261de5dd7550885bd172e3536824bf1cf6b121e200"}, {file = "watchfiles-1.0.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:832ccc221927c860e7286c55c9b6ebcc0265d5e072f49c7f6456c7798d2b39aa"}, {file = "watchfiles-1.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85fbb6102b3296926d0c62cfc9347f6237fb9400aecd0ba6bbda94cae15f2b3b"}, {file = "watchfiles-1.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:15ac96dd567ad6c71c71f7b2c658cb22b7734901546cd50a475128ab557593ca"}, {file = "watchfiles-1.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4b6227351e11c57ae997d222e13f5b6f1f0700d84b8c52304e8675d33a808382"}, {file = "watchfiles-1.0.5-cp39-cp39-win32.whl", hash = "sha256:974866e0db748ebf1eccab17862bc0f0303807ed9cda465d1324625b81293a18"}, {file = "watchfiles-1.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:9848b21ae152fe79c10dd0197304ada8f7b586d3ebc3f27f43c506e5a52a863c"}, {file = "watchfiles-1.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f59b870db1f1ae5a9ac28245707d955c8721dd6565e7f411024fa374b5362d1d"}, {file = "watchfiles-1.0.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9475b0093767e1475095f2aeb1d219fb9664081d403d1dff81342df8cd707034"}, {file = "watchfiles-1.0.5-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc533aa50664ebd6c628b2f30591956519462f5d27f951ed03d6c82b2dfd9965"}, {file = "watchfiles-1.0.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fed1cd825158dcaae36acce7b2db33dcbfd12b30c34317a88b8ed80f0541cc57"}, {file = "watchfiles-1.0.5-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:554389562c29c2c182e3908b149095051f81d28c2fec79ad6c8997d7d63e0009"}, {file = "watchfiles-1.0.5-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a74add8d7727e6404d5dc4dcd7fac65d4d82f95928bbee0cf5414c900e86773e"}, {file = "watchfiles-1.0.5-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb1489f25b051a89fae574505cc26360c8e95e227a9500182a7fe0afcc500ce0"}, {file = "watchfiles-1.0.5-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0901429650652d3f0da90bad42bdafc1f9143ff3605633c455c999a2d786cac"}, {file = "watchfiles-1.0.5.tar.gz", hash = "sha256:b7529b5dcc114679d43827d8c35a07c493ad6f083633d573d81c660abc5979e9"}, ] [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 = "2.3.0" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = "<3.14,>=3.9" groups = ["main"] markers = "platform_system == \"Windows\" and python_version >= \"3.12\"" files = [ {file = "winrt_runtime-2.3.0-cp310-cp310-win32.whl", hash = "sha256:5c22ed339b420a6026134e28281b25078a9e6755eceb494dce5d42ee5814e3fd"}, {file = "winrt_runtime-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:f3ef0d6b281a8d4155ea14a0f917faf82a004d4996d07beb2b3d2af191503fb1"}, {file = "winrt_runtime-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:93ce23df52396ed89dfe659ee0e1a968928e526b9c577942d4a54ad55b333644"}, {file = "winrt_runtime-2.3.0-cp311-cp311-win32.whl", hash = "sha256:352d70864846fd7ec89703845b82a35cef73f42d178a02a4635a38df5a61c0f8"}, {file = "winrt_runtime-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:286e6036af4903dd830398103c3edd110a46432347e8a52ba416d937c0e1f5f9"}, {file = "winrt_runtime-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:44d0f0f48f2f10c02b885989e8bbac41d7bf9c03550b20ddf562100356fca7a9"}, {file = "winrt_runtime-2.3.0-cp312-cp312-win32.whl", hash = "sha256:03d3e4aedc65832e57c0dbf210ec2a9d7fb2819c74d420ba889b323e9fa5cf28"}, {file = "winrt_runtime-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:0dc636aec2f4ee6c3849fa59dae10c128f4a908f0ce452e91af65d812ea66dcb"}, {file = "winrt_runtime-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:d9f140c71e4f3bf7bf7d6853b246eab2e1632c72f218ff163aa41a74b576736f"}, {file = "winrt_runtime-2.3.0-cp313-cp313-win32.whl", hash = "sha256:77f06df6b7a6cb536913ae455e30c1733d31d88dafe2c3cd8c3d0e2bcf7e2a20"}, {file = "winrt_runtime-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:7388774b74ea2f4510ab3a98c95af296665ebe69d9d7e2fd7ee2c3fc5856099e"}, {file = "winrt_runtime-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:0d3a4ac7661cad492d51653054e63328b940a6083c1ee1dd977f90069cb8afaa"}, {file = "winrt_runtime-2.3.0-cp39-cp39-win32.whl", hash = "sha256:cd7bce2c7703054e7f64d11be665e9728e15d9dae0d952a51228fe830e0c4b55"}, {file = "winrt_runtime-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:2da01af378ab9374a3a933da97543f471a676a3b844318316869bffeff811e8a"}, {file = "winrt_runtime-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:1c6bbfcc7cbe1c8159ed5d776b30b7f1cbc2c6990803292823b0788c22d75636"}, {file = "winrt_runtime-2.3.0.tar.gz", hash = "sha256:bb895a2b8c74b375781302215e2661914369c625aa1f8df84f8d37691b22db77"}, ] [[package]] name = "winrt-windows-devices-bluetooth" version = "2.3.0" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = "<3.14,>=3.9" groups = ["main"] markers = "platform_system == \"Windows\" and python_version >= \"3.12\"" files = [ {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp310-cp310-win32.whl", hash = "sha256:554aa6d0ca4bebc22a45f19fa60db1183a2b5643468f3c95cf0ebc33fbc1b0d0"}, {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:cec2682e10431f027c1823647772671fb09bebc1e8a00021a3651120b846d36f"}, {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:b4d42faef99845de2aded4c75c906f03cc3ba3df51fb4435e4cc88a19168cf99"}, {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp311-cp311-win32.whl", hash = "sha256:64e0992175d4d5a1160179a8c586c2202a0edbd47a5b6da4efdbc8bb601f2f99"}, {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:0830111c077508b599062fbe2d817203e4efa3605bd209cf4a3e03388ec39dda"}, {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:3943d538cb7b6bde3fd8741591eb6e23487ee9ee6284f05428b205e7d10b6d92"}, {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp312-cp312-win32.whl", hash = "sha256:544ed169039e6d5e250323cc18c87967cfeb4d3d09ce354fd7c5fd2283f3bb98"}, {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:f7becf095bf9bc999629fcb6401a88b879c3531b3c55c820e63259c955ddc06c"}, {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:a6a2980409c855b4e5dab0be9bde9f30236292ac1fc994df959fa5a518cd6690"}, {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp313-cp313-win32.whl", hash = "sha256:82f443be43379d4762e72633047c82843c873b6f26428a18855ca7b53e1958d7"}, {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:8b407da87ab52315c2d562a75d824dcafcae6e1628031cdb971072a47eb78ff0"}, {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:e36d0b487bc5b64662b8470085edf8bfa5a220d7afc4f2e8d7faa3e3ac2bae80"}, {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp39-cp39-win32.whl", hash = "sha256:6553023433edf5a75767e8962bf492d0623036975c7d8373d5bbccc633a77bbc"}, {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:77bdeadb043190c40ebbad462cd06e38b6461bc976bc67daf587e9395c387aae"}, {file = "winrt_Windows.Devices.Bluetooth-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:c588ab79b534fedecce48f7082b419315e8d797d0120556166492e603e90d932"}, {file = "winrt_windows_devices_bluetooth-2.3.0.tar.gz", hash = "sha256:a1204b71c369a0399ec15d9a7b7c67990dd74504e486b839bf81825bd381a837"}, ] [package.dependencies] winrt-runtime = "2.3.0" [package.extras] all = ["winrt-Windows.Devices.Bluetooth.GenericAttributeProfile[all] (==2.3.0)", "winrt-Windows.Devices.Bluetooth.Rfcomm[all] (==2.3.0)", "winrt-Windows.Devices.Enumeration[all] (==2.3.0)", "winrt-Windows.Devices.Radios[all] (==2.3.0)", "winrt-Windows.Foundation.Collections[all] (==2.3.0)", "winrt-Windows.Foundation[all] (==2.3.0)", "winrt-Windows.Networking[all] (==2.3.0)", "winrt-Windows.Storage.Streams[all] (==2.3.0)"] [[package]] name = "winrt-windows-devices-bluetooth-advertisement" version = "2.3.0" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = "<3.14,>=3.9" groups = ["main"] markers = "platform_system == \"Windows\" and python_version >= \"3.12\"" files = [ {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp310-cp310-win32.whl", hash = "sha256:4386498e7794ed383542ea868f0aa2dd8fb5f09f12bdffde024d12bd9f5a3756"}, {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:d6fa25b2541d2898ae17982e86e0977a639b04f75119612cb46e1719474513fd"}, {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:b200ff5acd181353f61f5b6446176faf78a61867d8c1d21e77a15e239d2cdf6b"}, {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp311-cp311-win32.whl", hash = "sha256:e56ad277813b48e35a3074f286c55a7a25884676e23ef9c3fc12349a42cb8fa4"}, {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:d6533fef6a5914dc8d519b83b1841becf6fd2f37163d6e07df318a6a6118f194"}, {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:8f4369cb0108f8ee0cace559f9870b00a4dde3fc1abd52f84adba08bc733825c"}, {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp312-cp312-win32.whl", hash = "sha256:d729d989acd7c1d703e2088299b6e219089a415db4a7b80cd52fdc507ec3ce95"}, {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d3d258d4388a2b46f2e46f2fbdede1bf327eaa9c2dd4605f8a7fe454077c49e"}, {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:d8c12457b00a79f8f1058d7a51bd8e7f177fb66e31389469e75b1104f6358921"}, {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp313-cp313-win32.whl", hash = "sha256:ac1e55a350881f82cb51e162cb7a4b5d9359e9e5fbde922de802404a951d64ec"}, {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0fc339340fb8be21c1c829816a49dc31b986c6d602d113d4a49ee8ffaf0e2396"}, {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:da63d9c56edcb3b2d5135e65cc8c9c4658344dd480a8a2daf45beb2106f17874"}, {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp39-cp39-win32.whl", hash = "sha256:e98c6ae4b0afd3e4f3ab4fa06e84d6017ff9242146a64e3bad73f7f34183a076"}, {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:cdc485f4143fbbb3ae0c9c9ad03b1021a5cb233c6df65bf56ac14f8e22c918c3"}, {file = "winrt_Windows.Devices.Bluetooth.Advertisement-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:7af519cc895be84d6974e9f70d102545a5e8db05e065903b0fd84521218e60a9"}, {file = "winrt_windows_devices_bluetooth_advertisement-2.3.0.tar.gz", hash = "sha256:c8adbec690b765ca70337c35efec9910b0937a40a0a242184ea295367137f81c"}, ] [package.dependencies] winrt-runtime = "2.3.0" [package.extras] all = ["winrt-Windows.Devices.Bluetooth[all] (==2.3.0)", "winrt-Windows.Foundation.Collections[all] (==2.3.0)", "winrt-Windows.Foundation[all] (==2.3.0)", "winrt-Windows.Storage.Streams[all] (==2.3.0)"] [[package]] name = "winrt-windows-devices-bluetooth-genericattributeprofile" version = "2.3.0" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = "<3.14,>=3.9" groups = ["main"] markers = "platform_system == \"Windows\" and python_version >= \"3.12\"" files = [ {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp310-cp310-win32.whl", hash = "sha256:1ec75b107370827874d8435a47852d0459cb66d5694e02a833e0a75c4748e847"}, {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:0a178aa936abbc56ae1cc54a222dee4a34ce6c09506a5b592d4f7d04dbe76b95"}, {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:b7067b8578e19ad17b28694090d5b000fee57db5b219462155961b685d71fba5"}, {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp311-cp311-win32.whl", hash = "sha256:e0aeba201e20b6c4bc18a4336b5b07d653d4ab4c9c17a301613db680a346cd5e"}, {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:f87b3995de18b98075ec2b02afc7252873fa75e7c840eb770d7bfafb4fda5c12"}, {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:7dccce04ec076666001efca8e2484d0ec444b2302ae150ef184aa253b8cfba09"}, {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp312-cp312-win32.whl", hash = "sha256:1b97ef2ab9c9f5bae984989a47565d0d19c84969d74982a2664a4a3485cb8274"}, {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:5fac2c7b301fa70e105785d7504176c76e4d824fc3823afed4d1ab6a7682272c"}, {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:353fdccf2398b2a12e0835834cff8143a7efd9ba877fb5820fdcce531732b500"}, {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp313-cp313-win32.whl", hash = "sha256:f414f793767ccc56d055b1c74830efb51fa4cbdc9163847b1a38b1ee35778f49"}, {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ef35d9cda5bbdcc55aa7eaf143ab873227d6ee467aaf28edbd2428f229e7c94"}, {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:6a9e7308ba264175c2a9ee31f6cf1d647cb35ee9a1da7350793d8fe033a6b9b8"}, {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp39-cp39-win32.whl", hash = "sha256:aea58f7e484cf3480ab9472a3e99b61c157b8a47baae8694bc7400ea5335f5dc"}, {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:992b792a9e7f5771ccdc18eec4e526a11f23b75d9be5de3ec552ff719333897a"}, {file = "winrt_Windows.Devices.Bluetooth.GenericAttributeProfile-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:66b030a9cc6099dafe4253239e8e625cc063bb9bb115bebed6260d92dd86f6b1"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-2.3.0.tar.gz", hash = "sha256:f40f94bf2f7243848dc10e39cfde76c9044727a05e7e5dfb8cb7f062f3fd3dda"}, ] [package.dependencies] winrt-runtime = "2.3.0" [package.extras] all = ["winrt-Windows.Devices.Bluetooth[all] (==2.3.0)", "winrt-Windows.Devices.Enumeration[all] (==2.3.0)", "winrt-Windows.Foundation.Collections[all] (==2.3.0)", "winrt-Windows.Foundation[all] (==2.3.0)", "winrt-Windows.Storage.Streams[all] (==2.3.0)"] [[package]] name = "winrt-windows-devices-enumeration" version = "2.3.0" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = "<3.14,>=3.9" groups = ["main"] markers = "platform_system == \"Windows\" and python_version >= \"3.12\"" files = [ {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp310-cp310-win32.whl", hash = "sha256:461360ab47967f39721e71276fdcfe87ad2f71ba7b09d721f2f88bcdf16a6924"}, {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:a7d7b01d43d5dcc1f3846db12f4c552155efae75469f36052623faed7f0f74a8"}, {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:6478fbe6f45172a9911c15b061ec9b0f30c9f4845ba3fd1e9e1bb78c1fb691c4"}, {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp311-cp311-win32.whl", hash = "sha256:30be5cba8e9e81ea8dd514ba1300b5bb14ad7cc4e32efe908ddddd14c73e7f61"}, {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:86c2a1865e0a0146dd4f51f17e3d773d3e6732742f61838c05061f28738c6dbd"}, {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:1b50d9304e49a9f04bc8139831b75be968ff19a1f50529d5eb0081dae2103d92"}, {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp312-cp312-win32.whl", hash = "sha256:42ed0349f0290a1b0a101425a06196c5d5db1240db6f8bd7d2204f23c48d727b"}, {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:83e385fbf85b9511699d33c659673611f42b98bd3a554a85b377a34cc3b68b2e"}, {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:26f855caee61c12449c6b07e22ea1ad470f8daa24223d8581e1fe622c70b48a8"}, {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp313-cp313-win32.whl", hash = "sha256:a5f2cff6ee584e5627a2246bdbcd1b3a3fd1e7ae0741f62c59f7d5a5650d5791"}, {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:7516171521aa383ccdc8f422cc202979a2359d0d1256f22852bfb0b55d9154f0"}, {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:80d01dfffe4b548439242f3f7a737189354768b203cca023dc29b267dfe5595a"}, {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp39-cp39-win32.whl", hash = "sha256:990a375cd8edc2d30b939a49dcc1349ede3a4b8e4da78baf0de5e5711d3a4f00"}, {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:2e7bedf0eac2066d7d37b1d34071b95bb57024e9e083867be1d24e916e012ac0"}, {file = "winrt_Windows.Devices.Enumeration-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:c53b673b80ba794f1c1320a5e0a14d795193c3f64b8132ebafba2f49c7301c2f"}, {file = "winrt_windows_devices_enumeration-2.3.0.tar.gz", hash = "sha256:a14078aac41432781acb0c950fcdcdeb096e2f80f7591a3d46435f30221fc3eb"}, ] [package.dependencies] winrt-runtime = "2.3.0" [package.extras] all = ["winrt-Windows.ApplicationModel.Background[all] (==2.3.0)", "winrt-Windows.Foundation.Collections[all] (==2.3.0)", "winrt-Windows.Foundation[all] (==2.3.0)", "winrt-Windows.Security.Credentials[all] (==2.3.0)", "winrt-Windows.Storage.Streams[all] (==2.3.0)", "winrt-Windows.UI.Popups[all] (==2.3.0)", "winrt-Windows.UI[all] (==2.3.0)"] [[package]] name = "winrt-windows-foundation" version = "2.3.0" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = "<3.14,>=3.9" groups = ["main"] markers = "platform_system == \"Windows\" and python_version >= \"3.12\"" files = [ {file = "winrt_Windows.Foundation-2.3.0-cp310-cp310-win32.whl", hash = "sha256:ea7b0e82be5c05690fedaf0dac5aa5e5fefd7ebf90b1497e5993197d305d916d"}, {file = "winrt_Windows.Foundation-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:6807dd40f8ecd6403679f6eae0db81674fdcf33768d08fdee66e0a17b7a02515"}, {file = "winrt_Windows.Foundation-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:0a861815e97ace82583210c03cf800507b0c3a97edd914bfffa5f88de1fbafcc"}, {file = "winrt_Windows.Foundation-2.3.0-cp311-cp311-win32.whl", hash = "sha256:c79b3d9384128b6b28c2483b4600f15c5d32c1f6646f9d77fdb3ee9bbaef6f81"}, {file = "winrt_Windows.Foundation-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:fdd9c4914070dc598f5961d9c7571dd7d745f5cc60347603bf39d6ee921bd85c"}, {file = "winrt_Windows.Foundation-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:62bbb0ffa273551d33fd533d6e09b6f9f633dc214225d483722af47d2525fb84"}, {file = "winrt_Windows.Foundation-2.3.0-cp312-cp312-win32.whl", hash = "sha256:d36f472ac258e79eee6061e1bb4ce50bfd200f9271392d23479c800ca6aee8d1"}, {file = "winrt_Windows.Foundation-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:8de9b5e95a3fdabdb45b1952e05355dd5a678f80bf09a54d9f966dccc805b383"}, {file = "winrt_Windows.Foundation-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:37da09c08c9c772baedb1958e5ee116fe63809f33c6820c69750f340b3dda292"}, {file = "winrt_Windows.Foundation-2.3.0-cp313-cp313-win32.whl", hash = "sha256:2b00fad3f2a3859ccae41eee12ab44434813a371c2f3003b4f2419e5eecb4832"}, {file = "winrt_Windows.Foundation-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:686619932b2a2c689cbebc7f5196437a45fd2056656ef130bb10240bb111086a"}, {file = "winrt_Windows.Foundation-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b38dcb83fe82a7da9a57d7d5ad5deb09503b5be6d9357a9fd3016ca31673805d"}, {file = "winrt_Windows.Foundation-2.3.0-cp39-cp39-win32.whl", hash = "sha256:2d6922de4dc38061b86d314c7319d7c6bd78a52d64ee0c93eb81474bddb499bc"}, {file = "winrt_Windows.Foundation-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:1513e43adff3779d2f611d8bdf9350ac1a7c04389e9e6b1d777c5cd54f46e4fc"}, {file = "winrt_Windows.Foundation-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:c811e4a4f79b947fbbb50f74d34ef6840dd2dd26e0199bd61a4185e48c6a84a8"}, {file = "winrt_windows_foundation-2.3.0.tar.gz", hash = "sha256:c5766f011c8debbe89b460af4a97d026ca252144e62d7278c9c79c5581ea0c02"}, ] [package.dependencies] winrt-runtime = "2.3.0" [package.extras] all = ["winrt-Windows.Foundation.Collections[all] (==2.3.0)"] [[package]] name = "winrt-windows-foundation-collections" version = "2.3.0" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = "<3.14,>=3.9" groups = ["main"] markers = "platform_system == \"Windows\" and python_version >= \"3.12\"" files = [ {file = "winrt_Windows.Foundation.Collections-2.3.0-cp310-cp310-win32.whl", hash = "sha256:d2fca59eef9582a33c2797b1fda1d5757d66827cc34e6fc1d1c94a5875c4c043"}, {file = "winrt_Windows.Foundation.Collections-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:d14b47d9137aebad71aa4fde5892673f2fa326f5f4799378cb9f6158b07a9824"}, {file = "winrt_Windows.Foundation.Collections-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:cca5398a4522dffd76decf64a28368cda67e81dc01cad35a9f39cc351af69bdd"}, {file = "winrt_Windows.Foundation.Collections-2.3.0-cp311-cp311-win32.whl", hash = "sha256:3808af64c95a9b464e8e97f6bec57a8b22168185f1c893f30de69aaf48c85b17"}, {file = "winrt_Windows.Foundation.Collections-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1e9a3842a39feb965545124abfe79ed726adc5a1fc6a192470a3c5d3ec3f7a74"}, {file = "winrt_Windows.Foundation.Collections-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:751c2a68fef080dfe0af892ef4cebf317844e4baa786e979028757fe2740fba4"}, {file = "winrt_Windows.Foundation.Collections-2.3.0-cp312-cp312-win32.whl", hash = "sha256:498c1fc403d3dc7a091aaac92af471615de4f9550d544347cb3b169c197183b5"}, {file = "winrt_Windows.Foundation.Collections-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:4d1b1cacc159f38d8e6b662f6e7a5c41879a36aa7434c1580d7f948c9037419e"}, {file = "winrt_Windows.Foundation.Collections-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:398d93b76a2cf70d5e75c1f802e1dd856501e63bc9a31f4510ac59f718951b9e"}, {file = "winrt_Windows.Foundation.Collections-2.3.0-cp313-cp313-win32.whl", hash = "sha256:1e5f1637e0919c7bb5b11ba1eebbd43bc0ad9600cf887b59fcece0f8a6c0eac3"}, {file = "winrt_Windows.Foundation.Collections-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:c809a70bc0f93d53c7289a0a86d8869740e09fff0c57318a14401f5c17e0b912"}, {file = "winrt_Windows.Foundation.Collections-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:269942fe86af06293a2676c8b2dcd5cb1d8ddfe1b5244f11c16e48ae0a5d100f"}, {file = "winrt_Windows.Foundation.Collections-2.3.0-cp39-cp39-win32.whl", hash = "sha256:936b1c5720b564ec699673198addee97f3bdb790622d24c8fd1b346a9767717c"}, {file = "winrt_Windows.Foundation.Collections-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:905a6ac9cd6b51659a9bba08cf44cfc925f528ef34cdd9c3a6c2632e97804a96"}, {file = "winrt_Windows.Foundation.Collections-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:1d6eac85976bd831e1b8cc479d7f14afa51c27cec5a38e2540077d3400cbd3ef"}, {file = "winrt_windows_foundation_collections-2.3.0.tar.gz", hash = "sha256:15c997fd6b64ef0400a619319ea3c6851c9c24e31d51b6448ba9bac3616d25a0"}, ] [package.dependencies] winrt-runtime = "2.3.0" [package.extras] all = ["winrt-Windows.Foundation[all] (==2.3.0)"] [[package]] name = "winrt-windows-storage-streams" version = "2.3.0" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = "<3.14,>=3.9" groups = ["main"] markers = "platform_system == \"Windows\" and python_version >= \"3.12\"" files = [ {file = "winrt_Windows.Storage.Streams-2.3.0-cp310-cp310-win32.whl", hash = "sha256:2c0901aee1232e92ed9320644b853d7801a0bdb87790164d56e961cd39910f07"}, {file = "winrt_Windows.Storage.Streams-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:ba07dc25decffd29aa8603119629c167bd03fa274099e3bad331a4920c292b78"}, {file = "winrt_Windows.Storage.Streams-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:5b60b48460095c50a00a6f7f9b3b780f5bdcb1ec663fc09458201499f93e23ea"}, {file = "winrt_Windows.Storage.Streams-2.3.0-cp311-cp311-win32.whl", hash = "sha256:8388f37759df64ceef1423ae7dd9275c8a6eb3b8245d400173b4916adc94b5ad"}, {file = "winrt_Windows.Storage.Streams-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:e5783dbe3694cc3deda594256ebb1088655386959bb834a6bfb7cd763ee87631"}, {file = "winrt_Windows.Storage.Streams-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:0a487d19c73b82aafa3d5ef889bb35e6e8e2487ca4f16f5446f2445033d5219c"}, {file = "winrt_Windows.Storage.Streams-2.3.0-cp312-cp312-win32.whl", hash = "sha256:272e87e6c74cb2832261ab33db7966a99e7a2400240cc4f8bf526a80ca054c68"}, {file = "winrt_Windows.Storage.Streams-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:997bf1a2d52c5f104b172947e571f27d9916a4409b4da592ec3e7f907848dd1a"}, {file = "winrt_Windows.Storage.Streams-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:d56daa00205c24ede6669d41eb70d6017e0202371d99f8ee2b0b31350ab59bd5"}, {file = "winrt_Windows.Storage.Streams-2.3.0-cp313-cp313-win32.whl", hash = "sha256:7ac4e46fc5e21d8badc5d41779273c3f5e7196f1cf2df1959b6b70eca1d5d85f"}, {file = "winrt_Windows.Storage.Streams-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1460027c94c107fcee484997494f3a400f08ee40396f010facb0e72b3b74c457"}, {file = "winrt_Windows.Storage.Streams-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:e4553a70f5264a7733596802a2991e2414cdcd5e396b9d11ee87be9abae9329e"}, {file = "winrt_Windows.Storage.Streams-2.3.0-cp39-cp39-win32.whl", hash = "sha256:28e1117e23046e499831af16d11f5e61e6066ed6247ef58b93738702522c29b0"}, {file = "winrt_Windows.Storage.Streams-2.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:5511dc578f92eb303aee4d3345ee4ffc88aa414564e43e0e3d84ff29427068f0"}, {file = "winrt_Windows.Storage.Streams-2.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:6f5b3f8af4df08f5bf9329373949236ffaef22d021070278795e56da5326a876"}, {file = "winrt_windows_storage_streams-2.3.0.tar.gz", hash = "sha256:d2c010beeb1dd7c135ed67ecfaea13440474a7c469e2e9aa2852db27d2063d44"}, ] [package.dependencies] winrt-runtime = "2.3.0" [package.extras] all = ["winrt-Windows.Foundation.Collections[all] (==2.3.0)", "winrt-Windows.Foundation[all] (==2.3.0)", "winrt-Windows.Storage[all] (==2.3.0)", "winrt-Windows.System[all] (==2.3.0)"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.14" content-hash = "71ed56d737b9f7b0f57abc6bca7d345bfa716d1721609ba1bbf51ed4954fa19e" habluetooth-3.48.2/pyproject.toml000066400000000000000000000077001500544257300170530ustar00rootroot00000000000000[build-system] requires = ['setuptools>=77.0', 'Cython>=3,<3.1', "poetry-core>=2.0.0"] build-backend = "poetry.core.masonry.api" [project] name = "habluetooth" version = "3.48.2" 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 = ">=0.21.1" bleak-retry-connector = ">=3.9.0" bluetooth-data-tools = ">=1.28.0" bluetooth-adapters = ">=0.16.1" bluetooth-auto-recovery = ">=1.5.1" async-interrupt = ">=1.1.1" dbus-fast = { version = ">=2.30.2", markers = "platform_system == 'Linux'" } [tool.poetry.group.dev.dependencies] pytest = ">=7,<9" pytest-cov = ">=3,<7" pytest-asyncio = ">=0.23.6,<0.27.0" pytest-codspeed = ">=2.2.1,<4.0.0" freezegun = "^1.5.1" 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 = "py38" line-length = 88 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 ] [tool.ruff.per-file-ignores] "tests/**/*" = [ "D100", "D101", "D102", "D103", "D104", "S101", ] "setup.py" = ["D100"] "conftest.py" = ["D100"] "docs/conf.py" = ["D100"] [tool.ruff.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 habluetooth-3.48.2/renovate.json000066400000000000000000000001011500544257300166410ustar00rootroot00000000000000{ "extends": ["github>browniebroke/renovate-configs:python"] } habluetooth-3.48.2/src/000077500000000000000000000000001500544257300147225ustar00rootroot00000000000000habluetooth-3.48.2/src/habluetooth/000077500000000000000000000000001500544257300172405ustar00rootroot00000000000000habluetooth-3.48.2/src/habluetooth/__init__.py000066400000000000000000000043431500544257300213550ustar00rootroot00000000000000__version__ = "3.48.2" 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, HaScannerRegistration, HaScannerRegistrationEvent, ) 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", "BaseHaRemoteScanner", "BaseHaScanner", "BluetoothManager", "BluetoothScannerDevice", "BluetoothScanningMode", "BluetoothServiceInfo", "BluetoothServiceInfoBleak", "DiscoveredDeviceAdvertisementData", "DiscoveredDeviceAdvertisementDataDict", "DiscoveryStorageType", "HaBleakClientWrapper", "HaBleakScannerWrapper", "HaBluetoothConnector", "HaBluetoothSlotAllocations", "HaScanner", "HaScannerDetails", "HaScannerRegistration", "HaScannerRegistrationEvent", "ScannerStartError", "discovered_device_advertisement_data_from_dict", "discovered_device_advertisement_data_to_dict", "expire_stale_scanner_discovered_device_advertisement_data", "get_manager", "set_manager", ] habluetooth-3.48.2/src/habluetooth/advertisement_tracker.pxd000066400000000000000000000006701500544257300243450ustar00rootroot00000000000000import 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) habluetooth-3.48.2/src/habluetooth/advertisement_tracker.py000066400000000000000000000056261500544257300242100ustar00rootroot00000000000000"""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) habluetooth-3.48.2/src/habluetooth/base_scanner.pxd000066400000000000000000000073241500544257300224060ustar00rootroot00000000000000 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 _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) cdef class BaseHaRemoteScanner(BaseHaScanner): cdef public dict _details cdef public double _expire_seconds cdef public object _cancel_track cdef public dict _previous_service_info @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) @cython.locals(info=BluetoothServiceInfoBleak) cpdef tuple get_discovered_device_advertisement_data(self, str address) @cython.locals(info=BluetoothServiceInfoBleak) cdef dict _build_discovered_device_advertisement_datas(self) @cython.locals(info=BluetoothServiceInfoBleak) cdef dict _build_discovered_device_timestamps(self) habluetooth-3.48.2/src/habluetooth/base_scanner.py000066400000000000000000000570011500544257300222400ustar00rootroot00000000000000"""Base classes for HA Bluetooth scanners for bluetooth.""" from __future__ import annotations import asyncio import logging import warnings from collections.abc import Generator from contextlib import contextmanager from typing import TYPE_CHECKING, Any, Final, Iterable, final from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from bleak_retry_connector import NO_RSSI_VALUE 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, ) 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_watchdog", "_connect_failures", "_connect_in_progress", "_connecting", "_last_detection", "_loop", "_manager", "_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() self.details = HaScannerDetails( source=self.source, connectable=self.connectable, name=self.name, adapter=self.adapter, ) self._connect_failures: dict[str, int] = {} self._connect_in_progress: dict[str, int] = {} 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) 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 _score_connection_paths( self, rssi_diff: _int, scanner_device: BluetoothScannerDevice ) -> float: """Score the connection paths.""" 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) if scanner_connections_in_progress: # Very large penalty for multiple connections in progress # to avoid overloading the adapter score -= rssi_diff * scanner_connections_in_progress * 1.01 if previous_failures: score -= rssi_diff * previous_failures * 0.51 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 def async_setup(self) -> CALLBACK_TYPE: """Set up the scanner.""" self._loop = asyncio.get_running_loop() 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.""" @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 ], } class BaseHaRemoteScanner(BaseHaScanner): """Base class for a high availability remote BLE scanner.""" __slots__ = ( "_cancel_track", "_details", "_expire_seconds", "_previous_service_info", ) def __init__( self, scanner_id: str, name: str, connector: HaBluetoothConnector | None, connectable: bool, requested_mode: BluetoothScanningMode | None = None, current_mode: BluetoothScanningMode | None = None, ) -> None: """Initialize the scanner.""" super().__init__( scanner_id, name, connector, connectable, requested_mode, current_mode ) self._details: dict[str, str | HaBluetoothConnector] = {"source": scanner_id} # 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._cancel_track: asyncio.TimerHandle | None = None self._previous_service_info: dict[str, BluetoothServiceInfoBleak] = {} 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 _cancel_expire_devices(self) -> None: """Cancel the expiration of old devices.""" if self._cancel_track: self._cancel_track.cancel() self._cancel_track = None def _unsetup(self) -> None: """Unset up the scanner.""" self._async_stop_scanner_watchdog() self._cancel_expire_devices() def async_setup(self) -> CALLBACK_TYPE: """Set up the scanner.""" super().async_setup() self._schedule_expire_devices() self._async_setup_scanner_watchdog() return self._unsetup 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 _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] @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 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}, rssi, # deprecated, will be removed in newer bleak ) 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 # # _rssi is deprecated, will be removed in newer bleak # pylint: disable-next=protected-access info.device._rssi = rssi 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) 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, "time_since_last_device_detection": { address: now - timestamp for address, timestamp in discovered_device_timestamps.items() }, } habluetooth-3.48.2/src/habluetooth/central_manager.py000066400000000000000000000012131500544257300227310ustar00rootroot00000000000000"""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 habluetooth-3.48.2/src/habluetooth/const.py000066400000000000000000000037321500544257300207450ustar00rootroot00000000000000"""Constants.""" from __future__ import annotations from datetime import timedelta from typing import Callable, 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 habluetooth-3.48.2/src/habluetooth/manager.pxd000066400000000000000000000053431500544257300213740ustar00rootroot00000000000000import 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 object _subclass_discover_info @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) habluetooth-3.48.2/src/habluetooth/manager.py000066400000000000000000001104151500544257300212260ustar00rootroot00000000000000"""The bluetooth integration.""" from __future__ import annotations import asyncio import itertools import logging 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 .const import ( ADV_RSSI_SWITCH_THRESHOLD, CALLBACK_TYPE, FAILED_ADAPTER_MAC, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, UNAVAILABLE_TRACK_SECONDS, ) from .models import ( BluetoothServiceInfoBleak, HaBluetoothSlotAllocations, 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 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", "_non_connectable_scanners", "_recovery_lock", "_scanner_registration_callbacks", "_sources", "_subclass_discover_info", "_unavailable_callbacks", "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._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._subclass_discover_info = self._discover_service_info 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 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 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() 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() 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 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) 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 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()) habluetooth-3.48.2/src/habluetooth/models.pxd000066400000000000000000000017571500544257300212520ustar00rootroot00000000000000import 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) habluetooth-3.48.2/src/habluetooth/models.py000066400000000000000000000240511500544257300210770ustar00rootroot00000000000000"""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) 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 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 habluetooth-3.48.2/src/habluetooth/py.typed000066400000000000000000000000001500544257300207250ustar00rootroot00000000000000habluetooth-3.48.2/src/habluetooth/scanner.pxd000066400000000000000000000012011500544257300214000ustar00rootroot00000000000000import 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 ) habluetooth-3.48.2/src/habluetooth/scanner.py000066400000000000000000000530231500544257300212460ustar00rootroot00000000000000# cython: profile=True """A local bleak scanner.""" from __future__ import annotations import asyncio import logging import platform from typing import Any, Coroutine, Iterable, 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 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 SYSTEM = platform.system() IS_LINUX = SYSTEM == "Linux" IS_MACOS = SYSTEM == "Darwin" if IS_LINUX: from bleak.backends.bluezdbus.advertisement_monitor import ( AdvertisementMonitor, OrPattern, ) from bleak.backends.bluezdbus.scanner import BlueZScannerArgs 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, scanning_mode: BluetoothScanningMode, adapter: str | None, ) -> bleak.BleakScanner: """Create a Bleak scanner.""" scanner_kwargs: dict[str, Any] = { "detection_callback": detection_callback, "scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode], } 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 ) 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 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_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.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.current_mode = BluetoothScanningMode.PASSIVE assert self.current_mode is not None # noqa: S101 self.scanner = create_bleak_scanner( self._async_detection_callback, self.current_mode, self.adapter ) 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 asyncio.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) 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 (asyncio.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 asyncio.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) habluetooth-3.48.2/src/habluetooth/scanner_device.py000066400000000000000000000013341500544257300225630ustar00rootroot00000000000000"""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) habluetooth-3.48.2/src/habluetooth/storage.py000066400000000000000000000241271500544257300212640ustar00rootroot00000000000000"""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 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: ( BLEDevice(**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_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 backwards 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] habluetooth-3.48.2/src/habluetooth/usage.py000066400000000000000000000033051500544257300207170ustar00rootroot00000000000000"""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. """ habluetooth-3.48.2/src/habluetooth/util.py000066400000000000000000000011061500544257300205650ustar00rootroot00000000000000"""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() habluetooth-3.48.2/src/habluetooth/wrappers.py000066400000000000000000000345341500544257300214660ustar00rootroot00000000000000"""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 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 CALLBACK_TYPE FILTER_UUIDS: Final = "UUIDs" _LOGGER = logging.getLogger(__name__) 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, *args: Any, timeout: float = 10.0, **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._backend: BaseBleakClient | None = None @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) -> bool: """Connect to the specified GATT server.""" 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 ), timeout=self.__timeout, ) 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 ) connected = False address = device.address try: scanner._add_connecting(address) connected = await super().connect(**kwargs) 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) if debug_logging: _LOGGER.debug( "%s: %s via %s (last rssi: %s)", description, "Connected" if connected else "Failed to connect", scanner.name, rssi, ) return connected 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, ) 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"(score={device.score_connection_path(0)})" 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 raise BleakError( "No backend with an available connection slot that can reach address" f" {address} was found" ) async def disconnect(self) -> bool: """Disconnect from the device.""" if self._backend is None: return True return await self._backend.disconnect() habluetooth-3.48.2/templates/000077500000000000000000000000001500544257300161315ustar00rootroot00000000000000habluetooth-3.48.2/templates/CHANGELOG.md.j2000066400000000000000000000012351500544257300202550ustar00rootroot00000000000000# 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 #} habluetooth-3.48.2/tests/000077500000000000000000000000001500544257300152755ustar00rootroot00000000000000habluetooth-3.48.2/tests/__init__.py000066400000000000000000000120061500544257300174050ustar00rootroot00000000000000import asyncio import time from contextlib import contextmanager from datetime import datetime, timezone from functools import partial from typing import Any, Generator 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, timezone.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, "rssi": -127, "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, rssi: int | 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 if rssi is not None: new["rssi"] = rssi 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 habluetooth-3.48.2/tests/conftest.py000066400000000000000000000162341500544257300175020ustar00rootroot00000000000000from collections.abc import Iterable from typing import AsyncGenerator, Generator 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 {} @pytest_asyncio.fixture(scope="session", autouse=True) async def manager() -> AsyncGenerator[None, None]: slot_manager = BleakSlotManager() bluetooth_adapters = FakeBluetoothAdapters() manager = BluetoothManager(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() habluetooth-3.48.2/tests/test_base_scanner.py000066400000000000000000000621261500544257300213400ustar00rootroot00000000000000"""Tests for the Bluetooth base scanner models.""" from __future__ import annotations import asyncio import time from datetime import timedelta from unittest.mock import ANY import pytest from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from bluetooth_data_tools import monotonic_time_coarse from habluetooth import ( BaseHaRemoteScanner, BluetoothScanningMode, HaBluetoothConnector, HaScannerDetails, get_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, ) 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, ) 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, switchbot_device_2.rssi, 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, ) 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, ) 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, ) 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 habluetooth-3.48.2/tests/test_benchmark_base_scanner.py000066400000000000000000000611511500544257300233470ustar00rootroot00000000000000"""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 _rssi = switchbot_device_adv.rssi _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, _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 _rssi = switchbot_device_adv.rssi _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, _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 _rssi = switchbot_device_adv.rssi _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, _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 _rssi = switchbot_device_adv.rssi _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, _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 _rssi = switchbot_device_adv.rssi _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, _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, sensor_push_device.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 _rssi = sensorpush_device_adv.rssi _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, _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 _rssi = device.rssi _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, _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 _rssi = device.rssi _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, _rssi, _name, _service_uuids, _service_data, _manufacturer_data, _tx_power, _details, _now, ) cancel() unsetup() habluetooth-3.48.2/tests/test_init.py000066400000000000000000000156131500544257300176570ustar00rootroot00000000000000from unittest.mock import ANY import pytest from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from bleak_retry_connector import BleakSlotManager from bluetooth_adapters import BluetoothAdapters from habluetooth import ( BaseHaRemoteScanner, BaseHaScanner, BluetoothManager, BluetoothScanningMode, HaBluetoothConnector, HaScanner, set_manager, ) @pytest.fixture(scope="session", autouse=True) def manager(): slot_manager = BleakSlotManager() bluetooth_adapters = BluetoothAdapters() set_manager(BluetoothManager(bluetooth_adapters, slot_manager)) 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, -21, ) 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 first_device.rssi == ble_device.rssi 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 assert ( scanner.discovered_devices_and_advertisement_data["AA:BB:CC:DD:EE:FF"][0].rssi == -21 ) 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) habluetooth-3.48.2/tests/test_manager.py000066400000000000000000001175021500544257300203260ustar00rootroot00000000000000"""Tests for the manager.""" import asyncio import time from datetime import timedelta from typing import Any from unittest.mock import ANY, 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, BluetoothServiceInfoBleak, HaBluetoothSlotAllocations, 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 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 habluetooth-3.48.2/tests/test_models.py000066400000000000000000000230051500544257300201710ustar00rootroot00000000000000from __future__ import annotations import time from bleak.backends.device import BLEDevice from habluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak from . import generate_advertisement_data 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 = BLEDevice("44:44:33:11:23:45", "wohand", {}, -127) 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 = BLEDevice("44:44:33:11:23:45", "wohand", {}, -127) 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 = BLEDevice("44:44:33:11:23:45", "wohand", {}, -127) 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 = BLEDevice("44:44:33:11:23:45", "wohand", {}, -127) 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 = BLEDevice(address, name, {}, rssi) 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 = BLEDevice("44:44:33:11:23:45", "wohand", {}, -127) 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 habluetooth-3.48.2/tests/test_scanner.py000066400000000000000000000655211500544257300203500ustar00rootroot00000000000000"""Tests for the Bluetooth integration scanners.""" import asyncio import time from datetime import timedelta from typing import Any from unittest.mock import ANY, 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, HaScanner, ScannerStartError, scanner, set_manager, ) from habluetooth.scanner import InvalidMessageError from . import ( async_fire_time_changed, generate_advertisement_data, generate_ble_device, patch_bluetooth_time, utcnow, ) from .conftest import FakeBluetoothAdapters 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. 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 manager(): """Return the BluetoothManager instance.""" adapters = FakeBluetoothAdapters() slot_manager = BleakSlotManager() manager = BluetoothManager(adapters, slot_manager) set_manager(manager) return manager @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 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", } habluetooth-3.48.2/tests/test_storage.py000066400000000000000000000364151500544257300203630ustar00rootroot00000000000000import 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"}, rssi=-50, ), 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, }, } }, "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", "rssi": -50, }, } }, "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"}, rssi=-50, ) 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 assert out_ble_device.rssi == expected_ble_device.rssi assert out_ble_device.metadata == expected_ble_device.metadata 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", "rssi": -50, }, }, "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", "rssi": -50, }, }, }, "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", "rssi": -50, }, } }, "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", "rssi": -50, }, }, "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", "rssi": -50, }, }, }, "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", "rssi": -50, }, } }, "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"}, "rssi": -50, "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"}, "rssi": -50, }, } }, "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 habluetooth-3.48.2/tests/test_wrappers.py000066400000000000000000000726031500544257300205610ustar00rootroot00000000000000"""Tests for bluetooth wrappers.""" from __future__ import annotations import asyncio from contextlib import contextmanager from typing import Any, Generator from unittest.mock import patch import bleak import pytest from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from bleak.exc import BleakError 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: None, 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 class FakeBleakClientFailsToConnect(BaseFakeBleakClient): """Fake bleak client that fails to connect.""" async def connect(self, *args, **kwargs): """Connect.""" 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] client = bleak.BleakClient(ble_device) assert await client.connect() is True 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: if "hci1" in ble_device.details["path"]: return True return False 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] client = bleak.BleakClient(ble_device) assert await client.connect() is True 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_fails_to_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) assert await client.connect() is False 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) -> bool: """Connect.""" assert isinstance(self._device, BLEDevice) if "/hci0/" in self._device.details["path"]: return False return True class FakeBleakClientFailsHCI1Only(BaseFakeBleakClient): """Fake bleak client that fails to connect on hci1.""" async def connect(self, *args: Any, **kwargs: Any) -> bool: """Connect.""" assert isinstance(self._device, BLEDevice) if "/hci1/" in self._device.details["path"]: return False return True with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsHCI0Only, ): # Should try to connect to hci0 first assert await client.connect() is False # Should try to connect with hci0 again assert await client.connect() is False # After two tries we should switch to hci1 assert await client.connect() is True # ..and we remember that hci1 works as long as the client doesn't change assert await client.connect() is True # If we replace the client, we should remember hci0 is failing client = bleak.BleakClient(ble_device) assert await client.connect() is True with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsHCI1Only, ): # Should try to connect to hci1 first assert await client.connect() is False # Should try to connect with hci0 next assert await client.connect() is True # Next attempt should also use hci0 assert await client.connect() is True 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.""" async def connect(self, *args: Any, **kwargs: Any) -> bool: """Connect.""" assert isinstance(self._device, BLEDevice) if "/hci0/" in self._device.details["path"]: await asyncio.sleep(0.4) return True return True 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() assert await task2 is True task3 = asyncio.create_task(client.connect()) await asyncio.sleep(0.1) assert task3.done() assert await task3 is True assert await task is True 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.""" async def connect(self, *args: Any, **kwargs: Any) -> bool: """Connect.""" assert isinstance(self._device, BLEDevice) return "/hci0/" in self._device.details["path"] with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFastConnect, ): assert await client.connect() is True 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 True with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClient, ): assert await client.connect() is True 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