pax_global_header00006660000000000000000000000064152111770450014515gustar00rootroot0000000000000052 comment=1ba76eeac0515911afba8967569d40cb1de0486c Bluetooth-Devices-habluetooth-75cbe37/000077500000000000000000000000001521117704500177775ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-75cbe37/.all-contributorsrc000066400000000000000000000004621521117704500236320ustar00rootroot00000000000000{ "projectName": "habluetooth", "projectOwner": "bluetooth-devices", "repoType": "github", "repoHost": "https://github.com", "files": [ "README.md" ], "imageSize": 80, "commit": true, "commitConvention": "angular", "contributors": [], "contributorsPerLine": 7, "skipCi": true } Bluetooth-Devices-habluetooth-75cbe37/.copier-answers.yml000066400000000000000000000010531521117704500235400ustar00rootroot00000000000000# Changes here will be overwritten by Copier _commit: 0b42cfd _src_path: gh:browniebroke/pypackage-template add_me_as_contributor: false copyright_year: '2023' documentation: true email: bluetooth@koston.org full_name: J. Nick Koston github_username: bluetooth-devices has_cli: false initial_commit: true open_source_license: Apache Software License 2.0 package_name: habluetooth project_name: habluetooth project_short_description: High availability Bluetooth project_slug: habluetooth run_poetry_install: true setup_github: true setup_pre_commit: true Bluetooth-Devices-habluetooth-75cbe37/.editorconfig000066400000000000000000000004441521117704500224560ustar00rootroot00000000000000# http://editorconfig.org root = true [*] indent_style = space indent_size = 4 trim_trailing_whitespace = true insert_final_newline = true charset = utf-8 end_of_line = lf [*.bat] indent_style = tab end_of_line = crlf [LICENSE] insert_final_newline = false [Makefile] indent_style = tab Bluetooth-Devices-habluetooth-75cbe37/.github/000077500000000000000000000000001521117704500213375ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-75cbe37/.github/ISSUE_TEMPLATE/000077500000000000000000000000001521117704500235225ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-75cbe37/.github/ISSUE_TEMPLATE/1-bug_report.md000066400000000000000000000004221521117704500263500ustar00rootroot00000000000000--- name: Bug report about: Create a report to help us improve labels: bug --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: **Additional context** Add any other context about the problem here. Bluetooth-Devices-habluetooth-75cbe37/.github/ISSUE_TEMPLATE/2-feature-request.md000066400000000000000000000006721521117704500273310ustar00rootroot00000000000000--- name: Feature request about: Suggest an idea for this project labels: enhancement --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Additional context** Add any other context or screenshots about the feature request here. Bluetooth-Devices-habluetooth-75cbe37/.github/actions/000077500000000000000000000000001521117704500227775ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-75cbe37/.github/actions/setup-uv-python/000077500000000000000000000000001521117704500261065ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-75cbe37/.github/actions/setup-uv-python/action.yml000066400000000000000000000055241521117704500301140ustar00rootroot00000000000000name: Set up uv and managed Python description: >- Pins uv and proactively installs the requested Python so cached venvs resolve their interpreter symlinks in jobs that only restore the venv. setup-uv alone only sets UV_PYTHON, it does not actually fetch the interpreter until uv first uses it, so jobs that just activate a cached venv blow up with broken symlinks on cache hit. setup-uv v8.1.0 also fetches its version manifest from raw.githubusercontent.com on every run even when version is pinned, so the setup step is retried a couple of times to ride out raw.githubusercontent.com flakes. inputs: python-version: description: The Python version uv should install and use. required: true uv-version: description: The uv version setup-uv should install. required: true outputs: python-version: description: The Python version uv reports as installed. value: ${{ steps.uv3.outputs.python-version || steps.uv2.outputs.python-version || steps.uv1.outputs.python-version }} runs: using: composite steps: - name: Set up uv (attempt 1) id: uv1 continue-on-error: true uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: version: ${{ inputs.uv-version }} python-version: ${{ inputs.python-version }} # Persist astral's managed Python across jobs so 'uv venv' / poetry # env use below is fast on the second job onwards. cache-python: true # Jobs that only configure the toolchain (no deps to cache) would # otherwise abort with "Nothing to cache" on the post step. ignore-nothing-to-cache: true - name: Set up uv (attempt 2) id: uv2 if: steps.uv1.outcome == 'failure' continue-on-error: true uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: version: ${{ inputs.uv-version }} python-version: ${{ inputs.python-version }} cache-python: true ignore-nothing-to-cache: true - name: Set up uv (attempt 3) id: uv3 if: steps.uv1.outcome == 'failure' && steps.uv2.outcome == 'failure' uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: version: ${{ inputs.uv-version }} python-version: ${{ inputs.python-version }} cache-python: true ignore-nothing-to-cache: true - name: Install Python interpreter shell: bash env: PYTHON_VERSION: ${{ inputs.python-version }} # 'uv python install' on Windows blows up with a reparse-point tag # mismatch (os error 4394) when cache-python: true already restored # the install dir, so only install when find says we have nothing yet. run: | if ! uv python find "${PYTHON_VERSION}" >/dev/null 2>&1; then uv python install "${PYTHON_VERSION}" fi Bluetooth-Devices-habluetooth-75cbe37/.github/dependabot.yml000066400000000000000000000013441521117704500241710ustar00rootroot00000000000000# To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "monthly" commit-message: prefix: "chore(ci): " groups: github-actions: patterns: - "*" - package-ecosystem: "pip" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" Bluetooth-Devices-habluetooth-75cbe37/.github/labels.toml000066400000000000000000000035151521117704500235020ustar00rootroot00000000000000[breaking] color = "ffcc00" name = "breaking" description = "Breaking change." [bug] color = "d73a4a" name = "bug" description = "Something isn't working" [dependencies] color = "0366d6" name = "dependencies" description = "Pull requests that update a dependency file" [github_actions] color = "000000" name = "github_actions" description = "Update of github actions" [documentation] color = "1bc4a5" name = "documentation" description = "Improvements or additions to documentation" [duplicate] color = "cfd3d7" name = "duplicate" description = "This issue or pull request already exists" [enhancement] color = "a2eeef" name = "enhancement" description = "New feature or request" ["good first issue"] color = "7057ff" name = "good first issue" description = "Good for newcomers" ["help wanted"] color = "008672" name = "help wanted" description = "Extra attention is needed" [invalid] color = "e4e669" name = "invalid" description = "This doesn't seem right" [nochangelog] color = "555555" name = "nochangelog" description = "Exclude pull requests from changelog" [question] color = "d876e3" name = "question" description = "Further information is requested" [removed] color = "e99695" name = "removed" description = "Removed piece of functionalities." [tests] color = "bfd4f2" name = "tests" description = "CI, CD and testing related changes" [wontfix] color = "ffffff" name = "wontfix" description = "This will not be worked on" [discussion] color = "c2e0c6" name = "discussion" description = "Some discussion around the project" [hacktoberfest] color = "ffa663" name = "hacktoberfest" description = "Good issues for Hacktoberfest" [answered] color = "0ee2b6" name = "answered" description = "Automatically closes as answered after a delay" [waiting] color = "5f7972" name = "waiting" description = "Automatically closes if no answer after a delay" Bluetooth-Devices-habluetooth-75cbe37/.github/workflows/000077500000000000000000000000001521117704500233745ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-75cbe37/.github/workflows/auto-merge.yml000066400000000000000000000011341521117704500261630ustar00rootroot00000000000000name: Dependabot auto-merge on: pull_request_target permissions: pull-requests: write contents: write jobs: dependabot: runs-on: ubuntu-latest if: ${{ github.actor == 'dependabot[bot]' }} steps: - name: Dependabot metadata id: metadata uses: dependabot/fetch-metadata@v3 with: github-token: "${{ secrets.GITHUB_TOKEN }}" - name: Enable auto-merge for Dependabot PRs run: gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{github.event.pull_request.html_url}} GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} Bluetooth-Devices-habluetooth-75cbe37/.github/workflows/ci.yml000066400000000000000000000276271521117704500245300ustar00rootroot00000000000000name: CI on: push: branches: - main pull_request: concurrency: group: ${{ github.head_ref || github.run_id }} cancel-in-progress: true env: POETRY_VIRTUALENVS_IN_PROJECT: "true" UV_PYTHON_PREFERENCE: only-managed UV_VERSION: "0.11.16" jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5 with: python-version: 3.13 - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 # Make sure the PR title follows the conventional commits convention: # https://www.conventionalcommits.org # PRs are squash-merged, so the PR title becomes the commit on main and # drives python-semantic-release's version bump. pr-title: name: Lint PR Title runs-on: ubuntu-latest if: github.event_name == 'pull_request' permissions: pull-requests: read steps: - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: subjectPattern: ^(?![A-Z]).+$ subjectPatternError: | The subject "{subject}" found in the pull request title "{title}" didn't match the configured pattern. Please ensure that the subject starts with a lowercase character. test: strategy: fail-fast: false matrix: python-version: - "3.11" - "3.12" - "3.13" - "3.14" - "3.14t" os: - ubuntu-latest - macOS-latest - windows-latest extension: - "skip_cython" - "use_cython" runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 - name: Set up uv and Python ${{ matrix.python-version }} id: python uses: ./.github/actions/setup-uv-python with: uv-version: ${{ env.UV_VERSION }} python-version: ${{ matrix.python-version }} - name: Install poetry run: uv tool install poetry shell: bash - name: Cache poetry venv id: cache-venv uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | .venv src/habluetooth/**/*.so key: venv-v3-${{ runner.os }}-py${{ steps.python.outputs.python-version }}-${{ matrix.extension }}-${{ hashFiles('poetry.lock', 'pyproject.toml', 'build_ext.py', 'src/habluetooth/**/*.py', 'src/habluetooth/**/*.pxd') }} - name: Install Dependencies if: steps.cache-venv.outputs.cache-hit != 'true' env: PYTHON_VERSION: ${{ matrix.python-version }} run: | poetry env use "$(uv python find "${PYTHON_VERSION}")" 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@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5 with: token: ${{ secrets.CODECOV_TOKEN }} benchmark: # Keep actions/setup-python here so codspeed history stays comparable; # astral's managed Python ships PGO/LTO/BOLT/mimalloc which would shift # the baseline. runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 - name: Set up uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true - name: Install poetry run: uv tool install poetry - name: Setup Python 3.14 id: setup-python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5 with: python-version: "3.14" cache: "poetry" allow-prereleases: true - name: Cache poetry venv id: cache-venv uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | .venv src/habluetooth/**/*.so key: venv-v2-${{ runner.os }}-benchmark-py${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock', 'pyproject.toml', 'build_ext.py', 'src/habluetooth/**/*.py', 'src/habluetooth/**/*.pxd') }} - name: Install Dependencies if: steps.cache-venv.outputs.cache-hit != 'true' run: | REQUIRE_CYTHON=1 poetry install --only=main,dev shell: bash - name: Run benchmarks uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v3 with: token: ${{ secrets.CODSPEED_TOKEN }} run: poetry run pytest --no-cov -vvvvv --codspeed mode: instrumentation release: needs: - test - lint runs-on: ubuntu-latest # Only enter the protected 'release' environment when actually releasing # from main; PR dry-runs would otherwise be blocked by the env's # main-only branch policy. environment: ${{ github.ref_name == 'main' && 'release' || '' }} concurrency: group: release-${{ github.head_ref || github.ref }} cancel-in-progress: false permissions: id-token: write contents: write outputs: released: ${{ steps.release.outputs.released }} newest_release_tag: ${{ steps.release.outputs.tag }} steps: # Mint a short-lived installation token for the release-bot GitHub # App, which is in the main ruleset's bypass_actors list so PSR's # version-bump commit/tag push isn't blocked by required checks. # Per PSR's docs, the same token must be passed to actions/checkout # (via `token`) so its persisted http.extraheader doesn't override # PSR's URL-embedded auth at push time and re-attribute the push # to github-actions[bot]. - name: Generate release App token if: github.ref_name == 'main' id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ secrets.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 with: fetch-depth: 0 ref: ${{ github.ref }} token: ${{ steps.app-token.outputs.token || github.token }} - name: Create local branch name run: git switch -C ${{ github.head_ref || github.ref_name }} # Do a dry run of PSR - name: Test release uses: python-semantic-release/python-semantic-release@350c48fcb3ffcdfd2e0a235206bc2ecea6b69df0 # v10.5.3 if: github.ref_name != 'main' with: no_operation_mode: true # On main branch: actual PSR + upload to PyPI & GitHub - name: Release uses: python-semantic-release/python-semantic-release@350c48fcb3ffcdfd2e0a235206bc2ecea6b69df0 # v10.5.3 id: release if: github.ref_name == 'main' with: github_token: ${{ steps.app-token.outputs.token }} - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # 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: ${{ steps.app-token.outputs.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-latest] qemu: [""] musl: [""] pyver: [""] include: - os: ubuntu-latest musl: "musllinux" - os: ubuntu-24.04-arm musl: "musllinux" # qemu is slow, make a single # runner per Python version - os: ubuntu-latest qemu: armv7l musl: "musllinux" pyver: cp311 - os: ubuntu-latest qemu: armv7l musl: "musllinux" pyver: cp312 - os: ubuntu-latest qemu: armv7l musl: "musllinux" pyver: cp313 - os: ubuntu-latest qemu: armv7l musl: "musllinux" pyver: cp314 - os: ubuntu-latest qemu: armv7l musl: "musllinux" pyver: cp314t # qemu is slow, make a single # runner per Python version - os: ubuntu-latest qemu: armv7l musl: "" pyver: cp311 - os: ubuntu-latest qemu: armv7l musl: "" pyver: cp312 - os: ubuntu-latest qemu: armv7l musl: "" pyver: cp313 - os: ubuntu-latest qemu: armv7l musl: "" pyver: cp314 - os: ubuntu-latest qemu: armv7l musl: "" pyver: cp314t steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 with: ref: ${{ needs.release.outputs.newest_release_tag }} fetch-depth: 0 # Used to host cibuildwheel - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5 with: python-version: "3.12" - name: Set up QEMU if: ${{ matrix.qemu }} uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 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@8d2b08b68458a16aeb24b64e68a09ab1c8e82084 # v3.4.1 env: CIBW_SKIP: cp36-* cp37-* cp38-* cp39-* cp310-* pp* ${{ matrix.musl == 'musllinux' && '*manylinux*' || '*musllinux*' }} REQUIRE_CYTHON: 1 - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # 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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # 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@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 Bluetooth-Devices-habluetooth-75cbe37/.github/workflows/hacktoberfest.yml000066400000000000000000000005341521117704500267450ustar00rootroot00000000000000name: Hacktoberfest on: schedule: # Run every day in October - cron: "0 0 * 10 *" # Run on the 1st of November to revert - cron: "0 13 1 11 *" jobs: hacktoberfest: runs-on: ubuntu-latest steps: - uses: browniebroke/hacktoberfest-labeler-action@v2.6.0 with: github_token: ${{ secrets.GH_PAT }} Bluetooth-Devices-habluetooth-75cbe37/.github/workflows/issue-manager.yml000066400000000000000000000013401521117704500266550ustar00rootroot00000000000000name: Issue Manager on: schedule: - cron: "0 0 * * *" issue_comment: types: - created issues: types: - labeled pull_request_target: types: - labeled workflow_dispatch: jobs: issue-manager: runs-on: ubuntu-latest steps: - uses: tiangolo/issue-manager@0.6.0 with: token: ${{ secrets.GITHUB_TOKEN }} config: > { "answered": { "message": "Assuming the original issue was solved, it will be automatically closed now." }, "waiting": { "message": "Automatically closing. To re-open, please provide the additional information requested." } } Bluetooth-Devices-habluetooth-75cbe37/.github/workflows/poetry-upgrade.yml000066400000000000000000000003371521117704500270710ustar00rootroot00000000000000name: Upgrader on: workflow_dispatch: schedule: - cron: "1 5 23 * *" jobs: upgrade: uses: browniebroke/github-actions/.github/workflows/poetry-upgrade.yml@v1 secrets: gh_pat: ${{ secrets.GH_PAT }} Bluetooth-Devices-habluetooth-75cbe37/.gitignore000066400000000000000000000041141521117704500217670ustar00rootroot00000000000000# Created by .ignore support plugin (hsz.mobi) ### Python template # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so *.c # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder {{package_name}} settings .spyderproject .spyproject # Rope {{package_name}} settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ Bluetooth-Devices-habluetooth-75cbe37/.gitpod.yml000066400000000000000000000003061521117704500220650ustar00rootroot00000000000000tasks: - command: | pip install poetry PIP_USER=false poetry install - command: | pip install pre-commit pre-commit install PIP_USER=false pre-commit install-hooks Bluetooth-Devices-habluetooth-75cbe37/.idea/000077500000000000000000000000001521117704500207575ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-75cbe37/.idea/habluetooth.iml000066400000000000000000000005151521117704500240010ustar00rootroot00000000000000 Bluetooth-Devices-habluetooth-75cbe37/.idea/watcherTasks.xml000066400000000000000000000052531521117704500241510ustar00rootroot00000000000000 Bluetooth-Devices-habluetooth-75cbe37/.idea/workspace.xml000066400000000000000000000027361521117704500235070ustar00rootroot00000000000000 Bluetooth-Devices-habluetooth-75cbe37/.pre-commit-config.yaml000066400000000000000000000025641521117704500242670ustar00rootroot00000000000000# 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/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: debug-statements - id: check-builtin-literals - id: check-case-conflict - id: check-docstring-first - id: check-json - id: check-toml - id: check-xml - id: check-yaml - id: detect-private-key - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/python-poetry/poetry rev: 2.4.1 hooks: - id: poetry-check - repo: https://github.com/pre-commit/mirrors-prettier rev: v4.0.0-alpha.8 hooks: - id: prettier args: ["--tab-width", "2"] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.15 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - repo: https://github.com/codespell-project/codespell rev: v2.4.2 hooks: - id: codespell - repo: https://github.com/pre-commit/mirrors-mypy rev: v2.1.0 hooks: - id: mypy additional_dependencies: [] Bluetooth-Devices-habluetooth-75cbe37/.readthedocs.yml000066400000000000000000000010511521117704500230620ustar00rootroot00000000000000# Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details # Required version: 2 # Set the version of Python and other tools you might need build: os: ubuntu-20.04 tools: python: "3.12" jobs: post_create_environment: # Install poetry - pip install poetry post_install: # Install dependencies - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --with docs # Build documentation in the docs directory with Sphinx sphinx: configuration: docs/conf.py Bluetooth-Devices-habluetooth-75cbe37/CHANGELOG.md000066400000000000000000001176101521117704500216160ustar00rootroot00000000000000# Changelog ## v6.8.3 (2026-06-07) ### Bug fixes - Promote non-connectable advertisement when a registered connectable path exists ([`59171a8`](https://github.com/Bluetooth-Devices/habluetooth/commit/59171a89b18e7855c1b038e9d951a5975b003204)) ### Testing - Cover manager.py error-handling and edge branches ([`30cd335`](https://github.com/Bluetooth-Devices/habluetooth/commit/30cd335f13e9f1964f869da682bc8899b8bf23cf)) ## v6.8.2 (2026-06-07) ### Bug fixes - Register scanner detection callback on start, not init ([`3924b4c`](https://github.com/Bluetooth-Devices/habluetooth/commit/3924b4cc0b352ac1af23045fdb2f20ec023e3888)) ### Testing - Benchmark auto-scheduler on_advertisement ingestion path ([`8232243`](https://github.com/Bluetooth-Devices/habluetooth/commit/82322437ad58427d14c6842338bf3369b0075faf)) - Cover untested hableakclientwrapper public methods ([`ec71cd4`](https://github.com/Bluetooth-Devices/habluetooth/commit/ec71cd4f90af8be11142752094de130ff59fb527)) ## v6.8.1 (2026-06-01) ### Bug fixes - Show scanner name in reachability diagnostics via line ([`632e840`](https://github.com/Bluetooth-Devices/habluetooth/commit/632e84038f90610b4235f06b481ca22b68ee0893)) ## v6.8.0 (2026-05-29) ### Features - Add async_address_reachability_diagnostics for unreachable devices ([`2429ce8`](https://github.com/Bluetooth-Devices/habluetooth/commit/2429ce8ea7caa62652363decad115511a63b7eeb)) ## v6.7.9 (2026-05-27) ### Performance improvements - Per-worker owned-due-at view for o(owned) wakes ([`d50defb`](https://github.com/Bluetooth-Devices/habluetooth/commit/d50defb3dfc4563373b8beb8d38b0d14ce911644)) ## v6.7.8 (2026-05-27) ### Bug fixes - Propagate refresh errors to concurrent adapter refresh waiters ([`ba75c66`](https://github.com/Bluetooth-Devices/habluetooth/commit/ba75c6645326babfa42443c3ebc34fe0760ee3c1)) ## v6.7.7 (2026-05-26) ### Performance improvements - Materialize discovered_addresses once per cycle in _async_check_unavailable ([`e29434e`](https://github.com/Bluetooth-Devices/habluetooth/commit/e29434e1f363b08f200c9b4daf9deb9a676c732c)) ## v6.7.6 (2026-05-26) ### Bug fixes - Make scanner unregister idempotent ([`44722dd`](https://github.com/Bluetooth-Devices/habluetooth/commit/44722dd276c3054e8bb1cb87ddb67b9d4b2716f3)) ### Testing - Benchmarks for auto_scheduler per-worker owned-needs (#506) ([`5d4b90c`](https://github.com/Bluetooth-Devices/habluetooth/commit/5d4b90c16cab38047381800d90c88ea31df34fff)) ## v6.7.5 (2026-05-26) ### Bug fixes - Correct class name in _discovered_device_timestamps deprecation warning ([`ba8632d`](https://github.com/Bluetooth-Devices/habluetooth/commit/ba8632d21938d5fac8f902f0991fbec3bbabea2b)) ### Testing - Benchmarks for _async_check_unavailable (issue #505) ([`baff661`](https://github.com/Bluetooth-Devices/habluetooth/commit/baff66138f06229979d7f4ea563fdfa43e2f886d)) ## v6.7.4 (2026-05-25) ### Bug fixes - Report passive or active for local scanner current_mode under auto ([`e2bd7c4`](https://github.com/Bluetooth-Devices/habluetooth/commit/e2bd7c429661fd94657aee4980d3c63ee554a88a)) ## v6.7.3 (2026-05-25) ### Bug fixes - Bluez backend construction under bleak 3.x ([`05e2ff7`](https://github.com/Bluetooth-Devices/habluetooth/commit/05e2ff7f8ab5ed47bfb27984162009bf765bf14f)) ## v6.7.2 (2026-05-24) ### Bug fixes - Fast-return when on-demand sweep dispatches nothing ([`2f7316d`](https://github.com/Bluetooth-Devices/habluetooth/commit/2f7316d0b1d43052c255cb8429f9bc8ea8f9ea4b)) ## v6.7.1 (2026-05-24) ### Bug fixes - Address late review comments on async_request_active_scan ([`5ddc407`](https://github.com/Bluetooth-Devices/habluetooth/commit/5ddc40766b2be02999984b029edca2c3b82dcdc3)) - Coalesce near-future due entries into the current window ([`e556e32`](https://github.com/Bluetooth-Devices/habluetooth/commit/e556e32511ca041938f1ad6e152c5fd7bc8de52e)) ## v6.7.0 (2026-05-23) ### Features - Expose async_request_active_scan for on-demand discovery ([`96d9153`](https://github.com/Bluetooth-Devices/habluetooth/commit/96d9153ec47bcb3161b3f57d01758be2bf84a951)) ## v6.6.1 (2026-05-23) ### Bug fixes - Shorten initial sweep delay from 10m to 4m ([`0b24ff7`](https://github.com/Bluetooth-Devices/habluetooth/commit/0b24ff7fa1577ed0c29b4d91734257cd5d45d9dd)) ## v6.6.0 (2026-05-23) ### Features - Expose diagnostics for sweeps, windows, and watched devices ([`6a6e084`](https://github.com/Bluetooth-Devices/habluetooth/commit/6a6e08492954e7b624e435fb3cda96921b11f9a2)) ## v6.5.0 (2026-05-23) ### Features - Route active-window scans around a connecting scanner ([`2d1471d`](https://github.com/Bluetooth-Devices/habluetooth/commit/2d1471d297a395a44131495697df3d40ea12aa0f)) ## v6.4.0 (2026-05-23) ### Features - Log mgmt side channel state when watchdog fires ([`edad0e7`](https://github.com/Bluetooth-Devices/habluetooth/commit/edad0e7a35a5f2b6b21a3bd370f1fc7515df3e0c)) ### Testing - Collapse repeated detection-recorder closures into a helper ([`3f18867`](https://github.com/Bluetooth-Devices/habluetooth/commit/3f18867114ee927e6fff57e7c0771302aa1d0762)) - Extract injectableremotescanner base shared across test files ([`12f312d`](https://github.com/Bluetooth-Devices/habluetooth/commit/12f312d47c272c1695c25f3df3ce108a28c449af)) ## v6.3.1 (2026-05-23) ### Bug fixes - Narrow deserialize exception so shape mismatches don't hide bugs ([`89197d6`](https://github.com/Bluetooth-Devices/habluetooth/commit/89197d69bef50ea4b082900c5647a9ba65e39e80)) ### Refactoring - Extract _should_keep_previous_adv from hot path ([`c4c862f`](https://github.com/Bluetooth-Devices/habluetooth/commit/c4c862f39154f3d2fb79a0438bcecffcce9942de)) - Dedup source-keyed callback register/dispatch trios ([`d8df7b3`](https://github.com/Bluetooth-Devices/habluetooth/commit/d8df7b3fe6355833bfcfed98008b273cea25cbac)) ### Testing - Factor inline mockbleakscanner classes into a shared base ([`6c590d6`](https://github.com/Bluetooth-Devices/habluetooth/commit/6c590d6d0efcc2e6c36135d8200afc5c06371e01)) - Cover the lines codecov flagged on the auto-scan-mode merge ([`a561579`](https://github.com/Bluetooth-Devices/habluetooth/commit/a561579c4ed0fa630b447241e9ecf8adfafbd688)) ## v6.3.0 (2026-05-22) ### Features - Add auto scanning mode with on-demand active windows ([`89c374e`](https://github.com/Bluetooth-Devices/habluetooth/commit/89c374eda9f95f228e3b72d68988e514aa9a97ef)) ### Testing - Cover scanner.py error-path branches ([`4f870d5`](https://github.com/Bluetooth-Devices/habluetooth/commit/4f870d538e95a0039c0959d04a99a9b8f555096f)) - Bump pytest-timeout to 60s for benchmark suite ([`4abde2e`](https://github.com/Bluetooth-Devices/habluetooth/commit/4abde2e6214ea6562002eb6825b7357f7811185c)) - Add pytest-timeout with 5s default timeout ([`3d52e76`](https://github.com/Bluetooth-Devices/habluetooth/commit/3d52e76c118eb0a764341299d902dbe099eab235)) ## v6.2.1 (2026-05-22) ### Bug fixes - Scope cp314 wheel build to non free threaded abi ([`84162aa`](https://github.com/Bluetooth-Devices/habluetooth/commit/84162aa94b61cab70bdebec4dcbf1d0fd0204884)) ## v6.2.0 (2026-05-22) ### Bug fixes - Use release-bot app token so psr push bypasses main ruleset ([`4595999`](https://github.com/Bluetooth-Devices/habluetooth/commit/4595999cf92d51dfe21797dde2e2e4cd27f09246)) - Unify connect cleanup + phase 2 lifecycle audit findings (#340) ([`6578751`](https://github.com/Bluetooth-Devices/habluetooth/commit/6578751020aec5571e309b851f359157fdde9ba7)) ### Testing - Rename test_update_name_cache_* to test_seed_name_cache_* ([`29a8bca`](https://github.com/Bluetooth-Devices/habluetooth/commit/29a8bca04dc56855dd1a620d7b7d5c98ca121c21)) - Cover manager.py uncovered branches ([`47d4b71`](https://github.com/Bluetooth-Devices/habluetooth/commit/47d4b71899c7086b72de53f9aa1384836735ca99)) - Silence pytest collection and deprecated-shim warnings ([`6ae3eda`](https://github.com/Bluetooth-Devices/habluetooth/commit/6ae3eda86d6abd606aa4963b850373cb54ac61fd)) - Cover baseharemotescanner restore/diagnostics/mode no-ops ([`62e7be2`](https://github.com/Bluetooth-Devices/habluetooth/commit/62e7be200fc58c9a8ff6fc78a6c048741afa747e)) - Cover util and central_manager helpers ([`84b9d4d`](https://github.com/Bluetooth-Devices/habluetooth/commit/84b9d4dcb860a9905c9fa8f1132ec1e59b89ee20)) ### Features - Share device name cache across scanners ([`bb76920`](https://github.com/Bluetooth-Devices/habluetooth/commit/bb769205c7cb6172071f22a2a105a98447a53269)) - Track lifetime connect counters per scanner ([`416a438`](https://github.com/Bluetooth-Devices/habluetooth/commit/416a43867c1dc53c4d145a92fca7a4af4b2a380e)) - Surface connect-in-progress and failure counters in scanner diagnostics ([`32db633`](https://github.com/Bluetooth-Devices/habluetooth/commit/32db63362dfd8544e32423ad0c7924ea04048b26)) - Support bleak 3.0+ without deprecation warnings ([`6bfa77f`](https://github.com/Bluetooth-Devices/habluetooth/commit/6bfa77f13d15441c3a9ddb4eb886253630b5ce86)) ### Performance improvements - Parallelize cython extension compilation ([`a39c93b`](https://github.com/Bluetooth-Devices/habluetooth/commit/a39c93bb5d9ad331a6ac058fb980dd8d66a0cdb2)) ### Documentation - Add claude.md guide for ai assistants ([`b12f98a`](https://github.com/Bluetooth-Devices/habluetooth/commit/b12f98ac3a1cc275bc8844913c27cdbd30116c68)) ## v6.1.0 (2026-04-19) ### Features - Restore register_detection_callback as a deprecated shim ([`7fcc147`](https://github.com/Bluetooth-Devices/habluetooth/commit/7fcc147b7e00249c3489c8ce70eac7fd4725372c)) ## v6.0.1 (2026-04-17) ### Bug fixes - Initialize _backend_id so hableakclientwrapper.backend_id works ([`8919d40`](https://github.com/Bluetooth-Devices/habluetooth/commit/8919d402c967ddda3850a2a0f86e804a972af076)) ## v6.0.0 (2026-04-04) ### Features - Remove basebleakscanner inheritance and register_detection_callback ([`87b89f1`](https://github.com/Bluetooth-Devices/habluetooth/commit/87b89f158226c69d1ac2561bbd43d730b699c6b9)) ## v5.14.0 (2026-04-04) ### Features - Implement advertisement_data async iterator in hableakscannerwrapper ([`e6e3727`](https://github.com/Bluetooth-Devices/habluetooth/commit/e6e3727cc774efd6f8ddd1c1140c9ac42bdd6aee)) - Implement discovered_devices_and_advertisement_data in hableakscannerwrapper ([`7ecd2d8`](https://github.com/Bluetooth-Devices/habluetooth/commit/7ecd2d8a4ce5397d39b737cd7367217ee4e196d1)) ## v5.13.0 (2026-04-04) ### Features - Implement async context manager protocol in hableakscannerwrapper ([`c9341d2`](https://github.com/Bluetooth-Devices/habluetooth/commit/c9341d29f8f9335ede23908b450d3ba57a0db20d)) - Implement find_device_by_name in hableakscannerwrapper ([`151b71c`](https://github.com/Bluetooth-Devices/habluetooth/commit/151b71c56f62b9952be5a7a4e5899034795169e7)) ## v5.12.0 (2026-04-04) ### Features - Implement find_device_by_filter in hableakscannerwrapper ([`ac2e258`](https://github.com/Bluetooth-Devices/habluetooth/commit/ac2e2584271eff55615420f38fde2f0231f10320)) ## v5.11.2 (2026-03-27) ### Bug fixes - Ensure we don't call cleanup twice ([`fbbe17e`](https://github.com/Bluetooth-Devices/habluetooth/commit/fbbe17e13e63e6c914d8e58f07a0d14e3f914474)) ## v5.11.1 (2026-03-22) ### Bug fixes - Revert "feat: add python 3.14 support" ([`2cc4541`](https://github.com/Bluetooth-Devices/habluetooth/commit/2cc454145415da40001f181805c197c1035ca82a)) ## v5.11.0 (2026-03-21) ### Features - Add async_clear_advertisement_history to bluetoothmanager ([`1fb9e01`](https://github.com/Bluetooth-Devices/habluetooth/commit/1fb9e019dd358c9c352212d89ff0fdfeb7247972)) - Add python 3.14 support ([`cd51eb8`](https://github.com/Bluetooth-Devices/habluetooth/commit/cd51eb8c4074ab83697434b1178d4df0eec30a61)) ## v5.10.3 (2026-03-21) ### Bug fixes - Ensure bluez helper is cython compiled ([`8be8853`](https://github.com/Bluetooth-Devices/habluetooth/commit/8be885394247e4b045fe57ebec0b5f852cec255b)) ## v5.10.2 (2026-03-15) ### Bug fixes - Remove macos-13 from wheel build matrix ([`13166b2`](https://github.com/Bluetooth-Devices/habluetooth/commit/13166b2a00b776738f27097ae40742dc03b4364d)) ## v5.10.1 (2026-03-15) ### Performance improvements - Skip raw advertisement parsing when data unchanged ([`84e1b33`](https://github.com/Bluetooth-Devices/habluetooth/commit/84e1b33733732dbd788cc243a72dc49aa80df84e)) ### Testing - Add dedup coverage tests for scanner_adv_received ([`002f40e`](https://github.com/Bluetooth-Devices/habluetooth/commit/002f40e3860173fd846183bf0cf0db67078b0b80)) - Add bluez raw, bleak, and mgmt end-to-end advertisement benchmarks ([`f3684f7`](https://github.com/Bluetooth-Devices/habluetooth/commit/f3684f7ed884bb0df5959ebe203c139170d34318)) ## v5.10.0 (2026-03-15) ### Features - Split scanner_adv_received into cpdef entry + cdef internal ([`f05ea67`](https://github.com/Bluetooth-Devices/habluetooth/commit/f05ea67493ff9806dd4e389dcf4ae2d03874d831)) ### Performance improvements - Use len() for dict truthiness check in hot path ([`33d8a67`](https://github.com/Bluetooth-Devices/habluetooth/commit/33d8a676f9617a01b7e20e654d0231938028007b)) ## v5.9.1 (2026-03-07) ### Bug fixes - Warn when connection params cannot be set ([`e9e4ed0`](https://github.com/Bluetooth-Devices/habluetooth/commit/e9e4ed0e47d24192eaf4edf247950e6b37bf91bb)) ## v5.9.0 (2026-03-07) ### Features - Add ble connection parameters api ([`53f1e3e`](https://github.com/Bluetooth-Devices/habluetooth/commit/53f1e3ec1c3609c48143d335876e3b84a1e3b43b)) ## v5.8.0 (2025-12-02) ### Features - Support bleak 2.0 ([`15ca6d3`](https://github.com/Bluetooth-Devices/habluetooth/commit/15ca6d33ab0ce02a00b223fa7659f62b27291fca)) ## v5.7.0 (2025-10-04) ### Features - Python 3.14 support ([`3ef7243`](https://github.com/Bluetooth-Devices/habluetooth/commit/3ef7243b2fe996970a3bd18a279a550f10233ede)) ## v5.6.4 (2025-09-13) ### Bug fixes - Workaround kernel abi inconsistency in bluetooth mgmt socket send behavior ([`affc097`](https://github.com/Bluetooth-Devices/habluetooth/commit/affc0971edf310bfd6f5a9f880fa488b4ed5a215)) ### Unknown ## v5.6.3 (2025-09-13) ### Bug fixes - High cpu usage by replacing async context manager with setup/cleanup pattern to avoid cython bug ([`8aa021a`](https://github.com/Bluetooth-Devices/habluetooth/commit/8aa021aee970fc01ccdb3d7f3242eee7fc3802cf)) ## v5.6.2 (2025-09-09) ### Bug fixes - Resolve crash when compiled with cython 3.1 ([`bac6dcf`](https://github.com/Bluetooth-Devices/habluetooth/commit/bac6dcffaba3940ff7331739866801729eee2032)) ## v5.6.1 (2025-09-09) ### Bug fixes - Rebuild wheels ([`bcc77be`](https://github.com/Bluetooth-Devices/habluetooth/commit/bcc77be98b6d3da33b8d74f1dda899e9823aa652)) ## v5.6.0 (2025-09-08) ### Features - Callback on scanner start success ([`782b717`](https://github.com/Bluetooth-Devices/habluetooth/commit/782b717044beff9758774c54cecbe3af9520e78b)) ## v5.5.1 (2025-09-08) ### Bug fixes - Handle case where two scanners have the exact same rssi ([`f51f700`](https://github.com/Bluetooth-Devices/habluetooth/commit/f51f700bdf41d508072c9ba1109a4bf7c7e5c57f)) ## v5.5.0 (2025-09-08) ### Features - Log slots when connecting ([`a3881c4`](https://github.com/Bluetooth-Devices/habluetooth/commit/a3881c4cccac52d668518dc8632104688dbf2788)) ## v5.4.0 (2025-09-08) ### Features - Consider connection slots when selecting connection path ([`fb938fc`](https://github.com/Bluetooth-Devices/habluetooth/commit/fb938fc608fd9f846c3eedd70ff245d5b299b93c)) ## v5.3.1 (2025-09-06) ### Bug fixes - Detect missing net_admin/net_raw capabilities and fallback to bluez-only mode ([`1cf17c0`](https://github.com/Bluetooth-Devices/habluetooth/commit/1cf17c094fc2bce65e703d230259f3c2c66720e7)) ## v5.3.0 (2025-08-31) ### Features - Include scanner type in details ([`0b558f1`](https://github.com/Bluetooth-Devices/habluetooth/commit/0b558f155e650b26a1b8c996d7292f62906cc3b4)) ## v5.2.1 (2025-08-29) ### Bug fixes - Incorrect advertising interval calculation when scanner pauses for connections ([`1c8db59`](https://github.com/Bluetooth-Devices/habluetooth/commit/1c8db59b8da69439d253bc356fe7be5321c7fb23)) ## v5.2.0 (2025-08-28) ### Features - Add methods to set current and requested mode to scanner ([`3cf872c`](https://github.com/Bluetooth-Devices/habluetooth/commit/3cf872c899beadd9594a5749a5ce433d943852f4)) ## v5.1.0 (2025-08-19) ### Features - Warn when connections are established without bleak-retry-connector ([`ba23681`](https://github.com/Bluetooth-Devices/habluetooth/commit/ba23681de0a4056da130ce7d2e8d7c25dbb18ad0)) ### Unknown ## v5.0.2 (2025-08-12) ### Bug fixes - Solve performance regression while connecting in linux ([`47b5b7e`](https://github.com/Bluetooth-Devices/habluetooth/commit/47b5b7e4e00b2be7922a2101a690e37de0970e5e)) ## v5.0.1 (2025-08-09) ### Bug fixes - Ensure connect works without debug logging ([`4da091d`](https://github.com/Bluetooth-Devices/habluetooth/commit/4da091dc3ee512d499c82d179420f7fbc0539457)) ## v5.0.0 (2025-08-09) ### Features - Add bt management side channel ([`e345308`](https://github.com/Bluetooth-Devices/habluetooth/commit/e3453089f510548ec7605fe83aa2078028ef2468)) ## v4.0.2 (2025-08-06) ### Bug fixes - Add clear error when only passive bluetooth adapters are available ([`bbb494c`](https://github.com/Bluetooth-Devices/habluetooth/commit/bbb494c99bd87e8e090a922a28cf7e083b191bc4)) ## v4.0.1 (2025-07-03) ### Bug fixes - Small cleanups for bleak 1.x support ([`3cf74f4`](https://github.com/Bluetooth-Devices/habluetooth/commit/3cf74f492354c452469907fb84a74cac9c7edcd3)) ## v4.0.0 (2025-07-03) ### Features - Support bleak 1.x ([`a739199`](https://github.com/Bluetooth-Devices/habluetooth/commit/a739199cf6d56f5db316b149134c11eabfab9f1c)) ## v3.49.0 (2025-06-03) ### Features - Add raw_advertisement_data to diagnostics ([`a77933b`](https://github.com/Bluetooth-Devices/habluetooth/commit/a77933b8dd0195ab827907ea918c572cd1686750)) ## v3.48.2 (2025-05-03) ### Bug fixes - Remove duplicate _connecting slot from basehascanner ([`230bb03`](https://github.com/Bluetooth-Devices/habluetooth/commit/230bb038eea8ae07a3fe798ec15792489d06cd66)) ## v3.48.1 (2025-05-03) ### Bug fixes - Pin cython to <3.1 ([`21dc734`](https://github.com/Bluetooth-Devices/habluetooth/commit/21dc7340c548713c4539d8d8a067a2a574623906)) ## v3.48.0 (2025-05-03) ### Features - Refactor scanner history to live on the scanner itself ([`ea0d2fc`](https://github.com/Bluetooth-Devices/habluetooth/commit/ea0d2fc088832a1b3f8c7859c82e2e05bf1261f9)) ## v3.47.1 (2025-05-03) ### Bug fixes - Ensure logging does not fail when there is only a single scanner ([`d81378e`](https://github.com/Bluetooth-Devices/habluetooth/commit/d81378e6b4adedead6d04ab23be7b655cd3785fb)) ## v3.47.0 (2025-05-03) ### Bug fixes - Require bluetooth-auto-recovery >= 1.5.1 ([`8164ce5`](https://github.com/Bluetooth-Devices/habluetooth/commit/8164ce512084fe898cb80c5e44f664dde4751113)) ### Features - Avoid thundering heard of connections ([`943cc20`](https://github.com/Bluetooth-Devices/habluetooth/commit/943cc2043731f8d6fbb541f4d7ffcd37d8c6b4f3)) ## v3.46.0 (2025-05-03) ### Features - Improve recovery when adapter has gone silent and needs a usb reset ([`a4dd395`](https://github.com/Bluetooth-Devices/habluetooth/commit/a4dd395b7a8e70cb0ae94d97422d35eb638daaa5)) ## v3.45.0 (2025-04-29) ### Features - Improve performance of _async_on_advertisement_internal ([`be0b5a6`](https://github.com/Bluetooth-Devices/habluetooth/commit/be0b5a6d0da07f2f881984c92c0c7671117d3e5a)) ## v3.44.0 (2025-04-28) ### Features - Save the raw data in storage ([`eaf4107`](https://github.com/Bluetooth-Devices/habluetooth/commit/eaf41072ecc915b2de23ad3c9a03148f4b313f17)) ## v3.43.0 (2025-04-28) ### Features - Migrate storage code from bluetooth_adapters ([`5d671f9`](https://github.com/Bluetooth-Devices/habluetooth/commit/5d671f95b9a7964bfa871c7b42061a71a98ce80e)) ## v3.42.0 (2025-04-27) ### Features - Add raw field to bluetoothserviceinfobleak ([`343f18b`](https://github.com/Bluetooth-Devices/habluetooth/commit/343f18bfbbf3ebbee31e64beab60b2686700797f)) ## v3.41.0 (2025-04-27) ### Features - Add new _async_on_raw_advertisement base scanner api ([`fb2a487`](https://github.com/Bluetooth-Devices/habluetooth/commit/fb2a487c06cf102c17509410f916b5c06728df98)) ## v3.40.0 (2025-04-27) ### Features - Require bluetooth-data-tools 1.28.0 or later ([`e154136`](https://github.com/Bluetooth-Devices/habluetooth/commit/e154136db9f15d33c6de3d89bf9e4e53e03c690a)) ## v3.39.0 (2025-04-17) ### Features - Improve performance of _async_on_advertisement ([`0fc0500`](https://github.com/Bluetooth-Devices/habluetooth/commit/0fc0500d74cdc3d320111df979bef784a51a2eac)) ## v3.38.1 (2025-04-14) ### Bug fixes - Add missing dbus-fast dep on linux ([`5746448`](https://github.com/Bluetooth-Devices/habluetooth/commit/57464488482626577e9f84c42ab1ff100b7857b3)) ## v3.38.0 (2025-03-22) ### Bug fixes - Use project.license key ([`1decf97`](https://github.com/Bluetooth-Devices/habluetooth/commit/1decf9704f7db33bc8094880651321c1b58420c8)) ### Features - Improve performance of previous source checks ([`8d96528`](https://github.com/Bluetooth-Devices/habluetooth/commit/8d96528f605231f3089319c789390d784c45b4c5)) ## v3.37.0 (2025-03-21) ### Features - Improve performance of _prefer_previous_adv_from_different_source ([`73ec210`](https://github.com/Bluetooth-Devices/habluetooth/commit/73ec2107375be217ffb0310194be8c3d4f20e150)) ## v3.36.0 (2025-03-21) ### Features - Improve performance of filtering apple data ([`9f56840`](https://github.com/Bluetooth-Devices/habluetooth/commit/9f568405ae987de0fb3953d6ae7b39eabacde9ef)) ## v3.35.0 (2025-03-21) ### Features - Optimize previous local name matching ([`fadb722`](https://github.com/Bluetooth-Devices/habluetooth/commit/fadb722b8ded2bc15bd56b641a963d4c4d19838e)) ## v3.34.1 (2025-03-21) ### Bug fixes - Revert adding _async_on_advertisements ([`4bc3cb8`](https://github.com/Bluetooth-Devices/habluetooth/commit/4bc3cb89baf52570deec4f27ed3cd935249525ec)) ## v3.34.0 (2025-03-21) ### Features - Rename _async_on_raw_advertisement to _async_on_raw_advertisements ([`b3acb88`](https://github.com/Bluetooth-Devices/habluetooth/commit/b3acb882d888a33567ece3e7f9d0fa1d2b4c6acd)) ## v3.33.0 (2025-03-21) ### Features - Add _async_on_raw_advertisement ([`24d128f`](https://github.com/Bluetooth-Devices/habluetooth/commit/24d128fe4854135647e9a41c7eeaf1784fbda0bf)) ## v3.32.0 (2025-03-15) ### Features - Improve performance of dispatching discovery info to subclasses ([`d0fae7d`](https://github.com/Bluetooth-Devices/habluetooth/commit/d0fae7ddd9158903f6621888cc4c75480822ae35)) ## v3.31.0 (2025-03-15) ### Features - Avoid building on demand advertisementdata if there are no bleak callbacks ([`ae977b9`](https://github.com/Bluetooth-Devices/habluetooth/commit/ae977b9d53c29c581ff6394a2078d2a2b01066dd)) ## v3.30.0 (2025-03-15) ### Features - Improve performance of on demand advertisementdata construction ([`ab005cb`](https://github.com/Bluetooth-Devices/habluetooth/commit/ab005cbef5e2ece74a0facd502fca7173ba2b1fc)) ## v3.29.0 (2025-03-15) ### Features - Improve performance for device with large manufacturer data history ([`ec1f6aa`](https://github.com/Bluetooth-Devices/habluetooth/commit/ec1f6aa7989cea2a589029362461dba4f7a8f0db)) ## v3.28.0 (2025-03-15) ### Features - Improve performance of local name checks ([`9f57d2f`](https://github.com/Bluetooth-Devices/habluetooth/commit/9f57d2fcc23595b376d5785162c21633514f44bd)) ## v3.27.0 (2025-03-14) ### Features - Improve performance of base_scanner ([`5b8c59c`](https://github.com/Bluetooth-Devices/habluetooth/commit/5b8c59c7ffadead5997fa457b07ff37ec8ec31b5)) ## v3.26.0 (2025-03-14) ### Features - Improve manager performance ([`e0bdace`](https://github.com/Bluetooth-Devices/habluetooth/commit/e0bdace8180ff3ac450447be99f700fd647fb659)) ## v3.25.1 (2025-03-13) ### Bug fixes - Downgrade scanner gone quiet logger to debug ([`d450ffc`](https://github.com/Bluetooth-Devices/habluetooth/commit/d450ffca38dec015f44b5be08af484fe8ca09866)) ## v3.25.0 (2025-03-05) ### Bug fixes - Use trusted publishing for wheels ([`c726687`](https://github.com/Bluetooth-Devices/habluetooth/commit/c726687affb0025037676b76cf4ecefdef0da23f)) ### Features - Add armv7l to wheel builds ([`e394707`](https://github.com/Bluetooth-Devices/habluetooth/commit/e394707b6b7ffc54e6dc5b8c038a08c5404f1777)) - Reduce wheel sizes ([`5e6b644`](https://github.com/Bluetooth-Devices/habluetooth/commit/5e6b64476ff2db7a215d1b0d58ef01c04b839d34)) ## v3.24.1 (2025-02-27) ### Bug fixes - Update scanner discover signature for newer bleak ([`a071cb8`](https://github.com/Bluetooth-Devices/habluetooth/commit/a071cb8e3f921da30055b94a74a4b0aa339e53de)) ## v3.24.0 (2025-02-22) ### Features - Improve logging of scanner failures and time_since_last_detection ([`f0ff045`](https://github.com/Bluetooth-Devices/habluetooth/commit/f0ff04586849bda3933fbe98e8e1335c308999c4)) ## v3.23.0 (2025-02-21) ### Features - Add debug logging for connection paths ([`562d469`](https://github.com/Bluetooth-Devices/habluetooth/commit/562d46912e7596febc3ebcc0301280e6f334172b)) ## v3.22.1 (2025-02-20) ### Bug fixes - Try to force stop discovery if its stuck on ([`e28d836`](https://github.com/Bluetooth-Devices/habluetooth/commit/e28d836d28f0b8062831ee209ba54a7735c4d5ae)) ## v3.22.0 (2025-02-18) ### Features - Allow remote scanners to set current and requested mode ([`a39ba18`](https://github.com/Bluetooth-Devices/habluetooth/commit/a39ba184e0d01f983133534e4fd7c1b6202210fb)) ## v3.21.1 (2025-02-04) ### Bug fixes - Update poetry to v2 ([`aefe36e`](https://github.com/Bluetooth-Devices/habluetooth/commit/aefe36e2507566224267f371511c1f1c748a37a9)) ## v3.21.0 (2025-02-01) ### Features - Reduce remote scanner adv processing overhead ([`7bf302b`](https://github.com/Bluetooth-Devices/habluetooth/commit/7bf302bac3855cf7e229dd2744acce513b2e2ee4)) ## v3.20.1 (2025-02-01) ### Bug fixes - Remove unused centralbluetoothmanager in models ([`7466034`](https://github.com/Bluetooth-Devices/habluetooth/commit/74660343b30fec50b927fdddd92e72eacb4da6cf)) - Precision loss when comparing advs from different sources ([`02279a9`](https://github.com/Bluetooth-Devices/habluetooth/commit/02279a95ca5b590768bd631bf39ee507a64db7ad)) ## v3.20.0 (2025-02-01) ### Features - Reduce adv tracker overhead ([`69168a6`](https://github.com/Bluetooth-Devices/habluetooth/commit/69168a64572ab3fba696d2afedeb015953afb0cc)) ## v3.19.0 (2025-02-01) ### Features - Reduce overhead to convert non-connectable bluetoothserviceinfobleak to connectable ([`37fc839`](https://github.com/Bluetooth-Devices/habluetooth/commit/37fc839d5fc73ff6f784ec8041606be82d58322b)) ## v3.18.0 (2025-02-01) ### Features - Refactor scanner_adv_received to reduce ref counting ([`a1945ce`](https://github.com/Bluetooth-Devices/habluetooth/commit/a1945cedc2373082814e8f4b4426a50c79788305)) ## v3.17.1 (2025-01-31) ### Bug fixes - Ensure allocations are available if the adapter never makes any connections ([`b3dfa48`](https://github.com/Bluetooth-Devices/habluetooth/commit/b3dfa48dba2482c16f61fceaf9a0f58ea55df982)) ## v3.17.0 (2025-01-31) ### Features - Remove the need to call set_manager to set up ([`1312bf7`](https://github.com/Bluetooth-Devices/habluetooth/commit/1312bf7d978ff585e66d99bde766e85773fce006)) ## v3.16.0 (2025-01-31) ### Features - Allow bluetoothmanager to be created with defaults ([`70b2f69`](https://github.com/Bluetooth-Devices/habluetooth/commit/70b2f6952fbd3ecd499a4c66ec305869158a428e)) ## v3.15.0 (2025-01-31) ### Features - Include findmy packets in wanted adverts ([`5217850`](https://github.com/Bluetooth-Devices/habluetooth/commit/5217850934bfed5d8e70f8b43c84cd97cf53cdac)) ## v3.14.0 (2025-01-29) ### Features - Add allocations to diagnostics ([`aa41088`](https://github.com/Bluetooth-Devices/habluetooth/commit/aa4108872478720ab4cbcf52c5add015441fe72d)) ## v3.13.0 (2025-01-28) ### Features - Add async_register_scanner_registration_callback and async_current_scanners to the manager ([`99fcb46`](https://github.com/Bluetooth-Devices/habluetooth/commit/99fcb46a73ea6cb8f01817263d01a342365be78f)) ## v3.12.0 (2025-01-22) ### Features - Add support for connection allocations for non-connectable scanners ([`d76b7c9`](https://github.com/Bluetooth-Devices/habluetooth/commit/d76b7c9624b6c4e6beedc1bd56dd1a3c0df70eec)) ## v3.11.2 (2025-01-22) ### Bug fixes - Re-release again for failed arm runners ([`af2bb50`](https://github.com/Bluetooth-Devices/habluetooth/commit/af2bb50879713378a32339e490a57b56083a4fa7)) ## v3.11.1 (2025-01-22) ### Bug fixes - Re-release due to failed github action ([`90e2192`](https://github.com/Bluetooth-Devices/habluetooth/commit/90e2192ff75c13ccf610fd06a61e64d60dfd1a18)) ## v3.11.0 (2025-01-22) ### Features - Add api for getting current slot allocations ([`0a9bef9`](https://github.com/Bluetooth-Devices/habluetooth/commit/0a9bef927c5f29c3e724fb60aa06706b6d896f82)) ## v3.10.0 (2025-01-21) ### Features - Add support for getting callbacks when adapter allocations change ([`c6fd2ba`](https://github.com/Bluetooth-Devices/habluetooth/commit/c6fd2babf0c6438ff85220edef95df3d3b4fae9c)) ## v3.9.2 (2025-01-20) ### Bug fixes - Increase rssi switch value to 16 ([`db367db`](https://github.com/Bluetooth-Devices/habluetooth/commit/db367dbef3fa883348a72cf17e29d9c26a09de53)) ## v3.9.1 (2025-01-20) ### Bug fixes - Increase rssi switch threshold for advertisements ([`297c269`](https://github.com/Bluetooth-Devices/habluetooth/commit/297c2693f9a2c007f0e70175c24416c8bb7da099)) ## v3.9.0 (2025-01-17) ### Features - Switch to native arm runners for wheel builds ([`bf7e98b`](https://github.com/Bluetooth-Devices/habluetooth/commit/bf7e98b099597916bb7566eb03472023f8acef97)) ## v3.8.0 (2025-01-10) ### Features - Add async_register_disappeared_callback ([`ec1d445`](https://github.com/Bluetooth-Devices/habluetooth/commit/ec1d4456ca15c6fca3248f2e5d73fcb1ba9d36c6)) ## v3.7.0 (2025-01-05) ### Bug fixes - Publish workflow ([`341c8a4`](https://github.com/Bluetooth-Devices/habluetooth/commit/341c8a4b72fb2818a3bed44632048d8570fc3b67)) ### Features - Start building wheels for python 3.13 ([`26dd831`](https://github.com/Bluetooth-Devices/habluetooth/commit/26dd831c28f3c0dfe0745769749e795e7937c7df)) - Add codspeed benchmarks ([`5905fbd`](https://github.com/Bluetooth-Devices/habluetooth/commit/5905fbd2c54adea04c0e55fe8a299f771e6f31ed)) ### Unknown ## v3.6.0 (2024-10-20) ### Features - Speed up creation of advertisementdata namedtuple ([`28f7e60`](https://github.com/Bluetooth-Devices/habluetooth/commit/28f7e6093c3985da16e537bc9d989d839ad80c56)) ## v3.5.0 (2024-10-05) ### Features - Add support for python 3.13 ([`b8a4783`](https://github.com/Bluetooth-Devices/habluetooth/commit/b8a4783a43f6e771321974d2c085e5e0dda9e195)) ## v3.4.1 (2024-09-22) ### Bug fixes - Ensure build system required cython 3 ([`dc85d2f`](https://github.com/Bluetooth-Devices/habluetooth/commit/dc85d2fd1b8c8e4d8eb4515aa60af06782fc8722)) ## v3.4.0 (2024-09-02) ### Features - Add a fast cython init path for bluetoothserviceinfobleak ([`f532ed2`](https://github.com/Bluetooth-Devices/habluetooth/commit/f532ed215b429f0bbd14dacc30f87c53f22af245)) ## v3.3.2 (2024-08-20) ### Bug fixes - Disable 3.13 wheels ([`9e8bbff`](https://github.com/Bluetooth-Devices/habluetooth/commit/9e8bbff6179e08bd6e05341ff48fff3adc5c6157)) ## v3.3.1 (2024-08-20) ### Bug fixes - Bump cibuildwheel to fix wheel builds ([`68d838a`](https://github.com/Bluetooth-Devices/habluetooth/commit/68d838a1d2adab9efe1fb5eba65e81b5dcc9a351)) ## v3.3.0 (2024-08-20) ### Bug fixes - Cleanup advertisementmonitor mapper ([`7d3483d`](https://github.com/Bluetooth-Devices/habluetooth/commit/7d3483d87d3e03c19cf528a1838acce5b194533e)) ### Features - Override devicefound and devicelost for passive monitoring ([`a802859`](https://github.com/Bluetooth-Devices/habluetooth/commit/a8028596bf3576a35750ae8575f173c75f918f28)) ## v3.2.0 (2024-07-27) ### Features - Small speed ups to scanner detection callback ([`7a5129a`](https://github.com/Bluetooth-Devices/habluetooth/commit/7a5129a40a12382c089453880210c41bb0f28a32)) ## v3.1.3 (2024-06-24) ### Bug fixes - Wheel builds ([`b9a8eec`](https://github.com/Bluetooth-Devices/habluetooth/commit/b9a8eec4f79c2098c0ec318b6b1ff7e3376febf2)) ## v3.1.2 (2024-06-24) ### Bug fixes - Fix license classifier ([`04aaaa1`](https://github.com/Bluetooth-Devices/habluetooth/commit/04aaaa186c755b869c8d75678f563f6a9c089829)) ## v3.1.1 (2024-05-23) ### Bug fixes - Missing classmethod decorator on find_device_by_address ([`aa08b13`](https://github.com/Bluetooth-Devices/habluetooth/commit/aa08b136660cddea7c356274c21f20b6d0eef1fa)) ## v3.1.0 (2024-05-22) ### Features - Speed up dispatching bleak callbacks ([`cbc8b26`](https://github.com/Bluetooth-Devices/habluetooth/commit/cbc8b26f90b9ea4f2a8569c0625b527dd37ef180)) ## v3.0.1 (2024-05-03) ### Bug fixes - Ensure lazy advertisement uses none when name is not present ([`c300f73`](https://github.com/Bluetooth-Devices/habluetooth/commit/c300f73ba82d3549ea4c156ef11023e9478c8b6c)) ## v3.0.0 (2024-05-02) ### Features - Make generation of advertisementdata lazy ([`25f8437`](https://github.com/Bluetooth-Devices/habluetooth/commit/25f843795927ad663a1d5ef1fa9472ec366b9da5)) ## v2.8.1 (2024-05-02) ### Bug fixes - Add missing find_device_by_address mapping ([`cc8e57e`](https://github.com/Bluetooth-Devices/habluetooth/commit/cc8e57eef7b97a6f2a30488a64d156cb5023c6c6)) ## v2.8.0 (2024-04-17) ### Features - Add support for recovering failed adapters after reboot ([`04948c3`](https://github.com/Bluetooth-Devices/habluetooth/commit/04948c337adf0f7b291e4e33618a7eae6dc4ebc2)) ## v2.7.0 (2024-04-17) ### Features - Improve fallback to passive mode when active mode fails ([`17ecc01`](https://github.com/Bluetooth-Devices/habluetooth/commit/17ecc012e096bec0113efea9ceb6a21bb50023fe)) ## v2.6.0 (2024-04-17) ### Features - Speed up stopping the scanner when its stuck setting up ([`bba8b51`](https://github.com/Bluetooth-Devices/habluetooth/commit/bba8b514490d98dca1020bbfefd9dc1e6a79af5f)) ## v2.5.3 (2024-04-17) ### Bug fixes - Ensure scanner is stopped on cancellation ([`a21d70a`](https://github.com/Bluetooth-Devices/habluetooth/commit/a21d70a1ac88135eade61c0abc8912c5b04a6b8b)) ## v2.5.2 (2024-04-16) ### Bug fixes - Ensure discovered_devices returns an empty list for offline scanners ([`2350543`](https://github.com/Bluetooth-Devices/habluetooth/commit/23505437c98529f692ab2dc0f5c3bdb5c9b7e3bd)) ## v2.5.1 (2024-04-16) ### Bug fixes - Wheel builds ([`5bd671a`](https://github.com/Bluetooth-Devices/habluetooth/commit/5bd671a159292dffe30a69639411926d0bc28123)) ## v2.5.0 (2024-04-16) ### Features - Fallback to passive scanning if active cannot start ([`3fae981`](https://github.com/Bluetooth-Devices/habluetooth/commit/3fae98162e6b0279375823a3b6e60ee51b87c1bb)) ## v2.4.2 (2024-02-29) ### Bug fixes - Android beacons in passive mode with flags 0x02 ([`8330e18`](https://github.com/Bluetooth-Devices/habluetooth/commit/8330e187550ec00ed415d3650a2c231921fb8ae7)) ## v2.4.1 (2024-02-23) ### Bug fixes - Avoid concurrent refreshes of adapters ([`d355b17`](https://github.com/Bluetooth-Devices/habluetooth/commit/d355b1768705706dec7062ad5d6267089d87a88e)) ## v2.4.0 (2024-01-22) ### Features - Improve error reporting resolution suggestions ([`afff5ba`](https://github.com/Bluetooth-Devices/habluetooth/commit/afff5ba4dfd8a5582174b367ae5ed9c9953b81e9)) ## v2.3.1 (2024-01-22) ### Bug fixes - Ensure unavailable callbacks can be removed from fired callbacks ([`65e7706`](https://github.com/Bluetooth-Devices/habluetooth/commit/65e7706ef4cdb99f9df5a00f666ab1d30e92e3b1)) ## v2.3.0 (2024-01-22) ### Features - Reduce overhead to remove callbacks by using sets to store callbacks ([`05ceb85`](https://github.com/Bluetooth-Devices/habluetooth/commit/05ceb85901b17f72988068997c7f39bc0179dca2)) ## v2.2.0 (2024-01-14) ### Features - Improve remote scanner performance ([`c549b1c`](https://github.com/Bluetooth-Devices/habluetooth/commit/c549b1cf9bbbda0c39dfce92d2888d5b990211da)) ## v2.1.0 (2024-01-10) ### Features - Add support for windows ([`788dd77`](https://github.com/Bluetooth-Devices/habluetooth/commit/788dd77ffac6664083821d5ba8b264725a3baaff)) ## v2.0.2 (2024-01-04) ### Bug fixes - Handle subclassed str in the client wrapper ([`f18a30e`](https://github.com/Bluetooth-Devices/habluetooth/commit/f18a30e48fe064993dc64f3af01c5d64b676a82f)) ## v2.0.1 (2023-12-31) ### Bug fixes - Switching scanners too quickly ([`bd53685`](https://github.com/Bluetooth-Devices/habluetooth/commit/bd536854457bd8b27f9e91921965b88b0ff798c3)) ## v2.0.0 (2023-12-21) ### Features - Simplify async_register_scanner by removing connectable argument ([`10ac6da`](https://github.com/Bluetooth-Devices/habluetooth/commit/10ac6da0672c121b5f0246ed688e98111adc7339)) ## v1.0.0 (2023-12-12) ### Features - Eliminate the need to pass the new_info_callback ([`65c54a6`](https://github.com/Bluetooth-Devices/habluetooth/commit/65c54a68500be6053677511ffd21ce3dca4b6991)) ## v0.11.1 (2023-12-11) ### Bug fixes - Do not schedule an expire when restoring devices ([`144cf15`](https://github.com/Bluetooth-Devices/habluetooth/commit/144cf15050a68cca66e7a2e24a5ddc7b87c32e41)) ## v0.11.0 (2023-12-11) ### Features - Relocate bluetoothserviceinfobleak ([`4f4f32d`](https://github.com/Bluetooth-Devices/habluetooth/commit/4f4f32d78d6abe21e28171f54ff5f3b17c8fb702)) ## v0.10.0 (2023-12-07) ### Features - Small speed ups to base_scanner ([`e1ff7e9`](https://github.com/Bluetooth-Devices/habluetooth/commit/e1ff7e9fb91a274b1a4bf6943a26e2a3f19780e7)) ## v0.9.0 (2023-12-06) ### Features - Speed up processing incoming service infos ([`55f6522`](https://github.com/Bluetooth-Devices/habluetooth/commit/55f6522ffc2adaf7e203ff4d2c1b13adc5d8c6a2)) ## v0.8.0 (2023-12-06) ### Features - Auto build the cythonized manager ([`c3441e5`](https://github.com/Bluetooth-Devices/habluetooth/commit/c3441e5095d62e6e70c2c879c4b5c109a87f463c)) - Add cython implementation for manager ([`266a602`](https://github.com/Bluetooth-Devices/habluetooth/commit/266a6022fb433ef9399f72e87b18b86897524784)) ## v0.7.0 (2023-12-05) ### Features - Port bluetooth manager from ha ([`757640a`](https://github.com/Bluetooth-Devices/habluetooth/commit/757640a7b7f60072588168501148ba750316f170)) ## v0.6.1 (2023-12-04) ### Bug fixes - Add missing cythonize for the adv tracker ([`8140195`](https://github.com/Bluetooth-Devices/habluetooth/commit/8140195a27ef83ea89ca643a5899d80839e574ae)) ## v0.6.0 (2023-12-04) ### Features - Port advertisement_tracker ([`378667b`](https://github.com/Bluetooth-Devices/habluetooth/commit/378667bce851b5076ee79ff223a72501c5575325)) ## v0.5.1 (2023-12-04) ### Bug fixes - Remove slots to keep hascanner patchable ([`d068f48`](https://github.com/Bluetooth-Devices/habluetooth/commit/d068f480d292619a1fc49a1256be98bdc6efadd6)) ## v0.5.0 (2023-12-03) ### Features - Port local scanner support from ha ([`1b1d0e4`](https://github.com/Bluetooth-Devices/habluetooth/commit/1b1d0e4bc17a44a1b20382da6ae28ea8e50e80b7)) ## v0.4.0 (2023-12-03) ### Features - Add more typing for incoming bluetooth data ([`de590e5`](https://github.com/Bluetooth-Devices/habluetooth/commit/de590e5c886801ff4a87f99c118be8855f337bd0)) ## v0.3.0 (2023-12-03) ### Features - Refactor to be able to use __pyx_pyobject_fastcall ([`e15074b`](https://github.com/Bluetooth-Devices/habluetooth/commit/e15074b172242f44f641e5232ebdf6297537a2b8)) - Add basic pxd ([`fd97d07`](https://github.com/Bluetooth-Devices/habluetooth/commit/fd97d07db7c0e8e0e877e1544fd0e392d14448b3)) ## v0.2.0 (2023-12-03) ### Features - Add cython pxd for base_scanner ([`0195710`](https://github.com/Bluetooth-Devices/habluetooth/commit/0195710bc25c8c3cc68b17a8f31cf281494fdc22)) ## v0.1.0 (2023-12-03) ### Features - Port base scanner from ha ([`e01a57b`](https://github.com/Bluetooth-Devices/habluetooth/commit/e01a57b6e0003ea8fe64b8e6e11ce09a35c1ada2)) ## v0.0.1 (2023-12-02) ### Bug fixes - Reserve name ([`5493984`](https://github.com/Bluetooth-Devices/habluetooth/commit/5493984483902039ca396498122e6094524bbae6)) Bluetooth-Devices-habluetooth-75cbe37/CLAUDE.md000066400000000000000000000225521521117704500212640ustar00rootroot00000000000000# CLAUDE.md — habluetooth Guide for AI assistants working in this repo. Skim it before editing. ## What this is `habluetooth` is the Bluetooth core library used by Home Assistant. It wraps [`bleak`](https://github.com/hbldh/bleak) with multi-scanner orchestration, advertisement tracking, a per-scanner discovery cache, and Cython-compiled hot paths. The package is published to PyPI and consumed by Home Assistant plus related glue libraries (`bleak-retry-connector`, `bleak-esphome`, `bluetooth-adapters`, etc.). It is **not** a daemon. It runs in-process inside HA's event loop and coordinates multiple `BaseHaScanner` instances (local USB/UART adapters via BlueZ, ESPHome remote scanners, etc.). ## Layout ``` src/habluetooth/ manager.py BluetoothManager — central orchestrator, dispatch, scoring base_scanner.py BaseHaScanner / BaseHaRemoteScanner — shared scanner logic scanner.py HaScanner — local bleak scanner (BlueZ / CoreBluetooth) scanner_device.py BluetoothScannerDevice dataclass advertisement_tracker.py Per-device advertising interval estimator models.py BluetoothServiceInfo(Bleak), HaScannerDetails, enums storage.py Discovery-cache (de)serialization — TypedDict ↔ dataclass wrappers.py HaBleakClientWrapper / HaBleakScannerWrapper (public API) channels/bluez.py Low-level BlueZ raw-advertisement channel (Cython) central_manager.py Module-level singleton holder (get_manager / set_manager) const.py Timeouts, thresholds, connection-parameter presets usage.py, util.py Misc helpers tests/ pytest suite (asyncio + freezegun + codspeed) docs/ Sphinx documentation (readthedocs) build_ext.py Cython build script invoked by Poetry ``` Each "hot" module has a paired `.pxd` declaring its Cython attributes. See [Cython rules](#cython) below. ## Core concepts - **BluetoothManager** (`manager.py`) is the single in-process orchestrator. It is held by `central_manager.CentralBluetoothManager.manager` and accessed via `get_manager()` / `set_manager()`. There is no DI: tests typically set it directly. - **Scanners** subclass `BaseHaScanner` (with the local-vs-remote split in `BaseHaRemoteScanner`). Each scanner reports advertisements to the manager via the `_async_on_advertisement` path; the manager dedupes, scores, and fans out to registered Bleak callbacks. - **Advertisement tracker** (`advertisement_tracker.py`) learns each device's advertising interval and feeds expiry decisions. Until it has `ADVERTISING_TIMES_NEEDED` samples it uses a fallback timeout. - **Wrappers** (`wrappers.py`) — `HaBleakClientWrapper` / `HaBleakScannerWrapper` are the _public_ Bleak-compatible facade. External callers (HA integrations) talk to these, not to scanners directly. - **Allocations** — for proxy scanners (ESPHome) the manager tracks per-source slot allocations via `async_on_allocation_changed`. This state is push-only and trusted; habluetooth does **not** independently verify slot counts. See [Allocations are unverified](#allocations) below. ## Storage / "schema" There is no SQL. The persistence layer is `storage.py`: - HA's `Store` writes a JSON blob (`DiscoveryStorageType`) to disk. - Round-trip: in-memory timestamps are `time.monotonic()`; on serialize they are converted to wall-time via `_get_monotonic_time_diff = time.time() - time.monotonic()`, then inverted on load. - There is **no `version` field** as of 6.1.0. Backward compatibility is shape-based (`data.get(KEY, default)` + strip-on-read for removed `BLEDevice` fields). If you touch the schema in a load-bearing way, consider proposing a version key. - `discovered_device_advertisement_data_from_dict` / `..._to_dict` are the only legitimate entry points — don't hand-roll the format. ## Cython `build_ext.py` cythonizes these modules: ``` advertisement_tracker.py base_scanner.py manager.py models.py scanner.py channels/bluez.py ``` **Rules:** 1. When changing the attributes of a class declared `cdef class` in a `.pxd`, update the matching `.pxd` or the build breaks. 2. Type annotations in `.py` are advisory; the `.pxd` is authoritative for Cython. `cdef public object foo` in `.pxd` corresponds to an instance attribute on the Python side. 3. Set `SKIP_CYTHON=1` to install without compilation (faster local dev, matches one half of the CI matrix). Set `REQUIRE_CYTHON=1` to fail if compilation fails (matches the other half). 4. Avoid implicit Cython type narrowing for objects that must remain Python types. `models.py` declares `_float = float`, `_str = str`, `_int = int` for exactly this reason — use the underscore aliases when you need to guarantee a Python object. ## Development workflow Setup: ```bash poetry install # cython build SKIP_CYTHON=1 poetry install # pure-python (faster) ``` Run tests: ```bash poetry run pytest # full suite poetry run pytest tests/test_manager.py # single file poetry run pytest -k allocation # by keyword ``` Lint / format (pre-commit covers ruff, ruff-format, mypy, codespell, prettier, poetry-check): ```bash pre-commit run -a ``` Type checking (strict mypy — see `pyproject.toml`): ```bash poetry run mypy src ``` Tests use `pytest-asyncio` (no auto-mode — mark coroutines explicitly) and `freezegun` for time control. `pytest-codspeed` powers the benchmark file (`tests/test_benchmark_base_scanner.py`). ## Coding conventions - **Python ≥ 3.11**, target `py311` for ruff/black. Code may use 3.11+ features (PEP 604 unions, `Self`, etc.). - `from __future__ import annotations` at the top of every module. - Imports sorted by isort (ruff `I`); first-party = `habluetooth`, `tests`. - Black formatting, line length 88. - Public API is exported from `habluetooth/__init__.py` — when adding a symbol, also add it to `__all__`. - Docstrings: ruff enforces `D` rules with a small ignore list (see `pyproject.toml`). Module/package/`__init__` docstrings are not required; public function/class docstrings are. - `mypy` is strict (`disallow_untyped_defs`, `disallow_any_generics`, `warn_unreachable`, `warn_unused_ignores`). Tests are exempted via override. - Logging: module-level `_LOGGER = logging.getLogger(__name__)`. Never print. - No `assert` in production code (ruff `S101`). Tests are exempted. ## Commit / PR conventions - **Conventional Commits PR title, lowercase subject.** PRs are squash-merged, so the **PR title** becomes the commit on `main` and is the only string that has to parse as a Conventional Commit. The repo enforces this via the `pr-title` CI job in `ci.yml` using `amannn/action-semantic-pull-request`. Accepted types: `feat`, `fix`, `chore`, `ci`, `docs`, `refactor`, `test`, `perf`, `build`, etc. The subject (text after `type(scope):`) must start lowercase (enforced by `subjectPattern: ^(?![A-Z]).+$`). Per-commit messages on the PR branch are **not** linted; they get collapsed at squash-merge. - Releases are fully automated by `python-semantic-release` from the commit log. Anything that should land in the changelog must use `feat:` or `fix:` (or a breaking-change footer). `chore*` and `ci*` are excluded. - The version lives in three places, kept in sync by semantic-release: `pyproject.toml`, `src/habluetooth/__init__.py:__version__`, `docs/conf.py:release`. **Do not bump versions by hand.** - PRs target `main`. CI runs the matrix `{3.11, 3.12, 3.13, 3.14, 3.14t} × {linux, macos, windows} × {skip_cython, use_cython}` — flaky breakage in one cell usually means a Cython annotation got too aggressive. ## Gotchas - **Allocations are unverified.** `_allocations[source]` is updated solely from `async_on_allocation_changed` (called by external glue when a proxy reports slot state). habluetooth has no parallel "currently-connected" counter and does not cross-check. When a proxy gets stuck, the only observable symptom is the per-source score collapsing to `NO_RSSI_VALUE` in `wrappers.py`. - **`_connect_in_progress` is the only per-scanner "busy" signal.** There is no counter of active or completed connections — only "does this scanner have a connect attempt in flight right now". - **bleak 3.0 deprecations:** - `BleakScanner(adapter="hciN")` is gone; use `BleakScanner(bluez={"adapter": "hciN"})`. When also passing `PASSIVE_SCANNER_ARGS` (itself a `BlueZScannerArgs`), merge — don't overwrite. - `BLEDevice(..., rssi=-NN)` is gone; bleak 3.0 only accepts `(address, name, details)`. RSSI lives on `AdvertisementData` only. - **Test deprecation hygiene:** ```bash pytest tests/ -W "error::DeprecationWarning" \ -W "ignore::DeprecationWarning:asyncio" ``` turns each deprecation into a failure (while ignoring asyncio's internal ones). - **Time source.** Everything in hot paths uses `bluetooth_data_tools.monotonic_time_coarse()` — do not mix with `time.time()` or `time.monotonic()` except at storage boundaries. ## When in doubt - Public API contracts live in `__init__.py`'s `__all__` and in `wrappers.py`. Breaking those is a major version bump. - Internal refactors are fine as long as the public surface, the storage schema, and the scanner-callback signatures don't move. - Tests are the source of truth for expected behavior — if a test is awkward to write, the API probably needs to change, not the test. Bluetooth-Devices-habluetooth-75cbe37/CONTRIBUTING.md000066400000000000000000000074321521117704500222360ustar00rootroot00000000000000# Contributing Contributions are welcome, and they are greatly appreciated! Every little helps, and credit will always be given. You can contribute in many ways: ## Types of Contributions ### Report Bugs Report bugs to [our issue page][gh-issues]. If you are reporting a bug, please include: - Your operating system name and version. - Any details about your local setup that might be helpful in troubleshooting. - Detailed steps to reproduce the bug. ### Fix Bugs Look through the GitHub issues for bugs. Anything tagged with "bug" and "help wanted" is open to whoever wants to implement it. ### Implement Features Look through the GitHub issues for features. Anything tagged with "enhancement" and "help wanted" is open to whoever wants to implement it. ### Write Documentation habluetooth could always use more documentation, whether as part of the official habluetooth docs, in docstrings, or even on the web in blog posts, articles, and such. ### Submit Feedback The best way to send feedback [our issue page][gh-issues] on GitHub. If you are proposing a feature: - Explain in detail how it would work. - Keep the scope as narrow as possible, to make it easier to implement. - Remember that this is a volunteer-driven project, and that contributions are welcome 😊 ## Get Started! Ready to contribute? Here's how to set yourself up for local development. 1. Fork the repo on GitHub. 2. Clone your fork locally: ```shell $ git clone git@github.com:your_name_here/habluetooth.git ``` 3. Install the project dependencies with [Poetry](https://python-poetry.org): ```shell $ poetry install ``` 4. Create a branch for local development: ```shell $ git checkout -b name-of-your-bugfix-or-feature ``` Now you can make your changes locally. 5. When you're done making changes, check that your changes pass our tests: ```shell $ poetry run pytest ``` 6. Linting is done through [pre-commit](https://pre-commit.com). Provided you have the tool installed globally, you can run them all as one-off: ```shell $ pre-commit run -a ``` Or better, install the hooks once and have them run automatically each time you commit: ```shell $ pre-commit install ``` 7. Commit your changes and push your branch to GitHub: ```shell $ git add . $ git commit -m "feat(something): your detailed description of your changes" $ git push origin name-of-your-bugfix-or-feature ``` Note: the commit message should follow [the conventional commits](https://www.conventionalcommits.org). We run [`commitlint` on CI](https://github.com/marketplace/actions/commit-linter) to validate it, and if you've installed pre-commit hooks at the previous step, the message will be checked at commit time. 8. Submit a pull request through the GitHub website or using the GitHub CLI (if you have it installed): ```shell $ gh pr create --fill ``` ## Pull Request Guidelines We like to have the pull request open as soon as possible, that's a great place to discuss any piece of work, even unfinished. You can use draft pull request if it's still a work in progress. Here are a few guidelines to follow: 1. Include tests for feature or bug fixes. 2. Update the documentation for significant features. 3. Ensure tests are passing on CI. ## Tips To run a subset of tests: ```shell $ pytest tests ``` ## Making a new release The deployment should be automated and can be triggered from the Semantic Release workflow in GitHub. The next version will be based on [the commit logs](https://python-semantic-release.readthedocs.io/en/latest/commit-log-parsing.html#commit-log-parsing). This is done by [python-semantic-release](https://python-semantic-release.readthedocs.io/en/latest/index.html) via a GitHub action. [gh-issues]: https://github.com/bluetooth-devices/habluetooth/issues Bluetooth-Devices-habluetooth-75cbe37/LICENSE000066400000000000000000000261211521117704500210060ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2023 J. Nick Koston Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. Bluetooth-Devices-habluetooth-75cbe37/README.md000066400000000000000000000076041521117704500212650ustar00rootroot00000000000000# habluetooth

CI Status Documentation Status Test coverage percentage CodSpeed Badge

Poetry black pre-commit

PyPI Version Supported Python versions License

--- **Documentation**: https://habluetooth.readthedocs.io **Source Code**: https://github.com/bluetooth-devices/habluetooth --- High availability Bluetooth ## Installation Install this via pip (or your favourite package manager): `pip install habluetooth` ## Contributors ✨ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! ## Credits This package was created with [Copier](https://copier.readthedocs.io/) and the [browniebroke/pypackage-template](https://github.com/browniebroke/pypackage-template) project template. Bluetooth-Devices-habluetooth-75cbe37/build_ext.py000066400000000000000000000044221521117704500223320ustar00rootroot00000000000000"""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/auto_scheduler.py", "src/habluetooth/base_scanner.py", "src/habluetooth/manager.py", "src/habluetooth/models.py", "src/habluetooth/scanner.py", "src/habluetooth/channels/bluez.py", ] EXTENSIONS = [ Extension( ext.removeprefix("src/").removesuffix(".py").replace("/", "."), [ext], language="c", extra_compile_args=["-O3", "-g0"], ) for ext in TO_CYTHONIZE ] class BuildExt(build_ext): """Build extension.""" def build_extensions(self) -> None: """Build extensions.""" if self.parallel is None: # type: ignore[has-type, unused-ignore] self.parallel = os.cpu_count() or 1 try: super().build_extensions() except Exception as ex: # nosec # noqa: BLE001 # Cython is optional; any compile failure (missing C compiler, # platform mismatch, etc.) should fall back to the pure-Python # install rather than break the build. _LOGGER.debug("Failed to build extensions: %s", ex, exc_info=True) def build(setup_kwargs: Any) -> None: """Build optional cython modules.""" if os.environ.get("SKIP_CYTHON"): return try: # Cython is optional; defer the import so the SKIP_CYTHON # branch above never has to find it on sys.path. from Cython.Build import cythonize # noqa: PLC0415 setup_kwargs.update( { "ext_modules": cythonize( EXTENSIONS, compiler_directives={"language_level": "3"}, # Python 3 annotate=bool(os.environ.get("CYTHON_ANNOTATE")), ), "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 Bluetooth-Devices-habluetooth-75cbe37/docs/000077500000000000000000000000001521117704500207275ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-75cbe37/docs/Makefile000066400000000000000000000013721521117704500223720ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build .PHONY: help livehtml Makefile # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) # Build, watch and serve docs with live reload livehtml: sphinx-autobuild -b html -c . $(SOURCEDIR) $(BUILDDIR)/html # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) Bluetooth-Devices-habluetooth-75cbe37/docs/_static/000077500000000000000000000000001521117704500223555ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-75cbe37/docs/_static/.gitkeep000066400000000000000000000000001521117704500237740ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-75cbe37/docs/changelog.md000066400000000000000000000000601521117704500231740ustar00rootroot00000000000000(changelog)= ```{include} ../CHANGELOG.md ``` Bluetooth-Devices-habluetooth-75cbe37/docs/conf.py000066400000000000000000000012171521117704500222270ustar00rootroot00000000000000# 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 = "6.8.3" # General configuration extensions = [ "myst_parser", ] # The suffix of source filenames. source_suffix = [ ".rst", ".md", ] templates_path = [ "_templates", ] exclude_patterns = [ "_build", "Thumbs.db", ".DS_Store", ] # Options for HTML output html_theme = "furo" html_static_path = ["_static"] Bluetooth-Devices-habluetooth-75cbe37/docs/contributing.md000066400000000000000000000000661521117704500237620ustar00rootroot00000000000000(contributing)= ```{include} ../CONTRIBUTING.md ``` Bluetooth-Devices-habluetooth-75cbe37/docs/index.md000066400000000000000000000003501521117704500223560ustar00rootroot00000000000000# Welcome to habluetooth documentation! ```{toctree} :caption: Installation & Usage :maxdepth: 2 installation usage ``` ```{toctree} :caption: Project Info :maxdepth: 2 changelog contributing ``` ```{include} ../README.md ``` Bluetooth-Devices-habluetooth-75cbe37/docs/installation.md000066400000000000000000000004151521117704500237520ustar00rootroot00000000000000(installation)= # Installation The package is published on [PyPI](https://pypi.org/project/habluetooth/) and can be installed with `pip` (or any equivalent): ```bash pip install habluetooth ``` Next, see the {ref}`section about usage ` to see how to use it. Bluetooth-Devices-habluetooth-75cbe37/docs/make.bat000066400000000000000000000013751521117704500223420ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.https://www.sphinx-doc.org/ exit /b 1 ) if "%1" == "" goto help %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd Bluetooth-Devices-habluetooth-75cbe37/docs/usage.md000066400000000000000000000003261521117704500223560ustar00rootroot00000000000000(usage)= # Usage Assuming that you've followed the {ref}`installations steps `, you're now ready to use this package. Start by importing it: ```python import habluetooth ``` TODO: Document usage Bluetooth-Devices-habluetooth-75cbe37/examples/000077500000000000000000000000001521117704500216155ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-75cbe37/examples/bluez_api.py000066400000000000000000000024401521117704500241410ustar00rootroot00000000000000import asyncio import logging from habluetooth import BluetoothManager, BluetoothScanningMode from habluetooth.scanner import HaScanner int_ = int class LoggingHaScanner(HaScanner): """Logging ha scanner.""" def _async_on_raw_bluez_advertisement( self, address: bytes, address_type: int_, rssi: int_, flags: int_, data: bytes, ) -> None: """Handle raw advertisement data.""" print( f"address={address!r}, address_type={address_type}, " f"rssi={rssi}, flags={flags}, data={data!r}" ) async def main() -> None: """Main function to test the Bluetooth management API.""" # Set up logging logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger("habluetooth") logger.setLevel(logging.DEBUG) manager = BluetoothManager() await manager.async_setup() # Create an instance of MGMTBluetoothCtl scanner = LoggingHaScanner( BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF" ) manager.async_register_scanner(scanner) try: await asyncio.Event().wait() finally: # Close the management interface when done manager.async_stop() if __name__ == "__main__": # Run the main function asyncio.run(main()) Bluetooth-Devices-habluetooth-75cbe37/poetry.lock000066400000000000000000005614701521117704500222100ustar00rootroot00000000000000# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. [[package]] name = "accessible-pygments" version = "0.0.5" description = "A collection of accessible pygments styles" optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7"}, {file = "accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872"}, ] [package.dependencies] pygments = ">=1.5" [package.extras] dev = ["pillow", "pkginfo (>=1.10)", "playwright", "pre-commit", "setuptools", "twine (>=5.0)"] tests = ["hypothesis", "pytest"] [[package]] name = "aiooui" version = "0.1.9" description = "Async OUI lookups" optional = false python-versions = "<4.0,>=3.9" groups = ["main"] files = [ {file = "aiooui-0.1.9-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:64d904b43f14dd1d8d9fcf1684d9e2f558bc5e0bd68dc10023c93355c9027907"}, {file = "aiooui-0.1.9-py3-none-any.whl", hash = "sha256:737a5e62d8726540218c2b70e5f966d9912121e4644f3d490daf8f3c18b182e5"}, {file = "aiooui-0.1.9.tar.gz", hash = "sha256:e8c8bc59ab352419e0747628b4cce7c4e04d492574c1971e223401126389c5d8"}, ] [[package]] name = "alabaster" version = "1.0.0" description = "A light, configurable Sphinx theme" optional = false python-versions = ">=3.10" groups = ["docs"] files = [ {file = "alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b"}, {file = "alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e"}, ] [[package]] name = "anyio" version = "4.9.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.9" groups = ["dev", "docs"] files = [ {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, ] [package.dependencies] idna = ">=2.8" sniffio = ">=1.1" typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] trio = ["trio (>=0.26.1)"] [[package]] name = "async-interrupt" version = "1.2.2" description = "Context manager to raise an exception when a future is done" optional = false python-versions = ">=3.9" groups = ["main"] files = [ {file = "async_interrupt-1.2.2-py3-none-any.whl", hash = "sha256:0a8deb884acfb5fe55188a693ae8a4381bbbd2cb6e670dac83869489513eec2c"}, {file = "async_interrupt-1.2.2.tar.gz", hash = "sha256:be4331a029b8625777905376a6dc1370984c8c810f30b79703f3ee039d262bf7"}, ] [[package]] name = "babel" version = "2.17.0" description = "Internationalization utilities" optional = false python-versions = ">=3.8" groups = ["docs"] files = [ {file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"}, {file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"}, ] [package.extras] dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] [[package]] name = "beautifulsoup4" version = "4.13.4" description = "Screen-scraping library" optional = false python-versions = ">=3.7.0" groups = ["docs"] files = [ {file = "beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b"}, {file = "beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195"}, ] [package.dependencies] soupsieve = ">1.2" typing-extensions = ">=4.0.0" [package.extras] cchardet = ["cchardet"] chardet = ["chardet"] charset-normalizer = ["charset-normalizer"] html5lib = ["html5lib"] lxml = ["lxml"] [[package]] name = "bleak" version = "3.0.2" description = "Bluetooth Low Energy platform Agnostic Klient" optional = false python-versions = ">=3.10" groups = ["main"] files = [ {file = "bleak-3.0.2-py3-none-any.whl", hash = "sha256:39092feb9e83f1df5ad2f88e837723c7211c982ce9e9cda6235104bc2ebe0d0d"}, {file = "bleak-3.0.2.tar.gz", hash = "sha256:c2229cb8238d5876b4bd05c74bf7a1aea1f88da39d2e51ac9dfd5cc319d5265f"}, ] [package.dependencies] dbus-fast = {version = ">=1.83.0", markers = "sys_platform == \"linux\""} pyobjc-core = {version = ">=10.3", markers = "sys_platform == \"darwin\""} pyobjc-framework-corebluetooth = {version = ">=10.3", markers = "sys_platform == \"darwin\""} pyobjc-framework-libdispatch = {version = ">=10.3", markers = "sys_platform == \"darwin\""} typing-extensions = {version = ">=4.7.0", markers = "python_full_version < \"3.12.0\""} winrt-runtime = {version = ">=3.1", markers = "sys_platform == \"win32\""} winrt-windows-devices-bluetooth = {version = ">=3.1", markers = "sys_platform == \"win32\""} winrt-windows-devices-bluetooth-advertisement = {version = ">=3.1", markers = "sys_platform == \"win32\""} winrt-windows-devices-bluetooth-genericattributeprofile = {version = ">=3.1", markers = "sys_platform == \"win32\""} winrt-windows-devices-enumeration = {version = ">=3.1", markers = "sys_platform == \"win32\""} winrt-windows-devices-radios = {version = ">=3.1", markers = "sys_platform == \"win32\""} winrt-windows-foundation = {version = ">=3.1", markers = "sys_platform == \"win32\""} winrt-windows-foundation-collections = {version = ">=3.1", markers = "sys_platform == \"win32\""} winrt-windows-storage-streams = {version = ">=3.1", markers = "sys_platform == \"win32\""} [package.extras] pythonista = ["bleak-pythonista (>=0.1.1)"] [[package]] name = "bleak-retry-connector" version = "4.6.1" description = "A connector for Bleak Clients that handles transient connection failures" optional = false python-versions = ">=3.10" groups = ["main"] files = [ {file = "bleak_retry_connector-4.6.1-py3-none-any.whl", hash = "sha256:018ba421babe05785e5a6497c73f3894772fad0f7fa5b054d48beb3d180ce0c4"}, {file = "bleak_retry_connector-4.6.1.tar.gz", hash = "sha256:ac2d19362f96757708dff2b0fedfefd5a8d8efb724027a777e54cc8ac2fc5a3d"}, ] [package.dependencies] bleak = ">=2" bluetooth-adapters = {version = ">=0.15.2", markers = "platform_system == \"Linux\""} dbus-fast = {version = ">=4.3.0", markers = "platform_system == \"Linux\""} [[package]] name = "bluetooth-adapters" version = "2.2.0" description = "Tools to enumerate and find Bluetooth Adapters" optional = false python-versions = ">=3.10" groups = ["main"] files = [ {file = "bluetooth_adapters-2.2.0-py3-none-any.whl", hash = "sha256:52d6cf4d9c28bbf987ee5a27a3fed33f37edac3e0de202da6fbc2e6925adf1e3"}, {file = "bluetooth_adapters-2.2.0.tar.gz", hash = "sha256:b6011cdaf68b6d075b5a90c85c5102844799fdc377758d78142a638c7b0c06fb"}, ] [package.dependencies] aiooui = ">=0.1.1" bleak = ">=1" dbus-fast = {version = ">=1.21.0", markers = "platform_system == \"Linux\""} uart-devices = ">=0.1.0" usb-devices = ">=0.4.5" [package.extras] docs = ["Sphinx (>=5,<8)", "myst-parser (>=0.18,<3.1)", "sphinx-rtd-theme (>=1,<4)"] [[package]] name = "bluetooth-auto-recovery" version = "1.6.4" description = "Recover bluetooth adapters that are in an stuck state" optional = false python-versions = ">=3.10" groups = ["main"] files = [ {file = "bluetooth_auto_recovery-1.6.4-py3-none-any.whl", hash = "sha256:39485c41e17a2d4887c1fbf04b4e2fd37f0f3c7898db388753e026ca3addf055"}, {file = "bluetooth_auto_recovery-1.6.4.tar.gz", hash = "sha256:c69a9f3b5e00239ab005d808aa5e7afa3c36a82f86e9531b6c7682bde1bc3ecc"}, ] [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.29.18" description = "Tools for converting bluetooth data and packets" optional = false python-versions = ">=3.10" groups = ["main"] files = [ {file = "bluetooth_data_tools-1.29.18-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:fd5408d54eac9f5ecf34193a40e1badb905ae8b3a1801b92252e365064888c11"}, {file = "bluetooth_data_tools-1.29.18.tar.gz", hash = "sha256:87f678cc7b4963cb3ba73064dd72155f915bec4b21f22acd997848ddc0b1c67b"}, ] [package.dependencies] cryptography = ">=47.0.0" [package.extras] docs = ["Sphinx (>=5,<9)", "myst-parser (>=0.18,<4.1)", "sphinx-rtd-theme (>=1,<4)"] [[package]] name = "btsocket" version = "0.3.0" description = "Python library for BlueZ Bluetooth Management API" optional = false python-versions = "*" groups = ["main"] files = [ {file = "btsocket-0.3.0-py2.py3-none-any.whl", hash = "sha256:949821c1b580a88e73804ad610f5173d6ae258e7b4e389da4f94d614344f1a9c"}, {file = "btsocket-0.3.0.tar.gz", hash = "sha256:7ea495de0ff883f0d9f8eea59c72ca7fed492994df668fe476b84d814a147a0d"}, ] [package.extras] dev = ["bumpversion", "coverage", "pycodestyle", "pygments", "sphinx", "sphinx-rtd-theme", "twine"] docs = ["pygments", "sphinx", "sphinx-rtd-theme"] rel = ["bumpversion", "twine"] test = ["coverage", "pycodestyle"] [[package]] name = "certifi" version = "2025.6.15" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["docs"] files = [ {file = "certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057"}, {file = "certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b"}, ] [[package]] name = "cffi" version = "2.0.0" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.9" groups = ["main"] markers = "platform_python_implementation != \"PyPy\"" files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, ] [package.dependencies] pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} [[package]] name = "charset-normalizer" version = "3.4.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" groups = ["docs"] files = [ {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6"}, {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d"}, {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86"}, {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c"}, {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0"}, {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef"}, {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6"}, {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366"}, {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db"}, {file = "charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a"}, {file = "charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509"}, {file = "charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2"}, {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645"}, {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd"}, {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8"}, {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f"}, {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7"}, {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9"}, {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544"}, {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82"}, {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0"}, {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5"}, {file = "charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a"}, {file = "charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28"}, {file = "charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7"}, {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3"}, {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a"}, {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214"}, {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a"}, {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd"}, {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981"}, {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c"}, {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b"}, {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d"}, {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f"}, {file = "charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c"}, {file = "charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e"}, {file = "charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0"}, {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf"}, {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e"}, {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1"}, {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c"}, {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691"}, {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0"}, {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b"}, {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff"}, {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b"}, {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148"}, {file = "charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7"}, {file = "charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980"}, {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184"}, {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa"}, {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344"}, {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da"}, {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02"}, {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d"}, {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4"}, {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f"}, {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64"}, {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f"}, {file = "charset_normalizer-3.4.2-cp37-cp37m-win32.whl", hash = "sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58"}, {file = "charset_normalizer-3.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2"}, {file = "charset_normalizer-3.4.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb"}, {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a"}, {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45"}, {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5"}, {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1"}, {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027"}, {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b"}, {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455"}, {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01"}, {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58"}, {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681"}, {file = "charset_normalizer-3.4.2-cp38-cp38-win32.whl", hash = "sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7"}, {file = "charset_normalizer-3.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a"}, {file = "charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4"}, {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7"}, {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836"}, {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597"}, {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7"}, {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f"}, {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba"}, {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12"}, {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518"}, {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5"}, {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3"}, {file = "charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471"}, {file = "charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e"}, {file = "charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0"}, {file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"}, ] [[package]] name = "click" version = "8.2.1" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" groups = ["docs"] files = [ {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, ] [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} [[package]] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["dev", "docs"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] markers = {dev = "sys_platform == \"win32\""} [[package]] name = "coverage" version = "7.10.6" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "coverage-7.10.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70e7bfbd57126b5554aa482691145f798d7df77489a177a6bef80de78860a356"}, {file = "coverage-7.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301"}, {file = "coverage-7.10.6-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c61fc91ab80b23f5fddbee342d19662f3d3328173229caded831aa0bd7595460"}, {file = "coverage-7.10.6-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10356fdd33a7cc06e8051413140bbdc6f972137508a3572e3f59f805cd2832fd"}, {file = "coverage-7.10.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80b1695cf7c5ebe7b44bf2521221b9bb8cdf69b1f24231149a7e3eb1ae5fa2fb"}, {file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2e4c33e6378b9d52d3454bd08847a8651f4ed23ddbb4a0520227bd346382bbc6"}, {file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c8a3ec16e34ef980a46f60dc6ad86ec60f763c3f2fa0db6d261e6e754f72e945"}, {file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7d79dabc0a56f5af990cc6da9ad1e40766e82773c075f09cc571e2076fef882e"}, {file = "coverage-7.10.6-cp310-cp310-win32.whl", hash = "sha256:86b9b59f2b16e981906e9d6383eb6446d5b46c278460ae2c36487667717eccf1"}, {file = "coverage-7.10.6-cp310-cp310-win_amd64.whl", hash = "sha256:e132b9152749bd33534e5bd8565c7576f135f157b4029b975e15ee184325f528"}, {file = "coverage-7.10.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c706db3cabb7ceef779de68270150665e710b46d56372455cd741184f3868d8f"}, {file = "coverage-7.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e0c38dc289e0508ef68ec95834cb5d2e96fdbe792eaccaa1bccac3966bbadcc"}, {file = "coverage-7.10.6-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:752a3005a1ded28f2f3a6e8787e24f28d6abe176ca64677bcd8d53d6fe2ec08a"}, {file = "coverage-7.10.6-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:689920ecfd60f992cafca4f5477d55720466ad2c7fa29bb56ac8d44a1ac2b47a"}, {file = "coverage-7.10.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec98435796d2624d6905820a42f82149ee9fc4f2d45c2c5bc5a44481cc50db62"}, {file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b37201ce4a458c7a758ecc4efa92fa8ed783c66e0fa3c42ae19fc454a0792153"}, {file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2904271c80898663c810a6b067920a61dd8d38341244a3605bd31ab55250dad5"}, {file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5aea98383463d6e1fa4e95416d8de66f2d0cb588774ee20ae1b28df826bcb619"}, {file = "coverage-7.10.6-cp311-cp311-win32.whl", hash = "sha256:e3fb1fa01d3598002777dd259c0c2e6d9d5e10e7222976fc8e03992f972a2cba"}, {file = "coverage-7.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:f35ed9d945bece26553d5b4c8630453169672bea0050a564456eb88bdffd927e"}, {file = "coverage-7.10.6-cp311-cp311-win_arm64.whl", hash = "sha256:99e1a305c7765631d74b98bf7dbf54eeea931f975e80f115437d23848ee8c27c"}, {file = "coverage-7.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b2dd6059938063a2c9fee1af729d4f2af28fd1a545e9b7652861f0d752ebcea"}, {file = "coverage-7.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:388d80e56191bf846c485c14ae2bc8898aa3124d9d35903fef7d907780477634"}, {file = "coverage-7.10.6-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:90cb5b1a4670662719591aa92d0095bb41714970c0b065b02a2610172dbf0af6"}, {file = "coverage-7.10.6-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:961834e2f2b863a0e14260a9a273aff07ff7818ab6e66d2addf5628590c628f9"}, {file = "coverage-7.10.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf9a19f5012dab774628491659646335b1928cfc931bf8d97b0d5918dd58033c"}, {file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:99c4283e2a0e147b9c9cc6bc9c96124de9419d6044837e9799763a0e29a7321a"}, {file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:282b1b20f45df57cc508c1e033403f02283adfb67d4c9c35a90281d81e5c52c5"}, {file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cdbe264f11afd69841bd8c0d83ca10b5b32853263ee62e6ac6a0ab63895f972"}, {file = "coverage-7.10.6-cp312-cp312-win32.whl", hash = "sha256:a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d"}, {file = "coverage-7.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:856986eadf41f52b214176d894a7de05331117f6035a28ac0016c0f63d887629"}, {file = "coverage-7.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80"}, {file = "coverage-7.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6"}, {file = "coverage-7.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80"}, {file = "coverage-7.10.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003"}, {file = "coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27"}, {file = "coverage-7.10.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4"}, {file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d"}, {file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc"}, {file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc"}, {file = "coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e"}, {file = "coverage-7.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32"}, {file = "coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2"}, {file = "coverage-7.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b"}, {file = "coverage-7.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393"}, {file = "coverage-7.10.6-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27"}, {file = "coverage-7.10.6-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df"}, {file = "coverage-7.10.6-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb"}, {file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282"}, {file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4"}, {file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21"}, {file = "coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0"}, {file = "coverage-7.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5"}, {file = "coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b"}, {file = "coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e"}, {file = "coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb"}, {file = "coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034"}, {file = "coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1"}, {file = "coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a"}, {file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb"}, {file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d"}, {file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747"}, {file = "coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5"}, {file = "coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713"}, {file = "coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32"}, {file = "coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65"}, {file = "coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6"}, {file = "coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0"}, {file = "coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e"}, {file = "coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5"}, {file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7"}, {file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5"}, {file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0"}, {file = "coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7"}, {file = "coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930"}, {file = "coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b"}, {file = "coverage-7.10.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90558c35af64971d65fbd935c32010f9a2f52776103a259f1dee865fe8259352"}, {file = "coverage-7.10.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8953746d371e5695405806c46d705a3cd170b9cc2b9f93953ad838f6c1e58612"}, {file = "coverage-7.10.6-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c83f6afb480eae0313114297d29d7c295670a41c11b274e6bca0c64540c1ce7b"}, {file = "coverage-7.10.6-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7eb68d356ba0cc158ca535ce1381dbf2037fa8cb5b1ae5ddfc302e7317d04144"}, {file = "coverage-7.10.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b15a87265e96307482746d86995f4bff282f14b027db75469c446da6127433b"}, {file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fc53ba868875bfbb66ee447d64d6413c2db91fddcfca57025a0e7ab5b07d5862"}, {file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:efeda443000aa23f276f4df973cb82beca682fd800bb119d19e80504ffe53ec2"}, {file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9702b59d582ff1e184945d8b501ffdd08d2cee38d93a2206aa5f1365ce0b8d78"}, {file = "coverage-7.10.6-cp39-cp39-win32.whl", hash = "sha256:2195f8e16ba1a44651ca684db2ea2b2d4b5345da12f07d9c22a395202a05b23c"}, {file = "coverage-7.10.6-cp39-cp39-win_amd64.whl", hash = "sha256:f32ff80e7ef6a5b5b606ea69a36e97b219cd9dc799bcf2963018a4d8f788cfbf"}, {file = "coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3"}, {file = "coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90"}, ] [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" version = "48.0.0" 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.9" groups = ["main"] files = [ {file = "cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6"}, {file = "cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c"}, {file = "cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3"}, {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5"}, {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c"}, {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f"}, {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25"}, {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602"}, {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c"}, {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5"}, {file = "cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321"}, {file = "cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74"}, {file = "cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4"}, {file = "cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7"}, {file = "cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec"}, {file = "cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18"}, {file = "cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20"}, {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff"}, {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c"}, {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db"}, {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741"}, {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166"}, {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336"}, {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057"}, {file = "cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae"}, {file = "cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c"}, {file = "cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f"}, {file = "cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12"}, {file = "cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86"}, {file = "cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e"}, {file = "cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f"}, {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7"}, {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832"}, {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c"}, {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a"}, {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a"}, {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a"}, {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239"}, {file = "cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c"}, {file = "cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4"}, {file = "cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd"}, {file = "cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8"}, {file = "cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855"}, {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b"}, {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13"}, {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb"}, {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355"}, {file = "cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a"}, {file = "cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920"}, ] [package.dependencies] cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} [package.extras] ssh = ["bcrypt (>=3.1.5)"] [[package]] name = "dbus-fast" version = "5.0.16" description = "A faster version of dbus-next" optional = false python-versions = ">=3.10" groups = ["main", "dev"] files = [ {file = "dbus_fast-5.0.16-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:872bed67d7c0e58663d4e0ed5be44e7e56800222dd2ef20796be6ba40aaf8443"}, {file = "dbus_fast-5.0.16-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc1b3de7c70cb25cb4799fd098aae23caca89e957f199a02997c2c280f20a1a8"}, {file = "dbus_fast-5.0.16-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0601912b5f7015870f3987216c7d0cec49278adb8a282fc879e171a73b7f8f4"}, {file = "dbus_fast-5.0.16-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d57b49994666998dfdacaac726f6c788855ebd1942e3d40b91bf72b9bd64c079"}, {file = "dbus_fast-5.0.16-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b92b773075adc85f24a628875e8b57f4d1f1fbe63ec18db1bb9f136f26a6f621"}, {file = "dbus_fast-5.0.16-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:7fb55b9658f9a285bd52b6209e54a89613da7f1134d505aa089299ed99268c1a"}, {file = "dbus_fast-5.0.16-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c775272eccf2aa3948c72509c590f84a9a9bebb3bc7ee68452ad029209670c63"}, {file = "dbus_fast-5.0.16-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0a846c0103073f78cecd70b7cd229d4f37b739164797de4d7943d0591dac4393"}, {file = "dbus_fast-5.0.16-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddac5066da9e1e38d434e41372392ac62ab8ce80e4e9616a0504999749b9ec46"}, {file = "dbus_fast-5.0.16-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d41f51d2a420f7274d4f48b7a4f3e56b70ee8ca87ad666854007de102025ee3"}, {file = "dbus_fast-5.0.16-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:651347df9079744395c6404e0ed03a3704485d21833765db7ad71390c7c807a6"}, {file = "dbus_fast-5.0.16-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f5017cb3c32b07622f9c01a1285526183988ae1dd4e5fedc7390650a3cfbca2"}, {file = "dbus_fast-5.0.16-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:bff101277226e71aa744d72b968efcdde4e185dc0d3571b8168edb99a2f72d3a"}, {file = "dbus_fast-5.0.16-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3367dfeba013db0f97004b4c93f4a21c39728f2bbe0fbef74dbbeb6e078e47fa"}, {file = "dbus_fast-5.0.16-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f35c2ce7ab95ecc8a378119a024691b33693f07068bfaa3642d32d2f1a0e28a8"}, {file = "dbus_fast-5.0.16-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:85a7771883fd5beaa5e92b27772722db5e3d4dc2f7e04a1812d3bf01b7de440d"}, {file = "dbus_fast-5.0.16-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c42dc349ea1e52e4bd17462d5029c7650d76e8ccd2fbd136b6a0384abae61004"}, {file = "dbus_fast-5.0.16-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:941b926581ef458dfde70711badbb5ab1bfba523b527f1b73004c21cdf9976ec"}, {file = "dbus_fast-5.0.16-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:42a7901d5f053490d3d59c5a9b9a13b1d8322836458efcb93040ea360aced572"}, {file = "dbus_fast-5.0.16-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8deec1d4dd41336672b30b596d60ed78898e923f3b06f9ef60affc1a5dec656c"}, {file = "dbus_fast-5.0.16-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8b2c92f82af04d59c50cbccf65f995fa2e321ce7850e3c1b613e94680a8044e"}, {file = "dbus_fast-5.0.16-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:608f3e5f217a8f24c4d487667a2eae2bcd52899fd421bd0d05fc81462090936f"}, {file = "dbus_fast-5.0.16-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:831a6f6a865260395fa59f80d36c73b1e270b268603f53422552a813bf61c529"}, {file = "dbus_fast-5.0.16-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4eee863c411c0c1c783518f30c3de50339316fc745ac2abcc0778046e4adac40"}, {file = "dbus_fast-5.0.16-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:790c1310d38369e659b45ae5b95b6bce24ae51e011634142324cb6ebc8e11398"}, {file = "dbus_fast-5.0.16-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f0707f8473cd8e5be0ac1aceed7532e2fcb4b5b97584f2878f98cbe61049a184"}, {file = "dbus_fast-5.0.16-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:df3f3ee7cf61be297be9d3ce2074f74da44b13cdb413cc821e398902d2f38036"}, {file = "dbus_fast-5.0.16-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0b800db6ffaec39554f1489b54482e33a8b6247325974cf82659504a3816b956"}, {file = "dbus_fast-5.0.16-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a844384792afdec27f98ec9708e715399c539837fae576e532661b94a52f812a"}, {file = "dbus_fast-5.0.16-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874bbd1fff34054c10b1062676334fcf170f32a0f0f9f21d2a9d1bf22ef70b35"}, {file = "dbus_fast-5.0.16-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07554554373441f562d1469bd73d024dd3144c9725375bbffbe8b3375bf464fe"}, {file = "dbus_fast-5.0.16-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6fc1831c702725eb4875d69c877700d95e7c3fa8542f4edaad318c6ba3e66f2"}, {file = "dbus_fast-5.0.16-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:8447d1ab3cd7dc9976805dda5903453d0fdb8aaab870536f3bcaf6b1f58800a3"}, {file = "dbus_fast-5.0.16-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d36ff2f0f6a5fd6dae96a09f48783ee7de51bf12d0da18a13edf19766bd0b99a"}, {file = "dbus_fast-5.0.16-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fc43f5a7e7db86728e9925757a53a717eb3fec27de54e7bbc688cd65b8e9028c"}, {file = "dbus_fast-5.0.16-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:56e64ff9d1a1f503e07a940f861bf6dc80f24602425cb6f384d69bdc7fe7071f"}, {file = "dbus_fast-5.0.16-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f95648d4904426c1538df894b4f327843374b760f0491479d36eb115885ee4d"}, {file = "dbus_fast-5.0.16-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d8943d0b92ec9f49e37aed6224515ce161eb2b5df5e9fd3e1d692f35efbe48b"}, {file = "dbus_fast-5.0.16-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2f96e7da851b0dc9e0bc1e00d8b9cd6f227469b6e48e1b2ad474e44ff91841b"}, {file = "dbus_fast-5.0.16-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2832c5eefcc5b4e0623a27e20751ca34006d7fe73b21d58ef859aa2cef0a8ec8"}, {file = "dbus_fast-5.0.16-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d2c9ae45a01d5f2e27a9508159d6e3c34e6714b485884000839daf5320d76da9"}, {file = "dbus_fast-5.0.16-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e46276642f787a345f127389ff36a90ba9e4df6537b852d8afdc12e2b2f9bb91"}, {file = "dbus_fast-5.0.16-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f41ca52046e41775c0784148c7b6b636539722bcc1412bae3139ac7295883265"}, {file = "dbus_fast-5.0.16-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:61768afef41db9adac0ca7849a161a57b55cae34a9db49e3d39ed21727c3e321"}, {file = "dbus_fast-5.0.16-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2fc70c34595475bd5cbb523ddd4d119b6f1a5ebb03205b2a99b663d4120dd99"}, {file = "dbus_fast-5.0.16-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb3a62a2af18b8e4592555dbf92b66a66735121cf47a1664415076d0260721bd"}, {file = "dbus_fast-5.0.16-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:cc7a034d2c828ab796d58e42beef1469cdfdf6a00269efd4777c856c753cba9a"}, {file = "dbus_fast-5.0.16-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3346139351e86d81be963dddb2d0c25da2f0a9c60d40db7e66cb0cc5cf41674e"}, {file = "dbus_fast-5.0.16.tar.gz", hash = "sha256:24d0a86f32acb209a41806d20cf8a9207e4d46760e23c32479d1951d43739080"}, ] markers = {main = "sys_platform == \"linux\" or platform_system == \"Linux\""} [[package]] name = "docutils" version = "0.21.2" description = "Docutils -- Python Documentation Utilities" optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"}, {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, ] [[package]] name = "freezegun" version = "1.5.5" description = "Let your Python tests travel through time" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2"}, {file = "freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a"}, ] [package.dependencies] python-dateutil = ">=2.7" [[package]] name = "furo" version = "2025.12.19" description = "A clean customisable Sphinx documentation theme." optional = false python-versions = ">=3.8" groups = ["docs"] files = [ {file = "furo-2025.12.19-py3-none-any.whl", hash = "sha256:bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f"}, {file = "furo-2025.12.19.tar.gz", hash = "sha256:188d1f942037d8b37cd3985b955839fea62baa1730087dc29d157677c857e2a7"}, ] [package.dependencies] accessible-pygments = ">=0.0.5" beautifulsoup4 = "*" pygments = ">=2.7" sphinx = ">=7.0,<10.0" sphinx-basic-ng = ">=1.0.0b2" [[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.15" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" groups = ["dev", "docs"] files = [ {file = "idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8"}, {file = "idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc"}, ] [package.extras] all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] [[package]] name = "imagesize" version = "1.4.1" description = "Getting image size from png/jpeg/jpeg2000/gif file" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" groups = ["docs"] files = [ {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, ] [[package]] name = "iniconfig" version = "2.1.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] [[package]] name = "jinja2" version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" groups = ["docs"] files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, ] [package.dependencies] MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] [[package]] name = "markdown-it-py" version = "4.2.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.10" groups = ["dev", "docs"] files = [ {file = "markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"}, {file = "markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"}, ] [package.dependencies] mdurl = ">=0.1,<1.0" [package.extras] benchmarking = ["psutil", "pytest", "pytest-benchmark"] compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] linkify = ["linkify-it-py (>=1,<3)"] plugins = ["mdit-py-plugins (>=0.5.0)"] profiling = ["gprof2dot"] rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "pytest-timeout", "requests"] [[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.6.1" description = "Collection of plugins for markdown-it-py" optional = false python-versions = ">=3.10" groups = ["docs"] files = [ {file = "mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d"}, {file = "mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0"}, ] [package.dependencies] markdown-it-py = ">=2.0.0,<5.0.0" [package.extras] code-style = ["pre-commit"] rtd = ["myst-parser", "sphinx-book-theme"] testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "pytest-timeout"] [[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 = "5.1.0" description = "An extended [CommonMark](https://spec.commonmark.org/) compliant parser," optional = false python-versions = ">=3.11" groups = ["docs"] files = [ {file = "myst_parser-5.1.0-py3-none-any.whl", hash = "sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a"}, {file = "myst_parser-5.1.0.tar.gz", hash = "sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02"}, ] [package.dependencies] docutils = ">=0.20,<0.23" jinja2 = "*" markdown-it-py = ">=4.2,<5.0" mdit-py-plugins = ">=0.6.1,<1.0" pyyaml = "*" sphinx = ">=8,<10" [package.extras] code-style = ["pre-commit (>=4.0,<5.0)"] linkify = ["linkify-it-py (>=2.0,<3.0)"] rtd = ["ipython", "sphinx (>=8)", "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.13.0,<0.14.0)", "sphinxext-rediraffe (>=0.3.0,<0.4.0)"] testing = ["beautifulsoup4", "coverage[toml]", "defusedxml", "pygments (<2.21)", "pytest (>=9,<10)", "pytest-cov", "pytest-param-files (>=0.6.0,<0.7.0)", "pytest-regressions", "sphinx-pytest (>=0.3.0,<0.4.0)"] testing-docutils = ["pygments", "pytest (>=9,<10)", "pytest-param-files (>=0.6.0,<0.7.0)"] [[package]] name = "packaging" version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" groups = ["dev", "docs"] files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] [[package]] name = "pluggy" version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, ] [package.extras] dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "pycparser" version = "2.22" description = "C parser in Python" optional = false python-versions = ">=3.8" groups = ["main"] markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, ] [[package]] name = "pygments" version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.9" groups = ["dev", "docs"] files = [ {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, ] [package.extras] windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyobjc-core" version = "11.1" description = "Python<->ObjC Interoperability Module" optional = false python-versions = ">=3.8" groups = ["main"] markers = "sys_platform == \"darwin\"" files = [ {file = "pyobjc_core-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4c7536f3e94de0a3eae6bb382d75f1219280aa867cdf37beef39d9e7d580173c"}, {file = "pyobjc_core-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ec36680b5c14e2f73d432b03ba7c1457dc6ca70fa59fd7daea1073f2b4157d33"}, {file = "pyobjc_core-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:765b97dea6b87ec4612b3212258024d8496ea23517c95a1c5f0735f96b7fd529"}, {file = "pyobjc_core-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:18986f83998fbd5d3f56d8a8428b2f3e0754fd15cef3ef786ca0d29619024f2c"}, {file = "pyobjc_core-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8849e78cfe6595c4911fbba29683decfb0bf57a350aed8a43316976ba6f659d2"}, {file = "pyobjc_core-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cb9ed17a8d84a312a6e8b665dd22393d48336ea1d8277e7ad20c19a38edf731"}, {file = "pyobjc_core-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:f2455683e807f8541f0d83fbba0f5d9a46128ab0d5cc83ea208f0bec759b7f96"}, {file = "pyobjc_core-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4a99e6558b48b8e47c092051e7b3be05df1c8d0617b62f6fa6a316c01902d157"}, {file = "pyobjc_core-11.1.tar.gz", hash = "sha256:b63d4d90c5df7e762f34739b39cc55bc63dbcf9fb2fb3f2671e528488c7a87fe"}, ] [[package]] name = "pyobjc-framework-cocoa" version = "11.1" description = "Wrappers for the Cocoa frameworks on macOS" optional = false python-versions = ">=3.9" groups = ["main"] markers = "sys_platform == \"darwin\"" files = [ {file = "pyobjc_framework_cocoa-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b27a5bdb3ab6cdeb998443ff3fce194ffae5f518c6a079b832dbafc4426937f9"}, {file = "pyobjc_framework_cocoa-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7b9a9b8ba07f5bf84866399e3de2aa311ed1c34d5d2788a995bdbe82cc36cfa0"}, {file = "pyobjc_framework_cocoa-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806de56f06dfba8f301a244cce289d54877c36b4b19818e3b53150eb7c2424d0"}, {file = "pyobjc_framework_cocoa-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54e93e1d9b0fc41c032582a6f0834befe1d418d73893968f3f450281b11603da"}, {file = "pyobjc_framework_cocoa-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:fd5245ee1997d93e78b72703be1289d75d88ff6490af94462b564892e9266350"}, {file = "pyobjc_framework_cocoa-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:aede53a1afc5433e1e7d66568cc52acceeb171b0a6005407a42e8e82580b4fc0"}, {file = "pyobjc_framework_cocoa-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:1b5de4e1757bb65689d6dc1f8d8717de9ec8587eb0c4831c134f13aba29f9b71"}, {file = "pyobjc_framework_cocoa-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bbee71eeb93b1b31ffbac8560b59a0524a8a4b90846a260d2c4f2188f3d4c721"}, {file = "pyobjc_framework_cocoa-11.1.tar.gz", hash = "sha256:87df76b9b73e7ca699a828ff112564b59251bb9bbe72e610e670a4dc9940d038"}, ] [package.dependencies] pyobjc-core = ">=11.1" [[package]] name = "pyobjc-framework-corebluetooth" version = "11.1" description = "Wrappers for the framework CoreBluetooth on macOS" optional = false python-versions = ">=3.9" groups = ["main"] markers = "sys_platform == \"darwin\"" files = [ {file = "pyobjc_framework_corebluetooth-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ab509994503a5f0ec0f446a7ccc9f9a672d5a427d40dba4563dd00e8e17dfb06"}, {file = "pyobjc_framework_corebluetooth-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:433b8593eb1ea8b6262b243ec903e1de4434b768ce103ebe15aac249b890cc2a"}, {file = "pyobjc_framework_corebluetooth-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:36bef95a822c68b72f505cf909913affd61a15b56eeaeafea7302d35a82f4f05"}, {file = "pyobjc_framework_corebluetooth-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:992404b03033ecf637e9174caed70cb22fd1be2a98c16faa699217678e62a5c7"}, {file = "pyobjc_framework_corebluetooth-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ebb8648f5e33d98446eb1d6c4654ba4fcc15d62bfcb47fa3bbd5596f6ecdb37c"}, {file = "pyobjc_framework_corebluetooth-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:e84cbf52006a93d937b90421ada0bc4a146d6d348eb40ae10d5bd2256cc92206"}, {file = "pyobjc_framework_corebluetooth-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:4da1106265d7efd3f726bacdf13ba9528cc380fb534b5af38b22a397e6908291"}, {file = "pyobjc_framework_corebluetooth-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e9fa3781fea20a31b3bb809deaeeab3bdc7b86602a1fd829f0e86db11d7aa577"}, {file = "pyobjc_framework_corebluetooth-11.1.tar.gz", hash = "sha256:1deba46e3fcaf5e1c314f4bbafb77d9fe49ec248c493ad00d8aff2df212d6190"}, ] [package.dependencies] pyobjc-core = ">=11.1" pyobjc-framework-Cocoa = ">=11.1" [[package]] name = "pyobjc-framework-libdispatch" version = "11.1" description = "Wrappers for libdispatch on macOS" optional = false python-versions = ">=3.9" groups = ["main"] markers = "sys_platform == \"darwin\"" files = [ {file = "pyobjc_framework_libdispatch-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9c598c073a541b5956b5457b94bd33b9ce19ef8d867235439a0fad22d6beab49"}, {file = "pyobjc_framework_libdispatch-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2ddca472c2cbc6bb192e05b8b501d528ce49333abe7ef0eef28df3133a8e18b7"}, {file = "pyobjc_framework_libdispatch-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dc9a7b8c2e8a63789b7cf69563bb7247bde15353208ef1353fff0af61b281684"}, {file = "pyobjc_framework_libdispatch-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c4e219849f5426745eb429f3aee58342a59f81e3144b37aa20e81dacc6177de1"}, {file = "pyobjc_framework_libdispatch-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a9357736cb47b4a789f59f8fab9b0d10b0a9c84f9876367c398718d3de085888"}, {file = "pyobjc_framework_libdispatch-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:cd08f32ea7724906ef504a0fd40a32e2a0be4d64b9239530a31767ca9ccfc921"}, {file = "pyobjc_framework_libdispatch-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:5d9985b0e050cae72bf2c6a1cc8180ff4fa3a812cd63b2dc59e09c6f7f6263a1"}, {file = "pyobjc_framework_libdispatch-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cfe515f4c3ea66c13fce4a527230027517b8b779b40bbcb220ff7cdf3ad20bc4"}, {file = "pyobjc_framework_libdispatch-11.1.tar.gz", hash = "sha256:11a704e50a0b7dbfb01552b7d686473ffa63b5254100fdb271a1fe368dd08e87"}, ] [package.dependencies] pyobjc-core = ">=11.1" pyobjc-framework-Cocoa = ">=11.1" [[package]] name = "pyric" version = "0.1.6.3" description = "Python Wireless Library" optional = false python-versions = "*" groups = ["main"] files = [ {file = "PyRIC-0.1.6.3.tar.gz", hash = "sha256:b539b01cafebd2406c00097f94525ea0f8ecd1dd92f7731f43eac0ef16c2ccc9"}, ] [[package]] name = "pytest" version = "9.0.3" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ {file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"}, {file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"}, ] [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} iniconfig = ">=1.0.1" packaging = ">=22" pluggy = ">=1.5,<2" pygments = ">=2.7.2" [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" version = "1.4.0" description = "Pytest support for asyncio" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ {file = "pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1"}, {file = "pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42"}, ] [package.dependencies] pytest = ">=8.4,<10" typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)", "sphinx-tabs (>=3.5)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-codspeed" version = "5.0.3" description = "Pytest plugin to create CodSpeed benchmarks" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pytest_codspeed-5.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:005348ea52ace3ede2e2f595913912ad2564cca7b124211a88dc78a9cb1fca63"}, {file = "pytest_codspeed-5.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbe6a4a00b449b6ba2771f644cbc38bdf55acf5c812e60e5659110e19dd9f510"}, {file = "pytest_codspeed-5.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac4344f34bbcdd17f6f8c30dbac3da2f80d223dd112e568fd7f7c2cd4cbc693"}, {file = "pytest_codspeed-5.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f56d0339cd98d26f6e561987be25bdd2761a5d53d8f73493b1ebe02d0d451093"}, {file = "pytest_codspeed-5.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c682f6645d4eb472f3bd95dbda1805e3af4243610572cb7d6bf94a88e8a0b6c"}, {file = "pytest_codspeed-5.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f852bee785a7a124cb1720b1915670c6742af87747dc4d838f3ffdbd365ce9d9"}, {file = "pytest_codspeed-5.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2eeb25fb1ac3f73c4de50e739e78fea396b89782bdb740bf2a7cd2df21f8d4ee"}, {file = "pytest_codspeed-5.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73c5c9d98a3372a42611989ccfa437cce3842431ac6d6b9ab42c4f0e59c070f7"}, {file = "pytest_codspeed-5.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2e0ab65df73e837666d12357280ca50ff6d6ac03ea5266703be518b68170edf"}, {file = "pytest_codspeed-5.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6524c57fec279a22ffef6112af404036afc71b4704758ae9f0abda429b8478d4"}, {file = "pytest_codspeed-5.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c383c9121deb58a69f174188e9e4488ffc0daced0ed276abf87747182511901"}, {file = "pytest_codspeed-5.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4bcdb4b6522738152885ef067e0c8524d5699828d780fb6f464cdb3db44369c"}, {file = "pytest_codspeed-5.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:25464363c7f9b9bd5022e969c0addba616fa40ac9b8f0fc9e030c4538863b32d"}, {file = "pytest_codspeed-5.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efd43f82ea03ced8488a767ded9473f050791ab7783ea8654107e1e0ac66af40"}, {file = "pytest_codspeed-5.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:782f9985b6f6b45b8bc20152d206d3a52b56dd088ba81cb70a71f0b39841be9e"}, {file = "pytest_codspeed-5.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9aa0815b90196f3c20d736ea8691381e97f12bbe8c7d87af10a351e434b452cb"}, {file = "pytest_codspeed-5.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:85505c96a3477c346ec2d2b7dced8478f4c651e2b1666ee102d53a832b511853"}, {file = "pytest_codspeed-5.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20eba63765be9d1b6cacbbfad84b87d49eb04b357a7045a0899880da181f81e3"}, {file = "pytest_codspeed-5.0.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ec9fa6f0af0a9feb0e0bd517fb59ef28f806fbd50c0c6900ac26cbb4d080eba5"}, {file = "pytest_codspeed-5.0.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8df77b3409f54f4a268f77f3ff74992fe1d995cdbaf2cecf8ad74d32db217ce7"}, {file = "pytest_codspeed-5.0.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5d8695a227ea1c3a41d25db5b3fe720bf1b4808bd38862be811a4efd902c792"}, {file = "pytest_codspeed-5.0.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bf4cc4178cbace8f4d2bd240408276bc4da3850ac5fcb5fb5f8a74ab417615bb"}, {file = "pytest_codspeed-5.0.3-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abe793da40f87295d33988673d34f06ea569848b44490b847552cd416816258a"}, {file = "pytest_codspeed-5.0.3-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3a9ed38dfa776443b86f4b49a982e8443d0953db4974bd2673d63cc904ae1ad"}, {file = "pytest_codspeed-5.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bce0a6ea93a5b19658f713312bb67554c19283ab15b454a1e3e55a13e78130f8"}, {file = "pytest_codspeed-5.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a2097247985f5d915a94b80c5552d10979ca858c859fc3edef1bf2baa5c9b7a"}, {file = "pytest_codspeed-5.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e192905a2230f9956e6160732f76577836953a4a1fb2b1e7be74e51ac7b2a0"}, {file = "pytest_codspeed-5.0.3-py3-none-any.whl", hash = "sha256:fe2ea83c924c2250675b75686c3ee456b8cf0208d83d552e182a195fdf467378"}, {file = "pytest_codspeed-5.0.3.tar.gz", hash = "sha256:91afef90e6a96b013495e4702ef5d6358614a449e71008cdc194ef668778b92f"}, ] [package.dependencies] 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)"] [[package]] name = "pytest-cov" version = "7.1.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ {file = "pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678"}, {file = "pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2"}, ] [package.dependencies] coverage = {version = ">=7.10.6", extras = ["toml"]} pluggy = ">=1.2" pytest = ">=7" [package.extras] testing = ["process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-timeout" version = "2.4.0" description = "pytest plugin to abort hanging tests" optional = false python-versions = ">=3.7" groups = ["dev"] files = [ {file = "pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2"}, {file = "pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a"}, ] [package.dependencies] pytest = ">=7.0.0" [[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.33.0" description = "Python HTTP for Humans." optional = false python-versions = ">=3.10" groups = ["docs"] files = [ {file = "requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b"}, {file = "requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652"}, ] [package.dependencies] certifi = ">=2023.5.7" charset_normalizer = ">=2,<4" idna = ">=2.5,<4" urllib3 = ">=1.26,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] test = ["PySocks (>=1.5.6,!=1.5.7)", "pytest (>=3)", "pytest-cov", "pytest-httpbin (==2.1.0)", "pytest-mock", "pytest-xdist"] use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] [[package]] name = "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" version = "4.1.0" description = "Manipulate well-formed Roman numerals" optional = false python-versions = ">=3.10" groups = ["docs"] files = [ {file = "roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7"}, {file = "roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2"}, ] [[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 = ["dev", "docs"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] [[package]] name = "snowballstemmer" version = "3.0.1" description = "This package provides 32 stemmers for 30 languages generated from Snowball algorithms." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*" groups = ["docs"] files = [ {file = "snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064"}, {file = "snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895"}, ] [[package]] name = "soupsieve" version = "2.7" description = "A modern CSS selector implementation for Beautiful Soup." optional = false python-versions = ">=3.8" groups = ["docs"] files = [ {file = "soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4"}, {file = "soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a"}, ] [[package]] name = "sphinx" version = "9.0.4" description = "Python documentation generator" optional = false python-versions = ">=3.11" groups = ["docs"] files = [ {file = "sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb"}, {file = "sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3"}, ] [package.dependencies] alabaster = ">=0.7.14" babel = ">=2.13" colorama = {version = ">=0.4.6", markers = "sys_platform == \"win32\""} docutils = ">=0.20,<0.23" imagesize = ">=1.3" Jinja2 = ">=3.1" packaging = ">=23.0" Pygments = ">=2.17" requests = ">=2.30.0" roman-numerals = ">=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]] name = "sphinx-autobuild" version = "2025.8.25" description = "Rebuild Sphinx documentation on changes, with hot reloading in the browser." optional = false python-versions = ">=3.11" groups = ["docs"] files = [ {file = "sphinx_autobuild-2025.8.25-py3-none-any.whl", hash = "sha256:b750ac7d5a18603e4665294323fd20f6dcc0a984117026d1986704fa68f0379a"}, {file = "sphinx_autobuild-2025.8.25.tar.gz", hash = "sha256:9cf5aab32853c8c31af572e4fecdc09c997e2b8be5a07daf2a389e270e85b213"}, ] [package.dependencies] colorama = ">=0.4.6" Sphinx = "*" starlette = ">=0.35" uvicorn = ">=0.25" watchfiles = ">=0.20" websockets = ">=11" [package.extras] test = ["httpx", "pytest (>=6)"] [[package]] name = "sphinx-basic-ng" version = "1.0.0b2" description = "A modern skeleton for Sphinx themes." optional = false python-versions = ">=3.7" groups = ["docs"] files = [ {file = "sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b"}, {file = "sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9"}, ] [package.dependencies] sphinx = ">=4.0" [package.extras] docs = ["furo", "ipython", "myst-parser", "sphinx-copybutton", "sphinx-inline-tabs"] [[package]] name = "sphinxcontrib-applehelp" version = "2.0.0" description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"}, {file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"}, ] [package.extras] lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] standalone = ["Sphinx (>=5)"] test = ["pytest"] [[package]] name = "sphinxcontrib-devhelp" version = "2.0.0" description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents" optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"}, {file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"}, ] [package.extras] lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] standalone = ["Sphinx (>=5)"] test = ["pytest"] [[package]] name = "sphinxcontrib-htmlhelp" version = "2.1.0" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"}, {file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"}, ] [package.extras] lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] standalone = ["Sphinx (>=5)"] test = ["html5lib", "pytest"] [[package]] name = "sphinxcontrib-jsmath" version = "1.0.1" description = "A sphinx extension which renders display math in HTML via JavaScript" optional = false python-versions = ">=3.5" groups = ["docs"] files = [ {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, ] [package.extras] test = ["flake8", "mypy", "pytest"] [[package]] name = "sphinxcontrib-qthelp" version = "2.0.0" description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"}, {file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"}, ] [package.extras] lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] standalone = ["Sphinx (>=5)"] test = ["defusedxml (>=0.7.1)", "pytest"] [[package]] name = "sphinxcontrib-serializinghtml" version = "2.0.0" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)" optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"}, {file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"}, ] [package.extras] lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] standalone = ["Sphinx (>=5)"] test = ["pytest"] [[package]] name = "starlette" version = "1.0.1" description = "The little ASGI library that shines." optional = false python-versions = ">=3.10" groups = ["dev", "docs"] files = [ {file = "starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd"}, {file = "starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f"}, ] [package.dependencies] anyio = ">=3.6.2,<5" typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} [package.extras] full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] [[package]] name = "typing-extensions" version = "4.14.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" groups = ["main", "dev", "docs"] files = [ {file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"}, {file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"}, ] markers = {main = "python_version == \"3.11\" or sys_platform == \"win32\"", dev = "python_version < \"3.13\""} [[package]] name = "uart-devices" version = "0.1.1" description = "UART Devices for Linux" optional = false python-versions = "<4.0,>=3.9" groups = ["main"] files = [ {file = "uart_devices-0.1.1-py3-none-any.whl", hash = "sha256:55bc8cce66465e90b298f0910e5c496bc7be021341c5455954cf61c6253dc123"}, {file = "uart_devices-0.1.1.tar.gz", hash = "sha256:3a52c4ae0f5f7400ebe1ae5f6e2a2d40cc0b7f18a50e895236535c4e53c6ed34"}, ] [[package]] name = "urllib3" version = "2.7.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.10" groups = ["docs"] files = [ {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, ] [package.extras] brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "usb-devices" version = "0.4.5" description = "Tools for mapping, describing, and resetting USB devices" optional = false python-versions = ">=3.9,<4.0" groups = ["main"] files = [ {file = "usb_devices-0.4.5-py3-none-any.whl", hash = "sha256:8a415219ef1395e25aa0bddcad484c88edf9673acdeae8a07223ca7222a01dcf"}, {file = "usb_devices-0.4.5.tar.gz", hash = "sha256:9b5c7606df2bc791c6c45b7f76244a0cbed83cb6fa4c68791a143c03345e195d"}, ] [[package]] name = "uvicorn" version = "0.35.0" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"}, {file = "uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01"}, ] [package.dependencies] click = ">=7.0" h11 = ">=0.8" [package.extras] standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] [[package]] name = "watchfiles" version = "1.1.0" description = "Simple, modern and high performance file watching and code reload in python." optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "watchfiles-1.1.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:27f30e14aa1c1e91cb653f03a63445739919aef84c8d2517997a83155e7a2fcc"}, {file = "watchfiles-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3366f56c272232860ab45c77c3ca7b74ee819c8e1f6f35a7125556b198bbc6df"}, {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8412eacef34cae2836d891836a7fff7b754d6bcac61f6c12ba5ca9bc7e427b68"}, {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df670918eb7dd719642e05979fc84704af913d563fd17ed636f7c4783003fdcc"}, {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7642b9bc4827b5518ebdb3b82698ada8c14c7661ddec5fe719f3e56ccd13c97"}, {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:199207b2d3eeaeb80ef4411875a6243d9ad8bc35b07fc42daa6b801cc39cc41c"}, {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a479466da6db5c1e8754caee6c262cd373e6e6c363172d74394f4bff3d84d7b5"}, {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:935f9edd022ec13e447e5723a7d14456c8af254544cefbc533f6dd276c9aa0d9"}, {file = "watchfiles-1.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8076a5769d6bdf5f673a19d51da05fc79e2bbf25e9fe755c47595785c06a8c72"}, {file = "watchfiles-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:86b1e28d4c37e89220e924305cd9f82866bb0ace666943a6e4196c5df4d58dcc"}, {file = "watchfiles-1.1.0-cp310-cp310-win32.whl", hash = "sha256:d1caf40c1c657b27858f9774d5c0e232089bca9cb8ee17ce7478c6e9264d2587"}, {file = "watchfiles-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:a89c75a5b9bc329131115a409d0acc16e8da8dfd5867ba59f1dd66ae7ea8fa82"}, {file = "watchfiles-1.1.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c9649dfc57cc1f9835551deb17689e8d44666315f2e82d337b9f07bd76ae3aa2"}, {file = "watchfiles-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:406520216186b99374cdb58bc48e34bb74535adec160c8459894884c983a149c"}, {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb45350fd1dc75cd68d3d72c47f5b513cb0578da716df5fba02fff31c69d5f2d"}, {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11ee4444250fcbeb47459a877e5e80ed994ce8e8d20283857fc128be1715dac7"}, {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bda8136e6a80bdea23e5e74e09df0362744d24ffb8cd59c4a95a6ce3d142f79c"}, {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b915daeb2d8c1f5cee4b970f2e2c988ce6514aace3c9296e58dd64dc9aa5d575"}, {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed8fc66786de8d0376f9f913c09e963c66e90ced9aa11997f93bdb30f7c872a8"}, {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe4371595edf78c41ef8ac8df20df3943e13defd0efcb732b2e393b5a8a7a71f"}, {file = "watchfiles-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b7c5f6fe273291f4d414d55b2c80d33c457b8a42677ad14b4b47ff025d0893e4"}, {file = "watchfiles-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7738027989881e70e3723c75921f1efa45225084228788fc59ea8c6d732eb30d"}, {file = "watchfiles-1.1.0-cp311-cp311-win32.whl", hash = "sha256:622d6b2c06be19f6e89b1d951485a232e3b59618def88dbeda575ed8f0d8dbf2"}, {file = "watchfiles-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:48aa25e5992b61debc908a61ab4d3f216b64f44fdaa71eb082d8b2de846b7d12"}, {file = "watchfiles-1.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:00645eb79a3faa70d9cb15c8d4187bb72970b2470e938670240c7998dad9f13a"}, {file = "watchfiles-1.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9dc001c3e10de4725c749d4c2f2bdc6ae24de5a88a339c4bce32300a31ede179"}, {file = "watchfiles-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9ba68ec283153dead62cbe81872d28e053745f12335d037de9cbd14bd1877f5"}, {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130fc497b8ee68dce163e4254d9b0356411d1490e868bd8790028bc46c5cc297"}, {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50a51a90610d0845a5931a780d8e51d7bd7f309ebc25132ba975aca016b576a0"}, {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc44678a72ac0910bac46fa6a0de6af9ba1355669b3dfaf1ce5f05ca7a74364e"}, {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a543492513a93b001975ae283a51f4b67973662a375a403ae82f420d2c7205ee"}, {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ac164e20d17cc285f2b94dc31c384bc3aa3dd5e7490473b3db043dd70fbccfd"}, {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7590d5a455321e53857892ab8879dce62d1f4b04748769f5adf2e707afb9d4f"}, {file = "watchfiles-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:37d3d3f7defb13f62ece99e9be912afe9dd8a0077b7c45ee5a57c74811d581a4"}, {file = "watchfiles-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7080c4bb3efd70a07b1cc2df99a7aa51d98685be56be6038c3169199d0a1c69f"}, {file = "watchfiles-1.1.0-cp312-cp312-win32.whl", hash = "sha256:cbcf8630ef4afb05dc30107bfa17f16c0896bb30ee48fc24bf64c1f970f3b1fd"}, {file = "watchfiles-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:cbd949bdd87567b0ad183d7676feb98136cde5bb9025403794a4c0db28ed3a47"}, {file = "watchfiles-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0a7d40b77f07be87c6faa93d0951a0fcd8cbca1ddff60a1b65d741bac6f3a9f6"}, {file = "watchfiles-1.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30"}, {file = "watchfiles-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a"}, {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc"}, {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b"}, {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895"}, {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a"}, {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b"}, {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c"}, {file = "watchfiles-1.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b"}, {file = "watchfiles-1.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb"}, {file = "watchfiles-1.1.0-cp313-cp313-win32.whl", hash = "sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9"}, {file = "watchfiles-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7"}, {file = "watchfiles-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5"}, {file = "watchfiles-1.1.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1"}, {file = "watchfiles-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339"}, {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633"}, {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011"}, {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670"}, {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf"}, {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4"}, {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20"}, {file = "watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef"}, {file = "watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb"}, {file = "watchfiles-1.1.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9974d2f7dc561cce3bb88dfa8eb309dab64c729de85fba32e98d75cf24b66297"}, {file = "watchfiles-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c68e9f1fcb4d43798ad8814c4c1b61547b014b667216cb754e606bfade587018"}, {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95ab1594377effac17110e1352989bdd7bdfca9ff0e5eeccd8c69c5389b826d0"}, {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fba9b62da882c1be1280a7584ec4515d0a6006a94d6e5819730ec2eab60ffe12"}, {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3434e401f3ce0ed6b42569128b3d1e3af773d7ec18751b918b89cd49c14eaafb"}, {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa257a4d0d21fcbca5b5fcba9dca5a78011cb93c0323fb8855c6d2dfbc76eb77"}, {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd1b3879a578a8ec2076c7961076df540b9af317123f84569f5a9ddee64ce92"}, {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62cc7a30eeb0e20ecc5f4bd113cd69dcdb745a07c68c0370cea919f373f65d9e"}, {file = "watchfiles-1.1.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:891c69e027748b4a73847335d208e374ce54ca3c335907d381fde4e41661b13b"}, {file = "watchfiles-1.1.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:12fe8eaffaf0faa7906895b4f8bb88264035b3f0243275e0bf24af0436b27259"}, {file = "watchfiles-1.1.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bfe3c517c283e484843cb2e357dd57ba009cff351edf45fb455b5fbd1f45b15f"}, {file = "watchfiles-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9ccbf1f129480ed3044f540c0fdbc4ee556f7175e5ab40fe077ff6baf286d4e"}, {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba0e3255b0396cac3cc7bbace76404dd72b5438bf0d8e7cefa2f79a7f3649caa"}, {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4281cd9fce9fc0a9dbf0fc1217f39bf9cf2b4d315d9626ef1d4e87b84699e7e8"}, {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d2404af8db1329f9a3c9b79ff63e0ae7131986446901582067d9304ae8aaf7f"}, {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e78b6ed8165996013165eeabd875c5dfc19d41b54f94b40e9fff0eb3193e5e8e"}, {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:249590eb75ccc117f488e2fabd1bfa33c580e24b96f00658ad88e38844a040bb"}, {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05686b5487cfa2e2c28ff1aa370ea3e6c5accfe6435944ddea1e10d93872147"}, {file = "watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d0e10e6f8f6dc5762adee7dece33b722282e1f59aa6a55da5d493a97282fedd8"}, {file = "watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db"}, {file = "watchfiles-1.1.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:865c8e95713744cf5ae261f3067861e9da5f1370ba91fc536431e29b418676fa"}, {file = "watchfiles-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42f92befc848bb7a19658f21f3e7bae80d7d005d13891c62c2cd4d4d0abb3433"}, {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa0cc8365ab29487eb4f9979fd41b22549853389e22d5de3f134a6796e1b05a4"}, {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:90ebb429e933645f3da534c89b29b665e285048973b4d2b6946526888c3eb2c7"}, {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c588c45da9b08ab3da81d08d7987dae6d2a3badd63acdb3e206a42dbfa7cb76f"}, {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c55b0f9f68590115c25272b06e63f0824f03d4fc7d6deed43d8ad5660cabdbf"}, {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd17a1e489f02ce9117b0de3c0b1fab1c3e2eedc82311b299ee6b6faf6c23a29"}, {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da71945c9ace018d8634822f16cbc2a78323ef6c876b1d34bbf5d5222fd6a72e"}, {file = "watchfiles-1.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:51556d5004887045dba3acdd1fdf61dddea2be0a7e18048b5e853dcd37149b86"}, {file = "watchfiles-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04e4ed5d1cd3eae68c89bcc1a485a109f39f2fd8de05f705e98af6b5f1861f1f"}, {file = "watchfiles-1.1.0-cp39-cp39-win32.whl", hash = "sha256:c600e85f2ffd9f1035222b1a312aff85fd11ea39baff1d705b9b047aad2ce267"}, {file = "watchfiles-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:3aba215958d88182e8d2acba0fdaf687745180974946609119953c0e112397dc"}, {file = "watchfiles-1.1.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a6fd40bbb50d24976eb275ccb55cd1951dfb63dbc27cae3066a6ca5f4beabd5"}, {file = "watchfiles-1.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9f811079d2f9795b5d48b55a37aa7773680a5659afe34b54cc1d86590a51507d"}, {file = "watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2726d7bfd9f76158c84c10a409b77a320426540df8c35be172444394b17f7ea"}, {file = "watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df32d59cb9780f66d165a9a7a26f19df2c7d24e3bd58713108b41d0ff4f929c6"}, {file = "watchfiles-1.1.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0ece16b563b17ab26eaa2d52230c9a7ae46cf01759621f4fbbca280e438267b3"}, {file = "watchfiles-1.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:51b81e55d40c4b4aa8658427a3ee7ea847c591ae9e8b81ef94a90b668999353c"}, {file = "watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2bcdc54ea267fe72bfc7d83c041e4eb58d7d8dc6f578dfddb52f037ce62f432"}, {file = "watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:923fec6e5461c42bd7e3fd5ec37492c6f3468be0499bc0707b4bbbc16ac21792"}, {file = "watchfiles-1.1.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7b3443f4ec3ba5aa00b0e9fa90cf31d98321cbff8b925a7c7b84161619870bc9"}, {file = "watchfiles-1.1.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7049e52167fc75fc3cc418fc13d39a8e520cbb60ca08b47f6cedb85e181d2f2a"}, {file = "watchfiles-1.1.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54062ef956807ba806559b3c3d52105ae1827a0d4ab47b621b31132b6b7e2866"}, {file = "watchfiles-1.1.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a7bd57a1bb02f9d5c398c0c1675384e7ab1dd39da0ca50b7f09af45fa435277"}, {file = "watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575"}, ] [package.dependencies] anyio = ">=3.0.0" [[package]] name = "websockets" version = "15.0.1" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = false python-versions = ">=3.9" groups = ["docs"] files = [ {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b"}, {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205"}, {file = "websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a"}, {file = "websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e"}, {file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf"}, {file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb"}, {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d"}, {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9"}, {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c"}, {file = "websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256"}, {file = "websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41"}, {file = "websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431"}, {file = "websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57"}, {file = "websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905"}, {file = "websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562"}, {file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792"}, {file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413"}, {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8"}, {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3"}, {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf"}, {file = "websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85"}, {file = "websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065"}, {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3"}, {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665"}, {file = "websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2"}, {file = "websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215"}, {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5"}, {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65"}, {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe"}, {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4"}, {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597"}, {file = "websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9"}, {file = "websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7"}, {file = "websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931"}, {file = "websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675"}, {file = "websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151"}, {file = "websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22"}, {file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f"}, {file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8"}, {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375"}, {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d"}, {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4"}, {file = "websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa"}, {file = "websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561"}, {file = "websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5"}, {file = "websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a"}, {file = "websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b"}, {file = "websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770"}, {file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb"}, {file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054"}, {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee"}, {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed"}, {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880"}, {file = "websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411"}, {file = "websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4"}, {file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3"}, {file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1"}, {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475"}, {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9"}, {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04"}, {file = "websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122"}, {file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940"}, {file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e"}, {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9"}, {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b"}, {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f"}, {file = "websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123"}, {file = "websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f"}, {file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"}, ] [[package]] name = "winrt-runtime" version = "3.2.1" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = ">=3.9" groups = ["main"] markers = "sys_platform == \"win32\"" files = [ {file = "winrt_runtime-3.2.1-cp310-cp310-win32.whl", hash = "sha256:25a2d1e2b45423742319f7e10fa8ca2e7063f01284b6e85e99d805c4b50bbfb3"}, {file = "winrt_runtime-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:dc81d5fb736bf1ddecf743928622253dce4d0aac9a57faad776d7a3834e13257"}, {file = "winrt_runtime-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:363f584b1e9fcb601e3e178636d8877e6f0537ac3c96ce4a96f06066f8ff0eae"}, {file = "winrt_runtime-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9e9b64f1ba631cc4b9fe60b8ff16fef3f32c7ce2fcc84735a63129ff8b15c022"}, {file = "winrt_runtime-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c0a9046ae416808420a358c51705af8ae100acd40bc578be57ddfdd51cbb0f9c"}, {file = "winrt_runtime-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:e94f3cb40ea2d723c44c82c16d715c03c6b3bd977d135b49535fdd5415fd9130"}, {file = "winrt_runtime-3.2.1-cp312-cp312-win32.whl", hash = "sha256:762b3d972a2f7037f7db3acbaf379dd6d8f6cda505f71f66c6b425d1a1eae2f1"}, {file = "winrt_runtime-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:06510db215d4f0dc45c00fbb1251c6544e91742a0ad928011db33b30677e1576"}, {file = "winrt_runtime-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:14562c29a087ccad38e379e585fef333e5c94166c807bdde67b508a6261aa195"}, {file = "winrt_runtime-3.2.1-cp313-cp313-win32.whl", hash = "sha256:44e2733bc709b76c554aee6c7fe079443b8306b2e661e82eecfebe8b9d71e4d1"}, {file = "winrt_runtime-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:3c1fdcaeedeb2920dc3b9039db64089a6093cad2be56a3e64acc938849245a6d"}, {file = "winrt_runtime-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:28f3dab083412625ff4d2b46e81246932e6bebddf67bea7f05e01712f54e6159"}, {file = "winrt_runtime-3.2.1-cp314-cp314-win32.whl", hash = "sha256:9b6298375468ac2f6815d0c008a059fc16508c8f587e824c7936ed9216480dad"}, {file = "winrt_runtime-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:e36e587ab5fd681ee472cd9a5995743f75107a1a84d749c64f7e490bc86bc814"}, {file = "winrt_runtime-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:35d6241a2ebd5598e4788e69768b8890ee1eee401a819865767a1fbdd3e9a650"}, {file = "winrt_runtime-3.2.1-cp39-cp39-win32.whl", hash = "sha256:07c0cb4a53a4448c2cb7597b62ae8c94343c289eeebd8f83f946eb2c817bde01"}, {file = "winrt_runtime-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:1856325ca3354b45e0789cf279be9a882134085d34214946db76110d98391efa"}, {file = "winrt_runtime-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:cf237858de1d62e4c9b132c66b52028a7a3e8534e8ab90b0e29a68f24f7be39d"}, {file = "winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea"}, ] [package.dependencies] typing_extensions = ">=4.12.2" [[package]] name = "winrt-windows-devices-bluetooth" version = "3.2.1" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = ">=3.9" groups = ["main"] markers = "sys_platform == \"win32\"" files = [ {file = "winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win32.whl", hash = "sha256:49489351037094a088a08fbdf0f99c94e3299b574edb211f717c4c727770af78"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:20f6a21029034c18ea6a6b6df399671813b071102a0d6d8355bb78cf4f547cdb"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:69c523814eab795bc1bf913292309cb1025ef0a67d5fc33863a98788995e551d"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win32.whl", hash = "sha256:f4082a00b834c1e34b961e0612f3e581356bdb38c5798bd6842f88ec02e5152b"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:44277a3f2cc5ac32ce9b4b2d96c5c5f601d394ac5f02cc71bcd551f738660e2d"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:0803a417403a7d225316b9b0c4fe3f8446579d6a22f2f729a2c21f4befc74a80"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win32.whl", hash = "sha256:18c833ec49e7076127463679e85efc59f61785ade0dc185c852586b21be1f31c"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:9b6702c462b216c91e32388023a74d0f87210cef6fd5d93b7191e9427ce2faca"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:419fd1078c7749119f6b4bbf6be4e586e03a0ed544c03b83178f1d85f1b3d148"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win32.whl", hash = "sha256:12b0a16fb36ce0b42243ca81f22a6b53fbb344ed7ea07a6eeec294604f0505e4"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6703dfbe444ee22426738830fb305c96a728ea9ccce905acfdf811d81045fdb3"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2cf8a0bfc9103e32dc7237af15f84be06c791f37711984abdca761f6318bbdb2"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win32.whl", hash = "sha256:de36ded53ca3ba12fc6dd4deb14b779acc391447726543815df4800348aad63a"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3295d932cc93259d5ccb23a41e3a3af4c78ce5d6a6223b2b7638985f604fa34c"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1f61c178766a1bbce0669f44790c6161ff4669404c477b4aedaa576348f9e102"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp39-cp39-win32.whl", hash = "sha256:32fc355bfdc5d6b3b1875df16eaf12f9b9fc0445e01177833c27d9a4fc0d50b6"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:b886ef1fc0ed49163ae6c2422dd5cb8dd4709da7972af26c8627e211872818d0"}, {file = "winrt_windows_devices_bluetooth-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:8643afa53f9fb8fe3b05967227f86f0c8e1d7b822289e60a848c6368acc977d2"}, {file = "winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505"}, ] [package.dependencies] winrt-runtime = ">=3.2.1.0,<3.3.0.0" [package.extras] all = ["winrt-Windows.Devices.Bluetooth.GenericAttributeProfile[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Devices.Bluetooth.Rfcomm[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Devices.Enumeration[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Devices.Radios[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Networking[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage.Streams[all] (>=3.2.1.0,<3.3.0.0)"] [[package]] name = "winrt-windows-devices-bluetooth-advertisement" version = "3.2.1" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = ">=3.9" groups = ["main"] markers = "sys_platform == \"win32\"" files = [ {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win32.whl", hash = "sha256:a758c5f81a98cc38347fdfb024ce62720969480e8c5b98e402b89d2b09b32866"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:f982ef72e729ddd60cdb975293866e84bb838798828933012a57ee4bf12b0ea1"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:e88a72e1e09c7ccc899a9e6d2ab3fc0f43b5dd4509bcc49ec4abf65b55ab015f"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win32.whl", hash = "sha256:fe17c2cf63284646622e8b2742b064bf7970bbf53cfab02062136c67fa6b06c9"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:78e99dd48b4d89b71b7778c5085fdba64e754dd3ebc54fd09c200fe5222c6e09"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6d5d2295474deab444fc4311580c725a2ca8a814b0f3344d0779828891d75401"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win32.whl", hash = "sha256:901933cc40de5eb7e5f4188897c899dd0b0f577cb2c13eab1a63c7dfe89b08c4"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:e6c66e7d4f4ca86d2c801d30efd2b9673247b59a2b4c365d9e11650303d68d89"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:447d19defd8982d39944642eb7ebe89e4e20259ec9734116cf88879fb2c514ff"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4122348ea525a914e85615647a0b54ae8b2f42f92cdbf89c5a12eea53ef6ed90"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:b66410c04b8dae634a7e4b615c3b7f8adda9c7d4d6902bcad5b253da1a684943"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:07af19b1d252ddb9dd3eb2965118bc2b7cabff4dda6e499341b765e5038ca61d"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win32.whl", hash = "sha256:2985565c265b3f9eab625361b0e40e88c94b03d89f5171f36146f2e88b3ee214"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d102f3fac64fde32332e370969dfbc6f37b405d8cc055d9da30d14d07449a3c2"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:ffeb5e946cd42c32c6999a62e240d6730c653cdfb7b49c7839afba375e20a62a"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp39-cp39-win32.whl", hash = "sha256:6c4747d2e5b0e2ef24e9b84a848cf8fc50fb5b268a2086b5ee8680206d1e0197"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:18d4c5d8b80ee2d29cc13c2fc1353fdb3c0f620c8083701c9b9ecf5e6c503c8d"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:75dd856611d847299078d56aee60e319df52975b931c992cd1d32ad5143fe772"}, {file = "winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc"}, ] [package.dependencies] winrt-runtime = ">=3.2.1.0,<3.3.0.0" [package.extras] all = ["winrt-Windows.Devices.Bluetooth[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage.Streams[all] (>=3.2.1.0,<3.3.0.0)"] [[package]] name = "winrt-windows-devices-bluetooth-genericattributeprofile" version = "3.2.1" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = ">=3.9" groups = ["main"] markers = "sys_platform == \"win32\"" files = [ {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win32.whl", hash = "sha256:af4914d7b30b49232092cd3b934e3ed6f5d3b1715ba47238541408ee595b7f46"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:0e557dd52fc80392b8bd7c237e1153a50a164b3983838b4ac674551072efc9ed"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:64cff62baa6b7aadd6c206e61d149113fdcda17360feb6e9d05bc8bbda4b9fde"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win32.whl", hash = "sha256:832cf65d035a11e6dbfef4fd66abdcc46be7e911ec96e2e72e98e12d8d5b9d3c"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:8179638a6c721b0bbf04ba251ef98d5e02d9a17f0cce377398e42c4fbb441415"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:70b7edfca3190b89ae38bf60972b11978311b6d933d3142ae45560c955dbf5c7"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win32.whl", hash = "sha256:ef894d21e0a805f3e114940254636a8045335fa9de766c7022af5d127dfad557"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:db05de95cd1b24a51abb69cb936a8b17e9214e015757d0b37e3a5e207ddceb3d"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d4e131cf3d15fc5ad81c1bcde3509ac171298217381abed6bdf687f29871984"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win32.whl", hash = "sha256:b1879c8dcf46bd2110b9ad4b0b185f4e2a5f95170d014539203a5fee2b2115f0"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d8d89f01e9b6931fb48217847caac3227a0aeb38a5b7782af71c2e7b262ec30"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:4e71207bb89798016b1795bb15daf78afe45529f2939b3b9e78894cfe650b383"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win32.whl", hash = "sha256:d5f83739ca370f0baf52b0400aebd6240ab80150081fbfba60fd6e7b2e7b4c5f"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:13786a5853a933de140d456cd818696e1121c7c296ae7b7af262fc5d2cffb851"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:5140682da2860f6a55eb6faf9e980724dc457c2e4b4b35a10e1cebd8fc97d892"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp39-cp39-win32.whl", hash = "sha256:963339a0161f9970b577a6193924be783978d11693da48b41a025f61b3c5562a"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:d43615c5dfa939dd30fe80dc0649434a13cc7cf0294ad0d7283d5a9f48c6ce86"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:8e70fa970997e2e67a8a4172bc00b0b2a79b5ff5bb2668f79cf10b3fd63d3974"}, {file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71"}, ] [package.dependencies] winrt-runtime = ">=3.2.1.0,<3.3.0.0" [package.extras] all = ["winrt-Windows.Devices.Bluetooth[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Devices.Enumeration[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage.Streams[all] (>=3.2.1.0,<3.3.0.0)"] [[package]] name = "winrt-windows-devices-enumeration" version = "3.2.1" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = ">=3.9" groups = ["main"] markers = "sys_platform == \"win32\"" files = [ {file = "winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win32.whl", hash = "sha256:40dac777d8f45b41449f3ff1ae70f0d457f1ede53f53962a6e2521b651533db5"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:a101ec3e0ad0a0783032fdcd5dc48e7cd68ee034cbde4f903a8c7b391532c71a"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:3296a3863ac086928ff3f3dc872b2a2fb971dab728817424264f3ca547504e9e"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9f29465a6c6b0456e4330d4ad09eccdd53a17e1e97695c2e57db0d4666cc0011"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2a725d04b4cb43aa0e2af035f73a60d16a6c0ff165fcb6b763383e4e33a975fd"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6365ef5978d4add26678827286034acf474b6b133aa4054e76567d12194e6817"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win32.whl", hash = "sha256:1db22b0292b93b0688d11ad932ad1f3629d4f471310281a2fbfe187530c2c1f3"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:a73bc88d7f510af454f2b392985501c96f39b89fd987140708ccaec1588ceebc"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:2853d687803f0dd76ae1afe3648abc0453e09dff0e7eddbb84b792eddb0473ca"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win32.whl", hash = "sha256:14a71cdcc84f624c209cbb846ed6bd9767a9a9437b2bf26b48ac9a91599da6e9"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6ca40d334734829e178ad46375275c4f7b5d6d2d4fc2e8879690452cbfb36015"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2d14d187f43e4409c7814b7d1693c03a270e77489b710d92fcbbaeca5de260d4"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win32.whl", hash = "sha256:e087364273ed7c717cd0191fed4be9def6fdf229fe9b536a4b8d0228f7814106"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:0da1ddb8285d97a6775c36265d7157acf1bbcb88bcc9a7ce9a4549906c822472"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:09bf07e74e897e97a49a9275d0a647819254ddb74142806bbbcf4777ed240a22"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp39-cp39-win32.whl", hash = "sha256:986e8d651b769a0e60d2834834bdd3f6959f6a88caa0c9acb917797e6b43a588"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:10da7d403ac4afd385fe13bd5808c9a5dd616a8ef31ca5c64cea3f87673661c1"}, {file = "winrt_windows_devices_enumeration-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:679e471d21ac22cb50de1bf4dfc4c0c3f5da9f3e3fbc7f08dcacfe9de9d6dd58"}, {file = "winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf"}, ] [package.dependencies] winrt-runtime = ">=3.2.1.0,<3.3.0.0" [package.extras] all = ["winrt-Windows.ApplicationModel.Background[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Security.Credentials[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage.Streams[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.UI.Popups[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.UI[all] (>=3.2.1.0,<3.3.0.0)"] [[package]] name = "winrt-windows-devices-radios" version = "3.2.1" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = ">=3.9" groups = ["main"] markers = "sys_platform == \"win32\"" files = [ {file = "winrt_windows_devices_radios-3.2.1-cp310-cp310-win32.whl", hash = "sha256:f97766fd551d06c102155d51b2922f96663dee045e1f8d57177def0a2149cb78"}, {file = "winrt_windows_devices_radios-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:104b737fa1279a3b6a88ba3c6236157afc1de03c472657c45e5176ad7a209e23"}, {file = "winrt_windows_devices_radios-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:55b02877d2de06ca6f0f6140611a9af9d0c65710e28f1afdeaac1040433b1837"}, {file = "winrt_windows_devices_radios-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7c02790472414b6cda00d24a8cd23bca18e4b7474ddad4f9264f4484b891807e"}, {file = "winrt_windows_devices_radios-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f87745486d313ba1e7562ca97f25ad436ec01ad4b3b9ea349fb6b6f25cb41104"}, {file = "winrt_windows_devices_radios-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6cee6f946ff3a3571850d1ca745edaee7c331d06ca321873e650779654effc4a"}, {file = "winrt_windows_devices_radios-3.2.1-cp312-cp312-win32.whl", hash = "sha256:c3e683ce682338a5a5ed465f735e223ba7a22f16d0bbea2d070962bc7657edbb"}, {file = "winrt_windows_devices_radios-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:a116e552a3f38607b9be558fb2e7de9b4450d1f9080069944d74d80cdda1873e"}, {file = "winrt_windows_devices_radios-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4c28822f9251c9d547324f596b5c2581f050254ded05e5b786c650a3502744c1"}, {file = "winrt_windows_devices_radios-3.2.1-cp313-cp313-win32.whl", hash = "sha256:ae4a0065927fcd2d10215223f8a46be6fb89bad71cb4edd25dae3d01c137b3a8"}, {file = "winrt_windows_devices_radios-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:bf1a975f46a2aa271ffea1340be0c7e64985050d07433e701343dddc22a72290"}, {file = "winrt_windows_devices_radios-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:10b298ed154c5824cea2de174afce1694ed2aabfb58826de814074027ffef96f"}, {file = "winrt_windows_devices_radios-3.2.1-cp314-cp314-win32.whl", hash = "sha256:21452e1cae50e44cd1d5e78159e1b9986ac3389b66458ad89caa196ce5eca2d6"}, {file = "winrt_windows_devices_radios-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:6a8413e586fe597c6849607885cca7e0549da33ae5699165d11f7911534c6eaf"}, {file = "winrt_windows_devices_radios-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:39129fd9d09103adb003575f59881c1a5a70a43310547850150b46c6f4020312"}, {file = "winrt_windows_devices_radios-3.2.1-cp39-cp39-win32.whl", hash = "sha256:59b868d45ff22afad21b0b0d1466ec43e54543c4e4c6f1efcc2d4adc77053bd5"}, {file = "winrt_windows_devices_radios-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:dbfcbb977f60f19c852204987ace0cd6f7a432d735882a45b3074fdbfd3fdb5a"}, {file = "winrt_windows_devices_radios-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:659e07e6aa5542587ccfc4d4e2cc6e1ef0869606c867a3e95fc82cc8aeaf1f81"}, {file = "winrt_windows_devices_radios-3.2.1.tar.gz", hash = "sha256:4dc9b9d1501846049eb79428d64ec698d6476c27a357999b78a8331072e18a0b"}, ] [package.dependencies] winrt-runtime = ">=3.2.1.0,<3.3.0.0" [package.extras] all = ["winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)"] [[package]] name = "winrt-windows-foundation" version = "3.2.1" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = ">=3.9" groups = ["main"] markers = "sys_platform == \"win32\"" files = [ {file = "winrt_windows_foundation-3.2.1-cp310-cp310-win32.whl", hash = "sha256:677e98165dcbbf7a2367f905bc61090ef2c568b6e465f87cf7276df4734f3b0b"}, {file = "winrt_windows_foundation-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:a8f27b4f0fdb73ccc4a3e24bc8010a6607b2bdd722fa799eafce7daa87d19d39"}, {file = "winrt_windows_foundation-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:d900c6165fab4ea589811efa2feed27b532e1b6f505f63bf63e2052b8cb6bdc4"}, {file = "winrt_windows_foundation-3.2.1-cp311-cp311-win32.whl", hash = "sha256:d1b5970241ccd61428f7330d099be75f4f52f25e510d82c84dbbdaadd625e437"}, {file = "winrt_windows_foundation-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f3762be2f6e0f2aedf83a0742fd727290b397ffe3463d963d29211e4ebb53a7e"}, {file = "winrt_windows_foundation-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:806c77818217b3476e6c617293b3d5b0ff8a9901549dc3417586f6799938d671"}, {file = "winrt_windows_foundation-3.2.1-cp312-cp312-win32.whl", hash = "sha256:867642ccf629611733db482c4288e17b7919f743a5873450efb6d69ae09fdc2b"}, {file = "winrt_windows_foundation-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:45550c5b6c2125cde495c409633e6b1ea5aa1677724e3b95eb8140bfccbe30c9"}, {file = "winrt_windows_foundation-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:94f4661d71cb35ebc52be7af112f2eeabdfa02cb05e0243bf9d6bd2cafaa6f37"}, {file = "winrt_windows_foundation-3.2.1-cp313-cp313-win32.whl", hash = "sha256:3998dc58ed50ecbdbabace1cdef3a12920b725e32a5806d648ad3f4829d5ba46"}, {file = "winrt_windows_foundation-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6e98617c1e46665c7a56ce3f5d28e252798416d1ebfee3201267a644a4e3c479"}, {file = "winrt_windows_foundation-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2a8c1204db5c352f6a563130a5a41d25b887aff7897bb677d4ff0b660315aad4"}, {file = "winrt_windows_foundation-3.2.1-cp314-cp314-win32.whl", hash = "sha256:35e973ab3c77c2a943e139302256c040e017fd6ff1a75911c102964603bba1da"}, {file = "winrt_windows_foundation-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:a22a7ebcec0d262e60119cff728f32962a02df60471ded8b2735a655eccc0ef5"}, {file = "winrt_windows_foundation-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3be7fbae829b98a6a946db4fbaf356b11db1fbcbb5d4f37e7a73ac6b25de8b87"}, {file = "winrt_windows_foundation-3.2.1-cp39-cp39-win32.whl", hash = "sha256:14d5191725301498e4feb744d91f5b46ce317bf3d28370efda407d5c87f4423b"}, {file = "winrt_windows_foundation-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:de5e4f61d253a91ba05019dbf4338c43f962bdad935721ced5e7997933994af5"}, {file = "winrt_windows_foundation-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:ebbf6e8168398c9ed0c72c8bdde95a406b9fbb9a23e3705d4f0fe28e5a209705"}, {file = "winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656"}, ] [package.dependencies] winrt-runtime = ">=3.2.1.0,<3.3.0.0" [package.extras] all = ["winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)"] [[package]] name = "winrt-windows-foundation-collections" version = "3.2.1" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = ">=3.9" groups = ["main"] markers = "sys_platform == \"win32\"" files = [ {file = "winrt_windows_foundation_collections-3.2.1-cp310-cp310-win32.whl", hash = "sha256:46948484addfc4db981dab35688d4457533ceb54d4954922af41503fddaa8389"}, {file = "winrt_windows_foundation_collections-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:899eaa3a93c35bfb1857d649e8dd60c38b978dda7cedd9725fcdbcebba156fd6"}, {file = "winrt_windows_foundation_collections-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:c36eb49ad1eba1b32134df768bb47af13cabb9b59f974a3cea37843e2d80e0e6"}, {file = "winrt_windows_foundation_collections-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9b272d9936e7db4840881c5dcf921eb26789ae4ef23fb6ec15e13e19a16254e7"}, {file = "winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c646a5d442dd6540ade50890081ca118b41f073356e19032d0a5d7d0d38fbc89"}, {file = "winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:2c4630027c93cdd518b0cf4cc726b8fbdbc3388e36d02aa1de190a0fc18ca523"}, {file = "winrt_windows_foundation_collections-3.2.1-cp312-cp312-win32.whl", hash = "sha256:15704eef3125788f846f269cf54a3d89656fa09a1dc8428b70871f717d595ad6"}, {file = "winrt_windows_foundation_collections-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:550dfb8c82fe74d9e0728a2a16a9175cc9e34ca2b8ef758d69b2a398894b698b"}, {file = "winrt_windows_foundation_collections-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:810ad4bd11ab4a74fdbcd3ed33b597ef7c0b03af73fc9d7986c22bcf3bd24f84"}, {file = "winrt_windows_foundation_collections-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4267a711b63476d36d39227883aeb3fb19ac92b88a9fc9973e66fbce1fd4aed9"}, {file = "winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:5e12a6e75036ee90484c33e204b85fb6785fcc9e7c8066ad65097301f48cdd10"}, {file = "winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:34b556255562f1b36d07fba933c2bcd9f0db167fa96727a6cbb4717b152ad7a2"}, {file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win32.whl", hash = "sha256:33188ed2d63e844c8adfbb82d1d3d461d64aaf78d225ce9c5930421b413c45ab"}, {file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d4cfece7e9c0ead2941e55a1da82f20d2b9c8003bb7a8853bb7f999b539f80a4"}, {file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3884146fea13727510458f6a14040b7632d5d90127028b9bfd503c6c655d0c01"}, {file = "winrt_windows_foundation_collections-3.2.1-cp39-cp39-win32.whl", hash = "sha256:20610f098b84c87765018cbc71471092197881f3b92e5d06158fad3bfcea2563"}, {file = "winrt_windows_foundation_collections-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:e9739775320ac4c0238e1775d94a54e886d621f9995977e65d4feb8b3778c111"}, {file = "winrt_windows_foundation_collections-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:e4c6bddb1359d5014ceb45fe2ecd838d4afeb1184f2ea202c2d21037af0d08a3"}, {file = "winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5"}, ] [package.dependencies] winrt-runtime = ">=3.2.1.0,<3.3.0.0" [package.extras] all = ["winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)"] [[package]] name = "winrt-windows-storage-streams" version = "3.2.1" description = "Python projection of Windows Runtime (WinRT) APIs" optional = false python-versions = ">=3.9" groups = ["main"] markers = "sys_platform == \"win32\"" files = [ {file = "winrt_windows_storage_streams-3.2.1-cp310-cp310-win32.whl", hash = "sha256:89bb2d667ebed6861af36ed2710757456e12921ee56347946540320dacf6c003"}, {file = "winrt_windows_storage_streams-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:48a78e5dc7d3488eb77e449c278bc6d6ac28abcdda7df298462c4112d7635d00"}, {file = "winrt_windows_storage_streams-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:da71231d4a554f9f15f1249b4990c6431176f6dfb0e3385c7caa7896f4ca24d6"}, {file = "winrt_windows_storage_streams-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7dace2f9e364422255d0e2f335f741bfe7abb1f4d4f6003622b2450b87c91e69"}, {file = "winrt_windows_storage_streams-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:b02fa251a7eef6081eca1a5f64ecf349cfd1ac0ac0c5a5a30be52897d060bed5"}, {file = "winrt_windows_storage_streams-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:efdf250140340a75647e8e8ad002782d91308e9fdd1e19470a5b9cc969ae4780"}, {file = "winrt_windows_storage_streams-3.2.1-cp312-cp312-win32.whl", hash = "sha256:77c1f0e004b84347b5bd705e8f0fc63be8cd29a6093be13f1d0869d0d97b7d78"}, {file = "winrt_windows_storage_streams-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:e4508ee135af53e4fc142876abbf4bc7c2a95edfc7d19f52b291a8499cacd6dc"}, {file = "winrt_windows_storage_streams-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:040cb94e6fb26b0d00a00e8b88b06fadf29dfe18cf24ed6cb3e69709c3613307"}, {file = "winrt_windows_storage_streams-3.2.1-cp313-cp313-win32.whl", hash = "sha256:401bb44371720dc43bd1e78662615a2124372e7d5d9d65dfa8f77877bbcb8163"}, {file = "winrt_windows_storage_streams-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:202c5875606398b8bfaa2a290831458bb55f2196a39c1d4e5fa88a03d65ef915"}, {file = "winrt_windows_storage_streams-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:ca3c5ec0aab60895006bf61053a1aca6418bc7f9a27a34791ba3443b789d230d"}, {file = "winrt_windows_storage_streams-3.2.1-cp314-cp314-win32.whl", hash = "sha256:5cd0dbad86fcc860366f6515fce97177b7eaa7069da261057be4813819ba37ee"}, {file = "winrt_windows_storage_streams-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3c5bf41d725369b9986e6d64bad7079372b95c329897d684f955d7028c7f27a0"}, {file = "winrt_windows_storage_streams-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:293e09825559d0929bbe5de01e1e115f7a6283d8996ab55652e5af365f032987"}, {file = "winrt_windows_storage_streams-3.2.1-cp39-cp39-win32.whl", hash = "sha256:1c630cfdece58fcf82e4ed86c826326123529836d6d4d855ae8e9ceeff67b627"}, {file = "winrt_windows_storage_streams-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:d7ff22434a4829d616a04b068a191ac79e008f6c27541bb178c1f6f1fe7a1657"}, {file = "winrt_windows_storage_streams-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:fa90244191108f85f6f7afb43a11d365aca4e0722fe8adc62fb4d2c678d0993d"}, {file = "winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f"}, ] [package.dependencies] winrt-runtime = ">=3.2.1.0,<3.3.0.0" [package.extras] all = ["winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.System[all] (>=3.2.1.0,<3.3.0.0)"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.15" content-hash = "4fb1b805a1709e73be58458a201916e619c1b1923cdc3ad281932299d7be3042" Bluetooth-Devices-habluetooth-75cbe37/pyproject.toml000066400000000000000000000127471521117704500227260ustar00rootroot00000000000000[build-system] requires = ['setuptools>=77.0', 'Cython>=3', "poetry-core>=2.0.0"] build-backend = "poetry.core.masonry.api" [project] name = "habluetooth" version = "6.8.3" 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.15" bleak = ">=3.0.2" bleak-retry-connector = ">=4.6.1" bluetooth-data-tools = ">=1.29.18" bluetooth-adapters = ">=2.2.0" bluetooth-auto-recovery = ">=1.6.4" async-interrupt = ">=1.1.1" dbus-fast = { version = ">=5.0.16", markers = "platform_system == 'Linux'" } btsocket = ">=0.3.0" [tool.poetry.group.dev.dependencies] pytest = ">=9.0.3,<10" pytest-cov = ">=7.1.0,<8" pytest-asyncio = ">=1.4.0,<1.5.0" pytest-codspeed = ">=5.0.3,<6.0.0" pytest-timeout = ">=2.3.1" freezegun = "^1.5.5" dbus-fast = ">=5.0.16" [tool.poetry.group.docs] optional = true [tool.poetry.group.docs.dependencies] myst-parser = ">=5.1.0" 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" timeout = 5 [tool.coverage.run] branch = true [tool.coverage.report] exclude_lines = [ "pragma: no cover", "@overload", "if TYPE_CHECKING", "raise NotImplementedError", 'if __name__ == "__main__":', ] [tool.ruff] target-version = "py311" line-length = 88 [tool.ruff.lint] ignore = [ "E721", # type checks for cython "D203", # 1 blank line required before class docstring "D212", # Multi-line docstring summary should start at the first line "D100", # Missing docstring in public module "D104", # Missing docstring in public package "D107", # Missing docstring in `__init__` "D401", # First line of docstring should be in imperative mood "ASYNC109", # ``timeout`` is part of our bleak-compatible public API "TRY003", # too many to fix; long messages outside the exception class ] select = [ "A", # flake8-builtins "ASYNC", # flake8-async "B", # flake8-bugbear "BLE", # flake8-blind-except "C4", # flake8-comprehensions "C90", # mccabe complexity "D", # flake8-docstrings "DTZ", # flake8-datetimez "E", # pycodestyle "EM", # flake8-errmsg "ERA", # eradicate "EXE", # flake8-executable "F", # pyflake "FA", # flake8-future-annotations "FIX", # flake8-fixme "FLY", # flynt "FURB", # refurb "G", # flake8-logging-format "I", # isort "ICN", # flake8-import-conventions "INP", # flake8-no-pep420 "ISC", # flake8-implicit-str-concat "LOG", # flake8-logging "N", # pep8-naming "NPY", # numpy-specific rules "PERF", # Perflint "PGH", # pygrep-hooks "PIE", # flake8-pie "PLC", # pylint convention "PLW", # pylint warnings "PT", # flake8-pytest-style "PTH", # flake8-use-pathlib "PYI", # flake8-pyi "Q", # flake8-quotes "RET", # return "RSE", # flake8-raise "RUF", # ruff specific "S", # flake8-bandit "SIM", # simplify "SLOT", # flake8-slots "T10", # flake8-debugger "T20", # flake8-print "TC", # flake8-type-checking "TD", # flake8-todos "TRY", # tryceratops "UP", # pyupgrade "W", # pycodestyle "YTT", # flake8-2020 ] [tool.ruff.lint.per-file-ignores] "tests/**/*" = [ "D100", "D101", "D102", "D103", "D104", "S101", ] "setup.py" = ["D100"] "conftest.py" = ["D100"] # Sphinx config: ``copyright`` is the Sphinx convention, file is not a package "docs/conf.py" = ["D100", "A001", "INP001"] # Example scripts intentionally use print and aren't a package "examples/*" = ["INP001", "T201"] [tool.ruff.lint.isort] known-first-party = ["habluetooth", "tests"] [tool.mypy] check_untyped_defs = true disallow_any_generics = true disallow_incomplete_defs = true disallow_untyped_defs = true mypy_path = "src/" no_implicit_optional = true show_error_codes = true warn_unreachable = true warn_unused_ignores = true exclude = [ 'docs/.*', 'setup.py', ] [[tool.mypy.overrides]] module = "tests.*" allow_untyped_defs = true [[tool.mypy.overrides]] module = "docs.*" ignore_errors = true Bluetooth-Devices-habluetooth-75cbe37/renovate.json000066400000000000000000000001011521117704500225050ustar00rootroot00000000000000{ "extends": ["github>browniebroke/renovate-configs:python"] } Bluetooth-Devices-habluetooth-75cbe37/src/000077500000000000000000000000001521117704500205665ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/000077500000000000000000000000001521117704500231045ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/__init__.py000066400000000000000000000047041521117704500252220ustar00rootroot00000000000000__version__ = "6.8.3" from bleak_retry_connector import Allocations from .advertisement_tracker import ( TRACKER_BUFFERING_WOBBLE_SECONDS, AdvertisementTracker, ) from .base_scanner import BaseHaRemoteScanner, BaseHaScanner from .central_manager import get_manager, set_manager from .const import ( CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, SCANNER_WATCHDOG_INTERVAL, SCANNER_WATCHDOG_TIMEOUT, UNAVAILABLE_TRACK_SECONDS, ) from .manager import BluetoothManager from .models import ( BluetoothReachabilityIntent, BluetoothServiceInfo, BluetoothServiceInfoBleak, HaBluetoothConnector, HaBluetoothSlotAllocations, HaScannerDetails, HaScannerModeChange, HaScannerRegistration, HaScannerRegistrationEvent, HaScannerType, ) from .scanner import BluetoothScanningMode, HaScanner, ScannerStartError from .scanner_device import BluetoothScannerDevice from .storage import ( DiscoveredDeviceAdvertisementData, DiscoveredDeviceAdvertisementDataDict, DiscoveryStorageType, discovered_device_advertisement_data_from_dict, discovered_device_advertisement_data_to_dict, expire_stale_scanner_discovered_device_advertisement_data, ) from .wrappers import HaBleakClientWrapper, HaBleakScannerWrapper __all__ = [ "CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS", "FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS", "SCANNER_WATCHDOG_INTERVAL", "SCANNER_WATCHDOG_TIMEOUT", "TRACKER_BUFFERING_WOBBLE_SECONDS", "UNAVAILABLE_TRACK_SECONDS", "AdvertisementTracker", "Allocations", "BaseHaRemoteScanner", "BaseHaScanner", "BluetoothManager", "BluetoothReachabilityIntent", "BluetoothScannerDevice", "BluetoothScanningMode", "BluetoothServiceInfo", "BluetoothServiceInfoBleak", "DiscoveredDeviceAdvertisementData", "DiscoveredDeviceAdvertisementDataDict", "DiscoveryStorageType", "HaBleakClientWrapper", "HaBleakScannerWrapper", "HaBluetoothConnector", "HaBluetoothSlotAllocations", "HaScanner", "HaScannerDetails", "HaScannerModeChange", "HaScannerRegistration", "HaScannerRegistrationEvent", "HaScannerType", "ScannerStartError", "discovered_device_advertisement_data_from_dict", "discovered_device_advertisement_data_to_dict", "expire_stale_scanner_discovered_device_advertisement_data", "get_manager", "set_manager", ] Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/advertisement_tracker.pxd000066400000000000000000000007571521117704500302170ustar00rootroot00000000000000import cython from .models cimport BluetoothServiceInfoBleak cdef unsigned int _ADVERTISING_TIMES_NEEDED cdef class AdvertisementTracker: cdef public dict intervals cdef public dict fallback_intervals cdef public dict sources cdef public dict _timings @cython.locals(timings=list) cpdef void async_collect(self, BluetoothServiceInfoBleak service_info) cpdef void async_remove_address(self, object address) cpdef void async_scanner_paused(self, str source) Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/advertisement_tracker.py000066400000000000000000000072541521117704500300530ustar00rootroot00000000000000"""The advertisement tracker.""" from __future__ import annotations from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from .models import BluetoothServiceInfoBleak ADVERTISING_TIMES_NEEDED = 16 _ADVERTISING_TIMES_NEEDED = ADVERTISING_TIMES_NEEDED # Each scanner may buffer incoming packets so # we need to give a bit of leeway before we # mark a device unavailable TRACKER_BUFFERING_WOBBLE_SECONDS = 5 _str = str class AdvertisementTracker: """Tracker to determine the interval that a device is advertising.""" __slots__ = ("_timings", "fallback_intervals", "intervals", "sources") def __init__(self) -> None: """Initialize the tracker.""" self.intervals: dict[str, float] = {} self.fallback_intervals: dict[str, float] = {} self.sources: dict[str, str] = {} self._timings: dict[str, list[float]] = {} def async_diagnostics(self) -> dict[str, dict[str, Any]]: """Return diagnostics.""" return { "intervals": self.intervals, "fallback_intervals": self.fallback_intervals, "sources": self.sources, "timings": self._timings, } def async_collect(self, service_info: BluetoothServiceInfoBleak) -> None: """ Collect timings for the tracker. For performance reasons, it is the responsibility of the caller to check if the device already has an interval set or the source has changed before calling this function. """ self.sources[service_info.address] = service_info.source if not (timings := self._timings.get(service_info.address)): self._timings[service_info.address] = [service_info.time] return timings.append(service_info.time) if len(timings) != _ADVERTISING_TIMES_NEEDED: return max_time_between_advertisements = timings[1] - timings[0] for i in range(2, len(timings)): time_between_advertisements = timings[i] - timings[i - 1] if time_between_advertisements > max_time_between_advertisements: max_time_between_advertisements = time_between_advertisements # We now know the maximum time between advertisements self.intervals[service_info.address] = max_time_between_advertisements del self._timings[service_info.address] def async_remove_address(self, address: _str) -> None: """Remove the tracker.""" self.intervals.pop(address, None) self.sources.pop(address, None) self._timings.pop(address, None) def async_remove_fallback_interval(self, address: str) -> None: """Remove fallback interval.""" self.fallback_intervals.pop(address, None) def async_remove_source(self, source: str) -> None: """Remove the tracker.""" for address, tracked_source in list(self.sources.items()): if tracked_source == source: self.async_remove_address(address) def async_scanner_paused(self, source: str) -> None: """ Clear timing collection data when scanner is paused. When a scanner pauses to establish a connection, it stops listening for advertisements. If we don't clear the timing data, the next advertisement after the connection attempt will create an incorrectly large interval measurement (time_after_connection - time_before_connection) which doesn't represent the actual advertising interval of the device. """ # Only iterate through timing data (typically much smaller than sources) for address in list(self._timings): if self.sources.get(address) == source: del self._timings[address] Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/auto_scheduler.pxd000066400000000000000000000064511521117704500266350ustar00rootroot00000000000000import cython from .models cimport BluetoothServiceInfoBleak cdef double _AUTO_INITIAL_SWEEP_DELAY cdef double _AUTO_REDISCOVERY_INTERVAL cdef double _AUTO_REDISCOVERY_SWEEP_DURATION cdef double _AUTO_WINDOW_MAX_DURATION cdef double _AUTO_WINDOW_MIN_DURATION cdef double _AUTO_CONNECTING_DEFER cdef double _AUTO_COALESCE_LOOKAHEAD cdef double _ON_DEMAND_EXTENSION_SLOP cdef int NO_RSSI_VALUE cdef double _clamp_window_duration(double duration) noexcept cdef class ActiveScanRequest: cdef public str address cdef public double scan_interval cdef public double scan_duration cdef class _ScannerWorker: cdef public object _scheduler cdef public object _scanner cdef public object _manager cdef public object _wake cdef public object _task cdef public double _window_end cdef public double _sweep_last_completed cdef public bint _failed_window cdef public bint _warned_no_fallback cdef public dict _owned_due_at cpdef void start(self, object loop, double initial_offset=*) cpdef void stop(self) cpdef void wake(self) cpdef void _attach_owned(self, str address, dict entries) cpdef void _detach_owned(self, str address) cpdef void _clear_owned(self) cpdef void note_window_dispatched(self, double window_end, double now) @cython.locals( next_at=double, earliest=double, ) cpdef double _next_event_at(self, double now) @cython.locals( threshold=double, any_immediate=bint, t=double, ) cpdef tuple _collect_due_buckets(self, double now) cpdef void _advance_due(self, list due_buckets, double from_time) cdef class _ScanSchedule: cdef public dict _due_at cdef public dict _workers cdef public dict _owner_by_address cpdef bint seed(self, str address, ActiveScanRequest request, double due_time) cpdef void drop(self, str address, ActiveScanRequest request) cpdef void assign(self, str address, str new_source) cpdef void unown(self, str address) cpdef void clear_source(self, str source) cpdef void attach_worker(self, str source) cpdef void clear(self) cdef class AutoScanScheduler: cdef public object _manager cdef public dict _requests_by_address cdef public _ScanSchedule _schedule cdef public dict _workers cdef public object _loop cdef public bint _running cdef public object _on_demand_sweep_future cdef public double _on_demand_sweep_end cpdef void add_request(self, ActiveScanRequest request) cpdef void remove_request(self, ActiveScanRequest request) cpdef void add_scanner(self, object scanner) @cython.locals( source=str, ) cpdef void remove_scanner(self, object scanner) @cython.locals( address=str, requests=set, ) cpdef void on_advertisement(self, BluetoothServiceInfoBleak service_info) @cython.locals( request=ActiveScanRequest, ) cpdef void _seed_requests( self, str address, set requests, double now ) cpdef void start(self, object loop) cpdef void stop(self) @cython.locals( best_rssi=int, rssi=int, adv_rssi=object, scanner=object, mode=object, ) cpdef tuple _resolve_fallback_for_address( self, str address, str exclude_source ) Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/auto_scheduler.py000066400000000000000000001505111521117704500264670ustar00rootroot00000000000000""" Auto-mode active-window scheduler. Coordinates on-demand ACTIVE scans for AUTO-mode scanners. A scanner defaults to PASSIVE; the manager flips it to ACTIVE for ``duration`` seconds on demand when an integration has asked for active scans on a specific device address. Per-device active windows fire on **exactly one** scanner at a time: whichever scanner the manager currently considers the device's owner (``manager.async_last_service_info(address).source``). If three other AUTO scanners can also see the device, they stay PASSIVE for that window. Ownership can flip across scanners over time as RSSI changes; the next-due window then fires on the new owner (see "Migration" below). The rediscovery sweep is a floor for AUTO scanners that haven't active-scanned in ``AUTO_REDISCOVERY_INTERVAL`` (12 h); any active window — per-device, sweep, or a window delegated to this scanner by the connecting-fallback path — advances ``_sweep_last_completed``, so the sweep only fires on scanners that would otherwise stay idle. Flow ==== add_request(req) on_advertisement(adv) | | | _schedule.seed(...) | _schedule.seed(...) per req; | _schedule.assign(addr, | _schedule.assign(addr, | history.source) | adv.source) wakes owner v v +----------------------------------------------+ | AutoScanScheduler | | _requests_by_address | | addr -> set of ActiveScanRequest | | _workers | | source -> _ScannerWorker | | _schedule: _ScanSchedule | | _due_at | | addr -> {request: next_due_time} | | _owner_by_address | | addr -> source | +----------------------------------------------+ | | aliased per-worker view: | worker._owned_due_at[addr] | is _due_at[addr] for entries | this worker owns v +----------------------------------------------+ | _ScannerWorker._run loop | | | | sleep on _wake with timeout = | | _next_event_at(now) - now | | await _tick() | | | | _tick (sync collect, one await): | | 1. _collect_due_buckets iterates | | _owned_due_at (owned subset only); | | per-address history call is just for | | orphan/drift resync, not the owner | | check (ownership is the view itself) | | 2. sweep_due = sweep cadence elapsed | | 3. if owner has connect in progress: | | dispatch per-address to a | | fallback scanner; return | | 4. duration = max(due durations, | | SWEEP_DURATION if sweep_due) | | 5. _advance_due (pre-await) so the | | new owner of any of these | | addresses can't double-fire; | | _sweep_last_completed = now (any | | active scan satisfies the floor) | | 6. ONE await: | | scanner.async_request_active_window | +----------------------------------------------+ Migration ========= When a device moves from scanner A to scanner B (RSSI flip; manager swaps ``_all_history[addr].source`` from A to B), the scheduler picks up the new owner without any address-level rescheduling: 1. The manager's ``_scanner_adv_received`` updates ``_all_history`` and then calls ``auto_scheduler.on_advertisement(service_info)`` *before* the same-payload short-circuit, so the flip is visible to the scheduler even for static-payload beacons. 2. ``on_advertisement`` calls ``_schedule.assign(adv.address, adv.source)``. The schedule detaches the entry from A's ``_owned_due_at``, attaches it to B's ``_owned_due_at`` (same dict object, just a different worker holds the alias), and wakes B's worker. A's worker no longer sees the address at all; B's does. 3. On B's next ``_tick``, ``_collect_due_buckets`` iterates its ``_owned_due_at``, finds the entry, and dispatches it. 4. If the flip lands mid-window on A, the pre-await ``_advance_due`` in step 5 of ``_tick`` already advanced the entry's due time, so B can't double-fire. The rare orphan/drift branches in ``_collect_due_buckets`` handle the edge case where the manager's history disagrees with the cached owner view. Connecting fallback =================== A scanner mid-connect can't service the active-window mode flip (the radio is busy). At tick time, if ``scanner._connections_in_progress() > 0``, the owner's worker routes each due address via ``AutoScanScheduler._resolve_fallback_for_address``: * A non-connecting ACTIVE scanner that sees the address is treated as "covered" — the device is already being actively scanned, no flip needed. * Otherwise, the highest-RSSI non-connecting AUTO scanner that sees the address is picked as a fallback. Calls are coalesced per fallback so each scanner receives at most one ``async_request_active_window`` per tick. * No usable fallback: warn (rate-limited), advance the address by ``_AUTO_CONNECTING_DEFER`` so the next tick retries soon after the connect typically completes. ``_ScannerWorker.note_window_dispatched`` is called on the fallback worker before the await to mark its radio as currently active — this advances both ``_window_end`` (suppressing the fallback's own ticks during the delegated window) and ``_sweep_last_completed`` (any active window satisfies the sweep floor). Invariants ========== * At most one outstanding window per scanner (``_window_end`` guards re-entry into ``_tick``). * Per-device windows fire only on the scanner whose ``source`` matches the device's most recent advertisement source; other scanners that see the same device skip it. * Any active window (per-device, sweep, or delegated) advances the scanner's ``_sweep_last_completed`` to ``now``. The rediscovery sweep therefore fires only on AUTO scanners that haven't had *any* active scan in ``AUTO_REDISCOVERY_INTERVAL`` (12 h). * A registration kick-starts tracking immediately; ``on_advertisement`` is the fallback that re-creates the entry if a worker ``unown``'d it because the device's history was missing at tick time. * Every accepted advertisement on a tracked address wakes the source's worker so an ownership flip on the same scanner triggers a re-evaluation of ``_next_event_at``. """ from __future__ import annotations import asyncio import contextlib import logging from typing import TYPE_CHECKING, Any from bleak_retry_connector import NO_RSSI_VALUE from .const import ( AUTO_COALESCE_LOOKAHEAD, AUTO_INITIAL_SWEEP_DELAY, AUTO_REDISCOVERY_INTERVAL, AUTO_REDISCOVERY_SWEEP_DURATION, AUTO_WINDOW_MAX_DURATION, AUTO_WINDOW_MIN_DURATION, ) from .models import BluetoothScanningMode if TYPE_CHECKING: from .base_scanner import BaseHaScanner from .manager import BluetoothManager from .models import BluetoothServiceInfoBleak # Locally aliased so the Cython .pxd can declare them as C-typed constants; # the unaliased names stay importable from this module for Python callers. _AUTO_INITIAL_SWEEP_DELAY = AUTO_INITIAL_SWEEP_DELAY _AUTO_REDISCOVERY_INTERVAL = AUTO_REDISCOVERY_INTERVAL _AUTO_REDISCOVERY_SWEEP_DURATION = AUTO_REDISCOVERY_SWEEP_DURATION _AUTO_WINDOW_MAX_DURATION = AUTO_WINDOW_MAX_DURATION _AUTO_WINDOW_MIN_DURATION = AUTO_WINDOW_MIN_DURATION _AUTO_COALESCE_LOOKAHEAD = AUTO_COALESCE_LOOKAHEAD # Retry delay when the owner is mid-connect: short enough that we # retry shortly after a typical connect completes (~10s), long enough # that we don't busy-loop while it's in flight. _AUTO_CONNECTING_DEFER = 30.0 # Joiner-extension threshold: a desired end-time within this margin # of the in-flight window's end does not trigger an extension. # Absorbs sub-second task-start jitter for same-duration callers. _ON_DEMAND_EXTENSION_SLOP = 1.0 _LOGGER = logging.getLogger(__name__) def _clamp_window_duration(duration: float) -> float: """Clamp a window duration into ``[MIN, MAX]``.""" if duration < _AUTO_WINDOW_MIN_DURATION: return _AUTO_WINDOW_MIN_DURATION if duration > _AUTO_WINDOW_MAX_DURATION: return _AUTO_WINDOW_MAX_DURATION return duration class ActiveScanRequest: """ A registered need for on-demand active scans on one address. ``scan_interval`` and ``scan_duration`` must be finite positive floats. ``async_register_active_scan`` enforces this at the public boundary; direct constructors must honor the same contract. """ __slots__ = ("address", "scan_duration", "scan_interval") def __init__( self, address: str, scan_interval: float, scan_duration: float, ) -> None: self.address = address self.scan_interval = scan_interval self.scan_duration = scan_duration class _ScannerWorker: """One persistent task per AUTO scanner; sleeps until next due event.""" __slots__ = ( "_failed_window", "_manager", "_owned_due_at", "_scanner", "_scheduler", "_sweep_last_completed", "_task", "_wake", "_warned_no_fallback", "_window_end", ) def __init__( self, scheduler: AutoScanScheduler, scanner: BaseHaScanner, manager: BluetoothManager, ) -> None: self._scheduler = scheduler self._scanner = scanner self._manager = manager self._wake: asyncio.Event = asyncio.Event() self._task: asyncio.Task[None] | None = None self._window_end: float = 0.0 self._sweep_last_completed: float = 0.0 self._failed_window: bool = False self._warned_no_fallback: bool = False # Subset of _due_at owned by this worker; inner dicts aliased so # in-place advances stay visible. Mutated only via _attach_owned # / _detach_owned / _clear_owned, driven by _ScanSchedule. self._owned_due_at: dict[str, dict[ActiveScanRequest, float]] = {} def start( self, loop: asyncio.AbstractEventLoop, initial_offset: float = 0.0 ) -> None: """ Start the worker; first sweep at AUTO_INITIAL_SWEEP_DELAY + offset. ``initial_offset`` staggers first sweeps across concurrently- registered scanners so they don't all flip ACTIVE at once. """ self._sweep_last_completed = ( loop.time() + _AUTO_INITIAL_SWEEP_DELAY + initial_offset - _AUTO_REDISCOVERY_INTERVAL ) self._task = loop.create_task(self._run()) def stop(self) -> None: """Cancel the worker task.""" if self._task is not None and not self._task.done(): self._task.cancel() def wake(self) -> None: """Interrupt the worker's sleep so it re-evaluates pending work.""" self._wake.set() def _attach_owned( self, address: str, entries: dict[ActiveScanRequest, float] ) -> None: """Attach an owned ``_due_at`` bucket; called only by _ScanSchedule.""" self._owned_due_at[address] = entries def _detach_owned(self, address: str) -> None: """Detach an owned bucket; called only by _ScanSchedule.""" del self._owned_due_at[address] def _clear_owned(self) -> None: """Drop all owned buckets; called only by _ScanSchedule.""" self._owned_due_at.clear() def note_window_dispatched(self, window_end: float, now: float) -> None: """ Record that another worker delegated an active window here. Bumps ``_window_end`` to suppress redundant ticks during the delegated window, and ``_sweep_last_completed`` so the window counts as this worker's sweep. Both use ``max`` to preserve a longer pre-existing value. Known best-effort caveats; revisit if profiling shows they matter: * If this worker is mid-``_tick`` when we set ``_window_end``, its ``finally`` resets ``_window_end`` to 0 on exit, wiping our bump. The optimization is then skipped: this worker ticks normally during the delegated window. Correctness is preserved (scanner-level ``_active_window_handle`` extends the radio window idempotently; each worker advances its ``_owned_due_at`` entries on its own tick), only the intended "skip your own ticks during my window" hint is lost. ``_sweep_last_completed`` lives outside the ``finally`` and survives. * The rediscovery sweep only exists to give AUTO scanners that never see an active window a periodic active-scan floor. A fallback the dispatcher delegates to *is* actively scanning, so it doesn't need the floor — ``_sweep_last_completed`` is bumped to ``now`` on every delegation so its separately-scheduled 12 h sweep stays deferred while delegated windows are happening, which is the right answer regardless of how short the delegated window is. """ if self._window_end < window_end: self._window_end = window_end if self._sweep_last_completed < now: self._sweep_last_completed = now def _next_event_at(self, now: float) -> float: """ Return the earliest loop-time at which this worker has work. O(owned), no async_last_service_info call. """ if self._window_end > now: return self._window_end next_at = self._sweep_last_completed + _AUTO_REDISCOVERY_INTERVAL for entries in self._owned_due_at.values(): earliest = min(entries.values()) if earliest < next_at: next_at = earliest return next_at async def _run(self) -> None: """Sleep until next event or wake, then process due work.""" while True: loop = self._scheduler._loop if loop is None: return now = loop.time() next_at = self._next_event_at(now) self._wake.clear() delay = max(0.0, next_at - now) if delay > 0: with contextlib.suppress(asyncio.TimeoutError): await asyncio.wait_for(self._wake.wait(), timeout=delay) if not self._scheduler._running: return await self._tick() def _collect_due_buckets( self, now: float ) -> tuple[ list[tuple[str, dict[ActiveScanRequest, float], list[ActiveScanRequest]]], list[ActiveScanRequest], bool, ]: """ Return (due_buckets, all_due, any_immediate) for owned addresses. Collects entries due within ``_AUTO_COALESCE_LOOKAHEAD``; caller gates on ``any_immediate or sweep_due``. Prunes orphans and resyncs on owner drift. """ source = self._scanner.source last_service_info = self._manager.async_last_service_info threshold = now + _AUTO_COALESCE_LOOKAHEAD due_buckets: list[ tuple[str, dict[ActiveScanRequest, float], list[ActiveScanRequest]] ] = [] all_due: list[ActiveScanRequest] = [] any_immediate = False for address, entries in self._owned_due_at.copy().items(): history = last_service_info(address, False) if history is None: self._scheduler._schedule.unown(address) continue if history.source != source: self._scheduler._schedule.assign(address, history.source) continue due: list[ActiveScanRequest] = [] for r, t in entries.items(): if t <= threshold: due.append(r) if t <= now: any_immediate = True if not due: continue due_buckets.append((address, entries, due)) all_due.extend(due) return due_buckets, all_due, any_immediate def _advance_due( self, due_buckets: list[ tuple[str, dict[ActiveScanRequest, float], list[ActiveScanRequest]] ], from_time: float, ) -> None: """ Advance all buckets by ``from_time + scan_interval`` (pre-await). Called before the scanner await so a mid-window ownership flip can't let a new owner double-fire. """ for _address, entries, due in due_buckets: for request in due: entries[request] = from_time + request.scan_interval async def _tick(self) -> None: """ Fire one coalesced window covering due per-device + sweep work. Collection is sync; only the scanner call is awaited. The window duration is the max of every due per-device duration and (if sweep is due) the sweep duration. ``scan_interval`` runs from window start (now), not window end. Failure of the scanner call still advances the due times (``_advance_due`` ran pre-await) so a stuck scanner can't busy-loop the worker. If the owner is mid-connect at tick time, dispatch is routed to alternate scanners via ``_dispatch_to_fallback``. """ loop = self._scheduler._loop if loop is None: return now = loop.time() # Defense-in-depth re-entry guard: unreachable on the current # call path (single per-worker task, finally clears # _window_end) but kept for future callers of _tick. if self._window_end > now: return self._window_end = 0.0 try: due_buckets, all_due, any_immediate = self._collect_due_buckets(now) sweep_due = now >= self._sweep_last_completed + _AUTO_REDISCOVERY_INTERVAL # Gate on any_immediate (per-device hit now) or sweep_due # (12 h floor). Soon-due-only entries ride a window that # one of those triggers, but never trigger one alone. if not any_immediate and not sweep_due: return if self._scanner._connections_in_progress() > 0: # Per-address advance happens inside _dispatch_to_fallback # so no-fallback addresses get a short retry interval # rather than the full scan_interval. await self._dispatch_to_fallback(due_buckets, sweep_due, now) return duration = self._scheduler._coalesce_duration(all_due) if all_due else 0.0 if sweep_due and duration < _AUTO_REDISCOVERY_SWEEP_DURATION: duration = _AUTO_REDISCOVERY_SWEEP_DURATION self._window_end = now + duration # Advance pre-await: a new owner that wakes mid-window # must see the entries already advanced, otherwise an # RSSI flip would let the new owner fire a duplicate # window. self._advance_due(due_buckets, now) # Any active window is functionally a sweep — the # rediscovery sweep exists only to give AUTO scanners # that haven't actively scanned in 12 h a floor, so # there's no point in scheduling a separate one when # the radio is about to scan anyway. self._sweep_last_completed = now try: await self._scanner.async_request_active_window(duration) except Exception as ex: # pylint: disable=broad-except # First failure per recovery-cycle gets a traceback; # subsequent failures collapse to a one-liner so a # persistently broken scanner can't spam the log. # Flag clears on the next success so failure-after- # recovery captures a stack again. if self._failed_window: _LOGGER.warning( "%s: error running active window of %.1fs: %s", self._scanner.name, duration, ex, ) else: self._failed_window = True _LOGGER.exception( "%s: error running active window of %.1fs", self._scanner.name, duration, ) else: self._failed_window = False except Exception: # pylint: disable=broad-except # Sync-phase failure (collect/advance/coalesce). Log so # the worker doesn't die silently, then continue. _LOGGER.exception( "%s: unexpected error in auto-window tick", self._scanner.name ) finally: self._window_end = 0.0 async def _dispatch_to_fallback( # noqa: C901 self, due_buckets: list[ tuple[str, dict[ActiveScanRequest, float], list[ActiveScanRequest]] ], sweep_due: bool, now: float, ) -> None: """ Owner is mid-connect: route per-address windows to alternates. Per-address outcomes (advance done in-line so a mid-dispatch ownership flip can't double-fire): * ACTIVE scanner sees it -> covered, advance by scan_interval. * AUTO fallback found -> dispatch, advance by scan_interval. * Neither -> warn (rate-limited), advance only by ``_AUTO_CONNECTING_DEFER`` so the next tick retries soon after the connect typically completes. Sweep is per-scanner; defer via ``_sweep_last_completed`` so the next tick retries without spinning the loop. """ fallback_groups: dict[str, tuple[BaseHaScanner, list[ActiveScanRequest]]] = {} no_fallback_addresses: list[str] = [] exclude_source = self._scanner.source had_any_progress = False retry_at = now + _AUTO_CONNECTING_DEFER for address, entries, due in due_buckets: covered, fallback = self._scheduler._resolve_fallback_for_address( address, exclude_source ) if not covered and fallback is None: for request in due: entries[request] = retry_at no_fallback_addresses.append(address) continue for request in due: entries[request] = now + request.scan_interval had_any_progress = True if fallback is None: continue existing = fallback_groups.get(fallback.source) if existing is None: fallback_groups[fallback.source] = (fallback, list(due)) else: existing[1].extend(due) if no_fallback_addresses: if not self._warned_no_fallback: self._warned_no_fallback = True _LOGGER.warning( "%s: connect in progress and no fallback scanner for %s;" " retrying in %.1fs", self._scanner.name, ", ".join(no_fallback_addresses), _AUTO_CONNECTING_DEFER, ) elif had_any_progress: self._warned_no_fallback = False if sweep_due: self._sweep_last_completed = ( now - _AUTO_REDISCOVERY_INTERVAL + _AUTO_CONNECTING_DEFER ) # Entries were advanced by ``scan_interval`` and the fallback # worker was notified before this await; a failing dispatch # is treated like a successful one (no soon-retry) for the # same reason the owner path advances on failure — a stuck # fallback must not busy-loop the worker. The next normal # tick will pick the address up at its full cadence. # Dispatches are awaited sequentially on purpose: typical HA # setups have 0-2 fallbacks per tick, the BlueZ stop/start # path serializes at the daemon anyway, and a per-fallback # try/except keeps a stuck one from masking errors on the # others. ``asyncio.gather`` would parallelize but adds task # creation cost and ExceptionGroup handling for no win at # this scale. loop = self._scheduler._loop if TYPE_CHECKING: assert loop is not None workers = self._scheduler._workers fb_worker: _ScannerWorker | None for fb, fb_due in fallback_groups.values(): duration = self._scheduler._coalesce_duration(fb_due) fb_worker = workers.get(fb.source) if fb_worker is not None: # Sample loop.time() per iteration: each prior # ``async_request_active_window`` await can take # seconds (scanner stop/restart on Linux), so the # owner's tick-start ``now`` is stale for later # fallbacks and would put ``_window_end`` in the # past — leaving the fallback worker's tick # suppression off during the delegated window. dispatch_now = loop.time() fb_worker.note_window_dispatched(dispatch_now + duration, dispatch_now) try: await fb.async_request_active_window(duration) except Exception: _LOGGER.exception( "%s: error dispatching fallback active window of %.1fs to %s", self._scanner.name, duration, fb.name, ) class _ScanSchedule: """ Per-address scan schedule: due times, owner, and per-worker view. Owns ``_due_at`` (when each request is due), ``_owner_by_address`` (which scanner currently sees each address), and maintains an aliased subset of ``_due_at`` on each worker's ``_owned_due_at`` so workers iterate only the addresses they actually own. """ __slots__ = ("_due_at", "_owner_by_address", "_workers") def __init__(self, workers: dict[str, _ScannerWorker]) -> None: """Bind to the scheduler's ``_workers`` dict.""" self._workers = workers self._due_at: dict[str, dict[ActiveScanRequest, float]] = {} self._owner_by_address: dict[str, str] = {} def seed(self, address: str, request: ActiveScanRequest, due_time: float) -> bool: """Seed ``request`` at ``address``; return True if newly inserted.""" existing = self._due_at.setdefault(address, {}) if request in existing: return False existing[request] = due_time return True def drop(self, address: str, request: ActiveScanRequest) -> None: """Drop ``request`` at ``address``; ``unown`` if it was the last one.""" entries = self._due_at.get(address) if entries is None: return entries.pop(request, None) if not entries: self.unown(address) def assign(self, address: str, new_source: str) -> None: """Move ownership of ``address`` to ``new_source`` and wake its worker.""" new_worker = self._workers.get(new_source) old_source = self._owner_by_address.get(address) if old_source != new_source: if old_source is not None: old_worker = self._workers.get(old_source) if old_worker is not None: old_worker._detach_owned(address) self._owner_by_address[address] = new_source if new_worker is not None: new_worker._attach_owned(address, self._due_at[address]) if new_worker is not None: new_worker.wake() def unown(self, address: str) -> None: """Forget ``address`` entirely; drops due_at, owner, and worker view.""" del self._due_at[address] old_worker = self._workers.get(self._owner_by_address.pop(address)) if old_worker is not None: old_worker._detach_owned(address) def clear_source(self, source: str) -> None: """Drop owner mappings and ``_due_at`` entries owned by ``source``.""" worker = self._workers.get(source) if worker is not None: # AUTO source: iterate the worker's own view, O(owned-by-source). for address in list(worker._owned_due_at): del self._owner_by_address[address] del self._due_at[address] worker._clear_owned() return # Non-AUTO source (no worker): a PASSIVE / ACTIVE scanner can # still own an address via ``on_advertisement``, so scan # ``_owner_by_address`` to find what it owns. for address in list(self._owner_by_address): if self._owner_by_address[address] == source: del self._owner_by_address[address] del self._due_at[address] def attach_worker(self, source: str) -> None: """Attach pre-assigned entries to a newly-registered worker.""" worker = self._workers[source] for address, owner in self._owner_by_address.items(): if owner == source: worker._attach_owned(address, self._due_at[address]) def clear(self) -> None: """Reset all schedule state and every worker's owned view.""" for worker in self._workers.values(): worker._clear_owned() self._owner_by_address.clear() self._due_at.clear() class AutoScanScheduler: """Coordinates on-demand active windows across AUTO-mode scanners.""" __slots__ = ( "_loop", "_manager", "_on_demand_sweep_end", "_on_demand_sweep_future", "_requests_by_address", "_running", "_schedule", "_workers", ) def __init__(self, manager: BluetoothManager) -> None: """Initialize the scheduler bound to a manager.""" self._manager = manager self._requests_by_address: dict[str, set[ActiveScanRequest]] = {} self._workers: dict[str, _ScannerWorker] = {} self._schedule = _ScanSchedule(self._workers) self._loop: asyncio.AbstractEventLoop | None = None self._running = False self._on_demand_sweep_future: asyncio.Future[None] | None = None self._on_demand_sweep_end: float = 0.0 def start(self, loop: asyncio.AbstractEventLoop) -> None: """ Bind to the event loop and spawn one worker per AUTO scanner. Idempotent: no-op if already running. A genuine restart is ``stop()`` (which flips ``_running`` to False) then ``start(new_loop)``. Also replays any pre-start ``_requests_by_address`` into ``_due_at`` so embedders that register before ``async_setup`` still get the kick-start cadence; same history-gating as ``add_request``. """ if self._running: return self._loop = loop self._running = True for scanner in self._manager.async_current_scanners(): if ( scanner.requested_mode is BluetoothScanningMode.AUTO and scanner.source not in self._workers ): self._spawn_worker(scanner) now = loop.time() last_service_info = self._manager.async_last_service_info for address, requests in self._requests_by_address.items(): history = last_service_info(address, False) if history is None: continue self._seed_requests(address, requests, now) self._schedule.assign(address, history.source) def stop(self) -> None: """ Cancel all worker tasks (fire-and-forget). Sync to match ``BluetoothManager.async_stop``; ``worker.stop()`` cancels without awaiting. Nulls ``_loop`` too so post-stop ``add_request`` / ``on_advertisement`` fall back to the record-only path instead of seeding ``_due_at`` with timestamps from the cancelled loop. Clears ``_due_at`` so a later ``start(new_loop)`` re-seeds from ``_requests_by_address`` against the new loop's clock base; leaving stale due-times would let them fire instantly (or never) under a loop with a different ``time()`` origin. In-place restart (``stop()`` then ``start(new_loop)``) needs an ``await asyncio.sleep(0)`` between them so cancelled tasks finish before new workers spawn on the same sources; HA's flow never does this. Also resolves any in-flight on-demand sweep future since the leader is a caller task that ``worker.stop()`` cannot reach. """ self._running = False for worker in self._workers.values(): worker.stop() self._schedule.clear() self._workers.clear() # done() guard mirrors the leader's finally for symmetry; # a future left non-None after completion would otherwise # raise InvalidStateError here. future = self._on_demand_sweep_future if future is not None and not future.done(): future.set_result(None) self._on_demand_sweep_future = None self._on_demand_sweep_end = 0.0 self._loop = None def add_scanner(self, scanner: BaseHaScanner) -> None: """ Register an AUTO-mode scanner; spawn its worker if running. Skips when ``_running`` or ``_loop`` are unset (both cleared by ``stop()``), so a post-stop registration doesn't spawn a worker that would have to exit on its first iteration. """ if scanner.requested_mode is not BluetoothScanningMode.AUTO: return if self._loop is None or not self._running or scanner.source in self._workers: return self._spawn_worker(scanner) def remove_scanner(self, scanner: BaseHaScanner) -> None: """ Stop the worker for a scanner leaving the manager. Also prunes ``_due_at`` entries the scanner currently owns so a removed-and-not-rediscovered device doesn't keep a tracked entry pinned until the next history flip / age-out. """ source = scanner.source self._schedule.clear_source(source) worker = self._workers.pop(source, None) if worker is not None: worker.stop() def _spawn_worker(self, scanner: BaseHaScanner) -> None: assert self._loop is not None # noqa: S101 worker = _ScannerWorker(self, scanner, self._manager) # Stagger first sweeps so concurrently-registered scanners # don't all flip ACTIVE at once. Modulo into the initial-sweep # window so the Nth offset is bounded; past # AUTO_INITIAL_SWEEP_DELAY/SWEEP_DURATION scanners offsets # repeat, harmless since BLE radios don't interfere when # multiple are active. offset = ( len(self._workers) * _AUTO_REDISCOVERY_SWEEP_DURATION ) % _AUTO_INITIAL_SWEEP_DELAY worker.start(self._loop, offset) source = scanner.source self._workers[source] = worker # Attach entries pre-assigned before this scanner registered. self._schedule.attach_worker(source) def add_request(self, request: ActiveScanRequest) -> None: """ Register an active-scan request and start tracking. First window fires ``scan_interval`` after registration if history exists; otherwise ``on_advertisement`` bootstraps on first sight. ``ActiveScanRequest`` compares by identity so each public ``async_register_active_scan`` call adds an independent cadence; cancellation is per-registration. Pre-``start()`` calls just record the request (``start()`` replays them). """ self._requests_by_address.setdefault(request.address, set()).add(request) if self._loop is None: return history = self._manager.async_last_service_info(request.address, False) if history is None: # No history: skip the seed (the next tick would prune # it anyway); on_advertisement will bootstrap on first # sight. return if not self._schedule.seed( request.address, request, self._loop.time() + request.scan_interval ): return self._schedule.assign(request.address, history.source) def remove_request(self, request: ActiveScanRequest) -> None: """Drop the request from ``_requests_by_address`` and the schedule.""" if (bucket := self._requests_by_address.get(request.address)) is not None: bucket.discard(request) if not bucket: del self._requests_by_address[request.address] self._schedule.drop(request.address, request) def on_advertisement(self, service_info: BluetoothServiceInfoBleak) -> None: """ Hot path. Track requests for the ad's address; wake the owner. Wake is unconditional (when the address has requests) so it covers both bootstrap (entry created) and ownership flip (existing entry, this scanner is now the owner and must re-evaluate ``_next_event_at``). ``Event.set`` is cheap enough to fire per tracked-address advertisement. """ if not self._requests_by_address or self._loop is None: return address = service_info.address requests = self._requests_by_address.get(address) if requests is None: return self._seed_requests(address, requests, self._loop.time()) self._schedule.assign(address, service_info.source) def _seed_requests( self, address: str, requests: set[ActiveScanRequest], now: float, ) -> None: """ Insert any not-yet-tracked requests with next-due = now + interval. Shared by ``on_advertisement`` and the ``start()`` replay loop. Leaves existing entries' due times untouched. """ for request in requests: self._schedule.seed(address, request, now + request.scan_interval) def _resolve_fallback_for_address( self, address: str, exclude_source: str ) -> tuple[bool, BaseHaScanner | None]: """ Return ``(covered, best_auto_fallback)`` for a due address. ``covered``: a non-connecting ACTIVE scanner sees the address (already actively scanned; caller drops silently). ``best_auto_fallback``: highest-RSSI non-connecting AUTO scanner seeing the address, excluding the owner. PASSIVE is never a valid fallback. Early-returns on the first ACTIVE coverage since the caller short-circuits on ``covered``. """ best: BaseHaScanner | None = None best_rssi = 0 for device in self._manager.async_scanner_devices_by_address(address, False): scanner = device.scanner if scanner.source == exclude_source: continue if scanner._connections_in_progress() > 0: continue mode = scanner.requested_mode if mode is BluetoothScanningMode.ACTIVE: return True, None if mode is not BluetoothScanningMode.AUTO: continue # adv_rssi is held as object so a None value doesn't # trip the int conversion that ``rssi=int`` in # @cython.locals would do on direct assignment. adv_rssi = device.advertisement.rssi rssi = NO_RSSI_VALUE if adv_rssi is None else adv_rssi if best is None or rssi > best_rssi: best_rssi = rssi best = scanner return False, best def _coalesce_duration(self, entries: list[ActiveScanRequest]) -> float: """ Pick max requested duration, clamped to [MIN, MAX]. Hot path; trusts ``scan_duration`` to be a finite positive float (``async_register_active_scan`` enforces this at the boundary). """ return _clamp_window_duration( max( (e.scan_duration for e in entries), default=_AUTO_WINDOW_MIN_DURATION, ) ) async def _flip_scanners_for_sweep(self, duration: float) -> bool: # noqa: C901 """ Flip every non-busy AUTO scanner into a ``duration``-second window. Returns ``True`` if at least one scanner actually opened a window (per-scanner result was ``True``), ``False`` if no AUTO workers are registered, every one is mid-connect, or every dispatched scanner declined / raised so the caller can short-circuit any post-flip sleep on a window that never opened. ``return_exceptions=True`` plus the per-scanner log keeps one stuck adapter from aborting the bus-wide sweep while still surfacing its failure. Mid-connect scanners are skipped — unlike the periodic ``_tick`` path this does not route to a fallback; on-demand is best-effort. Re-flipping with a longer duration extends the radio's open window in place (``BaseHaScanner.async_request_active_window`` contract), so the same helper serves both leader and joiner-extension. Caller must guard ``self._loop is not None``. Pre-await bumps ``_window_end`` to suppress the worker's own tick during the window; ``_sweep_last_completed`` is bumped post-await only on ``True`` so a declined/raised flip leaves the 12 h rediscovery floor unsatisfied for that scanner. Per-target ``_window_end`` is reverted to its pre-bump value on a non-``True`` result (when our bump still holds) so a declined / raised scanner does not stay locked out of its own ticks for the on-demand duration with no actual radio window open. Best-effort caveat (concurrent revert): when a leader's flip and a joiner's extension flip both visit the same worker and both decline, the leader's exact-equality revert guard sees the joiner's bump and skips, while the joiner's revert restores to its observed ``previous_window_end`` (the leader's bumped value). The worker stays bumped to the leader's intended end despite no radio window opening; it self-heals on the next tick at that end. Symmetric to the ``_window_end`` caveat in ``note_window_dispatched``. """ if TYPE_CHECKING: assert self._loop is not None now = self._loop.time() window_end = now + duration targets: list[tuple[_ScannerWorker, BaseHaScanner, float]] = [] for worker in self._workers.values(): scanner = worker._scanner if scanner._connections_in_progress() > 0: continue previous_window_end = worker._window_end if previous_window_end < window_end: worker._window_end = window_end targets.append((worker, scanner, previous_window_end)) if not targets: return False results = await asyncio.gather( *( scanner.async_request_active_window(duration) for _, scanner, _ in targets ), return_exceptions=True, ) any_opened = False for (worker, scanner, previous_window_end), result in zip( targets, results, strict=True ): if result is True: any_opened = True if worker._sweep_last_completed < now: worker._sweep_last_completed = now continue # No window opened for this scanner; if our bump still # holds, revert so the worker can tick normally. A # concurrent extension that pushed past us, or a _tick # finally that cleared to 0, is left alone. if worker._window_end == window_end: worker._window_end = previous_window_end if isinstance(result, Exception): _LOGGER.warning( "%s: error running on-demand active window of %.1fs: %s", scanner.name, duration, result, ) elif isinstance(result, BaseException): # CancelledError etc. from a scanner that internally # cancelled; best-effort, log distinctly from a # genuine False-decline so logs do not mislead. _LOGGER.debug( "%s: cancelled during on-demand active window of %.1fs", scanner.name, duration, ) else: _LOGGER.debug( "%s: declined on-demand active window of %.1fs", scanner.name, duration, ) return any_opened async def async_request_active_scan(self, duration: float) -> None: """ Flip every AUTO scanner to ACTIVE for ``duration`` seconds. Public entry is ``BluetoothManager.async_request_active_scan`` (validates finite/positive); this method clamps to ``[MIN, MAX]``. Concurrent callers dedupe on ``_on_demand_sweep_future`` (synchronous check-and-set, atomic under cooperative scheduling — exactly one window per bus). A joiner whose ``desired_end`` exceeds the current end extends the in-flight window: re-flip the scanners and push ``_on_demand_sweep_end`` out; the leader's sleep loop re-reads it on each wake, so an extension just makes the leader sleep again. ``_ON_DEMAND_EXTENSION_SLOP`` on the extension threshold absorbs task-start jitter so same- duration concurrent callers do not trigger bogus extensions. Cancellation: leader's cancel propagates to its caller and joiners wake to ``None`` (best-effort — they get whatever radio activity happened). Joiners ``await asyncio.shield`` the future so a cancelled joiner cannot cancel the shared future and take down the siblings or the leader's ``set_result``. Fast-return: when the leader's flip neither opens a window itself (no AUTO workers, every one mid-connect, or every dispatched scanner declined / raised) nor sees a concurrent joiner that did (``_on_demand_sweep_end`` was not pushed past the leader's ``desired_end`` during the await), it skips the sleep loop and returns immediately rather than blocking the caller for a window that never opens. An extension whose re-flip opens nothing reverts its eager ``_on_demand_sweep_end`` push for the same reason. """ # Capture loop locally so a concurrent stop() (which nulls # self._loop) during the sleep loop or the flip-await cannot # turn a re-read into AttributeError. loop = self._loop if loop is None: return duration = _clamp_window_duration(duration) now = loop.time() desired_end = now + duration in_flight = self._on_demand_sweep_future if in_flight is not None: if desired_end - self._on_demand_sweep_end > _ON_DEMAND_EXTENSION_SLOP: previous_end = self._on_demand_sweep_end self._on_demand_sweep_end = desired_end # asyncio.shield the extension flip so a cancelled # joiner does not leave the shared end pushed out # past a partial re-flip; either all non-busy # scanners receive the longer duration or none do. flipped = await asyncio.shield( self._flip_scanners_for_sweep(desired_end - now) ) # No scanner opened or extended a window for us # (every worker mid-connect, or every dispatched # scanner declined / raised); revert the eager push # so the leader does not sleep past the in-flight # radio window for nothing. Guarded so a peer joiner # that pushed end further during our shielded await # is not clobbered. if not flipped and self._on_demand_sweep_end == desired_end: self._on_demand_sweep_end = previous_end await asyncio.shield(in_flight) return future = loop.create_future() self._on_demand_sweep_future = future self._on_demand_sweep_end = desired_end try: flipped = await self._flip_scanners_for_sweep(duration) if not flipped and self._on_demand_sweep_end <= desired_end: # No scanner opened a window bus-wide (no AUTO # workers, every one mid-connect, or every dispatched # scanner declined / raised) and no joiner that # interleaved during our await opened or extended a # window past our end; skip the sleep loop rather # than block callers for a window that never opens. # A joiner that succeeded would have pushed # `_on_demand_sweep_end` past `desired_end`; honor it # by falling through to the sleep loop so the leader # sleeps until that joiner's end (the joiner is # parked on the shared future and would otherwise be # cut short by the leader's finally). return while True: remaining = self._on_demand_sweep_end - loop.time() if remaining <= 0: break await asyncio.sleep(remaining) finally: # Identity check so a stop+start(new_loop) cycle does # not let this orphan leader clobber the fresh state. if self._on_demand_sweep_future is future: self._on_demand_sweep_future = None self._on_demand_sweep_end = 0.0 # stop() may have already resolved the future. if not future.done(): future.set_result(None) def async_diagnostics(self) -> dict[str, Any]: """ Return a snapshot of scheduler state for diagnostics. Per-worker timing fields and ``monotonic_time`` are raw ``loop.time()`` values so callers can compute deltas; before ``start()`` (or after ``stop()``) ``_loop`` is None and these are reported as 0.0. """ loop = self._loop now = loop.time() if loop is not None else 0.0 workers: dict[str, dict[str, Any]] = {} for source, worker in self._workers.items(): workers[source] = { "name": worker._scanner.name, "window_end": worker._window_end, "sweep_last_completed": worker._sweep_last_completed, "next_sweep_at": ( worker._sweep_last_completed + _AUTO_REDISCOVERY_INTERVAL ), "next_event_at": ( worker._next_event_at(now) if loop is not None else 0.0 ), "failed_window": worker._failed_window, "warned_no_fallback": worker._warned_no_fallback, } last_service_info = self._manager.async_last_service_info requests: dict[str, list[dict[str, Any]]] = {} for address, bucket in self._requests_by_address.items(): entries = self._schedule._due_at.get(address, {}) history = last_service_info(address, False) owner_source = history.source if history is not None else None requests[address] = [ { "scan_interval": request.scan_interval, "scan_duration": request.scan_duration, "next_due": entries.get(request), "owner_source": owner_source, } for request in bucket ] return { "running": self._running, "monotonic_time": now, "workers": workers, "requests": requests, } Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/base_scanner.pxd000066400000000000000000000076641521117704500262610ustar00rootroot00000000000000 import cython from .models cimport BluetoothServiceInfoBleak from .manager cimport BluetoothManager cdef object parse_advertisement_data_bytes cdef object NO_RSSI_VALUE cdef object BluetoothServiceInfoBleak cdef object AdvertisementData cdef object BLEDevice cdef bint TYPE_CHECKING cdef class BaseHaScanner: cdef public str adapter cdef public bint connectable cdef public str source cdef public object connector cdef public unsigned int _connecting cdef public str name cdef public bint scanning cdef public double _last_detection cdef public object _start_time cdef public object _cancel_watchdog cdef public object _loop cdef BluetoothManager _manager cdef public object details cdef public object current_mode cdef public object requested_mode cdef public dict _previous_service_info cdef public double _expire_seconds cdef public dict _details cdef public object _cancel_track cdef public dict _connect_failures cdef public dict _connect_in_progress cdef public unsigned int _connect_completed_total cdef public unsigned int _connect_failed_total cdef public double _last_connect_completed_time cpdef void _clear_connection_history(self) except * cpdef void _finished_connecting(self, str address, bint connected) except * cdef void _increase_count(self, dict target, str address) except * cdef void _add_connect_failure(self, str address) except * cpdef void _add_connecting(self, str address) except * cdef void _remove_connecting(self, str address) except * cdef void _clear_connect_failure(self, str address) except * @cython.locals( in_progress=Py_ssize_t, count=Py_ssize_t ) cpdef _connections_in_progress(self) cpdef _connection_failures(self, str address) @cython.locals( score=double, scanner_connections_in_progress=Py_ssize_t, previous_failures=Py_ssize_t ) cpdef _score_connection_paths(self, int rssi_diff, object scanner_device) cpdef tuple get_discovered_device_advertisement_data(self, str address) cpdef float time_since_last_detection(self) @cython.locals(info=BluetoothServiceInfoBleak) cdef dict _build_discovered_device_advertisement_datas(self) @cython.locals(info=BluetoothServiceInfoBleak) cdef dict _build_discovered_device_timestamps(self) @cython.locals(parsed=tuple, prev_info=BluetoothServiceInfoBleak, info=BluetoothServiceInfoBleak) cpdef void _async_on_raw_advertisement( self, str address, int rssi, bytes raw, dict details, double advertisement_monotonic_time ) @cython.locals( prev_name=str, prev_discovery=tuple, has_local_name=bint, has_manufacturer_data=bint, has_service_data=bint, has_service_uuids=bint, sub_value=bytes, super_value=bytes, info=BluetoothServiceInfoBleak, prev_info=BluetoothServiceInfoBleak ) cdef void _async_on_advertisement_internal( self, str address, int rssi, str local_name, list service_uuids, dict service_data, dict manufacturer_data, object tx_power, dict details, double advertisement_monotonic_time, bytes raw ) cpdef void _async_on_advertisement( self, str address, int rssi, str local_name, list service_uuids, dict service_data, dict manufacturer_data, object tx_power, dict details, double advertisement_monotonic_time ) @cython.locals(now=double, timestamp=double, info=BluetoothServiceInfoBleak) cpdef void _async_expire_devices(self) cpdef void _schedule_expire_devices(self) cdef class BaseHaRemoteScanner(BaseHaScanner): @cython.locals(info=BluetoothServiceInfoBleak) cpdef tuple get_discovered_device_advertisement_data(self, str address) Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/base_scanner.py000066400000000000000000000760641521117704500261160ustar00rootroot00000000000000"""Base classes for HA Bluetooth scanners for bluetooth.""" from __future__ import annotations import asyncio import logging import warnings from contextlib import contextmanager from typing import TYPE_CHECKING, Any, Final, final from bleak.backends.device import BLEDevice from bleak_retry_connector import NO_RSSI_VALUE, Allocations from bluetooth_adapters import adapter_human_name from bluetooth_data_tools import monotonic_time_coarse, parse_advertisement_data_bytes from .central_manager import get_manager from .const import ( CALLBACK_TYPE, CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, SCANNER_WATCHDOG_INTERVAL, SCANNER_WATCHDOG_TIMEOUT, ) from .models import ( BluetoothScanningMode, BluetoothServiceInfoBleak, HaBluetoothConnector, HaScannerDetails, HaScannerType, ) from .storage import DiscoveredDeviceAdvertisementData if TYPE_CHECKING: from collections.abc import Generator, Iterable from bleak.backends.scanner import AdvertisementData from .scanner_device import BluetoothScannerDevice SCANNER_WATCHDOG_INTERVAL_SECONDS: Final = SCANNER_WATCHDOG_INTERVAL.total_seconds() _LOGGER = logging.getLogger(__name__) _bytes = bytes _float = float _int = int _str = str class BaseHaScanner: """Base class for high availability BLE scanners.""" __slots__ = ( "_cancel_track", "_cancel_watchdog", "_connect_completed_total", "_connect_failed_total", "_connect_failures", "_connect_in_progress", "_connecting", "_details", "_expire_seconds", "_last_connect_completed_time", "_last_detection", "_loop", "_manager", "_previous_service_info", "_start_time", "adapter", "connectable", "connector", "current_mode", "details", "name", "requested_mode", "scanning", "source", ) def __init__( self, source: str, adapter: str, connector: HaBluetoothConnector | None = None, connectable: bool = False, requested_mode: BluetoothScanningMode | None = None, current_mode: BluetoothScanningMode | None = None, ) -> None: """Initialize the scanner.""" self.connectable = connectable self.source = source self.connector = connector self._connecting = 0 self.adapter = adapter self.name = adapter_human_name(adapter, source) if adapter != source else source self.scanning: bool = True self.requested_mode = requested_mode self.current_mode = current_mode self._last_detection = 0.0 self._start_time = 0.0 self._cancel_watchdog: asyncio.TimerHandle | None = None self._loop: asyncio.AbstractEventLoop | None = None self._manager = get_manager() # Determine scanner type based on class type scanner_type = HaScannerType.UNKNOWN if isinstance(self, BaseHaRemoteScanner): scanner_type = HaScannerType.REMOTE # Try to get adapter type from manager's cached adapters elif ( (adapters := self._manager.get_cached_bluetooth_adapters()) and (adapter_details := adapters.get(adapter)) and (adapter_type := adapter_details.get("adapter_type")) ): if adapter_type == "usb": scanner_type = HaScannerType.USB elif adapter_type == "uart": scanner_type = HaScannerType.UART self.details = HaScannerDetails( source=self.source, connectable=self.connectable, name=self.name, adapter=self.adapter, scanner_type=scanner_type, ) self._previous_service_info: dict[str, BluetoothServiceInfoBleak] = {} # Scanners only care about connectable devices. The manager # will handle taking care of availability for non-connectable devices self._expire_seconds = CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS self._details: dict[str, str | HaBluetoothConnector] = {"source": source} self._cancel_track: asyncio.TimerHandle | None = None self._connect_failures: dict[str, int] = {} self._connect_in_progress: dict[str, int] = {} self._connect_completed_total: int = 0 self._connect_failed_total: int = 0 self._last_connect_completed_time: float = 0.0 def _on_start_success(self) -> None: """ Called when the scanner successfully starts. Notifies the manager that this scanner has started. """ if self._manager: self._manager.on_scanner_start(self) def _clear_connection_history(self) -> None: """Clear the connection history for a scanner.""" self._connect_failures.clear() self._connect_in_progress.clear() self._connect_completed_total = 0 self._connect_failed_total = 0 self._last_connect_completed_time = 0.0 def _finished_connecting(self, address: str, connected: bool) -> None: """Finished connecting.""" self._remove_connecting(address) if connected: self._connect_completed_total += 1 self._last_connect_completed_time = monotonic_time_coarse() self._clear_connect_failure(address) else: self._connect_failed_total += 1 self._add_connect_failure(address) def _increase_count(self, target: dict[str, int], address: str) -> None: """Increase the reference count.""" if address in target: target[address] += 1 else: target[address] = 1 def _add_connect_failure(self, address: str) -> None: """Add a connect failure.""" self._increase_count(self._connect_failures, address) def _add_connecting(self, address: str) -> None: """Add a connecting.""" self._increase_count(self._connect_in_progress, address) # Clear timing collection data when scanner pauses for connection # to prevent collecting invalid advertising interval data self._manager._advertisement_tracker.async_scanner_paused(self.source) def _remove_connecting(self, address: str) -> None: """Remove a connecting.""" if address not in self._connect_in_progress: _LOGGER.warning( "Removing a non-existing connecting %s %s", self.name, address ) return self._connect_in_progress[address] -= 1 if not self._connect_in_progress[address]: del self._connect_in_progress[address] def _clear_connect_failure(self, address: str) -> None: """Clear a connect failure.""" self._connect_failures.pop(address, None) def get_allocations(self) -> Allocations | None: """ Get current connection slot allocations for this scanner. Returns: Allocations object with free/limit/allocated info, or None if not available. Note: Subclasses should override this method to provide their allocation info. For local adapters, this will be overridden in HaScanner to query BleakSlotManager. For remote scanners, they should override to return their own tracking. """ return None def _score_connection_paths( self, rssi_diff: _int, scanner_device: BluetoothScannerDevice ) -> float: """Score the connection paths considering slot availability.""" address = scanner_device.ble_device.address score = scanner_device.advertisement.rssi or NO_RSSI_VALUE scanner_connections_in_progress = len(self._connect_in_progress) previous_failures = self._connect_failures.get(address, 0) # Use a minimum rssi_diff of 1 to ensure penalties are meaningful # even when scanners have identical RSSI effective_rssi_diff = max(rssi_diff, 1) # Penalize scanners with connections in progress if scanner_connections_in_progress: # Very large penalty for multiple connections in progress # to avoid overloading the adapter score -= effective_rssi_diff * scanner_connections_in_progress * 1.01 # Penalize based on previous failures if previous_failures: score -= effective_rssi_diff * previous_failures * 0.51 # Consider connection slot availability allocation = self.get_allocations() if allocation and allocation.slots > 0: if allocation.free == 0: # No slots available - return NO_RSSI_VALUE to indicate unavailable return NO_RSSI_VALUE if allocation.free == 1: # Last slot available - small penalty to prefer adapters with more slots score -= effective_rssi_diff * 0.76 return score def _connections_in_progress(self) -> int: """Return if the connection is in progress.""" in_progress = 0 for count in self._connect_in_progress.values(): in_progress += count return in_progress def _connection_failures(self, address: str) -> int: """Return the number of failures.""" return self._connect_failures.get(address, 0) def connections_in_progress(self) -> int: """ Return the number of per-address connection attempts in progress. This sums the in-flight connect attempts tracked per address; it is a different counter from ``connecting_count`` (the scanning-pause counter). """ return self._connections_in_progress() def connection_failures(self, address: str) -> int: """Return the number of failed connection attempts for an address.""" return self._connection_failures(address) @property def connecting_count(self) -> int: """ Return the number of connections currently pausing scanning. This is the scanning-pause counter incremented for the duration of the ``connecting()`` context manager; while it is non-zero ``scanning`` is False. It is distinct from ``connections_in_progress()``, which counts per-address connect attempts. """ return self._connecting def time_since_last_detection(self) -> float: """Return the time since the last detection.""" return monotonic_time_coarse() - self._last_detection @property def adapter_idx(self) -> int | None: """Return the adapter index if this is an hci adapter, None otherwise.""" if self.adapter and self.adapter.startswith("hci"): return int(self.adapter.removeprefix("hci")) return None def async_setup(self) -> CALLBACK_TYPE: """Set up the scanner.""" self._loop = asyncio.get_running_loop() self._schedule_expire_devices() return self._unsetup def _async_stop_scanner_watchdog(self) -> None: """Stop the scanner watchdog.""" if self._cancel_watchdog: self._cancel_watchdog.cancel() self._cancel_watchdog = None def _async_setup_scanner_watchdog(self) -> None: """If something has restarted or updated, we need to restart the scanner.""" self._start_time = self._last_detection = monotonic_time_coarse() if not self._cancel_watchdog: self._schedule_watchdog() def _schedule_watchdog(self) -> None: """Schedule the watchdog.""" loop = self._loop if TYPE_CHECKING: assert loop is not None self._cancel_watchdog = loop.call_at( loop.time() + SCANNER_WATCHDOG_INTERVAL_SECONDS, self._async_call_scanner_watchdog, ) @final def _async_call_scanner_watchdog(self) -> None: """Call the scanner watchdog and schedule the next one.""" self._async_scanner_watchdog() self._schedule_watchdog() def _async_watchdog_triggered(self) -> bool: """Check if the watchdog has been triggered.""" time_since_last_detection = self.time_since_last_detection() _LOGGER.debug( "%s: Scanner watchdog time_since_last_detection: %s", self.name, time_since_last_detection, ) return time_since_last_detection > SCANNER_WATCHDOG_TIMEOUT def _async_scanner_watchdog(self) -> None: """ Check if the scanner is running. Override this method if you need to do something else when the watchdog is triggered. """ if self._async_watchdog_triggered(): _LOGGER.debug( ( "%s: Bluetooth scanner has gone quiet for %ss, check logs on the" " scanner device for more information" ), self.name, self.time_since_last_detection(), ) self.scanning = False return self.scanning = not self._connecting def _unsetup(self) -> None: """Unset up the scanner.""" self._cancel_expire_devices() @contextmanager def connecting(self) -> Generator[None, None, None]: """Context manager to track connecting state.""" self._connecting += 1 self.scanning = not self._connecting try: yield finally: self._connecting -= 1 self.scanning = not self._connecting @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" raise NotImplementedError @property def discovered_devices_and_advertisement_data( self, ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: """Return a list of discovered devices and their advertisement data.""" raise NotImplementedError @property def discovered_addresses(self) -> Iterable[str]: """Return an iterable of discovered devices.""" raise NotImplementedError def get_discovered_device_advertisement_data( self, address: str ) -> tuple[BLEDevice, AdvertisementData] | None: """Return the advertisement data for a discovered device.""" raise NotImplementedError async def async_diagnostics(self) -> dict[str, Any]: """Return diagnostic information about the scanner.""" device_adv_datas = self.discovered_devices_and_advertisement_data.values() return { "name": self.name, "connectable": self.connectable, "start_time": self._start_time, "source": self.source, "scanning": self.scanning, "requested_mode": self.requested_mode, "current_mode": self.current_mode, "type": self.__class__.__name__, "last_detection": self._last_detection, "monotonic_time": monotonic_time_coarse(), "connect_in_progress": dict(self._connect_in_progress), "connect_failures": dict(self._connect_failures), "connect_completed_total": self._connect_completed_total, "connect_failed_total": self._connect_failed_total, "last_connect_completed_time": self._last_connect_completed_time, "discovered_devices_and_advertisement_data": [ { "name": device.name, "address": device.address, "rssi": advertisement_data.rssi, "advertisement_data": advertisement_data, "details": device.details, } for device, advertisement_data in device_adv_datas ], } def restore_discovered_devices( self, history: DiscoveredDeviceAdvertisementData ) -> None: """Restore discovered devices from a previous run.""" discovered_device_timestamps = history.discovered_device_timestamps self._previous_service_info = { address: BluetoothServiceInfoBleak( device.name or address, address, adv.rssi, adv.manufacturer_data, adv.service_data, adv.service_uuids, self.source, device, adv, self.connectable, discovered_device_timestamps[address], adv.tx_power, history.discovered_device_raw.get(address), ) for address, ( device, adv, ) in history.discovered_device_advertisement_datas.items() } # Expire anything that is too old self._async_expire_devices() # Seed the cross-scanner name cache with each restored entry so that # names learned by an active scanner in a previous run are immediately # available to passive scanners on restart, before any active scanner # has had a chance to re-observe them. for address, info in self._previous_service_info.items(): self._manager.seed_name_cache(address, info.name) 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( "BaseHaScanner._discovered_device_timestamps is deprecated " "and will be removed in a future version of habluetooth, use " "BaseHaScanner.discovered_device_timestamps instead", FutureWarning, stacklevel=2, ) return self._build_discovered_device_timestamps() @property def discovered_device_timestamps(self) -> dict[str, float]: """Return a dict of discovered device timestamps.""" return self._build_discovered_device_timestamps() def _build_discovered_device_advertisement_datas( self, ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: """Return a list of discovered devices and advertisement data.""" return { address: (info.device, info._advertisement_internal()) for address, info in self._previous_service_info.items() } def _build_discovered_device_timestamps(self) -> dict[str, float]: """Return a dict of discovered device timestamps.""" return { address: info.time for address, info in self._previous_service_info.items() } def _build_discovered_device_raw(self) -> dict[str, bytes | None]: """Return a dict of discovered device raw advertisement data.""" return { address: info.raw for address, info in self._previous_service_info.items() } def _async_on_raw_advertisement( self, address: _str, rssi: _int, raw: _bytes, details: dict[str, Any], advertisement_monotonic_time: _float, ) -> None: if ( prev_info := self._previous_service_info.get(address) ) is not None and prev_info.raw == raw: # Raw advertisement data unchanged — skip parsing and merge # logic, reuse the previous parsed data directly. self.scanning = not self._connecting self._last_detection = advertisement_monotonic_time info = BluetoothServiceInfoBleak.__new__(BluetoothServiceInfoBleak) info.device = prev_info.device info.name = prev_info.name info.manufacturer_data = prev_info.manufacturer_data info.service_data = prev_info.service_data info.service_uuids = prev_info.service_uuids 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 = prev_info.tx_power info.raw = prev_info.raw self._previous_service_info[address] = info self._manager._scanner_adv_received(info) return 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( # noqa: C901 self, address: _str, rssi: _int, local_name: _str | None, service_uuids: list[str], service_data: dict[str, bytes], manufacturer_data: dict[int, bytes], tx_power: _int | None, details: dict[Any, Any], advertisement_monotonic_time: _float, raw: _bytes | None, ) -> None: """Call the registered callback.""" self.scanning = not self._connecting self._last_detection = advertisement_monotonic_time info = BluetoothServiceInfoBleak.__new__(BluetoothServiceInfoBleak) if (prev_info := self._previous_service_info.get(address)) is None: # We expect this is the rare case and since py3.11+ has # near zero cost try on success, and we can avoid .get() # which is slower than [] we use the try/except pattern. info.device = BLEDevice( address, local_name, {**self._details, **details}, ) info.manufacturer_data = manufacturer_data info.service_data = service_data info.service_uuids = service_uuids info.name = local_name or address else: # Merge the new data with the old data # to function the same as BlueZ which # merges the dicts on PropertiesChanged info.device = prev_info.device prev_name = prev_info.device.name # # Bleak updates the BLEDevice via create_or_update_device. # We need to do the same to ensure integrations that already # have the BLEDevice object get the updated details when they # change. # # https://github.com/hbldh/bleak/blob/222618b7747f0467dbb32bd3679f8cfaa19b1668/bleak/backends/scanner.py#L203 if prev_name is not None and ( prev_name is local_name or not local_name or len(prev_name) > len(local_name) ): info.name = prev_name else: info.device.name = local_name info.name = local_name or address has_service_uuids = bool(service_uuids) if ( has_service_uuids and service_uuids is not prev_info.service_uuids and service_uuids != prev_info.service_uuids ): info.service_uuids = list({*service_uuids, *prev_info.service_uuids}) elif not has_service_uuids: info.service_uuids = prev_info.service_uuids else: info.service_uuids = service_uuids has_service_data = bool(service_data) if has_service_data and service_data is not prev_info.service_data: for uuid, sub_value in service_data.items(): if ( super_value := prev_info.service_data.get(uuid) ) is None or super_value != sub_value: info.service_data = { **prev_info.service_data, **service_data, } break else: info.service_data = prev_info.service_data elif not has_service_data: info.service_data = prev_info.service_data else: info.service_data = service_data has_manufacturer_data = bool(manufacturer_data) if ( has_manufacturer_data and manufacturer_data is not prev_info.manufacturer_data ): for id_, sub_value in manufacturer_data.items(): if ( super_value := prev_info.manufacturer_data.get(id_) ) is None or super_value != sub_value: info.manufacturer_data = { **prev_info.manufacturer_data, **manufacturer_data, } break else: info.manufacturer_data = prev_info.manufacturer_data elif not has_manufacturer_data: info.manufacturer_data = prev_info.manufacturer_data else: info.manufacturer_data = manufacturer_data info.address = address info.rssi = rssi info.source = self.source info._advertisement = None info.connectable = self.connectable info.time = advertisement_monotonic_time info.tx_power = tx_power info.raw = raw self._previous_service_info[address] = info self._manager._scanner_adv_received(info) def _async_expire_devices(self) -> None: """Expire old devices.""" now = monotonic_time_coarse() expired = [ address for address, info in self._previous_service_info.items() if now - info.time > self._expire_seconds ] for address in expired: del self._previous_service_info[address] def _cancel_expire_devices(self) -> None: """Cancel the expiration of old devices.""" if self._cancel_track: self._cancel_track.cancel() self._cancel_track = None def _schedule_expire_devices(self) -> None: """Schedule the expiration of old devices.""" loop = self._loop if TYPE_CHECKING: assert loop is not None self._cancel_expire_devices() self._cancel_track = loop.call_at( loop.time() + 30, self._async_expire_devices_schedule_next ) def _async_expire_devices_schedule_next(self) -> None: """Expire old devices and schedule the next expiration.""" self._async_expire_devices() self._schedule_expire_devices() def set_requested_mode(self, mode: BluetoothScanningMode | None) -> None: """Set the requested scanning mode and notify the manager.""" if self.requested_mode != mode: self.requested_mode = mode self._manager.scanner_mode_changed(self) def set_current_mode(self, mode: BluetoothScanningMode | None) -> None: """Set the current scanning mode and notify the manager.""" if self.current_mode != mode: self.current_mode = mode self._manager.scanner_mode_changed(self) async def async_request_active_window(self, duration: float) -> bool: """ Run an active scan for ``duration`` seconds, then restore prior mode. Default no-op returning False. Subclasses that can flip the underlying adapter / proxy into active scanning on demand should override; ``True`` indicates the override actually flipped the radio, ``False`` that the request was ignored. The auto scheduler branches on the return value: per-device ``_due_at`` entries still advance by ``scan_interval`` regardless (to avoid busy-looping a stuck scanner), but a ``True`` is what advances ``_sweep_last_completed`` (satisfies the 12 h rediscovery floor) and counts toward the on-demand sweep's "at least one window opened" predicate that lets the leader's caller actually wait for the window. A ``False`` / raised result reverts the on-demand pre-bumped ``_window_end`` so the worker is not locked out of its own ticks for a window that never opened. Implementations should therefore return ``True`` only when the radio actually entered active mode for the requested duration. """ _LOGGER.debug( "%s: scanner does not support on-demand active windows", self.name ) return False class BaseHaRemoteScanner(BaseHaScanner): """Base class for a high availability remote BLE scanner.""" def _unsetup(self) -> None: """Unset up the scanner.""" super()._unsetup() self._async_stop_scanner_watchdog() def async_setup(self) -> CALLBACK_TYPE: """Set up the scanner.""" super().async_setup() self._async_setup_scanner_watchdog() return self._unsetup @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" infos = self._previous_service_info.values() return [device_advertisement_data.device for device_advertisement_data in infos] @property def discovered_devices_and_advertisement_data( self, ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: """Return a list of discovered devices and advertisement data.""" return self._build_discovered_device_advertisement_datas() @property def discovered_addresses(self) -> Iterable[str]: """Return an iterable of discovered devices.""" return self._previous_service_info def get_discovered_device_advertisement_data( self, address: str ) -> tuple[BLEDevice, AdvertisementData] | None: """Return the advertisement data for a discovered device.""" if (info := self._previous_service_info.get(address)) is not None: return info.device, info.advertisement return None async def async_diagnostics(self) -> dict[str, Any]: """Return diagnostic information about the scanner.""" now = monotonic_time_coarse() discovered_device_timestamps = self._build_discovered_device_timestamps() return await super().async_diagnostics() | { "discovered_device_timestamps": discovered_device_timestamps, "raw_advertisement_data": { address: info.raw for address, info in self._previous_service_info.items() }, "time_since_last_device_detection": { address: now - timestamp for address, timestamp in discovered_device_timestamps.items() }, } Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/central_manager.py000066400000000000000000000012351521117704500266010ustar00rootroot00000000000000"""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: msg = "BluetoothManager has not been set" raise RuntimeError(msg) return CentralBluetoothManager.manager def set_manager(manager: BluetoothManager) -> None: """Set the BluetoothManager.""" CentralBluetoothManager.manager = manager Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/channels/000077500000000000000000000000001521117704500246775ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/channels/__init__.py000066400000000000000000000000001521117704500267760ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/channels/bluez.pxd000066400000000000000000000030071521117704500265350ustar00rootroot00000000000000 import cython from ..scanner cimport HaScanner cdef bint TYPE_CHECKING cdef unsigned short DEVICE_FOUND cdef unsigned short ADV_MONITOR_DEVICE_FOUND cdef unsigned short MGMT_OP_GET_CONNECTIONS cdef unsigned short MGMT_OP_LOAD_CONN_PARAM cdef unsigned short MGMT_EV_CMD_COMPLETE cdef unsigned short MGMT_EV_CMD_STATUS cdef class BluetoothMGMTProtocol: cdef public object transport cdef object connection_made_future cdef bytes _buffer cdef unsigned int _buffer_len cdef unsigned int _pos cdef dict _scanners cdef object _on_connection_lost cdef object _is_shutting_down cdef dict _pending_commands cdef public object _sock @cython.locals(bytes_data=bytes) cdef void _add_to_buffer(self, object data) except * @cython.locals(end_of_frame_pos="unsigned int", cstr="const unsigned char *") cdef void _remove_from_buffer(self) except * @cython.locals( header="const unsigned char *", event_code="unsigned short", controller_idx="unsigned short", param_len="unsigned short", rssi="short", flags="unsigned int", data="bytes", parse_offset="unsigned short", scanner=HaScanner, opcode="unsigned short", status="unsigned char", param_offset="unsigned short", param_count="unsigned short" ) cpdef void data_received(self, object data) except * cdef void _handle_load_conn_param_response( self, unsigned char status, unsigned short controller_idx ) except * Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/channels/bluez.py000066400000000000000000000555431521117704500264060ustar00rootroot00000000000000from __future__ import annotations import asyncio import logging from asyncio import timeout as asyncio_timeout from contextlib import asynccontextmanager from struct import Struct from typing import TYPE_CHECKING, cast from btsocket import btmgmt_socket from btsocket.btmgmt_socket import BluetoothSocketError from ..const import ( FAST_CONN_LATENCY, FAST_CONN_TIMEOUT, FAST_MAX_CONN_INTERVAL, FAST_MIN_CONN_INTERVAL, MEDIUM_CONN_LATENCY, MEDIUM_CONN_TIMEOUT, MEDIUM_MAX_CONN_INTERVAL, MEDIUM_MIN_CONN_INTERVAL, ConnectParams, ) if TYPE_CHECKING: import socket from collections.abc import AsyncIterator, Callable from ..scanner import HaScanner _LOGGER = logging.getLogger(__name__) _int = int _bytes = bytes # Everything is little endian HEADER_SIZE = 6 # Header is event_code (2 bytes), controller_idx (2 bytes), param_len (2 bytes) DEVICE_FOUND = 0x0012 ADV_MONITOR_DEVICE_FOUND = 0x002F # Management commands MGMT_OP_GET_CONNECTIONS = 0x0015 MGMT_OP_LOAD_CONN_PARAM = 0x0035 # Management events MGMT_EV_CMD_COMPLETE = 0x0001 MGMT_EV_CMD_STATUS = 0x0002 # Pre-compiled struct formats for performance COMMAND_HEADER = Struct(" None: """Set the future result if not done.""" if future is not None and not future.done(): future.set_result(None) class BluetoothMGMTProtocol: """Bluetooth MGMT protocol.""" def __init__( self, connection_made_future: asyncio.Future[None], scanners: dict[int, HaScanner], on_connection_lost: Callable[[], None], is_shutting_down: Callable[[], bool], sock: socket.socket, ) -> None: """Initialize the protocol.""" self.transport: asyncio.Transport | None = None self.connection_made_future = connection_made_future self._buffer: bytes | None = None self._buffer_len = 0 self._pos = 0 self._scanners = scanners self._on_connection_lost = on_connection_lost self._is_shutting_down = is_shutting_down self._pending_commands: dict[int, asyncio.Future[tuple[int, bytes]]] = {} self._sock = sock def connection_made(self, transport: asyncio.BaseTransport) -> None: """Handle connection made.""" _set_future_if_not_done(self.connection_made_future) self.transport = cast("asyncio.Transport", transport) def _write_to_socket(self, data: bytes) -> None: """ Write data directly to the socket, bypassing asyncio transport. This works around a kernel bug where sendto() on Bluetooth management sockets returns 0 instead of the number of bytes sent on some platforms (e.g., Odroid M1 with kernel 6.12.43). When asyncio sees 0, it thinks the send failed and retries forever. Since mgmt sockets are SOCK_RAW, sends are atomic - either the entire packet is sent or nothing is sent. """ try: n = self._sock.send(data) # On buggy kernels, n might be 0 even though the data was sent # We treat 0 as success for mgmt sockets if n == 0 and len(data) > 0: # Kernel bug: returned 0 but data was actually sent _LOGGER.debug( "Bluetooth mgmt socket returned 0 for %d bytes (kernel bug fix)", len(data), ) except Exception: _LOGGER.exception("Failed to write to mgmt socket") raise @asynccontextmanager async def command_response( self, opcode: int ) -> AsyncIterator[asyncio.Future[tuple[int, bytes]]]: """ Context manager for handling command responses. Usage: async with protocol.command_response(opcode) as future: transport.write(command) status, data = await future """ future: asyncio.Future[tuple[int, bytes]] = ( asyncio.get_running_loop().create_future() ) self._pending_commands[opcode] = future try: yield future finally: # Clean up if the future wasn't resolved self._pending_commands.pop(opcode, None) def _add_to_buffer(self, data: bytes | bytearray | memoryview) -> None: """Add data to the buffer.""" # Protractor sends a bytearray, so we need to convert it to bytes # https://github.com/esphome/issues/issues/5117 # type(data) should not be isinstance(data, bytes) because we want to # to explicitly check for bytes and not for subclasses of bytes bytes_data = bytes(data) if type(data) is not bytes else data if self._buffer_len == 0: # This is the best case scenario, we don't have to copy the data # and can just use the buffer directly. This is the most common # case as well. self._buffer = bytes_data else: if TYPE_CHECKING: assert self._buffer is not None, "Buffer should be set" # This is the worst case scenario, we have to copy the bytes_data # and can't just use the buffer directly. This is also very # uncommon since we usually read the entire frame at once. self._buffer += bytes_data self._buffer_len += len(bytes_data) def _remove_from_buffer(self) -> None: """Remove data from the buffer.""" end_of_frame_pos = self._pos self._buffer_len -= end_of_frame_pos if self._buffer_len == 0: # This is the best case scenario, we can just set the buffer to None # and don't have to copy the data. This is the most common case as well. self._buffer = None return if TYPE_CHECKING: assert self._buffer is not None, "Buffer should be set" # This is the worst case scenario, we have to copy the data # and can't just use the buffer directly. This should only happen # when we read multiple frames at once because the event loop # is blocked and we cannot pull the data out of the buffer fast enough. cstr = self._buffer # Important: we must use the explicit length for the slice # since Cython will stop at any '\0' character if we don't self._buffer = cstr[end_of_frame_pos : self._buffer_len + end_of_frame_pos] def data_received(self, data: _bytes) -> None: # noqa: C901 """Handle data received.""" self._add_to_buffer(data) while self._buffer_len >= 6: if TYPE_CHECKING: assert self._buffer is not None, "Buffer should be set" self._pos = 6 header = self._buffer event_code = header[0] | (header[1] << 8) controller_idx = header[2] | (header[3] << 8) param_len = header[4] | (header[5] << 8) if self._buffer_len < self._pos + param_len: # We don't have the entire frame yet, so we need to wait # for more data to arrive. return self._pos += param_len if event_code == DEVICE_FOUND: parse_offset = 6 elif event_code == ADV_MONITOR_DEVICE_FOUND: parse_offset = 8 elif event_code in {MGMT_EV_CMD_COMPLETE, MGMT_EV_CMD_STATUS}: # Handle management command responses if param_len >= 3: opcode = header[6] | (header[7] << 8) status = header[8] if opcode == MGMT_OP_LOAD_CONN_PARAM: self._handle_load_conn_param_response(status, controller_idx) elif ( opcode == MGMT_OP_GET_CONNECTIONS and opcode in self._pending_commands ): # Handle GET_CONNECTIONS response for capability check future = self._pending_commands.pop(opcode) if not future.done(): # Return status and any response data response_data = ( header[9 : self._pos] if param_len > 3 else b"" ) future.set_result((status, response_data)) self._remove_from_buffer() continue else: self._remove_from_buffer() continue address = header[parse_offset : parse_offset + 6] address_type = header[parse_offset + 6] rssi = header[parse_offset + 7] if rssi > 128: rssi -= 256 flags = ( header[parse_offset + 8] | (header[parse_offset + 9] << 8) | (header[parse_offset + 10] << 16) | (header[parse_offset + 11] << 24) ) # Skip AD_Data_Length (2 bytes) at parse_offset+12 and +13 data = header[parse_offset + 14 : self._pos] self._remove_from_buffer() if (scanner := self._scanners.get(controller_idx)) is not None: # We have a scanner for this controller, so we can # pass the data to it. scanner._async_on_raw_bluez_advertisement( address, address_type, rssi, flags, data, ) def _handle_load_conn_param_response( self, status: _int, controller_idx: _int ) -> None: """Handle MGMT_OP_LOAD_CONN_PARAM response.""" if status != 0: _LOGGER.warning( "hci%u: Failed to load conn params: status=%d", controller_idx, status, ) else: _LOGGER.debug( "hci%u: Connection parameters loaded successfully", controller_idx, ) def connection_lost(self, exc: Exception | None) -> None: """Handle connection lost.""" # Only suppress warnings during shutdown, not info messages if exc: if not self._is_shutting_down(): _LOGGER.warning("Bluetooth management socket connection lost: %s", exc) else: _LOGGER.info("Bluetooth management socket connection closed") self.transport = None self._on_connection_lost() class MGMTBluetoothCtl: """Class to control interfaces using the BlueZ management API.""" def __init__(self, timeout: float, scanners: dict[int, HaScanner]) -> None: """Initialize the control class.""" # Internal state self.timeout = timeout self.protocol: BluetoothMGMTProtocol | None = None self.sock: socket.socket | None = None self.scanners = scanners self._reconnect_task: asyncio.Task[None] | None = None self._on_connection_lost_future: asyncio.Future[None] | None = None self._shutting_down = False def close(self) -> None: """Close the management interface.""" self._shutting_down = True if self._reconnect_task: self._reconnect_task.cancel() if self.protocol and self.protocol.transport: self.protocol.transport.close() self.protocol = None btmgmt_socket.close(self.sock) def _on_connection_lost(self) -> None: """Handle connection lost.""" if self._shutting_down: _LOGGER.debug("Bluetooth management socket connection lost during shutdown") else: _LOGGER.debug("Bluetooth management socket connection lost, reconnecting") _set_future_if_not_done(self._on_connection_lost_future) self._on_connection_lost_future = None async def reconnect_task(self) -> None: """Monitor the connection and reconnect if needed.""" while not self._shutting_down: if self._on_connection_lost_future: await self._on_connection_lost_future if self._shutting_down: break # type: ignore[unreachable] _LOGGER.debug("Reconnecting to Bluetooth management socket") try: await self._establish_connection() except CONNECTION_ERRORS: _LOGGER.debug("Bluetooth management socket connection timed out") # If we get a timeout, we should try to reconnect # after a short delay await asyncio.sleep(1) async def _establish_connection(self) -> None: """Establish a connection to the Bluetooth management socket.""" _LOGGER.debug("Establishing Bluetooth management socket connection") self.sock = btmgmt_socket.open() loop = asyncio.get_running_loop() connection_made_future: asyncio.Future[None] = loop.create_future() try: async with asyncio_timeout(self.timeout): # _create_connection_transport accessed # directly to avoid SOCK_STREAM check # see https://bugs.python.org/issue38285 _, protocol = await loop._create_connection_transport( # type: ignore[attr-defined] self.sock, lambda: BluetoothMGMTProtocol( connection_made_future, self.scanners, self._on_connection_lost, lambda: self._shutting_down, self.sock, ), None, None, ) await connection_made_future except TimeoutError: btmgmt_socket.close(self.sock) raise _LOGGER.debug("Bluetooth management socket connection established") self.protocol = cast("BluetoothMGMTProtocol", protocol) self._on_connection_lost_future = loop.create_future() def _has_mgmt_capabilities_from_status(self, status: int) -> bool: """ Check if a MGMT command status indicates we have capabilities. Returns True if we have capabilities, False otherwise. Status codes: - 0x00 = Success (we have permissions) - 0x01 = Unknown Command (might happen if kernel is too old) - 0x0D = Invalid Parameters - 0x10 = Not Powered (for some operations) - 0x11 = Invalid Index (adapter doesn't exist but we have permissions) - 0x14 = Permission Denied (missing NET_ADMIN/NET_RAW) """ if status == 0x14: # Permission denied _LOGGER.debug( "MGMT capability check failed with permission denied - " "missing NET_ADMIN/NET_RAW" ) return False if status in (0x00, 0x11): # Success or Invalid Index _LOGGER.debug("MGMT capability check passed (status: %#x)", status) return True # Unknown status - log it and assume no permissions to be safe _LOGGER.debug( "MGMT capability check returned unexpected status %#x - " "assuming missing permissions", status, ) return False async def _check_capabilities(self) -> bool: """ Check if we have the necessary capabilities to use MGMT. Returns True if we have capabilities, False otherwise. """ if not self.protocol or not self.protocol.transport: return False # Try GET_CONNECTIONS for adapter 0 - this is a read-only command # that requires NET_ADMIN privileges but doesn't change any state header = COMMAND_HEADER_PACK( MGMT_OP_GET_CONNECTIONS, # opcode 0, # controller index 0 (hci0) 0, # no parameters ) try: return await self._do_mgmt_op_get_connections(header) except (TimeoutError, OSError) as ex: _LOGGER.debug( "MGMT capability check failed: %s - likely missing NET_ADMIN/NET_RAW", ex, ) return False async def _do_mgmt_op_get_connections(self, header: bytes) -> bool: """Send a MGMT_OP_GET_CONNECTIONS command and check capabilities.""" if TYPE_CHECKING: assert self.protocol is not None assert self.protocol.transport is not None async with self.protocol.command_response( MGMT_OP_GET_CONNECTIONS ) as response_future: self.protocol._write_to_socket(header) # Wait for response with timeout async with asyncio_timeout(5.0): status, _ = await response_future return self._has_mgmt_capabilities_from_status(status) async def setup(self) -> None: """Set up management interface.""" await self._establish_connection() # Check if we actually have the capabilities to use MGMT if not await self._check_capabilities(): # Mark as shutting down to prevent reconnection attempts self._shutting_down = True # Close the connection and raise an error to trigger fallback if self.protocol and self.protocol.transport: self.protocol.transport.close() btmgmt_socket.close(self.sock) msg = "Missing NET_ADMIN/NET_RAW capabilities for Bluetooth management" raise PermissionError(msg) self._reconnect_task = asyncio.create_task(self.reconnect_task()) def load_conn_params( self, adapter_idx: int, address: str, address_type: int, params: ConnectParams, ) -> bool: """ Load connection parameters for a specific device. Args: adapter_idx: Adapter index (e.g., 0 for hci0) address: Device MAC address (e.g., "AA:BB:CC:DD:EE:FF") address_type: BDADDR_LE_PUBLIC (1) or BDADDR_LE_RANDOM (2) params: Connection parameters to load (ConnectParams.FAST or ConnectParams.MEDIUM) Returns: True if command was sent successfully """ if not self.protocol or not self.protocol.transport: _LOGGER.error("Cannot load conn params: no connection") return False # Parse MAC address addr_bytes = bytes.fromhex(address.replace(":", "")) if len(addr_bytes) != 6: _LOGGER.error("Invalid MAC address: %s", address) return False # Build command structure (C definitions from BlueZ mgmt-api.txt): # struct mgmt_cp_load_conn_param { # uint16_t param_count; # struct mgmt_conn_param params[0]; # }; # struct mgmt_conn_param { # struct mgmt_addr_info addr; # uint16_t min_interval; # uint16_t max_interval; # uint16_t latency; # uint16_t timeout; # }; # struct mgmt_addr_info { # bdaddr_t bdaddr; # uint8_t type; # }; # Get the appropriate connection parameters based on the enum if params is ConnectParams.FAST: min_interval = FAST_MIN_CONN_INTERVAL max_interval = FAST_MAX_CONN_INTERVAL latency = FAST_CONN_LATENCY timeout = FAST_CONN_TIMEOUT else: # params is ConnectParams.MEDIUM: min_interval = MEDIUM_MIN_CONN_INTERVAL max_interval = MEDIUM_MAX_CONN_INTERVAL latency = MEDIUM_CONN_LATENCY timeout = MEDIUM_CONN_TIMEOUT # Pack the command cmd_data = CONN_PARAM_PACK( 1, # param_count = 1 addr_bytes[::-1], # bdaddr (reversed for little endian) address_type, # address type min_interval, # min_interval max_interval, # max_interval latency, # latency timeout, # timeout ) # Send the command try: header = COMMAND_HEADER_PACK( MGMT_OP_LOAD_CONN_PARAM, # opcode adapter_idx, # controller index len(cmd_data), # parameter length ) self.protocol._write_to_socket(header + cmd_data) _LOGGER.debug( "Loaded conn params for %s: interval=%d-%d, latency=%d, timeout=%d", address, min_interval, max_interval, latency, timeout, ) except Exception: _LOGGER.exception("Failed to load conn params") return False else: return True def load_conn_params_explicit( self, adapter_idx: int, address: str, address_type: int, min_interval: int, max_interval: int, latency: int, timeout: int, ) -> bool: """ Load explicit connection parameters for a specific device. Args: adapter_idx: Adapter index (e.g., 0 for hci0) address: Device MAC address (e.g., "AA:BB:CC:DD:EE:FF") address_type: BDADDR_LE_PUBLIC (1) or BDADDR_LE_RANDOM (2) min_interval: Minimum connection interval (units of 1.25ms) max_interval: Maximum connection interval (units of 1.25ms) latency: Connection latency (number of events) timeout: Supervision timeout (units of 10ms) Returns: True if command was sent successfully """ if not self.protocol or not self.protocol.transport: _LOGGER.error("Cannot load conn params: no connection") return False # Parse MAC address addr_bytes = bytes.fromhex(address.replace(":", "")) if len(addr_bytes) != 6: _LOGGER.error("Invalid MAC address: %s", address) return False # Pack the command cmd_data = CONN_PARAM_PACK( 1, # param_count = 1 addr_bytes[::-1], # bdaddr (reversed for little endian) address_type, # address type min_interval, max_interval, latency, timeout, ) # Send the command try: header = COMMAND_HEADER_PACK( MGMT_OP_LOAD_CONN_PARAM, # opcode adapter_idx, # controller index len(cmd_data), # parameter length ) self.protocol._write_to_socket(header + cmd_data) _LOGGER.debug( "Loaded explicit conn params for %s:" " interval=%d-%d, latency=%d, timeout=%d", address, min_interval, max_interval, latency, timeout, ) except Exception: _LOGGER.exception("Failed to load conn params") return False else: return True Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/const.py000066400000000000000000000117401521117704500246070ustar00rootroot00000000000000"""Constants.""" from __future__ import annotations from collections.abc import Callable from datetime import timedelta from enum import Enum from typing import Final CALLBACK_TYPE = Callable[[], None] SOURCE_LOCAL: Final = "local" START_TIMEOUT = 15 STOP_TIMEOUT = 5 # The maximum time between advertisements for a device to be considered # stale when the advertisement tracker cannot determine the interval. # # We have to set this quite high as we don't know # when devices fall out of the ESPHome device (and other non-local scanners)'s # stack like we do with BlueZ so its safer to assume its available # since if it does go out of range and it is in range # of another device the timeout is much shorter and it will # switch over to using that adapter anyways. # FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 60 * 15 # The maximum time between advertisements for a device to be considered # stale when the advertisement tracker can determine the interval for # connectable devices. # # BlueZ uses 180 seconds by default but we give it a bit more time # to account for the esp32's bluetooth stack being a bit slower # than BlueZ's. CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 195 # We must recover before we hit the 180s mark # where the device is removed from the stack # or the devices will go unavailable. Since # we only check every 30s, we need this number # to be # 180s Time when device is removed from stack # - 30s check interval # - 30s scanner restart time * 2 # SCANNER_WATCHDOG_TIMEOUT: Final = 90 # How often to check if the scanner has reached # the SCANNER_WATCHDOG_TIMEOUT without seeing anything SCANNER_WATCHDOG_INTERVAL: Final = timedelta(seconds=30) UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5 # AUTO scanning mode: each scanner gets its first sweep # AUTO_INITIAL_SWEEP_DELAY after joining, then every # AUTO_REDISCOVERY_INTERVAL, serialized across scanners. AUTO_INITIAL_SWEEP_DELAY: Final = 60 * 4 AUTO_REDISCOVERY_INTERVAL: Final = 60 * 60 * 12 AUTO_REDISCOVERY_SWEEP_DURATION: Final = 15.0 # Per-callback scan_duration is clamped into this range. The floor # matches the validation in async_register_active_scan; the ceiling is # the longest single ACTIVE flip we'll ever do for one device tick. AUTO_WINDOW_MIN_DURATION: Final = 5.0 AUTO_WINDOW_MAX_DURATION: Final = 30.0 # Per-device entries due within AUTO_COALESCE_LOOKAHEAD of now are # pulled into the current window so staggered registrations sync up # instead of triggering back-to-back active flips. Must exceed # AUTO_WINDOW_MAX_DURATION so a window can never outlive its # lookahead; the slop absorbs loop.time drift between bucket # collection and window open under a blocked event loop. AUTO_COALESCE_LOOKAHEAD_SLOP: Final = 5.0 AUTO_COALESCE_LOOKAHEAD: Final = AUTO_WINDOW_MAX_DURATION + AUTO_COALESCE_LOOKAHEAD_SLOP # Minimum values accepted by async_register_active_scan. Anything # shorter would just churn the radio without giving the device time to # respond on its scan response. MIN_ACTIVE_SCAN_INTERVAL: Final = 60.0 MIN_ACTIVE_SCAN_DURATION: Final = 5.0 # Defaults used by async_register_active_scan when the caller does # not specify a cadence. One 10s active window every 5 minutes per # device covers the typical temperature/humidity/battery sensor case # without burning the proxy's radio or the sensor's battery; an # integration that genuinely needs faster updates can pass a smaller # scan_interval explicitly. DEFAULT_ACTIVE_SCAN_INTERVAL: Final = 300.0 DEFAULT_ACTIVE_SCAN_DURATION: Final = 10.0 # Default duration for an on-demand sweep triggered by # BluetoothManager.async_request_active_scan (HA config-flow discovery). # 10s gives every device on the bus a chance to advertise during the # window without holding the caller too long. DEFAULT_ON_DEMAND_SWEEP_DURATION: Final = 10.0 FAILED_ADAPTER_MAC = "00:00:00:00:00:00" ADV_RSSI_SWITCH_THRESHOLD: Final = 16 # The switch threshold for the rssi value # to switch to a different adapter for advertisements # Note that this does not affect the connection # selection that uses RSSI_SWITCH_THRESHOLD from # bleak_retry_connector # Connection parameter constants (units of 1.25ms for intervals) # Fast connection parameters for initial connection and service discovery FAST_MIN_CONN_INTERVAL: Final = 0x06 # 6 * 1.25ms = 7.5ms (BLE minimum) FAST_MAX_CONN_INTERVAL: Final = 0x06 # 6 * 1.25ms = 7.5ms FAST_CONN_LATENCY: Final = 0 # No latency for fast response FAST_CONN_TIMEOUT: Final = 1000 # 1000 * 10ms = 10s # Medium connection parameters for standard operation # Balanced for stability with WiFi-based BLE proxies MEDIUM_MIN_CONN_INTERVAL: Final = 0x07 # 7 * 1.25ms = 8.75ms MEDIUM_MAX_CONN_INTERVAL: Final = 0x09 # 9 * 1.25ms = 11.25ms MEDIUM_CONN_LATENCY: Final = 0 # No latency MEDIUM_CONN_TIMEOUT: Final = 800 # 800 * 10ms = 8s # Bluetooth address types BDADDR_BREDR: Final = 0x00 BDADDR_LE_PUBLIC: Final = 0x01 BDADDR_LE_RANDOM: Final = 0x02 class ConnectParams(Enum): """Connection parameter presets.""" FAST = "fast" MEDIUM = "medium" Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/manager.pxd000066400000000000000000000103611521117704500252340ustar00rootroot00000000000000import cython from .advertisement_tracker cimport AdvertisementTracker from .auto_scheduler cimport ActiveScanRequest, AutoScanScheduler 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 dict _name_cache cdef public set _non_connectable_scanners cdef public set _connectable_scanners cdef public dict _adapters cdef public dict _sources cdef public object _bluetooth_adapters cdef public object slot_manager cdef public bint _debug cdef public bint shutdown cdef public object _loop cdef public object _adapter_refresh_future cdef public object _recovery_lock cdef public set _disappeared_callbacks cdef public dict _allocations_callbacks cdef public object _cancel_allocation_callbacks cdef public dict _adapter_sources cdef public dict _allocations cdef public dict _scanner_registration_callbacks cdef public dict _scanner_mode_change_callbacks cdef public object _subclass_discover_info cdef public bint has_advertising_side_channel cdef public dict _side_channel_scanners cdef public object _mgmt_ctl # _auto_scheduler stays untyped to avoid a typed cdef field that # triggers Cython's type-import path during manager init; the hot # path casts to AutoScanScheduler via cython.locals on # _scanner_adv_received so the call into on_advertisement is still # a direct vtable dispatch. cdef public object _auto_scheduler @cython.locals(stale_seconds=double) cdef bint _prefer_previous_adv_from_different_source( self, BluetoothServiceInfoBleak old, BluetoothServiceInfoBleak new ) @cython.locals(scanner=BaseHaScanner) cdef bint _should_keep_previous_adv( self, BluetoothServiceInfoBleak old_info, BluetoothServiceInfoBleak new_info ) @cython.locals( cached=str, cached_cf=str, name_cf=str, cached_len=Py_ssize_t, name_len=Py_ssize_t, ) cdef void _update_name_cache(self, str address, str name) cdef void _handle_name_cache_miss( self, BluetoothServiceInfoBleak service_info, str cached_name, ) cpdef void scanner_adv_received(self, BluetoothServiceInfoBleak service_info) @cython.locals( old_service_info=BluetoothServiceInfoBleak, old_connectable_service_info=BluetoothServiceInfoBleak, source=str, connectable=bint, apple_cstr="const unsigned char *", bleak_callback=BleakCallback, cached_name=str, auto_scheduler=AutoScanScheduler, ) cdef void _scanner_adv_received(self, BluetoothServiceInfoBleak service_info) cpdef _async_describe_source(self, BluetoothServiceInfoBleak service_info) cpdef void _unregister_source_callback( self, dict callbacks_dict, object source, object callback, ) except * cdef void _dispatch_source_callbacks( self, dict callbacks_dict, object source, object payload, str label, ) except * Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/manager.py000066400000000000000000001701431521117704500250760ustar00rootroot00000000000000"""The bluetooth integration.""" from __future__ import annotations import asyncio import itertools import logging import math import platform from dataclasses import asdict from functools import partial from typing import TYPE_CHECKING, Any, Final 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 .auto_scheduler import ActiveScanRequest, AutoScanScheduler from .channels.bluez import CONNECTION_ERRORS, MGMTBluetoothCtl from .const import ( ADV_RSSI_SWITCH_THRESHOLD, CALLBACK_TYPE, DEFAULT_ACTIVE_SCAN_DURATION, DEFAULT_ACTIVE_SCAN_INTERVAL, DEFAULT_ON_DEMAND_SWEEP_DURATION, FAILED_ADAPTER_MAC, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, MIN_ACTIVE_SCAN_DURATION, MIN_ACTIVE_SCAN_INTERVAL, UNAVAILABLE_TRACK_SECONDS, ) from .models import ( BluetoothReachabilityIntent, BluetoothServiceInfoBleak, HaBluetoothSlotAllocations, HaScannerModeChange, HaScannerRegistration, HaScannerRegistrationEvent, ) from .scanner_device import BluetoothScannerDevice from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher from .util import async_reset_adapter, coalesce_concurrent_future if TYPE_CHECKING: from collections.abc import Callable, Iterable from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback from .base_scanner import BaseHaScanner from .scanner import HaScanner SYSTEM = platform.system() IS_LINUX = SYSTEM == "Linux" FILTER_UUIDS: Final = "UUIDs" APPLE_MFR_ID: Final = 76 APPLE_IBEACON_START_BYTE: Final = 0x02 # iBeacon (tilt_ble) APPLE_HOMEKIT_START_BYTE: Final = 0x06 # homekit_controller APPLE_DEVICE_ID_START_BYTE: Final = 0x10 # bluetooth_le_tracker APPLE_HOMEKIT_NOTIFY_START_BYTE: Final = 0x11 # homekit_controller APPLE_FINDMY_START_BYTE: Final = 0x12 # FindMy network advertisements _str = str _int = int _LOGGER = logging.getLogger(__name__) def _dispatch_bleak_callback( bleak_callback: BleakCallback, device: BLEDevice, advertisement_data: AdvertisementData, ) -> None: """Dispatch the callback.""" if ( uuids := bleak_callback.filters.get(FILTER_UUIDS) ) is not None and not uuids.intersection(advertisement_data.service_uuids): return try: bleak_callback.callback(device, advertisement_data) except Exception: # pylint: disable=broad-except _LOGGER.exception("Error in callback: %s", bleak_callback.callback) class BleakCallback: """Bleak callback.""" __slots__ = ("callback", "filters") def __init__( self, callback: AdvertisementDataCallback, filters: dict[str, set[str]] ) -> None: """Init bleak callback.""" self.callback = callback self.filters = filters class BluetoothManager: """Manage Bluetooth.""" __slots__ = ( "_adapter_refresh_future", "_adapter_sources", "_adapters", "_advertisement_tracker", "_all_history", "_allocations", "_allocations_callbacks", "_auto_scheduler", "_bleak_callbacks", "_bluetooth_adapters", "_cancel_allocation_callbacks", "_cancel_unavailable_tracking", "_connectable_history", "_connectable_scanners", "_connectable_unavailable_callbacks", "_connection_history", "_debug", "_disappeared_callbacks", "_fallback_intervals", "_intervals", "_loop", "_mgmt_ctl", "_name_cache", "_non_connectable_scanners", "_recovery_lock", "_scanner_mode_change_callbacks", "_scanner_registration_callbacks", "_side_channel_scanners", "_sources", "_subclass_discover_info", "_unavailable_callbacks", "has_advertising_side_channel", "shutdown", "slot_manager", ) def __init__( self, bluetooth_adapters: BluetoothAdapters | None = None, slot_manager: BleakSlotManager | None = None, ) -> None: """Init bluetooth manager.""" self._cancel_unavailable_tracking: asyncio.TimerHandle | None = None self._advertisement_tracker = AdvertisementTracker() self._fallback_intervals = self._advertisement_tracker.fallback_intervals self._intervals = self._advertisement_tracker.intervals self._unavailable_callbacks: dict[ str, set[Callable[[BluetoothServiceInfoBleak], None]] ] = {} self._connectable_unavailable_callbacks: dict[ str, set[Callable[[BluetoothServiceInfoBleak], None]] ] = {} self._bleak_callbacks: set[BleakCallback] = set() self._all_history: dict[str, BluetoothServiceInfoBleak] = {} self._connectable_history: dict[str, BluetoothServiceInfoBleak] = {} # Cross-scanner name cache: address -> best name seen across all # scanners. Passive scanners typically miss the device name because # it lives in SCAN_RSP (active-only); the cache lets a name learned # by an active scanner flow to passive scanners' service_info on # dispatch. Updates use the case-folded prefix-extension rule: a # longer name only replaces a shorter cached one when the cached # one is a case-folded prefix; otherwise the new name is treated # as a rename and replaces unconditionally. self._name_cache: dict[str, str] = {} self._non_connectable_scanners: set[BaseHaScanner] = set() self._connectable_scanners: set[BaseHaScanner] = set() self._adapters: dict[str, AdapterDetails] = {} self._adapter_sources: dict[str, str] = {} self._allocations: dict[str, HaBluetoothSlotAllocations] = {} self._sources: dict[str, BaseHaScanner] = {} self._bluetooth_adapters = bluetooth_adapters or get_adapters() self.slot_manager = slot_manager or BleakSlotManager() self._cancel_allocation_callbacks = ( self.slot_manager.register_allocation_callback( self._async_slot_manager_changed ) ) self._debug = _LOGGER.isEnabledFor(logging.DEBUG) self.shutdown = False self.has_advertising_side_channel = False self._side_channel_scanners: dict[int, HaScanner] = {} self._loop: asyncio.AbstractEventLoop | None = None self._adapter_refresh_future: asyncio.Future[None] | None = None self._recovery_lock: asyncio.Lock = asyncio.Lock() self._disappeared_callbacks: set[Callable[[str], None]] = set() self._allocations_callbacks: dict[ str | None, set[Callable[[HaBluetoothSlotAllocations], None]] ] = {} self._scanner_registration_callbacks: dict[ str | None, set[Callable[[HaScannerRegistration], None]] ] = {} self._scanner_mode_change_callbacks: dict[ str | None, set[Callable[[HaScannerModeChange], None]] ] = {} self._subclass_discover_info = self._discover_service_info self._mgmt_ctl: MGMTBluetoothCtl | None = None self._auto_scheduler = AutoScanScheduler(self) if ( self._discover_service_info.__func__ # type: ignore[attr-defined] is BluetoothManager._discover_service_info ): _LOGGER.warning( "%s: does not implement _discover_service_info, " "subclasses must implement this method to consume " "discovery data", type(self).__name__, ) @property def supports_passive_scan(self) -> bool: """Return if passive scan is supported.""" return any(adapter[ADAPTER_PASSIVE_SCAN] for adapter in self._adapters.values()) def is_operating_degraded(self) -> bool: """ Return if the manager is operating in degraded mode. On Linux, we're in degraded mode if mgmt control is not available. This typically means we don't have NET_ADMIN/NET_RAW capabilities. """ return IS_LINUX and self._mgmt_ctl is None def on_scanner_start(self, scanner: BaseHaScanner) -> None: """ Called when a scanner starts. Subclasses can override this to perform custom actions when a scanner starts. """ def async_scanner_count(self, connectable: bool = True) -> int: """Return the number of scanners.""" if connectable: return len(self._connectable_scanners) return len(self._connectable_scanners) + len(self._non_connectable_scanners) async def async_diagnostics(self) -> dict[str, Any]: """Diagnostics for the manager.""" scanner_diagnostics = await asyncio.gather( *[ scanner.async_diagnostics() for scanner in itertools.chain( self._non_connectable_scanners, self._connectable_scanners ) ] ) return { "adapters": self._adapters, "slot_manager": self.slot_manager.diagnostics(), "allocations": { source: asdict(allocations) for source, allocations in self._allocations.items() }, "scanners": scanner_diagnostics, "connectable_history": [ service_info.as_dict() for service_info in self._connectable_history.values() ], "all_history": [ service_info.as_dict() for service_info in self._all_history.values() ], "advertisement_tracker": self._advertisement_tracker.async_diagnostics(), "auto_scheduler": self._auto_scheduler.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) @coalesce_concurrent_future("_adapter_refresh_future") async def _async_refresh_adapters(self) -> None: """Refresh the adapters.""" await self._bluetooth_adapters.refresh() self._adapters = self._bluetooth_adapters.adapters def get_cached_bluetooth_adapters(self) -> dict[str, AdapterDetails] | None: """Get cached bluetooth adapters synchronously.""" return self._adapters async def async_get_bluetooth_adapters( self, cached: bool = True ) -> dict[str, AdapterDetails]: """Get bluetooth adapters.""" if not self._adapters or not cached: if not cached: await self._async_refresh_adapters() self._adapters = self._bluetooth_adapters.adapters return self._adapters async def async_get_adapter_from_address(self, address: str) -> str | None: """Get adapter from address.""" if adapter := self._find_adapter_by_address(address): return adapter await self._async_refresh_adapters() return self._find_adapter_by_address(address) async def async_get_adapter_from_address_or_recover( self, address: str ) -> str | None: """Get adapter from address or recover.""" if adapter := self._find_adapter_by_address(address): return adapter await self._async_recover_failed_adapters() return self._find_adapter_by_address(address) async def _async_recover_failed_adapters(self) -> None: """Recover failed adapters.""" if self._recovery_lock.locked(): # Already recovering, no need to # start another recovery return async with self._recovery_lock: adapters = await self.async_get_bluetooth_adapters() for adapter in [ adapter for adapter, details in adapters.items() if details[ADAPTER_ADDRESS] == FAILED_ADAPTER_MAC ]: await async_reset_adapter(adapter, FAILED_ADAPTER_MAC, False) await self._async_refresh_adapters() async def async_setup(self) -> None: """Set up the bluetooth manager.""" # Deferred to avoid the circular import that a top-level # ``from .central_manager import CentralBluetoothManager`` # would create (central_manager itself imports BluetoothManager # under TYPE_CHECKING but only this method writes through it). from .central_manager import CentralBluetoothManager # noqa: PLC0415 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() self._auto_scheduler.start(self._loop) if not IS_LINUX: return self._mgmt_ctl = MGMTBluetoothCtl(10.0, self._side_channel_scanners) try: await self._mgmt_ctl.setup() except PermissionError: _LOGGER.exception( "Missing required permissions for Bluetooth management. " "Automatic adapter recovery is unavailable. " "Add NET_ADMIN and NET_RAW capabilities to the container to enable it" ) self._mgmt_ctl = None except CONNECTION_ERRORS as ex: _LOGGER.debug("Cannot start Bluetooth Management API: %s", ex) self._mgmt_ctl = None else: self.has_advertising_side_channel = True def async_stop(self) -> None: """Stop the Bluetooth integration at shutdown.""" _LOGGER.debug("Stopping bluetooth manager") self.shutdown = True if self._cancel_unavailable_tracking: self._cancel_unavailable_tracking.cancel() self._cancel_unavailable_tracking = None self._auto_scheduler.stop() uninstall_multiple_bleak_catcher() self._cancel_allocation_callbacks() if self._mgmt_ctl: self._mgmt_ctl.close() self._mgmt_ctl = None def async_scanner_devices_by_address( self, address: str, connectable: bool ) -> list[BluetoothScannerDevice]: """Get BluetoothScannerDevice by address.""" if not connectable: scanners: Iterable[BaseHaScanner] = itertools.chain( self._connectable_scanners, self._non_connectable_scanners ) else: scanners = self._connectable_scanners return [ BluetoothScannerDevice(scanner, *device_adv) for scanner in scanners if (device_adv := scanner.get_discovered_device_advertisement_data(address)) ] def async_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: # noqa: C901 """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 # Materialize each scanner's discovered_addresses exactly once per # cycle. For local HaScanner this property rebuilds bleak's # discovered-devices dict on every access, so the prior two-pass # iteration paid that cost twice for the connectable scanners. connectable_addrs: set[str] = set() for scanner in self._connectable_scanners: connectable_addrs.update(scanner.discovered_addresses) all_addrs = connectable_addrs.copy() for scanner in self._non_connectable_scanners: all_addrs.update(scanner.discovered_addresses) 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( connectable_addrs if connectable else all_addrs ) 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) self._name_cache.pop(address, None) 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 _should_keep_previous_adv( self, old_info: BluetoothServiceInfoBleak, new_info: BluetoothServiceInfoBleak, ) -> bool: """ Return True when ``old_info`` should win over ``new_info``. Only relevant when ``old_info`` came from a different still-scanning source. The ``is not / !=`` ordering is a PyObject_RichCompare short-circuit that dominates this hot path; keep it intact. """ return ( new_info.source is not old_info.source and new_info.source != old_info.source and (scanner := self._sources.get(old_info.source)) is not None and scanner.scanning and self._prefer_previous_adv_from_different_source(old_info, new_info) ) def _prefer_previous_adv_from_different_source( self, old: BluetoothServiceInfoBleak, new: BluetoothServiceInfoBleak, ) -> bool: """Prefer previous advertisement from a different source if it is better.""" if stale_seconds := self._intervals.get( new.address, self._fallback_intervals.get(new.address, 0) ): stale_seconds += TRACKER_BUFFERING_WOBBLE_SECONDS else: stale_seconds = FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS if new.time - old.time > stale_seconds: # If the old advertisement is stale, any new advertisement is preferred if self._debug: _LOGGER.debug( "%s (%s): Switching from %s to %s (time elapsed:%s > stale" " seconds:%s)", new.name, new.address, self._async_describe_source(old), self._async_describe_source(new), new.time - old.time, stale_seconds, ) return False if (new.rssi or NO_RSSI_VALUE) - ADV_RSSI_SWITCH_THRESHOLD > ( old.rssi or NO_RSSI_VALUE ): # If new advertisement is ADV_RSSI_SWITCH_THRESHOLD more, # the new one is preferred. if self._debug: _LOGGER.debug( "%s (%s): Switching from %s to %s (new rssi:%s - threshold:%s >" " old rssi:%s)", new.name, new.address, self._async_describe_source(old), self._async_describe_source(new), new.rssi, ADV_RSSI_SWITCH_THRESHOLD, old.rssi, ) return False return True def get_bluez_mgmt_ctl(self) -> MGMTBluetoothCtl | None: """ Get the BlueZ management controller if available. Returns: The MGMTBluetoothCtl instance or None if not available """ return self._mgmt_ctl def _handle_name_cache_miss( self, service_info: BluetoothServiceInfoBleak, cached_name: str | None, ) -> None: """ Handle the cold path when cached_name is not service_info.name. Called from _scanner_adv_received only when the cached name and the incoming name are different str objects (steady-state identity match is filtered out at the call site). Walks through three cases: 1. The incoming ad has no real name (empty or the MAC fallback set by base_scanner): patch service_info from the cache if we have one; this is the path that lets passive scanners inherit a name learned by an active scanner. 2. No cached name yet: store the incoming name directly if it is real; no patch needed since the cache now matches. 3. Cached and incoming are both real but differ: apply the prefix rule via _update_name_cache and patch service_info with whatever the cache settled on. """ # When we patch service_info.name and service_info.device.name, # we also clear service_info._advertisement so the lazy rebuild # in BluetoothServiceInfoBleak._advertisement_internal picks up # the canonical name and propagates it to bleak callbacks via # advertisement.local_name. Remote scanners arrive with # _advertisement = None (see base_scanner.py:657), but # HaScanner.on_advertisement (scanner.py:331) pre-sets it to # bleak's AdvertisementData, so without this invalidation a # local passive scanner whose dispatched view we patch would # still hand bleak callbacks an AdvertisementData with the # original (missing) local_name. if ( not service_info.name or service_info.name is service_info.address or service_info.name == service_info.address ): if cached_name is not None: service_info.name = cached_name service_info.device.name = cached_name service_info._advertisement = None return if cached_name is None: self._name_cache[service_info.address] = service_info.name return if cached_name == service_info.name: return self._update_name_cache(service_info.address, service_info.name) cached_name = self._name_cache[service_info.address] if cached_name is not service_info.name and cached_name != service_info.name: service_info.name = cached_name service_info.device.name = cached_name service_info._advertisement = None def seed_name_cache(self, address: str, name: str) -> None: """ Apply the prefix rule to the cross-scanner name cache. Python-visible entry point intended for cold paths such as BaseHaScanner.restore_discovered_devices (called once per scanner at startup). The hot per-advertisement path does not use this method; it inlines the steady-state checks and calls the internal cdef _update_name_cache directly. """ self._update_name_cache(address, name) def _update_name_cache(self, address: str, name: str) -> None: """ Update the cross-scanner name cache for an address. Applies the case-folded prefix-extension rule: - identical name -> no-op (fastest path; identity check first) - empty name or name == address -> no-op (never pollute the cache with the address fallback used by base_scanner) - cached is None -> store new - new is a case-folded extension of cached -> store new (e.g. "Onv" -> "Onvis XXX") - cached is a case-folded extension of new -> keep cached (e.g. "Onvis XXX" -> "Onv" is a truncation) - neither is a case-folded prefix of the other -> rename, store new (e.g. "Onv" -> "Donkey") Performance note: after the steady-state identity / equality short circuits, length-based dispatch ensures we do at most ONE str.startswith per call (instead of up to two), since a prefix relationship is only possible when the shorter string could be a prefix of the longer. Compares casefolded lengths because casefold can change length for some characters (e.g. German "ß" -> "ss"). """ cached = self._name_cache.get(address) if cached is name: return if not name or name == address: return if cached is None: self._name_cache[address] = name return if cached == name: return cached_cf = cached.casefold() name_cf = name.casefold() cached_len = len(cached_cf) name_len = len(name_cf) if name_len > cached_len: # New is longer -> only "extension" or "rename" are possible. # Either way the new name wins (extension upgrades, rename replaces). self._name_cache[address] = name return if name_len < cached_len: # New is shorter -> "truncation" (keep cached) or "rename" (replace). if cached_cf.startswith(name_cf): return self._name_cache[address] = name return # Equal casefolded length, raw not equal -> case-only diff or rename. if cached_cf == name_cf: return self._name_cache[address] = name def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None: """ Handle a new advertisement from any scanner. Callbacks from all the scanners arrive here. This is the cpdef entry point for external callers. Internal callers should use _scanner_adv_received directly to avoid cpdef virtual dispatch overhead. """ self._scanner_adv_received(service_info) def _scanner_adv_received( # noqa: C901 self, service_info: BluetoothServiceInfoBleak ) -> None: """ Handle a new advertisement from any scanner (internal cdef path). 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 ( len(service_info.service_data) == 0 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 # Cross-scanner name cache. Only the steady-state identity check # is inlined here because this code runs on every advertisement # after the Apple pre-filter; the rest is handled in a cdef # helper to keep this method readable. The hot path is a single # dict.get plus a pointer compare; the function call to the # helper only fires when the cached name and the incoming name # are different str objects, which excludes the dominant case of # the same scanner re-broadcasting the same name. cached_name = self._name_cache.get(service_info.address) if cached_name is not service_info.name: self._handle_name_cache_miss(service_info, cached_name) 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 self._should_keep_previous_adv( 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 it's the same as the preferred source, we're done; we know # we prefer the old advertisement from the check above. old_connectable_service_info is old_service_info # Otherwise the old connectable came from a different source; # re-run the predicate against the connectable history entry. or self._should_keep_previous_adv( 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 # Hand the advertisement to the auto-scan scheduler right after # _all_history is updated. Ownership-flip detection (a different # scanner taking over a device's source) needs to fire even when # the advertisement payload is identical to the previous one; # the data-comparison short-circuit below would otherwise hide # that flip from the scheduler. Local-typed assignment so # cython.locals casts to AutoScanScheduler and the call is a # direct vtable dispatch even though _auto_scheduler is stored # untyped on BluetoothManager. auto_scheduler = self._auto_scheduler auto_scheduler.on_advertisement(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 # A non-connectable scanner may currently be the closest path, but if a # still-registered connectable scanner also has a path to the device we # surface this advertisement as connectable so connectable callbacks and # discovery fire (the BleakClient routes any connection attempt to the # connectable path). connectable_history is only pruned by the periodic # unavailable check, so validate the stored entry's source is still # registered before trusting it as a live connectable path. This lookup is # deferred to here (after the identical-advertisement short-circuit above) # so the dominant non-connectable rebroadcast hot path never pays it. if ( not service_info.connectable and ( connectable_path := self._connectable_history.get(service_info.address) ) is not None and connectable_path.source in self._sources ): service_info = service_info._as_connectable() if service_info.connectable 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 async_clear_advertisement_history(self, address: str) -> None: """ Clear cached advertisement history for a device. Causes the next advertisement from this address to be treated as new data, bypassing both the advertisement-merging logic in scanners and the change-detection guard. Intended for devices that encode state in mutually-exclusive service UUIDs. """ self._all_history.pop(address, None) self._connectable_history.pop(address, None) self._name_cache.pop(address, None) for scanner in self._sources.values(): scanner._previous_service_info.pop(address, None) 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_address_reachability_diagnostics( self, address: str, intent: BluetoothReachabilityIntent ) -> str: """ Return a human-readable explanation of an address's reachability. Intended for embedding in error and log messages when a device cannot be found or used. The ``intent`` selects which facts are relevant: a caller that only consumes advertisements (``PASSIVE_ADVERTISEMENT`` / ``ACTIVE_ADVERTISEMENT``) does not care about connectable paths or connection slots, while a caller that wants to connect (``CONNECTION``) does. This is read-only and side-effect free, and is only meant for the cold error path, not the hot advertisement path. The returned string is for embedding in human-readable error and log messages only; its wording and format are not stable and must not be parsed. The address is not included, callers already have it in context. """ now = monotonic_time_coarse() parts: list[str] = [] # All scanners (connectable and non-connectable) that currently see the # address. Materialized once; reused for the per-scanner detail below. devices = self.async_scanner_devices_by_address(address, False) if intent is BluetoothReachabilityIntent.CONNECTION: self._append_connection_diagnostics(address, devices, parts) else: self._append_advertisement_diagnostics(address, devices, parts) parts.append(self._scanner_availability_summary()) for device in devices: scanner = device.scanner detail = ( f"{scanner.name} (connectable={scanner.connectable}, " f"rssi={device.advertisement.rssi}" ) if intent is BluetoothReachabilityIntent.CONNECTION: detail += ( f", failures={scanner.connection_failures(address)}, " f"in_progress={scanner.connections_in_progress()}" ) if (allocations := scanner.get_allocations()) is not None: detail += f", slots={allocations.free}/{allocations.slots}" parts.append(detail + ")") if (info := self._all_history.get(address)) is not None: if (via_scanner := self._sources.get(info.source)) is not None: via = via_scanner.name else: via = info.source parts.append(f"last advertisement {now - info.time:.0f}s ago via {via}") return "; ".join(parts) def _scanner_availability_summary(self) -> str: """ Summarize how many scanners are registered, scanning and connectable. A scanner pauses scanning while it has a connection in progress, so a device can disappear from every scanner if they are all busy connecting; this is called out explicitly because no advertisements can be received while no scanner is scanning. """ scanners = self.async_current_scanners() total = len(scanners) scanning = 0 connecting = 0 connectable = 0 # A scanner pauses scanning while it has a connection in progress, so # in normal operation scanning and connecting_count are mutually # exclusive. Count them independently anyway so the "all paused # connecting" advice below stays correct even if that invariant drifts. for scanner in scanners: if scanner.connectable: connectable += 1 if scanner.scanning: scanning += 1 if scanner.connecting_count: connecting += 1 summary = ( f"{total} scanner(s) registered, {scanning} scanning, " f"{connectable} connectable" ) if connecting: summary += f", {connecting} paused while connecting" if total and scanning == 0: summary += ( "; no scanner is currently scanning so no advertisements can be " "received" ) if connecting == total: summary += ( " (all are paused retrying connections; the available adapters " "are overloaded, add more Bluetooth adapters or proxies)" ) return summary def _append_connection_diagnostics( self, address: str, devices: list[BluetoothScannerDevice], parts: list[str], ) -> None: """Append connectable-path reachability facts for a connect intent.""" if address in self._connectable_history: parts.append("in connectable history") elif address in self._all_history: parts.append("only in non-connectable history (no connectable path)") else: parts.append("unknown (never seen by any scanner)") connectable_devices = [d for d in devices if d.scanner.connectable] non_connectable_devices = [d for d in devices if not d.scanner.connectable] if not connectable_devices and non_connectable_devices: parts.append( f"seen by {len(non_connectable_devices)} scanner(s) but none with" " a connectable path" ) return # Only consider scanners that actually report slot allocations; a # scanner returning None (e.g. a local adapter that does not track # slots) tells us nothing, so it must not suppress or trigger the # message. We only claim the reporting scanners are full, not that # every connectable path is exhausted. reported = [ allocations for d in connectable_devices if (allocations := d.scanner.get_allocations()) is not None and allocations.slots > 0 ] if reported and all(a.free == 0 for a in reported): parts.append( "connectable scanner(s) that report slot allocations are all full" ) def _append_advertisement_diagnostics( self, address: str, devices: list[BluetoothScannerDevice], parts: list[str], ) -> None: """Append advertisement-only reachability facts for an advertisement intent.""" # Advertisement callers only need adverts, so connectable paths and # connection slots are irrelevant; report only whether the device is # being seen and by how many scanners. ``_all_history`` outlives any # single scanner's discovered cache, so an address can be in history # while no scanner currently has it cached; do not claim it is still # advertising in that case. if devices: parts.append(f"advertising, seen by {len(devices)} scanner(s)") elif address in self._all_history: parts.append("previously seen but no scanner currently has it cached") else: parts.append("unknown (never seen by any scanner)") def _async_unregister_scanner_internal( self, scanners: set[BaseHaScanner], scanner: BaseHaScanner, connection_slots: int | None, ) -> None: """Unregister a scanner.""" if scanner not in scanners: _LOGGER.debug("Scanner %s already unregistered; skipping", scanner.name) return _LOGGER.debug("Unregistering scanner %s", scanner.name) self._advertisement_tracker.async_remove_source(scanner.source) scanners.discard(scanner) scanner._clear_connection_history() self._sources.pop(scanner.source, None) self._adapter_sources.pop(scanner.adapter, None) self._allocations.pop(scanner.source, None) if connection_slots: self.slot_manager.remove_adapter(scanner.adapter) if (idx := scanner.adapter_idx) is not None: self._side_channel_scanners.pop(idx, None) self._auto_scheduler.remove_scanner(scanner) self._async_on_scanner_registration(scanner, HaScannerRegistrationEvent.REMOVED) def async_register_scanner( self, scanner: BaseHaScanner, connection_slots: int | None = None, ) -> CALLBACK_TYPE: """Register a new scanner.""" _LOGGER.debug("Registering scanner %s", scanner.name) if scanner.connectable: scanners = self._connectable_scanners else: scanners = self._non_connectable_scanners self._allocations[scanner.source] = HaBluetoothSlotAllocations( source=scanner.source, slots=0, free=0, allocated=[] ) scanners.add(scanner) scanner._clear_connection_history() self._sources[scanner.source] = scanner self._adapter_sources[scanner.adapter] = scanner.source if (idx := scanner.adapter_idx) is not None: self._side_channel_scanners[idx] = scanner # type: ignore[assignment] if connection_slots: self.slot_manager.register_adapter(scanner.adapter, connection_slots) self.async_on_allocation_changed( self.slot_manager.get_allocations(scanner.adapter) ) self._auto_scheduler.add_scanner(scanner) 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_register_active_scan( self, address: str, scan_interval: float | None = None, scan_duration: float | None = None, ) -> CALLBACK_TYPE: """ Declare an on-demand active-scan need for a specific address. Colon-form MAC addresses are normalized to upper-case to match BlueZ / ESPHome / Shelly source addresses; UUIDs (no colons, used by macOS CoreBluetooth) are passed through as-is since CoreBluetooth preserves case on its source addresses. ``scan_interval`` / ``scan_duration`` default to DEFAULT_ACTIVE_SCAN_INTERVAL (300s, 5 min) and DEFAULT_ACTIVE_SCAN_DURATION (10s); pass smaller values to get a tighter cadence. The effective window is clamped to [AUTO_WINDOW_MIN_DURATION, AUTO_WINDOW_MAX_DURATION] (5s..30s) and coalesced with other due requests for the scanner; very large ``scan_duration`` values are capped. ``scan_interval`` is measured between window starts (not between successive windows). ACTIVE / PASSIVE scanners ignore the request. Returns a cancel callable. """ if not address: msg = "address must be a non-empty string" raise ValueError(msg) if scan_interval is None: scan_interval = DEFAULT_ACTIVE_SCAN_INTERVAL if scan_duration is None: scan_duration = DEFAULT_ACTIVE_SCAN_DURATION # Reject non-finite values explicitly: NaN compared to anything # returns False, so a NaN would slip past the lower-bound # checks below and end up in _due_at and call_later as a NaN # due-time / duration, busy-looping the worker. if not math.isfinite(scan_interval) or scan_interval < MIN_ACTIVE_SCAN_INTERVAL: msg = ( f"scan_interval must be a finite number >= " f"{MIN_ACTIVE_SCAN_INTERVAL:.0f}s" ) raise ValueError(msg) if not math.isfinite(scan_duration) or scan_duration < MIN_ACTIVE_SCAN_DURATION: msg = ( f"scan_duration must be a finite number >= " f"{MIN_ACTIVE_SCAN_DURATION:.0f}s" ) raise ValueError(msg) # MAC addresses (colon-form) get upper-cased to match BlueZ / # ESPHome conventions; UUIDs (macOS CoreBluetooth) pass # through as-is. normalized = address.upper() if ":" in address else address request = ActiveScanRequest(normalized, scan_interval, scan_duration) self._auto_scheduler.add_request(request) return partial(self._auto_scheduler.remove_request, request) async def async_request_active_scan(self, duration: float | None = None) -> None: """ Run an on-demand active sweep across every AUTO scanner. Intended for HA config-flow discovery: probes the bus actively without waiting for the 12 h rediscovery cadence, awaits ``duration`` so the caller can then read newly-discovered advertisements. Default 10s; clamped to ``[AUTO_WINDOW_MIN_DURATION, AUTO_WINDOW_MAX_DURATION]`` by the scheduler. Concurrent callers dedupe to one bus-wide window (a longer request extends the in-flight one); see ``AutoScanScheduler.async_request_active_scan``. """ if duration is None: duration = DEFAULT_ON_DEMAND_SWEEP_DURATION if not math.isfinite(duration) or duration <= 0.0: msg = "duration must be a finite positive number" raise ValueError(msg) await self._auto_scheduler.async_request_active_scan(duration) 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 _unregister_source_callback( self, callbacks_dict: dict[Any, set[Callable[..., None]]], source: object, callback: Callable[..., None], ) -> None: """Unregister a source-keyed callback.""" if (callbacks := callbacks_dict.get(source)) is not None: callbacks.discard(callback) if not callbacks: del callbacks_dict[source] def _dispatch_source_callbacks( self, callbacks_dict: dict[Any, set[Callable[..., None]]], source: object, payload: object, label: str, ) -> None: """Dispatch payload to source-specific and global (None) callbacks.""" for source_key in (source, None): if not (callbacks := callbacks_dict.get(source_key)): continue for callback_ in callbacks.copy(): try: callback_(payload) except Exception: # pylint: disable=broad-except _LOGGER.exception("Error in %s", label) 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 self._dispatch_source_callbacks( self._allocations_callbacks, source, ha_slot_allocations, "allocation callback", ) def _async_on_scanner_registration( self, scanner: BaseHaScanner, event: HaScannerRegistrationEvent ) -> None: """Call scanner callbacks.""" self._dispatch_source_callbacks( self._scanner_registration_callbacks, scanner.source, HaScannerRegistration(event, scanner), "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._unregister_source_callback, self._allocations_callbacks, source, callback, ) 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._unregister_source_callback, self._scanner_registration_callbacks, source, callback, ) def async_current_scanners(self) -> list[BaseHaScanner]: """Return the current scanners.""" return list(self._sources.values()) def async_register_scanner_mode_change_callback( self, callback: Callable[[HaScannerModeChange], None], source: str | None ) -> CALLBACK_TYPE: """Register a callback to be called when a scanner mode changes.""" self._scanner_mode_change_callbacks.setdefault(source, set()).add(callback) return partial( self._unregister_source_callback, self._scanner_mode_change_callbacks, source, callback, ) def scanner_mode_changed(self, scanner: BaseHaScanner) -> None: """Notify callbacks that a scanner's mode has changed.""" self._dispatch_source_callbacks( self._scanner_mode_change_callbacks, scanner.source, HaScannerModeChange( scanner=scanner, requested_mode=scanner.requested_mode, current_mode=scanner.current_mode, ), "scanner mode change callback", ) Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/models.pxd000066400000000000000000000017571521117704500251160ustar00rootroot00000000000000import cython cdef object BLEDevice cdef object AdvertisementData cdef object _float cdef object _int cdef object _str cdef object _BluetoothServiceInfoBleakSelfT cdef object _BluetoothServiceInfoSelfT cdef object NO_RSSI_VALUE cdef object TUPLE_NEW cdef class BluetoothServiceInfo: """Prepared info from bluetooth entries.""" cdef public str name cdef public str address cdef public int rssi cdef public dict manufacturer_data cdef public dict service_data cdef public list service_uuids cdef public str source cdef class BluetoothServiceInfoBleak(BluetoothServiceInfo): """BluetoothServiceInfo with bleak data.""" cdef public object device cdef public object _advertisement cdef public bint connectable cdef public double time cdef public object tx_power cdef public bytes raw @cython.locals(new_obj=BluetoothServiceInfoBleak) cpdef BluetoothServiceInfoBleak _as_connectable(self) cdef object _advertisement_internal(self) Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/models.py000066400000000000000000000265651521117704500247570ustar00rootroot00000000000000"""Models for bluetooth.""" from __future__ import annotations from dataclasses import dataclass from enum import Enum from typing import TYPE_CHECKING, Any, Final, Self from bleak.backends.scanner import AdvertisementData from bleak_retry_connector import NO_RSSI_VALUE if TYPE_CHECKING: from collections.abc import Callable from bleak import BaseBleakClient from bleak.backends.device import BLEDevice from .base_scanner import BaseHaScanner SOURCE_LOCAL: Final = "local" TUPLE_NEW: Final = tuple.__new__ _float = float # avoid cython conversion since we always want a pyfloat _str = str # avoid cython conversion since we always want a pystr _int = int # avoid cython conversion since we always want a pyint @dataclass(slots=True, frozen=True) class HaBluetoothSlotAllocations: """Data for how to allocate slots for BLEDevice connections.""" source: str # Adapter MAC slots: int # Number of slots free: int # Number of free slots allocated: list[str] # Addresses of connected devices class HaScannerRegistrationEvent(Enum): """Events for scanner registration.""" ADDED = "added" REMOVED = "removed" UPDATED = "updated" @dataclass(slots=True, frozen=True) class HaScannerRegistration: """Data for a scanner event.""" event: HaScannerRegistrationEvent scanner: BaseHaScanner @dataclass(slots=True, frozen=True) class HaScannerModeChange: """Data for a scanner mode change event.""" scanner: BaseHaScanner requested_mode: BluetoothScanningMode | None current_mode: BluetoothScanningMode | None @dataclass(slots=True) class HaBluetoothConnector: """Data for how to connect a BLEDevice from a given scanner.""" client: type[BaseBleakClient] source: str can_connect: Callable[[], bool] @dataclass(slots=True, frozen=True) class HaScannerDetails: """Details for a scanner.""" source: str connectable: bool name: str adapter: str scanner_type: HaScannerType class HaScannerType(Enum): """The type of scanner.""" USB = "usb" UART = "uart" REMOTE = "remote" UNKNOWN = "unknown" class BluetoothScanningMode(Enum): """The mode of scanning for bluetooth devices.""" PASSIVE = "passive" ACTIVE = "active" # AUTO starts the scanner in PASSIVE and lets the manager promote it to # ACTIVE on demand via BaseHaScanner.async_request_active_window — used # for per-callback active windows and the periodic rediscovery sweep. AUTO = "auto" class BluetoothReachabilityIntent(Enum): """ What a caller needs from a device, for reachability diagnostics. The intent changes which facts are relevant when explaining why an address is not usable. A caller that only consumes advertisements does not care whether a connectable path or a free connection slot exists; a caller that wants to open a connection does. """ # The caller only needs to receive passive advertisements from the device. PASSIVE_ADVERTISEMENT = "passive_advertisement" # The caller needs scan response (SCAN_RSP) data, which is only received # from a scanner that is actively scanning. Treated the same as # PASSIVE_ADVERTISEMENT for now; kept distinct so the diagnostics can later # report when no scanner seeing the device is actively scanning. ACTIVE_ADVERTISEMENT = "active_advertisement" # The caller needs to open an outbound connection to the device. CONNECTION = "connection" 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, device: BLEDevice, advertisement_data: AdvertisementData, source: str, ) -> Self: """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.""" # MANUFACTURERS is a multi-kilobyte dict; lazy-load so the # cost is only paid by the rare caller that asks for the # manufacturer name (most don't). from bleak.backends._manufacturers import MANUFACTURERS # noqa: PLC0415 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, source: str, device: BLEDevice, advertisement_data: AdvertisementData, monotonic_time: _float, connectable: bool, ) -> Self: """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, device: BLEDevice, advertisement_data: AdvertisementData, source: str, time: _float, connectable: bool, ) -> Self: """Create a BluetoothServiceInfoBleak from a device and advertisement.""" return cls( advertisement_data.local_name or device.name or device.address, device.address, advertisement_data.rssi, advertisement_data.manufacturer_data, advertisement_data.service_data, advertisement_data.service_uuids, source, device, advertisement_data, connectable, time, advertisement_data.tx_power, ) def _as_connectable(self) -> BluetoothServiceInfoBleak: """Return a connectable version of this object.""" new_obj = BluetoothServiceInfoBleak.__new__(BluetoothServiceInfoBleak) new_obj.name = self.name new_obj.address = self.address new_obj.rssi = self.rssi new_obj.manufacturer_data = self.manufacturer_data new_obj.service_data = self.service_data new_obj.service_uuids = self.service_uuids new_obj.source = self.source new_obj.device = self.device new_obj._advertisement = self._advertisement new_obj.connectable = True new_obj.time = self.time new_obj.tx_power = self.tx_power new_obj.raw = self.raw return new_obj Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/py.typed000066400000000000000000000000001521117704500245710ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/scanner.pxd000066400000000000000000000017231521117704500252550ustar00rootroot00000000000000import 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 cdef public object _scan_mode_override cdef public object _active_window_handle cdef public double _active_window_end @cython.locals(service_info=BluetoothServiceInfoBleak) cpdef void _async_detection_callback( self, object device, object advertisement_data ) cpdef void _async_on_raw_bluez_advertisement( self, bytes address, unsigned short address_type, short rssi, unsigned int flags, bytes data, ) except * Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/scanner.py000066400000000000000000001153711521117704500251170ustar00rootroot00000000000000"""A local bleak scanner.""" from __future__ import annotations import asyncio import contextlib import logging import math import platform from functools import lru_cache from typing import TYPE_CHECKING, Any, no_type_check import async_interrupt import bleak from bleak import BleakError from bleak.assigned_numbers import AdvertisementDataType from bleak_retry_connector import Allocations, restore_discoveries from bleak_retry_connector.bluez import stop_discovery from bluetooth_adapters import DEFAULT_ADDRESS from bluetooth_data_tools import monotonic_time_coarse from .base_scanner import BaseHaScanner from .const import ( CALLBACK_TYPE, SCANNER_WATCHDOG_INTERVAL, SCANNER_WATCHDOG_TIMEOUT, SOURCE_LOCAL, START_TIMEOUT, STOP_TIMEOUT, ) from .models import BluetoothScanningMode, BluetoothServiceInfoBleak from .util import async_reset_adapter, is_docker_env if TYPE_CHECKING: from collections.abc import Coroutine, Iterable from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback int_ = int SYSTEM = platform.system() IS_LINUX = SYSTEM == "Linux" IS_MACOS = SYSTEM == "Darwin" if IS_LINUX: from bleak.args.bluez import BlueZScannerArgs, OrPattern from bleak.backends.bluezdbus.advertisement_monitor import ( AdvertisementMonitor, ) from dbus_fast import InvalidMessageError from dbus_fast.service import method # or_patterns is a workaround for the fact that passive scanning # needs at least one matcher to be set. The below matcher # will match all devices. PASSIVE_SCANNER_ARGS = BlueZScannerArgs( or_patterns=[ OrPattern(0, AdvertisementDataType.FLAGS, b"\x02"), OrPattern(0, AdvertisementDataType.FLAGS, b"\x06"), OrPattern(0, AdvertisementDataType.FLAGS, b"\x1a"), ] ) class HaAdvertisementMonitor(AdvertisementMonitor): """Implementation of the org.bluez.AdvertisementMonitor1 D-Bus interface.""" # Method names are dictated by the BlueZ AdvertisementMonitor1 # D-Bus interface; ``dbus_fast`` matches the Python attribute # name against the interface, so the CamelCase form is required. @method() @no_type_check def DeviceFound(self, device: o): # noqa: F821, N802 """Device found.""" @method() @no_type_check def DeviceLost(self, device: o): # noqa: F821, N802 """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", } def _resolve_radio_mode(mode: BluetoothScanningMode) -> BluetoothScanningMode: """ Resolve AUTO to the underlying mode the radio actually runs in. AUTO is a habluetooth scheduling concept, not a radio state. The backend always runs in either passive or active. current_mode is supposed to reflect that real state so diagnostics and the manager callbacks line up with what remote scanners (e.g. ESPHome) already report; otherwise local adapters look stuck on "auto" forever. Single source of truth for the AUTO -> radio mapping; both create_bleak_scanner and the active-window toggle defer here so a future platform change (or a new platform) only needs to update this one function. """ if mode is BluetoothScanningMode.AUTO: return ( BluetoothScanningMode.ACTIVE if IS_MACOS else BluetoothScanningMode.PASSIVE ) return mode # The minimum number of seconds to know # the adapter has not had advertisements # and we already tried to restart the scanner # without success when the first time the watch # dog hit the failure path. SCANNER_WATCHDOG_MULTIPLE = ( SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds() ) class _AbortStartError(Exception): """Error to indicate that the start should be aborted.""" class ScannerStartError(Exception): """Error to indicate that the scanner failed to start.""" def create_bleak_scanner( detection_callback: AdvertisementDataCallback | None, scanning_mode: BluetoothScanningMode, adapter: str | None, ) -> bleak.BleakScanner: """Create a Bleak scanner.""" # Resolve AUTO before doing anything else so the rest of this # function only ever sees ACTIVE or PASSIVE; CoreBluetooth has no # passive mode so AUTO collapses to ACTIVE on macOS (the radio # just stays in active and async_request_active_window is a no-op # there), and Linux/other platforms start AUTO in passive with # the scheduler flipping to active on demand. scanning_mode = _resolve_radio_mode(scanning_mode) scanner_kwargs: dict[str, Any] = { "scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode], } if detection_callback: scanner_kwargs["detection_callback"] = detection_callback if IS_LINUX: # Only Linux supports multiple adapters bluez_args: BlueZScannerArgs = {} # bleak's passive scanner needs at least one or_pattern matcher # or it won't start. AUTO has been resolved to PASSIVE above on # Linux (the scheduler restarts with scan_mode_override=ACTIVE # to flip to active on demand, which lands here as ACTIVE and # skips this branch). if scanning_mode is BluetoothScanningMode.PASSIVE: bluez_args = dict(PASSIVE_SCANNER_ARGS) if adapter: # bleak 3.0 deprecated the top-level ``adapter`` kwarg in favor of # the ``bluez`` kwarg; this form is supported across bleak 1.x-3.x. bluez_args["adapter"] = adapter if bluez_args: scanner_kwargs["bluez"] = bluez_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: msg = f"Failed to initialize Bluetooth: {ex}" raise RuntimeError(msg) from ex def _error_indicates_reset_needed(error_str: str) -> bool: """Return if the error indicates a reset is needed.""" return any( needs_reset_error in error_str for needs_reset_error in NEED_RESET_ERRORS ) def _error_indicates_wait_for_adapter_to_init(error_str: str) -> bool: """Return if the error indicates the adapter is still initializing.""" return any( wait_error in error_str for wait_error in WAIT_FOR_ADAPTER_TO_INIT_ERRORS ) @lru_cache(maxsize=512) def bytes_mac_to_str(mac: bytes) -> str: """Convert a MAC address in bytes to a string in big-endian (MSB-first) order.""" return ":".join(f"{b:02X}" for b in reversed(mac)) @lru_cache(maxsize=512) def make_bluez_details(address: str, adapter: str) -> dict[str, Any]: """Make the details for a bluez advertisement.""" base_path = f"/org/bluez/{adapter}" return { "path": f"{base_path}/dev_{address.replace(':', '_')}", "props": { "Adapter": base_path, }, } class HaScanner(BaseHaScanner): """ Operate and automatically recover a BleakScanner. Multiple BleakScanner can be used at the same time if there are multiple adapters. This is only useful if the adapters are not located physically next to each other. Example use cases are usbip, a long extension cable, usb to bluetooth over ethernet, usb over ethernet, etc. """ __slots__ = ( "_active_window_end", "_active_window_handle", "_background_tasks", "_scan_mode_override", "_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 # Set while an on-demand active window (auto-mode) is in flight. # When set, `_async_start_attempt` uses this mode instead of # `requested_mode`. `requested_mode` itself stays at AUTO so external # listeners still see the integration's intent. self._scan_mode_override: BluetoothScanningMode | None = None self._active_window_handle: asyncio.TimerHandle | None = None self._active_window_end: float = 0.0 def _create_background_task(self, coro: Coroutine[Any, Any, None]) -> None: """Create a background task and add it to the background tasks set.""" task = asyncio.create_task(coro) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" if not self.scanner: return [] return self.scanner.discovered_devices @property def discovered_devices_and_advertisement_data( self, ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: """Return a list of discovered devices and advertisement data.""" if not self.scanner: return {} return self.scanner.discovered_devices_and_advertisement_data @property def discovered_addresses(self) -> Iterable[str]: """Return an iterable of discovered devices.""" return self.discovered_devices_and_advertisement_data def get_discovered_device_advertisement_data( self, address: str ) -> tuple[BLEDevice, AdvertisementData] | None: """Return the advertisement data for a discovered device.""" return self.discovered_devices_and_advertisement_data.get(address) def get_allocations(self) -> Allocations | None: """Get current connection slot allocations from BleakSlotManager.""" if self._manager and self._manager.slot_manager: return self._manager.slot_manager.get_allocations(self.adapter) return None def async_setup(self) -> CALLBACK_TYPE: """Set up the scanner.""" super().async_setup() return self._unsetup async def async_diagnostics(self) -> dict[str, Any]: """Return diagnostic information about the scanner.""" base_diag = await super().async_diagnostics() return base_diag | {"adapter": self.adapter} def _async_on_raw_bluez_advertisement( self, address: bytes, address_type: int_, rssi: int_, flags: int_, data: bytes, ) -> None: """Handle raw advertisement data.""" address_str = bytes_mac_to_str(address) self._async_on_raw_advertisement( address_str, rssi, data, make_bluez_details(address_str, self.adapter), monotonic_time_coarse(), ) def _async_detection_callback( self, device: BLEDevice, advertisement_data: AdvertisementData, ) -> None: """ Call the callback when an advertisement is received. Currently this is used to feed the callbacks into the central manager. """ callback_time = monotonic_time_coarse() address = device.address local_name = advertisement_data.local_name manufacturer_data = advertisement_data.manufacturer_data service_data = advertisement_data.service_data service_uuids = advertisement_data.service_uuids if local_name or manufacturer_data or service_data or service_uuids: # Don't count empty advertisements # as the adapter is in a failure # state if all the data is empty. self._last_detection = callback_time name = local_name or device.name or address if name is not None and type(name) is not str: name = str(name) tx_power = advertisement_data.tx_power if tx_power is not None and type(tx_power) is not int: tx_power = int(tx_power) service_info = BluetoothServiceInfoBleak.__new__(BluetoothServiceInfoBleak) service_info.name = name service_info.address = address service_info.rssi = advertisement_data.rssi service_info.manufacturer_data = manufacturer_data service_info.service_data = service_data service_info.service_uuids = service_uuids service_info.source = self.source service_info.device = device service_info._advertisement = advertisement_data service_info.connectable = True service_info.time = callback_time service_info.tx_power = tx_power service_info.raw = None # not available in bleak. self._manager._scanner_adv_received(service_info) async def async_start(self) -> None: """Start bluetooth scanner.""" async with self._start_stop_lock: await self._async_start() async def _async_start(self) -> None: """Start bluetooth scanner under the lock.""" for attempt in range(1, START_ATTEMPTS + 1): if await self._async_start_attempt(attempt): # Everything is fine, break out of the loop break await self._async_on_successful_start() async def _async_on_successful_start(self) -> None: """Run when the scanner has successfully started.""" self.scanning = True self._async_setup_scanner_watchdog() await restore_discoveries(self.scanner, self.adapter) def _effective_mode(self) -> BluetoothScanningMode | None: """ Mode the scanner should actually start in. Override beats requested_mode so the scheduler can flip AUTO to ACTIVE for an on-demand window without losing intent. """ return self._scan_mode_override or self.requested_mode async def _async_start_attempt(self, attempt: int) -> bool: # noqa: C901 """Start the scanner and handle errors.""" assert ( # noqa: S101 self._loop is not None ), "Loop is not set, call async_setup first" effective_mode = self._effective_mode() radio_mode = ( _resolve_radio_mode(effective_mode) if effective_mode is not None else None ) self.set_current_mode(radio_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 radio_mode is BluetoothScanningMode.ACTIVE ): _LOGGER.debug( "%s: Falling back to passive scanning mode " "after active scanning failed (%s/%s)", self.name, attempt, START_ATTEMPTS, ) self.set_current_mode(BluetoothScanningMode.PASSIVE) assert self.current_mode is not None # noqa: S101 self.scanner = create_bleak_scanner( ( None if self._manager.has_advertising_side_channel else self._async_detection_callback ), self.current_mode, self.adapter, ) # If the scanner is already running, trying to start it again # can result in a deadlock. So we need to stop it first. # hci0: Opcode 0x200b failed: -110 # hci0: start background scanning failed: -110 # hci0: Controller not accepting commands anymore: ncmd = 0 # hci0: Injecting HCI hardware error event # hci0: hardware error 0x00 await self._async_force_stop_discovery() self._log_start_attempt(attempt) self._start_future = self._loop.create_future() try: async with ( asyncio.timeout(START_TIMEOUT), async_interrupt.interrupt(self._start_future, _AbortStartError, None), ): await self.scanner.start() except _AbortStartError as ex: await self._async_stop_scanner() self._raise_for_abort_start(ex) except InvalidMessageError as ex: await self._async_stop_scanner() self._raise_for_invalid_dbus_message(ex) except BrokenPipeError as ex: await self._async_stop_scanner() self._raise_for_broken_pipe_error(ex) except FileNotFoundError as ex: await self._async_stop_scanner() self._raise_for_file_not_found_error(ex) except TimeoutError as ex: await self._async_stop_scanner() if attempt == 2: await self._async_reset_adapter(False) if attempt < START_ATTEMPTS: self._log_start_timeout(attempt) return False msg = ( f"{self.name}: Timed out starting Bluetooth after" f" {START_TIMEOUT} seconds; " "Try power cycling the Bluetooth hardware." ) raise ScannerStartError(msg) 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 msg = ( f"{self.name}: Failed to start Bluetooth: {ex}; " "Try power cycling the Bluetooth hardware." ) raise ScannerStartError(msg) from ex except BaseException: await self._async_stop_scanner() raise finally: self._start_future = None self._log_start_success(attempt, radio_mode) self._on_start_success() return True def _log_adapter_init_wait(self, attempt: int) -> None: _LOGGER.debug( "%s: Waiting for adapter to initialize; attempt (%s/%s)", self.name, attempt, START_ATTEMPTS, ) def _log_start_success( self, attempt: int, radio_mode: BluetoothScanningMode | None ) -> None: # Compare against the resolved radio mode we *tried* to start # in rather than requested_mode: an AUTO scanner mid-active- # window has requested_mode=AUTO but radio_mode=ACTIVE, and we # don't want to warn "fell back to passive" when the active # restart actually succeeded. if self.current_mode is not radio_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=ex, ) 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=ex, ) 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=ex, ) if is_docker_env(): msg = ( f"{self.name}: DBus service not found; docker config may " "be missing `-v /run/dbus:/run/dbus:ro`: {ex}" ) raise ScannerStartError(msg) from ex msg = ( f"{self.name}: DBus service not found; make sure the DBus socket " f"is available: {ex}" ) raise ScannerStartError(msg) 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=ex) 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=ex, ) msg = f"{self.name}: Invalid DBus message received: {ex}; try restarting `dbus`" raise ScannerStartError(msg) from ex def _describe_side_channel_state(self) -> str: """Summarize where this scanner expects advertisements to come from.""" manager = self._manager idx = self.adapter_idx if idx is None: return "no adapter_idx; bleak detection_callback path" if not manager.has_advertising_side_channel: return "MGMT side channel unavailable; bleak detection_callback path" registered = manager._side_channel_scanners.get(idx) if registered is None: return f"MGMT side channel up but hci{idx} unregistered" if registered is not self: return f"MGMT side channel at hci{idx} bound to a different scanner" mgmt_ctl = manager._mgmt_ctl protocol = getattr(mgmt_ctl, "protocol", None) if mgmt_ctl else None if protocol is None: return f"MGMT side channel registered at hci{idx} but protocol down" if protocol.transport is None: return f"MGMT side channel registered at hci{idx} but transport closed" return f"MGMT side channel feeding hci{idx}" 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 (%s), restarting", self.name, self.time_since_last_detection(), self._describe_side_channel_state(), ) # 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: _LOGGER.exception( "%s: Failed to restart Bluetooth scanner", self.name, ) 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._clear_active_window_state() self._async_stop_scanner_watchdog() await self._async_stop_scanner() def _clear_active_window_state(self) -> None: """Reset AUTO active-window state (caller must hold start/stop lock).""" if self._active_window_handle is not None: self._active_window_handle.cancel() self._active_window_handle = None self._scan_mode_override = None self._active_window_end = 0.0 def _arm_active_window_timer_if_extends(self, duration: float) -> None: """ Re-arm the timer only if the new duration extends the window. Shorter callers no-op so they can't shrink a window another caller is depending on. """ if TYPE_CHECKING: assert self._loop is not None if self._loop.time() + duration > self._active_window_end: self._arm_active_window_timer(duration) def _arm_active_window_timer(self, duration: float) -> None: """ Schedule the end-of-window callback. Stores ``_active_window_end`` from ``loop.time()`` at arming time so it matches the real ``call_later`` fire time (a pre-restart snapshot would let a shorter follow-up masquerade as an extension). Cancels any existing handle first to avoid leaking a pending timer. """ if TYPE_CHECKING: assert self._loop is not None if self._active_window_handle is not None: self._active_window_handle.cancel() self._active_window_end = self._loop.time() + duration self._active_window_handle = self._loop.call_later( duration, self._schedule_end_active_window ) async def async_request_active_window(self, duration: float) -> bool: """ Run an active scan for ``duration`` seconds then restore prior mode. No-op on non-AUTO scanners. On macOS AUTO is permanent active (no passive mode in CoreBluetooth), so a no-op success there. Concurrent / repeat callers while a window is open: a longer follow-up extends the timer; a shorter follow-up is a no-op on the timer but still returns True. No second restart fires. Rejects non-finite or non-positive ``duration`` so a stray NaN/inf can't poison ``loop.call_later`` or the extension comparison; the scheduler clamps before calling but other callers (subclasses, tests) may not. """ if self.requested_mode is not BluetoothScanningMode.AUTO: return False if not math.isfinite(duration) or duration <= 0.0: _LOGGER.warning( "%s: refusing active window with invalid duration %r", self.name, duration, ) return False if IS_MACOS: return True if TYPE_CHECKING: assert self._loop is not None if self._active_window_handle is not None: self._arm_active_window_timer_if_extends(duration) return True async with self._start_stop_lock: self._scan_mode_override = BluetoothScanningMode.ACTIVE # If the scanner is still ACTIVE here, the end-of-window task # for the previous timer is queued but hasn't run yet (it # would have cleared current_mode to PASSIVE). Skip the # restart; same extend-only rule as the lockless fast path. if self.current_mode is BluetoothScanningMode.ACTIVE: self._arm_active_window_timer_if_extends(duration) return True if IS_LINUX: entered = await self._async_begin_active_window_via_toggle() else: entered = await self._async_begin_active_window_via_restart() if not entered: return False self._arm_active_window_timer(duration) return True async def _async_begin_active_window_via_toggle(self) -> bool: """ Cheap Linux/BlueZ entry via in-place ``_scanning_mode`` flip. Caller holds ``_start_stop_lock`` and has set the override. On failure clears the override and recovers via a full restart so the scanner isn't left stopped. """ try: flipped = await self._async_toggle_active_window_mode() except BaseException: # Any error (CancelledError, SystemExit, leaked BleakError, # etc.) must not leave the override stuck at ACTIVE for # the next start. Clear and re-raise. self._scan_mode_override = None raise if not flipped: return await self._async_abort_active_window() return True async def _async_begin_active_window_via_restart(self) -> bool: """ Non-Linux entry via full stop+recreate+start in ACTIVE mode. Caller holds ``_start_stop_lock`` and has set the override so the fresh BleakScanner is constructed in ACTIVE. On ScannerStartError or the Linux 4th-attempt PASSIVE fallback the override is cleared and False is returned. """ try: await self._async_stop_then_start_under_lock() except ScannerStartError: return await self._async_abort_active_window() except BaseException: self._scan_mode_override = None raise if self.current_mode is not BluetoothScanningMode.ACTIVE: self._scan_mode_override = None return False return True async def _async_abort_active_window(self) -> bool: """ Roll back a failed active-window entry. Clears the ACTIVE override and runs a best-effort stop+restart so the scanner comes back up in its underlying AUTO/passive mode rather than being left stopped. Returns False so callers can ``return await self._async_abort_...``. """ self._scan_mode_override = None with contextlib.suppress(ScannerStartError): await self._async_stop_then_start_under_lock() return False def _schedule_end_active_window(self) -> None: """Spawn the end-of-window restart task.""" self._active_window_handle = None self._create_background_task(self._async_end_active_window()) async def _async_end_active_window(self) -> None: """Restore the scanner to its underlying mode after the window ends.""" async with self._start_stop_lock: if self._active_window_handle is not None: # A new window took over; let it own the override and timer. return self._scan_mode_override = None if not self.scanning: return if IS_LINUX and await self._async_toggle_active_window_mode(): return # Non-Linux backend, or toggle failed; full restart so we # don't leave the scanner stuck in ACTIVE. try: await self._async_stop_then_start_under_lock() except ScannerStartError as ex: _LOGGER.warning( "%s: Failed to restart scanner after active window: %s", self.name, ex, ) async def _async_stop_then_start_under_lock(self) -> None: """ Stop and restart the BleakScanner; caller holds _start_stop_lock. Full teardown: nulls ``self.scanner`` and constructs a fresh one. AUTO active-window flips on Linux use ``_async_toggle_active_window_mode`` instead to skip the dbus setup + ``restore_discoveries`` cost. """ await self._async_stop_scanner() await self._async_start() async def _async_toggle_active_window_mode(self) -> bool: """ Toggle the existing BleakScanner between active and passive. Stops the live ``self.scanner``, mutates its private ``_backend._scanning_mode`` to the value from ``_effective_mode()``, restarts the same instance. Skips the new dbus client + ``restore_discoveries`` cost of a fresh construction; bleak's device cache survives same-instance stop+start so ``BleakClient(address)`` keeps working. Linux/BlueZ only — callers must check ``IS_LINUX``. Returns False if the scanner is gone or stop/start raised (caller falls back to the full path). """ if self.scanner is None: return False effective_mode = self._effective_mode() if TYPE_CHECKING: assert effective_mode is not None radio_mode = _resolve_radio_mode(effective_mode) mode_str = SCANNING_MODE_TO_BLEAK[radio_mode] try: async with asyncio.timeout(STOP_TIMEOUT): await self.scanner.stop() except (TimeoutError, BleakError) as ex: _LOGGER.warning( "%s: Error stopping scanner during active-window flip: %s", self.name, ex, ) # The bleak scanner may be in an undefined state; mark # the wrapper not-scanning so the caller's fallback path # treats it as stopped. self.scanning = False return False # Private bleak attribute — no public API for mode change. # BlueZ reads it on every start; macOS isn't reachable here. # Guarded so a future bleak refactor that renames/drops the # attribute can't leave the scanner stopped with no restart; # caller falls back to the full stop+recreate+start path. try: self.scanner._backend._scanning_mode = mode_str except AttributeError as ex: _LOGGER.warning( "%s: bleak _backend._scanning_mode unavailable; " "cannot toggle in place: %s", self.name, ex, ) self.scanning = False return False try: async with asyncio.timeout(START_TIMEOUT): await self.scanner.start() except (TimeoutError, BleakError, ScannerStartError) as ex: _LOGGER.warning( "%s: Error starting scanner during active-window flip: %s", self.name, ex, ) # Scanner was stopped above and didn't come back; mark # not-scanning so it matches reality. self.scanning = False return False self.scanning = True self.set_current_mode(radio_mode) return True async def _async_stop_scanner(self) -> None: """Stop bluetooth discovery under the lock.""" self.scanning = False if self.scanner is None: _LOGGER.debug("%s: Scanner is already stopped", self.name) return _LOGGER.debug("%s: Stopping bluetooth discovery", self.name) try: async with asyncio.timeout(STOP_TIMEOUT): await self.scanner.stop() except (TimeoutError, BleakError): # This is not fatal, and they may want to reload # the config entry to restart the scanner if they # change the bluetooth dongle. _LOGGER.exception("%s: Error stopping scanner", self.name) self.scanner = None async def _async_force_stop_discovery(self) -> None: """Force stop discovery.""" _LOGGER.debug("%s: Force stopping bluetooth discovery", self.name) try: async with asyncio.timeout(STOP_TIMEOUT): await stop_discovery(self.adapter) except TimeoutError: _LOGGER.exception("%s: Timeout force stopping scanner", self.name) except Exception: # Best-effort BlueZ cleanup; dbus_fast can raise a wide # variety of errors and we don't want any of them to # propagate out of the recovery path. _LOGGER.exception("%s: Failed to force stop scanner", self.name) Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/scanner_device.py000066400000000000000000000013441521117704500264300ustar00rootroot00000000000000"""Base classes for HA Bluetooth scanners for bluetooth.""" from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING if TYPE_CHECKING: from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from .base_scanner import BaseHaScanner @dataclass(slots=True) class BluetoothScannerDevice: """Data for a bluetooth device from a given scanner.""" scanner: BaseHaScanner ble_device: BLEDevice advertisement: AdvertisementData def score_connection_path(self, rssi_diff: int) -> float: """Return a score for the connection path to this device.""" return self.scanner._score_connection_paths(rssi_diff, self) Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/storage.py000066400000000000000000000252771521117704500251370ustar00rootroot00000000000000"""Serialize/Deserialize bluetooth adapter discoveries.""" from __future__ import annotations import logging import time from dataclasses import dataclass, field from typing import Any, Final, TypedDict from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData _LOGGER = logging.getLogger(__name__) @dataclass class DiscoveredDeviceAdvertisementData: """Discovered device advertisement data deserialized from storage.""" connectable: bool expire_seconds: float discovered_device_advertisement_datas: dict[ str, tuple[BLEDevice, AdvertisementData] ] discovered_device_timestamps: dict[str, float] discovered_device_raw: dict[str, bytes | None] = field(default_factory=dict) CONNECTABLE: Final = "connectable" EXPIRE_SECONDS: Final = "expire_seconds" DISCOVERED_DEVICE_ADVERTISEMENT_DATAS: Final = "discovered_device_advertisement_datas" DISCOVERED_DEVICE_TIMESTAMPS: Final = "discovered_device_timestamps" DISCOVERED_DEVICE_RAW: Final = "discovered_device_raw" class DiscoveredDeviceAdvertisementDataDict(TypedDict): """Discovered device advertisement data dict in storage.""" connectable: bool expire_seconds: float discovered_device_advertisement_datas: dict[str, DiscoveredDeviceDict] discovered_device_timestamps: dict[str, float] discovered_device_raw: dict[str, str | None] ADDRESS: Final = "address" NAME: Final = "name" RSSI: Final = "rssi" DETAILS: Final = "details" class BLEDeviceDict(TypedDict): """BLEDevice dict.""" address: str name: str | None rssi: int | None # Kept for backward compatibility details: dict[str, Any] LOCAL_NAME: Final = "local_name" MANUFACTURER_DATA: Final = "manufacturer_data" SERVICE_DATA: Final = "service_data" SERVICE_UUIDS: Final = "service_uuids" TX_POWER: Final = "tx_power" PLATFORM_DATA: Final = "platform_data" class AdvertisementDataDict(TypedDict): """AdvertisementData dict.""" local_name: str | None manufacturer_data: dict[str, str] service_data: dict[str, str] service_uuids: list[str] rssi: int tx_power: int | None platform_data: list[Any] class DiscoveredDeviceDict(TypedDict): """Discovered device dict.""" device: BLEDeviceDict advertisement_data: AdvertisementDataDict def expire_stale_scanner_discovered_device_advertisement_data( data_by_scanner: dict[str, DiscoveredDeviceAdvertisementDataDict], ) -> None: """Expire stale discovered device advertisement data.""" now = time.time() expired_scanners: list[str] = [] for scanner, data in data_by_scanner.items(): expire: list[str] = [] expire_seconds = data[EXPIRE_SECONDS] timestamps = data[DISCOVERED_DEVICE_TIMESTAMPS] discovered_device_advertisement_datas = data[ DISCOVERED_DEVICE_ADVERTISEMENT_DATAS ] discovered_device_raw = data.get(DISCOVERED_DEVICE_RAW, {}) for address, timestamp in timestamps.items(): time_diff = now - timestamp if time_diff > expire_seconds: expire.append(address) elif time_diff < 0: _LOGGER.warning( "Discarding timestamp %s for %s on " "scanner %s as it is the future (now = %s)", timestamp, address, scanner, now, ) expire.append(address) for address in expire: del timestamps[address] del discovered_device_advertisement_datas[address] discovered_device_raw.pop(address, None) if not timestamps: expired_scanners.append(scanner) _LOGGER.debug( "Loaded %s fresh discovered devices for %s", len(timestamps), scanner ) for scanner in expired_scanners: del data_by_scanner[scanner] def discovered_device_advertisement_data_from_dict( data: DiscoveredDeviceAdvertisementDataDict, ) -> DiscoveredDeviceAdvertisementData | None: """Build discovered_device_advertisement_data dict.""" try: return DiscoveredDeviceAdvertisementData( data[CONNECTABLE], data[EXPIRE_SECONDS], _deserialize_discovered_device_advertisement_datas( data[DISCOVERED_DEVICE_ADVERTISEMENT_DATAS] ), _deserialize_discovered_device_timestamps( data[DISCOVERED_DEVICE_TIMESTAMPS] ), _deserialize_discovered_device_raw(data.get(DISCOVERED_DEVICE_RAW, {})), ) except (KeyError, ValueError, TypeError): _LOGGER.warning( "Discovery cache shape mismatch, discarding cache; " "adapter startup will be slow" ) except Exception: # pylint: disable=broad-except _LOGGER.exception( "Unexpected error deserializing discovered_device_advertisement_data, " "adapter startup will be slow" ) return None def discovered_device_advertisement_data_to_dict( data: DiscoveredDeviceAdvertisementData, ) -> DiscoveredDeviceAdvertisementDataDict: """Build discovered_device_advertisement_data dict.""" return DiscoveredDeviceAdvertisementDataDict( connectable=data.connectable, expire_seconds=data.expire_seconds, discovered_device_advertisement_datas=_serialize_discovered_device_advertisement_datas( data.discovered_device_advertisement_datas ), discovered_device_timestamps=_serialize_discovered_device_timestamps( data.discovered_device_timestamps ), discovered_device_raw=_serialize_discovered_device_raw( data.discovered_device_raw ), ) def _serialize_discovered_device_advertisement_datas( discovered_device_advertisement_datas: dict[ str, tuple[BLEDevice, AdvertisementData] ], ) -> dict[str, DiscoveredDeviceDict]: """Serialize discovered_device_advertisement_datas.""" return { address: DiscoveredDeviceDict( device=_ble_device_to_dict(device, advertisement_data), advertisement_data=_advertisement_data_to_dict(advertisement_data), ) for ( address, (device, advertisement_data), ) in discovered_device_advertisement_datas.items() } def _deserialize_discovered_device_advertisement_datas( discovered_device_advertisement_datas: dict[str, DiscoveredDeviceDict], ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: """Deserialize discovered_device_advertisement_datas.""" return { address: ( _ble_device_from_dict(device_advertisement_data["device"]), _advertisement_data_from_dict( device_advertisement_data["advertisement_data"] ), ) for ( address, device_advertisement_data, ) in discovered_device_advertisement_datas.items() } def _ble_device_from_dict(device_dict: BLEDeviceDict) -> BLEDevice: """Deserialize BLEDevice from dict, handling backward compatibility.""" # Remove rssi from dict as BLEDevice no longer accepts it in bleak 1.x device_data = device_dict.copy() device_data.pop("rssi", None) # type: ignore[misc] # Remove rssi if present (backward compatibility) return BLEDevice(**device_data) def _ble_device_to_dict( ble_device: BLEDevice, advertisement_data: AdvertisementData ) -> BLEDeviceDict: """Serialize ble_device.""" return BLEDeviceDict( address=ble_device.address, name=ble_device.name, rssi=advertisement_data.rssi, # For backward compatibility details=ble_device.details, ) def _advertisement_data_from_dict( advertisement_data: AdvertisementDataDict, ) -> AdvertisementData: """Deserialize advertisement_data.""" return AdvertisementData( local_name=advertisement_data[LOCAL_NAME], manufacturer_data={ int(manufacturer_id): bytes.fromhex(manufacturer_data) for manufacturer_id, manufacturer_data in advertisement_data[ MANUFACTURER_DATA ].items() }, service_data={ service_uuid: bytes.fromhex(service_data) for service_uuid, service_data in advertisement_data[SERVICE_DATA].items() }, service_uuids=advertisement_data[SERVICE_UUIDS], rssi=advertisement_data[RSSI], tx_power=advertisement_data[TX_POWER], platform_data=tuple(advertisement_data[PLATFORM_DATA]), ) def _advertisement_data_to_dict( advertisement_data: AdvertisementData, ) -> AdvertisementDataDict: """Serialize advertisement_data.""" return AdvertisementDataDict( local_name=advertisement_data.local_name, manufacturer_data={ str(manufacturer_id): manufacturer_data.hex() for manufacturer_id, manufacturer_data in advertisement_data.manufacturer_data.items() # noqa: E501 }, service_data={ service_uuid: service_data.hex() for service_uuid, service_data in advertisement_data.service_data.items() }, service_uuids=advertisement_data.service_uuids, rssi=advertisement_data.rssi, tx_power=advertisement_data.tx_power, platform_data=list(advertisement_data.platform_data), ) def _get_monotonic_time_diff() -> float: """Get monotonic time diff.""" return time.time() - time.monotonic() def _deserialize_discovered_device_timestamps( discovered_device_timestamps: dict[str, float], ) -> dict[str, float]: """Deserialize discovered_device_timestamps.""" time_diff = _get_monotonic_time_diff() return { address: unix_time - time_diff for address, unix_time in discovered_device_timestamps.items() } def _serialize_discovered_device_timestamps( discovered_device_timestamps: dict[str, float], ) -> dict[str, float]: """Serialize discovered_device_timestamps.""" time_diff = _get_monotonic_time_diff() return { address: monotonic_time + time_diff for address, monotonic_time in discovered_device_timestamps.items() } def _deserialize_discovered_device_raw( discovered_device_raw: dict[str, str | None], ) -> dict[str, bytes | None]: """Deserialize discovered_device_timestamps.""" return { address: None if raw is None else bytes.fromhex(raw) for address, raw in discovered_device_raw.items() } def _serialize_discovered_device_raw( discovered_device_raw: dict[str, bytes | None], ) -> dict[str, str | None]: """Serialize discovered_device_timestamps.""" return { address: None if raw is None else raw.hex() for address, raw in discovered_device_raw.items() } DiscoveryStorageType = dict[str, DiscoveredDeviceAdvertisementDataDict] Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/usage.py000066400000000000000000000033761521117704500245730ustar00rootroot00000000000000"""bluetooth usage utility to handle multiple instances.""" from __future__ import annotations from typing import TYPE_CHECKING import bleak import bleak_retry_connector from .wrappers import HaBleakClientWrapper, HaBleakScannerWrapper if TYPE_CHECKING: from bleak.backends.service import BleakGATTServiceCollection ORIGINAL_BLEAK_SCANNER = bleak.BleakScanner ORIGINAL_BLEAK_CLIENT = bleak.BleakClient ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT_WITH_SERVICE_CACHE = ( bleak_retry_connector.BleakClientWithServiceCache ) ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT = bleak_retry_connector.BleakClient def install_multiple_bleak_catcher() -> None: """ Wrap the bleak classes to return the shared instance. In case multiple instances are detected. """ bleak.BleakScanner = HaBleakScannerWrapper bleak.BleakClient = HaBleakClientWrapper bleak_retry_connector.BleakClientWithServiceCache = HaBleakClientWithServiceCache bleak_retry_connector.BleakClient = HaBleakClientWrapper def uninstall_multiple_bleak_catcher() -> None: """Unwrap the bleak classes.""" bleak.BleakScanner = ORIGINAL_BLEAK_SCANNER bleak.BleakClient = ORIGINAL_BLEAK_CLIENT bleak_retry_connector.BleakClientWithServiceCache = ( ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT_WITH_SERVICE_CACHE ) bleak_retry_connector.BleakClient = ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT class HaBleakClientWithServiceCache(HaBleakClientWrapper): """A BleakClient that implements service caching.""" def set_cached_services(self, services: BleakGATTServiceCollection | None) -> None: """ Set the cached services. No longer used since bleak 0.17+ has service caching built-in. This was only kept for backwards compatibility. """ Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/util.py000066400000000000000000000063121521117704500244350ustar00rootroot00000000000000"""The bluetooth utilities.""" from __future__ import annotations import asyncio import functools from contextlib import suppress from functools import cache from pathlib import Path from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar from bluetooth_auto_recovery import recover_adapter if TYPE_CHECKING: from collections.abc import Callable, Coroutine _P = ParamSpec("_P") _R = TypeVar("_R") 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() def coalesce_concurrent_future( attr: str, ) -> Callable[ [Callable[_P, Coroutine[Any, Any, _R]]], Callable[_P, Coroutine[Any, Any, _R]], ]: """ Coalesce concurrent async method calls onto a single shared future. Mirrors the home-assistant ``loader.py`` shared-future pattern. The first caller runs the wrapped coroutine and the result (or exception) is published on a future stored at ``self.``; concurrent callers wait on the same future and observe the same outcome. ``asyncio.wait`` is used on the waiter side so a cancelled waiter does not transitively cancel the shared future and strand the leader or its siblings. Cancellation contract: if the leader is cancelled the ``CancelledError`` is forwarded as-is to every waiter via ``set_exception`` (matching ``loader.py``); callers that need to be insulated from leader cancellation should wrap their own call in ``asyncio.shield``. Pre-condition: ``self.`` must already exist on the instance and be initialised to ``None`` before the first call. The decorator reads it via ``getattr`` (no default) and resets it to ``None`` in ``finally`` once the leader completes. Only usable on instance methods, ``self`` is taken from ``args[0]``. """ def decorator( func: Callable[_P, Coroutine[Any, Any, _R]], ) -> Callable[_P, Coroutine[Any, Any, _R]]: @functools.wraps(func) async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: self = args[0] future: asyncio.Future[_R] | None = getattr(self, attr) if future is not None: await asyncio.wait((future,)) return future.result() future = asyncio.get_running_loop().create_future() setattr(self, attr, future) try: result = await func(*args, **kwargs) except BaseException as ex: future.set_exception(ex) # Mark the exception as retrieved so asyncio does not warn # when no concurrent waiters consume it. with suppress(BaseException): future.result() raise else: future.set_result(result) return result finally: setattr(self, attr, None) return wrapper return decorator Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/wrappers.py000066400000000000000000000611451521117704500253300ustar00rootroot00000000000000"""Bleak wrappers for bluetooth.""" from __future__ import annotations import asyncio import contextlib import inspect import logging import warnings from dataclasses import dataclass from functools import partial from typing import TYPE_CHECKING, Any, Final, Literal, Self, overload from bleak import BleakClient, BleakError, normalize_uuid_str from bleak.backends.client import BaseBleakClient, get_platform_client_backend_type from bleak.backends.device import BLEDevice from bleak_retry_connector import ( ble_device_description, clear_cache, device_source, ) from .central_manager import get_manager from .const import BDADDR_LE_PUBLIC, BDADDR_LE_RANDOM, CALLBACK_TYPE, ConnectParams from .models import BluetoothReachabilityIntent FILTER_UUIDS: Final = "UUIDs" _LOGGER = logging.getLogger(__name__) def _get_device_address_type(device: BLEDevice) -> int: """ Get the address type for a BLE device. Returns: BDADDR_LE_RANDOM if the device has a random address, BDADDR_LE_PUBLIC otherwise """ details: dict[str, dict[str, Any]] = device.details return ( BDADDR_LE_RANDOM if details.get("props", {}).get("AddressType") == "random" else BDADDR_LE_PUBLIC ) if TYPE_CHECKING: from collections.abc import AsyncGenerator, Callable from bleak.backends import BleakBackend from bleak.backends.scanner import ( AdvertisementData, AdvertisementDataCallback, AdvertisementDataFilter, ) 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 backend_name: BleakBackend | str class HaBleakScannerWrapper: """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() self._started = False remapped_kwargs = { "detection_callback": detection_callback, "service_uuids": service_uuids or [], **kwargs, } self._map_filters(*args, **remapped_kwargs) if detection_callback is not None: self._advertisement_data_callback = detection_callback # Callback registered in start(), torn down in stop(). @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) @classmethod async def find_device_by_name( cls, name: str, timeout: float = 10.0, **kwargs: Any ) -> BLEDevice | None: """Find a device by name.""" return await cls.find_device_by_filter( lambda d, ad: ad.local_name == name, timeout=timeout, **kwargs, ) @classmethod async def find_device_by_filter( cls, filterfunc: AdvertisementDataFilter, timeout: float = 10.0, **kwargs: Any, ) -> BLEDevice | None: """Find a device by filter.""" manager = get_manager() for info in manager.async_discovered_service_info(False): if filterfunc(info.device, info.advertisement): return info.device return None @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.""" self._started = False self._cancel_callback() async def start(self, *args: Any, **kwargs: Any) -> None: """Start scanning for devices.""" self._started = True self._setup_detection_callback() async def __aenter__(self) -> Self: """Enter the context manager.""" await self.start() return self async def __aexit__(self, *args: object) -> None: """Exit the context manager.""" await self.stop() 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) and self._started: 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)) @property def discovered_devices_and_advertisement_data( self, ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: """Return a dict of discovered devices and their advertisement data.""" return { info.address: (info.device, info.advertisement) for info in get_manager().async_discovered_service_info(True) } def register_detection_callback( self, callback: AdvertisementDataCallback | None ) -> Callable[[], None]: """ Register a detection callback (deprecated). bleak removed this method from ``BleakScanner``; it remains here only so integrations that have not yet migrated keep working. Pass ``detection_callback`` to the constructor instead. This shim will be removed in a future habluetooth release. """ warnings.warn( "HaBleakScannerWrapper.register_detection_callback() is deprecated " "and will be removed in a future release; bleak already removed " "this method from BleakScanner. Pass detection_callback to the " "HaBleakScannerWrapper constructor instead.", DeprecationWarning, stacklevel=2, ) _LOGGER.warning( "HaBleakScannerWrapper.register_detection_callback() is deprecated " "and will be removed in a future release; bleak already removed " "this method from BleakScanner. Pass detection_callback to the " "HaBleakScannerWrapper constructor instead." ) self._advertisement_data_callback = callback self._setup_detection_callback() return self._cancel_callback async def advertisement_data( self, ) -> AsyncGenerator[tuple[BLEDevice, AdvertisementData], None]: """Yield devices and advertisement data as they are discovered.""" queue: asyncio.Queue[tuple[BLEDevice, AdvertisementData]] = asyncio.Queue() cancel = get_manager().async_register_bleak_callback( lambda bd, ad: queue.put_nowait((bd, ad)), self._mapped_filters, ) try: while True: yield await queue.get() finally: 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() manager = get_manager() if not inspect.iscoroutinefunction(callback): detection_callback = callback else: def detection_callback( ble_device: BLEDevice, advertisement_data: AdvertisementData ) -> None: task = asyncio.create_task(callback(ble_device, advertisement_data)) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) self._detection_cancel = manager.async_register_bleak_callback( detection_callback, self._mapped_filters ) def __del__(self) -> None: """Delete the BleakScanner.""" if self._detection_cancel: # Nothing to do if event loop is already closed with contextlib.suppress(RuntimeError): asyncio.get_running_loop().call_soon_threadsafe(self._detection_cancel) class HaBleakClientWrapper(BleakClient): """ Wrap the BleakClient to ensure it does not shutdown our scanner. If an address is passed into BleakClient instead of a BLEDevice, bleak will quietly start a new scanner under the hood to resolve the address. This can cause a conflict with our scanner. We need to handle translating the address to the BLEDevice in this case to avoid the whole stack from getting stuck in an in progress state when an integration does this. """ def __init__( # pylint: disable=super-init-not-called self, address_or_ble_device: str | BLEDevice, disconnected_callback: Callable[[BleakClient], None] | None = None, services: list[str] | None = None, *, timeout: float = 10.0, pair: bool = False, **kwargs: Any, ) -> None: """Initialize the BleakClient.""" if isinstance(address_or_ble_device, BLEDevice): self.__address = address_or_ble_device.address else: # If we are passed an address we need to make sure # its not a subclassed str self.__address = str(address_or_ble_device) self.__disconnected_callback = disconnected_callback self.__manager = get_manager() self.__timeout = timeout self.__services = services self._backend: BaseBleakClient | None = None self._connected_scanner: BaseHaScanner | None = None self._connected_device: BLEDevice | None = None self._pair_before_connect = pair # Check if this client is being created through establish_connection # by checking for the '_is_retry_client' marker in kwargs self._is_retry_client = kwargs.pop("_is_retry_client", False) # bleak 2.0+ BleakClient.backend_id reads self._backend_id, but since # we skip super().__init__() it is never set. The real backend is not # chosen until connect(), so seed with "" and update there. self._backend_id: BleakBackend | str = "" @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) async def set_connection_params( self, min_interval: int, max_interval: int, latency: int, timeout: int, ) -> None: """Set BLE connection parameters on a connected device.""" if self._backend is not None and hasattr( self._backend, "set_connection_params" ): await self._backend.set_connection_params( min_interval, max_interval, latency, timeout ) return # BlueZ local path - use mgmt API if ( self._connected_scanner is not None and self._connected_device is not None and (adapter_idx := self._connected_scanner.adapter_idx) is not None and (mgmt_ctl := self.__manager.get_bluez_mgmt_ctl()) ): mgmt_ctl.load_conn_params_explicit( adapter_idx, self._connected_device.address, _get_device_address_type(self._connected_device), min_interval, max_interval, latency, timeout, ) return if self._backend is not None: _LOGGER.warning( "%s: Backend %s does not support setting connection" " parameters; Upgrade the backend library", self.__address, type(self._backend).__name__, ) def set_disconnected_callback( self, callback: Callable[[BleakClient], None] | None, **kwargs: Any, ) -> None: """Set the disconnect callback.""" self.__disconnected_callback = callback if self._backend: self._backend.set_disconnected_callback( self._make_disconnected_callback(callback), **kwargs, ) def _make_disconnected_callback( self, callback: Callable[[BleakClient], None] | None ) -> Callable[[], None] | None: """ Make the disconnected callback. https://github.com/hbldh/bleak/pull/1256 The disconnected callback needs to get the top level BleakClientWrapper instance, not the backend instance. The signature of the callback for the backend is: Callable[[], None] To make this work we need to wrap the callback in a partial that passes the BleakClientWrapper instance as the first argument. """ return None if callback is None else partial(callback, self) async def connect(self, **kwargs: Any) -> None: # noqa: C901 """Connect to the specified GATT server.""" if self.is_connected: return # Warn if not using bleak-retry-connector's establish_connection if not self._is_retry_client: _LOGGER.warning( "%s: BleakClient.connect() called without bleak-retry-connector. " "For reliable connection establishment, use " "bleak_retry_connector.establish_connection(). " "See https://github.com/Bluetooth-Devices/bleak-retry-connector", self.__address, ) manager = self.__manager if manager.shutdown: msg = "Bluetooth is already shutdown" raise BleakError(msg) 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_id = wrapped_backend.backend_name self._backend = wrapped_backend.client( device, disconnected_callback=self._make_disconnected_callback( self.__disconnected_callback ), services=( None if self.__services is None else set(map(normalize_uuid_str, self.__services)) ), timeout=self.__timeout, bluez={}, ) description = "" rssi = None if debug_logging: # Only lookup the description if we are going to log it description = ble_device_description(device) device_adv = scanner.get_discovered_device_advertisement_data( device.address ) if TYPE_CHECKING: assert device_adv is not None adv = device_adv[1] rssi = adv.rssi backend_name = ( f" [{wrapped_backend.backend_name}]" if wrapped_backend.backend_name else "" ) _LOGGER.debug( "%s: Connecting via %s%s (last rssi: %s)", description, scanner.name, backend_name, rssi, ) # Load fast connection parameters before connecting if mgmt API is available self._load_conn_params( scanner, device, ConnectParams.FAST, debug_logging, description, ) connected = False address = device.address try: scanner._add_connecting(address) await super().connect(**kwargs) connected = True finally: scanner._finished_connecting(address, connected) if not connected: # Clear backend on any failure path (including BaseException # such as asyncio.CancelledError) so the wrapper does not hold # a partially-initialised backend. self._backend = None # Local adapters need an explicit slot release on failure; # remote scanners manage slot accounting on the proxy side. if not connected and not wrapped_backend.source: manager.async_release_connection_slot(device) # Load medium connection parameters after successful connection if connected: self._connected_scanner = scanner self._connected_device = device self._load_conn_params( scanner, device, ConnectParams.MEDIUM, debug_logging, description, ) if debug_logging: _LOGGER.debug( "%s: %s via %s%s (last rssi: %s)", description, "Connected" if connected else "Failed to connect", scanner.name, backend_name, rssi, ) return def _load_conn_params( self, scanner: BaseHaScanner, device: BLEDevice, params: ConnectParams, debug_logging: bool, description: str, ) -> None: """Load connection parameters for a device.""" if ( (adapter_idx := scanner.adapter_idx) is not None and (mgmt_ctl := self.__manager.get_bluez_mgmt_ctl()) and mgmt_ctl.load_conn_params( adapter_idx, device.address, _get_device_address_type(device), params, ) and debug_logging ): _LOGGER.debug("%s: Loaded %s connection parameters", description, params) def _async_get_backend_for_ble_device( self, manager: BluetoothManager, scanner: BaseHaScanner, ble_device: BLEDevice ) -> _HaWrappedBleakBackend | None: """Get the backend for a BLEDevice.""" if not (source := device_source(ble_device)): # If client is not defined in details # its the client for this platform if not manager.async_allocate_connection_slot(ble_device): return None backend = get_platform_client_backend_type() # bleak 2.0.0+ returns a tuple (backend_class, backend_id) if isinstance(backend, tuple): cls, backend_name = backend else: cls = backend backend_name = type(cls).__name__ return _HaWrappedBleakBackend( ble_device, scanner, cls, source, backend_name ) # 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, type(scanner.connector.client).__name__, ) def _async_get_best_available_backend_and_device( self, manager: BluetoothManager ) -> _HaWrappedBleakBackend: """ Get a best available backend and device for the given address. This method will return the backend with the best rssi that has a free connection slot. """ address = self.__address sorted_devices = sorted( manager.async_scanner_devices_by_address(self.__address, True), key=lambda x: x.advertisement.rssi, reverse=True, ) rssi_diff = 0 # Default when there's only one device if len(sorted_devices) > 1: rssi_diff = ( sorted_devices[0].advertisement.rssi - sorted_devices[1].advertisement.rssi ) sorted_devices = sorted( sorted_devices, key=lambda device: device.score_connection_path(rssi_diff), reverse=True, ) if sorted_devices and _LOGGER.isEnabledFor(logging.INFO): _LOGGER.info( "%s - %s: Found %s connection path(s), preferred order: %s", address, sorted_devices[0].ble_device.name, len(sorted_devices), ", ".join( ( f"{device.scanner.name} " f"(RSSI={device.advertisement.rssi}) " f"(failures={device.scanner.connection_failures(address)}) " f"(in_progress={device.scanner.connections_in_progress()}) " + ( f"(slots={allocations.free}/{allocations.slots} free) " if (allocations := device.scanner.get_allocations()) else "" ) + f"(score={device.score_connection_path(rssi_diff)})" ) for device in sorted_devices ), ) for device in sorted_devices: if backend := self._async_get_backend_for_ble_device( manager, device.scanner, device.ble_device ): return backend # Check if all registered scanners are passive-only if scanners := manager.async_current_scanners(): has_active_capable_scanner = any( scanner.connectable for scanner in scanners ) if not has_active_capable_scanner: scanner_names = [scanner.name for scanner in scanners] msg = ( f"{address}: No connectable Bluetooth adapters. " f"Shelly devices are passive-only and cannot connect. " f"Need local Bluetooth adapter or ESPHome proxy. " f"Available: {', '.join(scanner_names)}" ) raise BleakError(msg) msg = ( "No backend with an available connection slot that can reach address" f" {address} was found" ) # Best-effort diagnostics; never let a diagnostics failure mask the # original "no backend" error we are about to raise. try: diagnostics = manager.async_address_reachability_diagnostics( address, BluetoothReachabilityIntent.CONNECTION ) except Exception: # pylint: disable=broad-except _LOGGER.exception("Error building reachability diagnostics for %s", address) else: msg = f"{msg}: {diagnostics}" raise BleakError(msg) async def disconnect(self) -> None: """Disconnect from the device.""" if self._backend is None: return await self._backend.disconnect() Bluetooth-Devices-habluetooth-75cbe37/templates/000077500000000000000000000000001521117704500217755ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-75cbe37/templates/CHANGELOG.md.j2000066400000000000000000000012351521117704500241210ustar00rootroot00000000000000# Changelog {%- for version, release in context.history.released.items() %} ## {{ version.as_tag() }} ({{ release.tagged_date.strftime("%Y-%m-%d") }}) {%- for category, commits in release["elements"].items() %} {# Category title: Breaking, Fix, Documentation #} ### {{ category | capitalize }} {# List actual changes in the category #} {%- for commit in commits %} {% if commit is not none and commit.descriptions is defined %} - {{ commit.descriptions[0] | capitalize }} ([`{{ commit.short_hash }}`]({{ commit.hexsha | commit_hash_url }})) {% endif %} {%- endfor %}{# for commit #} {%- endfor %}{# for category, commits #} {%- endfor %}{# for version, release #} Bluetooth-Devices-habluetooth-75cbe37/tests/000077500000000000000000000000001521117704500211415ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-75cbe37/tests/__init__.py000066400000000000000000000216621521117704500232610ustar00rootroot00000000000000import asyncio import time import types from collections.abc import Generator from contextlib import contextmanager from datetime import UTC, datetime from functools import partial from typing import Any from unittest.mock import MagicMock, patch from bleak.backends.scanner import ( AdvertisementData, AdvertisementDataCallback, BLEDevice, ) from bluetooth_data_tools import monotonic_time_coarse from habluetooth import BaseHaRemoteScanner, get_manager from habluetooth.models import BluetoothServiceInfoBleak utcnow = partial(datetime.now, UTC) HCI0_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:00" HCI1_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:11" NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:FF" _MONOTONIC_RESOLUTION = time.get_clock_info("monotonic").resolution ADVERTISEMENT_DATA_DEFAULTS = { "local_name": "Unknown", "manufacturer_data": {}, "service_data": {}, "service_uuids": [], "rssi": -127, "platform_data": ((),), "tx_power": -127, } BLE_DEVICE_DEFAULTS = { "name": None, "details": None, } def generate_advertisement_data(**kwargs: Any) -> AdvertisementData: """Generate advertisement data with defaults.""" new = kwargs.copy() for key, value in ADVERTISEMENT_DATA_DEFAULTS.items(): new.setdefault(key, value) return AdvertisementData(**new) def generate_ble_device( address: str | None = None, name: str | None = None, details: Any | None = None, **kwargs: Any, ) -> BLEDevice: """ Generate a BLEDevice with defaults. Extra kwargs (e.g. legacy ``rssi``) are silently dropped — bleak 3.0 removed those fields from BLEDevice, and passing them now warns. """ new: dict[str, Any] = {} if address is not None: new["address"] = address if name is not None: new["name"] = name if details is not None: new["details"] = details for key, value in BLE_DEVICE_DEFAULTS.items(): new.setdefault(key, value) # Only forward kwargs BLEDevice still accepts in bleak 3.0+. for key in ("address", "name", "details"): if key in kwargs: new[key] = kwargs[key] 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 class MockBleakScanner: """ Drop-in fake for ``bleak.BleakScanner`` that satisfies ``HaScanner``. Provides the four attributes ``HaScanner`` actually touches (``start`` / ``stop`` / ``discovered_devices`` / ``register_detection_callback``) plus a ``_backend`` namespace with ``_scanning_mode`` for the active-window toggle path. Subclass to override individual methods for failure injection (e.g. ``async def start(self): raise BleakError(...)``); each instance owns its own ``_backend`` so mutations don't leak between tests. """ def __init__(self) -> None: # Typed as ``Any`` so subclasses can substitute custom backend # objects (e.g. for AttributeError injection tests). self._backend: Any = types.SimpleNamespace(_scanning_mode="passive") async def start(self) -> None: """No-op start.""" async def stop(self) -> None: """No-op stop.""" @property def discovered_devices(self) -> list[BLEDevice]: """No devices by default; override for fixture-style fakes.""" return [] @property def discovered_devices_and_advertisement_data( self, ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: """No discoveries by default; override for fixture-style fakes.""" return {} def register_detection_callback(self, callback: AdvertisementDataCallback) -> None: """No-op detection-callback registration.""" class InjectableRemoteScanner(BaseHaRemoteScanner): """ Remote scanner that exposes test-only ``inject_advertisement`` helpers. Replaces the near-identical ``FakeScanner`` / ``_SeedFakeScanner`` classes that used to live in test_base_scanner / test_name_cache / test_wrappers. ``device.details`` (when present) is merged into the advertisement ``details`` dict so callers that route via DBus paths (e.g. test_wrappers) keep the path key, while callers that pass a ``details``-less device (the default) see only the test marker. """ def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData, now: float | None = None, ) -> None: """Inject an advertisement through the scanner's normal entry point.""" 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 or {}) | {"scanner_specific_data": "test"}, now if now is not None else monotonic_time_coarse(), ) def inject_raw_advertisement( self, address: str, rssi: int, adv: bytes, now: float | None = None, ) -> None: """Inject a raw advertisement through the scanner's normal entry point.""" self._async_on_raw_advertisement( address, rssi, adv, {"scanner_specific_data": "test"}, now if now is not None else monotonic_time_coarse(), ) def patch_bleak_scanner_factory(factory: Any) -> Any: """ Patch ``OriginalBleakScanner`` to call ``factory(*args, **kwargs)``. Convenience wrapper to avoid the noisy ``patch(..., side_effect=lambda *_a, **_kw: factory())`` ritual used at every mock-scanner site. """ return patch( "habluetooth.scanner.OriginalBleakScanner", side_effect=lambda *_a, **_kw: factory(), ) def inject_advertisement(device: BLEDevice, adv: AdvertisementData) -> None: """Inject an advertisement into the manager.""" return inject_advertisement_with_source(device, adv, "local") def inject_advertisement_with_source( device: BLEDevice, adv: AdvertisementData, source: str ) -> None: """Inject an advertisement into the manager from a specific source.""" inject_advertisement_with_time_and_source(device, adv, time.monotonic(), source) def inject_advertisement_with_time_and_source( device: BLEDevice, adv: AdvertisementData, time: float, source: str, ) -> None: """Inject an advertisement into the manager from a specific source at a time.""" inject_advertisement_with_time_and_source_connectable( device, adv, time, source, True ) def inject_advertisement_with_time_and_source_connectable( device: BLEDevice, adv: AdvertisementData, time: float, source: str, connectable: bool, ) -> None: """ Inject an advertisement into the manager from a specific source at a time. As well as and connectable status. """ manager = get_manager() manager.scanner_adv_received( BluetoothServiceInfoBleak( name=adv.local_name or device.name or device.address, address=device.address, rssi=adv.rssi, manufacturer_data=adv.manufacturer_data, service_data=adv.service_data, service_uuids=adv.service_uuids, source=source, device=device, advertisement=adv, connectable=connectable, time=time, tx_power=adv.tx_power, ) ) @contextmanager def patch_discovered_devices( mock_discovered: list[BLEDevice], ) -> Generator[None, None, None]: """Mock the combined best path to discovered devices from all the scanners.""" manager = get_manager() original_all_history = manager._all_history original_connectable_history = manager._connectable_history manager._connectable_history = {} manager._all_history = { device.address: MagicMock(device=device) for device in mock_discovered } yield manager._all_history = original_all_history manager._connectable_history = original_connectable_history Bluetooth-Devices-habluetooth-75cbe37/tests/channels/000077500000000000000000000000001521117704500227345ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-75cbe37/tests/channels/__init__.py000066400000000000000000000000001521117704500250330ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-75cbe37/tests/channels/test_bluez.py000066400000000000000000001514211521117704500254720ustar00rootroot00000000000000"""Tests for the BlueZ management API module.""" from __future__ import annotations import asyncio import logging from typing import Any from unittest.mock import Mock, patch import pytest from btsocket.btmgmt_socket import BluetoothSocketError from habluetooth.channels.bluez import ( BluetoothMGMTProtocol, MGMTBluetoothCtl, ) from habluetooth.const import ( BDADDR_LE_PUBLIC, BDADDR_LE_RANDOM, FAST_CONN_LATENCY, FAST_CONN_TIMEOUT, FAST_MAX_CONN_INTERVAL, FAST_MIN_CONN_INTERVAL, MEDIUM_CONN_LATENCY, MEDIUM_CONN_TIMEOUT, MEDIUM_MAX_CONN_INTERVAL, MEDIUM_MIN_CONN_INTERVAL, ConnectParams, ) from habluetooth.scanner import HaScanner class MockHaScanner(HaScanner): """Mock HaScanner for testing with Cython.""" def __init__(self): """Initialize without calling parent __init__ to avoid BleakScanner setup.""" self.source = "test" self.connectable = True # Mock the method that will be called self._async_on_raw_bluez_advertisement: Any = Mock() @pytest.fixture def event_loop(): """Create and manage event loop for tests.""" loop = asyncio.new_event_loop() yield loop loop.close() @pytest.fixture def mock_scanner() -> MockHaScanner: """Create a mock scanner for testing.""" return MockHaScanner() @pytest.fixture def mock_transport() -> Mock: """Create a mock transport.""" transport = Mock() transport.write = Mock() # Create a mock socket for direct writes mock_socket = Mock() mock_socket.send = Mock(return_value=6) # Default to successful send transport.get_extra_info = Mock(return_value=mock_socket) return transport def test_connection_made( event_loop: asyncio.AbstractEventLoop, mock_transport: Mock ) -> None: """Test connection_made sets up the protocol correctly.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) protocol.connection_made(mock_transport) assert protocol.transport is mock_transport assert future.done() assert future.result() is None def test_connection_lost( event_loop: asyncio.AbstractEventLoop, mock_transport: Mock ) -> None: """Test connection_lost handles disconnection.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) protocol.connection_made(mock_transport) # Test with exception protocol.connection_lost(Exception("Test error")) assert protocol.transport is None on_connection_lost.assert_called_once() def test_connection_lost_no_exception( event_loop: asyncio.AbstractEventLoop, mock_transport: Mock, caplog: pytest.LogCaptureFixture, ) -> None: """Test connection_lost without exception.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) protocol.connection_made(mock_transport) # Test without exception protocol.connection_lost(None) assert "Bluetooth management socket connection closed" in caplog.text def test_data_received_device_found( event_loop: asyncio.AbstractEventLoop, mock_scanner: MockHaScanner ) -> None: """Test data_received handles DEVICE_FOUND event.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {0: mock_scanner} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a DEVICE_FOUND event (event_code 0x0012). Header layout is # event_code (2), controller_idx (2), param_len (2); params layout # is address (6), address_type (1), rssi (1), flags (4), # ad_data_len (2), then ad_data. ad_data = b"\x02\x01\x06" # Simple advertisement data param_len = 6 + 1 + 1 + 4 + 2 + len(ad_data) header = b"\x12\x00" # DEVICE_FOUND header += b"\x00\x00" # controller_idx = 0 header += param_len.to_bytes(2, "little") params = b"\xaa\xbb\xcc\xdd\xee\xff" # address (reversed) params += b"\x01" # address_type params += b"\xc8" # rssi = -56 (200 - 256) params += b"\x00\x00\x00\x00" # flags params += len(ad_data).to_bytes(2, "little") # ad_data_len params += ad_data protocol.data_received(header + params) mock_scanner._async_on_raw_bluez_advertisement.assert_called_once_with( b"\xaa\xbb\xcc\xdd\xee\xff", 1, -56, 0, ad_data, ) def test_data_received_adv_monitor_device_found( event_loop: asyncio.AbstractEventLoop, mock_scanner: MockHaScanner ) -> None: """Test data_received handles ADV_MONITOR_DEVICE_FOUND event.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {0: mock_scanner} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create an ADV_MONITOR_DEVICE_FOUND event (event_code 0x002F) # Has 2 extra bytes at the beginning of params ad_data = b"\x02\x01\x06" param_len = 2 + 6 + 1 + 1 + 4 + 2 + len(ad_data) header = b"\x2f\x00" # ADV_MONITOR_DEVICE_FOUND header += b"\x00\x00" # controller_idx = 0 header += param_len.to_bytes(2, "little") params = b"\x00\x00" # 2 extra bytes params += b"\xaa\xbb\xcc\xdd\xee\xff" # address params += b"\x02" # address_type params += b"\x64" # rssi = 100 (positive, no conversion needed) params += b"\x00\x00\x00\x00" # flags params += len(ad_data).to_bytes(2, "little") params += ad_data protocol.data_received(header + params) mock_scanner._async_on_raw_bluez_advertisement.assert_called_once_with( b"\xaa\xbb\xcc\xdd\xee\xff", 2, 100, 0, ad_data, ) def test_data_received_cmd_complete_success( event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture, ) -> None: """Test data_received handles successful MGMT_EV_CMD_COMPLETE.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a CMD_COMPLETE event for LOAD_CONN_PARAM header = b"\x01\x00" # MGMT_EV_CMD_COMPLETE header += b"\x00\x00" # controller_idx = 0 header += b"\x03\x00" # param_len = 3 params = b"\x35\x00" # opcode = MGMT_OP_LOAD_CONN_PARAM params += b"\x00" # status = 0 (success) protocol.data_received(header + params) assert "Connection parameters loaded successfully" in caplog.text def test_data_received_cmd_complete_failure( event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture, ) -> None: """Test data_received handles failed MGMT_EV_CMD_COMPLETE.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a CMD_COMPLETE event with failure header = b"\x01\x00" # MGMT_EV_CMD_COMPLETE header += b"\x01\x00" # controller_idx = 1 header += b"\x03\x00" # param_len = 3 params = b"\x35\x00" # opcode = MGMT_OP_LOAD_CONN_PARAM params += b"\x0c" # status = 12 (Not Supported) protocol.data_received(header + params) assert "Failed to load conn params: status=12" in caplog.text def test_data_received_cmd_status( event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture ) -> None: """Test data_received handles MGMT_EV_CMD_STATUS.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a CMD_STATUS event header = b"\x02\x00" # MGMT_EV_CMD_STATUS header += b"\x00\x00" # controller_idx = 0 header += b"\x03\x00" # param_len = 3 params = b"\x35\x00" # opcode = MGMT_OP_LOAD_CONN_PARAM params += b"\x01" # status = 1 (Unknown Command) protocol.data_received(header + params) assert "Failed to load conn params: status=1" in caplog.text def test_data_received_partial_data( event_loop: asyncio.AbstractEventLoop, mock_scanner: MockHaScanner ) -> None: """Test data_received handles partial data correctly.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {0: mock_scanner} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a DEVICE_FOUND event but send it in chunks ad_data = b"\x02\x01\x06" param_len = 6 + 1 + 1 + 4 + 2 + len(ad_data) full_data = b"\x12\x00\x00\x00" + param_len.to_bytes(2, "little") full_data += b"\xaa\xbb\xcc\xdd\xee\xff\x01\xc8\x00\x00\x00\x00" full_data += len(ad_data).to_bytes(2, "little") + ad_data # Send header first protocol.data_received(full_data[:6]) mock_scanner._async_on_raw_bluez_advertisement.assert_not_called() # Send rest of data protocol.data_received(full_data[6:]) mock_scanner._async_on_raw_bluez_advertisement.assert_called_once() def test_data_received_partial_data_split_in_params( event_loop: asyncio.AbstractEventLoop, mock_scanner: MockHaScanner ) -> None: """Test data_received handles data split in the middle of params.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {0: mock_scanner} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a DEVICE_FOUND event ad_data = b"\x02\x01\x06\x03\xff\x00\x01" # Longer ad data param_len = 6 + 1 + 1 + 4 + 2 + len(ad_data) full_data = b"\x12\x00\x00\x00" + param_len.to_bytes(2, "little") full_data += b"\xaa\xbb\xcc\xdd\xee\xff\x01\xc8\x00\x00\x00\x00" full_data += len(ad_data).to_bytes(2, "little") + ad_data # Split in the middle of the address protocol.data_received(full_data[:10]) # Header + part of address mock_scanner._async_on_raw_bluez_advertisement.assert_not_called() # Send rest of data protocol.data_received(full_data[10:]) mock_scanner._async_on_raw_bluez_advertisement.assert_called_once_with( b"\xaa\xbb\xcc\xdd\xee\xff", 1, -56, 0, ad_data, ) def test_data_received_multiple_small_chunks( event_loop: asyncio.AbstractEventLoop, mock_scanner: MockHaScanner ) -> None: """Test data_received handles data sent in many small chunks.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {0: mock_scanner} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a DEVICE_FOUND event ad_data = b"\x02\x01\x06" param_len = 6 + 1 + 1 + 4 + 2 + len(ad_data) full_data = b"\x12\x00\x00\x00" + param_len.to_bytes(2, "little") full_data += b"\xaa\xbb\xcc\xdd\xee\xff\x01\xc8\x00\x00\x00\x00" full_data += len(ad_data).to_bytes(2, "little") + ad_data # Send data byte by byte for i in range(len(full_data)): protocol.data_received(full_data[i : i + 1]) if i < len(full_data) - 1: mock_scanner._async_on_raw_bluez_advertisement.assert_not_called() # After all bytes are sent, callback should be called once mock_scanner._async_on_raw_bluez_advertisement.assert_called_once() def test_data_received_multiple_events_in_one_chunk( event_loop: asyncio.AbstractEventLoop, mock_scanner: Mock, caplog: pytest.LogCaptureFixture, ) -> None: """Test data_received handles multiple events in one data chunk.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {0: mock_scanner} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create two events: a DEVICE_FOUND and a CMD_COMPLETE ad_data = b"\x02\x01\x06" param_len1 = 6 + 1 + 1 + 4 + 2 + len(ad_data) event1 = b"\x12\x00\x00\x00" + param_len1.to_bytes(2, "little") event1 += b"\xaa\xbb\xcc\xdd\xee\xff\x01\xc8\x00\x00\x00\x00" event1 += len(ad_data).to_bytes(2, "little") + ad_data event2 = b"\x01\x00\x00\x00\x03\x00" # CMD_COMPLETE header event2 += b"\x35\x00\x00" # LOAD_CONN_PARAM success # Send both events in one chunk protocol.data_received(event1 + event2) # Both events should be processed mock_scanner._async_on_raw_bluez_advertisement.assert_called_once() assert "Connection parameters loaded successfully" in caplog.text def test_data_received_partial_then_multiple_events( event_loop: asyncio.AbstractEventLoop, mock_scanner: MockHaScanner ) -> None: """Test partial data followed by multiple complete events.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {0: mock_scanner} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # First event (DEVICE_FOUND) ad_data1 = b"\x02\x01\x06" param_len1 = 6 + 1 + 1 + 4 + 2 + len(ad_data1) event1 = b"\x12\x00\x00\x00" + param_len1.to_bytes(2, "little") event1 += b"\x11\x22\x33\x44\x55\x66\x01\xc8\x00\x00\x00\x00" event1 += len(ad_data1).to_bytes(2, "little") + ad_data1 # Second event (ADV_MONITOR_DEVICE_FOUND) ad_data2 = b"\x03\xff\x00\x01" param_len2 = 2 + 6 + 1 + 1 + 4 + 2 + len(ad_data2) event2 = b"\x2f\x00\x00\x00" + param_len2.to_bytes(2, "little") event2 += b"\x00\x00" # Extra 2 bytes event2 += b"\x77\x88\x99\xaa\xbb\xcc\x02\x64\x00\x00\x00\x00" event2 += len(ad_data2).to_bytes(2, "little") + ad_data2 # Send partial first event protocol.data_received(event1[:15]) mock_scanner._async_on_raw_bluez_advertisement.assert_not_called() # Send rest of first event + second event protocol.data_received(event1[15:] + event2) # Both callbacks should be called assert mock_scanner._async_on_raw_bluez_advertisement.call_count == 2 calls = mock_scanner._async_on_raw_bluez_advertisement.call_args_list # First call assert calls[0][0] == ( b"\x11\x22\x33\x44\x55\x66", 1, -56, 0, ad_data1, ) # Second call assert calls[1][0] == ( b"\x77\x88\x99\xaa\xbb\xcc", 2, 100, 0, ad_data2, ) def test_data_received_cmd_complete_different_opcode( event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture ) -> None: """Test data_received handles CMD_COMPLETE for different opcodes.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a CMD_COMPLETE event for a different opcode (e.g., 0x0004 - Add UUID) header = b"\x01\x00" # MGMT_EV_CMD_COMPLETE header += b"\x00\x00" # controller_idx = 0 header += b"\x03\x00" # param_len = 3 params = b"\x04\x00" # opcode = 0x0004 (not MGMT_OP_LOAD_CONN_PARAM) params += b"\x00" # status = 0 (success) protocol.data_received(header + params) # Should not log anything about connection parameters assert "Connection parameters" not in caplog.text def test_data_received_cmd_status_different_opcode( event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture ) -> None: """Test data_received handles CMD_STATUS for different opcodes.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a CMD_STATUS event for a different opcode header = b"\x02\x00" # MGMT_EV_CMD_STATUS header += b"\x00\x00" # controller_idx = 0 header += b"\x03\x00" # param_len = 3 params = b"\x05\x00" # opcode = 0x0005 (not MGMT_OP_LOAD_CONN_PARAM) params += b"\x01" # status = 1 (failure) protocol.data_received(header + params) # Should not log anything about connection parameters assert "conn params" not in caplog.text def test_data_received_cmd_complete_short_params( event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture ) -> None: """Test data_received handles CMD_COMPLETE with param_len < 3.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a CMD_COMPLETE event with param_len < 3 header = b"\x01\x00" # MGMT_EV_CMD_COMPLETE header += b"\x00\x00" # controller_idx = 0 header += b"\x02\x00" # param_len = 2 (too short to contain opcode + status) params = b"\x00\x00" # Just 2 bytes protocol.data_received(header + params) # Should not log anything (no opcode to check) assert "conn params" not in caplog.text def test_data_received_cmd_status_param_len_1( event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture ) -> None: """Test data_received handles CMD_STATUS with param_len = 1.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a CMD_STATUS event with param_len = 1 header = b"\x02\x00" # MGMT_EV_CMD_STATUS header += b"\x00\x00" # controller_idx = 0 header += b"\x01\x00" # param_len = 1 (too short) params = b"\x00" # Just 1 byte protocol.data_received(header + params) # Should not log anything (no opcode to check) assert "conn params" not in caplog.text def test_data_received_cmd_complete_param_len_0( event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture ) -> None: """Test data_received handles CMD_COMPLETE with param_len = 0.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a CMD_COMPLETE event with param_len = 0 header = b"\x01\x00" # MGMT_EV_CMD_COMPLETE header += b"\x00\x00" # controller_idx = 0 header += b"\x00\x00" # param_len = 0 (no params at all) protocol.data_received(header) # Should not log anything (no opcode to check) assert "conn params" not in caplog.text def test_data_received_unknown_event(event_loop: asyncio.AbstractEventLoop) -> None: """Test data_received ignores unknown events.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create an unknown event header = b"\xff\x00" # Unknown event code header += b"\x00\x00" # controller_idx = 0 header += b"\x04\x00" # param_len = 4 params = b"\x00\x00\x00\x00" # Should not raise any exception protocol.data_received(header + params) def test_data_received_no_scanner_for_controller( event_loop: asyncio.AbstractEventLoop, ) -> None: """Test data_received handles missing scanner gracefully.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} # No scanner for controller 0 on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Create a DEVICE_FOUND event for controller 0 ad_data = b"\x02\x01\x06" param_len = 6 + 1 + 1 + 4 + 2 + len(ad_data) header = b"\x12\x00\x00\x00" + param_len.to_bytes(2, "little") params = b"\xaa\xbb\xcc\xdd\xee\xff\x01\xc8\x00\x00\x00\x00" params += len(ad_data).to_bytes(2, "little") + ad_data # Should not raise any exception protocol.data_received(header + params) @pytest.mark.asyncio async def test_setup_success() -> None: """Test successful setup.""" mock_sock = Mock() mock_sock.fileno.return_value = 1 # Mock socket file descriptor mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport # Mock the future that gets created and set loop = asyncio.get_running_loop() mock_future = loop.create_future() async def mock_create_connection(*args, **kwargs): # Set the future result to simulate connection made mock_future.set_result(None) return mock_transport, mock_protocol with ( patch("habluetooth.channels.bluez.btmgmt_socket.open", return_value=mock_sock), patch.object( asyncio.get_running_loop(), "_create_connection_transport", side_effect=mock_create_connection, ), patch.object( asyncio.get_running_loop(), "create_future", side_effect=[ mock_future, loop.create_future(), ], # First for connection, second for on_connection_lost ), patch.object( MGMTBluetoothCtl, "_check_capabilities", return_value=True, # Mock successful capability check ), ): ctl = MGMTBluetoothCtl(5.0, {}) await ctl.setup() assert ctl.sock is mock_sock assert ctl.protocol is mock_protocol assert ctl._reconnect_task is not None # Clean up ctl._reconnect_task.cancel() with pytest.raises(asyncio.CancelledError): await ctl._reconnect_task @pytest.mark.asyncio async def test_setup_timeout() -> None: """Test setup timeout.""" mock_sock = Mock() async def slow_connect(*args, **kwargs): await asyncio.sleep(10) with ( patch("habluetooth.channels.bluez.btmgmt_socket.open", return_value=mock_sock), patch.object( asyncio.get_running_loop(), "_create_connection_transport", side_effect=slow_connect, ), patch("habluetooth.channels.bluez.btmgmt_socket.close") as mock_close, ): ctl = MGMTBluetoothCtl(0.1, {}) with pytest.raises(TimeoutError): await ctl.setup() mock_close.assert_called_once_with(mock_sock) @pytest.mark.asyncio async def test_load_conn_params_fast() -> None: """Test loading fast connection parameters.""" mock_sock = Mock() mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport # Mock the _write_to_socket method mock_protocol._write_to_socket = Mock() ctl = MGMTBluetoothCtl(5.0, {}) ctl.protocol = mock_protocol ctl.sock = mock_sock result = ctl.load_conn_params( 0, # adapter_idx "AA:BB:CC:DD:EE:FF", # address BDADDR_LE_PUBLIC, # address_type ConnectParams.FAST, ) assert result is True # Verify the command was sent mock_protocol._write_to_socket.assert_called_once() call_args = mock_protocol._write_to_socket.call_args[0][0] # Check header (6 bytes) assert call_args[0:2] == b"\x35\x00" # MGMT_OP_LOAD_CONN_PARAM assert call_args[2:4] == b"\x00\x00" # adapter_idx = 0 assert call_args[4:6] == b"\x11\x00" # param_len = 17 (2 + 15) # Check command data assert call_args[6:8] == b"\x01\x00" # param_count = 1 assert call_args[8:14] == b"\xff\xee\xdd\xcc\xbb\xaa" # address (reversed) assert call_args[14] == BDADDR_LE_PUBLIC # address_type assert call_args[15:17] == FAST_MIN_CONN_INTERVAL.to_bytes(2, "little") assert call_args[17:19] == FAST_MAX_CONN_INTERVAL.to_bytes(2, "little") assert call_args[19:21] == FAST_CONN_LATENCY.to_bytes(2, "little") assert call_args[21:23] == FAST_CONN_TIMEOUT.to_bytes(2, "little") @pytest.mark.asyncio async def test_load_conn_params_medium() -> None: """Test loading medium connection parameters.""" mock_sock = Mock() mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport # Mock the _write_to_socket method mock_protocol._write_to_socket = Mock() ctl = MGMTBluetoothCtl(5.0, {}) ctl.protocol = mock_protocol ctl.sock = mock_sock result = ctl.load_conn_params( 1, # adapter_idx "11:22:33:44:55:66", # address BDADDR_LE_RANDOM, # address_type ConnectParams.MEDIUM, ) assert result is True # Verify the command was sent mock_protocol._write_to_socket.assert_called_once() call_args = mock_protocol._write_to_socket.call_args[0][0] # Check header assert call_args[0:2] == b"\x35\x00" # MGMT_OP_LOAD_CONN_PARAM assert call_args[2:4] == b"\x01\x00" # adapter_idx = 1 # Check parameters assert call_args[8:14] == b"\x66\x55\x44\x33\x22\x11" # address (reversed) assert call_args[14] == BDADDR_LE_RANDOM # address_type assert call_args[15:17] == MEDIUM_MIN_CONN_INTERVAL.to_bytes(2, "little") assert call_args[17:19] == MEDIUM_MAX_CONN_INTERVAL.to_bytes(2, "little") assert call_args[19:21] == MEDIUM_CONN_LATENCY.to_bytes(2, "little") assert call_args[21:23] == MEDIUM_CONN_TIMEOUT.to_bytes(2, "little") def test_load_conn_params_no_protocol(caplog: pytest.LogCaptureFixture) -> None: """Test load_conn_params when protocol is not connected.""" ctl = MGMTBluetoothCtl(5.0, {}) result = ctl.load_conn_params( 0, "AA:BB:CC:DD:EE:FF", BDADDR_LE_PUBLIC, ConnectParams.FAST, ) assert result is False assert "Cannot load conn params: no connection" in caplog.text def test_load_conn_params_invalid_address(caplog: pytest.LogCaptureFixture) -> None: """Test load_conn_params with invalid MAC address.""" mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport ctl = MGMTBluetoothCtl(5.0, {}) ctl.protocol = mock_protocol # Test with too short address result = ctl.load_conn_params( 0, "AA:BB", BDADDR_LE_PUBLIC, ConnectParams.FAST, ) assert result is False assert "Invalid MAC address: AA:BB" in caplog.text def test_load_conn_params_transport_error(caplog: pytest.LogCaptureFixture) -> None: """Test load_conn_params with transport write error.""" mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_socket = Mock() mock_socket.send.side_effect = Exception("Transport error") mock_transport.get_extra_info = Mock(return_value=mock_socket) mock_protocol.transport = mock_transport mock_protocol._sock = mock_socket mock_protocol._write_to_socket = Mock(side_effect=Exception("Transport error")) ctl = MGMTBluetoothCtl(5.0, {}) ctl.protocol = mock_protocol result = ctl.load_conn_params( 0, "AA:BB:CC:DD:EE:FF", BDADDR_LE_PUBLIC, ConnectParams.FAST, ) assert result is False assert "Failed to load conn params" in caplog.text @pytest.mark.asyncio async def test_load_conn_params_explicit() -> None: """Test loading explicit connection parameters.""" mock_sock = Mock() mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport mock_protocol._write_to_socket = Mock() ctl = MGMTBluetoothCtl(5.0, {}) ctl.protocol = mock_protocol ctl.sock = mock_sock result = ctl.load_conn_params_explicit( 0, # adapter_idx "AA:BB:CC:DD:EE:FF", # address BDADDR_LE_PUBLIC, # address_type 800, # min_interval 800, # max_interval 0, # latency 300, # timeout ) assert result is True mock_protocol._write_to_socket.assert_called_once() call_args = mock_protocol._write_to_socket.call_args[0][0] # Check header (6 bytes) assert call_args[0:2] == b"\x35\x00" # MGMT_OP_LOAD_CONN_PARAM assert call_args[2:4] == b"\x00\x00" # adapter_idx = 0 assert call_args[4:6] == b"\x11\x00" # param_len = 17 (2 + 15) # Check command data assert call_args[6:8] == b"\x01\x00" # param_count = 1 assert call_args[8:14] == b"\xff\xee\xdd\xcc\xbb\xaa" # address (reversed) assert call_args[14] == BDADDR_LE_PUBLIC # address_type assert call_args[15:17] == (800).to_bytes(2, "little") # min_interval assert call_args[17:19] == (800).to_bytes(2, "little") # max_interval assert call_args[19:21] == (0).to_bytes(2, "little") # latency assert call_args[21:23] == (300).to_bytes(2, "little") # timeout def test_load_conn_params_explicit_no_protocol( caplog: pytest.LogCaptureFixture, ) -> None: """Test load_conn_params_explicit when protocol is not connected.""" ctl = MGMTBluetoothCtl(5.0, {}) result = ctl.load_conn_params_explicit( 0, "AA:BB:CC:DD:EE:FF", BDADDR_LE_PUBLIC, 800, 800, 0, 300 ) assert result is False assert "Cannot load conn params: no connection" in caplog.text def test_load_conn_params_explicit_invalid_address( caplog: pytest.LogCaptureFixture, ) -> None: """Test load_conn_params_explicit with invalid MAC address.""" mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport ctl = MGMTBluetoothCtl(5.0, {}) ctl.protocol = mock_protocol result = ctl.load_conn_params_explicit( 0, "AA:BB", BDADDR_LE_PUBLIC, 800, 800, 0, 300 ) assert result is False assert "Invalid MAC address: AA:BB" in caplog.text def test_load_conn_params_explicit_transport_error( caplog: pytest.LogCaptureFixture, ) -> None: """Test load_conn_params_explicit with transport write error.""" mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport mock_protocol._write_to_socket = Mock(side_effect=Exception("Transport error")) ctl = MGMTBluetoothCtl(5.0, {}) ctl.protocol = mock_protocol result = ctl.load_conn_params_explicit( 0, "AA:BB:CC:DD:EE:FF", BDADDR_LE_PUBLIC, 800, 800, 0, 300 ) assert result is False assert "Failed to load conn params" in caplog.text def test_kernel_bug_workaround_send_returns_zero( event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture ) -> None: """Test that the kernel bug workaround handles send returning 0.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) # Create a mock socket that returns 0 (kernel bug behavior) mock_socket = Mock() mock_socket.send = Mock(return_value=0) protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_socket ) # Send some data test_data = b"\x25\x00\x00\x00\x00\x00" with caplog.at_level(logging.DEBUG): protocol._write_to_socket(test_data) # Verify the send was called and the workaround logged mock_socket.send.assert_called_once_with(test_data) assert "kernel bug fix" in caplog.text def test_kernel_bug_workaround_send_raises_exception( event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture ) -> None: """Test that _write_to_socket handles and re-raises exceptions.""" future = event_loop.create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) # Create a mock socket that raises an exception mock_socket = Mock() mock_socket.send = Mock(side_effect=OSError("Socket error")) protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_socket ) # Send some data and expect the exception to be re-raised test_data = b"\x25\x00\x00\x00\x00\x00" with pytest.raises(OSError, match="Socket error"): protocol._write_to_socket(test_data) # Verify the error was logged; the traceback carries the OSError text. assert "Failed to write to mgmt socket" in caplog.text assert "Socket error" in caplog.text mock_socket.send.assert_called_once_with(test_data) def test_close() -> None: """Test close method.""" mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport mock_sock = Mock() mock_reconnect_task = Mock() ctl = MGMTBluetoothCtl(5.0, {}) ctl.protocol = mock_protocol ctl.sock = mock_sock ctl._reconnect_task = mock_reconnect_task with patch("habluetooth.channels.bluez.btmgmt_socket.close") as mock_close: ctl.close() mock_reconnect_task.cancel.assert_called_once() mock_transport.close.assert_called_once() mock_close.assert_called_once_with(mock_sock) assert ctl.protocol is None def test_close_no_protocol() -> None: """Test close when protocol is not set.""" ctl = MGMTBluetoothCtl(5.0, {}) # Should not raise any exception with patch("habluetooth.channels.bluez.btmgmt_socket.close"): ctl.close() @pytest.mark.asyncio async def test_on_connection_lost() -> None: """Test _on_connection_lost callback.""" ctl = MGMTBluetoothCtl(5.0, {}) loop = asyncio.get_running_loop() ctl._on_connection_lost_future = loop.create_future() ctl._on_connection_lost() # _on_connection_lost sets the future to None after setting result assert ctl._on_connection_lost_future is None @pytest.mark.asyncio async def test_on_connection_lost_during_shutdown( caplog: pytest.LogCaptureFixture, ) -> None: """Test _on_connection_lost callback during shutdown.""" ctl = MGMTBluetoothCtl(5.0, {}) loop = asyncio.get_running_loop() ctl._on_connection_lost_future = loop.create_future() ctl._shutting_down = True with caplog.at_level(logging.DEBUG): ctl._on_connection_lost() # Should log shutdown message assert "Bluetooth management socket connection lost during shutdown" in caplog.text # Should not log reconnecting message assert "reconnecting" not in caplog.text # _on_connection_lost sets the future to None after setting result assert ctl._on_connection_lost_future is None @pytest.mark.asyncio async def test_reconnect_task() -> None: """Test reconnect_task behavior.""" mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport establish_count = 0 ctl = MGMTBluetoothCtl(5.0, {}) async def mock_establish_connection() -> None: nonlocal establish_count establish_count += 1 if establish_count == 1: # First call succeeds ctl.protocol = mock_protocol ctl._on_connection_lost_future = asyncio.get_running_loop().create_future() elif establish_count == 2: # Second call fails msg = "Test error" raise BluetoothSocketError(msg) else: # Stop the test raise asyncio.CancelledError with patch.object( ctl, "_establish_connection", side_effect=mock_establish_connection ): # Start the reconnect task task = asyncio.create_task(ctl.reconnect_task()) # Trigger reconnection by calling _on_connection_lost await asyncio.sleep(0.1) ctl._on_connection_lost() # Wait for reconnection attempt await asyncio.sleep(1.5) # Cancel the task task.cancel() with pytest.raises(asyncio.CancelledError): await task assert establish_count >= 2 @pytest.mark.asyncio async def test_reconnect_task_timeout() -> None: """Test reconnect_task with connection timeout.""" async def mock_establish_connection() -> None: msg = "Connection timeout" raise TimeoutError(msg) ctl = MGMTBluetoothCtl(5.0, {}) ctl._on_connection_lost_future = None with patch.object( ctl, "_establish_connection", side_effect=mock_establish_connection ): # Run reconnect_task briefly task = asyncio.create_task(ctl.reconnect_task()) await asyncio.sleep(0.1) # Cancel the task task.cancel() with pytest.raises(asyncio.CancelledError): await task @pytest.mark.asyncio async def test_reconnect_task_shutdown() -> None: """Test reconnect_task exits when shutting down.""" ctl = MGMTBluetoothCtl(5.0, {}) loop = asyncio.get_running_loop() establish_called = False async def mock_establish_connection() -> None: nonlocal establish_called establish_called = True # Should not be called since we're shutting down msg = "Should not be called" raise AssertionError(msg) with patch.object( ctl, "_establish_connection", side_effect=mock_establish_connection ): # Set up connection lost future ctl._on_connection_lost_future = loop.create_future() # Start the reconnect task task = asyncio.create_task(ctl.reconnect_task()) # Give it a moment to start await asyncio.sleep(0) # Simulate shutdown ctl._shutting_down = True # Trigger the future to wake up the task ctl._on_connection_lost_future.set_result(None) # Task should exit cleanly await task # _establish_connection should not have been called assert not establish_called @pytest.mark.asyncio async def test_command_response_context_manager() -> None: """Test the command_response context manager.""" future = asyncio.get_running_loop().create_future() future.set_result(None) # Mark connection as made scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) # Test successful command response opcode = 0x0015 # MGMT_OP_GET_CONNECTIONS async with protocol.command_response(opcode) as response_future: # Verify we got a future assert response_future is not None assert isinstance(response_future, asyncio.Future) # Simulate receiving a response response_data = ( b"\x01\x00" # MGMT_EV_CMD_COMPLETE b"\x00\x00" # controller index b"\x03\x00" # param_len (3 bytes: opcode=2 + status=1) + opcode.to_bytes(2, "little") # opcode + b"\x00" # status (success) ) protocol.data_received(response_data) # Get the result status, _data = await response_future assert status == 0 # Success # After context exits, future should be resolved assert response_future.done() @pytest.mark.asyncio async def test_command_response_cleanup_on_exception() -> None: """Test that command_response cleans up even if an exception occurs.""" future = asyncio.get_running_loop().create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) opcode = 0x0015 # MGMT_OP_GET_CONNECTIONS # Test cleanup on exception async def _raise_inside_command_response() -> None: async with protocol.command_response(opcode) as response_future: assert response_future is not None msg = "Test exception" raise ValueError(msg) with pytest.raises(ValueError, match="Test exception"): await _raise_inside_command_response() # The future should still exist after exception # (cleanup just removes it from internal tracking) @pytest.mark.asyncio async def test_get_connections_response_handling() -> None: """Test handling of GET_CONNECTIONS command response.""" future = asyncio.get_running_loop().create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) opcode = 0x0015 # MGMT_OP_GET_CONNECTIONS # Use the command_response context manager to register the command async with protocol.command_response(opcode) as response_future: # Test with permission denied status (0x14) response_data = ( b"\x01\x00" # MGMT_EV_CMD_COMPLETE b"\x00\x00" # controller index b"\x03\x00" # param_len + opcode.to_bytes(2, "little") # opcode + b"\x14" # status (permission denied) ) protocol.data_received(response_data) # Verify the future was resolved with the status status, data = await response_future assert status == 0x14 # Permission denied assert data == b"" # No additional data for param_len <= 3 @pytest.mark.asyncio async def test_get_connections_response_with_data() -> None: """Test GET_CONNECTIONS response with additional data.""" future = asyncio.get_running_loop().create_future() scanners: dict[int, HaScanner] = {} on_connection_lost = Mock() is_shutting_down = Mock(return_value=False) mock_sock = Mock() protocol = BluetoothMGMTProtocol( future, scanners, on_connection_lost, is_shutting_down, mock_sock ) opcode = 0x0015 # MGMT_OP_GET_CONNECTIONS # Use the command_response context manager to register the command async with protocol.command_response(opcode) as response_future: # Test with success status and additional data extra_data = b"\x01\x02\x03\x04" response_data = ( b"\x01\x00" # MGMT_EV_CMD_COMPLETE b"\x00\x00" # controller index + (3 + len(extra_data)).to_bytes( 2, "little" ) # param_len (opcode=2 + status=1 + extra_data) + opcode.to_bytes(2, "little") # opcode + b"\x00" # status (success) + extra_data # additional response data ) protocol.data_received(response_data) # Verify the future was resolved with status and data status, data = await response_future assert status == 0 # Success assert data == extra_data @pytest.mark.asyncio async def test_has_mgmt_capabilities_from_status() -> None: """Test _has_mgmt_capabilities_from_status helper function.""" mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={}) # Test permission denied assert mgmt_ctl._has_mgmt_capabilities_from_status(0x14) is False # Test success assert mgmt_ctl._has_mgmt_capabilities_from_status(0x00) is True # Test invalid index (still has permissions) assert mgmt_ctl._has_mgmt_capabilities_from_status(0x11) is True # Test unknown status (assumes no permissions) assert mgmt_ctl._has_mgmt_capabilities_from_status(0xFF) is False assert mgmt_ctl._has_mgmt_capabilities_from_status(0x01) is False assert mgmt_ctl._has_mgmt_capabilities_from_status(0x0D) is False @pytest.mark.asyncio async def test_check_capabilities_success() -> None: """Test _check_capabilities when permissions are available.""" mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={}) # Mock the protocol and transport mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport mgmt_ctl.protocol = mock_protocol # Mock command_response to return success def mock_command_response(opcode: int) -> object: future = asyncio.get_running_loop().create_future() future.set_result((0x00, b"")) # Success status class MockContext: async def __aenter__(self) -> asyncio.Future[tuple[int, bytes]]: return future async def __aexit__(self, *args: object) -> None: pass return MockContext() mock_protocol.command_response = mock_command_response # Mock the _write_to_socket method mock_protocol._write_to_socket = Mock() # Test capability check result = await mgmt_ctl._check_capabilities() assert result is True # Verify the command was sent mock_protocol._write_to_socket.assert_called_once() sent_data = mock_protocol._write_to_socket.call_args[0][0] # Check that it's a GET_CONNECTIONS command (opcode at bytes 0-1) assert sent_data[0:2] == b"\x15\x00" # MGMT_OP_GET_CONNECTIONS little-endian @pytest.mark.asyncio async def test_check_capabilities_permission_denied() -> None: """Test _check_capabilities when permissions are denied.""" mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={}) # Mock the protocol and transport mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport mgmt_ctl.protocol = mock_protocol # Mock command_response to return permission denied def mock_command_response(opcode: int) -> object: future = asyncio.get_running_loop().create_future() future.set_result((0x14, b"")) # Permission denied status class MockContext: async def __aenter__(self) -> asyncio.Future[tuple[int, bytes]]: return future async def __aexit__(self, *args: object) -> None: pass return MockContext() mock_protocol.command_response = mock_command_response # Test capability check result = await mgmt_ctl._check_capabilities() assert result is False @pytest.mark.asyncio async def test_check_capabilities_invalid_index() -> None: """Test _check_capabilities with invalid adapter index (still has permissions).""" mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={}) # Mock the protocol and transport mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport mgmt_ctl.protocol = mock_protocol # Mock command_response to return invalid index def mock_command_response(opcode: int) -> object: future = asyncio.get_running_loop().create_future() future.set_result((0x11, b"")) # Invalid index class MockContext: async def __aenter__(self) -> asyncio.Future[tuple[int, bytes]]: return future async def __aexit__(self, *args: object) -> None: pass return MockContext() mock_protocol.command_response = mock_command_response # Test capability check - invalid index means adapter doesn't exist # but we still have permissions result = await mgmt_ctl._check_capabilities() assert result is True @pytest.mark.asyncio async def test_check_capabilities_unknown_status() -> None: """Test _check_capabilities with unknown status code.""" mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={}) # Mock the protocol and transport mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport mgmt_ctl.protocol = mock_protocol # Mock command_response to return unknown status def mock_command_response(opcode: int) -> object: future = asyncio.get_running_loop().create_future() future.set_result((0xFF, b"")) # Unknown status class MockContext: async def __aenter__(self) -> asyncio.Future[tuple[int, bytes]]: return future async def __aexit__(self, *args: object) -> None: pass return MockContext() mock_protocol.command_response = mock_command_response # Test capability check - unknown status assumes no permissions result = await mgmt_ctl._check_capabilities() assert result is False @pytest.mark.asyncio async def test_check_capabilities_timeout() -> None: """Test _check_capabilities when command times out.""" mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={}) # Mock the protocol and transport mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport mgmt_ctl.protocol = mock_protocol # Mock command_response to timeout def mock_command_response(opcode: int) -> object: future = asyncio.get_running_loop().create_future() # Never resolve the future class MockContext: async def __aenter__(self) -> asyncio.Future[tuple[int, bytes]]: return future async def __aexit__(self, *args: object) -> None: pass return MockContext() mock_protocol.command_response = mock_command_response # Test capability check with a very short timeout with patch("habluetooth.channels.bluez.asyncio_timeout") as mock_timeout: # Make timeout raise immediately mock_timeout.side_effect = TimeoutError("Test timeout") result = await mgmt_ctl._check_capabilities() assert result is False @pytest.mark.asyncio async def test_check_capabilities_no_protocol() -> None: """Test _check_capabilities when protocol is not set.""" mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={}) # No protocol set mgmt_ctl.protocol = None result = await mgmt_ctl._check_capabilities() assert result is False @pytest.mark.asyncio async def test_check_capabilities_no_transport() -> None: """Test _check_capabilities when transport is not set.""" mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={}) # Mock protocol with no transport mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_protocol.transport = None mgmt_ctl.protocol = mock_protocol result = await mgmt_ctl._check_capabilities() assert result is False @pytest.mark.asyncio async def test_setup_with_failed_capabilities() -> None: """Test setup raises PermissionError when capabilities check fails.""" with ( patch("habluetooth.channels.bluez.btmgmt_socket") as mock_btmgmt, patch.object(MGMTBluetoothCtl, "_establish_connection") as mock_establish, patch.object(MGMTBluetoothCtl, "_check_capabilities", return_value=False), ): mock_socket = Mock() mock_socket.fileno.return_value = 99 mock_btmgmt.open.return_value = mock_socket mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={}) # Mock successful connection establishment mock_establish.return_value = None # Set the socket on mgmt_ctl mgmt_ctl.sock = mock_socket # Mock protocol for close operation mock_protocol = Mock() mock_transport = Mock() mock_protocol.transport = mock_transport mgmt_ctl.protocol = mock_protocol # Setup should raise PermissionError with pytest.raises(PermissionError) as exc_info: await mgmt_ctl.setup() assert "Missing NET_ADMIN/NET_RAW capabilities" in str(exc_info.value) # Verify cleanup assert mgmt_ctl._shutting_down is True mock_transport.close.assert_called_once() mock_btmgmt.close.assert_called_once_with(mock_socket) Bluetooth-Devices-habluetooth-75cbe37/tests/conftest.py000066400000000000000000000221041521117704500233370ustar00rootroot00000000000000from collections.abc import AsyncGenerator, Generator, Iterable from unittest.mock import AsyncMock, MagicMock, patch import pytest import pytest_asyncio from bleak.backends.scanner import AdvertisementData, BLEDevice from bleak_retry_connector import BleakSlotManager from bluetooth_adapters import AdapterDetails, BluetoothAdapters from habluetooth import ( BaseHaRemoteScanner, BaseHaScanner, BluetoothManager, get_manager, set_manager, ) from habluetooth import scanner as bluetooth_scanner class FakeBluetoothAdapters(BluetoothAdapters): @property def adapters(self) -> dict[str, AdapterDetails]: return {} class FakeScannerMixin: def get_discovered_device_advertisement_data( self, address: str ) -> tuple[BLEDevice, AdvertisementData] | None: """Return the advertisement data for a discovered device.""" return self.discovered_devices_and_advertisement_data.get(address) # type: ignore[attr-defined] @property def discovered_addresses(self) -> Iterable[str]: """Return an iterable of discovered devices.""" return self.discovered_devices_and_advertisement_data # type: ignore[attr-defined] class FakeScanner(FakeScannerMixin, BaseHaScanner): """Fake scanner.""" @property def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" return [] @property def discovered_devices_and_advertisement_data( self, ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: """Return a list of discovered devices and their advertisement data.""" return {} class PatchableBluetoothManager(BluetoothManager): """Patchable Bluetooth Manager for testing.""" @pytest_asyncio.fixture(autouse=True) async def manager() -> AsyncGenerator[None, None]: slot_manager = BleakSlotManager() bluetooth_adapters = FakeBluetoothAdapters() manager = PatchableBluetoothManager(bluetooth_adapters, slot_manager) set_manager(manager) await manager.async_setup() yield manager.async_stop() @pytest_asyncio.fixture(name="enable_bluetooth") async def mock_enable_bluetooth( mock_bleak_scanner_start: MagicMock, mock_bluetooth_adapters: None, ) -> AsyncGenerator[None, None]: """Fixture to mock starting the bleak scanner.""" manager = get_manager() assert manager._bluetooth_adapters is not None await manager.async_setup() yield manager._all_history.clear() manager._connectable_history.clear() manager._name_cache.clear() manager._unavailable_callbacks.clear() manager._connectable_unavailable_callbacks.clear() manager._bleak_callbacks.clear() manager._fallback_intervals.clear() manager._intervals.clear() manager._adapter_sources.clear() manager._adapters.clear() manager._sources.clear() manager._allocations.clear() manager._non_connectable_scanners.clear() manager._connectable_scanners.clear() @pytest.fixture(scope="session") def mock_bluetooth_adapters() -> Generator[None, None, None]: """Fixture to mock bluetooth adapters.""" with ( patch("bluetooth_auto_recovery.recover_adapter"), patch("bluetooth_adapters.systems.platform.system", return_value="Linux"), patch("bluetooth_adapters.systems.linux.LinuxAdapters.refresh"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.adapters", { "hci0": { "address": "00:00:00:00:00:01", "hw_version": "usb:v1D6Bp0246d053F", "passive_scan": False, "sw_version": "homeassistant", "manufacturer": "ACME", "product": "Bluetooth Adapter 5.0", "product_id": "aa01", "vendor_id": "cc01", }, }, ), ): yield @pytest.fixture def mock_bleak_scanner_start() -> Generator[MagicMock, None, None]: """Fixture to mock starting the bleak scanner.""" bluetooth_scanner.OriginalBleakScanner.stop = AsyncMock() with ( patch.object( bluetooth_scanner.OriginalBleakScanner, "start", ) as mock_bleak_scanner_start, patch.object(bluetooth_scanner, "HaScanner"), ): yield mock_bleak_scanner_start @pytest.fixture(name="two_adapters") def two_adapters_fixture(): """Fixture that mocks two adapters on Linux.""" with ( patch( "habluetooth.scanner.platform.system", return_value="Linux", ), patch("bluetooth_adapters.systems.platform.system", return_value="Linux"), patch("bluetooth_adapters.systems.linux.LinuxAdapters.refresh"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.adapters", { "hci0": { "address": "00:00:00:00:00:01", "hw_version": "usb:v1D6Bp0246d053F", "passive_scan": False, "sw_version": "homeassistant", "manufacturer": "ACME", "product": "Bluetooth Adapter 5.0", "product_id": "aa01", "vendor_id": "cc01", "connection_slots": 1, }, "hci1": { "address": "00:00:00:00:00:02", "hw_version": "usb:v1D6Bp0246d053F", "passive_scan": True, "sw_version": "homeassistant", "manufacturer": "ACME", "product": "Bluetooth Adapter 5.0", "product_id": "aa01", "vendor_id": "cc01", "connection_slots": 2, }, }, ), ): yield @pytest.fixture(name="macos_adapter") def macos_adapter() -> Generator[None, None, None]: """Fixture that mocks the macos adapter.""" with ( patch("bleak.get_platform_scanner_backend_type"), patch( "habluetooth.scanner.platform.system", return_value="Darwin", ), patch( "bluetooth_adapters.systems.platform.system", return_value="Darwin", ), patch("habluetooth.scanner.SYSTEM", "Darwin"), ): yield @pytest.fixture def register_hci0_scanner() -> Generator[None, None, None]: """Register an hci0 scanner.""" hci0_scanner = FakeScanner("AA:BB:CC:DD:EE:00", "hci0") hci0_scanner.connectable = True manager = get_manager() cancel = manager.async_register_scanner(hci0_scanner, connection_slots=5) yield cancel() @pytest.fixture def register_hci1_scanner() -> Generator[None, None, None]: """Register an hci1 scanner.""" hci1_scanner = FakeScanner("AA:BB:CC:DD:EE:11", "hci1") hci1_scanner.connectable = True manager = get_manager() cancel = manager.async_register_scanner(hci1_scanner, connection_slots=5) yield cancel() @pytest.fixture def register_non_connectable_scanner() -> Generator[None, None, None]: """Register an non connectable remote scanner.""" remote_scanner = BaseHaRemoteScanner( "AA:BB:CC:DD:EE:FF", "non connectable", None, False ) manager = get_manager() cancel = manager.async_register_scanner(remote_scanner) yield cancel() class MockBluetoothManagerWithCallbacks(BluetoothManager): """Mock bluetooth manager that tracks scanner start callbacks.""" def __init__(self, *args, **kwargs): """Initialize the mock manager.""" super().__init__(*args, **kwargs) self.scanner_start_calls = [] def on_scanner_start(self, scanner): """Track scanner start calls.""" self.scanner_start_calls.append(scanner) super().on_scanner_start(scanner) @pytest.fixture def mock_manager_with_scanner_callbacks() -> Generator[ MockBluetoothManagerWithCallbacks, None, None ]: """Provide a mock BluetoothManager that tracks scanner start callbacks.""" mock_bluetooth_adapters = FakeBluetoothAdapters() manager = MockBluetoothManagerWithCallbacks( mock_bluetooth_adapters, slot_manager=MagicMock(), ) # Save the original manager original_manager = get_manager() # Set our mock manager as the global manager set_manager(manager) try: yield manager finally: # Restore the original manager set_manager(original_manager) @pytest_asyncio.fixture async def async_mock_manager_with_scanner_callbacks() -> AsyncGenerator[ MockBluetoothManagerWithCallbacks, None ]: """Provide an async mock BluetoothManager that tracks scanner start callbacks.""" mock_bluetooth_adapters = FakeBluetoothAdapters() manager = MockBluetoothManagerWithCallbacks( mock_bluetooth_adapters, slot_manager=MagicMock(), ) # Setup the manager await manager.async_setup() # Save the original manager original_manager = get_manager() # Set our mock manager as the global manager set_manager(manager) try: yield manager finally: # Restore the original manager set_manager(original_manager) Bluetooth-Devices-habluetooth-75cbe37/tests/test_advertisement_tracker.py000066400000000000000000000061251521117704500271430ustar00rootroot00000000000000"""Test that advertising interval tracking is properly cleared when scanner pauses.""" import pytest from habluetooth.advertisement_tracker import AdvertisementTracker from habluetooth.base_scanner import BaseHaScanner from habluetooth.central_manager import get_manager @pytest.mark.asyncio async def test_scanner_paused_clears_timing_data(): """Test timing data is cleared when scanner pauses but intervals are preserved.""" tracker = AdvertisementTracker() source = "test_scanner" address = "AA:BB:CC:DD:EE:FF" # Simulate collecting timing data tracker.sources[address] = source tracker._timings[address] = [1.0, 2.0, 3.0] # Some timing data tracker.intervals[address] = 10.0 # Already learned interval # Call async_scanner_paused tracker.async_scanner_paused(source) # Check that timing data is cleared but interval is preserved assert address not in tracker._timings assert tracker.intervals[address] == 10.0 # Interval should still be there assert tracker.sources[address] == source # Source mapping should still be there @pytest.mark.asyncio async def test_scanner_paused_only_affects_matching_source(): """Test that pausing only affects devices from the matching source.""" tracker = AdvertisementTracker() source1 = "scanner1" source2 = "scanner2" address1 = "AA:BB:CC:DD:EE:01" address2 = "AA:BB:CC:DD:EE:02" # Set up data for two sources tracker.sources[address1] = source1 tracker.sources[address2] = source2 tracker._timings[address1] = [1.0, 2.0] tracker._timings[address2] = [1.0, 2.0] tracker.intervals[address1] = 5.0 tracker.intervals[address2] = 6.0 # Pause only source1 tracker.async_scanner_paused(source1) # Check that only source1 timing is cleared assert address1 not in tracker._timings assert address2 in tracker._timings # source2 should still have timing data assert tracker.intervals[address1] == 5.0 # Intervals preserved assert tracker.intervals[address2] == 6.0 @pytest.mark.asyncio async def test_connection_clears_timing_data(): """Test that timing data is cleared when a connection is initiated.""" # Get the manager that was set up by the fixture test_manager = get_manager() # Create actual BaseHaScanner to test the method real_scanner = BaseHaScanner( source="test_scanner", adapter="hci0", connectable=True ) # BaseHaScanner gets the manager internally via get_manager() # Set up some timing data address = "AA:BB:CC:DD:EE:FF" test_manager._advertisement_tracker.sources[address] = real_scanner.source test_manager._advertisement_tracker._timings[address] = [1.0, 2.0, 3.0] test_manager._advertisement_tracker.intervals[address] = 10.0 # Call _add_connecting which should clear timing data real_scanner._add_connecting(address) # Verify timing data was cleared but interval preserved assert address not in test_manager._advertisement_tracker._timings assert test_manager._advertisement_tracker.intervals.get(address) == 10.0 assert address in real_scanner._connect_in_progress Bluetooth-Devices-habluetooth-75cbe37/tests/test_auto_scheduler.py000066400000000000000000010010501521117704500255550ustar00rootroot00000000000000"""Tests for the auto-mode active-window scheduler.""" from __future__ import annotations import asyncio import contextlib import logging import math from typing import TYPE_CHECKING, Any from unittest.mock import patch import pytest from freezegun import freeze_time from habluetooth import ( BaseHaScanner, BluetoothScanningMode, get_manager, ) from habluetooth.auto_scheduler import ActiveScanRequest, _ScanSchedule from habluetooth.const import ( AUTO_INITIAL_SWEEP_DELAY, AUTO_REDISCOVERY_INTERVAL, AUTO_REDISCOVERY_SWEEP_DURATION, AUTO_WINDOW_MAX_DURATION, AUTO_WINDOW_MIN_DURATION, DEFAULT_ACTIVE_SCAN_DURATION, DEFAULT_ACTIVE_SCAN_INTERVAL, DEFAULT_ON_DEMAND_SWEEP_DURATION, ) from . import generate_advertisement_data, generate_ble_device if TYPE_CHECKING: from collections.abc import Iterable from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData class _RecordingAutoScanner(BaseHaScanner): """BaseHaScanner subclass that records active-window calls.""" __slots__ = ("_block_event", "_return_value", "active_window_calls") def __init__( self, source: str, mode: BluetoothScanningMode | None, connectable: bool = True, ) -> None: super().__init__(source, source, requested_mode=mode) self.connectable = connectable self.active_window_calls: list[float] = [] self._block_event: asyncio.Event | None = None self._return_value = True async def async_request_active_window(self, duration: float) -> bool: self.active_window_calls.append(duration) if self._block_event is not None: await self._block_event.wait() return self._return_value @property def discovered_devices(self) -> list[BLEDevice]: return [] @property def discovered_devices_and_advertisement_data( self, ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: return {} def get_discovered_device_advertisement_data( self, address: str ) -> tuple[BLEDevice, AdvertisementData] | None: return None @property def discovered_addresses(self) -> Iterable[str]: return () def _inject(scanner: _RecordingAutoScanner, address: str) -> None: """Drive a fake advertisement through the scanner's normal path.""" adv = generate_advertisement_data(local_name="x") device = generate_ble_device(address, "x") scanner._async_on_advertisement( device.address, adv.rssi, device.name or "", adv.service_uuids, adv.service_data, adv.manufacturer_data, adv.tx_power, {}, asyncio.get_running_loop().time(), ) async def _run_worker_tick(scheduler: object, source: str) -> None: """Drive one worker through a single tick for deterministic testing.""" worker = scheduler._workers[source] # type: ignore[attr-defined] await worker._tick() def _assert_schedule_invariant(sched: object) -> None: """ Assert the schedule's three indices are in lock step. 1. ``_due_at`` and ``_owner_by_address`` have the same keyset. 2. Every ``_due_at`` value is a non-empty dict. 3. For each AUTO worker, its ``_owned_due_at`` exactly equals the addresses the schedule says it owns, and each entry's dict object is the *same* dict aliased from ``_due_at`` (not a copy). 4. Addresses owned by a non-AUTO source do not appear in any worker's ``_owned_due_at``. 5. Each worker's hot-path ``_next_event_at`` returns without raising. An empty bucket in ``_owned_due_at`` would crash with ``ValueError`` from ``min(())``, so calling it here is the active runtime check for the no-empty-buckets invariant the production code relies on. """ schedule = sched._schedule # type: ignore[attr-defined] workers = sched._workers # type: ignore[attr-defined] assert set(schedule._due_at) == set(schedule._owner_by_address), ( f"_due_at keys {set(schedule._due_at)} != " f"_owner_by_address keys {set(schedule._owner_by_address)}" ) for address, entries in schedule._due_at.items(): assert entries, f"_due_at[{address}] is empty" for source, worker in workers.items(): index_owned = { addr for addr, owner in schedule._owner_by_address.items() if owner == source } worker_owned = set(worker._owned_due_at) assert worker_owned == index_owned, ( f"worker {source}: _owned_due_at={worker_owned} " f"!= index-owned={index_owned}" ) for addr in worker_owned: assert worker._owned_due_at[addr] is schedule._due_at[addr], ( f"worker {source}: _owned_due_at[{addr}] is not the " f"same dict object as _due_at[{addr}]" ) for address, source in schedule._owner_by_address.items(): if source not in workers: for worker in workers.values(): assert address not in worker._owned_due_at, ( f"non-AUTO owner {source} leaked into worker " f"{worker._scanner.source}'s _owned_due_at" ) # Hot-path sanity: would raise ``ValueError`` from ``min(())`` if # any owned bucket were empty. This keeps the guard the source # code removed alive at the test layer. now = asyncio.get_running_loop().time() for worker in workers.values(): worker._next_event_at(now) @pytest.mark.asyncio async def test_advertisement_starts_tracking() -> None: """A matching address advertisement creates a per-(address, request) entry.""" manager = get_manager() sched = manager._auto_scheduler cancel = manager.async_register_active_scan( "11:22:33:44:55:66", scan_interval=120.0, scan_duration=6.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, "11:22:33:44:55:66") assert "11:22:33:44:55:66" in sched._schedule._due_at finally: cancel() register_cancel() assert sched._schedule._due_at == {} @pytest.mark.asyncio async def test_advertisement_for_unrelated_address_is_ignored() -> None: """An advertisement for an unregistered address creates no tracking.""" manager = get_manager() sched = manager._auto_scheduler cancel = manager.async_register_active_scan( "11:22:33:44:55:66", scan_interval=120.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: # The registered address has tracking from add_request; the # unrelated advertisement must not create its own entry. _inject(scanner, "AA:AA:AA:AA:AA:AA") assert "AA:AA:AA:AA:AA:AA" not in sched._schedule._due_at finally: cancel() register_cancel() @pytest.mark.asyncio async def test_worker_tick_fires_active_window() -> None: """A due tracker entry causes the owning scanner's worker to fire a window.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() cancel = manager.async_register_active_scan( "11:22:33:44:55:66", scan_interval=120.0, scan_duration=5.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, "11:22:33:44:55:66") entries = sched._schedule._due_at["11:22:33:44:55:66"] request = next(iter(entries)) entries[request] = loop.time() - 1.0 await _run_worker_tick(sched, scanner.source) assert scanner.active_window_calls == [5.0] assert entries[request] > loop.time() finally: cancel() register_cancel() @pytest.mark.asyncio async def test_worker_tick_advances_by_scan_interval_from_window_start() -> None: """ Next-due is window_start + scan_interval, not window_end + scan_interval. scan_interval is documented as the cadence between window *starts*. The scheduler advances entries from the tick's ``now`` (when the window starts) so the effective period is exactly ``scan_interval``; advancing from ``window_end`` instead would make the effective period ``scan_interval + scan_duration``. """ manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:77" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=15.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) entries = sched._schedule._due_at[address] request = next(iter(entries)) entries[request] = loop.time() - 1.0 before_tick = loop.time() await _run_worker_tick(sched, scanner.source) # entries[request] should be the tick's now + scan_interval == # roughly before_tick + 120. Definitely NOT before_tick + 135 # (which is what "scan_interval after window ends" would give). assert entries[request] == pytest.approx(before_tick + 120.0, abs=0.1) assert entries[request] < before_tick + 130.0 finally: cancel() register_cancel() @pytest.mark.asyncio async def test_worker_tick_coalesces_near_future_due_entries() -> None: """Staggered registrations sync to one window instead of back-to-back flips.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() addr_a = "11:22:33:44:55:01" addr_b = "11:22:33:44:55:02" cancel_a = manager.async_register_active_scan( addr_a, scan_interval=300.0, scan_duration=10.0 ) cancel_b = manager.async_register_active_scan( addr_b, scan_interval=300.0, scan_duration=10.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, addr_a) _inject(scanner, addr_b) # A is due now; B is due 10s from now (within the lookahead). # One tick should serve both. now = loop.time() entries_a = sched._schedule._due_at[addr_a] entries_b = sched._schedule._due_at[addr_b] for req in entries_a: entries_a[req] = now - 1.0 for req in entries_b: entries_b[req] = now + 10.0 await _run_worker_tick(sched, scanner.source) # One window covers both — not two back-to-back. assert scanner.active_window_calls == [10.0] # Both advanced from now (~now + 300), so they coalesce again # next tick rather than staying 10s apart forever. post_tick_now = loop.time() a_next = next(iter(entries_a.values())) b_next = next(iter(entries_b.values())) assert a_next == pytest.approx(post_tick_now + 300.0, abs=1.0) assert b_next == pytest.approx(post_tick_now + 300.0, abs=1.0) assert abs(a_next - b_next) < 0.5 finally: cancel_a() cancel_b() register_cancel() @pytest.mark.asyncio async def test_worker_tick_does_not_fire_when_only_soon_due_no_immediate() -> None: """Soon-due entries alone do not trigger an early tick.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan( address, scan_interval=300.0, scan_duration=10.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) entries = sched._schedule._due_at[address] # Set next_due 5s in the future — within the lookahead but # not immediately due. for req in entries: entries[req] = loop.time() + 5.0 await _run_worker_tick(sched, scanner.source) assert scanner.active_window_calls == [] finally: cancel() register_cancel() @pytest.mark.asyncio async def test_worker_tick_coalesces_near_max_window_boundary() -> None: """A 30s window pulls in a device due at now+25; lookahead > max window.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() addr_a = "11:22:33:44:55:01" addr_b = "11:22:33:44:55:02" cancel_a = manager.async_register_active_scan( addr_a, scan_interval=300.0, scan_duration=30.0 ) cancel_b = manager.async_register_active_scan( addr_b, scan_interval=300.0, scan_duration=30.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, addr_a) _inject(scanner, addr_b) now = loop.time() entries_a = sched._schedule._due_at[addr_a] entries_b = sched._schedule._due_at[addr_b] for req in entries_a: entries_a[req] = now - 1.0 for req in entries_b: entries_b[req] = now + 25.0 await _run_worker_tick(sched, scanner.source) # One 30s window covers both. assert scanner.active_window_calls == [30.0] finally: cancel_a() cancel_b() register_cancel() @pytest.mark.asyncio async def test_worker_tick_fallback_dispatch_rides_soon_due_entries() -> None: """Soon-due entries coalesce into the connecting-fallback dispatch.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() addr_a = "11:22:33:44:55:01" addr_b = "11:22:33:44:55:02" cancel_a = manager.async_register_active_scan( addr_a, scan_interval=300.0, scan_duration=10.0 ) cancel_b = manager.async_register_active_scan( addr_b, scan_interval=300.0, scan_duration=10.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:01:01", BluetoothScanningMode.AUTO) fallback = _DiscoverableAutoScanner("AA:00:00:00:01:02", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_fallback = manager.async_register_scanner(fallback) try: _inject_with_rssi(owner, addr_a, rssi=-50) _inject_with_rssi(owner, addr_b, rssi=-50) fallback.add_discovered(addr_a, rssi=-70) fallback.add_discovered(addr_b, rssi=-70) owner._add_connecting(addr_a) now = loop.time() entries_a = sched._schedule._due_at[addr_a] entries_b = sched._schedule._due_at[addr_b] for req in entries_a: entries_a[req] = now - 1.0 for req in entries_b: entries_b[req] = now + 10.0 await _run_worker_tick(sched, owner.source) # Owner is mid-connect; fallback gets exactly one coalesced # call covering both addresses (both scan_duration=10). assert owner.active_window_calls == [] assert fallback.active_window_calls == [10.0] # Both addresses advanced by their scan_interval — soon-due # rode the fallback dispatch instead of firing separately. post_tick_now = loop.time() a_new_due = next(iter(entries_a.values())) b_new_due = next(iter(entries_b.values())) assert a_new_due == pytest.approx(post_tick_now + 300.0, abs=1.0) assert b_new_due == pytest.approx(post_tick_now + 300.0, abs=1.0) finally: owner._finished_connecting(addr_a, connected=False) cancel_a() cancel_b() c_owner() c_fallback() @pytest.mark.asyncio async def test_worker_tick_sweep_alone_pulls_in_soon_due_entries() -> None: """A sweep-only tick pulls in soon-due entries so they don't fire after.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan( address, scan_interval=300.0, scan_duration=10.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) worker = sched._workers[scanner.source] # Make the sweep due; per-device entry is soon-due but not # immediate. worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 entries = sched._schedule._due_at[address] soon_due_at = loop.time() + 5.0 for req in entries: entries[req] = soon_due_at await _run_worker_tick(sched, scanner.source) # Sweep fired; the soon-due entry was advanced so it does # not trigger a back-to-back flip after the sweep ends. assert len(scanner.active_window_calls) == 1 post_tick_now = loop.time() new_due = next(iter(entries.values())) assert new_due > soon_due_at assert new_due == pytest.approx(post_tick_now + 300.0, abs=1.0) finally: cancel() register_cancel() @pytest.mark.asyncio async def test_worker_tick_coalesces_overlapping_requests() -> None: """Multiple requests for the same address coalesce on max scan_duration.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel1 = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) cancel2 = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=10.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) entries = sched._schedule._due_at[address] for req in list(entries): entries[req] = loop.time() - 1.0 await _run_worker_tick(sched, scanner.source) assert scanner.active_window_calls == [10.0] finally: cancel1() cancel2() register_cancel() @pytest.mark.asyncio async def test_multiple_requests_same_address_track_independent_intervals() -> None: """Two registrations for the same address fire on their own cadences.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel_fast = manager.async_register_active_scan( address, scan_interval=60.0, scan_duration=5.0 ) cancel_slow = manager.async_register_active_scan( address, scan_interval=300.0, scan_duration=7.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) entries = sched._schedule._due_at[address] assert len(entries) == 2 fast, slow = sorted(entries, key=lambda r: r.scan_interval) entries[fast] = loop.time() - 1.0 entries[slow] = loop.time() + 200.0 await _run_worker_tick(sched, scanner.source) assert scanner.active_window_calls == [5.0] assert entries[fast] > loop.time() assert entries[slow] > loop.time() + 100 entries[fast] = loop.time() - 1.0 entries[slow] = loop.time() - 1.0 await _run_worker_tick(sched, scanner.source) assert scanner.active_window_calls == [5.0, 7.0] finally: cancel_fast() cancel_slow() register_cancel() @pytest.mark.asyncio async def test_no_worker_for_non_auto_scanner() -> None: """ACTIVE / PASSIVE scanners don't get a worker; their windows are never fired.""" manager = get_manager() sched = manager._auto_scheduler scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.ACTIVE) register_cancel = manager.async_register_scanner(scanner) try: assert scanner.source not in sched._workers finally: register_cancel() @pytest.mark.asyncio async def test_global_sweep_runs_on_auto_scanner() -> None: """The sweep fires async_request_active_window with SWEEP_DURATION.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 await _run_worker_tick(sched, scanner.source) assert scanner.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION] assert worker._sweep_last_completed > loop.time() - 1.0 finally: register_cancel() @pytest.mark.asyncio async def test_first_sweeps_stagger_across_scanners() -> None: """Concurrently-registered scanners get offset first-sweep times.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() s1 = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) s2 = _RecordingAutoScanner("AA:BB:CC:DD:EE:11", BluetoothScanningMode.AUTO) s3 = _RecordingAutoScanner("AA:BB:CC:DD:EE:22", BluetoothScanningMode.AUTO) c1 = manager.async_register_scanner(s1) c2 = manager.async_register_scanner(s2) c3 = manager.async_register_scanner(s3) try: now = loop.time() sweep_1 = ( sched._workers[s1.source]._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL ) sweep_2 = ( sched._workers[s2.source]._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL ) sweep_3 = ( sched._workers[s3.source]._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL ) # Each subsequent worker's first sweep is at least one # sweep-duration later than the previous one's. The delta is # `SWEEP_DURATION + (loop.time() drift between spawn calls)`, # so assert the floor rather than equality with a tight # tolerance — CI registrations can take >10ms between # _spawn_worker calls and would otherwise flake. assert sweep_2 - sweep_1 >= AUTO_REDISCOVERY_SWEEP_DURATION assert sweep_3 - sweep_2 >= AUTO_REDISCOVERY_SWEEP_DURATION # And the drift component stays small — well under a second. assert sweep_2 - sweep_1 < AUTO_REDISCOVERY_SWEEP_DURATION + 1.0 assert sweep_3 - sweep_2 < AUTO_REDISCOVERY_SWEEP_DURATION + 1.0 # Roughly the configured initial delay from now. assert sweep_1 - now == pytest.approx(AUTO_INITIAL_SWEEP_DELAY, abs=1.0) finally: c1() c2() c3() @pytest.mark.asyncio async def test_first_sweep_stagger_wraps_past_window_size() -> None: """ Past AUTO_INITIAL_SWEEP_DELAY/SWEEP_DURATION scanners, offsets wrap. With the modulo cap on the spawn offset, the Nth scanner where N == AUTO_INITIAL_SWEEP_DELAY/AUTO_REDISCOVERY_SWEEP_DURATION wraps back to offset 0. This locks in the contract that the stagger does not grow unboundedly with worker count. """ manager = get_manager() sched = manager._auto_scheduler wrap_at = int(AUTO_INITIAL_SWEEP_DELAY // AUTO_REDISCOVERY_SWEEP_DURATION) n = wrap_at + 1 # one past the wrap cancels = [] try: for i in range(n): s = _RecordingAutoScanner( f"AA:BB:CC:00:00:{i:02x}", BluetoothScanningMode.AUTO ) cancels.append(manager.async_register_scanner(s)) # The Nth scanner's first sweep is wrap_at scanners' worth of # offset modulo AUTO_INITIAL_SWEEP_DELAY -> back to 0; the # first scanner was also at offset 0, so their next-sweep times # match within a small slack for loop.time() advancing. workers = list(sched._workers.values()) first_sweep_a = workers[0]._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL first_sweep_wrap = ( workers[wrap_at]._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL ) assert abs(first_sweep_wrap - first_sweep_a) < 1.0 finally: for c in cancels: c() @pytest.mark.asyncio async def test_active_scan_registered_before_auto_scanner_wakes_on_register() -> None: """ A request registered before any AUTO scanner exists wakes the right one. Sequence: async_register_active_scan (request enters _requests_by_address; no worker exists yet for the device). Later, an AUTO scanner is registered and starts seeing the device. The first advertisement on that scanner must wake its worker so the entry in _due_at is acted upon. """ manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:88" cancel = manager.async_register_active_scan(address, scan_interval=60.0) # Sanity: request is recorded; no worker yet for any source. assert address in sched._requests_by_address try: scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:99", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] worker._wake.clear() _inject(scanner, address) assert worker._wake.is_set() # The address now has a tracked entry on this scanner. assert address in sched._schedule._due_at finally: register_cancel() finally: cancel() @pytest.mark.asyncio async def test_remove_request_clears_tracking() -> None: """Cancelling a registration removes its per-(address, request) entries.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan(address, scan_interval=60.0) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) assert address in sched._schedule._due_at cancel() assert address not in sched._schedule._due_at assert sched._requests_by_address == {} finally: register_cancel() @pytest.mark.asyncio async def test_failed_sweep_advances_sweep_last_completed() -> None: """A False return on a sweep advances the worker's sweep clock.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) scanner._return_value = False register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 before = worker._sweep_last_completed await _run_worker_tick(sched, scanner.source) assert scanner.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION] # Even on False, the worker's sweep clock advanced so the next # sweep is one full interval out instead of immediate. assert worker._sweep_last_completed > before finally: register_cancel() @pytest.mark.asyncio async def test_stop_cancels_worker_tasks() -> None: """Scheduler.stop cancels every worker task.""" manager = get_manager() sched = manager._auto_scheduler scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] task = worker._task assert task is not None sched.stop() await asyncio.sleep(0) assert task.cancelled() or task.done() assert sched._workers == {} finally: register_cancel() @pytest.mark.asyncio async def test_dispatch_drops_tracking_for_unseen_address() -> None: """An address whose history aged out is pruned on the next worker tick.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "AA:BB:CC:DD:EE:FF" cancel = manager.async_register_active_scan(address, scan_interval=60.0) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) request = next(iter(sched._schedule._due_at[address])) sched._schedule._due_at[address][request] = loop.time() - 1.0 # Simulate the manager's history aging out under the worker's # feet — the orphan-prune branch should clean both _due_at and # _owner_by_address. manager._all_history.pop(address, None) manager._connectable_history.pop(address, None) await _run_worker_tick(sched, scanner.source) assert address not in sched._schedule._due_at assert address not in sched._schedule._owner_by_address assert address not in sched._workers[scanner.source]._owned_due_at finally: cancel() register_cancel() @pytest.mark.asyncio async def test_first_sweep_is_delayed_after_scanner_registers() -> None: """A newly registered AUTO scanner's first sweep is AUTO_INITIAL_SWEEP_DELAY out.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] first_sweep_at = worker._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL now = loop.time() assert ( AUTO_INITIAL_SWEEP_DELAY - 1.0 <= first_sweep_at - now <= AUTO_INITIAL_SWEEP_DELAY + 1.0 ) finally: register_cancel() @pytest.mark.asyncio async def test_initial_sweep_fires_4_minutes_after_scanner_registers() -> None: """ The initial sweep is suppressed for ~4 minutes after registration. Literal seconds are used so this test pins the contract that AUTO_INITIAL_SWEEP_DELAY stays at 4 minutes; the symbolic relationship between the worker's scheduled time and the constant is covered by ``test_first_sweep_is_delayed_after_scanner_registers``. Freezegun patches ``time.monotonic`` which backs ``loop.time()``, so the worker's ``_tick`` sees the advanced clock. The background ``_run`` task is cancelled up-front so its own ``wait_for`` timeout (also driven by frozen monotonic) does not race the explicit ticks below. """ manager = get_manager() sched = manager._auto_scheduler four_minutes = 4 * 60.0 with freeze_time() as frozen: scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) worker = sched._workers[scanner.source] worker.stop() await asyncio.sleep(0) try: # 1s before the 4-minute mark; no window should fire. frozen.tick(four_minutes - 1.0) await worker._tick() assert scanner.active_window_calls == [] # Crossing the 4-minute mark; the sweep fires with SWEEP_DURATION. frozen.tick(2.0) await worker._tick() assert scanner.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION] finally: register_cancel() @pytest.mark.asyncio async def test_remove_scanner_stops_its_worker() -> None: """Unregistering a scanner cancels and drops its worker.""" manager = get_manager() sched = manager._auto_scheduler scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) cancel = manager.async_register_scanner(scanner) assert scanner.source in sched._workers worker = sched._workers[scanner.source] task = worker._task cancel() await asyncio.sleep(0) assert scanner.source not in sched._workers assert task is not None assert task.cancelled() or task.done() @pytest.mark.asyncio async def test_remove_scanner_prunes_owned_due_at_entries() -> None: """ _due_at entries owned by the leaving scanner are pruned at remove. Without the prune, those entries would sit pinned until the device either turns up on another scanner (history flips) or expires from _all_history. """ manager = get_manager() sched = manager._auto_scheduler address_owned = "AA:00:00:00:00:10" address_foreign = "AA:00:00:00:00:11" s_a = _RecordingAutoScanner("AA:00:00:00:00:01", BluetoothScanningMode.AUTO) s_b = _RecordingAutoScanner("AA:00:00:00:00:02", BluetoothScanningMode.AUTO) c_a = manager.async_register_scanner(s_a) c_b = manager.async_register_scanner(s_b) cancel_owned = manager.async_register_active_scan( address_owned, scan_interval=60.0, scan_duration=5.0 ) cancel_foreign = manager.async_register_active_scan( address_foreign, scan_interval=60.0, scan_duration=5.0 ) try: _inject(s_a, address_owned) _inject(s_b, address_foreign) assert address_owned in sched._schedule._due_at assert address_foreign in sched._schedule._due_at # Remove s_a. The owned entry must be pruned; the foreign one # (owned by s_b) must remain. c_a() await asyncio.sleep(0) assert address_owned not in sched._schedule._due_at assert address_foreign in sched._schedule._due_at finally: cancel_owned() cancel_foreign() c_b() @pytest.mark.asyncio async def test_add_scanner_before_start_defers_worker() -> None: """A scanner registered before start() gets its worker on start().""" manager = get_manager() sched = manager._auto_scheduler loop = sched._loop assert loop is not None sched._loop = None sched._running = False try: scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) sched.add_scanner(scanner) assert scanner.source not in sched._workers manager._sources[scanner.source] = scanner sched.start(loop) assert scanner.source in sched._workers sched._workers[scanner.source].stop() finally: manager._sources.pop("AA:BB:CC:DD:EE:00", None) @pytest.mark.asyncio async def test_stop_is_safe_when_already_idle() -> None: """Calling stop() twice in a row is fully idempotent.""" manager = get_manager() sched = manager._auto_scheduler sched.stop() sched.stop() assert sched._workers == {} @pytest.mark.asyncio async def test_stop_clears_loop_so_post_stop_add_request_is_record_only() -> None: """ After stop(), add_request and on_advertisement skip _due_at. Without nulling _loop, post-stop add_request would seed _due_at with timestamps from the cancelled loop and try to wake a worker that no longer exists. on_advertisement is similar. Both must fall back to the record-only / no-op path once stop() runs. """ manager = get_manager() sched = manager._auto_scheduler address = "AA:BB:CC:DD:EE:90" scanner = _RecordingAutoScanner("AA:00:00:00:00:33", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) # seed history sched.stop() assert sched._loop is None # add_request after stop: still tracked in _requests_by_address # but no _due_at seed (loop is None). cancel = manager.async_register_active_scan(address, scan_interval=60.0) try: assert address in sched._requests_by_address assert address not in sched._schedule._due_at # on_advertisement after stop is a no-op on _due_at too. _inject(scanner, address) assert address not in sched._schedule._due_at finally: cancel() finally: register_cancel() # Restore the scheduler so the conftest teardown isn't surprised # by a None loop. sched.start(asyncio.get_running_loop()) @pytest.mark.asyncio async def test_duration_clamped_to_bounds() -> None: """_coalesce_duration clamps the requested duration to the configured range.""" sched = get_manager()._auto_scheduler def _req(duration: float) -> ActiveScanRequest: return ActiveScanRequest("AA", 60.0, duration) assert sched._coalesce_duration([_req(0.01)]) == AUTO_WINDOW_MIN_DURATION assert sched._coalesce_duration([_req(1000.0)]) == AUTO_WINDOW_MAX_DURATION assert sched._coalesce_duration([_req(7.5)]) == 7.5 assert sched._coalesce_duration([_req(0.01), _req(7.5)]) == 7.5 assert ( sched._coalesce_duration([_req(7.5), _req(1000.0)]) == AUTO_WINDOW_MAX_DURATION ) # Empty list falls back to the configured minimum. assert sched._coalesce_duration([]) == AUTO_WINDOW_MIN_DURATION @pytest.mark.asyncio async def test_on_advertisement_early_returns_with_no_requests() -> None: """Hot path is a no-op when no active-scan request is registered.""" manager = get_manager() sched = manager._auto_scheduler scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, "11:22:33:44:55:66") assert sched._schedule._due_at == {} assert sched._requests_by_address == {} finally: register_cancel() @pytest.mark.asyncio async def test_on_advertisement_re_bootstraps_pruned_tracking() -> None: """If a tracking entry was pruned, the next ad re-creates it and wakes.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:66" scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) cancel = manager.async_register_active_scan(address, scan_interval=120.0) try: worker = sched._workers[scanner.source] # No advertisement has been seen yet, so add_request skipped # the _due_at seed (the prune-on-no-history path). Simulate the # "pruned" state by ensuring it's not there. sched._schedule._due_at.pop(address, None) worker._wake.clear() _inject(scanner, address) assert address in sched._schedule._due_at assert worker._wake.is_set() finally: cancel() register_cancel() @pytest.mark.asyncio async def test_register_active_scan_validates_inputs() -> None: """scan_interval / scan_duration below the configured minimums raise.""" manager = get_manager() # scan_interval below 60s. with pytest.raises(ValueError, match="scan_interval must"): manager.async_register_active_scan("AA:BB:CC:DD:EE:00", scan_interval=0) with pytest.raises(ValueError, match="scan_interval must"): manager.async_register_active_scan("AA:BB:CC:DD:EE:00", scan_interval=30.0) # scan_duration below 5s. with pytest.raises(ValueError, match="scan_duration must"): manager.async_register_active_scan( "AA:BB:CC:DD:EE:00", scan_interval=60.0, scan_duration=-0.5 ) with pytest.raises(ValueError, match="scan_duration must"): manager.async_register_active_scan( "AA:BB:CC:DD:EE:00", scan_interval=60.0, scan_duration=4.5 ) # Empty address. with pytest.raises(ValueError, match="address must be a non-empty string"): manager.async_register_active_scan("", scan_interval=60.0) # Non-finite values must be rejected: NaN compared to anything # returns False, so without the explicit isfinite() check a NaN # would slip past the lower-bound validators. for bad in (math.nan, math.inf, -math.inf): with pytest.raises(ValueError, match="scan_interval must be a finite number"): manager.async_register_active_scan("AA:BB:CC:DD:EE:00", scan_interval=bad) with pytest.raises(ValueError, match="scan_duration must be a finite number"): manager.async_register_active_scan( "AA:BB:CC:DD:EE:00", scan_interval=60.0, scan_duration=bad ) @pytest.mark.asyncio async def test_register_active_scan_applies_defaults() -> None: """Omitting scan_interval/scan_duration uses the configured defaults.""" manager = get_manager() sched = manager._auto_scheduler address = "AA:BB:CC:DD:EE:42" cancel = manager.async_register_active_scan(address) try: request = next(iter(sched._requests_by_address[address])) assert request.scan_interval == DEFAULT_ACTIVE_SCAN_INTERVAL assert request.scan_duration == DEFAULT_ACTIVE_SCAN_DURATION finally: cancel() @pytest.mark.asyncio async def test_register_active_scan_uuid_passes_through_unchanged() -> None: """ MacOS CoreBluetooth UUIDs are not uppercased. BlueZ / proxy addresses are colon-form MACs and get normalized to upper-case; UUIDs (no colons) must pass through unchanged because CoreBluetooth preserves case on its source addresses. """ manager = get_manager() sched = manager._auto_scheduler uuid = "abcd1234-5678-90ab-cdef-1234567890ab" cancel = manager.async_register_active_scan(uuid, scan_interval=60.0) try: assert uuid in sched._requests_by_address assert uuid.upper() not in sched._requests_by_address request = next(iter(sched._requests_by_address[uuid])) assert request.address == uuid finally: cancel() @pytest.mark.asyncio async def test_register_active_scan_normalizes_address_case() -> None: """ Lowercase addresses get normalized to the upper-case form. Matches the upper-case form BlueZ / bleak use for advertisement source addresses so on_advertisement's dict lookup finds the request regardless of caller case. """ manager = get_manager() sched = manager._auto_scheduler upper = "AA:BB:CC:DD:EE:55" cancel = manager.async_register_active_scan(upper.lower(), scan_interval=60.0) try: # Stored under the upper-case form, regardless of caller's case. assert upper in sched._requests_by_address assert upper.lower() not in sched._requests_by_address request = next(iter(sched._requests_by_address[upper])) assert request.address == upper finally: cancel() @pytest.mark.asyncio async def test_add_request_without_history_skips_seed() -> None: """ add_request skips _due_at when no last_service_info exists yet. on_advertisement bootstraps tracking instead. The previous behavior seeded unconditionally and let the next worker tick prune the orphan entry; skipping the seed avoids that churn. """ manager = get_manager() sched = manager._auto_scheduler address = "AA:BB:CC:DD:EE:56" scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) cancel = manager.async_register_active_scan(address, scan_interval=60.0) try: # Sanity: history doesn't exist for this address yet. assert manager.async_last_service_info(address, False) is None # _due_at was not seeded -> no entry to prune later. assert address not in sched._schedule._due_at # But the request IS recorded for on_advertisement to pick up. assert address in sched._requests_by_address # First advertisement bootstraps tracking and wakes the # owner's worker. worker = sched._workers[scanner.source] worker._wake.clear() _inject(scanner, address) assert address in sched._schedule._due_at assert worker._wake.is_set() finally: cancel() register_cancel() @pytest.mark.asyncio async def test_run_window_swallows_scanner_exception() -> None: """An exception from async_request_active_window is logged, not re-raised.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() class _FailingScanner(_RecordingAutoScanner): async def async_request_active_window(self, duration: float) -> bool: msg = "boom" raise RuntimeError(msg) scanner = _FailingScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 await worker._tick() # The exception was swallowed; sweep state still advanced. assert worker._sweep_last_completed > loop.time() - 1.0 finally: register_cancel() @pytest.mark.asyncio async def test_repeated_window_failures_log_only_first_traceback( caplog: pytest.LogCaptureFixture, ) -> None: """ Persistently failing scanner gets one exception log then warnings. Without rate-limiting, a permanently broken scanner would emit a full traceback every scan_interval (>= 60s). The first failure still logs the full stack so the root cause is captured; subsequent failures collapse to a one-line warning to avoid flooding the log. """ manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() class _FailingScanner(_RecordingAutoScanner): async def async_request_active_window(self, duration: float) -> bool: msg = "boom" raise RuntimeError(msg) scanner = _FailingScanner("AA:BB:CC:DD:EE:11", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"): await worker._tick() # Trigger a second failure. worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 await worker._tick() records = [ r for r in caplog.records if "error running active window" in r.message ] assert len(records) == 2 # First has exception info (full traceback), second does not. assert records[0].exc_info is not None assert records[1].exc_info is None finally: register_cancel() @pytest.mark.asyncio async def test_tick_sync_phase_exception_is_logged_and_worker_survives( caplog: pytest.LogCaptureFixture, ) -> None: """ Sync-phase failures in _tick are logged; worker survives. Stubs async_last_service_info to raise so _collect_due_buckets blows up; the outer except in _tick catches it and logs. """ manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:91" cancel = manager.async_register_active_scan( address, scan_interval=60.0, scan_duration=5.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:31", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) worker = sched._workers[scanner.source] original = manager.async_last_service_info def _boom(_addr: str, _conn: bool) -> None: msg = "boom in last_service_info" raise RuntimeError(msg) manager.async_last_service_info = _boom # type: ignore[assignment,method-assign] try: with caplog.at_level(logging.ERROR): await worker._tick() assert any( "unexpected error in auto-window tick" in record.message for record in caplog.records ) # Worker is still alive; _window_end was reset. assert worker._window_end == 0.0 finally: manager.async_last_service_info = original # type: ignore[method-assign] finally: cancel() register_cancel() @pytest.mark.asyncio async def test_mode_switch_unregister_then_register_picks_up_existing_request() -> None: """ Scheduler survives a HA-style scanner mode switch on the same source. HA's UI mode-switch path reloads the config entry: the old scanner is unregistered, a new one with the same source is registered with the new mode. The scheduler must (1) prune _due_at entries the leaving scanner owned via remove_scanner, (2) keep user-registered ActiveScanRequests in _requests_by_address, (3) spawn a fresh worker for a new AUTO scanner via add_scanner, and (4) bootstrap _due_at on the first advertisement from the new scanner. """ manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:92" # Register the active-scan need first. cancel = manager.async_register_active_scan( address, scan_interval=60.0, scan_duration=5.0 ) # Start in AUTO, see the device, then "switch to ACTIVE". auto_scanner = _RecordingAutoScanner( "AA:BB:CC:DD:EE:32", BluetoothScanningMode.AUTO ) auto_cancel = manager.async_register_scanner(auto_scanner) try: _inject(auto_scanner, address) assert address in sched._schedule._due_at assert auto_scanner.source in sched._workers # Mode switch in UI -> unregister AUTO scanner. auto_cancel() assert auto_scanner.source not in sched._workers assert address not in sched._schedule._due_at # User's registration is preserved across the switch. assert address in sched._requests_by_address # Re-register with the SAME source but PASSIVE mode. passive_scanner = _RecordingAutoScanner( "AA:BB:CC:DD:EE:32", BluetoothScanningMode.PASSIVE ) passive_cancel = manager.async_register_scanner(passive_scanner) try: # PASSIVE doesn't get a worker. assert passive_scanner.source not in sched._workers # Still no _due_at entry (no AUTO scanner owns it). assert address not in sched._schedule._due_at passive_cancel() # Now switch BACK to AUTO with the same source. new_auto = _RecordingAutoScanner( "AA:BB:CC:DD:EE:32", BluetoothScanningMode.AUTO ) new_auto_cancel = manager.async_register_scanner(new_auto) try: assert new_auto.source in sched._workers # First advertisement on the new AUTO scanner bootstraps # tracking again from the still-registered request. _inject(new_auto, address) assert address in sched._schedule._due_at finally: new_auto_cancel() except BaseException: passive_cancel() raise finally: cancel() @pytest.mark.asyncio async def test_start_replays_pre_start_requests_into_due_at() -> None: """ add_request before start() seeds _due_at at start() if history exists. Also covers the no-history skip path and the already-in-existing-entries no-op so the replay loop's branches are all exercised. """ manager = get_manager() sched = manager._auto_scheduler address_with_history = "11:22:33:44:55:80" address_no_history = "11:22:33:44:55:81" # Get history in place for one address only. scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:21", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) _inject(scanner, address_with_history) try: saved_loop = sched._loop assert saved_loop is not None sched._loop = None sched._running = False try: # Register TWO requests on the with-history address so # we can pre-populate _due_at with one of them and prove # start() (a) leaves the pre-existing entry alone and # (b) inserts a fresh entry for the other. cancel_with_a = manager.async_register_active_scan( address_with_history, scan_interval=60.0, scan_duration=5.0 ) cancel_with_b = manager.async_register_active_scan( address_with_history, scan_interval=120.0, scan_duration=5.0 ) cancel_without = manager.async_register_active_scan( address_no_history, scan_interval=60.0, scan_duration=5.0 ) try: assert address_with_history not in sched._schedule._due_at requests = list(sched._requests_by_address[address_with_history]) pre_existing, to_be_inserted = requests # Pre-populate _due_at with one request only. The # sentinel is well above loop.time() + scan_interval # so the test is robust against the loop being # freshly-started (CI) or long-lived; we don't care # about the absolute value, only that start() leaves # it alone. sentinel = saved_loop.time() + 1.0e9 sched._schedule._due_at[address_with_history] = {pre_existing: sentinel} before_start = saved_loop.time() sched.start(saved_loop) seeded = sched._schedule._due_at[address_with_history] # The pre-existing entry was left alone (covers the # `request not in existing` False branch). assert seeded[pre_existing] == sentinel # The other request got freshly inserted (covers the # insert line in the replay loop). assert to_be_inserted in seeded assert seeded[to_be_inserted] == pytest.approx( before_start + to_be_inserted.scan_interval, abs=0.1 ) # No-history address: skipped by the # `last_service_info(...) is None` branch. assert address_no_history not in sched._schedule._due_at finally: cancel_with_a() cancel_with_b() cancel_without() finally: sched._loop = saved_loop sched._running = True finally: register_cancel() @pytest.mark.asyncio async def test_start_is_idempotent_when_already_running() -> None: """ A second start() call without an intervening stop() is a no-op. Guards against an accidental double-call binding a different loop to the same scheduler or re-running the pre-start replay block. """ manager = get_manager() sched = manager._auto_scheduler # The conftest's async_setup already called start(), so _running # is True. A second start with a different loop must NOT replace # _loop or re-run anything. original_loop = sched._loop bogus_loop = object() sched.start(bogus_loop) # type: ignore[arg-type] assert sched._loop is original_loop @pytest.mark.asyncio async def test_dispatch_does_not_resurrect_cancelled_request() -> None: """A request cancelled while the window awaits is not re-added to entries.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan( address, scan_interval=60.0, scan_duration=6.0 ) gate = asyncio.Event() class _CancelDuringWindow(_RecordingAutoScanner): async def async_request_active_window(self, duration: float) -> bool: # Mid-window: caller cancels the registration. cancel() gate.set() return True scanner = _CancelDuringWindow("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) entries = sched._schedule._due_at[address] request = next(iter(entries)) entries[request] = loop.time() - 1.0 await sched._workers[scanner.source]._tick() await gate.wait() # remove_request emptied the bucket; the tick must not have # re-added the cancelled request. assert address not in sched._schedule._due_at finally: register_cancel() @pytest.mark.asyncio async def test_dispatch_skips_address_owned_by_other_scanner() -> None: """An address whose owner is a different scanner is left alone.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan(address, scan_interval=60.0) owner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) other = _RecordingAutoScanner("AA:BB:CC:DD:EE:11", BluetoothScanningMode.AUTO) c1 = manager.async_register_scanner(owner) c2 = manager.async_register_scanner(other) try: _inject(owner, address) entries = sched._schedule._due_at[address] for req in list(entries): entries[req] = loop.time() - 1.0 # The "other" scanner runs its tick. The address is owned by # owner, so other should not fire its window. await sched._workers[other.source]._tick() assert other.active_window_calls == [] finally: cancel() c1() c2() @pytest.mark.asyncio async def test_next_event_at_returns_current_window_end() -> None: """While a window is in flight, next event is its end time.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] worker._window_end = loop.time() + 42.0 assert worker._next_event_at(loop.time()) == worker._window_end finally: register_cancel() @pytest.mark.asyncio async def test_next_event_at_returns_earliest_per_device_need() -> None: """Per-device entries owned by this scanner influence the next-event time.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan(address, scan_interval=120.0) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) worker = sched._workers[scanner.source] # Sweep is far in the future (initial delay window). The earliest # event for the worker is the per-device next-due. entries = sched._schedule._due_at[address] request = next(iter(entries)) per_device_at = loop.time() + 5.0 entries[request] = per_device_at assert worker._next_event_at(loop.time()) == per_device_at finally: cancel() register_cancel() @pytest.mark.asyncio async def test_dispatch_per_device_skips_not_yet_due() -> None: """Entries with future due times don't fire.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan(address, scan_interval=120.0) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) # Push the due time far in the future. entries = sched._schedule._due_at[address] for request in list(entries): entries[request] = loop.time() + 1000.0 await sched._workers[scanner.source]._tick() assert scanner.active_window_calls == [] finally: cancel() register_cancel() @pytest.mark.asyncio async def test_tick_skips_when_sweep_not_due_and_no_per_device() -> None: """No-op tick: no per-device work due, sweep not due either.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] worker._sweep_last_completed = loop.time() await worker._tick() assert scanner.active_window_calls == [] finally: register_cancel() @pytest.mark.asyncio async def test_worker_tick_no_op_when_loop_detached() -> None: """Worker tick exits cleanly if the scheduler's loop is None.""" manager = get_manager() sched = manager._auto_scheduler scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] original_loop = sched._loop sched._loop = None try: await worker._tick() finally: sched._loop = original_loop assert scanner.active_window_calls == [] finally: register_cancel() @pytest.mark.asyncio async def test_tick_no_op_when_already_inside_window() -> None: """A tick that arrives while a window is in flight returns early.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] # Pretend a window is mid-flight; _tick must defer to that # window and not start a new one. worker._window_end = loop.time() + 60.0 await worker._tick() assert scanner.active_window_calls == [] finally: register_cancel() async def _replace_worker_task(worker: object) -> None: """Cancel the worker's existing task so a fresh _run() can be tested.""" task = worker._task # type: ignore[attr-defined] if task is not None and not task.done(): task.cancel() with contextlib.suppress(asyncio.CancelledError): await task @pytest.mark.asyncio async def test_run_exits_when_scheduler_not_running() -> None: """The worker's _run loop exits cleanly when _running is False after a wake.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] await _replace_worker_task(worker) # Put the sweep clock far in the past so _next_event_at returns # a time <= now and the wait_for branch is skipped; the loop # falls straight through to the "not running" check. worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 sched._running = False new_task = loop.create_task(worker._run()) await asyncio.wait_for(new_task, timeout=1.0) assert new_task.done() assert not new_task.cancelled() finally: sched._running = True register_cancel() @pytest.mark.asyncio async def test_run_exits_when_loop_detached() -> None: """The worker's _run loop exits when scheduler._loop becomes None.""" manager = get_manager() sched = manager._auto_scheduler scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] await _replace_worker_task(worker) original_loop = sched._loop sched._loop = None new_task = asyncio.get_running_loop().create_task(worker._run()) await asyncio.wait_for(new_task, timeout=1.0) assert new_task.done() assert not new_task.cancelled() sched._loop = original_loop finally: register_cancel() @pytest.mark.asyncio async def test_add_request_without_history_does_not_wake() -> None: """When the address has never been seen, add_request is a pure registry op.""" manager = get_manager() sched = manager._auto_scheduler scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] worker._wake.clear() cancel = manager.async_register_active_scan( "AA:AA:AA:AA:AA:AA", scan_interval=60.0 ) # No prior advertisement: history is None, so no wake is sent. assert not worker._wake.is_set() cancel() finally: register_cancel() @pytest.mark.asyncio async def test_remove_request_handles_missing_bucket() -> None: """remove_request tolerates a request whose bucket is already gone.""" manager = get_manager() sched = manager._auto_scheduler request = ActiveScanRequest("AA:BB:CC:DD:EE:99", 60.0, 10.0) # Bucket was never added; remove_request must be a no-op. sched.remove_request(request) assert "AA:BB:CC:DD:EE:99" not in sched._requests_by_address assert "AA:BB:CC:DD:EE:99" not in sched._schedule._due_at @pytest.mark.asyncio async def test_on_advertisement_no_match_no_wake() -> None: """An ad whose address has no registered request doesn't add anything.""" manager = get_manager() sched = manager._auto_scheduler cancel = manager.async_register_active_scan("11:22:33:44:55:66", scan_interval=60.0) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] worker._wake.clear() _inject(scanner, "AA:AA:AA:AA:AA:AA") assert "AA:AA:AA:AA:AA:AA" not in sched._schedule._due_at assert not worker._wake.is_set() finally: cancel() register_cancel() @pytest.mark.asyncio async def test_on_advertisement_wakes_on_every_ad_for_tracked_address() -> None: """ Every ad on a tracked address wakes the source's worker. The wake is what makes ownership-flip detection work: when this scanner becomes the new owner mid-sleep, the wake forces the worker to re-evaluate _next_event_at and pick up the entry that is now owned by it. """ manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:66" cancel1 = manager.async_register_active_scan(address, scan_interval=60.0) cancel2 = manager.async_register_active_scan(address, scan_interval=120.0) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] _inject(scanner, address) worker._wake.clear() _inject(scanner, address) # Second inject still wakes; the wake is unconditional now so # ownership flips on an existing entry are seen by the new # owner. assert worker._wake.is_set() finally: cancel1() cancel2() register_cancel() @pytest.mark.asyncio async def test_on_advertisement_with_all_requests_already_tracked() -> None: """on_advertisement still wakes when every request is already in _due_at.""" manager = get_manager() sched = manager._auto_scheduler scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) address = "11:22:33:44:55:66" cancel_a = manager.async_register_active_scan(address, scan_interval=60.0) cancel_b = manager.async_register_active_scan(address, scan_interval=120.0) try: # First advertisement seeds + assigns + wakes. _inject(scanner, address) worker = sched._workers[scanner.source] entries_before = dict(sched._schedule._due_at[address]) worker._wake.clear() # Second advertisement: all requests already tracked; assign # still wakes for ownership-flip detection. _inject(scanner, address) assert worker._wake.is_set() # Sanity: the entries we put in are untouched. assert sched._schedule._due_at[address] == entries_before finally: cancel_a() cancel_b() register_cancel() @pytest.mark.asyncio async def test_add_request_with_history_wakes_owning_worker() -> None: """add_request wakes the worker whose scanner currently sees the address.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:66" scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: # Populate manager._all_history WITHOUT first registering an # active scan, so the inject doesn't go through on_advertisement's # wake-on-added path. add_request then sees the history entry # and assign fires the worker wake itself. _inject(scanner, address) worker = sched._workers[scanner.source] worker._wake.clear() cancel = manager.async_register_active_scan(address, scan_interval=60.0) assert worker._wake.is_set() cancel() finally: register_cancel() @pytest.mark.asyncio async def test_next_event_at_skips_per_device_later_than_sweep() -> None: """A per-device next-due later than the sweep cadence does not lower next_at.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan(address, scan_interval=60.0) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) worker = sched._workers[scanner.source] sweep_at = worker._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL # Push per-device need past the sweep cadence so the earliest < # next_at branch is False inside _next_event_at. for req in list(sched._schedule._due_at[address]): sched._schedule._due_at[address][req] = sweep_at + 100.0 assert worker._next_event_at(loop.time()) == sweep_at finally: cancel() register_cancel() @pytest.mark.asyncio async def test_start_ignores_non_auto_scanner() -> None: """A non-AUTO scanner already on the manager doesn't get a worker on start.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() auto = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) active = _RecordingAutoScanner("AA:BB:CC:DD:EE:11", BluetoothScanningMode.ACTIVE) c_auto = manager.async_register_scanner(auto) c_active = manager.async_register_scanner(active) try: assert active.source not in sched._workers # Re-run start() so the False branch (non-AUTO scanner) of the # `if scanner.requested_mode is AUTO` check inside start() is hit. # First shut down the worker tasks the existing start() already # spawned so we don't leak. Also flip _running back to False so # start()'s idempotency guard lets the re-run through. for worker in list(sched._workers.values()): await _replace_worker_task(worker) sched._workers.clear() sched._running = False sched.start(loop) assert auto.source in sched._workers assert active.source not in sched._workers finally: c_auto() c_active() @pytest.mark.asyncio async def test_coalesce_three_due_uses_max_clamped() -> None: """Three due requests on one address fire one window using max duration.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" c1 = manager.async_register_active_scan( address, scan_interval=60.0, scan_duration=5.0 ) c2 = manager.async_register_active_scan( address, scan_interval=60.0, scan_duration=7.0 ) c3 = manager.async_register_active_scan( address, scan_interval=60.0, scan_duration=9.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) entries = sched._schedule._due_at[address] for req in list(entries): entries[req] = loop.time() - 1.0 await sched._workers[scanner.source]._tick() assert scanner.active_window_calls == [9.0] finally: c1() c2() c3() register_cancel() @pytest.mark.asyncio async def test_coalesce_clamps_oversize_request() -> None: """A scan_duration above the max is clamped on dispatch.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan( address, scan_interval=60.0, scan_duration=AUTO_WINDOW_MAX_DURATION + 50.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) entries = sched._schedule._due_at[address] for req in list(entries): entries[req] = loop.time() - 1.0 await sched._workers[scanner.source]._tick() assert scanner.active_window_calls == [AUTO_WINDOW_MAX_DURATION] finally: cancel() register_cancel() @pytest.mark.asyncio async def test_coalesce_only_due_requests_count() -> None: """Only the requests that are actually due contribute to coalesced duration.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" c_short = manager.async_register_active_scan( address, scan_interval=60.0, scan_duration=5.0 ) c_long = manager.async_register_active_scan( address, scan_interval=300.0, scan_duration=20.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) entries = sched._schedule._due_at[address] short_req = next(r for r in entries if r.scan_duration == 5.0) long_req = next(r for r in entries if r.scan_duration == 20.0) # Only the short request is due; the long one is well in the # future and must not pull its bigger duration into the window. entries[short_req] = loop.time() - 1.0 entries[long_req] = loop.time() + 200.0 await sched._workers[scanner.source]._tick() assert scanner.active_window_calls == [5.0] finally: c_short() c_long() register_cancel() @pytest.mark.asyncio async def test_coalesce_distinct_addresses_share_one_window() -> None: """Two due addresses on the same scanner share one max-duration window.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() addr_a = "11:22:33:44:55:01" addr_b = "11:22:33:44:55:02" c1 = manager.async_register_active_scan( addr_a, scan_interval=60.0, scan_duration=6.0 ) c2 = manager.async_register_active_scan( addr_b, scan_interval=60.0, scan_duration=7.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, addr_a) _inject(scanner, addr_b) for address in (addr_a, addr_b): entries = sched._schedule._due_at[address] for req in list(entries): entries[req] = loop.time() - 1.0 await sched._workers[scanner.source]._tick() # A single ACTIVE flip covers both devices; the window length is # the max of every due request's duration. assert scanner.active_window_calls == [7.0] finally: c1() c2() register_cancel() @pytest.mark.asyncio async def test_tick_combines_due_sweep_and_per_device_into_one_window() -> None: """A due sweep + due per-device fold into a single window at max duration.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan( address, scan_interval=60.0, scan_duration=6.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) entries = sched._schedule._due_at[address] for req in list(entries): entries[req] = loop.time() - 1.0 worker = sched._workers[scanner.source] worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 await worker._tick() # The sweep duration (15s) beats the per-device duration (3s) # so the merged window is sized to the sweep. assert scanner.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION] # Sweep clock advanced. assert worker._sweep_last_completed > loop.time() - 1.0 finally: cancel() register_cancel() @pytest.mark.asyncio async def test_three_inkbirds_share_one_scan() -> None: """ Three Inkbirds at the same 5min / 15s cadence share a single window. Each Inkbird has its own address but all three are owned by the same scanner and become due at the same time. The worker coalesces every due request across all addresses into a single 15s active window, so the radio only stops and restarts once per tick. """ manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() addresses = ["C0:01:01:11:11:11", "C0:01:01:22:22:22", "C0:01:01:33:33:33"] cancels = [ manager.async_register_active_scan( addr, scan_interval=300.0, scan_duration=15.0 ) for addr in addresses ] scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: for addr in addresses: _inject(scanner, addr) entries = sched._schedule._due_at[addr] for req in list(entries): entries[req] = loop.time() - 1.0 await sched._workers[scanner.source]._tick() # All three addresses fold into one coalesced 15s window. assert scanner.active_window_calls == [15.0] # Next-due moved forward by scan_interval for every request. for addr in addresses: for due in sched._schedule._due_at[addr].values(): assert due > loop.time() + 250.0 finally: for cancel in cancels: cancel() register_cancel() @pytest.mark.asyncio async def test_dispatch_coalesces_different_durations_to_max() -> None: """Two addresses with different durations fire one window at the max.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() addr_short = "11:22:33:44:55:01" addr_long = "11:22:33:44:55:02" c_short = manager.async_register_active_scan( addr_short, scan_interval=60.0, scan_duration=6.0 ) c_long = manager.async_register_active_scan( addr_long, scan_interval=60.0, scan_duration=12.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: for addr in (addr_short, addr_long): _inject(scanner, addr) entries = sched._schedule._due_at[addr] for req in list(entries): entries[req] = loop.time() - 1.0 await sched._workers[scanner.source]._tick() # Single window sized to the larger of the two durations. assert scanner.active_window_calls == [12.0] finally: c_short() c_long() register_cancel() @pytest.mark.asyncio async def test_three_inkbirds_same_address_coalesce_to_one_scan() -> None: """ Three Inkbird-style registrations on the same address share one window. Realistic case: three integrations each register their own callback for the same Inkbird; the scheduler must NOT fire 3 separate 15s windows back-to-back. Instead all three requests coalesce into one single 15s window via _coalesce_duration's max-of-durations. """ manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "C0:01:01:11:11:11" cancels = [ manager.async_register_active_scan( address, scan_interval=300.0, scan_duration=15.0 ) for _ in range(3) ] scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) entries = sched._schedule._due_at[address] assert len(entries) == 3 for req in list(entries): entries[req] = loop.time() - 1.0 await sched._workers[scanner.source]._tick() # All three coalesced into a single 15s window. assert scanner.active_window_calls == [15.0] # Each request's next-due advanced by its own scan_interval. for due in entries.values(): assert due > loop.time() + 250.0 finally: for cancel in cancels: cancel() register_cancel() @pytest.mark.asyncio async def test_three_inkbirds_window_unchanged_after_removal() -> None: """ Removing one of three same-address registrations preserves the window. All three asked for the same 15s duration so the coalesced window is 15s. Cancelling one of them leaves two requests still asking for 15s; the resulting window must still be 15s, not regress to the MIN_DURATION floor. """ manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "C0:01:01:11:11:11" cancels = [ manager.async_register_active_scan( address, scan_interval=300.0, scan_duration=15.0 ) for _ in range(3) ] scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) entries = sched._schedule._due_at[address] assert len(entries) == 3 # Cancel one of the three; two should remain in both the registry # and the _due_at tracker. cancels.pop()() assert len(sched._requests_by_address[address]) == 2 entries = sched._schedule._due_at[address] assert len(entries) == 2 for req in list(entries): entries[req] = loop.time() - 1.0 await sched._workers[scanner.source]._tick() # Window duration is unchanged because the remaining two still # ask for 15s; coalesce takes the max. assert scanner.active_window_calls == [15.0] finally: for cancel in cancels: cancel() register_cancel() @pytest.mark.asyncio async def test_only_owning_scanner_fires_among_four() -> None: """ Of four AUTO scanners, only the one owning the device's history fires. The device is injected from one specific scanner so the manager's _all_history points at that source. Every worker's _tick runs; only the owner produces an active window. The other three scanners stay PASSIVE. """ manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan( address, scan_interval=60.0, scan_duration=5.0 ) scanners = [ _RecordingAutoScanner(f"AA:00:00:00:00:0{n}", BluetoothScanningMode.AUTO) for n in range(4) ] register_cancels = [manager.async_register_scanner(s) for s in scanners] try: owner = scanners[2] _inject(owner, address) entries = sched._schedule._due_at[address] for req in list(entries): entries[req] = loop.time() - 1.0 for scanner in scanners: await sched._workers[scanner.source]._tick() # Only the owning scanner flipped to ACTIVE for the requested 5s. assert [s.active_window_calls for s in scanners] == [[], [], [5.0], []] finally: for c in register_cancels: c() cancel() @pytest.mark.asyncio async def test_add_request_before_start_does_not_seed_due_at() -> None: """If add_request runs before start() the entry is deferred to advertisement.""" manager = get_manager() sched = manager._auto_scheduler address = "BB:00:00:00:00:00" original_loop = sched._loop sched._loop = None try: sched.add_request(ActiveScanRequest(address, 60.0, 10.0)) assert address in sched._requests_by_address assert address not in sched._schedule._due_at finally: sched._loop = original_loop sched._requests_by_address.pop(address, None) @pytest.mark.asyncio async def test_add_request_idempotent_keeps_existing_due() -> None: """ Re-adding the same request preserves its existing next-due time. Also verifies the wake is gated on "actually inserted a new entry": a re-register (e.g. an HA config-entry reload) is a no-op on the schedule, so the worker should not be woken. """ manager = get_manager() sched = manager._auto_scheduler address = "BC:00:00:00:00:00" scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:42", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: request = ActiveScanRequest(address, 60.0, 10.0) sched.add_request(request) # Inject so add_request can see history on the second call. _inject(scanner, address) sched._schedule._due_at[address][request] = 1234.5 worker = sched._workers[scanner.source] worker._wake.clear() sched.add_request(request) assert sched._schedule._due_at[address][request] == 1234.5 # No new entry → no wake. assert not worker._wake.is_set() sched.remove_request(request) finally: register_cancel() @pytest.mark.asyncio async def test_run_loop_waits_then_ticks() -> None: """The _run loop's wait_for + _tick path is exercised end-to-end.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] await _replace_worker_task(worker) # Sweep ~1ms in the future so _run's wait_for times out quickly # and _tick runs once before we shut it down. worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL + 0.001 task = loop.create_task(worker._run()) await asyncio.sleep(0.05) assert scanner.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION] sched._running = False worker._wake.set() await asyncio.wait_for(task, timeout=1.0) finally: sched._running = True register_cancel() @pytest.mark.asyncio async def test_owner_flip_during_window_does_not_double_fire() -> None: """ If ownership flips to a second scanner mid-window, no duplicate fire. Worker A starts its window for address X. While A awaits the radio, a new advertisement makes B the owner (B's _all_history.source). B's worker wakes and ticks. Because A advanced X's next-due BEFORE starting the await, B's _collect_due_buckets sees the entry as not yet due and skips it. A finishes alone with one window; B fires none. """ manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan( address, scan_interval=60.0, scan_duration=5.0 ) gate = asyncio.Event() s_a = _RecordingAutoScanner("AA:00:00:00:00:01", BluetoothScanningMode.AUTO) s_a._block_event = gate s_b = _RecordingAutoScanner("AA:00:00:00:00:02", BluetoothScanningMode.AUTO) c_a = manager.async_register_scanner(s_a) c_b = manager.async_register_scanner(s_b) try: _inject(s_a, address) entries = sched._schedule._due_at[address] for req in list(entries): entries[req] = loop.time() - 1.0 # Worker A starts its tick and blocks inside the scanner call. t_a = asyncio.create_task(sched._workers[s_a.source]._tick()) for _ in range(4): await asyncio.sleep(0) assert s_a.active_window_calls == [5.0] # A advanced entries BEFORE the await; verify that. for due in entries.values(): assert due > loop.time() + 50.0 # Ownership flips to B (a fresh advertisement on B). _inject(s_b, address) # B's worker ticks. Because the entry is already in the future # it must NOT fire a second window. await sched._workers[s_b.source]._tick() assert s_b.active_window_calls == [] gate.set() await t_a finally: gate.set() c_a() c_b() cancel() def _inject_with_rssi(scanner: _RecordingAutoScanner, address: str, rssi: int) -> None: """Drive an advertisement through the scanner with a specific RSSI.""" adv = generate_advertisement_data(local_name="x", rssi=rssi) device = generate_ble_device(address, "x") scanner._async_on_advertisement( device.address, adv.rssi, device.name or "", adv.service_uuids, adv.service_data, adv.manufacturer_data, adv.tx_power, {}, asyncio.get_running_loop().time(), ) @pytest.mark.asyncio async def test_device_migration_between_scanners_fires_on_new_owner() -> None: """ Migrating from scanner A to B fires the next window on B, not on A. Sequence: register active_scan. A sees the device first and becomes owner. A's worker fires the first window. The device then comes through B with a much stronger RSSI so the manager's ADV_RSSI_SWITCH_THRESHOLD flips ownership. Make the entry due again and tick both workers: B fires the new window, A skips. """ manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:99" cancel = manager.async_register_active_scan( address, scan_interval=60.0, scan_duration=5.0 ) s_a = _RecordingAutoScanner("AA:00:00:00:00:01", BluetoothScanningMode.AUTO) s_b = _RecordingAutoScanner("AA:00:00:00:00:02", BluetoothScanningMode.AUTO) c_a = manager.async_register_scanner(s_a) c_b = manager.async_register_scanner(s_b) try: # A sees the device first; A becomes owner. _inject_with_rssi(s_a, address, rssi=-80) info_a = manager.async_last_service_info(address, False) assert info_a is not None assert info_a.source == s_a.source # Make the existing tracking entry due and fire the first # window on A. entries = sched._schedule._due_at[address] for req in list(entries): entries[req] = loop.time() - 1.0 await sched._workers[s_a.source]._tick() assert s_a.active_window_calls == [5.0] assert s_b.active_window_calls == [] # Device migrates to B with much stronger signal (delta beats # ADV_RSSI_SWITCH_THRESHOLD). The manager flips # _all_history.source to B. _inject_with_rssi(s_b, address, rssi=-30) info_b = manager.async_last_service_info(address, False) assert info_b is not None assert info_b.source == s_b.source # Force the entry due again and run both workers. B (the new # owner) fires; A skips because history.source is no longer # A's source. for req in list(entries): entries[req] = loop.time() - 1.0 await sched._workers[s_a.source]._tick() await sched._workers[s_b.source]._tick() assert s_a.active_window_calls == [5.0] assert s_b.active_window_calls == [5.0] finally: c_a() c_b() cancel() @pytest.mark.asyncio async def test_device_migration_wakes_new_owner_worker() -> None: """ A fresh advertisement on the new owner wakes its worker. Without this wake, a worker that became the owner mid-sleep would sit until its previously-computed _next_event_at (sweep cadence) even though there's a tracked address whose due time is much sooner. The wake is on_advertisement's job and must fire even when the _due_at entry already exists (i.e. the ad doesn't add a new request, it just notifies us this scanner now sees the device). """ manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:AA" cancel = manager.async_register_active_scan(address, scan_interval=60.0) s_a = _RecordingAutoScanner("AA:00:00:00:00:01", BluetoothScanningMode.AUTO) s_b = _RecordingAutoScanner("AA:00:00:00:00:02", BluetoothScanningMode.AUTO) c_a = manager.async_register_scanner(s_a) c_b = manager.async_register_scanner(s_b) try: # A sees the device first. _inject_with_rssi(s_a, address, rssi=-80) worker_b = sched._workers[s_b.source] worker_b._wake.clear() # B sees the device with stronger RSSI and becomes the new # owner. B's worker must be woken so it re-evaluates # _next_event_at and picks up the existing entry. _inject_with_rssi(s_b, address, rssi=-30) assert worker_b._wake.is_set() finally: c_a() c_b() cancel() @pytest.mark.asyncio async def test_stop_clears_due_at_so_restart_does_not_reuse_stale_due_times() -> None: """ stop() drops _due_at so a later start(new_loop) seeds fresh due-times. Without this, a restart against a different event loop (whose ``time()`` origin differs) would reuse timestamps from the cancelled loop and either fire windows immediately or never. """ manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:CC" cancel = manager.async_register_active_scan( address, scan_interval=60.0, scan_duration=5.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:CC", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) assert address in sched._schedule._due_at original_loop = sched._loop sched.stop() # _due_at cleared so stale timestamps from the now-defunct loop # can't survive into a re-start. assert sched._schedule._due_at == {} assert sched._loop is None assert sched._workers == {} # _requests_by_address is loop-independent and must persist so # start() can replay registrations on the new loop. assert address in sched._requests_by_address # Restart against the same loop; the request gets re-seeded with # a fresh due time from the new loop.time() base. assert original_loop is not None sched.start(original_loop) assert address in sched._schedule._due_at entries = sched._schedule._due_at[address] expected_due = original_loop.time() + 60.0 assert all(abs(due - expected_due) < 0.5 for due in entries.values()) finally: cancel() register_cancel() class _DiscoverableAutoScanner(_RecordingAutoScanner): """Recording scanner that reports a configurable discovered set.""" __slots__ = ("_discovered",) def __init__( self, source: str, mode: BluetoothScanningMode | None, connectable: bool = True, ) -> None: super().__init__(source, mode, connectable) self._discovered: dict[str, tuple[BLEDevice, AdvertisementData]] = {} def add_discovered(self, address: str, rssi: int | None = -60) -> None: """Mark ``address`` as currently discovered by this scanner.""" device = generate_ble_device(address, "x") adv = generate_advertisement_data(local_name="x", rssi=rssi) self._discovered[address] = (device, adv) def get_discovered_device_advertisement_data( self, address: str ) -> tuple[BLEDevice, AdvertisementData] | None: return self._discovered.get(address) def _make_due(sched: object, address: str) -> None: """Make every tracked request for ``address`` due immediately.""" entries = sched._schedule._due_at[address] # type: ignore[attr-defined] loop = asyncio.get_running_loop() for req in list(entries): entries[req] = loop.time() - 1.0 @pytest.mark.asyncio async def test_worker_tick_delegates_to_fallback_when_owner_is_connecting() -> None: """ Owner mid-connect dispatches the active-window scan to fallback. Owner scanner is in the connect-attempt phase (``_connections_in_progress() > 0``) so its radio can't service the active-window flip. A second AUTO scanner also sees the device. The worker for the owner must call ``async_request_active_window`` on the fallback, not on the owner. """ manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:01" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=7.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:01:01", BluetoothScanningMode.AUTO) fallback = _DiscoverableAutoScanner("AA:00:00:00:01:02", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_fallback = manager.async_register_scanner(fallback) try: _inject_with_rssi(owner, address, rssi=-50) info = manager.async_last_service_info(address, False) assert info is not None assert info.source == owner.source fallback.add_discovered(address, rssi=-70) owner._add_connecting(address) _make_due(sched, address) await _run_worker_tick(sched, owner.source) # Owner can't service the flip; fallback gets the call. assert owner.active_window_calls == [] assert fallback.active_window_calls == [7.0] finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_fallback() @pytest.mark.asyncio async def test_worker_tick_warns_when_no_fallback_available( caplog: pytest.LogCaptureFixture, ) -> None: """No fallback emits a single WARNING; owner is not flipped.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:02" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:02:01", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) try: _inject_with_rssi(owner, address, rssi=-50) owner._add_connecting(address) _make_due(sched, address) with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"): await _run_worker_tick(sched, owner.source) assert owner.active_window_calls == [] assert any( "no fallback scanner" in record.message and address in record.message for record in caplog.records ) finally: owner._finished_connecting(address, connected=False) cancel() c_owner() @pytest.mark.asyncio async def test_worker_tick_no_fallback_warning_is_rate_limited( caplog: pytest.LogCaptureFixture, ) -> None: """A second connecting tick with no fallback does not re-warn.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:03" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:03:01", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) try: _inject_with_rssi(owner, address, rssi=-50) owner._add_connecting(address) _make_due(sched, address) with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"): await _run_worker_tick(sched, owner.source) count_after_first = sum( 1 for record in caplog.records if "no fallback scanner" in record.message ) assert count_after_first == 1 _make_due(sched, address) await _run_worker_tick(sched, owner.source) count_after_second = sum( 1 for record in caplog.records if "no fallback scanner" in record.message ) assert count_after_second == 1 finally: owner._finished_connecting(address, connected=False) cancel() c_owner() @pytest.mark.asyncio async def test_worker_tick_no_fallback_flag_resets_after_recovery( caplog: pytest.LogCaptureFixture, ) -> None: """Successful fallback dispatch re-arms the no-fallback warning.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:04" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:04:01", BluetoothScanningMode.AUTO) fallback = _DiscoverableAutoScanner("AA:00:00:00:04:02", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_fallback = manager.async_register_scanner(fallback) try: _inject_with_rssi(owner, address, rssi=-50) owner._add_connecting(address) _make_due(sched, address) with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"): # No fallback -> warning. await _run_worker_tick(sched, owner.source) assert sched._workers[owner.source]._warned_no_fallback is True # Fallback appears, dispatch succeeds -> flag clears. fallback.add_discovered(address, rssi=-70) _make_due(sched, address) await _run_worker_tick(sched, owner.source) assert fallback.active_window_calls == [6.0] assert sched._workers[owner.source]._warned_no_fallback is False # Fallback disappears again -> warning fires once more. fallback._discovered.clear() _make_due(sched, address) caplog.clear() await _run_worker_tick(sched, owner.source) assert any( "no fallback scanner" in record.message for record in caplog.records ) finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_fallback() @pytest.mark.asyncio async def test_worker_tick_active_scanner_covers_address_no_warning( caplog: pytest.LogCaptureFixture, ) -> None: """ ACTIVE-mode scanner seeing the address counts as scan done. The owner is mid-connect, so it can't service the active-window flip. Another scanner has ``requested_mode is ACTIVE`` and sees the address — by definition that scanner is already actively scanning. The dispatch must drop the request silently: no warning, no ``async_request_active_window`` call on the ACTIVE scanner (which would no-op anyway via the ``requested_mode`` guard in ``HaScanner.async_request_active_window``). """ manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:05" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:05:01", BluetoothScanningMode.AUTO) active = _DiscoverableAutoScanner("AA:00:00:00:05:03", BluetoothScanningMode.ACTIVE) c_owner = manager.async_register_scanner(owner) c_active = manager.async_register_scanner(active) try: _inject_with_rssi(owner, address, rssi=-50) active.add_discovered(address, rssi=-60) owner._add_connecting(address) _make_due(sched, address) with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"): await _run_worker_tick(sched, owner.source) assert owner.active_window_calls == [] assert active.active_window_calls == [] assert not any( "no fallback scanner" in record.message for record in caplog.records ) assert sched._workers[owner.source]._warned_no_fallback is False finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_active() @pytest.mark.asyncio async def test_worker_tick_active_coverage_preferred_over_auto_fallback() -> None: """When ACTIVE covers, no AUTO-fallback flip is needed either.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:0C" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:0C:01", BluetoothScanningMode.AUTO) auto_fb = _DiscoverableAutoScanner("AA:00:00:00:0C:02", BluetoothScanningMode.AUTO) active = _DiscoverableAutoScanner("AA:00:00:00:0C:03", BluetoothScanningMode.ACTIVE) c_owner = manager.async_register_scanner(owner) c_auto = manager.async_register_scanner(auto_fb) c_active = manager.async_register_scanner(active) try: _inject_with_rssi(owner, address, rssi=-50) auto_fb.add_discovered(address, rssi=-55) active.add_discovered(address, rssi=-70) owner._add_connecting(address) _make_due(sched, address) await _run_worker_tick(sched, owner.source) # Covered by ACTIVE: no flip needed on AUTO fallback either. assert owner.active_window_calls == [] assert auto_fb.active_window_calls == [] assert active.active_window_calls == [] finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_auto() c_active() @pytest.mark.asyncio async def test_worker_tick_passive_only_fallback_warns( caplog: pytest.LogCaptureFixture, ) -> None: """ Only PASSIVE scanners around: no flip possible, must warn. A PASSIVE scanner refuses ``async_request_active_window`` and isn't actively scanning, so the active scan is truly deferred until the owner's connect completes — the warning must fire. """ manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:0D" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:0D:01", BluetoothScanningMode.AUTO) passive = _DiscoverableAutoScanner( "AA:00:00:00:0D:02", BluetoothScanningMode.PASSIVE ) c_owner = manager.async_register_scanner(owner) c_passive = manager.async_register_scanner(passive) try: _inject_with_rssi(owner, address, rssi=-50) passive.add_discovered(address, rssi=-60) owner._add_connecting(address) _make_due(sched, address) with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"): await _run_worker_tick(sched, owner.source) assert owner.active_window_calls == [] assert passive.active_window_calls == [] assert any( "no fallback scanner" in record.message and address in record.message for record in caplog.records ) finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_passive() @pytest.mark.asyncio async def test_worker_tick_dispatch_never_calls_same_fallback_twice() -> None: """ Per-tick dispatch never calls the same fallback more than once. Same-tick coalescing guarantees we don't simultaneously trigger ``async_request_active_window`` twice on one scanner from a single owner's tick. (Cross-tick concurrency between distinct owner workers delegating to the same fallback is handled inside the scanner: ``HaScanner.async_request_active_window`` extends an open active-window timer instead of stopping and restarting the radio, and the actual stop/start is serialized by ``_start_stop_lock``.) """ manager = get_manager() sched = manager._auto_scheduler addresses = ("11:22:33:44:55:0E", "11:22:33:44:55:0F", "11:22:33:44:55:10") cancels = [ manager.async_register_active_scan(addr, scan_interval=120.0, scan_duration=6.0) for addr in addresses ] owner = _DiscoverableAutoScanner("AA:00:00:00:0E:01", BluetoothScanningMode.AUTO) fb = _DiscoverableAutoScanner("AA:00:00:00:0E:02", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_fb = manager.async_register_scanner(fb) try: for addr in addresses: _inject_with_rssi(owner, addr, rssi=-50) fb.add_discovered(addr, rssi=-60) owner._add_connecting(addresses[0]) for addr in addresses: _make_due(sched, addr) await _run_worker_tick(sched, owner.source) # One coalesced call regardless of how many addresses route to fb. assert len(fb.active_window_calls) == 1 assert owner.active_window_calls == [] finally: owner._finished_connecting(addresses[0], connected=False) for cancel in cancels: cancel() c_owner() c_fb() @pytest.mark.asyncio async def test_worker_tick_active_covers_one_address_warns_for_other( caplog: pytest.LogCaptureFixture, ) -> None: """ Per-address classification: covered for one, warn for another. Owner is mid-connect with two due addresses. Address A is covered by an ACTIVE scanner; address B has no fallback. We expect: silent skip for A, warning for B that names only B. """ manager = get_manager() sched = manager._auto_scheduler addr_covered = "11:22:33:44:55:11" addr_orphan = "11:22:33:44:55:12" c1 = manager.async_register_active_scan( addr_covered, scan_interval=120.0, scan_duration=6.0 ) c2 = manager.async_register_active_scan( addr_orphan, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:11:01", BluetoothScanningMode.AUTO) active = _DiscoverableAutoScanner("AA:00:00:00:11:02", BluetoothScanningMode.ACTIVE) c_owner = manager.async_register_scanner(owner) c_active = manager.async_register_scanner(active) try: _inject_with_rssi(owner, addr_covered, rssi=-50) _inject_with_rssi(owner, addr_orphan, rssi=-50) active.add_discovered(addr_covered, rssi=-60) # Note: ACTIVE scanner does NOT see addr_orphan. owner._add_connecting(addr_covered) _make_due(sched, addr_covered) _make_due(sched, addr_orphan) with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"): await _run_worker_tick(sched, owner.source) warnings_for_orphan = [ record for record in caplog.records if "no fallback scanner" in record.message and addr_orphan in record.message ] assert len(warnings_for_orphan) == 1 # The covered address must not appear in any no-fallback # warning text. assert not any( "no fallback scanner" in record.message and addr_covered in record.message for record in caplog.records ) assert active.active_window_calls == [] assert owner.active_window_calls == [] finally: owner._finished_connecting(addr_covered, connected=False) c1() c2() c_owner() c_active() @pytest.mark.asyncio async def test_worker_tick_skips_fallback_that_is_also_connecting() -> None: """A candidate fallback that's mid-connect is also excluded.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:06" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:06:01", BluetoothScanningMode.AUTO) busy_fb = _DiscoverableAutoScanner("AA:00:00:00:06:02", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_busy = manager.async_register_scanner(busy_fb) try: _inject_with_rssi(owner, address, rssi=-50) busy_fb.add_discovered(address, rssi=-55) owner._add_connecting(address) busy_fb._add_connecting("AA:BB:CC:DD:EE:FF") _make_due(sched, address) await _run_worker_tick(sched, owner.source) assert owner.active_window_calls == [] assert busy_fb.active_window_calls == [] finally: owner._finished_connecting(address, connected=False) busy_fb._finished_connecting("AA:BB:CC:DD:EE:FF", connected=False) cancel() c_owner() c_busy() @pytest.mark.asyncio async def test_worker_tick_fallback_picks_highest_rssi() -> None: """When multiple AUTO fallbacks see the device, highest RSSI wins.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:07" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:07:01", BluetoothScanningMode.AUTO) weak = _DiscoverableAutoScanner("AA:00:00:00:07:02", BluetoothScanningMode.AUTO) strong = _DiscoverableAutoScanner("AA:00:00:00:07:03", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_weak = manager.async_register_scanner(weak) c_strong = manager.async_register_scanner(strong) try: _inject_with_rssi(owner, address, rssi=-50) weak.add_discovered(address, rssi=-90) strong.add_discovered(address, rssi=-40) owner._add_connecting(address) _make_due(sched, address) await _run_worker_tick(sched, owner.source) assert strong.active_window_calls == [6.0] assert weak.active_window_calls == [] finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_weak() c_strong() @pytest.mark.asyncio async def test_worker_tick_groups_addresses_by_fallback() -> None: """Two due addresses sharing one fallback coalesce to one call.""" manager = get_manager() sched = manager._auto_scheduler addr_a = "11:22:33:44:55:08" addr_b = "11:22:33:44:55:09" cancel_a = manager.async_register_active_scan( addr_a, scan_interval=120.0, scan_duration=6.0 ) cancel_b = manager.async_register_active_scan( addr_b, scan_interval=120.0, scan_duration=11.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:08:01", BluetoothScanningMode.AUTO) fb = _DiscoverableAutoScanner("AA:00:00:00:08:02", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_fb = manager.async_register_scanner(fb) try: _inject_with_rssi(owner, addr_a, rssi=-50) _inject_with_rssi(owner, addr_b, rssi=-50) fb.add_discovered(addr_a, rssi=-60) fb.add_discovered(addr_b, rssi=-60) owner._add_connecting(addr_a) _make_due(sched, addr_a) _make_due(sched, addr_b) await _run_worker_tick(sched, owner.source) # One coalesced call to the shared fallback with the max duration. assert fb.active_window_calls == [11.0] assert owner.active_window_calls == [] finally: owner._finished_connecting(addr_a, connected=False) cancel_a() cancel_b() c_owner() c_fb() @pytest.mark.asyncio async def test_worker_tick_defers_sweep_when_owner_is_connecting() -> None: """ Sweep is per-scanner; defer when connecting rather than spinning. With no per-device buckets but sweep_due True, the connecting branch must not call ``async_request_active_window`` on the owner. It must also advance ``_sweep_last_completed`` so the next worker tick re-evaluates in roughly ``_AUTO_CONNECTING_DEFER`` seconds rather than firing immediately. """ manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() owner = _DiscoverableAutoScanner("AA:00:00:00:09:01", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) try: worker = sched._workers[owner.source] # Force sweep due: place _sweep_last_completed safely in the # past so now > _sweep_last_completed + AUTO_REDISCOVERY_INTERVAL. worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 owner._add_connecting("11:22:33:44:55:0A") before = loop.time() await worker._tick() assert owner.active_window_calls == [] # Next-due time should be roughly now + _AUTO_CONNECTING_DEFER, # i.e. _sweep_last_completed + AUTO_REDISCOVERY_INTERVAL >= # before + (_AUTO_CONNECTING_DEFER - epsilon). next_sweep_due = worker._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL assert next_sweep_due >= before + 25.0 assert next_sweep_due <= loop.time() + 60.0 finally: owner._finished_connecting("11:22:33:44:55:0A", connected=False) c_owner() @pytest.mark.asyncio async def test_worker_tick_skips_fallback_when_owner_is_connected_not_connecting() -> ( None ): """A fully-connected (not connecting) owner still fires its own window.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:0B" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:0B:01", BluetoothScanningMode.AUTO) fb = _DiscoverableAutoScanner("AA:00:00:00:0B:02", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_fb = manager.async_register_scanner(fb) try: _inject_with_rssi(owner, address, rssi=-50) fb.add_discovered(address, rssi=-60) # No _add_connecting on the owner: connect has either not # started or has already completed. The owner is responsible # for the window; fallback stays silent. _make_due(sched, address) await _run_worker_tick(sched, owner.source) assert owner.active_window_calls == [6.0] assert fb.active_window_calls == [] finally: cancel() c_owner() c_fb() @pytest.mark.asyncio async def test_worker_tick_fallback_exception_does_not_block_others( caplog: pytest.LogCaptureFixture, ) -> None: """ A raising fallback gets logged; remaining fallbacks still run. Two due addresses route to two different fallbacks. The first fallback's ``async_request_active_window`` raises. The dispatch must still call the second fallback and the worker must remain alive. """ class _RaisingScanner(_DiscoverableAutoScanner): async def async_request_active_window(self, duration: float) -> bool: self.active_window_calls.append(duration) msg = "boom" raise RuntimeError(msg) manager = get_manager() sched = manager._auto_scheduler addr_a = "11:22:33:44:55:13" addr_b = "11:22:33:44:55:14" c1 = manager.async_register_active_scan( addr_a, scan_interval=120.0, scan_duration=6.0 ) c2 = manager.async_register_active_scan( addr_b, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:13:01", BluetoothScanningMode.AUTO) fb_bad = _RaisingScanner("AA:00:00:00:13:02", BluetoothScanningMode.AUTO) fb_good = _DiscoverableAutoScanner("AA:00:00:00:13:03", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_bad = manager.async_register_scanner(fb_bad) c_good = manager.async_register_scanner(fb_good) try: _inject_with_rssi(owner, addr_a, rssi=-50) _inject_with_rssi(owner, addr_b, rssi=-50) # Only fb_bad sees addr_a; only fb_good sees addr_b. So each # address routes to a different fallback. fb_bad.add_discovered(addr_a, rssi=-60) fb_good.add_discovered(addr_b, rssi=-60) owner._add_connecting(addr_a) _make_due(sched, addr_a) _make_due(sched, addr_b) with caplog.at_level(logging.ERROR, logger="habluetooth.auto_scheduler"): await _run_worker_tick(sched, owner.source) assert fb_bad.active_window_calls == [6.0] assert fb_good.active_window_calls == [6.0] assert any( "error dispatching fallback active window" in record.message and fb_bad.name in record.message for record in caplog.records ) finally: owner._finished_connecting(addr_a, connected=False) c1() c2() c_owner() c_bad() c_good() @pytest.mark.asyncio async def test_worker_tick_sweep_and_per_device_both_handled_when_connecting() -> None: """ Mixed tick: per-device dispatched to fallback AND sweep deferred. Sweep is due AND a per-device window is due AND the owner is mid-connect. The per-device flip lands on the fallback; the sweep is deferred (no flip on the owner) — both behaviors coexist. """ manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:15" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:15:01", BluetoothScanningMode.AUTO) fb = _DiscoverableAutoScanner("AA:00:00:00:15:02", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_fb = manager.async_register_scanner(fb) try: _inject_with_rssi(owner, address, rssi=-50) fb.add_discovered(address, rssi=-60) owner._add_connecting(address) worker = sched._workers[owner.source] worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 _make_due(sched, address) before = loop.time() await worker._tick() # Per-device went to fallback. assert fb.active_window_calls == [6.0] assert owner.active_window_calls == [] # Sweep was deferred (next due roughly now + _AUTO_CONNECTING_DEFER). next_sweep_due = worker._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL assert next_sweep_due >= before + 25.0 finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_fb() @pytest.mark.asyncio async def test_worker_tick_two_different_fallbacks_both_dispatched() -> None: """ Two due addresses with distinct fallbacks → both get called. Confirms that the per-fallback grouping does *not* collapse different fallbacks into one — each fallback receives its own coalesced ``async_request_active_window`` call. """ manager = get_manager() sched = manager._auto_scheduler addr_a = "11:22:33:44:55:16" addr_b = "11:22:33:44:55:17" c1 = manager.async_register_active_scan( addr_a, scan_interval=120.0, scan_duration=6.0 ) c2 = manager.async_register_active_scan( addr_b, scan_interval=120.0, scan_duration=8.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:16:01", BluetoothScanningMode.AUTO) fb_a = _DiscoverableAutoScanner("AA:00:00:00:16:02", BluetoothScanningMode.AUTO) fb_b = _DiscoverableAutoScanner("AA:00:00:00:16:03", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_a = manager.async_register_scanner(fb_a) c_b = manager.async_register_scanner(fb_b) try: _inject_with_rssi(owner, addr_a, rssi=-50) _inject_with_rssi(owner, addr_b, rssi=-50) fb_a.add_discovered(addr_a, rssi=-60) fb_b.add_discovered(addr_b, rssi=-60) owner._add_connecting(addr_a) _make_due(sched, addr_a) _make_due(sched, addr_b) await _run_worker_tick(sched, owner.source) assert fb_a.active_window_calls == [6.0] assert fb_b.active_window_calls == [8.0] assert owner.active_window_calls == [] finally: owner._finished_connecting(addr_a, connected=False) c1() c2() c_owner() c_a() c_b() @pytest.mark.asyncio async def test_worker_tick_advance_pre_dispatch_blocks_double_fire() -> None: """ Per-address ``_due_at`` entries are advanced before the dispatch. The pre-dispatch advance protects against an in-flight ownership flip causing a duplicate window on a different worker (same reasoning as the non-connecting path's pre-await advance). """ manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:18" cancel = manager.async_register_active_scan( address, scan_interval=90.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:18:01", BluetoothScanningMode.AUTO) fb = _DiscoverableAutoScanner("AA:00:00:00:18:02", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_fb = manager.async_register_scanner(fb) try: _inject_with_rssi(owner, address, rssi=-50) fb.add_discovered(address, rssi=-60) owner._add_connecting(address) _make_due(sched, address) before = loop.time() await _run_worker_tick(sched, owner.source) entries = sched._schedule._due_at[address] for due in entries.values(): # Advanced to roughly before + 90s, NOT before - 1.0. assert due == pytest.approx(before + 90.0, abs=0.5) assert due > loop.time() finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_fb() @pytest.mark.asyncio async def test_worker_tick_passive_plus_auto_uses_auto( caplog: pytest.LogCaptureFixture, ) -> None: """ PASSIVE + AUTO mix: AUTO is used, PASSIVE ignored, no warning. Confirms that a PASSIVE scanner alongside a viable AUTO fallback doesn't poison the result — we ignore PASSIVE and flip the AUTO. """ manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:19" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:19:01", BluetoothScanningMode.AUTO) passive = _DiscoverableAutoScanner( "AA:00:00:00:19:02", BluetoothScanningMode.PASSIVE ) auto_fb = _DiscoverableAutoScanner("AA:00:00:00:19:03", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_pass = manager.async_register_scanner(passive) c_auto = manager.async_register_scanner(auto_fb) try: _inject_with_rssi(owner, address, rssi=-50) # Passive has a much stronger RSSI to confirm we still ignore it. passive.add_discovered(address, rssi=-30) auto_fb.add_discovered(address, rssi=-70) owner._add_connecting(address) _make_due(sched, address) with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"): await _run_worker_tick(sched, owner.source) assert owner.active_window_calls == [] assert passive.active_window_calls == [] assert auto_fb.active_window_calls == [6.0] assert not any( "no fallback scanner" in record.message for record in caplog.records ) finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_pass() c_auto() @pytest.mark.asyncio async def test_worker_tick_passive_plus_active_active_covers( caplog: pytest.LogCaptureFixture, ) -> None: """ PASSIVE + ACTIVE mix: ACTIVE covers, PASSIVE ignored, no warning. No AUTO fallback exists, but an ACTIVE scanner sees the address — that's enough for "scan already in progress". The PASSIVE scanner is irrelevant. """ manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:1A" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:1A:01", BluetoothScanningMode.AUTO) passive = _DiscoverableAutoScanner( "AA:00:00:00:1A:02", BluetoothScanningMode.PASSIVE ) active = _DiscoverableAutoScanner("AA:00:00:00:1A:03", BluetoothScanningMode.ACTIVE) c_owner = manager.async_register_scanner(owner) c_pass = manager.async_register_scanner(passive) c_active = manager.async_register_scanner(active) try: _inject_with_rssi(owner, address, rssi=-50) passive.add_discovered(address, rssi=-40) active.add_discovered(address, rssi=-70) owner._add_connecting(address) _make_due(sched, address) with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"): await _run_worker_tick(sched, owner.source) assert owner.active_window_calls == [] assert passive.active_window_calls == [] assert active.active_window_calls == [] assert not any( "no fallback scanner" in record.message for record in caplog.records ) finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_pass() c_active() @pytest.mark.asyncio async def test_worker_tick_all_three_modes_active_wins( caplog: pytest.LogCaptureFixture, ) -> None: """ PASSIVE + ACTIVE + AUTO all present: ACTIVE covers, no flip needed. The dispatch must short-circuit on the ACTIVE coverage even when an AUTO fallback is also available. PASSIVE is ignored. """ manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:1B" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:1B:01", BluetoothScanningMode.AUTO) passive = _DiscoverableAutoScanner( "AA:00:00:00:1B:02", BluetoothScanningMode.PASSIVE ) active = _DiscoverableAutoScanner("AA:00:00:00:1B:03", BluetoothScanningMode.ACTIVE) auto_fb = _DiscoverableAutoScanner("AA:00:00:00:1B:04", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_pass = manager.async_register_scanner(passive) c_active = manager.async_register_scanner(active) c_auto = manager.async_register_scanner(auto_fb) try: _inject_with_rssi(owner, address, rssi=-50) passive.add_discovered(address, rssi=-40) active.add_discovered(address, rssi=-70) auto_fb.add_discovered(address, rssi=-55) owner._add_connecting(address) _make_due(sched, address) with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"): await _run_worker_tick(sched, owner.source) # ACTIVE covers → no flip on anyone. assert owner.active_window_calls == [] assert passive.active_window_calls == [] assert active.active_window_calls == [] assert auto_fb.active_window_calls == [] assert not any( "no fallback scanner" in record.message for record in caplog.records ) finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_pass() c_active() c_auto() @pytest.mark.asyncio async def test_worker_tick_three_way_mix_per_address( caplog: pytest.LogCaptureFixture, ) -> None: """ Three addresses, three outcomes in one tick: covered, flipped, warned. * addr_covered: only ACTIVE sees → covered, no flip, no warning. * addr_flipped: only AUTO sees → flipped on AUTO fallback. * addr_orphan: no fallback at all → single warning naming addr_orphan. """ manager = get_manager() sched = manager._auto_scheduler addr_covered = "11:22:33:44:55:1C" addr_flipped = "11:22:33:44:55:1D" addr_orphan = "11:22:33:44:55:1E" c1 = manager.async_register_active_scan( addr_covered, scan_interval=120.0, scan_duration=6.0 ) c2 = manager.async_register_active_scan( addr_flipped, scan_interval=120.0, scan_duration=6.0 ) c3 = manager.async_register_active_scan( addr_orphan, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:1C:01", BluetoothScanningMode.AUTO) active = _DiscoverableAutoScanner("AA:00:00:00:1C:02", BluetoothScanningMode.ACTIVE) auto_fb = _DiscoverableAutoScanner("AA:00:00:00:1C:03", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_active = manager.async_register_scanner(active) c_auto = manager.async_register_scanner(auto_fb) try: for addr in (addr_covered, addr_flipped, addr_orphan): _inject_with_rssi(owner, addr, rssi=-50) active.add_discovered(addr_covered, rssi=-60) auto_fb.add_discovered(addr_flipped, rssi=-60) owner._add_connecting(addr_covered) for addr in (addr_covered, addr_flipped, addr_orphan): _make_due(sched, addr) with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"): await _run_worker_tick(sched, owner.source) assert owner.active_window_calls == [] assert active.active_window_calls == [] assert auto_fb.active_window_calls == [6.0] warnings_for_orphan = [ record for record in caplog.records if "no fallback scanner" in record.message and addr_orphan in record.message ] assert len(warnings_for_orphan) == 1 # Covered/flipped addresses must not appear in any no-fallback warning. assert not any( "no fallback scanner" in record.message and addr_covered in record.message for record in caplog.records ) assert not any( "no fallback scanner" in record.message and addr_flipped in record.message for record in caplog.records ) finally: owner._finished_connecting(addr_covered, connected=False) c1() c2() c3() c_owner() c_active() c_auto() @pytest.mark.asyncio async def test_worker_tick_owner_connecting_different_address_still_delegates() -> None: """ The connecting-phase signal is per-scanner, not per-address. The owner is in a connect attempt to address X, while the due per-device window is for address Y. The owner's radio is still busy with X's connect, so Y must be delegated to a fallback too. """ manager = get_manager() sched = manager._auto_scheduler addr_due = "11:22:33:44:55:1F" addr_connecting = "AA:BB:CC:DD:EE:99" cancel = manager.async_register_active_scan( addr_due, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:1F:01", BluetoothScanningMode.AUTO) fb = _DiscoverableAutoScanner("AA:00:00:00:1F:02", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_fb = manager.async_register_scanner(fb) try: _inject_with_rssi(owner, addr_due, rssi=-50) fb.add_discovered(addr_due, rssi=-60) # Connect-in-progress is for a different address. owner._add_connecting(addr_connecting) _make_due(sched, addr_due) await _run_worker_tick(sched, owner.source) assert owner.active_window_calls == [] assert fb.active_window_calls == [6.0] finally: owner._finished_connecting(addr_connecting, connected=False) cancel() c_owner() c_fb() @pytest.mark.asyncio async def test_worker_tick_fallback_returning_false_does_not_warn( caplog: pytest.LogCaptureFixture, ) -> None: """ A fallback returning False from async_request_active_window is silent. The helper's contract is "True = window armed/extended, False = refused" — both are terminal answers. We consume the call without raising and without warning, consistent with the non-connecting path that also ignores the return value. """ manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:20" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:20:01", BluetoothScanningMode.AUTO) fb = _DiscoverableAutoScanner("AA:00:00:00:20:02", BluetoothScanningMode.AUTO) fb._return_value = False c_owner = manager.async_register_scanner(owner) c_fb = manager.async_register_scanner(fb) try: _inject_with_rssi(owner, address, rssi=-50) fb.add_discovered(address, rssi=-60) owner._add_connecting(address) _make_due(sched, address) with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"): await _run_worker_tick(sched, owner.source) # Call was made, no warning, no exception escaped. assert fb.active_window_calls == [6.0] assert owner.active_window_calls == [] assert not any( "no fallback scanner" in record.message for record in caplog.records ) finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_fb() @pytest.mark.asyncio async def test_worker_tick_non_connectable_auto_fallback_is_eligible() -> None: """ A non-connectable AUTO scanner is a valid fallback for scanning. Fallback selection is about *scanning*, not connecting — a non-connectable scanner that can see the device is just as good for an active-window flip as a connectable one. ``async_scanner_devices_by_address(address, False)`` is called with ``connectable=False`` so both lists are considered. """ manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:21" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:21:01", BluetoothScanningMode.AUTO) fb = _DiscoverableAutoScanner( "AA:00:00:00:21:02", BluetoothScanningMode.AUTO, connectable=False ) c_owner = manager.async_register_scanner(owner) c_fb = manager.async_register_scanner(fb) try: _inject_with_rssi(owner, address, rssi=-50) fb.add_discovered(address, rssi=-60) owner._add_connecting(address) _make_due(sched, address) await _run_worker_tick(sched, owner.source) assert owner.active_window_calls == [] assert fb.active_window_calls == [6.0] finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_fb() @pytest.mark.asyncio async def test_worker_tick_connect_starting_after_check_still_runs_locally() -> None: """ Race: a connect that starts AFTER the connecting check still hits the owner. The connecting-state snapshot is taken once at the top of ``_tick``. If a connect begins between that check and the ``async_request_active_window`` await on the owner, the call has already been committed — we do not re-check mid-dispatch. The test pins this contract: ``_add_connecting`` after the call has started must not flip dispatch to a fallback for THIS tick. The next tick will see the new connecting state. """ manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:22" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:22:01", BluetoothScanningMode.AUTO) fb = _DiscoverableAutoScanner("AA:00:00:00:22:02", BluetoothScanningMode.AUTO) owner._block_event = asyncio.Event() c_owner = manager.async_register_scanner(owner) c_fb = manager.async_register_scanner(fb) try: _inject_with_rssi(owner, address, rssi=-50) fb.add_discovered(address, rssi=-60) # Owner NOT connecting at tick start. _make_due(sched, address) tick_task = asyncio.create_task(_run_worker_tick(sched, owner.source)) # Yield so the worker enters its await on owner.async_request_active_window # (which is blocked on owner._block_event). await asyncio.sleep(0) assert owner.active_window_calls == [6.0] # Race window: connect starts after the check, while the # owner call is in flight. The mid-flight call must not be # diverted; the fallback must not be called for THIS tick. owner._add_connecting(address) owner._block_event.set() await tick_task assert fb.active_window_calls == [] finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_fb() @pytest.mark.asyncio async def test_worker_tick_connect_finish_during_dispatch_keeps_dispatch() -> None: """ Race: owner finishes connecting WHILE the fallback dispatch is awaiting. The connecting state was True at tick start, so we entered the fallback branch and already advanced ``_due_at``. The connect completing mid-await must not cancel the in-flight fallback call nor cause a duplicate window on the owner. """ manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:23" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:23:01", BluetoothScanningMode.AUTO) fb = _DiscoverableAutoScanner("AA:00:00:00:23:02", BluetoothScanningMode.AUTO) fb._block_event = asyncio.Event() c_owner = manager.async_register_scanner(owner) c_fb = manager.async_register_scanner(fb) try: _inject_with_rssi(owner, address, rssi=-50) fb.add_discovered(address, rssi=-60) owner._add_connecting(address) _make_due(sched, address) tick_task = asyncio.create_task(_run_worker_tick(sched, owner.source)) # Yield so the worker enters its await on the blocked fallback. await asyncio.sleep(0) assert fb.active_window_calls == [6.0] # Connect finishes mid-dispatch. owner._finished_connecting(address, connected=True) assert owner._connections_in_progress() == 0 # Unblock the fallback so the dispatch can complete. fb._block_event.set() await tick_task # Dispatch completed on the fallback only; owner stayed # untouched for this tick. assert fb.active_window_calls == [6.0] assert owner.active_window_calls == [] finally: cancel() c_owner() c_fb() @pytest.mark.asyncio async def test_worker_tick_two_owners_delegate_to_same_fallback_concurrently() -> None: """ Cross-tick: two owner workers concurrently delegate to one fallback. The auto_scheduler doesn't serialize across workers — both deliveries go through. The scanner-level ``_active_window_handle`` / ``_start_stop_lock`` extend-if-extends logic is what guarantees the radio doesn't double-flip. Here we verify the auto_scheduler delivers both calls cleanly without deadlock or exception. """ manager = get_manager() sched = manager._auto_scheduler addr_a = "11:22:33:44:55:24" addr_b = "11:22:33:44:55:25" c1 = manager.async_register_active_scan( addr_a, scan_interval=120.0, scan_duration=6.0 ) c2 = manager.async_register_active_scan( addr_b, scan_interval=120.0, scan_duration=8.0 ) owner_a = _DiscoverableAutoScanner("AA:00:00:00:24:01", BluetoothScanningMode.AUTO) owner_b = _DiscoverableAutoScanner("AA:00:00:00:24:02", BluetoothScanningMode.AUTO) fb = _DiscoverableAutoScanner("AA:00:00:00:24:03", BluetoothScanningMode.AUTO) c_a = manager.async_register_scanner(owner_a) c_b = manager.async_register_scanner(owner_b) c_fb = manager.async_register_scanner(fb) try: _inject_with_rssi(owner_a, addr_a, rssi=-50) _inject_with_rssi(owner_b, addr_b, rssi=-50) fb.add_discovered(addr_a, rssi=-60) fb.add_discovered(addr_b, rssi=-60) owner_a._add_connecting(addr_a) owner_b._add_connecting(addr_b) _make_due(sched, addr_a) _make_due(sched, addr_b) await asyncio.gather( _run_worker_tick(sched, owner_a.source), _run_worker_tick(sched, owner_b.source), ) # Both owners delegated to fb; both calls were delivered. assert sorted(fb.active_window_calls) == [6.0, 8.0] assert owner_a.active_window_calls == [] assert owner_b.active_window_calls == [] finally: owner_a._finished_connecting(addr_a, connected=False) owner_b._finished_connecting(addr_b, connected=False) c1() c2() c_a() c_b() c_fb() @pytest.mark.asyncio async def test_worker_tick_ownership_flip_during_dispatch_no_double_fire() -> None: """ Race: ownership flips from owner to fallback during the dispatch. Same protection as the non-connecting migration test: the pre-await ``_advance_due`` updates ``_due_at`` to ``now + scan_interval`` before the fallback await, so if RSSI causes ownership to shift to the fallback mid-dispatch, the fallback's own next tick sees a future due time and skips — no duplicate window fires on the new owner. """ manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:26" cancel = manager.async_register_active_scan( address, scan_interval=90.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:26:01", BluetoothScanningMode.AUTO) fb = _DiscoverableAutoScanner("AA:00:00:00:26:02", BluetoothScanningMode.AUTO) fb._block_event = asyncio.Event() c_owner = manager.async_register_scanner(owner) c_fb = manager.async_register_scanner(fb) try: _inject_with_rssi(owner, address, rssi=-80) fb.add_discovered(address, rssi=-60) owner._add_connecting(address) _make_due(sched, address) before = loop.time() tick_task = asyncio.create_task(_run_worker_tick(sched, owner.source)) await asyncio.sleep(0) # Confirm fallback call is in flight and entries advanced. assert fb.active_window_calls == [6.0] entries = sched._schedule._due_at[address] for due in entries.values(): assert due == pytest.approx(before + 90.0, abs=0.5) # Ownership flips to fb mid-dispatch (much stronger RSSI). _inject_with_rssi(fb, address, rssi=-30) info = manager.async_last_service_info(address, False) assert info is not None assert info.source == fb.source # fb's own next tick must skip because entries are already advanced. fb._block_event.set() await tick_task await sched._workers[fb.source]._tick() # Only one call landed on fb; no double-fire. assert fb.active_window_calls == [6.0] assert owner.active_window_calls == [] finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_fb() @pytest.mark.asyncio async def test_worker_tick_resolver_excludes_owner_when_owner_self_reports() -> None: """ The owner's own source is skipped even if it appears in the scanner list. If the owner's ``get_discovered_device_advertisement_data`` returns non-None for the address (so the manager lists it among the scanner-devices), the resolver must still skip it via the ``scanner.source == exclude_source`` guard rather than picking the busy owner as its own fallback. """ manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:27" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:27:01", BluetoothScanningMode.AUTO) fb = _DiscoverableAutoScanner("AA:00:00:00:27:02", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_fb = manager.async_register_scanner(fb) try: _inject_with_rssi(owner, address, rssi=-50) # Owner self-reports: would normally be sorted as the highest-RSSI # candidate, but the resolver must exclude itself. owner.add_discovered(address, rssi=-30) fb.add_discovered(address, rssi=-70) owner._add_connecting(address) _make_due(sched, address) await _run_worker_tick(sched, owner.source) # Owner skipped despite self-reporting; weaker fallback wins. assert owner.active_window_calls == [] assert fb.active_window_calls == [6.0] finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_fb() @pytest.mark.asyncio async def test_worker_tick_dispatch_advances_fallback_sweep_clock() -> None: """ Delegating an active window to a fallback advances its sweep clock. The fallback's radio is actively scanning for ``duration`` seconds, which subsumes the work its own rediscovery sweep would do. We bump ``_sweep_last_completed = now`` so the fallback doesn't immediately schedule another sweep window on top of the one we just triggered. """ manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:28" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:28:01", BluetoothScanningMode.AUTO) fb = _DiscoverableAutoScanner("AA:00:00:00:28:02", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_fb = manager.async_register_scanner(fb) try: _inject_with_rssi(owner, address, rssi=-50) fb.add_discovered(address, rssi=-60) # Make fb's own sweep imminent so we can detect the advance. fb_worker = sched._workers[fb.source] fb_worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 sweep_before = fb_worker._sweep_last_completed owner._add_connecting(address) _make_due(sched, address) before = loop.time() await _run_worker_tick(sched, owner.source) assert fb.active_window_calls == [6.0] # Sweep clock advanced to ~now so fb won't immediately resweep. assert fb_worker._sweep_last_completed > sweep_before assert fb_worker._sweep_last_completed >= before finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_fb() @pytest.mark.asyncio async def test_worker_tick_dispatch_sets_fallback_window_end() -> None: """ Delegation marks the fallback worker as in-window for ``duration``. With ``fb._window_end > now``, the fallback's own ``_tick`` and ``_next_event_at`` short-circuit during the delegated window so the fallback doesn't redundantly tick on its own due work for the duration of the active scan it is already running. """ manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:29" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:29:01", BluetoothScanningMode.AUTO) fb = _DiscoverableAutoScanner("AA:00:00:00:29:02", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_fb = manager.async_register_scanner(fb) try: _inject_with_rssi(owner, address, rssi=-50) fb.add_discovered(address, rssi=-60) owner._add_connecting(address) _make_due(sched, address) before = loop.time() await _run_worker_tick(sched, owner.source) fb_worker = sched._workers[fb.source] # Window-end bumped roughly to before + duration. assert fb_worker._window_end >= before + 5.0 assert fb_worker._window_end <= loop.time() + 7.0 # A fb tick while _window_end > now must short-circuit # (no async_request_active_window call recorded). calls_before = list(fb.active_window_calls) await fb_worker._tick() assert fb.active_window_calls == calls_before finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_fb() @pytest.mark.asyncio async def test_worker_tick_dispatch_does_not_shrink_existing_fallback_window() -> None: """ A larger pre-existing ``_window_end`` on the fallback is preserved. If the fallback is already running a longer window when we delegate (e.g., a much earlier delegation from another owner extended its own ``_window_end``), our shorter delegation must not shrink it back. """ manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:2A" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=5.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:2A:01", BluetoothScanningMode.AUTO) fb = _DiscoverableAutoScanner("AA:00:00:00:2A:02", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_fb = manager.async_register_scanner(fb) try: _inject_with_rssi(owner, address, rssi=-50) fb.add_discovered(address, rssi=-60) fb_worker = sched._workers[fb.source] # Pre-seed a longer pending window on the fallback. existing_window_end = loop.time() + 60.0 fb_worker._window_end = existing_window_end owner._add_connecting(address) _make_due(sched, address) await _run_worker_tick(sched, owner.source) # The shorter (5s) delegation must not have shrunk the # existing 60s window. assert fb_worker._window_end == existing_window_end finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_fb() @pytest.mark.asyncio async def test_worker_tick_three_addresses_same_fallback_coalesce_to_max() -> None: """ Three due addresses with distinct durations on one fallback coalesce to max. Confirms per-fallback coalescing picks the max ``scan_duration`` over all grouped requests, not the first. """ manager = get_manager() sched = manager._auto_scheduler addr_a = "11:22:33:44:55:2B" addr_b = "11:22:33:44:55:2C" addr_c = "11:22:33:44:55:2D" c1 = manager.async_register_active_scan( addr_a, scan_interval=120.0, scan_duration=6.0 ) c2 = manager.async_register_active_scan( addr_b, scan_interval=120.0, scan_duration=11.0 ) c3 = manager.async_register_active_scan( addr_c, scan_interval=120.0, scan_duration=8.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:2B:01", BluetoothScanningMode.AUTO) fb = _DiscoverableAutoScanner("AA:00:00:00:2B:02", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_fb = manager.async_register_scanner(fb) try: for addr in (addr_a, addr_b, addr_c): _inject_with_rssi(owner, addr, rssi=-50) fb.add_discovered(addr, rssi=-60) owner._add_connecting(addr_a) for addr in (addr_a, addr_b, addr_c): _make_due(sched, addr) await _run_worker_tick(sched, owner.source) # Single call to the shared fallback at max(6, 11, 8) = 11. assert fb.active_window_calls == [11.0] assert owner.active_window_calls == [] finally: owner._finished_connecting(addr_a, connected=False) c1() c2() c3() c_owner() c_fb() @pytest.mark.asyncio async def test_worker_tick_three_addresses_three_fallbacks_each_own_duration() -> None: """Three due addresses on three fallbacks, each call uses its own duration.""" manager = get_manager() sched = manager._auto_scheduler addr_a = "11:22:33:44:55:2E" addr_b = "11:22:33:44:55:2F" addr_c = "11:22:33:44:55:30" c1 = manager.async_register_active_scan( addr_a, scan_interval=120.0, scan_duration=6.0 ) c2 = manager.async_register_active_scan( addr_b, scan_interval=180.0, scan_duration=9.0 ) c3 = manager.async_register_active_scan( addr_c, scan_interval=240.0, scan_duration=12.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:2E:01", BluetoothScanningMode.AUTO) fb_a = _DiscoverableAutoScanner("AA:00:00:00:2E:02", BluetoothScanningMode.AUTO) fb_b = _DiscoverableAutoScanner("AA:00:00:00:2E:03", BluetoothScanningMode.AUTO) fb_c = _DiscoverableAutoScanner("AA:00:00:00:2E:04", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_a = manager.async_register_scanner(fb_a) c_b = manager.async_register_scanner(fb_b) c_c = manager.async_register_scanner(fb_c) try: _inject_with_rssi(owner, addr_a, rssi=-50) _inject_with_rssi(owner, addr_b, rssi=-50) _inject_with_rssi(owner, addr_c, rssi=-50) fb_a.add_discovered(addr_a, rssi=-60) fb_b.add_discovered(addr_b, rssi=-60) fb_c.add_discovered(addr_c, rssi=-60) owner._add_connecting(addr_a) for addr in (addr_a, addr_b, addr_c): _make_due(sched, addr) await _run_worker_tick(sched, owner.source) assert fb_a.active_window_calls == [6.0] assert fb_b.active_window_calls == [9.0] assert fb_c.active_window_calls == [12.0] assert owner.active_window_calls == [] finally: owner._finished_connecting(addr_a, connected=False) c1() c2() c3() c_owner() c_a() c_b() c_c() @pytest.mark.asyncio async def test_worker_tick_three_addresses_no_fallback_advance_by_defer() -> None: """ No-fallback advance uses ``_AUTO_CONNECTING_DEFER``, not scan_interval. Three addresses with very different ``scan_interval``s (120/600/3600s) must all be advanced to ~now + 30s so the next tick retries shortly after the connect completes. """ manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() addr_a = "11:22:33:44:55:31" addr_b = "11:22:33:44:55:32" addr_c = "11:22:33:44:55:33" c1 = manager.async_register_active_scan( addr_a, scan_interval=120.0, scan_duration=6.0 ) c2 = manager.async_register_active_scan( addr_b, scan_interval=600.0, scan_duration=9.0 ) c3 = manager.async_register_active_scan( addr_c, scan_interval=3600.0, scan_duration=12.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:31:01", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) try: for addr in (addr_a, addr_b, addr_c): _inject_with_rssi(owner, addr, rssi=-50) _make_due(sched, addr) owner._add_connecting(addr_a) before = loop.time() await _run_worker_tick(sched, owner.source) for addr in (addr_a, addr_b, addr_c): entries = sched._schedule._due_at[addr] for due in entries.values(): assert due == pytest.approx(before + 30.0, abs=0.5) assert due < before + 60.0 finally: owner._finished_connecting(addr_a, connected=False) c1() c2() c3() c_owner() @pytest.mark.asyncio async def test_note_window_dispatched_preserves_more_recent_sweep() -> None: """ ``note_window_dispatched`` does not move ``_sweep_last_completed`` backwards. Covers the False branch of ``if self._sweep_last_completed < now`` when the fallback's sweep clock is already further in the future than the ``now`` we're passing in. """ manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:38" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:38:01", BluetoothScanningMode.AUTO) fb = _DiscoverableAutoScanner("AA:00:00:00:38:02", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_fb = manager.async_register_scanner(fb) try: _inject_with_rssi(owner, address, rssi=-50) fb.add_discovered(address, rssi=-60) fb_worker = sched._workers[fb.source] future_sweep = loop.time() + 600.0 fb_worker._sweep_last_completed = future_sweep owner._add_connecting(address) _make_due(sched, address) await _run_worker_tick(sched, owner.source) # Sweep clock not moved backwards by our note_window_dispatched. assert fb_worker._sweep_last_completed == future_sweep assert fb.active_window_calls == [6.0] finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_fb() @pytest.mark.asyncio async def test_worker_tick_dispatch_handles_missing_fallback_worker() -> None: """ Dispatch tolerates ``workers.get(fb.source) is None``. Covers the False branch of ``if fb_worker is not None``. Reachable when a fallback scanner is unregistered between resolution and the per-fallback iteration (sim: drop the worker entry between registration and tick to force the lookup miss). The dispatch should still call ``async_request_active_window`` on the fallback even with no worker available to receive ``note_window_dispatched``. """ manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:37" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:37:01", BluetoothScanningMode.AUTO) fb = _DiscoverableAutoScanner("AA:00:00:00:37:02", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_fb = manager.async_register_scanner(fb) try: _inject_with_rssi(owner, address, rssi=-50) fb.add_discovered(address, rssi=-60) owner._add_connecting(address) _make_due(sched, address) # Drop fb's worker so workers.get(fb.source) is None during dispatch. # The fb scanner is still registered (so resolve still picks it) # and async_scanner_devices_by_address still returns it. sched._workers.pop(fb.source) await _run_worker_tick(sched, owner.source) # Dispatch still happened on the fallback's scanner even though # we couldn't notify the worker. assert fb.active_window_calls == [6.0] assert owner.active_window_calls == [] finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_fb() @pytest.mark.asyncio async def test_worker_tick_three_addresses_mixed_outcomes_advance_correctly() -> None: """ Three addresses, three outcomes, each advanced per its outcome. covered (ACTIVE) -> ``scan_interval`` (full cadence). AUTO fallback -> ``scan_interval`` (full cadence). no fallback -> ``_AUTO_CONNECTING_DEFER`` (short retry). """ manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() addr_covered = "11:22:33:44:55:34" addr_flipped = "11:22:33:44:55:35" addr_orphan = "11:22:33:44:55:36" c1 = manager.async_register_active_scan( addr_covered, scan_interval=120.0, scan_duration=6.0 ) c2 = manager.async_register_active_scan( addr_flipped, scan_interval=240.0, scan_duration=9.0 ) c3 = manager.async_register_active_scan( addr_orphan, scan_interval=600.0, scan_duration=12.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:34:01", BluetoothScanningMode.AUTO) active = _DiscoverableAutoScanner("AA:00:00:00:34:02", BluetoothScanningMode.ACTIVE) fb = _DiscoverableAutoScanner("AA:00:00:00:34:03", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_active = manager.async_register_scanner(active) c_fb = manager.async_register_scanner(fb) try: for addr in (addr_covered, addr_flipped, addr_orphan): _inject_with_rssi(owner, addr, rssi=-50) _make_due(sched, addr) active.add_discovered(addr_covered, rssi=-60) fb.add_discovered(addr_flipped, rssi=-60) owner._add_connecting(addr_covered) before = loop.time() await _run_worker_tick(sched, owner.source) for due in sched._schedule._due_at[addr_covered].values(): assert due == pytest.approx(before + 120.0, abs=0.5) for due in sched._schedule._due_at[addr_flipped].values(): assert due == pytest.approx(before + 240.0, abs=0.5) for due in sched._schedule._due_at[addr_orphan].values(): assert due == pytest.approx(before + 30.0, abs=0.5) assert fb.active_window_calls == [9.0] assert active.active_window_calls == [] assert owner.active_window_calls == [] finally: owner._finished_connecting(addr_covered, connected=False) c1() c2() c3() c_owner() c_active() c_fb() @pytest.mark.asyncio async def test_worker_tick_fallback_with_none_rssi_is_still_dispatched() -> None: """ A fallback whose last advertisement has ``rssi is None`` is usable. Pins Kōan blocker #1: ``_resolve_fallback_for_address`` must not crash on ``None`` RSSI (would raise ``TypeError`` on ``None > -10_000``). The defensive ``rssi or NO_RSSI_VALUE`` normalization keeps the scanner in the candidate pool with the sentinel score. Here the single fallback has ``rssi=None`` and the dispatch must still fire on it. """ manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:39" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:39:01", BluetoothScanningMode.AUTO) fb = _DiscoverableAutoScanner("AA:00:00:00:39:02", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_fb = manager.async_register_scanner(fb) try: _inject_with_rssi(owner, address, rssi=-50) fb.add_discovered(address, rssi=None) owner._add_connecting(address) _make_due(sched, address) await _run_worker_tick(sched, owner.source) assert owner.active_window_calls == [] assert fb.active_window_calls == [6.0] finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_fb() @pytest.mark.asyncio async def test_worker_tick_fallback_with_rssi_loses_to_better_fallback() -> None: """ A ``None``-RSSI fallback loses to a fallback with a real RSSI. With ``None`` normalized to ``NO_RSSI_VALUE`` (-127), any real-world RSSI beats it, so the ``None``-RSSI scanner is only picked when nothing else is available. """ manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:3A" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:3A:01", BluetoothScanningMode.AUTO) fb_none = _DiscoverableAutoScanner("AA:00:00:00:3A:02", BluetoothScanningMode.AUTO) fb_real = _DiscoverableAutoScanner("AA:00:00:00:3A:03", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_n = manager.async_register_scanner(fb_none) c_r = manager.async_register_scanner(fb_real) try: _inject_with_rssi(owner, address, rssi=-50) fb_none.add_discovered(address, rssi=None) fb_real.add_discovered(address, rssi=-90) owner._add_connecting(address) _make_due(sched, address) await _run_worker_tick(sched, owner.source) # Real RSSI (-90) beats the normalized None (-127). assert fb_real.active_window_calls == [6.0] assert fb_none.active_window_calls == [] finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_n() c_r() @pytest.mark.asyncio async def test_worker_tick_failed_fallback_advances_entries_by_full_interval() -> None: """ Failed fallback dispatch still advances entries by ``scan_interval``. Pins Kōan suggestion #2 / the documented "advance on failure" semantics: when ``fb.async_request_active_window`` raises, the per-address entries have already been advanced by ``scan_interval`` (NOT reset to ``retry_at``) and the fallback worker's ``_window_end`` / ``_sweep_last_completed`` bumps from ``note_window_dispatched`` are preserved. A failing fallback is treated like a successful one to avoid busy-looping on a stuck scanner. """ class _RaisingScanner(_DiscoverableAutoScanner): async def async_request_active_window(self, duration: float) -> bool: self.active_window_calls.append(duration) msg = "boom" raise RuntimeError(msg) manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:3B" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:3B:01", BluetoothScanningMode.AUTO) fb = _RaisingScanner("AA:00:00:00:3B:02", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_fb = manager.async_register_scanner(fb) try: _inject_with_rssi(owner, address, rssi=-50) fb.add_discovered(address, rssi=-60) owner._add_connecting(address) _make_due(sched, address) fb_worker = sched._workers[fb.source] sweep_before = fb_worker._sweep_last_completed before = loop.time() await _run_worker_tick(sched, owner.source) # Entries advanced by full scan_interval, NOT retry_at. for due in sched._schedule._due_at[address].values(): assert due == pytest.approx(before + 120.0, abs=0.5) assert due > before + 60.0 # well past the 30s retry_at # fb_worker bumps from note_window_dispatched are preserved. assert fb_worker._sweep_last_completed > sweep_before assert fb_worker._sweep_last_completed >= before finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_fb() @pytest.mark.asyncio async def test_worker_tick_fallback_with_rssi_zero_is_strongest() -> None: """ A fallback with ``rssi == 0`` beats a fallback with negative RSSI. Pins the explicit ``rssi is None`` check (rather than ``rssi or NO_RSSI_VALUE``): an RSSI of 0 is a valid very-strong signal and must not be coerced to the missing-RSSI sentinel via ``0 or X`` falsiness. """ manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:3D" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:3D:01", BluetoothScanningMode.AUTO) fb_zero = _DiscoverableAutoScanner("AA:00:00:00:3D:02", BluetoothScanningMode.AUTO) fb_neg = _DiscoverableAutoScanner("AA:00:00:00:3D:03", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_zero = manager.async_register_scanner(fb_zero) c_neg = manager.async_register_scanner(fb_neg) try: _inject_with_rssi(owner, address, rssi=-50) fb_zero.add_discovered(address, rssi=0) fb_neg.add_discovered(address, rssi=-50) owner._add_connecting(address) _make_due(sched, address) await _run_worker_tick(sched, owner.source) # rssi=0 beats rssi=-50; the falsy-or pattern would have # incorrectly normalised 0 to NO_RSSI_VALUE and lost. assert fb_zero.active_window_calls == [6.0] assert fb_neg.active_window_calls == [] finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_zero() c_neg() @pytest.mark.asyncio async def test_worker_tick_dispatch_short_window_still_resets_full_sweep() -> None: """ A 5s delegated window resets the fallback's full 12h sweep cadence. Pins the documented best-effort caveat: ``note_window_dispatched`` advances ``_sweep_last_completed`` to ``now`` regardless of how short the delegated window is. With min duration (5s, well below ``AUTO_REDISCOVERY_SWEEP_DURATION`` of 15s), the next sweep is still pushed out a full ``AUTO_REDISCOVERY_INTERVAL`` (12h). """ manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:3C" # Request duration well under MIN; coalesce_duration clamps to # _AUTO_WINDOW_MIN_DURATION (5.0). cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=5.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:3C:01", BluetoothScanningMode.AUTO) fb = _DiscoverableAutoScanner("AA:00:00:00:3C:02", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_fb = manager.async_register_scanner(fb) try: _inject_with_rssi(owner, address, rssi=-50) fb.add_discovered(address, rssi=-60) # Place fb's sweep clock far in the past so we can see the bump. fb_worker = sched._workers[fb.source] fb_worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL / 2 owner._add_connecting(address) _make_due(sched, address) before = loop.time() await _run_worker_tick(sched, owner.source) # Delegated window was 5s; fb's next sweep was pushed to # roughly now + 12h regardless of the short window. next_sweep_due = fb_worker._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL assert next_sweep_due == pytest.approx( before + AUTO_REDISCOVERY_INTERVAL, abs=1 ) assert fb.active_window_calls == [5.0] finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_fb() @pytest.mark.asyncio async def test_worker_tick_per_device_window_satisfies_sweep_floor() -> None: """ A per-device active window advances ``_sweep_last_completed``. The rediscovery sweep is a floor: scanners that haven't active-scanned in 12 h get a 15 s sweep. A scanner that just ran a per-device active window has already actively scanned, so its next sweep is pushed out a full ``AUTO_REDISCOVERY_INTERVAL`` even when ``sweep_due`` was False at tick time. """ manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:3E" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=6.0 ) scanner = _DiscoverableAutoScanner("AA:00:00:00:3E:01", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject_with_rssi(scanner, address, rssi=-50) worker = sched._workers[scanner.source] # Place sweep clock recent enough that sweep is NOT due — we # want to prove the per-device window still advances it. recent_sweep = loop.time() - 60.0 worker._sweep_last_completed = recent_sweep _make_due(sched, address) before = loop.time() await _run_worker_tick(sched, scanner.source) assert scanner.active_window_calls == [6.0] # Per-device window pushed sweep clock from "60s ago" to "now", # demonstrating any active scan satisfies the sweep floor. assert worker._sweep_last_completed > recent_sweep assert worker._sweep_last_completed >= before finally: cancel() register_cancel() @pytest.mark.asyncio async def test_worker_tick_dispatch_samples_time_per_fallback() -> None: """ Each fallback's ``window_end`` is anchored to its dispatch time. Each ``await fb.async_request_active_window(duration)`` can take seconds in production (scanner stop/restart on Linux). Reusing the owner's tick-start ``now`` for every fallback's ``note_window_dispatched`` would leave later fallbacks' ``_window_end`` in the past — defeating the suppression. Use a first fallback that ``asyncio.sleep``s during its dispatch so the second fallback's ``loop.time()`` is strictly later than the owner's tick-start ``now``, then verify the second fallback's ``_window_end`` reflects its own dispatch time. """ class _SlowScanner(_DiscoverableAutoScanner): async def async_request_active_window(self, duration: float) -> bool: self.active_window_calls.append(duration) await asyncio.sleep(0.1) return self._return_value manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() addr_a = "11:22:33:44:55:3F" addr_b = "11:22:33:44:55:40" c1 = manager.async_register_active_scan( addr_a, scan_interval=120.0, scan_duration=6.0 ) c2 = manager.async_register_active_scan( addr_b, scan_interval=120.0, scan_duration=6.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:3F:01", BluetoothScanningMode.AUTO) fb_slow = _SlowScanner("AA:00:00:00:3F:02", BluetoothScanningMode.AUTO) fb_late = _DiscoverableAutoScanner("AA:00:00:00:3F:03", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_s = manager.async_register_scanner(fb_slow) c_l = manager.async_register_scanner(fb_late) try: _inject_with_rssi(owner, addr_a, rssi=-50) _inject_with_rssi(owner, addr_b, rssi=-50) fb_slow.add_discovered(addr_a, rssi=-60) fb_late.add_discovered(addr_b, rssi=-60) owner._add_connecting(addr_a) _make_due(sched, addr_a) _make_due(sched, addr_b) tick_start = loop.time() await _run_worker_tick(sched, owner.source) # Both fallbacks were called. assert fb_slow.active_window_calls == [6.0] assert fb_late.active_window_calls == [6.0] # fb_late was dispatched AFTER fb_slow's 0.1s sleep, so its # _window_end is anchored to dispatch_now ≈ tick_start + 0.1, # i.e. > tick_start + duration (6.0). The owner's tick-start # ``now`` would have given tick_start + 6.0 ≈ tick_start + 6.0 # exactly, which is < tick_start + 6.0 + 0.05. fb_late_worker = sched._workers[fb_late.source] assert fb_late_worker._window_end > tick_start + 6.0 + 0.05 finally: owner._finished_connecting(addr_a, connected=False) c1() c2() c_owner() c_s() c_l() @pytest.mark.asyncio async def test_async_diagnostics() -> None: """Diagnostics expose per-worker sweep timing and per-address requests.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel1 = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=5.0 ) cancel2 = manager.async_register_active_scan( address, scan_interval=240.0, scan_duration=10.0 ) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) diagnostics = sched.async_diagnostics() assert diagnostics["running"] is True assert diagnostics["monotonic_time"] == pytest.approx(loop.time(), abs=0.5) workers = diagnostics["workers"] assert set(workers) == {scanner.source} worker_diag = workers[scanner.source] assert worker_diag["name"] == scanner.name assert worker_diag["window_end"] == 0.0 assert worker_diag["failed_window"] is False assert worker_diag["warned_no_fallback"] is False assert worker_diag["next_sweep_at"] == pytest.approx( worker_diag["sweep_last_completed"] + AUTO_REDISCOVERY_INTERVAL ) assert worker_diag["next_event_at"] > 0.0 requests = diagnostics["requests"] assert set(requests) == {address} entries = requests[address] assert len(entries) == 2 pairs = sorted( (entry["scan_interval"], entry["scan_duration"]) for entry in entries ) assert pairs == [(120.0, 5.0), (240.0, 10.0)] for entry in entries: assert entry["owner_source"] == scanner.source assert entry["next_due"] is not None assert entry["next_due"] > loop.time() finally: cancel1() cancel2() register_cancel() # After cancellation the address falls out of both indexes. post = sched.async_diagnostics() assert post["requests"] == {} @contextlib.asynccontextmanager async def _no_real_sleep(): """ Replace ``asyncio.sleep`` with an immediate fake-time advance. Sweeps clamp duration to AUTO_WINDOW_MIN_DURATION (5s); stubbing the sleep keeps tests fast while preserving the call shape so we can still observe what duration was requested. Each mocked sleep also advances ``loop.time()`` by ``duration`` so the on-demand sweep's sleep-until-end loop (which re-reads ``_on_demand_sweep_end`` on each wake) terminates instead of spinning forever against a frozen clock. """ loop = asyncio.get_running_loop() real_time = loop.time fake_advance = [0.0] async def _instant(duration: float) -> None: fake_advance[0] += duration def _fake_time() -> float: return real_time() + fake_advance[0] with ( patch("asyncio.sleep", new=_instant), patch.object(loop, "time", _fake_time), ): yield @pytest.mark.asyncio async def test_async_request_active_scan_fires_active_window_on_each_auto_scanner() -> ( None ): """A sweep flips every AUTO scanner into ACTIVE for the duration.""" manager = get_manager() a = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) b = _RecordingAutoScanner("AA:BB:CC:DD:EE:01", BluetoothScanningMode.AUTO) c_a = manager.async_register_scanner(a) c_b = manager.async_register_scanner(b) try: async with _no_real_sleep(): await manager.async_request_active_scan(duration=7.0) assert a.active_window_calls == [7.0] assert b.active_window_calls == [7.0] finally: c_a() c_b() @pytest.mark.asyncio async def test_async_request_active_scan_skips_connecting_scanner() -> None: """A scanner mid-connect is skipped; non-connecting peers still flip.""" manager = get_manager() busy = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) free = _RecordingAutoScanner("AA:BB:CC:DD:EE:01", BluetoothScanningMode.AUTO) c_busy = manager.async_register_scanner(busy) c_free = manager.async_register_scanner(free) busy._add_connecting("11:22:33:44:55:66") try: async with _no_real_sleep(): await manager.async_request_active_scan(duration=5.0) assert busy.active_window_calls == [] assert free.active_window_calls == [5.0] finally: busy._finished_connecting("11:22:33:44:55:66", connected=False) c_busy() c_free() @pytest.mark.asyncio async def test_async_request_active_scan_resets_next_sweep_time() -> None: """A sweep advances each flipped worker's _sweep_last_completed to now.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) worker = sched._workers[scanner.source] # Backdate so we can observe the bump. worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 try: before = loop.time() async with _no_real_sleep(): await manager.async_request_active_scan(duration=5.0) assert worker._sweep_last_completed >= before assert worker._sweep_last_completed <= loop.time() + 0.1 finally: register_cancel() @pytest.mark.asyncio async def test_async_request_active_scan_mixed_durations_extends_to_longest() -> None: """ Concurrent callers asking for (10, 15, 5, 20) all wait until T0+20. The first caller to win the check-and-set runs a 10s sweep; the 15s and 20s callers extend the in-flight window (re-flipping the radio with the longer remaining duration); the 5s caller fits within the already-extended end and does not flip again. The scanner records each flip's duration so we can verify the extension chain. """ manager = get_manager() scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: async with _no_real_sleep(): await asyncio.gather( manager.async_request_active_scan(duration=10.0), manager.async_request_active_scan(duration=15.0), manager.async_request_active_scan(duration=5.0), manager.async_request_active_scan(duration=20.0), ) # Exactly three flip durations: leader's 10s + two extensions # (approximately 15s and 20s, with sub-second drift from # task-start jitter — pytest.approx absorbs the drift, and # the ordering pins the chain. assert len(scanner.active_window_calls) == 3 assert scanner.active_window_calls[0] == 10.0 assert scanner.active_window_calls[1] == pytest.approx(15.0, abs=1.0) assert scanner.active_window_calls[2] == pytest.approx(20.0, abs=1.0) assert ( scanner.active_window_calls[0] < scanner.active_window_calls[1] < scanner.active_window_calls[2] ) # The future and end are cleared once the leader finishes. assert manager._auto_scheduler._on_demand_sweep_future is None assert manager._auto_scheduler._on_demand_sweep_end == 0.0 finally: register_cancel() @pytest.mark.asyncio async def test_async_request_active_scan_dedupes_concurrent_callers() -> None: """N concurrent sweep calls share one window; the bus flips once.""" manager = get_manager() scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: async with _no_real_sleep(): # Three concurrent callers, mirroring HA integrations each # opening their own config flow at the same time. await asyncio.gather( manager.async_request_active_scan(duration=5.0), manager.async_request_active_scan(duration=5.0), manager.async_request_active_scan(duration=5.0), ) # Only one active window despite three callers. assert scanner.active_window_calls == [5.0] # The deduped future is cleared once the sweep finishes. assert manager._auto_scheduler._on_demand_sweep_future is None finally: register_cancel() @pytest.mark.asyncio async def test_async_request_active_scan_default_duration_is_10s() -> None: """Calling without a duration uses DEFAULT_ON_DEMAND_SWEEP_DURATION (10s).""" manager = get_manager() scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: async with _no_real_sleep(): await manager.async_request_active_scan() assert scanner.active_window_calls == [DEFAULT_ON_DEMAND_SWEEP_DURATION] finally: register_cancel() @pytest.mark.asyncio async def test_async_request_active_scan_clamps_to_window_bounds() -> None: """Out-of-range durations are clamped to [MIN, MAX].""" manager = get_manager() scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: async with _no_real_sleep(): await manager.async_request_active_scan(duration=0.5) # below MIN await manager.async_request_active_scan(duration=999.0) # above MAX assert scanner.active_window_calls == [ AUTO_WINDOW_MIN_DURATION, AUTO_WINDOW_MAX_DURATION, ] finally: register_cancel() @pytest.mark.asyncio async def test_async_request_active_scan_rejects_invalid_duration() -> None: """NaN, inf, zero, and negative durations raise ValueError.""" manager = get_manager() for bad in (float("nan"), float("inf"), float("-inf"), 0.0, -1.0): with pytest.raises(ValueError, match="finite positive"): await manager.async_request_active_scan(duration=bad) @pytest.mark.asyncio async def test_async_request_active_scan_no_op_when_scheduler_stopped() -> None: """After stop() the scheduler has no loop; the sweep returns immediately.""" manager = get_manager() manager._auto_scheduler.stop() await manager.async_request_active_scan(duration=5.0) @pytest.mark.asyncio async def test_async_request_active_scan_no_op_without_auto_scanners() -> None: """With no AUTO workers the sweep returns immediately, no sleep.""" manager = get_manager() loop = asyncio.get_running_loop() async def _fail_on_sleep(delay: float) -> None: # Surface a regression as a clean assertion instead of a # pytest-timeout: if the leader's sleep loop runs at all, # fail now rather than spin / block on the patched sleep. pytest.fail(f"async_request_active_scan slept for {delay}s on NOOP") before = loop.time() with patch("asyncio.sleep", new=_fail_on_sleep): await manager.async_request_active_scan(duration=5.0) # Bounded wall time confirms we did not block on the 5s duration. assert loop.time() - before < 0.5 assert manager._auto_scheduler._on_demand_sweep_future is None assert manager._auto_scheduler._on_demand_sweep_end == 0.0 @pytest.mark.asyncio async def test_async_request_active_scan_no_op_when_all_scanners_connecting() -> None: """Every AUTO scanner mid-connect is the same NOOP; no sleep, fast-return.""" manager = get_manager() loop = asyncio.get_running_loop() busy_a = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) busy_b = _RecordingAutoScanner("AA:BB:CC:DD:EE:01", BluetoothScanningMode.AUTO) c_a = manager.async_register_scanner(busy_a) c_b = manager.async_register_scanner(busy_b) busy_a._add_connecting("11:22:33:44:55:66") busy_b._add_connecting("11:22:33:44:55:77") async def _fail_on_sleep(delay: float) -> None: pytest.fail(f"async_request_active_scan slept for {delay}s on NOOP") try: before = loop.time() with patch("asyncio.sleep", new=_fail_on_sleep): await manager.async_request_active_scan(duration=5.0) assert busy_a.active_window_calls == [] assert busy_b.active_window_calls == [] assert loop.time() - before < 0.5 assert manager._auto_scheduler._on_demand_sweep_future is None assert manager._auto_scheduler._on_demand_sweep_end == 0.0 finally: busy_a._finished_connecting("11:22:33:44:55:66", connected=False) busy_b._finished_connecting("11:22:33:44:55:77", connected=False) c_a() c_b() @pytest.mark.asyncio async def test_async_request_active_scan_awaits_the_full_duration() -> None: """ The sweep awaits ``duration`` so the caller can read advertisements. Freezegun patches ``time.monotonic`` (and thus ``loop.time``); advancing the frozen clock by the requested duration lets the scheduler's internal ``asyncio.sleep`` complete and the task finish. The scanner is registered inside the freeze so the worker's ``_sweep_last_completed`` is anchored to the frozen clock; the worker's background ``_run`` task is cancelled so it cannot tick during the on-demand window. """ manager = get_manager() sched = manager._auto_scheduler with freeze_time() as frozen: scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) worker = sched._workers[scanner.source] worker.stop() await asyncio.sleep(0) try: task = asyncio.create_task(manager.async_request_active_scan(duration=5.0)) # Let the task start, flip the radio, and enter asyncio.sleep. for _ in range(5): await asyncio.sleep(0) assert scanner.active_window_calls == [5.0] assert not task.done() # Advance past the sweep duration; the sleep wakes up. frozen.tick(5.1) await task assert task.done() finally: register_cancel() @pytest.mark.asyncio async def test_async_request_active_scan_extension_reverts_end_when_no_targets() -> ( None ): """ A joiner extension that finds every worker busy reverts the end push. Leader dispatches successfully and parks in its sleep loop. The only scanner then goes mid-connect; the joiner's extension flip has no eligible targets and returns False. The eager ``_on_demand_sweep_end`` push must be reverted to the leader's original end so the leader does not sleep past the in-flight radio window for nothing. """ manager = get_manager() sched = manager._auto_scheduler with freeze_time() as frozen: scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) worker = sched._workers[scanner.source] worker.stop() await asyncio.sleep(0) try: leader = asyncio.create_task( manager.async_request_active_scan(duration=5.0) ) for _ in range(5): await asyncio.sleep(0) assert scanner.active_window_calls == [5.0] leader_end = sched._on_demand_sweep_end # Mark the only worker busy before the joiner extension fires. scanner._add_connecting("11:22:33:44:55:66") try: joiner = asyncio.create_task( manager.async_request_active_scan(duration=20.0) ) for _ in range(5): await asyncio.sleep(0) # Joiner's extension dispatched nothing; only the leader's # flip is recorded and the eager push was reverted. assert scanner.active_window_calls == [5.0] assert sched._on_demand_sweep_end == leader_end finally: scanner._finished_connecting("11:22:33:44:55:66", connected=False) # Advance past the leader's end; leader and joiner both wake. frozen.tick(5.1) await leader await joiner assert sched._on_demand_sweep_future is None finally: register_cancel() @pytest.mark.asyncio async def test_async_request_active_scan_logs_per_scanner_flip_failures( caplog: pytest.LogCaptureFixture, ) -> None: """A scanner whose flip raises is logged; the sweep still completes.""" manager = get_manager() class _FailingScanner(_RecordingAutoScanner): async def async_request_active_window(self, duration: float) -> bool: msg = "boom" raise RuntimeError(msg) bad = _FailingScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) good = _RecordingAutoScanner("AA:BB:CC:DD:EE:01", BluetoothScanningMode.AUTO) c_bad = manager.async_register_scanner(bad) c_good = manager.async_register_scanner(good) try: with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"): async with _no_real_sleep(): await manager.async_request_active_scan(duration=5.0) assert good.active_window_calls == [5.0] assert any( "on-demand active window" in record.message and "boom" in record.message for record in caplog.records ) finally: c_bad() c_good() @pytest.mark.asyncio async def test_async_request_active_scan_no_op_when_every_dispatch_declines() -> None: """ All-False / all-raise from dispatched scanners is the same NOOP as no targets. The flip dispatches but every scanner declines or raises so no radio window actually opens; the leader must skip the sleep loop rather than block on a window that never opened. """ manager = get_manager() loop = asyncio.get_running_loop() class _DecliningScanner(_RecordingAutoScanner): async def async_request_active_window(self, duration: float) -> bool: self.active_window_calls.append(duration) return False class _FailingScanner(_RecordingAutoScanner): async def async_request_active_window(self, duration: float) -> bool: self.active_window_calls.append(duration) msg = "boom" raise RuntimeError(msg) decliner = _DecliningScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) raiser = _FailingScanner("AA:BB:CC:DD:EE:01", BluetoothScanningMode.AUTO) c_dec = manager.async_register_scanner(decliner) c_raise = manager.async_register_scanner(raiser) async def _fail_on_sleep(delay: float) -> None: pytest.fail(f"async_request_active_scan slept for {delay}s on NOOP") sched = manager._auto_scheduler dec_worker = sched._workers[decliner.source] raise_worker = sched._workers[raiser.source] try: before = loop.time() with patch("asyncio.sleep", new=_fail_on_sleep): await manager.async_request_active_scan(duration=5.0) # Both scanners were dispatched to; neither opened a window. assert decliner.active_window_calls == [5.0] assert raiser.active_window_calls == [5.0] assert loop.time() - before < 0.5 assert manager._auto_scheduler._on_demand_sweep_future is None assert manager._auto_scheduler._on_demand_sweep_end == 0.0 # _window_end was pre-bumped for each worker but reverted post-result # so the worker is not locked out of its own ticks. New workers start # with _window_end == 0.0 and the flip should leave them there. assert dec_worker._window_end == 0.0 assert raise_worker._window_end == 0.0 finally: c_dec() c_raise() @pytest.mark.asyncio async def test_async_request_active_scan_revert_skipped_on_concurrent_push() -> None: """A concurrent _window_end push past our bump is not clobbered on revert.""" manager = get_manager() sched = manager._auto_scheduler holder: list[Any] = [] class _MutatingScanner(_RecordingAutoScanner): async def async_request_active_window(self, duration: float) -> bool: self.active_window_calls.append(duration) # Simulate a concurrent extension pushing _window_end out # past the on-demand bump between pre-bump and result; the # revert guard must observe the mismatch and leave it alone. holder[0]._window_end = 1.0e12 return False scanner = _MutatingScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) holder.append(sched._workers[scanner.source]) try: async with _no_real_sleep(): await manager.async_request_active_scan(duration=5.0) # The concurrent push survived our revert (guarded by exact # equality on the value we set). assert holder[0]._window_end == 1.0e12 finally: register_cancel() @pytest.mark.asyncio async def test_async_request_active_scan_window_end_kept_for_succeeding() -> None: """ Mixed True/False results: the True scanner keeps its bumped _window_end. A decliner runs alongside a scanner that returns True. The decliner's pre-bumped _window_end is reverted while the True scanner keeps the bump so its periodic worker tick stays suppressed for the duration of the real radio window. """ manager = get_manager() sched = manager._auto_scheduler class _DecliningScanner(_RecordingAutoScanner): async def async_request_active_window(self, duration: float) -> bool: self.active_window_calls.append(duration) return False decliner = _DecliningScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) good = _RecordingAutoScanner("AA:BB:CC:DD:EE:01", BluetoothScanningMode.AUTO) c_dec = manager.async_register_scanner(decliner) c_good = manager.async_register_scanner(good) dec_worker = sched._workers[decliner.source] good_worker = sched._workers[good.source] try: async with _no_real_sleep(): await manager.async_request_active_scan(duration=5.0) assert decliner.active_window_calls == [5.0] assert good.active_window_calls == [5.0] # Decliner reverted to 0.0; succeeding scanner retains the bump. assert dec_worker._window_end == 0.0 assert good_worker._window_end > 0.0 finally: c_dec() c_good() @pytest.mark.asyncio async def test_async_request_active_scan_leader_honors_joiner_success_on_decline() -> ( None ): """ Leader's all-declined flip must not wipe state while a joiner has opened a window. Sequence: 1. Leader flips X (eligible, blocks on its window call); Y is mid-connect and skipped. 2. While leader is parked in its gather, Y finishes connecting and a joiner arrives wanting a longer window. The joiner extends ``_on_demand_sweep_end`` and re-flips; Y returns True, the joiner does not revert, then parks on the shared future. 3. X unblocks and returns False; the leader's flip therefore returns False (only X was dispatched, X declined). 4. ``_on_demand_sweep_end`` was pushed past the leader's ``desired_end`` by the joiner, so the leader must NOT fast-return: it falls through to the sleep loop and sleeps until the joiner's end, otherwise the joiner is cut short and the radio window is orphaned from scheduler bookkeeping. """ manager = get_manager() sched = manager._auto_scheduler with freeze_time() as frozen: x = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) y = _RecordingAutoScanner("AA:BB:CC:DD:EE:01", BluetoothScanningMode.AUTO) x._return_value = False x._block_event = asyncio.Event() c_x = manager.async_register_scanner(x) c_y = manager.async_register_scanner(y) wx = sched._workers[x.source] wy = sched._workers[y.source] # Silence each worker's own tick so it cannot pollute # active_window_calls during the on-demand sweep. wx.stop() wy.stop() await asyncio.sleep(0) # Y starts mid-connect so the leader's flip skips it entirely. y._add_connecting("11:22:33:44:55:66") try: leader = asyncio.create_task( manager.async_request_active_scan(duration=5.0) ) for _ in range(3): await asyncio.sleep(0) # Leader is now blocked inside X's flip; Y was skipped. assert x.active_window_calls == [5.0] assert y.active_window_calls == [] # Free Y so a joiner can dispatch it. y._finished_connecting("11:22:33:44:55:66", connected=False) joiner = asyncio.create_task( manager.async_request_active_scan(duration=20.0) ) for _ in range(3): await asyncio.sleep(0) # Joiner extended end and dispatched Y; Y opened a window. assert y.active_window_calls == [20.0] joiner_end = sched._on_demand_sweep_end assert joiner_end > 0.0 # Unblock X; leader's flip resolves with all-declined. x._block_event.set() for _ in range(5): await asyncio.sleep(0) # Without the joiner-extension guard, the leader would # have fast-returned and zeroed _on_demand_sweep_end / # resolved the shared future. With the guard, both tasks # are still parked and the end is intact. assert not leader.done() assert not joiner.done() assert sched._on_demand_sweep_end == joiner_end # Advance past the joiner's end; both tasks complete. frozen.tick(20.1) await leader await joiner assert sched._on_demand_sweep_future is None finally: c_x() c_y() @pytest.mark.asyncio async def test_async_request_active_scan_joiner_cancel_during_extension() -> None: """ Joiner cancelled mid-extension still finishes the all-or-nothing re-flip. Without ``asyncio.shield`` on the extension flip, a joiner cancelled while extending would leave ``_on_demand_sweep_end`` pushed out past a partial re-flip, so the leader (and other joiners) would sleep until the extended end believing every scanner was active when some were not. With the shield, the re-flip runs to completion; the scanner records both the leader's original flip duration and the extension duration even after the joiner is cancelled. """ manager = get_manager() sched = manager._auto_scheduler with freeze_time() as frozen: # Register inside freeze + stop the worker so the worker's # own periodic sweep does not fire and pollute the recorded # active_window_calls. scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) worker = sched._workers[scanner.source] worker.stop() await asyncio.sleep(0) try: # Block scanner flips so the leader stays in its first # flip await while the joiner extends and is cancelled. scanner._block_event = asyncio.Event() leader = asyncio.create_task( manager.async_request_active_scan(duration=5.0) ) for _ in range(3): await asyncio.sleep(0) joiner = asyncio.create_task( manager.async_request_active_scan(duration=20.0) ) # Yield enough for the joiner to enter its shielded # extension flip (which is now also blocked on the # scanner's block_event). for _ in range(3): await asyncio.sleep(0) assert len(scanner.active_window_calls) == 2 joiner.cancel() with contextlib.suppress(asyncio.CancelledError): await joiner # Release the scanner; leader's flip returns, joiner's # shielded flip also completes as an orphan task. scanner._block_event.set() frozen.tick(20.1) await leader # Both leader's 5s flip and joiner's ~20s extension # recorded despite the cancel. assert scanner.active_window_calls[0] == 5.0 assert scanner.active_window_calls[1] == pytest.approx(20.0, abs=1.0) assert sched._on_demand_sweep_future is None finally: register_cancel() @pytest.mark.asyncio async def test_async_request_active_scan_joiner_cancel_keeps_siblings() -> None: """ Cancelling one joiner must not cancel the shared future. Without ``asyncio.shield`` on the joiner's await, a cancelled joiner would cancel the underlying future, which then propagates ``CancelledError`` to sibling joiners and makes the leader's ``finally`` raise ``InvalidStateError`` on ``set_result``. """ manager = get_manager() sched = manager._auto_scheduler scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: with freeze_time() as frozen: scanner._block_event = asyncio.Event() leader = asyncio.create_task( manager.async_request_active_scan(duration=5.0) ) joiner_a = asyncio.create_task( manager.async_request_active_scan(duration=5.0) ) joiner_b = asyncio.create_task( manager.async_request_active_scan(duration=5.0) ) for _ in range(5): await asyncio.sleep(0) # Cancel one joiner; siblings and leader must continue. joiner_a.cancel() with contextlib.suppress(asyncio.CancelledError): await joiner_a scanner._block_event.set() frozen.tick(5.1) # Leader and the surviving joiner both complete normally. await leader await joiner_b assert joiner_b.result() is None assert sched._on_demand_sweep_future is None finally: register_cancel() @pytest.mark.asyncio async def test_async_request_active_scan_leader_cancellation_releases_joiners() -> None: """ Cancelling the leader still resolves the future so joiners do not hang. Joiners see ``None`` (no propagated ``CancelledError``) and benefit from whatever radio activity already happened; a subsequent sweep can run because ``_on_demand_sweep_future`` is cleared. """ manager = get_manager() sched = manager._auto_scheduler scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: with freeze_time(): # Block the leader inside its scanner-flip await so we know # we're past the gather and inside the leader's sleep when # we cancel. scanner._block_event = asyncio.Event() leader = asyncio.create_task( manager.async_request_active_scan(duration=5.0) ) joiner = asyncio.create_task( manager.async_request_active_scan(duration=5.0) ) for _ in range(5): await asyncio.sleep(0) # Both tasks are now waiting; joiner has latched onto the # leader's future. assert not leader.done() assert not joiner.done() scanner._block_event.set() leader.cancel() with contextlib.suppress(asyncio.CancelledError): await leader # Joiner completes normally with no exception. await joiner assert joiner.result() is None # Future is cleared so a fresh sweep can start. assert sched._on_demand_sweep_future is None finally: register_cancel() @pytest.mark.asyncio async def test_async_request_active_scan_declined_does_not_advance_sweep_floor() -> ( None ): """A False flip leaves _sweep_last_completed alone so the periodic sweep retries.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) scanner._return_value = False register_cancel = manager.async_register_scanner(scanner) worker = sched._workers[scanner.source] # Backdate so an erroneous bump would be visible. original = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 worker._sweep_last_completed = original try: async with _no_real_sleep(): await manager.async_request_active_scan(duration=5.0) assert scanner.active_window_calls == [5.0] assert worker._sweep_last_completed == original finally: register_cancel() @pytest.mark.asyncio async def test_async_request_active_scan_exception_does_not_advance_sweep_floor() -> ( None ): """A flip that raises also leaves the sweep floor alone for that scanner.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() class _FailingScanner(_RecordingAutoScanner): async def async_request_active_window(self, duration: float) -> bool: msg = "boom" raise RuntimeError(msg) bad = _FailingScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) good = _RecordingAutoScanner("AA:BB:CC:DD:EE:01", BluetoothScanningMode.AUTO) c_bad = manager.async_register_scanner(bad) c_good = manager.async_register_scanner(good) bad_worker = sched._workers[bad.source] good_worker = sched._workers[good.source] original = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 bad_worker._sweep_last_completed = original good_worker._sweep_last_completed = original try: async with _no_real_sleep(): await manager.async_request_active_scan(duration=5.0) # bad raised; floor stays put. good returned True; floor advances. assert bad_worker._sweep_last_completed == original assert good_worker._sweep_last_completed > original finally: c_bad() c_good() @pytest.mark.asyncio async def test_async_request_active_scan_cancelled_flip_logged_distinctly( caplog: pytest.LogCaptureFixture, ) -> None: """A CancelledError result is logged as cancelled, not as a False decline.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() class _CancellingScanner(_RecordingAutoScanner): async def async_request_active_window(self, duration: float) -> bool: raise asyncio.CancelledError cancelling = _CancellingScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(cancelling) worker = sched._workers[cancelling.source] original = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0 worker._sweep_last_completed = original try: with caplog.at_level(logging.DEBUG, logger="habluetooth.auto_scheduler"): async with _no_real_sleep(): await manager.async_request_active_scan(duration=5.0) assert worker._sweep_last_completed == original assert any( "cancelled during on-demand active window" in record.message for record in caplog.records ) assert not any( "declined on-demand active window" in record.message for record in caplog.records ) finally: register_cancel() @pytest.mark.asyncio async def test_async_request_active_scan_does_not_shrink_existing_window_end() -> None: """A pre-existing longer _window_end is not shrunk by a new flip.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) worker = sched._workers[scanner.source] pre_existing = loop.time() + 9999.0 worker._window_end = pre_existing try: async with _no_real_sleep(): await manager.async_request_active_scan(duration=5.0) assert worker._window_end == pre_existing finally: register_cancel() @pytest.mark.asyncio async def test_async_request_active_scan_does_not_move_sweep_floor_backwards() -> None: """A pre-existing later _sweep_last_completed is not moved backwards.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) worker = sched._workers[scanner.source] pre_existing = loop.time() + 9999.0 worker._sweep_last_completed = pre_existing try: async with _no_real_sleep(): await manager.async_request_active_scan(duration=5.0) assert worker._sweep_last_completed == pre_existing finally: register_cancel() @pytest.mark.asyncio async def test_stop_resolves_in_flight_on_demand_sweep_future() -> None: """stop() resolves the future, frees joiners, leader's finally tolerates it.""" manager = get_manager() sched = manager._auto_scheduler scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) worker = sched._workers[scanner.source] worker.stop() await asyncio.sleep(0) try: scanner._block_event = asyncio.Event() leader = asyncio.create_task(manager.async_request_active_scan(duration=5.0)) joiner = asyncio.create_task(manager.async_request_active_scan(duration=5.0)) for _ in range(5): await asyncio.sleep(0) assert sched._on_demand_sweep_future is not None assert not joiner.done() sched.stop() # Future resolved, state cleared, joiner wakes up to None. assert manager._auto_scheduler._on_demand_sweep_future is None assert manager._auto_scheduler._on_demand_sweep_end == 0.0 await joiner assert joiner.result() is None # Release the leader so its finally runs; the done() guard # absorbs ``stop()``'s already-resolved future. scanner._block_event.set() await asyncio.wait_for(leader, timeout=1.0) assert leader.result() is None finally: register_cancel() @pytest.mark.asyncio async def test_stop_is_safe_when_on_demand_future_already_done() -> None: """stop() does not raise InvalidStateError on an already-resolved future.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() done_future: asyncio.Future[None] = loop.create_future() done_future.set_result(None) sched._on_demand_sweep_future = done_future sched.stop() assert manager._auto_scheduler._on_demand_sweep_future is None assert manager._auto_scheduler._on_demand_sweep_end == 0.0 @pytest.mark.asyncio async def test_stop_is_safe_without_in_flight_on_demand_sweep() -> None: """``stop()`` with no active sweep is a no-op for the sweep state.""" manager = get_manager() sched = manager._auto_scheduler scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: assert sched._on_demand_sweep_future is None sched.stop() assert manager._auto_scheduler._on_demand_sweep_future is None assert manager._auto_scheduler._on_demand_sweep_end == 0.0 finally: register_cancel() @pytest.mark.asyncio async def test_orphan_leader_does_not_clobber_fresh_sweep_state() -> None: """A leader orphaned by stop()+start() does not clear a fresh future.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) sched._workers[scanner.source].stop() await asyncio.sleep(0) try: # Start L1 and block it inside its flip await. scanner._block_event = asyncio.Event() l1 = asyncio.create_task(manager.async_request_active_scan(duration=5.0)) for _ in range(5): await asyncio.sleep(0) assert sched._on_demand_sweep_future is not None # Tear down and re-arm against the same loop, then install # fresh sweep state as if L2 had won the next dedup check. # Stop the freshly-spawned worker so its periodic tick does # not interfere with the assertion below. sched.stop() sched.start(loop) sched._workers[scanner.source].stop() await asyncio.sleep(0) fresh_future: asyncio.Future[None] = loop.create_future() sched._on_demand_sweep_future = fresh_future # Past-end so L1's sleep loop exits immediately and runs # its finally; the fresh state below is what we check. sched._on_demand_sweep_end = loop.time() - 1.0 # Release L1 so its finally runs; it must leave the fresh # future alone (identity check on ``_on_demand_sweep_future``) # and must not call ``set_result`` on the already-done L1 # future (the ``done()`` guard). scanner._block_event.set() await asyncio.wait_for(l1, timeout=1.0) assert sched._on_demand_sweep_future is fresh_future assert not fresh_future.done() finally: if not fresh_future.done(): fresh_future.set_result(None) sched._on_demand_sweep_future = None sched._on_demand_sweep_end = 0.0 register_cancel() @pytest.mark.asyncio async def test_owned_due_at_populated_for_owner_on_add_request_with_history() -> None: """add_request hooks the entry into the owner's _owned_due_at view.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:66" scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: # First seed history via an advertisement so add_request's # history-gating finds it. _inject(scanner, address) # Drop the entry add_request seeded so we can re-add through # add_request and observe its _schedule.assign side-effect. sched._schedule._due_at.pop(address, None) sched._schedule._owner_by_address.pop(address, None) sched._workers[scanner.source]._owned_due_at.pop(address, None) cancel = manager.async_register_active_scan(address, scan_interval=60.0) try: owned = sched._workers[scanner.source]._owned_due_at assert address in owned # Inner dict must be the SAME object aliased between # _due_at and _owned_due_at so _advance_due mutations apply # to both views. assert owned[address] is sched._schedule._due_at[address] assert sched._schedule._owner_by_address[address] == scanner.source finally: cancel() finally: register_cancel() @pytest.mark.asyncio async def test_add_request_without_history_leaves_owned_due_at_empty() -> None: """add_request without history defers seeding to on_advertisement.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:66" scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: cancel = manager.async_register_active_scan(address, scan_interval=60.0) try: assert address not in sched._schedule._due_at assert address not in sched._schedule._owner_by_address assert address not in sched._workers[scanner.source]._owned_due_at finally: cancel() finally: register_cancel() @pytest.mark.asyncio async def test_on_advertisement_bootstraps_owned_due_at() -> None: """First advertisement for a tracked address populates owner's view.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:66" scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: cancel = manager.async_register_active_scan(address, scan_interval=60.0) try: _inject(scanner, address) owned = sched._workers[scanner.source]._owned_due_at assert address in owned assert owned[address] is sched._schedule._due_at[address] assert sched._schedule._owner_by_address[address] == scanner.source finally: cancel() finally: register_cancel() @pytest.mark.asyncio async def test_ownership_flip_moves_entry_between_owned_due_at() -> None: """On migration, the entry moves from old owner's view to new owner's view.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:99" cancel = manager.async_register_active_scan(address, scan_interval=60.0) s_a = _RecordingAutoScanner("AA:00:00:00:00:01", BluetoothScanningMode.AUTO) s_b = _RecordingAutoScanner("AA:00:00:00:00:02", BluetoothScanningMode.AUTO) c_a = manager.async_register_scanner(s_a) c_b = manager.async_register_scanner(s_b) try: _inject_with_rssi(s_a, address, rssi=-80) worker_a = sched._workers[s_a.source] worker_b = sched._workers[s_b.source] assert address in worker_a._owned_due_at assert address not in worker_b._owned_due_at assert sched._schedule._owner_by_address[address] == s_a.source # B with much stronger RSSI triggers ownership flip in the # manager; scheduler's on_advertisement reassigns owner. _inject_with_rssi(s_b, address, rssi=-30) assert address not in worker_a._owned_due_at assert address in worker_b._owned_due_at assert worker_b._owned_due_at[address] is sched._schedule._due_at[address] assert sched._schedule._owner_by_address[address] == s_b.source finally: c_a() c_b() cancel() @pytest.mark.asyncio async def test_remove_request_clears_owner_when_bucket_empties() -> None: """The last remove_request for an address clears owner and _owned_due_at.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:66" scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: cancel = manager.async_register_active_scan(address, scan_interval=60.0) _inject(scanner, address) worker = sched._workers[scanner.source] assert address in worker._owned_due_at cancel() assert address not in sched._schedule._due_at assert address not in sched._schedule._owner_by_address assert address not in worker._owned_due_at finally: register_cancel() @pytest.mark.asyncio async def test_remove_request_preserves_owner_when_other_requests_remain() -> None: """Removing one of N requests on an address keeps the owner mapping intact.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:66" scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: cancel1 = manager.async_register_active_scan(address, scan_interval=60.0) cancel2 = manager.async_register_active_scan(address, scan_interval=120.0) _inject(scanner, address) worker = sched._workers[scanner.source] assert len(sched._schedule._due_at[address]) == 2 cancel1() assert address in sched._schedule._due_at assert sched._schedule._owner_by_address[address] == scanner.source assert address in worker._owned_due_at cancel2() assert address not in sched._schedule._due_at assert address not in sched._schedule._owner_by_address assert address not in worker._owned_due_at finally: register_cancel() @pytest.mark.asyncio async def test_remove_scanner_clears_owner_and_due_at() -> None: """Removing the owning scanner drops the entry from _due_at and the owner index.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan(address, scan_interval=60.0) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) assert address in sched._schedule._due_at assert sched._schedule._owner_by_address[address] == scanner.source register_cancel() assert address not in sched._schedule._due_at assert address not in sched._schedule._owner_by_address assert scanner.source not in sched._workers finally: cancel() @pytest.mark.asyncio async def test_stop_clears_all_owned_due_at_state() -> None: """stop() drains _due_at, _owner_by_address, and per-worker _owned_due_at.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan(address, scan_interval=60.0) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) worker = sched._workers[scanner.source] assert address in worker._owned_due_at sched.stop() assert sched._schedule._due_at == {} assert sched._schedule._owner_by_address == {} # Worker dropped from registry; the cleared dict is on the # detached instance still held by the local. Confirm both. assert sched._workers == {} assert worker._owned_due_at == {} finally: cancel() # register_cancel() may double-call but is idempotent on the # manager side; guard against AttributeError nonetheless. with contextlib.suppress(KeyError, ValueError): register_cancel() @pytest.mark.asyncio async def test_start_replay_populates_owned_due_at() -> None: """A request registered before start() is hooked into the worker's view on start.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: # Seed history via a real injection so the replay loop's # last_service_info check sees a source. _inject(scanner, address) # Wipe scheduler state and stop the running scheduler so we # can drive start() ourselves with pre-registered requests. sched._workers[scanner.source].stop() await asyncio.sleep(0) sched.stop() cancel = manager.async_register_active_scan(address, scan_interval=60.0) try: # Pre-start: only _requests_by_address is populated. assert address not in sched._schedule._due_at assert address not in sched._schedule._owner_by_address sched.start(loop) try: # After start, the replay seeded _due_at and assigned # the owner. worker = sched._workers[scanner.source] assert address in sched._schedule._due_at assert sched._schedule._owner_by_address[address] == scanner.source assert address in worker._owned_due_at assert worker._owned_due_at[address] is sched._schedule._due_at[address] finally: # Stop the spawned worker tasks so they do not leak. for w in list(sched._workers.values()): w.stop() await asyncio.sleep(0) finally: cancel() finally: register_cancel() @pytest.mark.asyncio async def test_spawn_worker_picks_up_preassigned_owner() -> None: """A scanner registering after on_advertisement gets the entry hooked up.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan(address, scan_interval=60.0) source = "AA:BB:CC:DD:EE:00" # Forge an owner mapping for an unregistered source — the same # state that arises when on_advertisement seeds before its scanner # registers as AUTO. We bypass the manager here because plumbing a # synthetic history entry is more wiring than the invariant needs. request = next(iter(sched._requests_by_address[address])) sched._schedule._due_at[address] = {request: loop.time()} sched._schedule._owner_by_address[address] = source try: scanner = _RecordingAutoScanner(source, BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[source] assert address in worker._owned_due_at assert worker._owned_due_at[address] is sched._schedule._due_at[address] finally: register_cancel() finally: cancel() @pytest.mark.asyncio async def test_next_event_at_skips_other_workers_entries() -> None: """An entry owned by scanner A doesn't lower scanner B's next event.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan(address, scan_interval=60.0) s_a = _RecordingAutoScanner("AA:00:00:00:00:01", BluetoothScanningMode.AUTO) s_b = _RecordingAutoScanner("AA:00:00:00:00:02", BluetoothScanningMode.AUTO) c_a = manager.async_register_scanner(s_a) c_b = manager.async_register_scanner(s_b) try: _inject(s_a, address) # Drive the entry's due time well below A's sweep so any leak # of foreign entries would change B's next_at. entries = sched._schedule._due_at[address] for req in list(entries): entries[req] = loop.time() + 1.0 worker_b = sched._workers[s_b.source] sweep_at_b = worker_b._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL assert worker_b._next_event_at(loop.time()) == sweep_at_b finally: c_a() c_b() cancel() @pytest.mark.asyncio async def test_next_event_at_makes_no_history_calls() -> None: """The hot path no longer pays a per-entry async_last_service_info lookup.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan(address, scan_interval=60.0) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) worker = sched._workers[scanner.source] with patch.object( manager, "async_last_service_info", side_effect=AssertionError ): # Must not consult the manager's history; iterates owned # entries exclusively. worker._next_event_at(loop.time()) finally: cancel() register_cancel() @pytest.mark.asyncio async def test_collect_due_buckets_resyncs_owned_view_on_drift() -> None: """If owned view disagrees with manager history, _collect_due_buckets resyncs.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan(address, scan_interval=60.0) s_a = _RecordingAutoScanner("AA:00:00:00:00:01", BluetoothScanningMode.AUTO) s_b = _RecordingAutoScanner("AA:00:00:00:00:02", BluetoothScanningMode.AUTO) c_a = manager.async_register_scanner(s_a) c_b = manager.async_register_scanner(s_b) try: _inject(s_a, address) worker_a = sched._workers[s_a.source] worker_b = sched._workers[s_b.source] # Force a drift: manager's history says B owns the address, # scheduler still has it parked under A's view. (Simulates # any future code path that mutates history without notifying # on_advertisement.) info = manager.async_last_service_info(address, False) assert info is not None info.source = s_b.source entries = sched._schedule._due_at[address] for req in list(entries): entries[req] = loop.time() - 1.0 await worker_a._tick() # A skipped (foreign), reassigned ownership to B. assert s_a.active_window_calls == [] assert address not in worker_a._owned_due_at assert address in worker_b._owned_due_at assert sched._schedule._owner_by_address[address] == s_b.source finally: c_a() c_b() cancel() @pytest.mark.asyncio async def test_assign_owner_noop_when_source_unchanged() -> None: """Repeated on_advertisement from the same scanner is a no-op.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan(address, scan_interval=60.0) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) worker = sched._workers[scanner.source] entries = sched._schedule._due_at[address] owned_before = worker._owned_due_at[address] # Second injection from the same scanner: owner unchanged, # owned-view aliasing preserved (same dict object). _inject(scanner, address) assert sched._schedule._owner_by_address[address] == scanner.source assert worker._owned_due_at[address] is owned_before assert sched._schedule._due_at[address] is entries finally: cancel() register_cancel() @pytest.mark.asyncio async def test_spawn_worker_skips_foreign_preassigned_owners() -> None: """_spawn_worker only attaches entries owned by the new scanner's source.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address_a = "11:22:33:44:55:66" address_b = "77:88:99:AA:BB:CC" cancel_a = manager.async_register_active_scan(address_a, scan_interval=60.0) cancel_b = manager.async_register_active_scan(address_b, scan_interval=60.0) foreign_source = "AA:BB:CC:DD:EE:FF" request_b = next(iter(sched._requests_by_address[address_b])) sched._schedule._due_at[address_b] = {request_b: loop.time()} sched._schedule._owner_by_address[address_b] = foreign_source try: scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] # Foreign-owned address must not appear in this worker's view. assert address_b not in worker._owned_due_at finally: register_cancel() finally: cancel_a() cancel_b() @pytest.mark.asyncio async def test_ownership_index_clear_source_no_match() -> None: """clear_source over an index with no addresses for the source is a no-op.""" manager = get_manager() sched = manager._auto_scheduler scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] sched._schedule.clear_source(scanner.source) assert sched._schedule._owner_by_address == {} assert worker._owned_due_at == {} finally: register_cancel() def test_ownership_index_clear_no_workers() -> None: """clear() over an index with no workers leaves state empty.""" workers: dict[str, Any] = {} idx = _ScanSchedule(workers) idx._owner_by_address["AA:00:00:00:00:99"] = "ghost" idx.clear() assert idx._owner_by_address == {} @pytest.mark.asyncio async def test_ownership_assign_records_non_auto_owner() -> None: """assign() records ownership for a non-AUTO source despite no worker.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan(address, scan_interval=60.0) # ACTIVE scanner does not get an AUTO worker; advertising from it # exercises the ``new_worker is None`` branch in assign(). passive = _RecordingAutoScanner("AA:BB:CC:DD:EE:01", BluetoothScanningMode.PASSIVE) register_cancel = manager.async_register_scanner(passive) try: _inject(passive, address) assert sched._schedule._owner_by_address[address] == passive.source assert passive.source not in sched._workers finally: cancel() register_cancel() @pytest.mark.asyncio async def test_remove_non_auto_scanner_clears_its_owned_addresses() -> None: """clear_source's non-AUTO fallback prunes addresses owned by a PASSIVE source.""" manager = get_manager() sched = manager._auto_scheduler own_addr = "11:22:33:44:55:66" other_addr = "11:22:33:44:55:77" cancel_own = manager.async_register_active_scan(own_addr, scan_interval=60.0) cancel_other = manager.async_register_active_scan(other_addr, scan_interval=60.0) passive = _RecordingAutoScanner("AA:BB:CC:DD:EE:01", BluetoothScanningMode.PASSIVE) auto = _RecordingAutoScanner("AA:BB:CC:DD:EE:02", BluetoothScanningMode.AUTO) c_passive = manager.async_register_scanner(passive) c_auto = manager.async_register_scanner(auto) try: # PASSIVE owns own_addr; AUTO owns other_addr. Both end up in # _owner_by_address, so the non-AUTO clear loop iterates past # other_addr without matching. _inject(passive, own_addr) _inject(auto, other_addr) assert sched._schedule._owner_by_address[own_addr] == passive.source assert sched._schedule._owner_by_address[other_addr] == auto.source c_passive() assert own_addr not in sched._schedule._owner_by_address assert own_addr not in sched._schedule._due_at # The AUTO-owned address is untouched. assert sched._schedule._owner_by_address[other_addr] == auto.source finally: cancel_own() cancel_other() c_auto() @pytest.mark.asyncio async def test_ownership_flip_from_non_auto_to_auto_owner() -> None: """assign() handles flipping from a non-AUTO owner whose worker is absent.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:66" cancel = manager.async_register_active_scan(address, scan_interval=60.0) passive = _RecordingAutoScanner("AA:BB:CC:DD:EE:01", BluetoothScanningMode.PASSIVE) auto = _RecordingAutoScanner("AA:BB:CC:DD:EE:02", BluetoothScanningMode.AUTO) c_passive = manager.async_register_scanner(passive) c_auto = manager.async_register_scanner(auto) try: # PASSIVE first with weak RSSI; records owner mapping with no worker. _inject_with_rssi(passive, address, rssi=-80) assert sched._schedule._owner_by_address[address] == passive.source # AUTO flip with stronger RSSI: old_source has no worker (PASSIVE), # so the detach branch in assign() hits ``old_worker is None``. _inject_with_rssi(auto, address, rssi=-30) assert sched._schedule._owner_by_address[address] == auto.source assert address in sched._workers[auto.source]._owned_due_at finally: cancel() c_passive() c_auto() @pytest.mark.asyncio async def test_invariant_through_full_lifecycle() -> None: """Schedule invariant holds at every step of a typical request lifecycle.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:66" _assert_schedule_invariant(sched) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) _assert_schedule_invariant(sched) cancel = manager.async_register_active_scan(address, scan_interval=60.0) _assert_schedule_invariant(sched) _inject(scanner, address) _assert_schedule_invariant(sched) assert address in sched._schedule._due_at assert sched._schedule._owner_by_address[address] == scanner.source cancel() _assert_schedule_invariant(sched) assert address not in sched._schedule._due_at assert address not in sched._schedule._owner_by_address register_cancel() _assert_schedule_invariant(sched) @pytest.mark.asyncio async def test_invariant_through_ownership_flips() -> None: """Schedule invariant holds through a sequence of RSSI-driven flips.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:66" s_a = _RecordingAutoScanner("AA:00:00:00:00:01", BluetoothScanningMode.AUTO) s_b = _RecordingAutoScanner("AA:00:00:00:00:02", BluetoothScanningMode.AUTO) s_c = _RecordingAutoScanner("AA:00:00:00:00:03", BluetoothScanningMode.AUTO) c_a = manager.async_register_scanner(s_a) c_b = manager.async_register_scanner(s_b) c_c = manager.async_register_scanner(s_c) cancel = manager.async_register_active_scan(address, scan_interval=60.0) try: _assert_schedule_invariant(sched) # Each step is a >= 40 dB jump (monotonically stronger) so the # manager flips ownership on every inject. for scanner, rssi in ( (s_a, -100), (s_b, -60), (s_c, -20), ): _inject_with_rssi(scanner, address, rssi=rssi) _assert_schedule_invariant(sched) assert sched._schedule._owner_by_address[address] == scanner.source # Same scanner re-advertises: same owner; invariant holds. _inject_with_rssi(s_c, address, rssi=-15) _assert_schedule_invariant(sched) assert sched._schedule._owner_by_address[address] == s_c.source finally: cancel() c_a() c_b() c_c() _assert_schedule_invariant(sched) @pytest.mark.asyncio async def test_invariant_through_mixed_mode_flips() -> None: """ Schedule invariant holds when ownership flips across all scanner modes. Mix of ACTIVE, PASSIVE, and AUTO scanners; ownership migrates among them as RSSI climbs. Only the AUTO scanner has a worker, so the invariant exercises both the "owner has worker" and "owner has no worker" branches in the same scenario. """ manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:66" active = _RecordingAutoScanner("AA:00:00:00:00:0A", BluetoothScanningMode.ACTIVE) passive = _RecordingAutoScanner("AA:00:00:00:00:0B", BluetoothScanningMode.PASSIVE) auto = _RecordingAutoScanner("AA:00:00:00:00:0C", BluetoothScanningMode.AUTO) c_active = manager.async_register_scanner(active) c_passive = manager.async_register_scanner(passive) c_auto = manager.async_register_scanner(auto) cancel = manager.async_register_active_scan(address, scan_interval=60.0) try: _assert_schedule_invariant(sched) # Workers exist only for AUTO scanners. assert active.source not in sched._workers assert passive.source not in sched._workers assert auto.source in sched._workers auto_worker = sched._workers[auto.source] # ACTIVE first (no worker for the owner; address is in # _owner_by_address but not in any worker's _owned_due_at). _inject_with_rssi(active, address, rssi=-100) _assert_schedule_invariant(sched) assert sched._schedule._owner_by_address[address] == active.source assert address not in auto_worker._owned_due_at # PASSIVE with stronger RSSI: another non-AUTO owner. _inject_with_rssi(passive, address, rssi=-60) _assert_schedule_invariant(sched) assert sched._schedule._owner_by_address[address] == passive.source assert address not in auto_worker._owned_due_at # AUTO with stronger RSSI: ownership flips to the worker-having # source; the address now appears in the worker's owned view. _inject_with_rssi(auto, address, rssi=-20) _assert_schedule_invariant(sched) assert sched._schedule._owner_by_address[address] == auto.source assert address in auto_worker._owned_due_at # Flip BACK to PASSIVE by unregistering the AUTO scanner: its # owned-view entry is pruned via clear_source. The address goes # away from the schedule entirely (no other scanner advertised # since the AUTO claim). c_auto() _assert_schedule_invariant(sched) assert address not in sched._schedule._owner_by_address # A subsequent PASSIVE advertisement re-bootstraps ownership; # back to non-AUTO-owned state without a worker view. _inject_with_rssi(passive, address, rssi=-50) _assert_schedule_invariant(sched) assert sched._schedule._owner_by_address[address] == passive.source finally: cancel() c_active() c_passive() _assert_schedule_invariant(sched) @pytest.mark.asyncio async def test_invariant_through_orphan_prune() -> None: """Schedule invariant holds after a tick prunes an aged-out address.""" manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "AA:BB:CC:DD:EE:FF" cancel = manager.async_register_active_scan(address, scan_interval=60.0) scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject(scanner, address) _assert_schedule_invariant(sched) # Force the entry due so the tick actually inspects history. request = next(iter(sched._schedule._due_at[address])) sched._schedule._due_at[address][request] = loop.time() - 1.0 # History ages out under the worker's feet; the tick's orphan # branch should clean every index in lock step. manager._all_history.pop(address, None) manager._connectable_history.pop(address, None) await _run_worker_tick(sched, scanner.source) _assert_schedule_invariant(sched) assert address not in sched._schedule._due_at assert address not in sched._schedule._owner_by_address finally: cancel() register_cancel() @pytest.mark.asyncio async def test_invariant_through_many_addresses_and_scanners() -> None: """Schedule invariant holds when many scanners and addresses interleave.""" manager = get_manager() sched = manager._auto_scheduler scanners = [ _RecordingAutoScanner(f"AA:00:00:00:00:{i:02X}", BluetoothScanningMode.AUTO) for i in range(4) ] register_cancels = [manager.async_register_scanner(s) for s in scanners] cancels = [ manager.async_register_active_scan( f"BB:00:00:00:00:{i:02X}", scan_interval=60.0 ) for i in range(16) ] addresses = [f"BB:00:00:00:00:{i:02X}" for i in range(16)] try: _assert_schedule_invariant(sched) # Round-robin each address to a scanner, ramping RSSI by 30 dB # per round so every later inject flips ownership. for round_ix, scanner in enumerate(scanners): rssi = -100 + 30 * round_ix for address in addresses: _inject_with_rssi(scanner, address, rssi=rssi) _assert_schedule_invariant(sched) # All addresses now owned by the last scanner (strongest RSSI). last = scanners[-1] for address in addresses: assert sched._schedule._owner_by_address[address] == last.source # Removing the owner scanner prunes the addresses it owned; # the schedule cleans them out without surfacing them anywhere. register_cancels[-1]() _assert_schedule_invariant(sched) for address in addresses: assert address not in sched._schedule._due_at finally: for c in cancels: c() for rc in register_cancels[:-1]: rc() _assert_schedule_invariant(sched) @pytest.mark.asyncio async def test_unown_fails_fast_on_missing_due_at() -> None: """Pin the fail-fast contract: KeyError if ``_due_at`` lacks the address.""" manager = get_manager() sched = manager._auto_scheduler sched._schedule._owner_by_address["AA:00:00:00:00:99"] = "ghost" try: with pytest.raises(KeyError): sched._schedule.unown("AA:00:00:00:00:99") finally: sched._schedule._owner_by_address.pop("AA:00:00:00:00:99", None) @pytest.mark.asyncio async def test_unown_fails_fast_on_missing_owner() -> None: """Pin the fail-fast contract: KeyError if ``_owner_by_address`` lacks address.""" manager = get_manager() sched = manager._auto_scheduler sched._schedule._due_at["AA:00:00:00:00:99"] = {} try: with pytest.raises(KeyError): sched._schedule.unown("AA:00:00:00:00:99") finally: sched._schedule._due_at.pop("AA:00:00:00:00:99", None) @pytest.mark.asyncio async def test_detach_owned_fails_fast_on_missing_address() -> None: """Pin the fail-fast contract: KeyError if worker doesn't own the address.""" manager = get_manager() sched = manager._auto_scheduler scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] with pytest.raises(KeyError): worker._detach_owned("AA:00:00:00:00:99") finally: register_cancel() @pytest.mark.asyncio async def test_next_event_at_fails_fast_on_empty_owned_bucket() -> None: """Pin the fail-fast contract: ValueError on an empty owned bucket.""" manager = get_manager() sched = manager._auto_scheduler scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: worker = sched._workers[scanner.source] worker._owned_due_at["AA:00:00:00:00:99"] = {} try: with pytest.raises(ValueError, match="min"): worker._next_event_at(asyncio.get_running_loop().time()) finally: worker._owned_due_at.pop("AA:00:00:00:00:99", None) finally: register_cancel() @pytest.mark.asyncio async def test_invariant_through_mode_switches() -> None: """ Schedule invariant holds across in-place scanner mode switches. HA's UI mode switch is implemented as unregister-old + register-new with the same source. The scheduler has to (a) drop the worker when AUTO leaves, (b) spawn a fresh worker when AUTO arrives, and (c) keep ``_requests_by_address`` intact across the gap so the new worker can be re-bootstrapped. Walk through every transition with invariant checks at each step. """ manager = get_manager() sched = manager._auto_scheduler source = "AA:BB:CC:DD:EE:32" address = "11:22:33:44:55:99" cancel = manager.async_register_active_scan(address, scan_interval=60.0) _assert_schedule_invariant(sched) try: # 1. PASSIVE first (entering as non-AUTO). passive = _RecordingAutoScanner(source, BluetoothScanningMode.PASSIVE) c_passive = manager.async_register_scanner(passive) _assert_schedule_invariant(sched) assert source not in sched._workers _inject(passive, address) _assert_schedule_invariant(sched) assert sched._schedule._owner_by_address[address] == source # 2. Switch INTO AUTO: unregister PASSIVE, register AUTO at same source. c_passive() _assert_schedule_invariant(sched) # PASSIVE's clear_source removed the owner mapping. assert address not in sched._schedule._owner_by_address # User's registration survived. assert address in sched._requests_by_address auto = _RecordingAutoScanner(source, BluetoothScanningMode.AUTO) c_auto = manager.async_register_scanner(auto) _assert_schedule_invariant(sched) assert source in sched._workers # 3. AUTO sees the device, becomes owner with a worker attached. _inject(auto, address) _assert_schedule_invariant(sched) assert sched._schedule._owner_by_address[address] == source assert address in sched._workers[source]._owned_due_at # 4. Switch OUT of AUTO: unregister AUTO, register ACTIVE same source. c_auto() _assert_schedule_invariant(sched) assert source not in sched._workers assert address not in sched._schedule._owner_by_address active = _RecordingAutoScanner(source, BluetoothScanningMode.ACTIVE) c_active = manager.async_register_scanner(active) _assert_schedule_invariant(sched) assert source not in sched._workers # 5. ACTIVE sees the device; back to a non-AUTO-owned state. _inject(active, address) _assert_schedule_invariant(sched) assert sched._schedule._owner_by_address[address] == source c_active() _assert_schedule_invariant(sched) finally: cancel() _assert_schedule_invariant(sched) @pytest.mark.asyncio async def test_invariant_through_stop_and_restart() -> None: """Schedule invariant holds across stop + start replay.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:55:66" scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) cancel = manager.async_register_active_scan(address, scan_interval=60.0) try: _inject(scanner, address) _assert_schedule_invariant(sched) sched.stop() _assert_schedule_invariant(sched) assert sched._schedule._due_at == {} assert sched._schedule._owner_by_address == {} # Give the previously running worker task a chance to fully exit # so the post-restart spawn doesn't share its source slot. await asyncio.sleep(0) loop = asyncio.get_running_loop() sched.start(loop) _assert_schedule_invariant(sched) # start() replayed the request and re-assigned via history. assert address in sched._schedule._due_at assert sched._schedule._owner_by_address[address] == scanner.source finally: cancel() register_cancel() _assert_schedule_invariant(sched) Bluetooth-Devices-habluetooth-75cbe37/tests/test_base_scanner.py000066400000000000000000001434121521117704500252020ustar00rootroot00000000000000"""Tests for the Bluetooth base scanner models.""" from __future__ import annotations import asyncio import time from datetime import timedelta from typing import Any from unittest.mock import ANY, MagicMock, Mock, patch import pytest from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from bleak_retry_connector import Allocations from bluetooth_data_tools import monotonic_time_coarse from habluetooth import ( BaseHaRemoteScanner, BaseHaScanner, BluetoothManager, BluetoothScannerDevice, BluetoothScanningMode, HaBluetoothConnector, HaScannerDetails, HaScannerModeChange, HaScannerType, get_manager, set_manager, ) from habluetooth.const import ( CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, SCANNER_WATCHDOG_INTERVAL, SCANNER_WATCHDOG_TIMEOUT, ) from habluetooth.storage import ( DiscoveredDeviceAdvertisementData, ) from . import ( HCI0_SOURCE_ADDRESS, MockBleakClient, async_fire_time_changed, generate_advertisement_data, generate_ble_device, patch_bluetooth_time, utcnow, ) from . import ( InjectableRemoteScanner as FakeScanner, ) from .conftest import FakeBluetoothAdapters, MockBluetoothManagerWithCallbacks @pytest.mark.parametrize("name_2", [None, "w"]) @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_remote_scanner(name_2: str | None) -> None: """Test the remote scanner base class merges advertisement_data.""" manager = get_manager() switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"], service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"}, manufacturer_data={1: b"\x01"}, rssi=-100, ) switchbot_device_2 = generate_ble_device( "44:44:33:11:23:45", name_2, {}, rssi=-100, ) switchbot_device_adv_2 = generate_advertisement_data( local_name=name_2, service_uuids=["00000001-0000-1000-8000-00805f9b34fb"], service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"}, manufacturer_data={1: b"\x01", 2: b"\x02"}, rssi=-100, ) switchbot_device_3 = generate_ble_device( "44:44:33:11:23:45", "wohandlonger", {}, rssi=-100, ) switchbot_device_adv_3 = generate_advertisement_data( local_name="wohandlonger", service_uuids=["00000001-0000-1000-8000-00805f9b34fb"], service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"}, manufacturer_data={1: b"\x01", 2: b"\x02"}, rssi=-100, ) switchbot_device_adv_4 = generate_advertisement_data( local_name="wohandlonger", service_uuids=["00000001-0000-1000-8000-00805f9b34fb"], service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"}, manufacturer_data={1: b"\x04", 2: b"\x02", 3: b"\x03"}, rssi=-100, ) switchbot_device_adv_5 = generate_advertisement_data( local_name="wohandlonger", service_uuids=["00000001-0000-1000-8000-00805f9b34fb"], service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"}, manufacturer_data={1: b"\x04", 2: b"\x01"}, rssi=-100, ) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = FakeScanner("esp32", "esp32", connector, True) details = scanner.details assert details == HaScannerDetails( source=scanner.source, connectable=scanner.connectable, name=scanner.name, adapter=scanner.adapter, scanner_type=HaScannerType.REMOTE, ) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) scanner.inject_advertisement(switchbot_device, switchbot_device_adv) data = scanner.discovered_devices_and_advertisement_data discovered_device, discovered_adv_data = data[switchbot_device.address] assert discovered_device.address == switchbot_device.address assert discovered_device.name == switchbot_device.name assert ( discovered_adv_data.manufacturer_data == switchbot_device_adv.manufacturer_data ) assert discovered_adv_data.service_data == switchbot_device_adv.service_data assert discovered_adv_data.service_uuids == switchbot_device_adv.service_uuids scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_2) data = scanner.discovered_devices_and_advertisement_data discovered_device, discovered_adv_data = data[switchbot_device.address] assert discovered_device.address == switchbot_device.address assert discovered_device.name == switchbot_device.name assert discovered_adv_data.manufacturer_data == {1: b"\x01", 2: b"\x02"} assert discovered_adv_data.service_data == { "050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff", "00000001-0000-1000-8000-00805f9b34fb": b"\n\xff", } assert set(discovered_adv_data.service_uuids) == { "050a021a-0000-1000-8000-00805f9b34fb", "00000001-0000-1000-8000-00805f9b34fb", } # The longer name should be used scanner.inject_advertisement(switchbot_device_3, switchbot_device_adv_3) assert discovered_device.name == switchbot_device_3.name # Inject the shorter name / None again to make # sure we always keep the longer name scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_2) assert discovered_device.name == switchbot_device_3.name scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_4) assert scanner.discovered_devices_and_advertisement_data[ switchbot_device_2.address ][1].manufacturer_data == {1: b"\x04", 2: b"\x02", 3: b"\x03"} scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_5) assert scanner.discovered_devices_and_advertisement_data[ switchbot_device_2.address ][1].manufacturer_data == {1: b"\x04", 2: b"\x01", 3: b"\x03"} assert ( "00090401-0052-036b-3206-ff0a050a021a" not in scanner.discovered_devices_and_advertisement_data[ switchbot_device_2.address ][1].service_data ) scanner.inject_raw_advertisement( switchbot_device_2.address, 0, b"\x12\x21\x1a\x02\n\x05\n\xff\x062k\x03R\x00\x01\x04\t\x00\x04", ) assert ( "00090401-0052-036b-3206-ff0a050a021a" in scanner.discovered_devices_and_advertisement_data[ switchbot_device_2.address ][1].service_data ) assert scanner.serialize_discovered_devices() == DiscoveredDeviceAdvertisementData( connectable=True, expire_seconds=195, discovered_device_advertisement_datas={"44:44:33:11:23:45": ANY}, discovered_device_timestamps={"44:44:33:11:23:45": ANY}, discovered_device_raw={ "44:44:33:11:23:45": b"\x12!\x1a\x02" b"\n\x05\n\xff" b"\x062k\x03" b"R\x00\x01\x04" b"\t\x00\x04" }, ) cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_raw_advertisement_fast_path_unchanged() -> None: """Test that sending the same raw bytes twice uses the fast path.""" manager = get_manager() connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) raw_adv = b"\x12\x21\x1a\x02\n\x05\n\xff\x062k\x03R\x00\x01\x04\t\x00\x04" address = "44:44:33:11:23:45" # First raw advertisement — takes the slow path (parse + merge) scanner.inject_raw_advertisement(address, -60, raw_adv, 1.0) info1 = scanner._previous_service_info[address] assert info1.rssi == -60 assert info1.raw == raw_adv # Second raw advertisement with same bytes — takes the fast path scanner.inject_raw_advertisement(address, -50, raw_adv, 2.0) info2 = scanner._previous_service_info[address] assert info2.rssi == -50 assert info2.time == 2.0 # Fast path reuses parsed data from prev_info assert info2.manufacturer_data is info1.manufacturer_data assert info2.service_data is info1.service_data assert info2.service_uuids is info1.service_uuids assert info2.name is info1.name assert info2.device is info1.device assert info2.raw is info1.raw # Third raw advertisement with different bytes — takes the slow path raw_adv_changed = b"\x12\x21\x1a\x02\n\x05\n\xff\x062k\x03R\x00\x01\x04\t\x00\x05" scanner.inject_raw_advertisement(address, -40, raw_adv_changed, 3.0) info3 = scanner._previous_service_info[address] assert info3.rssi == -40 assert info3.raw == raw_adv_changed # Slow path re-parsed, so objects may differ assert info3.raw is not info1.raw 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 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 ( 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 ( scanner.get_discovered_device_advertisement_data(switchbot_device.address) is None ) cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_remote_scanner_discovered_device_timestamps_deprecated() -> None: """The leading-underscore shim still returns timestamps but warns.""" 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) scanner.inject_advertisement(switchbot_device, switchbot_device_adv) with pytest.warns( FutureWarning, match=r"BaseHaScanner\._discovered_device_timestamps" ): legacy = scanner._discovered_device_timestamps assert legacy == scanner.discovered_device_timestamps assert len(legacy) == 1 cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_base_scanner_connecting_behavior() -> None: """Test the default behavior is to mark the scanner as not scanning on connect.""" manager = get_manager() switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-100, ) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) with scanner.connecting(): assert scanner.scanning is False # We should still accept new advertisements while connecting # since advertisements are delivered asynchronously and # we don't want to miss any even when we are willing to # accept advertisements from another scanner in the brief window # between when we start connecting and when we stop scanning scanner.inject_advertisement(switchbot_device, switchbot_device_adv) devices = scanner.discovered_devices assert len(scanner.discovered_devices) == 1 assert len(scanner.discovered_devices_and_advertisement_data) == 1 assert devices[0].name == "wohand" cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_scanner_stops_responding() -> None: """Test we mark a scanner are not scanning when it stops responding.""" manager = get_manager() connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = FakeScanner( "esp32", "esp32", connector, True, current_mode=BluetoothScanningMode.ACTIVE, requested_mode=BluetoothScanningMode.ACTIVE, ) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) start_time_monotonic = time.monotonic() assert scanner.scanning is True failure_reached_time = ( start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds() ) # We hit the timer with no detections, # so we reset the adapter and restart the scanner with patch_bluetooth_time(failure_reached_time): async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL) await asyncio.sleep(0) assert scanner.scanning is False bparasite_device = generate_ble_device( # type: ignore[unreachable] "44:44:33:11:23:45", "bparasite", {}, rssi=-100, ) bparasite_device_adv = generate_advertisement_data( local_name="bparasite", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-100, ) failure_reached_time += 1 with patch_bluetooth_time(failure_reached_time): scanner.inject_advertisement( bparasite_device, bparasite_device_adv, failure_reached_time ) # As soon as we get a detection, we know the scanner is working again assert scanner.scanning is True assert scanner.requested_mode == BluetoothScanningMode.ACTIVE assert scanner.current_mode == BluetoothScanningMode.ACTIVE cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_merge_manufacturer_data_history_existing() -> None: """Test merging manufacturer data history.""" manager = get_manager() sensor_push_device = generate_ble_device( "44:44:33:11:23:45", "", {}, rssi=-60, ) sensor_push_device_adv = generate_advertisement_data( local_name="", rssi=-60, manufacturer_data={ 64256: b"B\r.\xa9\xb6", 31488: b"\x98\xfa\xb6\x91\xb6", }, service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"], service_data={}, ) sensor_push_adv_2 = generate_advertisement_data( local_name="", service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"], service_data={}, manufacturer_data={ 31488: b"\x98\xfa\xb6\x91\xb6", }, rssi=-100, ) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = FakeScanner("esp32", "esp32", connector, True) details = scanner.details assert details == HaScannerDetails( source=scanner.source, connectable=scanner.connectable, name=scanner.name, adapter=scanner.adapter, scanner_type=HaScannerType.REMOTE, ) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) scanner.inject_advertisement(sensor_push_device, sensor_push_device_adv) data = scanner.discovered_devices_and_advertisement_data discovered_device, discovered_adv_data = data[sensor_push_device.address] assert discovered_device.address == sensor_push_device.address assert discovered_device.name == sensor_push_device.name assert ( discovered_adv_data.manufacturer_data == sensor_push_device_adv.manufacturer_data ) assert discovered_adv_data.service_data == sensor_push_device_adv.service_data assert discovered_adv_data.service_uuids == sensor_push_device_adv.service_uuids scanner.inject_advertisement(sensor_push_device, sensor_push_adv_2) data = scanner.discovered_devices_and_advertisement_data discovered_device, discovered_adv_data = data[sensor_push_device.address] assert discovered_device.address == sensor_push_device.address assert discovered_device.name == sensor_push_device.name assert discovered_adv_data.manufacturer_data == { **sensor_push_device_adv.manufacturer_data, **sensor_push_adv_2.manufacturer_data, } assert discovered_adv_data.service_data == {} assert set(discovered_adv_data.service_uuids) == { *sensor_push_device_adv.service_uuids } cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_merge_manufacturer_data_history_new() -> None: """Test merging manufacturer data history.""" manager = get_manager() sensor_push_device = generate_ble_device( "44:44:33:11:23:45", "", {}, rssi=-60, ) sensor_push_device_adv = generate_advertisement_data( local_name="", rssi=-60, manufacturer_data={ 64256: b"B\r.\xa9\xb6", 31488: b"\x98\xfa\xb6\x91\xb6", }, service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"], service_data={}, ) sensor_push_adv_2 = generate_advertisement_data( local_name="", service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"], service_data={}, manufacturer_data={ 21248: b"\xb9\xe9\xe1\xb9\xb6", }, rssi=-100, ) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = FakeScanner("esp32", "esp32", connector, True) details = scanner.details assert details == HaScannerDetails( source=scanner.source, connectable=scanner.connectable, name=scanner.name, adapter=scanner.adapter, scanner_type=HaScannerType.REMOTE, ) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) scanner.inject_advertisement(sensor_push_device, sensor_push_device_adv) data = scanner.discovered_devices_and_advertisement_data discovered_device, discovered_adv_data = data[sensor_push_device.address] assert discovered_device.address == sensor_push_device.address assert discovered_device.name == sensor_push_device.name assert ( discovered_adv_data.manufacturer_data == sensor_push_device_adv.manufacturer_data ) assert discovered_adv_data.service_data == sensor_push_device_adv.service_data assert discovered_adv_data.service_uuids == sensor_push_device_adv.service_uuids scanner.inject_advertisement(sensor_push_device, sensor_push_adv_2) data = scanner.discovered_devices_and_advertisement_data discovered_device, discovered_adv_data = data[sensor_push_device.address] assert discovered_device.address == sensor_push_device.address assert discovered_device.name == sensor_push_device.name assert discovered_adv_data.manufacturer_data == { **sensor_push_device_adv.manufacturer_data, **sensor_push_adv_2.manufacturer_data, } assert discovered_adv_data.service_data == {} assert set(discovered_adv_data.service_uuids) == { *sensor_push_device_adv.service_uuids } cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_filter_apple_data() -> None: """Test filtering apple data accepts bytes that start with 01.""" manager = get_manager() device = generate_ble_device( "44:44:33:11:23:45", "", {}, rssi=-60, ) device_adv = generate_advertisement_data( local_name="", rssi=-60, manufacturer_data={ 76: b"\x01\r.\xa9\xb6", }, service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"], service_data={}, ) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = FakeScanner("esp32", "esp32", connector, True) details = scanner.details assert details == HaScannerDetails( source=scanner.source, connectable=scanner.connectable, name=scanner.name, adapter=scanner.adapter, scanner_type=HaScannerType.REMOTE, ) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) scanner.inject_advertisement(device, device_adv) data = scanner.discovered_devices_and_advertisement_data discovered_device, discovered_adv_data = data[device.address] assert discovered_device.address == device.address assert discovered_device.name == device.name assert discovered_adv_data.manufacturer_data == device_adv.manufacturer_data unsetup() cancel() @pytest.mark.usefixtures("register_hci0_scanner") def test_connection_history_count_in_progress() -> None: """Test connection history in process counting.""" manager = get_manager() device1_address = "44:44:33:11:23:12" device2_address = "44:44:33:11:23:13" hci0_scanner = manager.async_scanner_by_source(HCI0_SOURCE_ADDRESS) assert hci0_scanner is not None hci0_scanner._add_connecting(device1_address) assert hci0_scanner._connections_in_progress() == 1 hci0_scanner._add_connecting(device1_address) hci0_scanner._add_connecting(device2_address) assert hci0_scanner._connections_in_progress() == 3 hci0_scanner._finished_connecting(device1_address, True) assert hci0_scanner._connections_in_progress() == 2 hci0_scanner._finished_connecting(device1_address, False) assert hci0_scanner._connections_in_progress() == 1 hci0_scanner._finished_connecting(device2_address, False) assert hci0_scanner._connections_in_progress() == 0 @pytest.mark.usefixtures("register_hci0_scanner") def test_connection_history_failure_count(caplog: pytest.LogCaptureFixture) -> None: """Test connection history failure count.""" manager = get_manager() device1_address = "44:44:33:11:23:12" device2_address = "44:44:33:11:23:13" hci0_scanner = manager.async_scanner_by_source(HCI0_SOURCE_ADDRESS) assert hci0_scanner is not None hci0_scanner._add_connecting(device1_address) hci0_scanner._finished_connecting(device1_address, False) assert hci0_scanner._connection_failures(device1_address) == 1 hci0_scanner._add_connecting(device1_address) hci0_scanner._add_connecting(device2_address) hci0_scanner._finished_connecting(device1_address, False) assert hci0_scanner._connection_failures(device1_address) == 2 hci0_scanner._finished_connecting(device2_address, False) assert hci0_scanner._connection_failures(device2_address) == 1 hci0_scanner._add_connecting(device1_address) hci0_scanner._finished_connecting(device1_address, True) # On success, we should reset the failure count assert hci0_scanner._connection_failures(device1_address) == 0 assert "Removing a non-existing connecting" not in caplog.text hci0_scanner._finished_connecting(device1_address, True) assert "Removing a non-existing connecting" in caplog.text @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_scanner_mode_changes() -> None: """Test scanner mode change methods notify the manager.""" manager = get_manager() # Track mode changes mode_changes: list[HaScannerModeChange] = [] def mode_callback(change: HaScannerModeChange) -> None: """Track mode changes.""" mode_changes.append(change) cancel = manager.async_register_scanner_mode_change_callback(mode_callback, None) # Create a scanner with initial modes scanner = FakeScanner( HCI0_SOURCE_ADDRESS, "hci0", connectable=True, requested_mode=BluetoothScanningMode.PASSIVE, current_mode=BluetoothScanningMode.PASSIVE, ) # Set up the scanner unsetup = scanner.async_setup() # Test changing requested mode scanner.set_requested_mode(BluetoothScanningMode.ACTIVE) assert len(mode_changes) == 1 assert mode_changes[0].scanner == scanner assert mode_changes[0].requested_mode == BluetoothScanningMode.ACTIVE assert mode_changes[0].current_mode == BluetoothScanningMode.PASSIVE assert scanner.requested_mode == BluetoothScanningMode.ACTIVE # Test changing current mode scanner.set_current_mode(BluetoothScanningMode.ACTIVE) assert len(mode_changes) == 2 assert mode_changes[1].scanner == scanner assert mode_changes[1].requested_mode == BluetoothScanningMode.ACTIVE assert mode_changes[1].current_mode == BluetoothScanningMode.ACTIVE assert scanner.current_mode == BluetoothScanningMode.ACTIVE # Test no notification when mode doesn't change scanner.set_current_mode(BluetoothScanningMode.ACTIVE) assert len(mode_changes) == 2 # No new notification # Test setting to None scanner.set_requested_mode(None) assert len(mode_changes) == 3 assert mode_changes[2].requested_mode is None assert scanner.requested_mode is None scanner.set_current_mode(None) # type: ignore[unreachable] assert len(mode_changes) == 4 assert mode_changes[3].current_mode is None assert scanner.current_mode is None # Clean up unsetup() cancel() def test_remote_scanner_type() -> None: """Test that remote scanners have REMOTE type.""" class TestRemoteScanner(BaseHaRemoteScanner): """Test remote scanner implementation.""" scanner = TestRemoteScanner("test_source", "test_adapter") assert scanner.details.scanner_type is HaScannerType.REMOTE def test_base_scanner_with_connector() -> None: """Test BaseHaScanner with connector and adapter type.""" manager = get_manager() mock_adapters: dict[str, dict[str, Any]] = { "test_adapter": { "address": "00:1A:7D:DA:71:04", "adapter_type": "usb", } } connector = HaBluetoothConnector( client=MagicMock, source="test_source", can_connect=lambda: True ) with patch.object(manager, "_adapters", mock_adapters): scanner = BaseHaScanner( source="test_source", adapter="test_adapter", connector=connector, connectable=True, ) assert scanner.details.scanner_type is HaScannerType.USB class TestScanner(BaseHaScanner): """Test scanner without slots for mocking.""" __test__ = False def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._test_allocations = None def get_allocations(self): """Override to return test allocations.""" return self._test_allocations def test_score_with_no_allocations(): """Test scoring when no allocation info is available.""" scanner = TestScanner( source="test_source", adapter="test_adapter", connectable=True, ) ble_device = BLEDevice( address="00:11:22:33:44:55", name="Test Device", details={}, ) advertisement = AdvertisementData( local_name="Test Device", manufacturer_data={}, service_data={}, service_uuids=[], rssi=-50, platform_data=(), tx_power=None, ) scanner_device = BluetoothScannerDevice( scanner=scanner, ble_device=ble_device, advertisement=advertisement, ) # No allocation info available, should just use RSSI score = scanner._score_connection_paths(10, scanner_device) assert score == -50 def test_score_with_all_slots_free(): """Test scoring when all slots are free.""" scanner = TestScanner( source="test_source", adapter="test_adapter", connectable=True, ) # Set test allocations to return all slots free scanner._test_allocations = Allocations( adapter="test_adapter", slots=5, free=5, allocated=[], ) ble_device = BLEDevice( address="00:11:22:33:44:55", name="Test Device", details={}, ) advertisement = AdvertisementData( local_name="Test Device", manufacturer_data={}, service_data={}, service_uuids=[], rssi=-50, platform_data=(), tx_power=None, ) scanner_device = BluetoothScannerDevice( scanner=scanner, ble_device=ble_device, advertisement=advertisement, ) # All slots free, no penalty score = scanner._score_connection_paths(10, scanner_device) assert score == -50 def test_score_with_one_slot_remaining(): """Test scoring when only one slot remains.""" scanner = TestScanner( source="test_source", adapter="test_adapter", connectable=True, ) # Set test allocations to return only 1 slot free scanner._test_allocations = Allocations( adapter="test_adapter", slots=5, free=1, allocated=[ "AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02", "AA:BB:CC:DD:EE:03", "AA:BB:CC:DD:EE:04", ], ) ble_device = BLEDevice( address="00:11:22:33:44:55", name="Test Device", details={}, ) advertisement = AdvertisementData( local_name="Test Device", manufacturer_data={}, service_data={}, service_uuids=[], rssi=-50, platform_data=(), tx_power=None, ) scanner_device = BluetoothScannerDevice( scanner=scanner, ble_device=ble_device, advertisement=advertisement, ) # One slot remaining, small penalty (rssi_diff * 0.76) score = scanner._score_connection_paths(10, scanner_device) assert score == -50 - (10 * 0.76) assert score == -57.6 def test_score_with_no_slots_available(): """Test scoring when no slots are available.""" scanner = TestScanner( source="test_source", adapter="test_adapter", connectable=True, ) # Set test allocations to return no slots free scanner._test_allocations = Allocations( adapter="test_adapter", slots=5, free=0, allocated=[ "AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02", "AA:BB:CC:DD:EE:03", "AA:BB:CC:DD:EE:04", "AA:BB:CC:DD:EE:05", ], ) ble_device = BLEDevice( address="00:11:22:33:44:55", name="Test Device", details={}, ) advertisement = AdvertisementData( local_name="Test Device", manufacturer_data={}, service_data={}, service_uuids=[], rssi=-50, platform_data=(), tx_power=None, ) scanner_device = BluetoothScannerDevice( scanner=scanner, ble_device=ble_device, advertisement=advertisement, ) # No slots available, returns NO_RSSI_VALUE (-127) score = scanner._score_connection_paths(10, scanner_device) assert score == -127 def test_score_comparison_with_different_slot_availability(): """Test that scanners with more free slots score better.""" # Scanner with all slots free scanner_all_free = TestScanner( source="adapter1", adapter="adapter1", connectable=True, ) # Scanner with one slot remaining scanner_one_slot = TestScanner( source="adapter2", adapter="adapter2", connectable=True, ) # Scanner with no slots scanner_no_slots = TestScanner( source="adapter3", adapter="adapter3", connectable=True, ) ble_device = BLEDevice( address="00:11:22:33:44:55", name="Test Device", details={}, ) advertisement = AdvertisementData( local_name="Test Device", manufacturer_data={}, service_data={}, service_uuids=[], rssi=-50, platform_data=(), tx_power=None, ) # Create scanner devices for each scanner device_all_free = BluetoothScannerDevice( scanner=scanner_all_free, ble_device=ble_device, advertisement=advertisement, ) device_one_slot = BluetoothScannerDevice( scanner=scanner_one_slot, ble_device=ble_device, advertisement=advertisement, ) device_no_slots = BluetoothScannerDevice( scanner=scanner_no_slots, ble_device=ble_device, advertisement=advertisement, ) # Set allocations for each scanner scanner_all_free._test_allocations = Allocations( adapter="adapter1", slots=5, free=5, allocated=[], ) scanner_one_slot._test_allocations = Allocations( adapter="adapter2", slots=5, free=1, allocated=[ "AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02", "AA:BB:CC:DD:EE:03", "AA:BB:CC:DD:EE:04", ], ) scanner_no_slots._test_allocations = Allocations( adapter="adapter3", slots=5, free=0, allocated=[ "AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02", "AA:BB:CC:DD:EE:03", "AA:BB:CC:DD:EE:04", "AA:BB:CC:DD:EE:05", ], ) score_all_free = scanner_all_free._score_connection_paths(10, device_all_free) score_one_slot = scanner_one_slot._score_connection_paths(10, device_one_slot) score_no_slots = scanner_no_slots._score_connection_paths(10, device_no_slots) # Verify the scoring order: all_free > one_slot > no_slots assert score_all_free > score_one_slot assert score_one_slot > score_no_slots # Verify specific values assert score_all_free == -50 assert score_one_slot == -57.6 assert score_no_slots == -127 # NO_RSSI_VALUE def test_score_with_connections_in_progress_and_slots(): """Test that both connection progress and slot availability are considered.""" scanner = TestScanner( source="test_source", adapter="test_adapter", connectable=True, ) # Add a connection in progress scanner._add_connecting("FF:EE:DD:CC:BB:AA") # Set test allocations to return only 1 slot free scanner._test_allocations = Allocations( adapter="test_adapter", slots=5, free=1, allocated=[ "AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02", "AA:BB:CC:DD:EE:03", "AA:BB:CC:DD:EE:04", ], ) ble_device = BLEDevice( address="00:11:22:33:44:55", name="Test Device", details={}, ) advertisement = AdvertisementData( local_name="Test Device", manufacturer_data={}, service_data={}, service_uuids=[], rssi=-50, platform_data=(), tx_power=None, ) scanner_device = BluetoothScannerDevice( scanner=scanner, ble_device=ble_device, advertisement=advertisement, ) # Both penalties should apply score = scanner._score_connection_paths(10, scanner_device) # -50 (RSSI) - 10.1 (connection in progress) - 7.6 (last slot) assert score == -50 - 10.1 - 7.6 assert score == -67.7 @pytest.mark.asyncio async def test_on_scanner_start_callback_remote_scanner( async_mock_manager_with_scanner_callbacks: MockBluetoothManagerWithCallbacks, ) -> None: """Test that on_scanner_start is called when a remote scanner starts.""" manager = async_mock_manager_with_scanner_callbacks # Create a fake remote scanner scanner = FakeScanner( source="esp32_proxy", adapter="esp32_proxy", connector=None, connectable=True, ) # Simulate scanner start success scanner._on_start_success() # Verify the callback was called assert len(manager.scanner_start_calls) == 1 assert manager.scanner_start_calls[0] is scanner @pytest.mark.asyncio async def test_on_scanner_start_multiple_scanners( async_mock_manager_with_scanner_callbacks: MockBluetoothManagerWithCallbacks, ) -> None: """Test that on_scanner_start is called for multiple scanners.""" manager = async_mock_manager_with_scanner_callbacks # Create multiple scanners scanner1 = FakeScanner( source="scanner1", adapter="scanner1", connector=None, connectable=True, ) scanner2 = FakeScanner( source="scanner2", adapter="scanner2", connector=None, connectable=True, ) # Simulate both scanners starting scanner1._on_start_success() scanner2._on_start_success() # Verify both callbacks were called assert len(manager.scanner_start_calls) == 2 assert scanner1 in manager.scanner_start_calls assert scanner2 in manager.scanner_start_calls @pytest.mark.asyncio async def test_scanner_without_manager() -> None: """Test that _on_start_success handles scanner without manager gracefully.""" # Set a temporary manager for scanner creation mock_bluetooth_adapters = FakeBluetoothAdapters() temp_manager = BluetoothManager( mock_bluetooth_adapters, slot_manager=Mock(), ) original_manager = get_manager() set_manager(temp_manager) try: # Create a scanner scanner = FakeScanner( source="test", adapter="test", connector=None, connectable=True, ) # Clear the manager to simulate no manager scenario scanner._manager = None # type: ignore[assignment] # Should not raise an exception scanner._on_start_success() finally: # Restore original manager set_manager(original_manager) @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_remote_scanner_restore_discovered_devices() -> None: """Test serialize→restore round-trip on a BaseHaRemoteScanner.""" manager = get_manager() connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) # First scanner: receives advertisements, then serializes. src_scanner = FakeScanner("esp32", "esp32", connector, True) src_unsetup = src_scanner.async_setup() src_cancel = manager.async_register_scanner(src_scanner) address = "44:44:33:11:23:45" device = generate_ble_device(address, "wohand", {}) 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=-60, tx_power=4, ) now = monotonic_time_coarse() src_scanner.inject_advertisement(device, adv, now=now) raw_adv = b"\x12\x21\x1a\x02\n\x05\n\xff\x062k\x03R\x00\x01\x04\t\x00\x04" src_scanner.inject_raw_advertisement(address, -55, raw_adv, now=now + 1.0) history = src_scanner.serialize_discovered_devices() assert history.discovered_device_raw[address] == raw_adv assert history.discovered_device_timestamps[address] == now + 1.0 # Second scanner: fresh instance, restore from serialized blob. dst_scanner = FakeScanner("esp32-replay", "esp32-replay", connector, True) dst_unsetup = dst_scanner.async_setup() dst_cancel = manager.async_register_scanner(dst_scanner) assert dst_scanner.discovered_devices == [] dst_scanner.restore_discovered_devices(history) restored = dst_scanner.discovered_devices_and_advertisement_data assert set(restored) == {address} rest_device, rest_adv = restored[address] assert rest_device.address == address assert rest_device.name == "wohand" assert rest_adv.manufacturer_data == adv.manufacturer_data assert rest_adv.service_uuids == adv.service_uuids # Restored source overrides the previous scanner's source — the device # belongs to the scanner that owns it now. assert dst_scanner._previous_service_info[address].source == "esp32-replay" # Raw advertisement bytes survive the round-trip. assert dst_scanner._previous_service_info[address].raw == raw_adv src_cancel() src_unsetup() dst_cancel() dst_unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_remote_scanner_async_diagnostics() -> None: """Test BaseHaRemoteScanner.async_diagnostics shape.""" manager = get_manager() connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) address = "44:44:33:11:23:45" device = generate_ble_device(address, "wohand", {}) adv = generate_advertisement_data( local_name="wohand", service_uuids=[], service_data={}, manufacturer_data={1: b"\x01"}, rssi=-60, ) now = monotonic_time_coarse() scanner.inject_advertisement(device, adv, now=now) raw = b"\x02\x01\x06" scanner.inject_raw_advertisement(address, -55, raw, now=now + 1.0) diagnostics = await scanner.async_diagnostics() # Remote scanner adds these three keys on top of the base diagnostics. assert diagnostics["raw_advertisement_data"] == {address: raw} assert diagnostics["discovered_device_timestamps"] == {address: now + 1.0} assert set(diagnostics["time_since_last_device_detection"]) == {address} # Base keys still present. assert diagnostics["source"] == "esp32" assert diagnostics["connectable"] is True # Connection counters start empty. assert diagnostics["connect_in_progress"] == {} assert diagnostics["connect_failures"] == {} cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_diagnostics_surface_connect_state() -> None: """async_diagnostics should expose connect-in-progress and failure counts.""" manager = get_manager() scanner = FakeScanner( source="diag", adapter="diag", connector=None, connectable=True ) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) addr_a = "AA:BB:CC:DD:EE:01" addr_b = "AA:BB:CC:DD:EE:02" # Two attempts in flight for addr_a, one for addr_b. scanner._add_connecting(addr_a) scanner._add_connecting(addr_a) scanner._add_connecting(addr_b) # addr_a then succeeds (clears its failures), addr_b fails twice. scanner._finished_connecting(addr_a, connected=True) scanner._finished_connecting(addr_b, connected=False) scanner._add_connecting(addr_b) scanner._finished_connecting(addr_b, connected=False) diagnostics = await scanner.async_diagnostics() # addr_a still has one in-flight attempt, addr_b is fully drained. assert diagnostics["connect_in_progress"] == {addr_a: 1} assert diagnostics["connect_failures"] == {addr_b: 2} # Lifetime counters: one success (addr_a), two failures (addr_b twice). assert diagnostics["connect_completed_total"] == 1 assert diagnostics["connect_failed_total"] == 2 assert diagnostics["last_connect_completed_time"] > 0.0 # Returned dicts must be copies — mutating them doesn't poison scanner state. diagnostics["connect_in_progress"].clear() diagnostics["connect_failures"].clear() assert scanner._connect_in_progress == {addr_a: 1} assert scanner._connect_failures == {addr_b: 2} cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_lifetime_connect_counters_reset_on_history_clear() -> None: """Lifetime counters should reset whenever connection history is cleared.""" manager = get_manager() scanner = FakeScanner( source="counters", adapter="counters", connector=None, connectable=True ) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) addr = "AA:BB:CC:DD:EE:99" # Three successes, one failure → totals reflect lifetime, not per-address state. for _ in range(3): scanner._add_connecting(addr) scanner._finished_connecting(addr, connected=True) scanner._add_connecting(addr) scanner._finished_connecting(addr, connected=False) assert scanner._connect_completed_total == 3 assert scanner._connect_failed_total == 1 assert scanner._last_connect_completed_time > 0.0 # Per-address failure count survives because success preceded the last failure. assert scanner._connect_failures == {addr: 1} scanner._clear_connection_history() assert scanner._connect_completed_total == 0 assert scanner._connect_failed_total == 0 assert scanner._last_connect_completed_time == 0.0 assert scanner._connect_failures == {} assert scanner._connect_in_progress == {} cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_set_mode_noop_when_unchanged() -> None: """set_requested_mode and set_current_mode should be no-ops on same value.""" manager = get_manager() scanner = FakeScanner( source="modes", adapter="modes", connector=None, connectable=True, requested_mode=BluetoothScanningMode.ACTIVE, ) with patch.object(manager, "scanner_mode_changed") as notify: # Same value → setter is a no-op, no notify. scanner.set_requested_mode(BluetoothScanningMode.ACTIVE) notify.assert_not_called() # Different value → notify fires once. scanner.set_requested_mode(BluetoothScanningMode.PASSIVE) assert notify.call_count == 1 # current_mode was None at init; setting None is a no-op. scanner.set_current_mode(None) assert notify.call_count == 1 # Different value → notify fires again. scanner.set_current_mode(BluetoothScanningMode.PASSIVE) assert notify.call_count == 2 # Repeating same value is a no-op. scanner.set_current_mode(BluetoothScanningMode.PASSIVE) assert notify.call_count == 2 Bluetooth-Devices-habluetooth-75cbe37/tests/test_benchmark_auto_scheduler.py000066400000000000000000000227021521117704500275750ustar00rootroot00000000000000"""Benchmarks for the auto-scan scheduler hot paths.""" from __future__ import annotations import asyncio from typing import TYPE_CHECKING import pytest from habluetooth import ( BaseHaScanner, BluetoothScanningMode, get_manager, ) from . import generate_advertisement_data, generate_ble_device if TYPE_CHECKING: from collections.abc import Iterable from bleak.backends.scanner import AdvertisementData, BLEDevice from pytest_codspeed import BenchmarkFixture from habluetooth.const import CALLBACK_TYPE pytestmark = pytest.mark.timeout(60) class _AutoScanner(BaseHaScanner): """Minimal AUTO-mode scanner that exposes nothing to the discovery cache.""" # Mirrors the _RecordingAutoScanner used by test_auto_scheduler.py but # without window-call tracking; the scheduler hot paths under benchmark # never enter the scanner's active-window path. __slots__ = () def __init__(self, source: str) -> None: super().__init__(source, source, requested_mode=BluetoothScanningMode.AUTO) self.connectable = True async def async_request_active_window(self, duration: float) -> bool: return True @property def discovered_devices(self) -> list[BLEDevice]: return [] @property def discovered_devices_and_advertisement_data( self, ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: return {} def get_discovered_device_advertisement_data( self, address: str ) -> tuple[BLEDevice, AdvertisementData] | None: return None @property def discovered_addresses(self) -> Iterable[str]: return () def _make_address(i: int) -> str: return f"AA:BB:CC:{(i >> 16) & 0xFF:02X}:{(i >> 8) & 0xFF:02X}:{i & 0xFF:02X}" def _make_source(i: int) -> str: # Distinct source MACs so each scanner registers as its own worker. return f"DD:EE:FF:{(i >> 16) & 0xFF:02X}:{(i >> 8) & 0xFF:02X}:{i & 0xFF:02X}" def _inject(scanner: _AutoScanner, address: str, now: float) -> None: """Drive a fake advertisement through the scanner's normal entry point.""" adv = generate_advertisement_data(local_name="x") device = generate_ble_device(address, "x") scanner._async_on_advertisement( device.address, adv.rssi, device.name or "", adv.service_uuids, adv.service_data, adv.manufacturer_data, adv.tx_power, {}, now, ) def _setup_scheduler( num_scanners: int, num_devices: int ) -> tuple[list[_AutoScanner], list[CALLBACK_TYPE], list[CALLBACK_TYPE]]: """ Register ``num_scanners`` AUTO scanners and ``num_devices`` scan requests. Each address is owned by exactly one scanner via a round-robin advertisement injection that populates manager history and auto_scheduler._due_at (and the per-worker _owned_due_at view on branches that have it). """ manager = get_manager() loop = asyncio.get_running_loop() now = loop.time() scanners: list[_AutoScanner] = [] scanner_cancels: list[CALLBACK_TYPE] = [] for i in range(num_scanners): scanner = _AutoScanner(_make_source(i)) scanners.append(scanner) scanner_cancels.append(manager.async_register_scanner(scanner)) request_cancels: list[CALLBACK_TYPE] = [] for i in range(num_devices): address = _make_address(i) request_cancels.append( manager.async_register_active_scan(address, scan_interval=120.0) ) # Inject through the round-robin owner so manager history points # back to that scanner's source. The scheduler picks ownership # from history.source on both the old and new code paths. _inject(scanners[i % num_scanners], address, now) return scanners, scanner_cancels, request_cancels def _teardown_scheduler( scanner_cancels: list[CALLBACK_TYPE], request_cancels: list[CALLBACK_TYPE], ) -> None: """Release scanner and active-scan registrations from ``_setup_scheduler``.""" for cancel in request_cancels: cancel() for cancel in scanner_cancels: cancel() @pytest.mark.asyncio async def test_next_event_at_single_worker_8_scanners_200_devices( benchmark: BenchmarkFixture, ) -> None: """ One worker computing its next wake among 8 scanners and 200 tracked devices. Prior to the per-worker owned-needs optimization (PR #508 / issue #506), every wake iterated the global ``_due_at`` map (200 entries) and called ``async_last_service_info`` on each to filter by ownership. The optimization narrows the iteration to the ~25 entries the worker owns and removes the per-entry history lookup. This benchmark exercises the single-worker hot path so any regression in ``_next_event_at`` cost shows up immediately. """ _, scanner_cancels, request_cancels = _setup_scheduler( num_scanners=8, num_devices=200 ) manager = get_manager() scheduler = manager._auto_scheduler worker = next(iter(scheduler._workers.values())) loop = asyncio.get_running_loop() @benchmark def run() -> None: worker._next_event_at(loop.time()) _teardown_scheduler(scanner_cancels, request_cancels) @pytest.mark.asyncio async def test_next_event_at_burst_8_scanners_200_devices( benchmark: BenchmarkFixture, ) -> None: """ All 8 workers compute their next wake — the burst scenario from issue #506. When an advertisement burst wakes every worker, the old code did O(K·N) work (K=8 workers each scanning N=200 entries). The optimization makes the total work O(N) because each worker only visits its owned subset. This benchmark captures the headline win. """ _, scanner_cancels, request_cancels = _setup_scheduler( num_scanners=8, num_devices=200 ) manager = get_manager() scheduler = manager._auto_scheduler workers = list(scheduler._workers.values()) loop = asyncio.get_running_loop() @benchmark def run() -> None: now = loop.time() for worker in workers: worker._next_event_at(now) _teardown_scheduler(scanner_cancels, request_cancels) @pytest.mark.asyncio async def test_collect_due_buckets_single_worker_8_scanners_200_devices( benchmark: BenchmarkFixture, ) -> None: """ One worker collecting due buckets among 8 scanners and 200 devices. ``_collect_due_buckets`` shares the same iteration-scope problem as ``_next_event_at``: pre-#508 it iterated the global ``_due_at`` and called ``async_last_service_info`` on every address to skip foreign owners; post-#508 it iterates the per-worker owned view directly. With entries scheduled well into the future, this exercises the no-dispatch read path that runs on every tick. """ _, scanner_cancels, request_cancels = _setup_scheduler( num_scanners=8, num_devices=200 ) manager = get_manager() scheduler = manager._auto_scheduler worker = next(iter(scheduler._workers.values())) loop = asyncio.get_running_loop() @benchmark def run() -> None: worker._collect_due_buckets(loop.time()) _teardown_scheduler(scanner_cancels, request_cancels) @pytest.mark.asyncio async def test_collect_due_buckets_burst_8_scanners_200_devices( benchmark: BenchmarkFixture, ) -> None: """All 8 workers collect due buckets — burst variant of the read path.""" _, scanner_cancels, request_cancels = _setup_scheduler( num_scanners=8, num_devices=200 ) manager = get_manager() scheduler = manager._auto_scheduler workers = list(scheduler._workers.values()) loop = asyncio.get_running_loop() @benchmark def run() -> None: now = loop.time() for worker in workers: worker._collect_due_buckets(now) _teardown_scheduler(scanner_cancels, request_cancels) @pytest.mark.asyncio async def test_on_advertisement_steady_state_8_scanners_200_devices( benchmark: BenchmarkFixture, ) -> None: """ Ingestion hot path: re-deliver an advertisement for every tracked address. ``AutoScanScheduler.on_advertisement`` runs once per advertisement for any address that has an active-scan request, fed from ``BluetoothManager._scanner_adv_received``. The existing benchmarks cover the timer side (``_next_event_at`` / ``_collect_due_buckets``); this one covers the per-advertisement ingestion side that the timer benchmarks never touch. The scenario is steady state — the address is already seeded and owned by the delivering scanner — so each call exercises the dominant real-world cost: a ``_requests_by_address`` lookup, a no-op ``_seed_requests`` pass (every request already present, so ``_DueSchedule.seed`` short-circuits), and a same-source ``_DueSchedule.assign`` that skips the owner reattach and only fires the worker's ``wake``. Delivering the cached ``service_info`` objects directly isolates the scheduler cost from the manager's dispatch and scoring path. """ _, scanner_cancels, request_cancels = _setup_scheduler( num_scanners=8, num_devices=200 ) manager = get_manager() scheduler = manager._auto_scheduler # Cached service_info objects carry the round-robin owner as # ``.source``, so each delivery hits the same-owner steady-state path. service_infos = list(manager._all_history.values()) @benchmark def run() -> None: for service_info in service_infos: scheduler.on_advertisement(service_info) _teardown_scheduler(scanner_cancels, request_cancels) Bluetooth-Devices-habluetooth-75cbe37/tests/test_benchmark_base_scanner.py000066400000000000000000001121541521117704500272130ustar00rootroot00000000000000"""Benchmarks for the base scanner.""" from __future__ import annotations import asyncio from typing import TYPE_CHECKING from unittest.mock import MagicMock import pytest from bluetooth_data_tools import monotonic_time_coarse from habluetooth import BaseHaRemoteScanner, HaBluetoothConnector, get_manager from habluetooth.channels.bluez import BluetoothMGMTProtocol from habluetooth.models import BluetoothScanningMode, BluetoothServiceInfoBleak from habluetooth.scanner import HaScanner from . import ( MockBleakClient, generate_advertisement_data, generate_ble_device, ) if TYPE_CHECKING: from bleak.backends.scanner import AdvertisementData from pytest_codspeed import BenchmarkFixture pytestmark = pytest.mark.timeout(60) @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_inject_100_simple_advertisements(benchmark: BenchmarkFixture) -> None: """Test injecting 100 simple advertisements.""" manager = get_manager() switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"], service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"}, manufacturer_data={1: b"\x01"}, rssi=-100, ) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) _address = switchbot_device.address _name = switchbot_device.name _service_uuids = switchbot_device_adv.service_uuids _service_data = switchbot_device_adv.service_data _manufacturer_data = switchbot_device_adv.manufacturer_data _tx_power = switchbot_device_adv.tx_power _details = {"scanner_specific_data": "test"} _now = monotonic_time_coarse() @benchmark def run(): for _ in range(100): scanner._async_on_advertisement( _address, -100, # rssi _name, _service_uuids, _service_data, _manufacturer_data, _tx_power, _details, _now, ) cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_inject_100_complex_advertisements(benchmark: BenchmarkFixture) -> None: """Test injecting 100 complex advertisements.""" manager = get_manager() switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"], service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"}, manufacturer_data=dict.fromkeys(range(100), b"\x01"), rssi=-100, ) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) _address = switchbot_device.address _name = switchbot_device.name _service_uuids = switchbot_device_adv.service_uuids _service_data = switchbot_device_adv.service_data _manufacturer_data = switchbot_device_adv.manufacturer_data _tx_power = switchbot_device_adv.tx_power _details = {"scanner_specific_data": "test"} _now = monotonic_time_coarse() @benchmark def run(): for _ in range(100): scanner._async_on_advertisement( _address, -100, # rssi _name, _service_uuids, _service_data, _manufacturer_data, _tx_power, _details, _now, ) cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_inject_100_different_advertisements(benchmark: BenchmarkFixture) -> None: """Test injecting 100 different advertisements.""" manager = get_manager() switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, rssi=-100, ) advs: list[AdvertisementData] = [] for i in range(100): switchbot_device_adv = generate_advertisement_data( local_name="wohand", service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"], service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"}, manufacturer_data={i: b"\x01"}, rssi=-100, ) advs.append(switchbot_device_adv) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) _address = switchbot_device.address _name = switchbot_device.name _service_uuids = switchbot_device_adv.service_uuids _service_data = switchbot_device_adv.service_data _tx_power = switchbot_device_adv.tx_power _details = {"scanner_specific_data": "test"} _now = monotonic_time_coarse() @benchmark def run(): for adv in advs: scanner._async_on_advertisement( _address, -100, # rssi _name, _service_uuids, _service_data, adv.manufacturer_data, _tx_power, _details, _now, ) cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_inject_100_different_manufacturer_data( benchmark: BenchmarkFixture, ) -> None: """Test injecting 100 different manufacturer_data.""" manager = get_manager() switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, rssi=-100, ) advs: list[AdvertisementData] = [] for i in range(100): switchbot_device_adv = generate_advertisement_data( local_name="wohand", service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"], service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"}, manufacturer_data={1: b"\x01", 3: bytes((i,) * 20)}, rssi=-100, ) advs.append(switchbot_device_adv) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) _address = switchbot_device.address _name = switchbot_device.name _service_uuids = switchbot_device_adv.service_uuids _service_data = switchbot_device_adv.service_data _tx_power = switchbot_device_adv.tx_power _details = {"scanner_specific_data": "test"} _now = monotonic_time_coarse() @benchmark def run(): for adv in advs: scanner._async_on_advertisement( _address, -100, # rssi _name, _service_uuids, _service_data, adv.manufacturer_data, _tx_power, _details, _now, ) cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_inject_100_different_service_data( benchmark: BenchmarkFixture, ) -> None: """Test injecting 100 different service_data.""" manager = get_manager() switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, rssi=-100, ) advs: list[AdvertisementData] = [] for i in range(100): switchbot_device_adv = generate_advertisement_data( local_name="wohand", service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"], service_data={"050a021a-0000-1000-8000-00805f9b34fb": bytes((i,) * 20)}, manufacturer_data={1: b"\x01"}, rssi=-100, ) advs.append(switchbot_device_adv) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) _address = switchbot_device.address _name = switchbot_device.name _service_uuids = switchbot_device_adv.service_uuids _service_data = switchbot_device_adv.service_data _tx_power = switchbot_device_adv.tx_power _details = {"scanner_specific_data": "test"} _now = monotonic_time_coarse() @benchmark def run(): for adv in advs: scanner._async_on_advertisement( _address, -100, # rssi _name, _service_uuids, _service_data, adv.manufacturer_data, _tx_power, _details, _now, ) cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_inject_100_rotating_manufacturer_data( benchmark: BenchmarkFixture, ) -> None: """Test injecting 100 different manufacturer_data to mimic a sensor push device.""" manager = get_manager() sensor_push_device = generate_ble_device( "44:44:33:11:23:45", "", {}, rssi=-60, ) sensor_push_device_adv = generate_advertisement_data( local_name="", rssi=-60, manufacturer_data={ 17667: b"\xad\x00\x01\x00\x00", 1280: b"\xe7\xb4\xe1\xaf\xb6", 2304: b"7\xe1:\xb7\xb6", 55552: b"#\xc7$\xad\xb6", 58624: b";\x01%\x9d\xb6", 44288: b"\xa2|'x\xb6", 64000: b";\xad\xdc\xa7\xb6", 28672: b"\xdb\xe8\\\xa2\xb6", 7168: b"\xb5\xbd\xe0\xaf\xb6", 11264: b"\x00S}\xae\xb6", 4096: b"\xe9\xef\x8e\xba\xb6", 44800: b"\x85\xa2=\xb5\xb6", 32768: b"\x86b\xe9\xc1\xb6", 37376: b"\x8bS<\xc1\xb6", 25344: b"\xb4\xb2\xe7\xbb\xb6", 51200: b"\xae\xdc\xc8\x97\xb6", 49152: b"O\x80O\xc7\xb6", 17664: b"\x0e\xb7q\xa0\xb6", 34816: b"\x9a\xf6\xf8\xc3\xb6", 21760: b"G\xd9\xd6\xa7\xb6", 512: b"\xaa\x14M\xc5\xb6", 41984: b"\xfd\xb4\xd7\xa5\xb6", 16640: b"\x9b\xdd\xd9\xa5\xb6", 33024: b"\x99\xdbB\xb9\xb6", 25088: b"\xee\xec\xea\xbf\xb6", 24576: b"\xc3G\x16\x99\xb6", 50176: b"\x88Q\x9d\xc4\xb6", 57856: b"~\x1a\xb0\x87\xb6", 2816: b"08\xa2\xc4\xb6", 19712: b",\xf1u\x9a\xb6", 26880: b"\x8f\x0f(\xa9\xb6", 54528: b"U\xbe\x1c\x9b\xb6", 7936: b"\x01\x1e\x93\xbc\xb6", 52992: b"R\x19\xb9\x91\xb6", 9472: b"\x0f\xb9\x9a\x87\xb6", 47360: b"A\xe16\xb5\xb6", 14080: b"r\x82S\xc7\xb6", 60416: b"#A\xc5v\xb6", 19968: b"\xf5=\x80\xa0\xb6", 30976: b"\r\x99\x13\x91\xb6", 9216: b'\x08">\xbd\xb6', 16896: b'"\x94L\xc7\xb6', 54784: b"\xae\xce%\x9d\xb6", 21248: b"\xb9\xe9\xe1\xb9\xb6", 40960: b"\x15}\xda\xbb\xb6", 16128: b"s\xe9\xf7\xc5\xb6", 36608: b"\xad\xd6\x8f\xc0\xb6", 1536: b"\x1a\xd1\x8c\xb0\xb6", 30720: b"\xf4`\x93\xb4\xb6", 17920: b"mIi\xae\xb6", 30464: b"\x8c}\x19\x99\xb6", 61952: b"\xb4{\xec\xbd\xb6", 30208: b'\xa8\xac"\x9b\xb6', 27904: b"D\xcb8\xb5\xb6", 45568: b"\xfc\xb5\xdf\xa9\xb6", 12288: b"\xe9\x11\xa7\x8f\xb6", 6400: b"\\\xcf\xe0\xb7\xb6", 10496: b"P_\xe1\xbb\xb6", 52736: b"fv\xd3\xa1\xb6", 37888: b"\xb1\x7f'\xaf\xb6", 6656: b"\x80Wh\x90\xb6", 15872: b"\xd7\x91\xe0\xb7\xb6", 28160: b"P<\xc5\x95\xb6", 37632: b"NN\xc7x\xb6", 11776: b"\x03z0\xab\xb6", 48896: b"B\x9e\xaa\xc8\xb6", 65280: b"w\xb1\xee\xb9\xb6", 56320: b"\xb1\xfa\x1f\x99\xb6", 59136: b"_\xd5\x1c\x97\xb6", 26368: b"\xbe\x82\xbd\x93\xb6", 7424: b"A\xc8\x19\x99\xb6", 49408: b"\xef\xda\x91\xb4\xb6", 24832: b"l\xc03\xbd\xb6", 48128: b"Vs4\xa9\xb6", 48384: b"\xack;\xbb\xb6", 20224: b"\xd8O\xe5\xb9\xb6", 35840: b"Nj\xe1\xbb\xb6", 51712: b"\x96\xba\xcc\x9b\xb6", 23296: b"\xda\\v\x9c\xb6", 39168: b"0j\xe3\xb3\xb6", 29440: b"\xf9\xc9J\xc3\xb6", 54016: b"\xe9\x1c\x88\xa6\xb6", 62208: b"\x1b\x0f\xe3\xbf\xb6", 33280: b"\xc2s\x83\xa2\xb6", 20480: b"\xa9\xc5\xc4\x95\xb6", 50688: b"\xd5O\xe5\xb7\xb6", 19456: b"T }\x9e\xb6", 27136: b"\xd3\n\xda\xbb\xb6", 34304: b"\x10\x164\xb7\xb6", 3328: b'"\xb1\x1c\x99\xb6', 50944: b"it\xbf\x91\xb6", 29952: b"\xd7\xc5\xb8\x93\xb6", 46592: b"\x14-\xbc\x95\xb6", 60928: b"|\xcd\xb8\x8d\xb6", 16384: b"4\x95\xce\x9b\xb6", 23040: b"\x99\xca\x9f\x8d\xb6", 58112: b"P\xcc;\xb7\xb6", 22784: b"\x8a4L\xc5\xb6", 12800: b"el\xe0\xad\xb6", 8960: b"xe\x8e\xb8\xb6", 13568: b"\xec2\x8f\xb8\xb6", 36864: b"\r\xde1\xb5\xb6", 64512: b"\xf7\xf8\x17n\xb6", 39424: b"?\xbc\x87\xa8\xb6", 8448: b"\xfa\x8c\xa6\x8f\xb6", 53760: b"\xf3\x92\xdd\xb3\xb6", 23552: b"A\xb5A\xc3\xb6", 51968: b"\xb6\xc9\xa5^\xb6", 9728: b"\xff\xa1\x7f\xa6\xb6", 18944: b"\xc0\xddI\xc1\xb6", 46848: b"\x05t\xea\xb9\xb6", 33792: b"\xdb\xa8\xd9\xa3\xb6", 6144: b"+\xcb?\xb9\xb6", 10752: b";\x93:\xb9\xb6", 40704: b"\x8e\x85p\x96\xb6", 58368: b"\x91\xf2\xd0\x9d\xb6", 32512: b"\xec\x80\x85\xa4\xb6", 55808: b"-\x98\x80\xb0\xb6", 25856: b"\x90\xd5\x85\xaa\xb6", 58880: b":J\x81\xba\xb6", 31232: b"\x80\xfe\xdd\xa5\xb6", 55040: b"o13\xa9\xb6", 50432: b"t\xe5I\xc3\xb6", 37120: b"\xd3\x05\x89\xa6\xb6", 12544: b"\x06\x00<\xbd\xb6", 59904: b"\xddb\xbe\x93\xb6", 27392: b"OB\x0f\x8f\xb6", 61696: b"\x1d\xe8\x18\x97\xb6", 29696: b"#\xcc\xde\xbd\xb6", 32000: b"X}3\xb5\xb6", 44544: b"\xb8\xa1\x1e\x99\xb6", 7680: b"\xe7Qr\x98\xb6", 45312: b"\xfbI\x10\x8f\xb6", 63488: b"\xc6\xda(\xa5\xb6", 25600: b"O\xda\xe2\xb7\xb6", 24320: b"r\x14n\x98\xb6", 62464: b"\xb0\x87\xf4\xc1\xb6", 63744: b"\x96\xd6\x14\x95\xb6", 21504: b"[\x85\x0f\x93\xb6", 8192: b"\xb7\x84\xd3\xa5\xb6", 29184: b"\xbf\xdfg\x90\xb6", 64768: b"\xa2\x84\xe5\xbf\xb6", 57088: b"9\t\x8a\xb4\xb6", 22272: b"r~(\x9f\xb6", 55296: b".\x03\xc6\x97\xb6", 34560: b"\xb5r\x7f\xa0\xb6", 52224: b"\xe2\xc3\x1c\xa3\xb6", 13824: b"8>\xe6\xb5\xb6", 46080: b"Y\x7f@\xb9\xb6", 34048: b"/_k\x96\xb6", 4608: b"9\x95K\xc3\xb6", 62720: b"K-;\x84\xb6", 44032: b"\xd5\xd0\xa7\xc6\xb6", 35584: b"?}D\xcd\xb6", 43008: b"2\x8f\x8a\xae\xb6", 47104: b"\xa1\xff\xe6\xbb\xb6", 61184: b"\xa3\x7f%\xa5\xb6", 59648: b"\xf1\xb8\x8d\xb4\xb6", 57344: b"\x88\xee2\xb7\xb6", 36096: b"\\\xd5\x9c\xc0\xb6", 38912: b"n\x12_\x90\xb6", 56832: b"$gG\xc3\xb6", 18176: b"\xf9\x96\xfc\xc5\xb6", 18432: b"b\xcdA\xbf\xb6", 57600: b":\x19@\xbf\xb6", 18688: b"$\xe2\xcb\x99\xb6", 38656: b"\x0cA-\xb9\xb6", 48640: b"V\x8c\xda\xab\xb6", 46336: b'"WL]\xb6', 9984: b"\xa8\xab\xa2\xd2\xb6", 42496: b"4\x0b\x1f\x9b\xb6", 41216: b")M%\xab\xb6", 49664: b"M%6\xa9\xb6", 42240: b",\x1e\x86\xb6\xb6", 20992: b"\xab\x052\xbd\xb6", 53504: b"\x8a\xf6\x84\xaa\xb6", 56064: b"\xda\xbf\xa4`\xb6", 53248: b"\x18:\xc8\x99\xb6", 19200: b"\xb1\xb4\x89\xbc\xb6", 38400: b"\xba=\x1f\x99\xb6", 41728: b"\xe4\xa3+\xb1\xb6", 5376: b"z\xd6\x94\xb2\xb6", 47616: b"\x88\x1f\xe3\xb9\xb6", 60672: b"\x9c\x85{\xb4\xb6", 3584: b"\xe7\xdc\xa8\xc6\xb6", 28416: b"\xdc\xddT\x90\xb6", 14336: b"\x87\xa6\xf2\xc5\xb6", 43776: b"9y\x8a\xae\xb6", 39936: b"\xe2\x8cSa\xb6", 5632: b"\xa5_0\xab\xb6", 14592: b"\xbf\xa9\x80\xae\xb6", 63232: b"\xd6A\xc5\x99\xb6", 13312: b"=\xcdL\xc3\xb6", 8704: b"\xf9\xd1'\xa1\xb6", 11008: b"\xdc\xed\xf6\xc5\xb6", 26624: b"\x9b\x81\xc2\x99\xb6", 13056: b"\x88@\xda\xab\xb6", 5888: b"p\xea\x85\xaa\xb6", 12032: b"L\xdb\xe9\xb9\xb6", 3072: b"$\x1e\x83\xac\xb6", 31744: b"\xcb\xe60\xad\xb6", 14848: b"\xee\x9d\xe8\xc9\xb6", 45824: b"Mo\x8e\xb2\xb6", 768: b"\xa6\x8d=\xb5\xb6", 56576: b"\x02\xba\x8d\xb0\xb6", 49920: b"\xa3\xac$\x9d\xb6", 41472: b"\x9dM\xe0\xab\xb6", 65024: b"\x89!\xf2\xbf\xb6", 1024: b"\x89\xbf\x8d\xb4\xb6", 0: b"\xb6\xc5\xc2\x97\xb6", 61440: b"\xad\xa8s\x98\xb6", 17408: b"\xc2\x99?\xbb\xb6", 42752: b"R\xf81\xa9\xb6", 38144: b'\x83\x89"\x9d\xb6', 43520: b"\xb7\xa2'\x9f\xb6", 35328: b";d\xa2\xd0\xb6", 51456: b"\xa4\x85h\x90\xb6", 35072: b"\xfb\x90@\xbf\xb6", 39680: b"\xf5\xcb\x04\xa1\xb6", 4352: b"j\xd0e\x92\xb6", 32256: b"\xcc\x99\xbf\x95\xb6", 3840: b"\xd0\xdd\xc7\x99\xb6", 45056: b"U\xf2\xf0\xc3\xb6", 47872: b'\xdc\x07"\x9b\xb6', 60160: b"0\x8a\xdf\xbb\xb6", 28928: b"\xe7\xa8\xdc\xaf\xb6", 54272: b"c\x15\x85\xb4\xb6", 17152: b"\xc0Q\x7f\xa0\xb6", 5120: b"B@w\x9a\xb6", 43264: b"rC\x85\xaa\xb6", 23808: b"[\xe3=\xb7\xb6", 256: b"\x9c\x9f\x90\xb2\xb6", 6912: b"\xf2\x18\xdc\xab\xb6", 15616: b"\x1b,/\xb5\xb6", 15104: b"{=\xbf\x91\xb6", 4864: b"g?\xe3\xb9\xb6", 36352: b"\x88\xac2\xad\xb6", 22016: b"O\x91\x18\x95\xb6", 52480: b"q\x8dS\xc9\xb6", 62976: b"ZX\x7f\xa2\xb6", 59392: b"\xef\xdc\xa5\xc4\xb6", 15360: b"\xd0\x9aD\xc1\xb6", 10240: b"a\x92\x92\xb0\xb6", 2048: b" /]\x9a\xb6", 20736: b"\x9d\xdek\x94\xb6", 2560: b"\xf5z=\xb3\xb6", 22528: b"j@\xe2\xad\xb6", 26112: b"\x18\x1f\xc5x\xb6", 40448: b"\xdf\xfe=\xbb\xb6", 11520: b"2\xf7<\xbb\xb6", 1792: b"$\n\x1c\x99\xb6", 40192: b"\xaa\x88\xff\xc9\xb6", 27648: b"\x87\xac\xb8\x8d\xb6", 33536: b"{:\x1b\x97\xb6", 64256: b"B\r.\xa9\xb6", 31488: b"\x98\xfa\xb6\x91\xb6", }, service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"], service_data={}, ) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) scanner._async_on_advertisement( sensor_push_device.address, -100, # rssi sensor_push_device.name, sensor_push_device_adv.service_uuids, sensor_push_device_adv.service_data, sensor_push_device_adv.manufacturer_data, sensor_push_device_adv.tx_power, {"scanner_specific_data": "test"}, monotonic_time_coarse(), ) advs: list[AdvertisementData] = [] for i in range(100): sensorpush_device_adv = generate_advertisement_data( local_name="", service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"], service_data={}, manufacturer_data={i: bytes((i,) * 20)}, rssi=-(i), ) advs.append(sensorpush_device_adv) _address = sensor_push_device.address _name = sensor_push_device.name _service_uuids = sensorpush_device_adv.service_uuids _service_data = sensorpush_device_adv.service_data _tx_power = sensorpush_device_adv.tx_power _details = {"scanner_specific_data": "test"} _now = monotonic_time_coarse() @benchmark def run(): for adv in advs: scanner._async_on_advertisement( _address, -100, # rssi _name, _service_uuids, _service_data, adv.manufacturer_data, _tx_power, _details, _now, ) cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_filter_unwanted_apple_advs(benchmark: BenchmarkFixture) -> None: """Test filtering unwanted apple data.""" manager = get_manager() device = generate_ble_device( "44:44:33:11:23:45", "beacon", {}, rssi=-100, ) device_adv = generate_advertisement_data( local_name="beacon", service_uuids=[], service_data={}, manufacturer_data={76: b"\xff"}, rssi=-100, ) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) _address = device.address _name = device.name _service_uuids = device_adv.service_uuids _service_data = device_adv.service_data _manufacturer_data = device_adv.manufacturer_data _tx_power = device_adv.tx_power _details = {"scanner_specific_data": "test"} _now = monotonic_time_coarse() @benchmark def run(): for _ in range(100): scanner._async_on_advertisement( _address, -100, # rssi _name, _service_uuids, _service_data, _manufacturer_data, _tx_power, _details, _now, ) cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_filter_wanted_apple_advs(benchmark: BenchmarkFixture) -> None: """Test filtering wanted apple data.""" manager = get_manager() device = generate_ble_device( "44:44:33:11:23:45", "beacon", {}, rssi=-100, ) device_adv = generate_advertisement_data( local_name="beacon", service_uuids=[], service_data={}, manufacturer_data={76: b"\x02"}, rssi=-100, ) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) _address = device.address _name = device.name _service_uuids = device_adv.service_uuids _service_data = device_adv.service_data _manufacturer_data = device_adv.manufacturer_data _tx_power = device_adv.tx_power _details = {"scanner_specific_data": "test"} _now = monotonic_time_coarse() @benchmark def run(): for _ in range(100): scanner._async_on_advertisement( _address, -100, # rssi _name, _service_uuids, _service_data, _manufacturer_data, _tx_power, _details, _now, ) cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_inject_100_raw_unchanged_advertisements( benchmark: BenchmarkFixture, ) -> None: """Test injecting 100 raw unchanged advertisements (BlueZ raw path).""" manager = get_manager() 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 = "44:44:33:11:23:45" _raw = b"\x12\x21\x1a\x02\n\x05\n\xff\x062k\x03R\x00\x01\x04\t\x00\x04" _details = {"scanner_specific_data": "test"} _now = monotonic_time_coarse() # Seed the first advertisement scanner._async_on_raw_advertisement(_address, -100, _raw, _details, _now) @benchmark def run(): for _ in range(100): scanner._async_on_raw_advertisement( _address, -100, _raw, _details, _now, ) cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_inject_100_bleak_unchanged_advertisements( benchmark: BenchmarkFixture, ) -> None: """Test injecting 100 unchanged advertisements via Bleak/HaScanner path.""" manager = get_manager() device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, rssi=-100, ) 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) _now = monotonic_time_coarse() # Seed the first advertisement through the manager service_info = 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="esp32", device=device, advertisement=adv, connectable=True, time=_now, tx_power=adv.tx_power, ) manager.scanner_adv_received(service_info) @benchmark def run(): for _ in range(100): info = 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="esp32", device=device, advertisement=adv, connectable=True, time=_now, tx_power=adv.tx_power, ) manager.scanner_adv_received(info) cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_inject_100_bleak_changed_advertisements( benchmark: BenchmarkFixture, ) -> None: """Test injecting 100 changed advertisements via Bleak/HaScanner path.""" manager = get_manager() device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, rssi=-100, ) advs: list[AdvertisementData] = [] for i in range(100): 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: bytes((i,))}, rssi=-100, ) advs.append(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) _now = monotonic_time_coarse() @benchmark def run(): for adv in advs: info = BluetoothServiceInfoBleak.__new__(BluetoothServiceInfoBleak) info.name = adv.local_name or device.name or device.address info.address = device.address info.rssi = adv.rssi info.manufacturer_data = adv.manufacturer_data info.service_data = adv.service_data info.service_uuids = adv.service_uuids info.source = "esp32" info.device = device info._advertisement = adv info.connectable = True info.time = _now info.tx_power = adv.tx_power info.raw = None manager.scanner_adv_received(info) cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_inject_100_bluez_raw_end_to_end_unchanged( benchmark: BenchmarkFixture, ) -> None: """Test 100 unchanged advertisements through full BlueZ MGMT protocol path.""" manager = get_manager() loop = asyncio.get_running_loop() scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() cancel = manager.async_register_scanner(scanner) scanners: dict[int, HaScanner] = {0: scanner} future: asyncio.Future[None] = loop.create_future() mock_sock = MagicMock() protocol = BluetoothMGMTProtocol( future, scanners, lambda: None, lambda: False, mock_sock ) # Build a DEVICE_FOUND MGMT packet # AD data: flags + local name + manufacturer data ad_data = ( b"\x02\x01\x06" # Flags b"\x08\x09TestDev" # Complete Local Name = "TestDev" b"\x04\xff\x01\x00\xaa" # Manufacturer data: company 0x0001, data 0xaa ) param_len = 6 + 1 + 1 + 4 + 2 + len(ad_data) packet = ( b"\x12\x00" # DEVICE_FOUND event b"\x00\x00" # controller_idx = 0 + param_len.to_bytes(2, "little") + b"\xaa\xbb\xcc\xdd\xee\xff" # address + b"\x01" # address_type + b"\xc4" # rssi = -60 + b"\x00\x00\x00\x00" # flags + len(ad_data).to_bytes(2, "little") + ad_data ) # Seed first advertisement protocol.data_received(packet) @benchmark def run(): for _ in range(100): protocol.data_received(packet) cancel() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_inject_100_bluez_raw_end_to_end_changed( benchmark: BenchmarkFixture, ) -> None: """Test 100 changed advertisements through full BlueZ MGMT protocol path.""" manager = get_manager() loop = asyncio.get_running_loop() scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() cancel = manager.async_register_scanner(scanner) scanners: dict[int, HaScanner] = {0: scanner} future: asyncio.Future[None] = loop.create_future() mock_sock = MagicMock() protocol = BluetoothMGMTProtocol( future, scanners, lambda: None, lambda: False, mock_sock ) # Build 100 different DEVICE_FOUND MGMT packets with varying manufacturer data packets: list[bytes] = [] for i in range(100): ad_data = ( b"\x02\x01\x06" # Flags b"\x08\x09TestDev" # Complete Local Name = "TestDev" b"\x04\xff\x01\x00" # Manufacturer data header: company 0x0001 + bytes((i,)) # Varying data byte ) param_len = 6 + 1 + 1 + 4 + 2 + len(ad_data) packet = ( b"\x12\x00" # DEVICE_FOUND event b"\x00\x00" # controller_idx = 0 + param_len.to_bytes(2, "little") + b"\xaa\xbb\xcc\xdd\xee\xff" # address + b"\x01" # address_type + b"\xc4" # rssi = -60 + b"\x00\x00\x00\x00" # flags + len(ad_data).to_bytes(2, "little") + ad_data ) packets.append(packet) @benchmark def run(): for packet in packets: protocol.data_received(packet) cancel() # --------------------------------------------------------------------------- # seed_name_cache benchmarks # # The per-advertisement cost of the name cache in production is dominated by # the inlined fast path inside _scanner_adv_received (covered end-to-end by # the test_inject_100_* benchmarks above); the cdef _update_name_cache helper # itself only runs on genuine cache writes. We cannot call that cdef directly # from Python, so these microbenchmarks measure it through the # seed_name_cache wrapper. That adds a Python-level call layer on top, so # absolute numbers here include some wrapper overhead, but the relative # numbers across the prefix-rule paths still catch regressions in the body. # Covered paths: # 1. identity - same Python str object as cached (typical steady state) # 2. equality - cached and incoming compare equal but are different objects # (e.g. names rebuilt by different deserialization paths) # 3. cold - address not yet in cache (first ad for a device) # 4. fallback - name equals the address (the no-op for nameless ads) # A fifth benchmark exercises the actual prefix-rule write paths. # --------------------------------------------------------------------------- @pytest.mark.usefixtures("enable_bluetooth") def test_seed_name_cache_steady_state_identity(benchmark: BenchmarkFixture) -> None: """Hot path: same name object as cached. Should be a dict.get + pointer compare.""" manager = get_manager() address = "44:44:33:11:23:60" name = "Onvis XXX" manager.seed_name_cache(address, name) assert manager._name_cache[address] is name @benchmark def run(): for _ in range(1000): manager.seed_name_cache(address, name) @pytest.mark.usefixtures("enable_bluetooth") def test_seed_name_cache_steady_state_equality(benchmark: BenchmarkFixture) -> None: """Hot path: cached name equals incoming but different str objects (no identity).""" manager = get_manager() address = "44:44:33:11:23:61" cached = b"Onvis XXX".decode() manager.seed_name_cache(address, cached) # Force a different object with the same value via a separate bytes.decode. incoming = b"Onvis XXX".decode() assert incoming is not cached assert incoming == cached @benchmark def run(): for _ in range(1000): manager.seed_name_cache(address, incoming) @pytest.mark.usefixtures("enable_bluetooth") def test_seed_name_cache_address_fallback(benchmark: BenchmarkFixture) -> None: """Hot path: passive scanner with no name (name == address). Must short-circuit.""" manager = get_manager() address = "44:44:33:11:23:62" @benchmark def run(): for _ in range(1000): manager.seed_name_cache(address, address) assert address not in manager._name_cache @pytest.mark.usefixtures("enable_bluetooth") def test_seed_name_cache_cold_first_name(benchmark: BenchmarkFixture) -> None: """First name observed for an address. Pops the entry every iteration.""" manager = get_manager() address = "44:44:33:11:23:63" name = "Onvis XXX" @benchmark def run(): for _ in range(1000): manager._name_cache.pop(address, None) manager.seed_name_cache(address, name) @pytest.mark.usefixtures("enable_bluetooth") def test_seed_name_cache_prefix_rule_paths(benchmark: BenchmarkFixture) -> None: """ Mixed write paths: extension, truncation, and rename. Each iteration exercises the casefold and length-dispatch logic. """ manager = get_manager() address = "44:44:33:11:23:64" short = "Onv" long = "Onvis XXX" other = "Donkey XX" # same length as long, not a prefix-extension manager.seed_name_cache(address, long) # seed @benchmark def run(): for _ in range(1000): # rename: same length, different -> replace manager.seed_name_cache(address, other) # extension: name is longer than cached -> replace manager.seed_name_cache(address, long) # truncation: name shorter than cached, prefix match -> keep manager.seed_name_cache(address, short) Bluetooth-Devices-habluetooth-75cbe37/tests/test_benchmark_manager.py000066400000000000000000000225461521117704500262070ustar00rootroot00000000000000"""Benchmarks for the BluetoothManager hot paths.""" from __future__ import annotations from typing import TYPE_CHECKING import pytest from bluetooth_data_tools import monotonic_time_coarse from habluetooth import ( BaseHaRemoteScanner, BaseHaScanner, HaBluetoothConnector, get_manager, ) from habluetooth.models import BluetoothServiceInfoBleak from . import ( MockBleakClient, generate_advertisement_data, generate_ble_device, ) if TYPE_CHECKING: from collections.abc import Iterable from bleak.backends.scanner import AdvertisementData, BLEDevice from pytest_codspeed import BenchmarkFixture pytestmark = pytest.mark.timeout(60) class _LocalScannerLike(BaseHaScanner): """Stand-in for HaScanner that rebuilds its discovered-dict each access.""" # The real HaScanner.discovered_addresses delegates to # bleak.BleakScanner.discovered_devices_and_advertisement_data which # walks bleak's backend cache and constructs a new dict each call. This # fake reproduces that allocation pattern so the benchmarks reflect the # redundant-rebuild cost that issue #505 targets — without depending on # a live BlueZ stack. def __init__(self, source: str, adapter: str, addresses: list[str]) -> None: super().__init__(source, adapter, connectable=True) self._addresses = addresses self._device = generate_ble_device( addresses[0] if addresses else "00:00:00:00:00:00", "x", {} ) self._adv = generate_advertisement_data(local_name="x") @property def discovered_devices(self) -> list[BLEDevice]: return [self._device for _ in self._addresses] @property def discovered_devices_and_advertisement_data( self, ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: # Rebuild the dict on every access, matching bleak's behavior. return dict.fromkeys(self._addresses, (self._device, self._adv)) @property def discovered_addresses(self) -> Iterable[str]: # Match HaScanner: dict iteration yields keys, but the dict is # rebuilt on every access. return self.discovered_devices_and_advertisement_data def get_discovered_device_advertisement_data( self, address: str ) -> tuple[BLEDevice, AdvertisementData] | None: return self.discovered_devices_and_advertisement_data.get(address) def _make_address(i: int) -> str: return f"AA:BB:CC:{(i >> 16) & 0xFF:02X}:{(i >> 8) & 0xFF:02X}:{i & 0xFF:02X}" def _seed_history(num_devices: int, source: str) -> list[str]: """Populate manager history with ``num_devices`` devices from ``source``.""" manager = get_manager() now = monotonic_time_coarse() addresses: list[str] = [] for i in range(num_devices): address = _make_address(i) addresses.append(address) device = generate_ble_device(address, f"dev{i}", {}) adv = generate_advertisement_data( local_name=f"dev{i}", manufacturer_data={1: bytes((i & 0xFF,))}, service_uuids=[], rssi=-60, ) manager.scanner_adv_received( BluetoothServiceInfoBleak( name=adv.local_name, address=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=True, time=now, tx_power=adv.tx_power, ) ) return addresses @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_check_unavailable_steady_state_remote( benchmark: BenchmarkFixture, ) -> None: """Steady-state _async_check_unavailable with one remote scanner and 200 devices.""" # Nothing has disappeared — every history address is still in the scanner's # discovered_addresses. This is the dominant production path: each cycle # runs the difference twice (connectable + non-connectable loops). manager = get_manager() connector = HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: True) scanner = BaseHaRemoteScanner("esp32", "esp32", connector, connectable=True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) addresses = _seed_history(200, "esp32") # Inject through the remote scanner's normal entry point so its # discovered_addresses (== _previous_service_info) is populated too. now = monotonic_time_coarse() for i, address in enumerate(addresses): scanner._async_on_advertisement( address, -60, f"dev{i}", [], {}, {1: bytes((i & 0xFF,))}, None, {"scanner_specific_data": "test"}, now, ) @benchmark def run() -> None: manager._async_check_unavailable() cancel() unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_check_unavailable_steady_state_local_like( benchmark: BenchmarkFixture, ) -> None: """Steady-state _async_check_unavailable with one rebuilding local-like scanner.""" # The local-like scanner rebuilds its discovered_addresses dict on every # property access. This isolates the cost issue #505 calls out: # _async_check_unavailable invokes discovered_addresses on every # connectable scanner twice per cycle, and for local HaScanner each # access rebuilds bleak's discovered-devices dict. manager = get_manager() addresses = _seed_history(200, "hci0") scanner = _LocalScannerLike("hci0", "hci0", addresses) cancel = manager.async_register_scanner(scanner) @benchmark def run() -> None: manager._async_check_unavailable() cancel() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_check_unavailable_many_scanners_local_like( benchmark: BenchmarkFixture, ) -> None: """Steady-state _async_check_unavailable: four local-like + one remote scanner.""" # 200 devices total. Multi-scanner deployments amplify the redundant-rebuild # cost: every connectable scanner's discovered_addresses is materialized # twice per cycle (issue #505). manager = get_manager() addresses = _seed_history(200, "hci0") scanners: list[BaseHaScanner] = [] cancels = [] for idx, adapter in enumerate(("hci0", "hci1", "hci2", "hci3")): # Each local-like scanner sees a non-empty overlapping slice so the # set difference work mirrors a real multi-adapter install. slice_size = max(1, len(addresses) // (idx + 1)) local = _LocalScannerLike(adapter, adapter, addresses[:slice_size]) scanners.append(local) cancels.append(manager.async_register_scanner(local)) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) remote = BaseHaRemoteScanner("esp32_nc", "esp32_nc", connector, connectable=False) remote_unsetup = remote.async_setup() cancels.append(manager.async_register_scanner(remote)) @benchmark def run() -> None: manager._async_check_unavailable() for c in cancels: c() remote_unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_check_unavailable_all_disappeared( benchmark: BenchmarkFixture, ) -> None: """Worst case: every history address has disappeared from every scanner.""" # Exercises the disappear-callback and tracker-cleanup branches of the # inner loop. Re-seeds history each iteration so the benchmark measures # the dispatch work, not a one-shot drain. 50 devices is small on purpose # to keep the re-seed overhead from dominating. manager = get_manager() connector = HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: True) scanner = BaseHaRemoteScanner("esp32", "esp32", connector, connectable=True) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner) template_addresses = [_make_address(i) for i in range(50)] def reseed() -> None: manager._all_history.clear() manager._connectable_history.clear() now = monotonic_time_coarse() - 10_000 # ensure beyond stale threshold for i, address in enumerate(template_addresses): device = generate_ble_device(address, f"dev{i}", {}) adv = generate_advertisement_data( local_name=f"dev{i}", manufacturer_data={1: bytes((i & 0xFF,))}, service_uuids=[], rssi=-60, ) manager.scanner_adv_received( BluetoothServiceInfoBleak( name=adv.local_name, address=address, rssi=adv.rssi, manufacturer_data=adv.manufacturer_data, service_data=adv.service_data, service_uuids=adv.service_uuids, source="esp32", device=device, advertisement=adv, connectable=True, time=now, tx_power=adv.tx_power, ) ) @benchmark def run() -> None: reseed() manager._async_check_unavailable() cancel() unsetup() Bluetooth-Devices-habluetooth-75cbe37/tests/test_central_manager.py000066400000000000000000000022301521117704500256710ustar00rootroot00000000000000"""Tests for habluetooth.central_manager.""" from __future__ import annotations from typing import TYPE_CHECKING import pytest from habluetooth.central_manager import ( CentralBluetoothManager, get_manager, set_manager, ) if TYPE_CHECKING: from collections.abc import Iterator @pytest.fixture def preserve_manager() -> Iterator[None]: """Save and restore the CentralBluetoothManager singleton around a test.""" original = CentralBluetoothManager.manager try: yield finally: CentralBluetoothManager.manager = original def test_get_manager_raises_when_unset(preserve_manager: None) -> None: """get_manager() raises RuntimeError when no manager has been set.""" CentralBluetoothManager.manager = None with pytest.raises(RuntimeError, match="BluetoothManager has not been set"): get_manager() def test_set_manager_replaces_singleton(preserve_manager: None) -> None: """set_manager() stores the instance on the central holder.""" sentinel = object() set_manager(sentinel) # type: ignore[arg-type] assert CentralBluetoothManager.manager is sentinel assert get_manager() is sentinel Bluetooth-Devices-habluetooth-75cbe37/tests/test_init.py000066400000000000000000000407771521117704500235340ustar00rootroot00000000000000from unittest.mock import ANY, MagicMock import pytest from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from habluetooth import ( BaseHaRemoteScanner, BaseHaScanner, BluetoothScanningMode, HaBluetoothConnector, HaScanner, get_manager, ) from habluetooth.models import BluetoothServiceInfoBleak class MockBleakClient: pass def test_create_scanner(): connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True) class MockScanner(BaseHaScanner): @property def discovered_devices_and_advertisement_data(self): return [] @property def discovered_devices(self): return [] scanner = MockScanner("any", "any", connector) assert isinstance(scanner, BaseHaScanner) def test_create_remote_scanner(): connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True) scanner = BaseHaRemoteScanner("any", "any", connector, True) assert isinstance(scanner, BaseHaRemoteScanner) def test__async_on_advertisement(): connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True) scanner = BaseHaRemoteScanner("any", "any", connector, True) details = scanner._details | {} scanner._async_on_advertisement( "AA:BB:CC:DD:EE:FF", -88, "name", ["service_uuid"], {"service_uuid": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"}, {32: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"}, -88, details, 1.0, ) scanner._async_on_advertisement( "AA:BB:CC:DD:EE:FF", -21, "name", ["service_uuid2"], {"service_uuid2": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"}, {21: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"}, -88, details, 1.0, ) ble_device = BLEDevice( "AA:BB:CC:DD:EE:FF", "name", details, ) first_device = scanner.discovered_devices[0] assert first_device.address == ble_device.address assert first_device.details == ble_device.details assert first_device.name == ble_device.name assert "AA:BB:CC:DD:EE:FF" in scanner.discovered_devices_and_advertisement_data adv = scanner.discovered_devices_and_advertisement_data["AA:BB:CC:DD:EE:FF"][1] assert set(adv.service_data) == {"service_uuid", "service_uuid2"} assert adv == AdvertisementData( local_name="name", manufacturer_data={ 32: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b", 21: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b", }, service_data={ "service_uuid": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b", "service_uuid2": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b", }, service_uuids=ANY, tx_power=-88, rssi=-21, platform_data=(), ) assert len(scanner.discovered_devices) == 1 assert scanner.discovered_devices[0].address == "AA:BB:CC:DD:EE:FF" assert len(scanner.discovered_devices_and_advertisement_data) == 1 # BLEDevice no longer has rssi attribute in bleak 1.0+ # rssi is only available in AdvertisementData assert ( scanner.discovered_devices_and_advertisement_data["AA:BB:CC:DD:EE:FF"][1].rssi == -21 ) assert "AA:BB:CC:DD:EE:FF" in scanner.discovered_addresses device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF") assert device_adv is not None assert device_adv[1] == adv def test__async_on_advertisement_first(): connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True) scanner = BaseHaRemoteScanner("any", "any", connector, True) details = scanner._details | {} scanner._async_on_advertisement( "AA:BB:CC:DD:EE:FF", -88, "name", ["service_uuid"], {"service_uuid": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"}, {32: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"}, -88, details, 1.0, ) device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF") assert device_adv is not None device, adv = device_adv assert device is not None assert adv is not None assert device.address == "AA:BB:CC:DD:EE:FF" assert adv.rssi == -88 assert adv.service_uuids == ["service_uuid"] assert adv.service_data == { "service_uuid": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b" } assert adv.manufacturer_data == { 32: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b" } assert adv.service_uuids == ANY assert adv.tx_power == -88 assert adv.rssi == -88 assert adv.platform_data == () assert device.name == "name" assert device.details == details def test__async_on_advertisement_prefers_longest_local_name(): connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True) scanner = BaseHaRemoteScanner("any", "any", connector, True) details = scanner._details | {} scanner._async_on_advertisement( "AA:BB:CC:DD:EE:FF", -88, "shortname", [], {}, {}, -88, details, 1.0, ) device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF") assert device_adv is not None device, adv = device_adv assert device is not None assert adv is not None assert device.name == "shortname" assert adv.local_name == "shortname" scanner._async_on_advertisement( "AA:BB:CC:DD:EE:FF", -88, "tinyname", [], {}, {}, -88, details, 1.0, ) device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF") assert device_adv is not None device, adv = device_adv assert device is not None assert adv is not None assert device.name == "shortname" assert adv.local_name == "shortname" scanner._async_on_advertisement( "AA:BB:CC:DD:EE:FF", -88, "longername", [], {}, {}, -88, details, 1.0, ) device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF") assert device_adv is not None device, adv = device_adv assert device is not None assert adv is not None assert device.name == "longername" assert adv.local_name == "longername" def test_create_ha_scanner(): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") assert isinstance(scanner, HaScanner) @pytest.mark.asyncio async def test_dedup_unchanged_same_source(): """Test that unchanged data from the same source is deduped (skipped).""" manager = get_manager() connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True) scanner = BaseHaRemoteScanner("source1", "source1", connector, True) cancel = manager.async_register_scanner(scanner) details = scanner._details | {} # First advertisement — seeds _all_history scanner._async_on_advertisement( "AA:BB:CC:DD:EE:FF", -88, "name", ["service_uuid"], {"service_uuid": b"\x01"}, {1: b"\x01"}, -88, details, 1.0, ) mock_discover = MagicMock() manager._subclass_discover_info = mock_discover # Second identical advertisement — same source, so dedup should skip dispatch scanner._async_on_advertisement( "AA:BB:CC:DD:EE:FF", -88, "name", ["service_uuid"], {"service_uuid": b"\x01"}, {1: b"\x01"}, -88, details, 2.0, ) # Dedup should have returned early — _subclass_discover_info not called mock_discover.assert_not_called() cancel() @pytest.mark.asyncio async def test_dedup_unchanged_different_source(): """Test unchanged data from a different source dispatches when data changes.""" manager = get_manager() connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True) scanner1 = BaseHaRemoteScanner("source1", "source1", connector, True) cancel1 = manager.async_register_scanner(scanner1) scanner2 = BaseHaRemoteScanner("source2", "source2", connector, True) cancel2 = manager.async_register_scanner(scanner2) details: dict[str, str] = {} # Scanner 1 sends advertisement — seeds _all_history with source1 scanner1._async_on_advertisement( "AA:BB:CC:DD:EE:FF", -50, "name", ["svc"], {"svc": b"\x01"}, {1: b"\x01"}, -88, details, 1.0, ) # Scanner 2 sends first adv — seeds scanner2's _previous_service_info. # _all_history switches to source2 (stale time diff). scanner2._async_on_advertisement( "AA:BB:CC:DD:EE:FF", -40, "name", ["svc"], {"svc": b"\x01"}, {1: b"\x01"}, -88, details, 1000.0, ) assert manager._all_history["AA:BB:CC:DD:EE:FF"].source == "source2" # Scanner 1 sends again — seeds scanner1's _previous_service_info with # same data. _all_history now has source2. scanner1._async_on_advertisement( "AA:BB:CC:DD:EE:FF", -50, "name", ["svc"], {"svc": b"\x01"}, {1: b"\x01"}, -88, details, 2001.0, ) # _all_history switches back to source1 (stale time diff) assert manager._all_history["AA:BB:CC:DD:EE:FF"].source == "source1" mock_discover = MagicMock() manager._subclass_discover_info = mock_discover # Scanner 1 sends SAME data again — unchanged from scanner1's perspective, # dedup via field comparison detects no change → returns early. scanner1._async_on_advertisement( "AA:BB:CC:DD:EE:FF", -50, "name", ["svc"], {"svc": b"\x01"}, {1: b"\x01"}, -88, details, 2002.0, ) # Same data, same source — dedup should skip dispatch mock_discover.assert_not_called() # Scanner 2 sends same data — source switches (stale), but field comparison # detects no data change, so dedup still skips dispatch on main. scanner2._async_on_advertisement( "AA:BB:CC:DD:EE:FF", -40, "name", ["svc"], {"svc": b"\x01"}, {1: b"\x01"}, -88, details, 3001.0, ) # On main, field-level dedup returns early even with source change mock_discover.assert_not_called() # But _all_history still tracks the source switch assert manager._all_history["AA:BB:CC:DD:EE:FF"].source == "source2" # Now scanner 2 sends CHANGED data — should dispatch scanner2._async_on_advertisement( "AA:BB:CC:DD:EE:FF", -40, "name", ["svc"], {"svc": b"\x02"}, {1: b"\x02"}, -88, details, 3002.0, ) mock_discover.assert_called_once() cancel1() cancel2() @pytest.mark.asyncio async def test_dedup_same_data_via_scanner_adv_received(): """Test that scanner_adv_received deduplicates same data via field comparison.""" manager = get_manager() connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True) scanner = BaseHaRemoteScanner("source1", "source1", connector, True) cancel = manager.async_register_scanner(scanner) device = BLEDevice("AA:BB:CC:DD:EE:FF", "name", {}) mfr_data = {1: b"\x01"} svc_data = {"service_uuid": b"\x01"} svc_uuids = ["service_uuid"] # First advertisement — seeds _all_history info1 = BluetoothServiceInfoBleak( name="name", address="AA:BB:CC:DD:EE:FF", rssi=-88, manufacturer_data=mfr_data, service_data=svc_data, service_uuids=svc_uuids, source="source1", device=device, advertisement=None, connectable=True, time=1.0, tx_power=-88, ) manager.scanner_adv_received(info1) # Second advertisement with same data info2 = BluetoothServiceInfoBleak( name="name", address="AA:BB:CC:DD:EE:FF", rssi=-88, manufacturer_data=mfr_data, service_data=svc_data, service_uuids=svc_uuids, source="source1", device=device, advertisement=None, connectable=True, time=2.0, tx_power=-88, ) mock_discover = MagicMock() manager._subclass_discover_info = mock_discover manager.scanner_adv_received(info2) # Same data — field comparison should detect no change and dedup mock_discover.assert_not_called() cancel() @pytest.mark.asyncio async def test_async_clear_advertisement_history(): """Test clearing advertisement history allows same data to trigger callbacks.""" manager = get_manager() connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True) scanner = BaseHaRemoteScanner("source1", "source1", connector, True) cancel = manager.async_register_scanner(scanner) address = "AA:BB:CC:DD:EE:FF" device = BLEDevice(address, "name", {}) mfr_data = {1: b"\x01"} svc_data = {"service_uuid": b"\x01"} svc_uuids = ["service_uuid"] # First advertisement — seeds history info1 = BluetoothServiceInfoBleak( name="name", address=address, rssi=-88, manufacturer_data=mfr_data, service_data=svc_data, service_uuids=svc_uuids, source="source1", device=device, advertisement=None, connectable=True, time=1.0, tx_power=-88, ) manager.scanner_adv_received(info1) mock_discover = MagicMock() manager._subclass_discover_info = mock_discover # Same data again — should be deduped info2 = BluetoothServiceInfoBleak( name="name", address=address, rssi=-88, manufacturer_data=mfr_data, service_data=svc_data, service_uuids=svc_uuids, source="source1", device=device, advertisement=None, connectable=True, time=2.0, tx_power=-88, ) manager.scanner_adv_received(info2) mock_discover.assert_not_called() # Clear history — next advertisement should be treated as new manager.async_clear_advertisement_history(address) info3 = BluetoothServiceInfoBleak( name="name", address=address, rssi=-88, manufacturer_data=mfr_data, service_data=svc_data, service_uuids=svc_uuids, source="source1", device=device, advertisement=None, connectable=True, time=3.0, tx_power=-88, ) manager.scanner_adv_received(info3) mock_discover.assert_called_once() cancel() @pytest.mark.asyncio async def test_async_clear_advertisement_history_clears_scanner_merging(): """Test that clearing history resets UUID merging in scanners.""" manager = get_manager() connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True) scanner = BaseHaRemoteScanner("source1", "source1", connector, True) cancel = manager.async_register_scanner(scanner) address = "AA:BB:CC:DD:EE:FF" # Seed scanner's _previous_service_info with state A UUID info_a = BluetoothServiceInfoBleak( name="name", address=address, rssi=-88, manufacturer_data={}, service_data={}, service_uuids=["0000e800-0000-1000-8000-00805f9b34fb"], source="source1", device=BLEDevice(address, "name", {}), advertisement=None, connectable=True, time=1.0, tx_power=-88, ) manager.scanner_adv_received(info_a) scanner._previous_service_info[address] = info_a # Seed with state B UUID — simulates merged set info_ab = BluetoothServiceInfoBleak( name="name", address=address, rssi=-88, manufacturer_data={}, service_data={}, service_uuids=[ "0000e800-0000-1000-8000-00805f9b34fb", "0000e000-0000-1000-8000-00805f9b34fb", ], source="source1", device=BLEDevice(address, "name", {}), advertisement=None, connectable=True, time=2.0, tx_power=-88, ) manager.scanner_adv_received(info_ab) scanner._previous_service_info[address] = info_ab # Clear history manager.async_clear_advertisement_history(address) # Verify scanner's _previous_service_info is cleared assert address not in scanner._previous_service_info # Verify manager histories are cleared assert address not in manager._all_history assert address not in manager._connectable_history cancel() Bluetooth-Devices-habluetooth-75cbe37/tests/test_manager.py000066400000000000000000002502131521117704500241670ustar00rootroot00000000000000"""Tests for the manager.""" import asyncio import logging import time from collections.abc import Iterable from datetime import timedelta from typing import Any from unittest.mock import ANY, AsyncMock, Mock, PropertyMock, patch import pytest from bleak.backends.scanner import AdvertisementData, BLEDevice from bleak_retry_connector import AllocationChange, Allocations, BleakSlotManager from bluetooth_adapters import ADAPTER_ADDRESS, ADAPTER_PASSIVE_SCAN 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, BluetoothReachabilityIntent, BluetoothScanningMode, BluetoothServiceInfoBleak, HaBluetoothSlotAllocations, HaScannerModeChange, HaScannerRegistration, HaScannerRegistrationEvent, get_manager, set_manager, ) from habluetooth.central_manager import CentralBluetoothManager from . import ( HCI0_SOURCE_ADDRESS, HCI1_SOURCE_ADDRESS, NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, InjectableRemoteScanner, 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) msg = "This is a test" raise ValueError(msg) 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_check_unavailable_materializes_each_scanner_once() -> None: """ _async_check_unavailable must hit each scanner's discovered_addresses once. Regression for https://github.com/Bluetooth-Devices/habluetooth/issues/505: the prior two-pass loop accessed every connectable scanner twice per cycle, and ``HaScanner.discovered_addresses`` rebuilds bleak's discovered-devices dict on every access. """ manager = get_manager() address = "44:44:33:11:23:12" connectable_calls = 0 non_connectable_calls = 0 class CountingConnectable(FakeScanner): @property def discovered_addresses(self) -> Iterable[str]: nonlocal connectable_calls connectable_calls += 1 return (address,) class CountingNonConnectable(FakeScanner): @property def discovered_addresses(self) -> Iterable[str]: nonlocal non_connectable_calls non_connectable_calls += 1 return (address,) connectable = CountingConnectable("hci0", "hci0", connectable=True) non_connectable = CountingNonConnectable("hci1", "hci1", connectable=False) cancel_c = manager.async_register_scanner(connectable) cancel_n = manager.async_register_scanner(non_connectable) manager._async_check_unavailable() assert connectable_calls == 1 assert non_connectable_calls == 1 cancel_c() cancel_n() @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) msg = "This is a test" raise ValueError(msg) 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) msg = "This is a test" raise ValueError(msg) ok_scanner_callbacks: list[HaScannerRegistration] = [] def _ok_callback(scanner_registration: HaScannerRegistration) -> None: """Ok callback.""" ok_scanner_callbacks.append(scanner_registration) cancel1 = manager.async_register_scanner_registration_callback( _failing_callback, None ) # Make sure the second callback still works if the first one fails and # raises an exception cancel2 = manager.async_register_scanner_registration_callback(_ok_callback, None) hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3") hci3_scanner.connectable = True manager = get_manager() cancel = manager.async_register_scanner(hci3_scanner, connection_slots=5) assert len(ok_scanner_callbacks) == 1 assert ok_scanner_callbacks[0] == HaScannerRegistration( HaScannerRegistrationEvent.ADDED, hci3_scanner ) assert len(failed_scanner_callbacks) == 1 cancel() assert len(ok_scanner_callbacks) == 2 assert ok_scanner_callbacks[1] == HaScannerRegistration( HaScannerRegistrationEvent.REMOVED, hci3_scanner ) cancel1() cancel2() @pytest.mark.asyncio @pytest.mark.usefixtures("enable_bluetooth") async def test_async_register_scanner_mode_change_callback( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """Test bluetooth async_register_scanner_mode_change_callback handles failures.""" manager = get_manager() assert manager._loop is not None scanners = manager.async_current_scanners() assert len(scanners) == 2 scanner = scanners[0] failed_mode_callbacks: list[HaScannerModeChange] = [] def _failing_callback(mode_change: HaScannerModeChange) -> None: """Failing callback.""" failed_mode_callbacks.append(mode_change) msg = "This is a test" raise ValueError(msg) ok_mode_callbacks: list[HaScannerModeChange] = [] def _ok_callback(mode_change: HaScannerModeChange) -> None: """Ok callback.""" ok_mode_callbacks.append(mode_change) cancel1 = manager.async_register_scanner_mode_change_callback( _failing_callback, None ) # Make sure the second callback still works if the first one fails and # raises an exception cancel2 = manager.async_register_scanner_mode_change_callback(_ok_callback, None) # Test specific source callback source_specific_callbacks: list[HaScannerModeChange] = [] def _source_specific_callback(mode_change: HaScannerModeChange) -> None: """Source specific callback.""" source_specific_callbacks.append(mode_change) cancel3 = manager.async_register_scanner_mode_change_callback( _source_specific_callback, scanner.source ) # Change requested mode scanner.set_requested_mode(BluetoothScanningMode.ACTIVE) assert len(ok_mode_callbacks) == 1 assert ok_mode_callbacks[0].scanner == scanner assert ok_mode_callbacks[0].requested_mode == BluetoothScanningMode.ACTIVE assert ok_mode_callbacks[0].current_mode == scanner.current_mode assert len(failed_mode_callbacks) == 1 assert len(source_specific_callbacks) == 1 # Change current mode scanner.set_current_mode(BluetoothScanningMode.ACTIVE) assert len(ok_mode_callbacks) == 2 assert ok_mode_callbacks[1].scanner == scanner assert ok_mode_callbacks[1].current_mode == BluetoothScanningMode.ACTIVE assert len(failed_mode_callbacks) == 2 assert len(source_specific_callbacks) == 2 # No change when setting the same mode scanner.set_current_mode(BluetoothScanningMode.ACTIVE) assert len(ok_mode_callbacks) == 2 cancel1() cancel2() cancel3() @pytest.mark.asyncio async def test_async_register_scanner_with_connection_slots() -> None: """Test registering a scanner with connection slots.""" manager = get_manager() assert manager._loop is not None scanners = manager.async_current_scanners() assert len(scanners) == 0 hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3") hci3_scanner.connectable = True manager = get_manager() cancel = manager.async_register_scanner(hci3_scanner, connection_slots=5) assert manager.async_current_allocations(hci3_scanner.source) == [ HaBluetoothSlotAllocations(hci3_scanner.source, 5, 5, []) ] cancel() @pytest.mark.asyncio async def test_async_unregister_scanner_is_idempotent( caplog: pytest.LogCaptureFixture, ) -> None: """Double-invoking the cancel callback must not raise.""" manager = get_manager() hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3") hci3_scanner.connectable = True cancel = manager.async_register_scanner(hci3_scanner, connection_slots=5) cancel() assert hci3_scanner not in manager.async_current_scanners() with caplog.at_level("DEBUG", logger="habluetooth.manager"): cancel() assert any("already unregistered" in record.message for record in caplog.records) @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", } }, "auto_scheduler": ANY, "connectable_history": ANY, "scanners": [ { "connect_failures": {}, "connect_in_progress": {}, "connect_completed_total": 0, "connect_failed_total": 0, "last_connect_completed_time": 0.0, "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 of 900 seconds. get_manager().async_set_fallback_availability_interval( "44:44:33:11:23:12", 604800.0 ) assert ( get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12") == 604800.0 ) start_monotonic_time = time.monotonic() switchbot_device = generate_ble_device("44:44:33:11:23:12", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) switchbot_device_went_unavailable = False inject_advertisement_with_time_and_source( switchbot_device, switchbot_adv, start_monotonic_time, SOURCE_LOCAL, ) def _switchbot_device_unavailable_callback( _address: BluetoothServiceInfoBleak, ) -> None: """Switchbot device unavailable callback.""" nonlocal switchbot_device_went_unavailable switchbot_device_went_unavailable = True assert ( get_manager().async_get_learned_advertising_interval("44:44:33:11:23:12") is None ) switchbot_device_unavailable_cancel = get_manager().async_track_unavailable( _switchbot_device_unavailable_callback, switchbot_device.address, connectable=False, ) # Check that device hasn't expired after a day monotonic_now = start_monotonic_time + 86400 with patch_bluetooth_time( monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed(utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS)) await asyncio.sleep(0) assert switchbot_device_went_unavailable is False # Try again after it has expired monotonic_now = start_monotonic_time + 604800 with patch_bluetooth_time( monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed(utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS)) await asyncio.sleep(0) assert switchbot_device_went_unavailable is True switchbot_device_unavailable_cancel() # type: ignore[unreachable] # We should forget fallback interval after it expires assert ( get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12") is None ) @pytest.mark.asyncio async def test_subclassing_bluetooth_manager(caplog: pytest.LogCaptureFixture) -> None: """Test subclassing BluetoothManager.""" slot_manager = BleakSlotManager() bluetooth_adapters = FakeBluetoothAdapters() class TestBluetoothManager(BluetoothManager): """ Test class for BluetoothManager. This class implements _discover_service_info. """ def _discover_service_info( self, service_info: BluetoothServiceInfoBleak ) -> None: """ Discover a new service info. This method is intended to be overridden by subclasses. """ TestBluetoothManager(bluetooth_adapters, slot_manager) assert "does not implement _discover_service_info" not in caplog.text class TestBluetoothManager2(BluetoothManager): """ Test class for BluetoothManager. This class does not implement _discover_service_info. """ TestBluetoothManager2(bluetooth_adapters, slot_manager) assert "does not implement _discover_service_info" in caplog.text @pytest.mark.asyncio async def test_is_operating_degraded_on_linux_with_mgmt() -> None: """Test is_operating_degraded returns False on Linux with mgmt control.""" mock_bluetooth_adapters = FakeBluetoothAdapters() manager = BluetoothManager( mock_bluetooth_adapters, slot_manager=Mock(), ) with ( patch("habluetooth.manager.IS_LINUX", True), patch.object(manager, "_mgmt_ctl", Mock()), ): # Mock mgmt_ctl being available assert manager.is_operating_degraded() is False @pytest.mark.asyncio async def test_is_operating_degraded_on_linux_without_mgmt() -> None: """Test is_operating_degraded returns True on Linux without mgmt control.""" mock_bluetooth_adapters = FakeBluetoothAdapters() manager = BluetoothManager( mock_bluetooth_adapters, slot_manager=Mock(), ) with patch("habluetooth.manager.IS_LINUX", True): # mgmt_ctl is None by default assert manager._mgmt_ctl is None assert manager.is_operating_degraded() is True @pytest.mark.asyncio async def test_is_operating_degraded_on_non_linux() -> None: """Test is_operating_degraded returns False on non-Linux systems.""" mock_bluetooth_adapters = FakeBluetoothAdapters() manager = BluetoothManager( mock_bluetooth_adapters, slot_manager=Mock(), ) with patch("habluetooth.manager.IS_LINUX", False): # Should return False regardless of mgmt_ctl state assert manager.is_operating_degraded() is False # Even with mgmt_ctl set manager._mgmt_ctl = Mock() assert manager.is_operating_degraded() is False @pytest.mark.asyncio async def test_is_operating_degraded_after_permission_error() -> None: """Test is_operating_degraded after mgmt setup fails with permission error.""" mock_bluetooth_adapters = FakeBluetoothAdapters() manager = BluetoothManager( mock_bluetooth_adapters, slot_manager=Mock(), ) with ( patch("habluetooth.manager.IS_LINUX", True), patch("habluetooth.manager.MGMTBluetoothCtl") as mock_mgmt_class, ): # Make setup fail with permission error mock_mgmt_instance = Mock() mock_mgmt_instance.setup = AsyncMock( side_effect=PermissionError("No permission") ) mock_mgmt_class.return_value = mock_mgmt_instance # Setup should handle the error and set mgmt_ctl to None await manager.async_setup() # Should be in degraded mode assert manager._mgmt_ctl is None assert manager.is_operating_degraded() is True @pytest.mark.asyncio @pytest.mark.usefixtures("enable_bluetooth") async def test_async_scanner_count_includes_non_connectable( register_hci0_scanner: None, register_non_connectable_scanner: None, ) -> None: """Connectable count excludes non-connectable; full count includes both.""" manager = get_manager() assert manager.async_scanner_count(connectable=True) == 1 assert manager.async_scanner_count(connectable=False) == 2 @pytest.mark.asyncio @pytest.mark.usefixtures("enable_bluetooth") async def test_async_address_present_non_connectable_history( register_non_connectable_scanner: None, ) -> None: """async_address_present(connectable=False) reads the all-history map.""" manager = get_manager() address = "44:44:33:11:23:99" device = generate_ble_device(address, "wohand") adv = generate_advertisement_data(local_name="wohand", service_uuids=[]) inject_advertisement_with_time_and_source_connectable( device, adv, time.monotonic(), "AA:BB:CC:DD:EE:FF", False ) assert manager.async_address_present(address, connectable=False) is True assert manager.async_address_present(address, connectable=True) is False missing = "00:00:00:00:00:00" assert manager.async_address_present(missing, connectable=False) is False @pytest.mark.asyncio async def test_async_track_unavailable_connectable_branch() -> None: """connectable=True routes the callback to the connectable callback map.""" manager = get_manager() def _cb(_info: BluetoothServiceInfoBleak) -> None: return address = "11:22:33:44:55:66" cancel = manager.async_track_unavailable(_cb, address, connectable=True) try: assert _cb in manager._connectable_unavailable_callbacks[address] assert address not in manager._unavailable_callbacks finally: cancel() assert address not in manager._connectable_unavailable_callbacks @pytest.mark.asyncio async def test_async_current_allocations_unknown_source_returns_empty() -> None: """Querying an unknown source returns [] rather than None.""" manager = get_manager() assert manager.async_current_allocations("not-a-real-source") == [] @pytest.mark.asyncio async def test_async_recover_failed_adapters_skips_when_lock_held() -> None: """If recovery is already in flight, a concurrent call is a no-op.""" manager = get_manager() with patch.object( manager, "async_get_bluetooth_adapters", new=AsyncMock() ) as mock_get: await manager._recovery_lock.acquire() try: await manager._async_recover_failed_adapters() finally: manager._recovery_lock.release() mock_get.assert_not_called() @pytest.mark.asyncio async def test_async_get_bluetooth_adapters_cached_false_triggers_refresh() -> None: """cached=False forces a refresh of the underlying adapter source.""" manager = get_manager() assert manager._bluetooth_adapters is not None with patch.object( manager._bluetooth_adapters, "refresh", new=AsyncMock() ) as mock_refresh: await manager.async_get_bluetooth_adapters(cached=False) mock_refresh.assert_awaited_once() @pytest.mark.asyncio async def test_async_refresh_adapters_propagates_exception_to_waiters() -> None: """Concurrent callers must see the refresh exception, not silent success.""" manager = get_manager() assert manager._bluetooth_adapters is not None refresh_started = asyncio.Event() release_refresh = asyncio.Event() async def slow_failing_refresh() -> None: refresh_started.set() await release_refresh.wait() msg = "boom" raise RuntimeError(msg) with patch.object(manager._bluetooth_adapters, "refresh", new=slow_failing_refresh): leader = asyncio.create_task(manager._async_refresh_adapters()) await refresh_started.wait() waiter_a = asyncio.create_task(manager._async_refresh_adapters()) waiter_b = asyncio.create_task(manager._async_refresh_adapters()) # Yield so waiters register on the shared future. await asyncio.sleep(0) release_refresh.set() with pytest.raises(RuntimeError, match="boom"): await leader with pytest.raises(RuntimeError, match="boom"): await waiter_a with pytest.raises(RuntimeError, match="boom"): await waiter_b # Shared future must be cleared so the next call refreshes again. assert manager._adapter_refresh_future is None @pytest.mark.asyncio async def test_async_refresh_adapters_success_resolves_waiters() -> None: """Concurrent callers all see success and share the same refresh call.""" manager = get_manager() assert manager._bluetooth_adapters is not None refresh_started = asyncio.Event() release_refresh = asyncio.Event() call_count = 0 async def slow_refresh() -> None: nonlocal call_count call_count += 1 refresh_started.set() await release_refresh.wait() with patch.object(manager._bluetooth_adapters, "refresh", new=slow_refresh): leader = asyncio.create_task(manager._async_refresh_adapters()) await refresh_started.wait() waiter_a = asyncio.create_task(manager._async_refresh_adapters()) waiter_b = asyncio.create_task(manager._async_refresh_adapters()) await asyncio.sleep(0) release_refresh.set() await leader await waiter_a await waiter_b assert call_count == 1 assert manager._adapter_refresh_future is None @pytest.mark.asyncio async def test_async_refresh_adapters_leader_cancellation_does_not_silently_succeed( caplog: pytest.LogCaptureFixture, ) -> None: """Leader cancellation must not let waiters proceed as if refresh succeeded.""" manager = get_manager() assert manager._bluetooth_adapters is not None refresh_started = asyncio.Event() async def hanging_refresh() -> None: refresh_started.set() await asyncio.Event().wait() # never resolves with patch.object(manager._bluetooth_adapters, "refresh", new=hanging_refresh): leader = asyncio.create_task(manager._async_refresh_adapters()) await refresh_started.wait() waiter = asyncio.create_task(manager._async_refresh_adapters()) await asyncio.sleep(0) leader.cancel() with pytest.raises(asyncio.CancelledError): await leader # Waiter must observe a CancelledError, not silently complete. with pytest.raises(asyncio.CancelledError): await waiter assert manager._adapter_refresh_future is None @pytest.mark.asyncio async def test_async_refresh_adapters_waiter_cancellation_does_not_break_leader() -> ( None ): """Cancelling one waiter must not strand the leader or other siblings.""" manager = get_manager() assert manager._bluetooth_adapters is not None refresh_started = asyncio.Event() release_refresh = asyncio.Event() async def slow_refresh() -> None: refresh_started.set() await release_refresh.wait() with patch.object(manager._bluetooth_adapters, "refresh", new=slow_refresh): leader = asyncio.create_task(manager._async_refresh_adapters()) await refresh_started.wait() waiter_a = asyncio.create_task(manager._async_refresh_adapters()) waiter_b = asyncio.create_task(manager._async_refresh_adapters()) await asyncio.sleep(0) waiter_a.cancel() with pytest.raises(asyncio.CancelledError): await waiter_a # Leader and surviving waiter must still complete normally. release_refresh.set() await leader await waiter_b assert manager._adapter_refresh_future is None @pytest.mark.asyncio async def test_async_refresh_adapters_adapters_property_failure_propagates() -> None: """Property access failure after refresh() must not strand waiters.""" manager = get_manager() assert manager._bluetooth_adapters is not None refresh_started = asyncio.Event() release_refresh = asyncio.Event() async def slow_refresh() -> None: refresh_started.set() await release_refresh.wait() failing_adapters = PropertyMock(side_effect=RuntimeError("adapters boom")) with ( patch.object(manager._bluetooth_adapters, "refresh", new=slow_refresh), patch.object(type(manager._bluetooth_adapters), "adapters", failing_adapters), ): leader = asyncio.create_task(manager._async_refresh_adapters()) await refresh_started.wait() waiter = asyncio.create_task(manager._async_refresh_adapters()) await asyncio.sleep(0) release_refresh.set() with pytest.raises(RuntimeError, match="adapters boom"): await leader with pytest.raises(RuntimeError, match="adapters boom"): await waiter assert manager._adapter_refresh_future is None @pytest.mark.asyncio async def test_async_refresh_adapters_recovers_after_prior_failure() -> None: """Sequential call after a failed refresh must start fresh and succeed.""" manager = get_manager() assert manager._bluetooth_adapters is not None call_count = 0 async def flaky_refresh() -> None: nonlocal call_count call_count += 1 if call_count == 1: msg = "first boom" raise RuntimeError(msg) with patch.object(manager._bluetooth_adapters, "refresh", new=flaky_refresh): with pytest.raises(RuntimeError, match="first boom"): await manager._async_refresh_adapters() assert manager._adapter_refresh_future is None # Second call must start a fresh refresh, not reuse stale future state. await manager._async_refresh_adapters() assert call_count == 2 assert manager._adapter_refresh_future is None @pytest.mark.asyncio async def test_address_reachability_diagnostics_connectable() -> None: """A connectable device in range reports its connectable scanner.""" manager = get_manager() address = "44:44:33:11:23:45" scanner = InjectableRemoteScanner( "AA:BB:CC:DD:EE:FF", "Living Room Proxy", None, True ) cancel = manager.async_register_scanner(scanner) device = generate_ble_device(address, "wohand") adv = generate_advertisement_data(local_name="wohand", rssi=-50) scanner.inject_advertisement(device, adv) diag = manager.async_address_reachability_diagnostics( address, BluetoothReachabilityIntent.CONNECTION ) # The address is intentionally not embedded; callers already have it. assert address not in diag assert "in connectable history" in diag assert "1 scanner(s) registered, 1 scanning, 1 connectable" in diag assert "Living Room Proxy (AA:BB:CC:DD:EE:FF) (connectable=True, rssi=-50" in diag # The "via" source resolves to the scanner name rather than a bare address. assert "last advertisement" in diag assert "via Living Room Proxy (AA:BB:CC:DD:EE:FF)" in diag cancel() @pytest.mark.asyncio async def test_address_reachability_diagnostics_non_connectable_only() -> None: """A device only seen by a non-connectable scanner has no connectable path.""" manager = get_manager() address = "44:44:33:11:23:46" connectable = InjectableRemoteScanner("hci0", "hci0", None, True) cancel_c = manager.async_register_scanner(connectable) non_connectable = InjectableRemoteScanner("proxy", "proxy", None, False) cancel_n = manager.async_register_scanner(non_connectable) device = generate_ble_device(address, "wohand") adv = generate_advertisement_data(local_name="wohand", rssi=-70) non_connectable.inject_advertisement(device, adv) diag = manager.async_address_reachability_diagnostics( address, BluetoothReachabilityIntent.CONNECTION ) assert "only in non-connectable history (no connectable path)" in diag assert "seen by 1 scanner(s) but none with a connectable path" in diag assert "proxy (connectable=False, rssi=-70" in diag cancel_c() cancel_n() @pytest.mark.asyncio async def test_address_reachability_diagnostics_advertisement_intent() -> None: """An advertisement intent ignores connectable paths and slots.""" manager = get_manager() address = "44:44:33:11:23:4a" non_connectable = InjectableRemoteScanner("proxy", "proxy", None, False) cancel = manager.async_register_scanner(non_connectable) device = generate_ble_device(address, "wohand") adv = generate_advertisement_data(local_name="wohand", rssi=-70) non_connectable.inject_advertisement(device, adv) diag = manager.async_address_reachability_diagnostics( address, BluetoothReachabilityIntent.PASSIVE_ADVERTISEMENT ) assert "advertising, seen by 1 scanner(s)" in diag assert "no connectable path" not in diag assert "slots" not in diag # ACTIVE_ADVERTISEMENT is treated the same as PASSIVE_ADVERTISEMENT for now. assert diag == manager.async_address_reachability_diagnostics( address, BluetoothReachabilityIntent.ACTIVE_ADVERTISEMENT ) cancel() @pytest.mark.parametrize( "intent", [ BluetoothReachabilityIntent.CONNECTION, BluetoothReachabilityIntent.PASSIVE_ADVERTISEMENT, BluetoothReachabilityIntent.ACTIVE_ADVERTISEMENT, ], ) @pytest.mark.asyncio async def test_address_reachability_diagnostics_unknown( intent: BluetoothReachabilityIntent, ) -> None: """An address never seen reports as unknown for every intent.""" manager = get_manager() diag = manager.async_address_reachability_diagnostics("44:44:33:11:23:47", intent) assert "unknown (never seen by any scanner)" in diag @pytest.mark.asyncio async def test_address_reachability_diagnostics_no_connectable_scanners() -> None: """With only a non-connectable scanner the connectable count is zero.""" manager = get_manager() address = "44:44:33:11:23:48" non_connectable = InjectableRemoteScanner("proxy", "proxy", None, False) cancel = manager.async_register_scanner(non_connectable) device = generate_ble_device(address, "wohand") adv = generate_advertisement_data(local_name="wohand", rssi=-70) non_connectable.inject_advertisement(device, adv) diag = manager.async_address_reachability_diagnostics( address, BluetoothReachabilityIntent.CONNECTION ) assert "1 scanner(s) registered, 1 scanning, 0 connectable" in diag cancel() @pytest.mark.asyncio async def test_address_reachability_diagnostics_out_of_slots() -> None: """A connectable scanner with no free slots is reported as full.""" manager = get_manager() address = "44:44:33:11:23:49" scanner = InjectableRemoteScanner("esphome_proxy", "esphome_proxy", None, True) cancel = manager.async_register_scanner(scanner) device = generate_ble_device(address, "wohand") adv = generate_advertisement_data(local_name="wohand", rssi=-50) scanner.inject_advertisement(device, adv) with patch.object( scanner, "get_allocations", return_value=Allocations("esphome_proxy", 3, 0, []), ): diag = manager.async_address_reachability_diagnostics( address, BluetoothReachabilityIntent.CONNECTION ) assert "connectable scanner(s) that report slot allocations are all full" in diag assert "slots=0/3" in diag cancel() @pytest.mark.asyncio async def test_address_reachability_diagnostics_in_history_no_scanner() -> None: """An address in history but cached by no scanner is not called advertising.""" manager = get_manager() address = "44:44:33:11:23:4c" device = generate_ble_device(address, "wohand") adv = generate_advertisement_data(local_name="wohand", rssi=-70) # Injected from a source with no registered scanner; lands in history but # no scanner currently has it cached. inject_advertisement_with_source(device, adv, "ghost") diag = manager.async_address_reachability_diagnostics( address, BluetoothReachabilityIntent.PASSIVE_ADVERTISEMENT ) assert "previously seen but no scanner currently has it cached" in diag assert "advertising" not in diag @pytest.mark.asyncio async def test_address_reachability_diagnostics_all_scanners_connecting() -> None: """When every scanner is paused connecting, the device cannot be seen.""" manager = get_manager() address = "44:44:33:11:23:4b" scanner = InjectableRemoteScanner("esphome_proxy", "esphome_proxy", None, True) cancel = manager.async_register_scanner(scanner) with scanner.connecting(): assert scanner.scanning is False diag = manager.async_address_reachability_diagnostics( address, BluetoothReachabilityIntent.CONNECTION ) assert "1 scanner(s) registered, 0 scanning, 1 connectable" in diag assert "1 paused while connecting" in diag assert "no scanner is currently scanning" in diag assert "add more Bluetooth adapters or proxies" in diag cancel() @pytest.mark.asyncio async def test_address_reachability_diagnostics_scanner_stopped_not_connecting() -> ( None ): """A stopped scanner (not connecting) reports no scanning without the advice.""" manager = get_manager() scanner = InjectableRemoteScanner("esphome_proxy", "esphome_proxy", None, True) cancel = manager.async_register_scanner(scanner) scanner.scanning = False diag = manager.async_address_reachability_diagnostics( "44:44:33:11:23:4d", BluetoothReachabilityIntent.CONNECTION ) assert "1 scanner(s) registered, 0 scanning, 1 connectable" in diag assert "no scanner is currently scanning" in diag assert "paused while connecting" not in diag assert "add more Bluetooth adapters or proxies" not in diag cancel() @pytest.mark.asyncio @pytest.mark.usefixtures("enable_bluetooth") async def test_bleak_callback_exception_is_logged_and_isolated( register_hci0_scanner: None, caplog: pytest.LogCaptureFixture, ) -> None: """A raising bleak callback is caught and logged; siblings still fire.""" manager = get_manager() address = "44:44:33:11:23:01" device = generate_ble_device(address, "wohand") adv = generate_advertisement_data(local_name="wohand", service_uuids=[], rssi=-40) received: list[Any] = [] def _failing(_device: Any, _adv: Any) -> None: msg = "boom" raise ValueError(msg) def _ok(_device: Any, _adv: Any) -> None: received.append(_device) cancel_fail = manager.async_register_bleak_callback(_failing, {}) cancel_ok = manager.async_register_bleak_callback(_ok, {}) try: # A single advertisement dispatch fans out to both callbacks in one # loop; the failing one must not stop the ok one from firing. inject_advertisement_with_source(device, adv, "hci0") assert received # the ok callback fired despite the sibling raising assert "Error in callback" in caplog.text finally: cancel_fail() cancel_ok() @pytest.mark.asyncio async def test_supports_passive_scan_reflects_adapter_capability() -> None: """supports_passive_scan is True iff any adapter advertises passive scan.""" manager = BluetoothManager(FakeBluetoothAdapters(), Mock()) manager._adapters = {"hci0": {ADAPTER_PASSIVE_SCAN: False}} assert manager.supports_passive_scan is False manager._adapters = { "hci0": {ADAPTER_PASSIVE_SCAN: False}, "hci1": {ADAPTER_PASSIVE_SCAN: True}, } assert manager.supports_passive_scan is True @pytest.mark.asyncio async def test_get_bluetooth_adapters_cached_with_empty_cache() -> None: """cached=True still populates when the adapter cache is empty (no refresh).""" adapters = FakeBluetoothAdapters() manager = BluetoothManager(adapters, Mock()) with patch("habluetooth.manager.IS_LINUX", False): await manager.async_setup() try: manager._adapters = {} # cached=True with an empty cache repopulates straight from the backend # without taking the refresh path. with patch.object(adapters, "refresh", wraps=adapters.refresh) as spy: result = await manager.async_get_bluetooth_adapters(cached=True) spy.assert_not_called() assert result == adapters.adapters finally: manager.async_stop() @pytest.mark.asyncio async def test_get_adapter_from_address_refreshes_when_not_found() -> None: """A miss triggers a refresh, then a second lookup.""" adapters = FakeBluetoothAdapters() manager = BluetoothManager(adapters, Mock()) with patch("habluetooth.manager.IS_LINUX", False): await manager.async_setup() try: manager._adapters = {} # Unknown address: first lookup misses, a refresh runs, second lookup # still misses against the empty fake backend. with patch.object(adapters, "refresh", wraps=adapters.refresh) as spy: assert ( await manager.async_get_adapter_from_address("00:00:00:00:00:09") is None ) spy.assert_called_once() # the miss forced a refresh # Known address resolves on the first lookup. manager._adapters = {"hci7": {ADAPTER_ADDRESS: "00:00:00:00:00:07"}} assert ( await manager.async_get_adapter_from_address("00:00:00:00:00:07") == "hci7" ) finally: manager.async_stop() @pytest.mark.asyncio async def test_async_setup_assigns_central_manager_when_unset() -> None: """async_setup claims the central singleton when it is unset.""" original = CentralBluetoothManager.manager manager = BluetoothManager(FakeBluetoothAdapters(), Mock()) try: CentralBluetoothManager.manager = None with patch("habluetooth.manager.IS_LINUX", False): await manager.async_setup() assert CentralBluetoothManager.manager is manager finally: CentralBluetoothManager.manager = original manager.async_stop() @pytest.mark.asyncio async def test_async_setup_returns_early_on_non_linux() -> None: """On non-Linux, setup skips mgmt control entirely.""" manager = BluetoothManager(FakeBluetoothAdapters(), Mock()) with patch("habluetooth.manager.IS_LINUX", False): await manager.async_setup() # Inside the non-Linux patch, setup returned before touching mgmt. assert manager._mgmt_ctl is None assert manager.is_operating_degraded() is False manager.async_stop() @pytest.mark.asyncio async def test_async_setup_handles_connection_error() -> None: """A CONNECTION_ERRORS failure during mgmt setup degrades gracefully.""" manager = BluetoothManager(FakeBluetoothAdapters(), Mock()) with ( patch("habluetooth.manager.IS_LINUX", True), patch("habluetooth.manager.MGMTBluetoothCtl") as mock_mgmt_class, ): mock_instance = Mock() mock_instance.setup = AsyncMock(side_effect=OSError("no socket")) mock_mgmt_class.return_value = mock_instance await manager.async_setup() try: assert manager._mgmt_ctl is None assert manager.has_advertising_side_channel is False finally: manager.async_stop() @pytest.mark.asyncio async def test_async_stop_without_unavailable_tracking() -> None: """async_stop is a no-op for unavailable tracking when none is scheduled.""" manager = BluetoothManager(FakeBluetoothAdapters(), Mock()) with patch("habluetooth.manager.IS_LINUX", False): await manager.async_setup() manager._cancel_unavailable_tracking = None # Should not raise even though there is no tracking handle to cancel. manager.async_stop() assert manager.shutdown is True @pytest.mark.asyncio @pytest.mark.usefixtures("enable_bluetooth") async def test_unavailable_callback_exception_isolated( register_hci0_scanner: None, caplog: pytest.LogCaptureFixture, ) -> None: """A raising unavailable callback is logged; a sibling still fires.""" manager = get_manager() address = "44:44:33:11:23:02" start = time.monotonic() device = generate_ble_device(address, "wohand") adv = generate_advertisement_data(local_name="wohand", service_uuids=[], rssi=-60) inject_advertisement_with_time_and_source_connectable( device, adv, start, HCI0_SOURCE_ADDRESS, False ) ok_calls: list[Any] = [] def _failing(_info: BluetoothServiceInfoBleak) -> None: msg = "boom" raise ValueError(msg) def _ok(_info: BluetoothServiceInfoBleak) -> None: ok_calls.append(_info) cancel_fail = manager.async_track_unavailable(_failing, address, connectable=False) cancel_ok = manager.async_track_unavailable(_ok, address, connectable=False) try: # Push the clock well past the fallback staleness window so the device # is considered unavailable. with patch_bluetooth_time(start + 100_000): manager._async_check_unavailable() assert len(ok_calls) == 1 assert "Error in unavailable callback" in caplog.text finally: cancel_fail() cancel_ok() @pytest.mark.asyncio @pytest.mark.usefixtures("enable_bluetooth") async def test_remove_unavailable_callback_keeps_siblings( register_hci0_scanner: None, ) -> None: """Cancelling one unavailable callback leaves the address entry in place.""" manager = get_manager() address = "44:44:33:11:23:03" def _cb_a(_info: BluetoothServiceInfoBleak) -> None: return def _cb_b(_info: BluetoothServiceInfoBleak) -> None: return cancel_a = manager.async_track_unavailable(_cb_a, address, connectable=False) cancel_b = manager.async_track_unavailable(_cb_b, address, connectable=False) try: cancel_a() # One callback remains, so the address bucket is not deleted. assert address in manager._unavailable_callbacks assert _cb_b in manager._unavailable_callbacks[address] finally: cancel_b() assert address not in manager._unavailable_callbacks @pytest.mark.asyncio @pytest.mark.usefixtures("enable_bluetooth") async def test_unregister_source_callback_keeps_siblings( register_hci0_scanner: None, ) -> None: """Cancelling one source-keyed callback leaves the source bucket in place.""" manager = get_manager() def _cb_a(_change: HaScannerModeChange) -> None: return def _cb_b(_change: HaScannerModeChange) -> None: return cancel_a = manager.async_register_scanner_mode_change_callback(_cb_a, None) cancel_b = manager.async_register_scanner_mode_change_callback(_cb_b, None) try: cancel_a() # One callback remains under the None source, so it is not deleted. assert None in manager._scanner_mode_change_callbacks assert _cb_b in manager._scanner_mode_change_callbacks[None] finally: cancel_b() assert None not in manager._scanner_mode_change_callbacks # Cancelling again once the source bucket is gone is a no-op. cancel_b() assert None not in manager._scanner_mode_change_callbacks @pytest.mark.asyncio @pytest.mark.usefixtures("enable_bluetooth") async def test_should_keep_previous_adv_logs_when_debug_enabled( register_hci0_scanner: None, register_hci1_scanner: None, caplog: pytest.LogCaptureFixture, ) -> None: """With debug on, the keep-previous decision logs its switch reasons.""" manager = get_manager() manager._debug = True address = "44:44:33:11:23:04" device = generate_ble_device(address, "wohand") start = time.monotonic() weak = generate_advertisement_data(local_name="wohand", service_uuids=[], rssi=-40) inject_advertisement_with_time_and_source(device, weak, start, HCI0_SOURCE_ADDRESS) with caplog.at_level(logging.DEBUG, logger="habluetooth.manager"): # A clearly stronger reading from a second still-scanning source wins # on RSSI (RSSI-switch debug branch). strong = generate_advertisement_data( local_name="wohand", service_uuids=[], rssi=-20 ) inject_advertisement_with_time_and_source( device, strong, start + 1, HCI1_SOURCE_ADDRESS ) assert "new rssi" in caplog.text caplog.clear() # A far-future reading makes the previous one stale, so any new # advertisement wins regardless of RSSI (stale-switch debug branch). inject_advertisement_with_time_and_source( device, weak, start + 100_000, HCI0_SOURCE_ADDRESS ) assert "time elapsed" in caplog.text @pytest.mark.asyncio @pytest.mark.usefixtures("enable_bluetooth") async def test_non_connectable_advertisement_rejected_in_favour_of_previous( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """A weaker non-connectable reading is rejected without re-adding history.""" manager = get_manager() address = "44:44:33:11:23:05" device = generate_ble_device(address, "wohand") start = time.monotonic() strong = generate_advertisement_data( local_name="wohand", service_uuids=[], rssi=-30 ) inject_advertisement_with_time_and_source_connectable( device, strong, start, HCI0_SOURCE_ADDRESS, False ) # A weaker, non-connectable reading from a second still-scanning source is # rejected; the stronger hci0 reading stays in history. weak = generate_advertisement_data(local_name="wohand", service_uuids=[], rssi=-95) inject_advertisement_with_time_and_source_connectable( device, weak, start + 1, HCI1_SOURCE_ADDRESS, False ) kept = manager.async_last_service_info(address, connectable=False) assert kept is not None assert kept.source == HCI0_SOURCE_ADDRESS assert kept.rssi == -30 @pytest.mark.asyncio @pytest.mark.usefixtures("enable_bluetooth") async def test_should_keep_previous_adv_switches_without_debug_logging( register_hci0_scanner: None, register_hci1_scanner: None, caplog: pytest.LogCaptureFixture, ) -> None: """Switch decisions are silent when debug logging is disabled.""" manager = get_manager() manager._debug = False address = "44:44:33:11:23:06" device = generate_ble_device(address, "wohand") start = time.monotonic() weak = generate_advertisement_data(local_name="wohand", service_uuids=[], rssi=-40) inject_advertisement_with_time_and_source(device, weak, start, HCI0_SOURCE_ADDRESS) with caplog.at_level(logging.DEBUG, logger="habluetooth.manager"): # RSSI switch: a stronger second source wins. strong = generate_advertisement_data( local_name="wohand", service_uuids=[], rssi=-20 ) inject_advertisement_with_time_and_source( device, strong, start + 1, HCI1_SOURCE_ADDRESS ) # Stale switch: a far-future reading wins. inject_advertisement_with_time_and_source( device, weak, start + 100_000, HCI0_SOURCE_ADDRESS ) # The switch happened, but nothing was logged. latest = manager.async_last_service_info(address, connectable=True) assert latest is not None assert latest.source == HCI0_SOURCE_ADDRESS assert "Switching from" not in caplog.text @pytest.mark.asyncio @pytest.mark.usefixtures("enable_bluetooth") async def test_non_connectable_adv_promoted_when_connectable_path_registered( register_hci0_scanner: None, ) -> None: """ A changed non-connectable adv is promoted when a connectable path is live. Regression test for #534: a connectable scanner has a path to the device, but the current best advertisement arrives from a non-connectable source. The service_info must surface as connectable so connectable callbacks and discovery fire, otherwise Home Assistant believes there is no connectable path. """ manager = get_manager() address = "44:44:33:11:23:45" now = time.monotonic() discovered: list[BluetoothServiceInfoBleak] = [] manager._subclass_discover_info = Mock(side_effect=discovered.append) bleak_devices: list[BLEDevice] = [] def _on_bleak(dev: BLEDevice, _adv: AdvertisementData) -> None: bleak_devices.append(dev) # Register up front so the connectable_history replay on registration cannot # be mistaken for a promotion dispatch. cancel = manager.async_register_bleak_callback(_on_bleak, {}) try: # Connectable adv from the registered hci0 scanner populates # connectable_history. device = generate_ble_device(address, "wohand") connectable_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-60, ) inject_advertisement_with_time_and_source_connectable( device, connectable_adv, now, HCI0_SOURCE_ADDRESS, True ) discovered.clear() bleak_devices.clear() # A stronger non-connectable adv from another source wins the best-path # comparison and carries changed data so the identical-adv short-circuit # does not skip dispatch. non_connectable_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x02"}, rssi=-20, ) inject_advertisement_with_time_and_source_connectable( device, non_connectable_adv, now, NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, False, ) finally: cancel() assert discovered assert discovered[-1].connectable is True assert bleak_devices @pytest.mark.asyncio @pytest.mark.usefixtures("enable_bluetooth") async def test_non_connectable_adv_not_promoted_after_connectable_scanner_unregisters() -> ( # noqa: E501 None ): """ A lingering connectable_history entry does not promote once its source is gone. connectable_history is only pruned by the periodic unavailable check, so an unregistered connectable scanner can leave a stale entry behind. The promotion must verify the stored source is still registered before claiming a live path. """ manager = get_manager() address = "44:44:33:11:23:46" now = time.monotonic() discovered: list[BluetoothServiceInfoBleak] = [] manager._subclass_discover_info = Mock(side_effect=discovered.append) bleak_devices: list[BLEDevice] = [] def _on_bleak(dev: BLEDevice, _adv: AdvertisementData) -> None: bleak_devices.append(dev) # Register up front so the connectable_history replay on registration cannot # be mistaken for a promotion dispatch. cancel = manager.async_register_bleak_callback(_on_bleak, {}) try: connectable_scanner = FakeScanner(HCI0_SOURCE_ADDRESS, "hci0") connectable_scanner.connectable = True cancel_scanner = manager.async_register_scanner(connectable_scanner) device = generate_ble_device(address, "wohand") connectable_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-60, ) inject_advertisement_with_time_and_source_connectable( device, connectable_adv, now, HCI0_SOURCE_ADDRESS, True ) # Unregister the only connectable scanner; the connectable_history entry # intentionally lingers since unregister does not prune it. cancel_scanner() assert HCI0_SOURCE_ADDRESS not in manager._sources assert address in manager._connectable_history discovered.clear() bleak_devices.clear() non_connectable_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x02"}, rssi=-20, ) inject_advertisement_with_time_and_source_connectable( device, non_connectable_adv, now, NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, False, ) finally: cancel() assert discovered assert discovered[-1].connectable is False assert not bleak_devices @pytest.mark.asyncio @pytest.mark.usefixtures("enable_bluetooth") async def test_connectable_adv_still_dispatches_to_bleak_callbacks( register_hci0_scanner: None, ) -> None: """A normal connectable adv still dispatches to bleak callbacks unchanged.""" manager = get_manager() address = "44:44:33:11:23:47" device = generate_ble_device(address, "wohand") adv = generate_advertisement_data(local_name="wohand", service_uuids=[], rssi=-40) bleak_devices: list[BLEDevice] = [] def _on_bleak(dev: BLEDevice, _adv: AdvertisementData) -> None: bleak_devices.append(dev) cancel = manager.async_register_bleak_callback(_on_bleak, {}) try: inject_advertisement_with_time_and_source_connectable( device, adv, time.monotonic(), HCI0_SOURCE_ADDRESS, True ) finally: cancel() assert bleak_devices == [device] Bluetooth-Devices-habluetooth-75cbe37/tests/test_models.py000066400000000000000000000233571521117704500240470ustar00rootroot00000000000000from __future__ import annotations import time from habluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak from . import generate_advertisement_data, generate_ble_device SOURCE_LOCAL = "local" def test_model(): service_info = BluetoothServiceInfo( name="Test", address="00:00:00:00:00:00", rssi=0, manufacturer_data={97: b"\x00\x00\x00\x00\x00\x00"}, service_data={}, service_uuids=[], source=SOURCE_LOCAL, ) assert service_info.manufacturer == "RDA Microelectronics" assert service_info.manufacturer_id == 97 service_info = BluetoothServiceInfo( name="Test", address="00:00:00:00:00:00", rssi=0, manufacturer_data={954547: b"\x00\x00\x00\x00\x00\x00"}, service_data={}, service_uuids=[], source=SOURCE_LOCAL, ) assert service_info.manufacturer is None assert service_info.manufacturer_id == 954547 def test_model_from_bleak(): switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand", {}) switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) service_info = BluetoothServiceInfo.from_advertisement( switchbot_device, switchbot_adv, SOURCE_LOCAL ) assert service_info.service_uuids == ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] assert service_info.name == "wohand" assert service_info.source == SOURCE_LOCAL assert service_info.manufacturer is None assert service_info.manufacturer_id is None def test_model_from_scanner(): switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand", {}) switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) now = time.monotonic() service_info = BluetoothServiceInfoBleak.from_scan( SOURCE_LOCAL, switchbot_device, switchbot_adv, now, True ) assert service_info.service_uuids == ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] assert service_info.name == "wohand" assert service_info.source == SOURCE_LOCAL assert service_info.manufacturer is None assert service_info.manufacturer_id is None assert service_info.time == now assert service_info.connectable is True safe_as_dict = service_info.as_dict() assert safe_as_dict == { "address": "44:44:33:11:23:45", "advertisement": switchbot_adv, "device": switchbot_device, "connectable": True, "manufacturer_data": {}, "name": "wohand", "raw": None, "rssi": -127, "service_data": {}, "service_uuids": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], "source": "local", "time": now, "tx_power": -127, } def test_construct_service_info_bleak(): switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand", {}) switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) now = time.monotonic() service_info = BluetoothServiceInfoBleak( name="wohand", address="44:44:33:11:23:45", rssi=-127, manufacturer_data=switchbot_adv.manufacturer_data, service_data=switchbot_adv.service_data, service_uuids=switchbot_adv.service_uuids, source=SOURCE_LOCAL, device=switchbot_device, advertisement=switchbot_adv, connectable=False, time=now, tx_power=1, ) assert service_info.service_uuids == ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] assert service_info.name == "wohand" assert service_info.source == SOURCE_LOCAL assert service_info.manufacturer is None assert service_info.manufacturer_id is None assert service_info.time == now assert service_info.connectable is False safe_as_dict = service_info.as_dict() assert safe_as_dict == { "address": "44:44:33:11:23:45", "advertisement": switchbot_adv, "device": switchbot_device, "connectable": False, "raw": None, "manufacturer_data": {}, "name": "wohand", "rssi": -127, "service_data": {}, "service_uuids": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], "source": "local", "time": now, "tx_power": 1, } def test_from_device_and_advertisement_data(): """ Test creating a BluetoothServiceInfoBleak. From a BLEDevice and AdvertisementData. """ switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand", {}) switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) now_monotonic = time.monotonic() service_info = BluetoothServiceInfoBleak.from_device_and_advertisement_data( switchbot_device, switchbot_adv, SOURCE_LOCAL, now_monotonic, True ) assert service_info.service_uuids == ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] assert service_info.name == "wohand" assert service_info.source == SOURCE_LOCAL assert service_info.manufacturer is None assert service_info.manufacturer_id is None safe_as_dict = service_info.as_dict() assert safe_as_dict == { "address": "44:44:33:11:23:45", "advertisement": switchbot_adv, "device": switchbot_device, "connectable": True, "manufacturer_data": {}, "name": "wohand", "raw": None, "rssi": -127, "service_data": {}, "service_uuids": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], "source": "local", "time": now_monotonic, "tx_power": -127, } assert str(service_info) == ( "" ) def test_pyobjc_compat(): # pyobjc-style snake_case names intentionally mirror the runtime # types we receive from CoreBluetooth so the coercion paths are # exercised with realistic class identities. class pyobjc_str(str): # noqa: N801 __slots__ = () class pyobjc_int(int): # noqa: N801 __slots__ = () name = pyobjc_str("wohand") address = pyobjc_str("44:44:33:11:23:45") rssi = pyobjc_int(-127) assert name == "wohand" assert address == "44:44:33:11:23:45" assert rssi == -127 switchbot_device = generate_ble_device(address, name, {}) switchbot_adv = generate_advertisement_data( local_name=name, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) now = time.monotonic() service_info = BluetoothServiceInfoBleak( name=str(name), address=str(address), rssi=rssi, manufacturer_data=switchbot_adv.manufacturer_data, service_data=switchbot_adv.service_data, service_uuids=switchbot_adv.service_uuids, source=SOURCE_LOCAL, device=switchbot_device, advertisement=switchbot_adv, connectable=False, time=now, tx_power=1, ) assert service_info.service_uuids == ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] assert service_info.name == "wohand" assert service_info.source == SOURCE_LOCAL assert service_info.manufacturer is None assert service_info.manufacturer_id is None assert service_info.time == now assert service_info.connectable is False safe_as_dict = service_info.as_dict() assert safe_as_dict == { "address": "44:44:33:11:23:45", "advertisement": switchbot_adv, "device": switchbot_device, "connectable": False, "manufacturer_data": {}, "name": "wohand", "raw": None, "rssi": -127, "service_data": {}, "service_uuids": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], "source": "local", "time": now, "tx_power": 1, } def test_as_connectable(): switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand", {}) switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) now = time.monotonic() service_info = BluetoothServiceInfoBleak( name="wohand", address="44:44:33:11:23:45", rssi=-127, manufacturer_data=switchbot_adv.manufacturer_data, service_data=switchbot_adv.service_data, service_uuids=switchbot_adv.service_uuids, source=SOURCE_LOCAL, device=switchbot_device, advertisement=switchbot_adv, connectable=False, time=now, tx_power=1, raw=b"\x00\x00\x00\x00\x00\x00", ) connectable_service_info = service_info._as_connectable() assert connectable_service_info.connectable is True assert service_info.connectable is False assert connectable_service_info is not service_info assert service_info.name == connectable_service_info.name assert service_info.address == connectable_service_info.address assert service_info.rssi == connectable_service_info.rssi assert service_info.manufacturer_data == connectable_service_info.manufacturer_data assert service_info.service_data == connectable_service_info.service_data assert service_info.service_uuids == connectable_service_info.service_uuids assert service_info.source == connectable_service_info.source assert service_info.device == connectable_service_info.device assert service_info.advertisement == connectable_service_info.advertisement assert service_info.time == connectable_service_info.time assert service_info.tx_power == connectable_service_info.tx_power assert service_info.raw == connectable_service_info.raw Bluetooth-Devices-habluetooth-75cbe37/tests/test_name_cache.py000066400000000000000000000443611521117704500246250ustar00rootroot00000000000000"""Tests for the cross-scanner name cache on BluetoothManager.""" import time from datetime import timedelta from unittest.mock import patch import pytest from freezegun import freeze_time from habluetooth import HaBluetoothConnector, get_manager from . import ( InjectableRemoteScanner as _SeedFakeScanner, ) from . import ( MockBleakClient, generate_advertisement_data, generate_ble_device, inject_advertisement_with_source, utcnow, ) # --------------------------------------------------------------------------- # Unit tests for the prefix-extension policy # --------------------------------------------------------------------------- def test_name_cache_empty_to_name() -> None: """First non-empty name observed is stored.""" manager = get_manager() address = "AA:BB:CC:DD:EE:01" manager.seed_name_cache(address, "Onv") assert manager._name_cache[address] == "Onv" def test_name_cache_extension_replaces_truncation() -> None: """A new name that extends the cached short name replaces it.""" manager = get_manager() address = "AA:BB:CC:DD:EE:02" manager.seed_name_cache(address, "Onv") manager.seed_name_cache(address, "Onvis XXX") assert manager._name_cache[address] == "Onvis XXX" def test_name_cache_truncation_keeps_cached() -> None: """A new name that is a truncation of the cached complete name is rejected.""" manager = get_manager() address = "AA:BB:CC:DD:EE:03" manager.seed_name_cache(address, "Onvis XXX") manager.seed_name_cache(address, "Onv") assert manager._name_cache[address] == "Onvis XXX" def test_name_cache_rename_replaces() -> None: """A completely different name (not prefix-related) replaces the cached name.""" manager = get_manager() address = "AA:BB:CC:DD:EE:04" manager.seed_name_cache(address, "Onv") manager.seed_name_cache(address, "Donkey") assert manager._name_cache[address] == "Donkey" def test_name_cache_same_name_noop() -> None: """Re-broadcasting the same name does not allocate a new cache entry.""" manager = get_manager() address = "AA:BB:CC:DD:EE:05" manager.seed_name_cache(address, "Onv") cached_first = manager._name_cache[address] manager.seed_name_cache(address, "Onv") # Same string compares equal; identity may or may not match depending on # interning but the value must be unchanged. assert manager._name_cache[address] == cached_first def test_name_cache_empty_name_noop() -> None: """An empty name never overwrites the cached value.""" manager = get_manager() address = "AA:BB:CC:DD:EE:06" manager.seed_name_cache(address, "Onv") manager.seed_name_cache(address, "") assert manager._name_cache[address] == "Onv" def test_name_cache_address_fallback_not_stored() -> None: """ Address fallback (name == address) must not pollute the cache. base_scanner sets info.name = address when no local_name is present. """ manager = get_manager() address = "AA:BB:CC:DD:EE:07" manager.seed_name_cache(address, address) assert address not in manager._name_cache def test_name_cache_address_fallback_does_not_overwrite() -> None: """The address-fallback no-op must not replace an existing cached name.""" manager = get_manager() address = "AA:BB:CC:DD:EE:08" manager.seed_name_cache(address, "Onv") manager.seed_name_cache(address, address) assert manager._name_cache[address] == "Onv" def test_name_cache_case_folded_extension() -> None: """The extension rule is case-folded: 'onv' is a prefix of 'ONVIS XXX'.""" manager = get_manager() address = "AA:BB:CC:DD:EE:09" manager.seed_name_cache(address, "onv") manager.seed_name_cache(address, "ONVIS XXX") assert manager._name_cache[address] == "ONVIS XXX" def test_name_cache_case_folded_truncation_keeps_cached() -> None: """Case-folded truncation: 'ONV' is a truncation of 'Onvis XXX'.""" manager = get_manager() address = "AA:BB:CC:DD:EE:0A" manager.seed_name_cache(address, "Onvis XXX") manager.seed_name_cache(address, "ONV") assert manager._name_cache[address] == "Onvis XXX" def test_name_cache_equal_value_different_objects_noop() -> None: """Two str objects with identical value hit the cached == name no-op path.""" manager = get_manager() address = "AA:BB:CC:DD:EE:0B" first = b"Onvis XXX".decode() second = b"Onvis XXX".decode() assert first is not second manager.seed_name_cache(address, first) manager.seed_name_cache(address, second) # Value unchanged; the original object is still cached (no rewrite). assert manager._name_cache[address] is first def test_name_cache_equal_length_case_only_diff_keeps_cached() -> None: """Equal-length casefolded names differing only in case keep the cached value.""" manager = get_manager() address = "AA:BB:CC:DD:EE:0C" manager.seed_name_cache(address, "Onv") manager.seed_name_cache(address, "ONV") assert manager._name_cache[address] == "Onv" # --------------------------------------------------------------------------- # Cross-scanner integration: passive scanner gains name from active scanner # --------------------------------------------------------------------------- @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_passive_scanner_gains_name_from_active_scanner() -> None: """ Passive scanner inherits the name learned by an active scanner. Uses real BaseHaRemoteScanner injection so the lazy AdvertisementData construction path in models._advertisement_internal is exercised. """ manager = get_manager() connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) active_scanner = _SeedFakeScanner("esp32-active", "esp32-active", connector, True) active_unsetup = active_scanner.async_setup() active_cancel = manager.async_register_scanner(active_scanner) passive_scanner = _SeedFakeScanner( "esp32-passive", "esp32-passive", connector, True ) passive_unsetup = passive_scanner.async_setup() passive_cancel = manager.async_register_scanner(passive_scanner) address = "44:44:33:11:23:50" # Active scanner reports the device with its full SCAN_RSP-derived name. active_device = generate_ble_device(address, "Onvis XXX", {}, rssi=-60) active_adv = generate_advertisement_data(local_name="Onvis XXX", rssi=-60) active_scanner.inject_advertisement(active_device, active_adv) assert manager._name_cache[address] == "Onvis XXX" assert manager._all_history[address].name == "Onvis XXX" # Passive scanner reports the same address without a name, with stronger # RSSI so it wins the source-preference comparison. passive_device = generate_ble_device(address, None, {}, rssi=-30) passive_adv = generate_advertisement_data( local_name=None, # Distinct manufacturer data to force past the fast-path early-return # so we actually exercise the patch path. manufacturer_data={99: b"\x99"}, rssi=-30, ) passive_scanner.inject_advertisement(passive_device, passive_adv) # The patched view in _all_history must carry the active scanner's name. patched = manager._all_history[address] assert patched.name == "Onvis XXX" assert patched.device.name == "Onvis XXX" # And the AdvertisementData built on-demand for bleak callbacks must # carry it too. assert patched.advertisement.local_name == "Onvis XXX" active_cancel() active_unsetup() passive_cancel() passive_unsetup() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_active_scanner_extension_propagates_across_sources() -> None: """ An active scanner's longer name upgrades the cache populated by a passive one. Order: passive sees the short name first, then active sees the full SCAN_RSP-derived name. The cache must upgrade and the dispatched view must carry the longer name. """ manager = get_manager() address = "44:44:33:11:23:51" # Passive scanner sees the shortened name first. passive_device = generate_ble_device(address, "Onv", {}, rssi=-80) passive_adv = generate_advertisement_data(local_name="Onv", rssi=-80) inject_advertisement_with_source(passive_device, passive_adv, "passive-source") assert manager._name_cache[address] == "Onv" # Active scanner sees the full SCAN_RSP-derived name. active_device = generate_ble_device(address, "Onvis XXX", {}, rssi=-70) active_adv = generate_advertisement_data( local_name="Onvis XXX", manufacturer_data={1: b"\x01"}, rssi=-70, ) inject_advertisement_with_source(active_device, active_adv, "active-source") assert manager._name_cache[address] == "Onvis XXX" assert manager._all_history[address].name == "Onvis XXX" @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_second_scanner_same_name_different_object_noop() -> None: """ Second scanner reporting the same name (different str object) is a no-op. Exercises the cached_name == service_info.name early-return inside _handle_name_cache_miss: identity mismatch but value match. """ manager = get_manager() connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner_a = _SeedFakeScanner("esp32-a", "esp32-a", connector, True) scanner_b = _SeedFakeScanner("esp32-b", "esp32-b", connector, True) cancels = [ scanner_a.async_setup(), manager.async_register_scanner(scanner_a), scanner_b.async_setup(), manager.async_register_scanner(scanner_b), ] address = "44:44:33:11:23:56" # Use bytes.decode() so each scanner gets a distinct str object. device_a = generate_ble_device(address, b"Onvis XXX".decode(), {}, rssi=-60) adv_a = generate_advertisement_data(local_name=b"Onvis XXX".decode(), rssi=-60) scanner_a.inject_advertisement(device_a, adv_a) cached_first = manager._name_cache[address] device_b = generate_ble_device(address, b"Onvis XXX".decode(), {}, rssi=-50) adv_b = generate_advertisement_data( local_name=b"Onvis XXX".decode(), manufacturer_data={1: b"\x01"}, rssi=-50, ) scanner_b.inject_advertisement(device_b, adv_b) # Cache value unchanged; original object is preserved (no rewrite). assert manager._name_cache[address] is cached_first for c in cancels: c() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_local_passive_scanner_advertisement_rebuilt_with_cached_name() -> None: """ Local passive scanner's AdvertisementData is rebuilt with the cached name. HaScanner.on_advertisement (scanner.py) pre-sets service_info._advertisement to bleak's AdvertisementData, so without invalidation the patched service_info.name would not propagate to advertisement.local_name for bleak callbacks. Mimics that producer shape via inject_advertisement_with_source (which also pre-sets _advertisement) and verifies the dispatch path rebuilds the AdvertisementData with the patched name. """ manager = get_manager() address = "44:44:33:11:23:58" # Seed the cache from an active scanner. active_device = generate_ble_device(address, "Onvis XXX", {}, rssi=-60) active_adv = generate_advertisement_data(local_name="Onvis XXX", rssi=-60) inject_advertisement_with_source(active_device, active_adv, "active-source") assert manager._name_cache[address] == "Onvis XXX" # Local passive scanner sees the same device without a name; its # AdvertisementData has local_name = None and is pre-built on # service_info._advertisement (matches HaScanner.on_advertisement). passive_device = generate_ble_device(address, None, {}, rssi=-30) passive_adv = generate_advertisement_data( local_name=None, manufacturer_data={123: b"\xde\xad"}, rssi=-30, ) inject_advertisement_with_source(passive_device, passive_adv, "passive-source") patched = manager._all_history[address] assert patched.name == "Onvis XXX" assert patched.device.name == "Onvis XXX" # _advertisement was invalidated on patch, so the lazy rebuild picks # up the canonical name; bleak callbacks now see it. assert patched.advertisement.local_name == "Onvis XXX" @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_truncation_from_second_scanner_patched_back() -> None: """ Scanner reporting a truncated name has its service_info patched back. Exercises the post-update patch branch in _handle_name_cache_miss: _update_name_cache decides to keep the longer cached value, then the incoming service_info is patched back to that cached value so the dispatch carries the canonical name. """ manager = get_manager() connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) active = _SeedFakeScanner("esp32-full", "esp32-full", connector, True) secondary = _SeedFakeScanner("esp32-trunc", "esp32-trunc", connector, True) cancels = [ active.async_setup(), manager.async_register_scanner(active), secondary.async_setup(), manager.async_register_scanner(secondary), ] address = "44:44:33:11:23:57" active_device = generate_ble_device(address, "Onvis XXX", {}, rssi=-60) active_adv = generate_advertisement_data(local_name="Onvis XXX", rssi=-60) active.inject_advertisement(active_device, active_adv) assert manager._name_cache[address] == "Onvis XXX" # Secondary scanner sees only the truncated name (e.g. no SCAN_RSP yet). trunc_device = generate_ble_device(address, "Onv", {}, rssi=-50) trunc_adv = generate_advertisement_data( local_name="Onv", manufacturer_data={1: b"\x01"}, rssi=-50 ) secondary.inject_advertisement(trunc_device, trunc_adv) # Cache keeps the longer name and the dispatched view is patched back. assert manager._name_cache[address] == "Onvis XXX" assert manager._all_history[address].name == "Onvis XXX" assert manager._all_history[address].device.name == "Onvis XXX" for c in cancels: c() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_rename_replaces_across_sources() -> None: """A genuine rename (not a prefix relationship) replaces the cached name.""" manager = get_manager() address = "44:44:33:11:23:52" device_a = generate_ble_device(address, "Onv", {}, rssi=-60) adv_a = generate_advertisement_data(local_name="Onv", rssi=-60) inject_advertisement_with_source(device_a, adv_a, "source-a") assert manager._name_cache[address] == "Onv" device_b = generate_ble_device(address, "Donkey", {}, rssi=-60) adv_b = generate_advertisement_data( local_name="Donkey", manufacturer_data={1: b"\x01"}, rssi=-60, ) inject_advertisement_with_source(device_b, adv_b, "source-b") assert manager._name_cache[address] == "Donkey" assert manager._all_history[address].name == "Donkey" # --------------------------------------------------------------------------- # Eviction # --------------------------------------------------------------------------- @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_async_clear_advertisement_history_evicts_cache() -> None: """async_clear_advertisement_history removes the name cache entry.""" manager = get_manager() address = "44:44:33:11:23:53" device = generate_ble_device(address, "Onvis XXX", {}, rssi=-60) adv = generate_advertisement_data(local_name="Onvis XXX", rssi=-60) inject_advertisement_with_source(device, adv, "source") assert address in manager._name_cache manager.async_clear_advertisement_history(address) assert address not in manager._name_cache @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_disappearance_evicts_cache() -> None: """A device that disappears via _async_check_unavailable evicts its cache entry.""" manager = get_manager() address = "44:44:33:11:23:54" device = generate_ble_device(address, "Onvis XXX", {}, rssi=-60) adv = generate_advertisement_data(local_name="Onvis XXX", rssi=-60) inject_advertisement_with_source(device, adv, "source") assert address in manager._name_cache 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() assert address not in manager._name_cache # --------------------------------------------------------------------------- # Seed on load from per-scanner persisted state # --------------------------------------------------------------------------- @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_restore_discovered_devices_seeds_cache() -> None: """ Restoring a scanner's persisted history seeds the shared name cache. Subsequent passive-scanner ads on the same address inherit the previously-known name without waiting for an active scanner to re-observe it. """ manager = get_manager() connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) src_scanner = _SeedFakeScanner("esp32-src", "esp32-src", connector, True) src_unsetup = src_scanner.async_setup() src_cancel = manager.async_register_scanner(src_scanner) address = "44:44:33:11:23:55" device = generate_ble_device(address, "Onvis XXX", {}, rssi=-60) adv = generate_advertisement_data(local_name="Onvis XXX", rssi=-60) src_scanner.inject_advertisement(device, adv) history = src_scanner.serialize_discovered_devices() # Fresh cache state; restore should re-populate it. manager._name_cache.pop(address, None) assert address not in manager._name_cache dst_scanner = _SeedFakeScanner("esp32-dst", "esp32-dst", connector, True) dst_unsetup = dst_scanner.async_setup() dst_cancel = manager.async_register_scanner(dst_scanner) dst_scanner.restore_discovered_devices(history) assert manager._name_cache[address] == "Onvis XXX" src_cancel() src_unsetup() dst_cancel() dst_unsetup() Bluetooth-Devices-habluetooth-75cbe37/tests/test_scanner.py000066400000000000000000003337201521117704500242130ustar00rootroot00000000000000"""Tests for the Bluetooth integration scanners.""" import asyncio import logging import platform import time from collections.abc import Generator, Iterable from datetime import timedelta from typing import Any from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch import pytest from bleak import BleakError from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback from bleak_retry_connector import Allocations, BleakSlotManager from habluetooth import ( SCANNER_WATCHDOG_INTERVAL, SCANNER_WATCHDOG_TIMEOUT, BaseHaScanner, BluetoothManager, BluetoothScanningMode, BluetoothServiceInfoBleak, HaScanner, HaScannerType, ScannerStartError, get_manager, scanner, set_manager, ) from habluetooth.channels.bluez import ( BluetoothMGMTProtocol, MGMTBluetoothCtl, ) from habluetooth.scanner import ( InvalidMessageError, bytes_mac_to_str, create_bleak_scanner, make_bluez_details, ) from . import ( MockBleakScanner, async_fire_time_changed, generate_advertisement_data, generate_ble_device, patch_bleak_scanner_factory, patch_bluetooth_time, utcnow, ) from .conftest import FakeBluetoothAdapters, MockBluetoothManagerWithCallbacks DEVICE_FOUND = 0x0012 ADV_MONITOR_DEVICE_FOUND = 0x002F IS_WINDOWS = 'os.name == "nt"' IS_POSIX = 'os.name == "posix"' NOT_POSIX = 'os.name != "posix"' # or_patterns is a workaround for the fact that passive scanning # needs at least one matcher to be set. The below matcher # will match all devices. if platform.system() == "Linux": # On Linux, use the real BlueZScannerArgs to avoid mocking issues from bleak.args.bluez import BlueZScannerArgs, OrPattern from bleak.assigned_numbers import AdvertisementDataType scanner.PASSIVE_SCANNER_ARGS = BlueZScannerArgs( or_patterns=[ OrPattern(0, AdvertisementDataType.FLAGS, b"\x02"), OrPattern(0, AdvertisementDataType.FLAGS, b"\x06"), OrPattern(0, AdvertisementDataType.FLAGS, b"\x1a"), ] ) else: # On other platforms ``bleak.args.bluez`` may not be importable. Use a # non-empty real mapping that mimics the Linux shape so the production # code's ``if bluez_args:`` truthy check still adds the ``bluez`` kwarg. scanner.PASSIVE_SCANNER_ARGS = {"or_patterns": [(0, 0x01, b"\x06")]} # If the adapter is in a stuck state the following errors are raised: NEED_RESET_ERRORS = [ "org.bluez.Error.Failed", "org.bluez.Error.InProgress", "org.bluez.Error.NotReady", "not found", ] @pytest.fixture(autouse=True, scope="module") def disable_stop_discovery(): """Disable stop discovery.""" with ( patch("habluetooth.scanner.stop_discovery"), patch("habluetooth.scanner.restore_discoveries"), ): yield @pytest.fixture def force_linux_scanner_mode() -> Generator[None, None, None]: """ Force scanner.IS_LINUX=True / IS_MACOS=False for AUTO-flow tests. Lets the active-window toggle path run on any host: the toggle is gated on IS_LINUX (BlueZ-only private attribute), and AUTO on macOS short-circuits to permanent active. """ with ( patch("habluetooth.scanner.IS_LINUX", True), patch("habluetooth.scanner.IS_MACOS", False), ): yield @pytest.fixture(autouse=True, scope="module") def manager(): """Return the BluetoothManager instance.""" adapters = FakeBluetoothAdapters() slot_manager = BleakSlotManager() manager = BluetoothManager(adapters, slot_manager) set_manager(manager) return manager @pytest.fixture def mock_btmgmt_socket(): """Mock the btmgmt_socket module.""" with patch("habluetooth.channels.bluez.btmgmt_socket") as mock_btmgmt: mock_socket = Mock() # Make the socket look like a real socket with a file descriptor mock_socket.fileno.return_value = 99 mock_btmgmt.open.return_value = mock_socket yield mock_btmgmt def test_bytes_mac_to_str() -> None: """Test bytes_mac_to_str.""" assert bytes_mac_to_str(b"\xff\xee\xdd\xcc\xbb\xaa") == "AA:BB:CC:DD:EE:FF" assert bytes_mac_to_str(b"\xff\xee\xdd\xcc\xbb\xaa") == "AA:BB:CC:DD:EE:FF" def test_make_bluez_details() -> None: """Test make_bluez_details.""" assert make_bluez_details("AA:BB:CC:DD:EE:FF", "hci0") == { "path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF", "props": {"Adapter": "/org/bluez/hci0"}, } def test_create_bleak_scanner_linux_no_adapter_active() -> None: """Linux + no adapter + active: ``bluez`` kwarg must be absent.""" with ( patch.object(scanner, "IS_LINUX", True), patch.object(scanner, "IS_MACOS", False), patch("habluetooth.scanner.OriginalBleakScanner") as mock_scanner, ): create_bleak_scanner(None, BluetoothScanningMode.ACTIVE, None) kwargs = mock_scanner.call_args.kwargs assert "bluez" not in kwargs assert "adapter" not in kwargs def test_create_bleak_scanner_linux_no_adapter_passive() -> None: """Linux + no adapter + passive: ``bluez`` carries passive args only.""" with ( patch.object(scanner, "IS_LINUX", True), patch.object(scanner, "IS_MACOS", False), patch("habluetooth.scanner.OriginalBleakScanner") as mock_scanner, ): create_bleak_scanner(None, BluetoothScanningMode.PASSIVE, None) bluez = mock_scanner.call_args.kwargs["bluez"] assert "adapter" not in bluez # PASSIVE args are copied in — the production dict must not be mutated. assert bluez == dict(scanner.PASSIVE_SCANNER_ARGS) def test_create_bleak_scanner_linux_adapter_active() -> None: """Linux + adapter + active: ``bluez`` carries adapter only.""" with ( patch.object(scanner, "IS_LINUX", True), patch.object(scanner, "IS_MACOS", False), patch("habluetooth.scanner.OriginalBleakScanner") as mock_scanner, ): create_bleak_scanner(None, BluetoothScanningMode.ACTIVE, "hci2") bluez = mock_scanner.call_args.kwargs["bluez"] assert bluez == {"adapter": "hci2"} def test_create_bleak_scanner_linux_adapter_passive() -> None: """Linux + adapter + passive: ``bluez`` merges adapter and passive args.""" with ( patch.object(scanner, "IS_LINUX", True), patch.object(scanner, "IS_MACOS", False), patch("habluetooth.scanner.OriginalBleakScanner") as mock_scanner, ): create_bleak_scanner(None, BluetoothScanningMode.PASSIVE, "hci1") bluez = mock_scanner.call_args.kwargs["bluez"] assert bluez.get("adapter") == "hci1" # The production code must copy PASSIVE_SCANNER_ARGS — assert the source # was not mutated by the adapter insertion. assert "adapter" not in scanner.PASSIVE_SCANNER_ARGS @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, ): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() with pytest.raises( ScannerStartError, match="DBus service not found; docker config may be missing", ): await scanner.async_start() assert mock_stop.called @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, ): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() with pytest.raises( ScannerStartError, match="DBus service not found; make sure the DBus socket is available", ): await scanner.async_start() assert mock_stop.called @pytest.mark.asyncio @pytest.mark.skipif(NOT_POSIX) async def test_handle_cancellation(caplog: pytest.LogCaptureFixture) -> None: """Test cancellation stops.""" with ( patch("habluetooth.scanner.is_docker_env", return_value=False), patch( "habluetooth.scanner.OriginalBleakScanner.start", side_effect=asyncio.CancelledError, ), patch( "habluetooth.scanner.OriginalBleakScanner.stop", ) as mock_stop, ): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() with pytest.raises(asyncio.CancelledError): await scanner.async_start() assert mock_stop.called @pytest.mark.asyncio @pytest.mark.skipif(NOT_POSIX) async def test_handle_stop_while_starting(caplog: pytest.LogCaptureFixture) -> None: """Test stop while starting.""" async def _start(*args, **kwargs): await asyncio.sleep(1000) with ( patch("habluetooth.scanner.is_docker_env", return_value=False), patch("habluetooth.scanner.OriginalBleakScanner.start", _start), patch( "habluetooth.scanner.OriginalBleakScanner.stop", ) as mock_stop, ): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() task = asyncio.create_task(scanner.async_start()) await asyncio.sleep(0) await asyncio.sleep(0) await scanner.async_stop() with pytest.raises( ScannerStartError, match="Starting bluetooth scanner aborted" ): await task assert mock_stop.called @pytest.mark.asyncio @pytest.mark.skipif(NOT_POSIX) async def test_dbus_broken_pipe_in_container(caplog: pytest.LogCaptureFixture) -> None: """Test we handle dbus broken pipe in the container.""" with ( patch("habluetooth.scanner.is_docker_env", return_value=True), patch( "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BrokenPipeError, ), patch( "habluetooth.scanner.OriginalBleakScanner.stop", ) as mock_stop, ): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() with pytest.raises(ScannerStartError, match="DBus connection broken"): await scanner.async_start() assert mock_stop.called @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, ): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() with pytest.raises(ScannerStartError, match="DBus connection broken:"): await scanner.async_start() assert mock_stop.called @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, ): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() with pytest.raises(ScannerStartError, match="Invalid DBus message received"): await scanner.async_start() @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 _DBusRecoveryScanner(MockBleakScanner): async def start(self, *args: object, **kwargs: object) -> None: nonlocal called_start called_start += 1 if called_start < 3: raise BleakError(error) async def stop(self, *args: object, **kwargs: object) -> None: nonlocal called_stop called_stop += 1 @property def discovered_devices(self): return mock_discovered def register_detection_callback( self, callback: AdvertisementDataCallback ) -> None: nonlocal _callback _callback = callback mock_scanner = _DBusRecoveryScanner() 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 _CallbackCapturingScanner(MockBleakScanner): def __init__( self, detection_callback: AdvertisementDataCallback, *args: object, **kwargs: object, ) -> None: super().__init__() nonlocal _callback _callback = detection_callback async def start(self, *args: object, **kwargs: object) -> None: nonlocal called_start called_start += 1 async def stop(self, *args: object, **kwargs: object) -> None: nonlocal called_stop called_stop += 1 @property def discovered_devices(self): return mock_discovered with patch( "habluetooth.scanner.OriginalBleakScanner", _CallbackCapturingScanner, ): 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[misc] 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 _AdapterRecoveryScanner(MockBleakScanner): async def start(self, *args: object, **kwargs: object) -> None: nonlocal called_start called_start += 1 async def stop(self, *args: object, **kwargs: object) -> None: nonlocal called_stop called_stop += 1 @property def discovered_devices(self): return mock_discovered def register_detection_callback( self, callback: AdvertisementDataCallback ) -> None: nonlocal _callback _callback = callback mock_scanner = _AdapterRecoveryScanner() 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 _RestartFailScanner(MockBleakScanner): async def start(self, *args: object, **kwargs: object) -> None: nonlocal called_start called_start += 1 if called_start == 1: return if called_start < 4: msg = "Failed to start" raise BleakError(msg) async def stop(self, *args: object, **kwargs: object) -> None: nonlocal called_stop called_stop += 1 @property def discovered_devices(self): return mock_discovered def register_detection_callback( self, callback: AdvertisementDataCallback ) -> None: nonlocal _callback _callback = callback mock_scanner = _RestartFailScanner() 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 _DBusInProgressScanner(MockBleakScanner): async def start(self, *args: object, **kwargs: object) -> None: nonlocal called_start called_start += 1 if called_start == 1: msg = "org.freedesktop.DBus.Error.UnknownObject" raise BleakError(msg) if called_start == 2: msg = "org.bluez.Error.InProgress" raise BleakError(msg) if called_start == 3: msg = "org.bluez.Error.InProgress" raise BleakError(msg) async def stop(self, *args: object, **kwargs: object) -> None: nonlocal called_stop called_stop += 1 @property def discovered_devices(self): return mock_discovered def register_detection_callback( self, callback: AdvertisementDataCallback ) -> None: nonlocal _callback _callback = callback mock_scanner = _DBusInProgressScanner() 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 _ReleaseGatedScanner(MockBleakScanner): async def start(self, *args: object, **kwargs: object) -> None: nonlocal called_start called_start += 1 if called_start == 1: return await release_start_event.wait() mock_scanner = _ReleaseGatedScanner() 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 _KwargsCapturingScanner(MockBleakScanner): def __init__(self, *args: object, **kwargs: object) -> None: super().__init__() nonlocal init_kwargs init_kwargs = kwargs with patch( "habluetooth.scanner.OriginalBleakScanner", _KwargsCapturingScanner, ): 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 _InProgressFatalScanner(MockBleakScanner): async def start(self, *args: object, **kwargs: object) -> None: nonlocal called_start called_start += 1 if called_start == 1: msg = "org.freedesktop.DBus.Error.UnknownObject" raise BleakError(msg) if called_start == 2: msg = "org.bluez.Error.InProgress" raise BleakError(msg) if called_start == 3: msg = "org.bluez.Error.InProgress" raise BleakError(msg) async def stop(self, *args: object, **kwargs: object) -> None: nonlocal called_stop called_stop += 1 @property def discovered_devices(self): return mock_discovered def register_detection_callback( self, callback: AdvertisementDataCallback ) -> None: nonlocal _callback _callback = callback mock_scanner = _InProgressFatalScanner() 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", "connect_failures": {}, "connect_in_progress": {}, "connect_completed_total": 0, "connect_failed_total": 0, "last_connect_completed_time": 0.0, "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", "connect_failures": {}, "connect_in_progress": {}, "connect_completed_total": 0, "connect_failed_total": 0, "last_connect_completed_time": 0.0, "connectable": True, "current_mode": BluetoothScanningMode.PASSIVE, "discovered_devices_and_advertisement_data": [], "last_detection": ANY, "monotonic_time": ANY, "name": "hci0 (AA:BB:CC:DD:EE:FF)", "requested_mode": BluetoothScanningMode.ACTIVE, "scanning": False, "source": "AA:BB:CC:DD:EE:FF", "start_time": ANY, "type": "HaScanner", } @pytest.mark.asyncio @pytest.mark.skipif(NOT_POSIX) async def test_scanner_with_bluez_mgmt_side_channel(mock_btmgmt_socket: Mock) -> None: """Test scanner receiving advertisements via BlueZ management side channel.""" # Mock capability check for the entire test with patch.object(MGMTBluetoothCtl, "_check_capabilities", return_value=True): # Create a custom manager that tracks discovered devices class TestBluetoothManager(BluetoothManager): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.discovered_infos = [] def _discover_service_info( self, service_info: BluetoothServiceInfoBleak ) -> None: """Track discovered service info.""" self.discovered_infos.append(service_info) # Create manager and setup mgmt controller adapters = FakeBluetoothAdapters() slot_manager = BleakSlotManager() manager = TestBluetoothManager(adapters, slot_manager) set_manager(manager) # Set up the manager first await manager.async_setup() # Create and setup the mgmt controller with the manager's side channel scanners mgmt_ctl = MGMTBluetoothCtl( timeout=5.0, scanners=manager._side_channel_scanners ) # Mock the protocol setup mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_transport = Mock() mock_protocol.transport = mock_transport async def mock_setup(): mgmt_ctl.protocol = mock_protocol mgmt_ctl._on_connection_lost_future = ( asyncio.get_running_loop().create_future() ) mgmt_ctl.setup = mock_setup # type: ignore[method-assign] # Inject mgmt controller into manager manager._mgmt_ctl = mgmt_ctl manager.has_advertising_side_channel = True # Verify get_bluez_mgmt_ctl returns our controller assert manager.get_bluez_mgmt_ctl() is mgmt_ctl # Register scanner scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() manager.async_register_scanner(scanner, connection_slots=2) # Start scanner - should be created without detection callback with patch("habluetooth.scanner.OriginalBleakScanner") as mock_scanner_class: mock_scanner = Mock() mock_scanner.start = AsyncMock() mock_scanner.stop = AsyncMock() mock_scanner.discovered_devices = [] mock_scanner_class.return_value = mock_scanner await scanner.async_start() # Verify scanner was created without detection callback # since side channel is available mock_scanner_class.assert_called_once() call_kwargs = mock_scanner_class.call_args[1] assert ( "detection_callback" not in call_kwargs or call_kwargs["detection_callback"] is None ) # Now simulate advertisement data coming through the mgmt protocol # The manager should have registered the scanner with mgmt_ctl assert 0 in mgmt_ctl.scanners # hci0 is index 0 assert mgmt_ctl.scanners[0] is scanner # Simulate the protocol calling the scanner's raw advertisement handler test_address = b"\xaa\xbb\xcc\xdd\xee\xff" test_rssi = -60 test_flags = 0x06 # Create valid advertisement data with flags # Each AD structure is: length (1 byte), type (1 byte), data test_data = ( b"\x02\x01\x06" # Length=2, Type=0x01 (Flags), Data=0x06 # Length=8, Type=0x09 (Complete Local Name), Data="TestDev" b"\x08\x09TestDev" ) # Call the method that the protocol would call scanner._async_on_raw_bluez_advertisement( test_address, 1, # address_type: BDADDR_LE_PUBLIC test_rssi, test_flags, test_data, ) # Allow time for processing await asyncio.sleep(0) # Verify the device was discovered in the base scanner assert len(scanner._previous_service_info) == 1 assert "FF:EE:DD:CC:BB:AA" in scanner._previous_service_info service_info = scanner._previous_service_info["FF:EE:DD:CC:BB:AA"] assert service_info.address == "FF:EE:DD:CC:BB:AA" assert service_info.rssi == test_rssi assert service_info.name == "TestDev" # Verify the manager also received the advertisement assert len(manager.discovered_infos) == 1 assert manager.discovered_infos[0] is service_info await scanner.async_stop() manager.async_stop() @pytest.mark.asyncio @pytest.mark.skipif(NOT_POSIX) async def test_scanner_without_bluez_mgmt_side_channel() -> None: """Test scanner uses normal detection callback when side channel unavailable.""" # Create manager without BlueZ mgmt support class TestBluetoothManager(BluetoothManager): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.discovered_infos = [] def _discover_service_info( self, service_info: BluetoothServiceInfoBleak ) -> None: """Track discovered service info.""" self.discovered_infos.append(service_info) adapters = FakeBluetoothAdapters() slot_manager = BleakSlotManager() manager = TestBluetoothManager(adapters, slot_manager) set_manager(manager) # Setup without mgmt controller await manager.async_setup() assert manager.has_advertising_side_channel is False # Register scanner scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() manager.async_register_scanner(scanner, connection_slots=2) # Start scanner - should be created with detection callback with patch("habluetooth.scanner.OriginalBleakScanner") as mock_scanner_class: mock_scanner = Mock() mock_scanner.start = AsyncMock() mock_scanner.stop = AsyncMock() mock_scanner.discovered_devices = [] mock_scanner_class.return_value = mock_scanner await scanner.async_start() # Verify scanner was created with detection callback since no side channel mock_scanner_class.assert_called_once() call_kwargs = mock_scanner_class.call_args[1] assert "detection_callback" in call_kwargs assert call_kwargs["detection_callback"] is not None assert call_kwargs["detection_callback"] == scanner._async_detection_callback await scanner.async_stop() manager.async_stop() @pytest.mark.asyncio @pytest.mark.skipif(NOT_POSIX) async def test_bluez_mgmt_protocol_data_flow(mock_btmgmt_socket: Mock) -> None: """Test data flow from BlueZ protocol through manager to scanner.""" # Mock capability check for the entire test with patch.object(MGMTBluetoothCtl, "_check_capabilities", return_value=True): # Create manager class TestBluetoothManager(BluetoothManager): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.discovered_infos = [] def _discover_service_info( self, service_info: BluetoothServiceInfoBleak ) -> None: """Track discovered service info.""" self.discovered_infos.append(service_info) adapters = FakeBluetoothAdapters() slot_manager = BleakSlotManager() manager = TestBluetoothManager(adapters, slot_manager) set_manager(manager) # Set up manager first await manager.async_setup() # Create mgmt controller with the manager's side channel scanners dictionary mgmt_ctl = MGMTBluetoothCtl( timeout=5.0, scanners=manager._side_channel_scanners ) # We'll capture the protocol when it's created captured_protocol: BluetoothMGMTProtocol | None = None async def mock_create_connection(sock, protocol_factory, *args, **kwargs): nonlocal captured_protocol captured_protocol = protocol_factory() mock_transport = Mock() captured_protocol.connection_made(mock_transport) return mock_transport, captured_protocol with patch.object( asyncio.get_running_loop(), "_create_connection_transport", mock_create_connection, ): await mgmt_ctl.setup() # Set mgmt controller on manager manager._mgmt_ctl = mgmt_ctl manager.has_advertising_side_channel = True # Register scanners for hci0 and hci1 scanner0 = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:00") scanner0.async_setup() manager.async_register_scanner(scanner0, connection_slots=2) scanner1 = HaScanner(BluetoothScanningMode.ACTIVE, "hci1", "AA:BB:CC:DD:EE:01") scanner1.async_setup() manager.async_register_scanner(scanner1, connection_slots=2) # Start scanners with patch("habluetooth.scanner.OriginalBleakScanner") as mock_scanner_class: mock_scanner = Mock() mock_scanner.start = AsyncMock() mock_scanner.stop = AsyncMock() mock_scanner.discovered_devices = [] mock_scanner_class.return_value = mock_scanner await scanner0.async_start() await scanner1.async_start() # Verify scanners are registered in mgmt_ctl assert 0 in mgmt_ctl.scanners assert 1 in mgmt_ctl.scanners assert mgmt_ctl.scanners[0] is scanner0 assert mgmt_ctl.scanners[1] is scanner1 # Test DEVICE_FOUND event for hci0 test_address = b"\x11\x22\x33\x44\x55\x66" rssi_byte = b"\xc4" # -60 in signed byte event_data = ( test_address + b"\x01" # address_type + rssi_byte + b"\x06\x00\x00\x00" # flags + b"\x03\x00" # data_len + b"\x02\x01\x06" # minimal adv data ) packet = ( DEVICE_FOUND.to_bytes(2, "little") + b"\x00\x00" # controller_idx 0 (hci0) + len(event_data).to_bytes(2, "little") + event_data ) # Feed packet to protocol assert captured_protocol is not None captured_protocol.data_received(packet) # Verify device discovered on scanner0 only assert len(scanner0._previous_service_info) == 1 assert "66:55:44:33:22:11" in scanner0._previous_service_info assert len(scanner1._previous_service_info) == 0 # Test ADV_MONITOR_DEVICE_FOUND event for hci1 test_address2 = b"\xaa\xbb\xcc\xdd\xee\x02" monitor_handle = b"\x01\x00" rssi_byte2 = b"\xba" # -70 in signed byte event_data2 = ( monitor_handle + test_address2 + b"\x02" # address_type (random) + rssi_byte2 + b"\x06\x00\x00\x00" # flags + b"\x03\x00" # data_len + b"\x02\x01\x06" # minimal adv data ) packet2 = ( ADV_MONITOR_DEVICE_FOUND.to_bytes(2, "little") + b"\x01\x00" # controller_idx 1 (hci1) + len(event_data2).to_bytes(2, "little") + event_data2 ) assert captured_protocol is not None captured_protocol.data_received(packet2) # Verify device discovered on scanner1 only assert len(scanner0._previous_service_info) == 1 # Still just the first device assert len(scanner1._previous_service_info) == 1 assert "02:EE:DD:CC:BB:AA" in scanner1._previous_service_info # Verify RSSI values info0 = scanner0._previous_service_info["66:55:44:33:22:11"] assert info0.rssi == -60 info1 = scanner1._previous_service_info["02:EE:DD:CC:BB:AA"] assert info1.rssi == -70 await scanner0.async_stop() await scanner1.async_stop() manager.async_stop() @pytest.mark.asyncio @pytest.mark.skipif(NOT_POSIX) async def test_mgmt_permission_error_fallback() -> None: """Test that permission errors in MGMT setup fall back to BlueZ-only mode.""" # Create manager class TestBluetoothManager(BluetoothManager): def _discover_service_info( self, service_info: BluetoothServiceInfoBleak ) -> None: """Track discovered service info.""" adapters = FakeBluetoothAdapters() slot_manager = BleakSlotManager() manager = TestBluetoothManager(adapters, slot_manager) # Mock MGMTBluetoothCtl setup to raise PermissionError with ( patch("habluetooth.manager.MGMTBluetoothCtl") as mock_mgmt_cls, patch("habluetooth.manager.IS_LINUX", True), ): mock_mgmt = Mock() mock_mgmt.setup = AsyncMock( side_effect=PermissionError( "Missing NET_ADMIN/NET_RAW capabilities for Bluetooth management" ) ) mock_mgmt_cls.return_value = mock_mgmt # Setup should complete without raising the exception await manager.async_setup() # Verify MGMT was attempted but then set to None mock_mgmt.setup.assert_called_once() assert manager._mgmt_ctl is None assert manager.has_advertising_side_channel is False def test_usb_scanner_type() -> None: """Test that USB adapters get USB scanner type.""" manager = get_manager() # Mock cached adapters with USB adapter mock_adapters: dict[str, dict[str, Any]] = { "hci0": { "address": "00:1A:7D:DA:71:04", "adapter_type": "usb", "manufacturer": "TestManufacturer", "product": "USB Bluetooth Adapter", } } with patch.object(manager, "_adapters", mock_adapters): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04") assert scanner.details.scanner_type is HaScannerType.USB def test_uart_scanner_type() -> None: """Test that UART adapters get UART scanner type.""" manager = get_manager() # Mock cached adapters with UART adapter mock_adapters: dict[str, dict[str, Any]] = { "hci0": { "address": "00:1A:7D:DA:71:04", "adapter_type": "uart", "manufacturer": "TestManufacturer", "product": "UART Bluetooth Module", } } with patch.object(manager, "_adapters", mock_adapters): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04") assert scanner.details.scanner_type is HaScannerType.UART def test_unknown_scanner_type_no_cached_adapters() -> None: """Test that scanners get UNKNOWN type when no adapter info is cached.""" manager = get_manager() # No cached adapters with patch.object(manager, "_adapters", None): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04") assert scanner.details.scanner_type is HaScannerType.UNKNOWN def test_unknown_scanner_type_adapter_not_found() -> None: """Test that scanners get UNKNOWN type when adapter is not in cache.""" manager = get_manager() # Cached adapters but not the one we're looking for mock_adapters: dict[str, dict[str, Any]] = { "hci1": { "address": "11:22:33:44:55:66", "adapter_type": "usb", } } with patch.object(manager, "_adapters", mock_adapters): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04") assert scanner.details.scanner_type is HaScannerType.UNKNOWN def test_unknown_scanner_type_no_adapter_type() -> None: """Test that scanners get UNKNOWN type when adapter_type is None.""" manager = get_manager() # Cached adapter without adapter_type field mock_adapters: dict[str, dict[str, Any]] = { "hci0": { "address": "00:1A:7D:DA:71:04", "adapter_type": None, "manufacturer": "TestManufacturer", } } with patch.object(manager, "_adapters", mock_adapters): scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04") assert scanner.details.scanner_type is HaScannerType.UNKNOWN @pytest.mark.asyncio async def test_scanner_type_with_real_adapter_data() -> None: """Test scanner type detection with realistic adapter data.""" # Create a custom manager for this test manager = BluetoothManager(bluetooth_adapters=MagicMock()) set_manager(manager) # Simulate real USB adapter data from Linux usb_adapter_data: dict[str, dict[str, Any]] = { "hci0": { "address": "00:1A:7D:DA:71:04", "sw_version": "homeassistant", "hw_version": "usb:v1D6Bp0246d053F", "passive_scan": False, "manufacturer": "XTech", "product": "Bluetooth 4.0 USB Adapter", "vendor_id": "0a12", "product_id": "0001", "adapter_type": "usb", } } manager._adapters = usb_adapter_data # Create USB scanner usb_scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04") assert usb_scanner.details.scanner_type is HaScannerType.USB assert usb_scanner.details.adapter == "hci0" # Simulate real UART adapter data uart_adapter_data: dict[str, dict[str, Any]] = { "hci1": { "address": "AA:BB:CC:DD:EE:FF", "sw_version": "homeassistant", "hw_version": "uart:ttyUSB0", "passive_scan": False, "manufacturer": "cyber-blue(HK)Ltd", "product": "Bluetooth 4.0 UART Module", "vendor_id": None, "product_id": None, "adapter_type": "uart", } } manager._adapters = uart_adapter_data # Create UART scanner uart_scanner = HaScanner(BluetoothScanningMode.PASSIVE, "hci1", "AA:BB:CC:DD:EE:FF") assert uart_scanner.details.scanner_type is HaScannerType.UART assert uart_scanner.details.adapter == "hci1" # Test with macOS/Windows adapter (no adapter_type) macos_adapter_data = { "Core Bluetooth": { "address": "00:00:00:00:00:00", "passive_scan": False, "sw_version": "18.7.0", "manufacturer": "Apple", "product": "Unknown MacOS Model", "vendor_id": "Unknown", "product_id": "Unknown", "adapter_type": None, } } manager._adapters = macos_adapter_data # Create scanner with unknown adapter type macos_scanner = HaScanner( BluetoothScanningMode.ACTIVE, "Core Bluetooth", "00:00:00:00:00:00" ) assert macos_scanner.details.scanner_type is HaScannerType.UNKNOWN @pytest.mark.asyncio async def test_scanner_type_updates_after_adapter_refresh() -> None: """Test scanner type is UNKNOWN initially, determined after adapters load.""" # Create a custom manager for this test manager = BluetoothManager(bluetooth_adapters=MagicMock()) set_manager(manager) # Initially no adapters cached manager._adapters = None # type: ignore[assignment] # Create scanner - should be UNKNOWN scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04") assert scanner.details.scanner_type is HaScannerType.UNKNOWN # Now simulate adapter data becoming available manager._adapters = { "hci0": { "address": "00:1A:7D:DA:71:04", "adapter_type": "usb", "manufacturer": "TestManufacturer", } } # Create a new scanner with the same adapter - should now be USB scanner2 = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04") assert scanner2.details.scanner_type is HaScannerType.USB # Note: The first scanner still has UNKNOWN since scanner_type is set at init assert scanner.details.scanner_type is HaScannerType.UNKNOWN def test_multiple_scanner_types_simultaneously() -> None: """Test that multiple scanners can have different types at the same time.""" manager = get_manager() # Set up adapters with different types mock_adapters = { "hci0": { "address": "00:1A:7D:DA:71:04", "adapter_type": "usb", }, "hci1": { "address": "AA:BB:CC:DD:EE:FF", "adapter_type": "uart", }, "hci2": { "address": "11:22:33:44:55:66", "adapter_type": None, }, } with patch.object(manager, "_adapters", mock_adapters): # Create scanners of different types usb_scanner = HaScanner( BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04" ) uart_scanner = HaScanner( BluetoothScanningMode.ACTIVE, "hci1", "AA:BB:CC:DD:EE:FF" ) unknown_scanner = HaScanner( BluetoothScanningMode.ACTIVE, "hci2", "11:22:33:44:55:66" ) # Verify each has the correct type assert usb_scanner.details.scanner_type is HaScannerType.USB assert uart_scanner.details.scanner_type is HaScannerType.UART assert unknown_scanner.details.scanner_type is HaScannerType.UNKNOWN # Verify they all have different types types = { usb_scanner.details.scanner_type, uart_scanner.details.scanner_type, unknown_scanner.details.scanner_type, } assert len(types) == 3 # All different def test_ha_scanner_get_allocations_no_slot_manager() -> None: """Test HaScanner.get_allocations returns None when manager has no slot_manager.""" scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") manager = get_manager() # Mock slot_manager as None with patch.object(manager, "slot_manager", None): assert scanner.get_allocations() is None def test_ha_scanner_get_allocations_with_slot_manager() -> None: """Test HaScanner.get_allocations returns allocation info from BleakSlotManager.""" scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") manager = get_manager() # Create mock allocations mock_allocations = Allocations( adapter="hci0", slots=5, free=3, allocated=["11:22:33:44:55:66", "AA:BB:CC:DD:EE:FF"], ) # Mock slot_manager mock_slot_manager = Mock(spec=BleakSlotManager) mock_slot_manager.get_allocations.return_value = mock_allocations with patch.object(manager, "slot_manager", mock_slot_manager): allocations = scanner.get_allocations() assert allocations is not None assert allocations == mock_allocations mock_slot_manager.get_allocations.assert_called_once_with("hci0") def test_ha_scanner_get_allocations_updates_dynamically() -> None: """Test that HaScanner.get_allocations returns current values as they change.""" scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") manager = get_manager() # Mock slot_manager mock_slot_manager = Mock(spec=BleakSlotManager) # Initial state - 3 free slots mock_slot_manager.get_allocations.return_value = Allocations( adapter="hci0", slots=3, free=3, allocated=[] ) with patch.object(manager, "slot_manager", mock_slot_manager): # Check initial state allocations = scanner.get_allocations() assert allocations is not None assert allocations.free == 3 assert allocations.allocated == [] # Update mock to simulate connection made mock_slot_manager.get_allocations.return_value = Allocations( adapter="hci0", slots=3, free=2, allocated=["11:22:33:44:55:66"] ) # Check updated state allocations = scanner.get_allocations() assert allocations is not None assert allocations.free == 2 assert allocations.allocated == ["11:22:33:44:55:66"] # Update mock to simulate another connection mock_slot_manager.get_allocations.return_value = Allocations( adapter="hci0", slots=3, free=1, allocated=["11:22:33:44:55:66", "AA:BB:CC:DD:EE:FF"], ) # Check final state allocations = scanner.get_allocations() assert allocations is not None assert allocations.free == 1 assert len(allocations.allocated) == 2 @pytest.mark.asyncio async def test_on_scanner_start_callback( async_mock_manager_with_scanner_callbacks: MockBluetoothManagerWithCallbacks, ) -> None: """Test that on_scanner_start is called when a local scanner starts.""" manager = async_mock_manager_with_scanner_callbacks # Create a local scanner (it will get the manager from get_manager()) scanner = HaScanner( mode=BluetoothScanningMode.ACTIVE, adapter="hci0", address="00:00:00:00:00:00", ) # Register scanner with manager manager.async_register_scanner(scanner) # Setup the scanner scanner.async_setup() # Directly call _on_start_success to test the callback # (In real usage, this is called by HaScanner._async_start_attempt # after successful start) scanner._on_start_success() # Verify the callback was called assert len(manager.scanner_start_calls) == 1 assert manager.scanner_start_calls[0] is scanner @pytest.mark.asyncio async def test_async_request_active_window_rejected_when_not_auto() -> None: """Non-AUTO scanners ignore active-window requests and return False.""" scanner = HaScanner(BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() assert await scanner.async_request_active_window(1.0) is False assert scanner._scan_mode_override is None @pytest.mark.asyncio @pytest.mark.parametrize("duration", [float("nan"), float("inf"), -1.0, 0.0]) async def test_async_request_active_window_rejects_invalid_duration( duration: float, ) -> None: """ NaN/inf/non-positive durations are refused at the entry point. A bad duration would poison ``loop.call_later`` (which raises on NaN) and the extension comparison (NaN ordering is always False, inf would lock the window open). Guard the public entry so a misbehaving subclass / direct caller can't corrupt the scheduler state. """ scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() assert await scanner.async_request_active_window(duration) is False assert scanner._scan_mode_override is None assert scanner._active_window_handle is None @pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_request_active_window_restarts_scanner_in_active_mode() -> None: """An AUTO scanner flips to ACTIVE and schedules a return to the prior mode.""" starts: list[str] = [] def _factory(*_args, **kwargs): starts.append(kwargs["scanning_mode"]) return MockBleakScanner() with patch("habluetooth.scanner.OriginalBleakScanner", side_effect=_factory): scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() # Initial construction: AUTO maps to passive in bleak's # scanning_mode. The active-window toggle path reuses # this single BleakScanner instance and just mutates # _backend._scanning_mode instead of constructing again. assert starts == ["passive"] backend = scanner.scanner._backend # type: ignore[union-attr] backend._scanning_mode = "passive" # Tiny duration so call_later fires on the next loop turn. # async_request_active_window rejects 0/NaN/inf at the boundary, # so we use the smallest positive value that round-trips through # the timer arithmetic. assert await scanner.async_request_active_window(1e-9) is True # The toggle flipped the existing instance to active. assert backend._scanning_mode == "active" assert scanner._scan_mode_override is BluetoothScanningMode.ACTIVE assert scanner._active_window_handle is not None # Let the call_later fire and the background restart task complete. for _ in range(6): await asyncio.sleep(0) # End-of-window toggled the same instance back to passive. assert backend._scanning_mode == "passive" assert scanner._scan_mode_override is None assert scanner._active_window_handle is None # type: ignore[unreachable] await scanner.async_stop() @pytest.mark.asyncio async def test_active_window_restart_does_not_log_fallback_warning( caplog: pytest.LogCaptureFixture, ) -> None: """ A successful active-window restart on an AUTO scanner must not warn. Regression: the start-success log compared current_mode against requested_mode. For an AUTO scanner mid-active-window, requested_mode is AUTO but current_mode is ACTIVE (because the restart was triggered by the scheduler with _scan_mode_override=ACTIVE), so the previous code logged a spurious "fell back to passive" warning on every active-window restart. The check now uses effective_mode (the mode we tried to start in) so it only triggers on a real fallback. """ with patch( "habluetooth.scanner.OriginalBleakScanner", side_effect=lambda *_a, **_kw: MockBleakScanner(), ): scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() caplog.clear() with caplog.at_level(logging.WARNING): assert await scanner.async_request_active_window(10.0) is True assert not any( "fall-back to passive" in record.message for record in caplog.records ) await scanner.async_stop() @pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_toggle_active_window_mode_returns_false_when_no_scanner() -> None: """The toggle helper bails when the scanner instance is gone.""" scanner_obj = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") scanner_obj.async_setup() assert scanner_obj.scanner is None assert await scanner_obj._async_toggle_active_window_mode() is False @pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_toggle_active_window_mode_returns_false_on_stop_error() -> None: """The toggle helper logs and bails when scanner.stop() raises.""" class StopErrorMockBleakScanner(MockBleakScanner): async def stop(self) -> None: msg = "simulated stop failure" raise BleakError(msg) with patch_bleak_scanner_factory(StopErrorMockBleakScanner): scanner_obj = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") scanner_obj.async_setup() await scanner_obj.async_start() scanner_obj._scan_mode_override = BluetoothScanningMode.ACTIVE assert scanner_obj.scanning is True assert await scanner_obj._async_toggle_active_window_mode() is False # scanner.stop() raised so the bleak scanner is in an # undefined state; the wrapper must reflect that as not- # scanning so the caller's fallback path treats it correctly. assert scanner_obj.scanning is False @pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_toggle_active_window_mode_marks_not_scanning_on_start_error() -> ( None ): """ Toggle's start-error path also clears self.scanning. The stop succeeded but the post-mode-flip start raised, so the bleak scanner is stopped. self.scanning must follow. """ starts = 0 class StartErrorMockBleakScanner(MockBleakScanner): async def start(self) -> None: nonlocal starts starts += 1 # First start (initial async_start) succeeds; the # post-flip start (second call) raises. if starts > 1: msg = "simulated start failure" raise BleakError(msg) with patch_bleak_scanner_factory(StartErrorMockBleakScanner): scanner_obj = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") scanner_obj.async_setup() await scanner_obj.async_start() scanner_obj._scan_mode_override = BluetoothScanningMode.ACTIVE assert scanner_obj.scanning is True assert await scanner_obj._async_toggle_active_window_mode() is False assert scanner_obj.scanning is False @pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_toggle_active_window_mode_attribute_error_marks_not_scanning() -> ( None ): """ Toggle gracefully handles bleak refactoring away ``_scanning_mode``. If a future bleak version drops or renames ``_backend._scanning_mode``, the mutation raises AttributeError. The stop has already completed, so without a guard the scanner would be left stopped and the caller would have no signal to fall back to the full path. The guard logs, clears ``self.scanning``, and returns False so the caller can recover via the full restart path. """ class MockBackend: @property def _scanning_mode(self) -> str: msg = "simulated bleak refactor — attribute removed" raise AttributeError(msg) @_scanning_mode.setter def _scanning_mode(self, value: str) -> None: msg = "simulated bleak refactor — attribute removed" raise AttributeError(msg) class AttrErrorMockBleakScanner(MockBleakScanner): def __init__(self) -> None: self._backend = MockBackend() with patch_bleak_scanner_factory(AttrErrorMockBleakScanner): scanner_obj = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") scanner_obj.async_setup() await scanner_obj.async_start() scanner_obj._scan_mode_override = BluetoothScanningMode.ACTIVE assert scanner_obj.scanning is True assert await scanner_obj._async_toggle_active_window_mode() is False assert scanner_obj.scanning is False @pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_arm_active_window_timer_cancels_existing_handle() -> None: """ _arm_active_window_timer cancels any prior handle before arming. Regression for the concurrent-callers race noted in PR review: two concurrent ``async_request_active_window`` calls could both reach _arm_active_window_timer without the second cancelling the first's TimerHandle, leaking a pending timer that would later fire an extra _async_end_active_window. Today only the scheduler drives the public method (and _tick serializes per worker) so the race isn't reachable through normal callers, but the contract on _arm_active_window_timer must defend against it. """ with patch( "habluetooth.scanner.OriginalBleakScanner", side_effect=lambda *_a, **_kw: MockBleakScanner(), ): scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() # Arm a window so there's a handle to potentially leak. assert await scanner.async_request_active_window(100.0) is True first_handle = scanner._active_window_handle assert first_handle is not None # Directly call _arm again (simulating the race-path second # caller). The first handle must be cancelled, not leaked. scanner._arm_active_window_timer(50.0) assert first_handle.cancelled() assert scanner._active_window_handle is not first_handle await scanner.async_stop() @pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_request_active_window_extends_existing_window() -> None: """A second request inside an active window extends the timer in place.""" starts: list[str] = [] def _factory(*_args, **kwargs): starts.append(kwargs["scanning_mode"]) return MockBleakScanner() with patch("habluetooth.scanner.OriginalBleakScanner", side_effect=_factory): scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() assert await scanner.async_request_active_window(100.0) is True first_handle = scanner._active_window_handle first_end = scanner._active_window_end # A longer request extends the existing window without a second restart. assert await scanner.async_request_active_window(200.0) is True assert scanner._active_window_handle is not first_handle assert scanner._active_window_end > first_end # Only one BleakScanner construction happened (the initial # passive one). The active-window flip toggles the existing # instance's _backend._scanning_mode instead of creating a # new scanner. assert starts == ["passive"] assert scanner.current_mode is BluetoothScanningMode.ACTIVE # A shorter follow-up is a no-op on the timer. kept_end = scanner._active_window_end assert await scanner.async_request_active_window(0.001) is True assert scanner._active_window_end == kept_end await scanner.async_stop() @pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_request_active_window_end_time_matches_real_timer() -> None: """ _active_window_end reflects the post-restart loop.time() + duration. Regression: a slow stop/restart cycle previously left ``_active_window_end`` set to ``loop.time() + duration`` captured *before* the restart, so it lagged the real ``call_later`` fire time by the restart duration. The fix moved the ``_active_window_end`` computation inside ``_arm_active_window_timer`` so it always matches when the timer will actually fire. Uses an asyncio.Event to gate the restart-in-progress deterministically rather than relying on asyncio.sleep precision, which can fire slightly early on busy CI runners. """ duration = 10.0 restart_started = asyncio.Event() gate = asyncio.Event() class GatedMockBleakScanner(MockBleakScanner): _first_start_done = False async def start(self) -> None: if not type(self)._first_start_done: type(self)._first_start_done = True return restart_started.set() await gate.wait() with patch_bleak_scanner_factory(GatedMockBleakScanner): scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() loop = asyncio.get_running_loop() before = loop.time() task = asyncio.create_task(scanner.async_request_active_window(duration)) await restart_started.wait() # Provably advance loop.time() past `before` before the restart # completes; the exact amount doesn't matter for the assertion # below as long as loop.time() has visibly moved. await asyncio.sleep(0.05) elapsed = loop.time() - before gate.set() assert await task is True # Contract: _active_window_end matches loop.time() + duration # measured AFTER the restart, not before. Pre-fix it would be # before + duration. Allow generous tolerance for the small # gap between arming and reading. now = loop.time() assert scanner._active_window_end == pytest.approx(now + duration, abs=0.1) # Reject pre-fix value (before + duration) explicitly with a # margin well above asyncio scheduling jitter: the stored end # is at least ``elapsed`` ahead of before + duration. assert scanner._active_window_end - before - duration >= elapsed / 2 first_handle = scanner._active_window_handle # A follow-up whose new_end lands between the pre-fix stored # end and the real fire time must NOT be treated as an # extension. With the fix this is rejected; without it the # live timer would be cancelled and armed shorter. target_new_end = before + duration + elapsed / 2 shorter_duration = target_new_end - loop.time() assert await scanner.async_request_active_window(shorter_duration) is True assert scanner._active_window_handle is first_handle await scanner.async_stop() @pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_request_active_window_skips_restart_if_still_active() -> None: """ Re-arm the timer instead of restarting if the scanner is still ACTIVE. A new request arriving after the end-of-window timer fires but before the bg task runs reuses the in-flight ACTIVE mode and just arms a new timer. """ starts: list[str] = [] def _factory(*_args, **kwargs): starts.append(kwargs["scanning_mode"]) return MockBleakScanner() with patch("habluetooth.scanner.OriginalBleakScanner", side_effect=_factory): scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() # Single construction (passive); toggle reuses the instance. assert starts == ["passive"] backend = scanner.scanner._backend # type: ignore[union-attr] backend._scanning_mode = "passive" assert await scanner.async_request_active_window(100.0) is True # Toggle flipped the existing instance to active. assert backend._scanning_mode == "active" # Simulate the timer firing but the end-window task not having # run yet: clear the handle (like _schedule_end_active_window # does) but leave _scan_mode_override / current_mode == ACTIVE. handle = scanner._active_window_handle assert handle is not None handle.cancel() scanner._active_window_handle = None # Scanner is still ACTIVE; a longer follow-up re-arms the # timer without flipping the radio again. A shorter follow-up # would no-op the timer (covered by # test_async_request_active_window_still_active_does_not_shrink). assert await scanner.async_request_active_window(200.0) is True assert scanner._active_window_handle is not None # Mode unchanged: no toggle happened on the still-ACTIVE path. assert backend._scanning_mode == "active" # type: ignore[unreachable] # Still only one BleakScanner construction. assert starts == ["passive"] await scanner.async_stop() @pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_request_active_window_still_active_does_not_shrink() -> None: """ Concurrent shorter caller into the still-ACTIVE locked branch is a no-op. Regression: the locked early-return at the top of ``async_request_active_window``'s lock block re-armed the timer unconditionally when ``current_mode is ACTIVE``. A second caller with a shorter duration could shrink an in-flight window someone else asked for. Guarded with the same ``loop.time() + duration > _active_window_end`` check the lockless fast-path uses. """ with patch( "habluetooth.scanner.OriginalBleakScanner", side_effect=lambda *_, **__: MockBleakScanner(), ): scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() # Open a long window so _active_window_handle is set and # current_mode is ACTIVE. assert await scanner.async_request_active_window(100.0) is True long_end = scanner._active_window_end long_handle = scanner._active_window_handle # Simulate the timer firing without _async_end_active_window # running yet: clear the handle so the locked branch is # reachable (lockless fast path needs handle is not None). assert long_handle is not None long_handle.cancel() scanner._active_window_handle = None # Concurrent shorter caller now hits the locked # current_mode-is-ACTIVE branch. Pre-fix this would re-arm # at end = now + 5 (shrinking the live window); post-fix the # stored end-time stays put and the timer isn't re-armed. assert await scanner.async_request_active_window(5.0) is True assert scanner._active_window_end == long_end await scanner.async_stop() @pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_stop_clears_active_window_state() -> None: """Stopping mid-window cancels the timer and clears the override.""" with patch( "habluetooth.scanner.OriginalBleakScanner", side_effect=lambda *_, **__: MockBleakScanner(), ): scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() await scanner.async_request_active_window(100.0) assert scanner._active_window_handle is not None await scanner.async_stop() assert scanner._active_window_handle is None assert scanner._scan_mode_override is None # type: ignore[unreachable] assert scanner._active_window_end == 0.0 @pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_request_active_window_recovers_on_start_failure() -> None: """If the ACTIVE restart raises, recovery brings the scanner back up.""" call_count = 0 fail_until = 0 class _CountingFailScanner(MockBleakScanner): async def start(self) -> None: nonlocal call_count call_count += 1 if call_count <= fail_until: msg = "simulated start failure" raise BleakError(msg) with patch_bleak_scanner_factory(_CountingFailScanner): scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() before = call_count # Fail the next 4 start attempts so the ACTIVE swap raises; # then succeed so the recovery restart can come back up. fail_until = call_count + 4 result = await scanner.async_request_active_window(1.0) assert result is False assert scanner._scan_mode_override is None # Recovery restart happened after the failure path. assert call_count > before + 4 await scanner.async_stop() @pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_request_active_window_clears_override_on_unexpected_error() -> ( None ): """ An unexpected exception from the restart clears _scan_mode_override. Regression: only ScannerStartError was caught explicitly, so any other exception propagating from _async_stop_then_start_under_lock would leave _scan_mode_override = ACTIVE. The next _async_start_attempt would then see effective_mode = ACTIVE instead of AUTO, poisoning subsequent starts. """ start_count = 0 class _UnexpectedErrorAfterFirstScanner(MockBleakScanner): async def start(self) -> None: nonlocal start_count start_count += 1 # First start (initial async_start) succeeds; second start # (the ACTIVE restart from async_request_active_window) # raises a non-ScannerStartError so we exercise the # broad-except cleanup path. if start_count > 1: msg = "simulated unexpected error" raise RuntimeError(msg) with patch_bleak_scanner_factory(_UnexpectedErrorAfterFirstScanner): scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() with pytest.raises(RuntimeError, match="simulated unexpected error"): await scanner.async_request_active_window(1.0) # The override must be cleared even though the exception # wasn't a ScannerStartError. assert scanner._scan_mode_override is None await scanner.async_stop() @pytest.mark.asyncio async def test_base_scanner_default_active_window_is_noop( caplog: pytest.LogCaptureFixture, ) -> None: """BaseHaScanner.async_request_active_window default returns False.""" class _PlainScanner(BaseHaScanner): @property def discovered_devices(self) -> list[BLEDevice]: return [] @property def discovered_devices_and_advertisement_data( self, ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: return {} def get_discovered_device_advertisement_data( self, address: str ) -> tuple[BLEDevice, AdvertisementData] | None: return None @property def discovered_addresses(self) -> Iterable[str]: return () scanner = _PlainScanner("AA:BB:CC:DD:EE:FF", "plain") with caplog.at_level(logging.DEBUG, logger="habluetooth"): result = await scanner.async_request_active_window(1.0) assert result is False assert any( "does not support on-demand active windows" in record.message for record in caplog.records ) @pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_end_active_window_defers_to_new_window() -> None: """If a new window armed the timer, the end-window task returns early.""" with patch( "habluetooth.scanner.OriginalBleakScanner", side_effect=lambda *_, **__: MockBleakScanner(), ): scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() await scanner.async_request_active_window(3600.0) # Simulate a new window taking over by leaving the handle in place # and call _async_end_active_window directly; it must short-circuit. assert scanner._active_window_handle is not None await scanner._async_end_active_window() # Override and handle untouched because we deferred to the new window. assert scanner._scan_mode_override == BluetoothScanningMode.ACTIVE assert scanner._active_window_handle is not None await scanner.async_stop() @pytest.mark.asyncio async def test_async_end_active_window_skips_when_not_scanning() -> None: """If the scanner was stopped during the window the restart is skipped.""" with patch( "habluetooth.scanner.OriginalBleakScanner", side_effect=lambda *_, **__: MockBleakScanner(), ): scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() await scanner.async_request_active_window(3600.0) # Pretend the end-window timer just fired (handle cleared) and the # scanner was stopped in the meantime. scanner._active_window_handle = None scanner.scanning = False # Should be a quick no-op: clears override, sees not scanning, returns. await scanner._async_end_active_window() assert scanner._scan_mode_override is None scanner.scanning = True await scanner.async_stop() @pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_request_active_window_passive_fallback_on_linux() -> None: """If the swap restart falls back to PASSIVE on Linux, request returns False.""" starts = 0 class _PassiveFallbackScanner(MockBleakScanner): async def start(self) -> None: nonlocal starts starts += 1 # Fail the first three attempts so the 4th-attempt PASSIVE # fallback inside _async_start_attempt kicks in. if 2 <= starts <= 4: msg = "simulated active failure" raise BleakError(msg) with ( patch("habluetooth.scanner.IS_LINUX", True), patch_bleak_scanner_factory(_PassiveFallbackScanner), patch("habluetooth.scanner.async_reset_adapter", AsyncMock()), patch("habluetooth.scanner.ADAPTER_INIT_TIME", 0), ): scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() result = await scanner.async_request_active_window(1.0) # The swap ran through to the 4th attempt and fell back to PASSIVE; # the request reports False because the scanner is not ACTIVE. assert result is False assert scanner._scan_mode_override is None await scanner.async_stop() @pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_async_end_active_window_handles_start_error( caplog: pytest.LogCaptureFixture, ) -> None: """ScannerStartError during the end-of-window restart logs a warning.""" starts = 0 fail_until = 0 class _FailUntilThresholdScanner(MockBleakScanner): async def start(self) -> None: nonlocal starts starts += 1 if starts <= fail_until: msg = "simulated end-window failure" raise BleakError(msg) with patch_bleak_scanner_factory(_FailUntilThresholdScanner): scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF") scanner.async_setup() await scanner.async_start() try: # Open a long active window then drive end-of-window # with the bleak start mocked to fail. await scanner.async_request_active_window(3600.0) assert scanner._active_window_handle is not None # Fail enough start() calls that BOTH the toggle attempt # and every retry in the fallback _async_start cycle # raise, so we exercise the "Failed to restart scanner # after active window" warning. fail_until = starts + 100 scanner._active_window_handle.cancel() scanner._active_window_handle = None caplog.clear() with caplog.at_level(logging.WARNING): await scanner._async_end_active_window() assert any( "Failed to restart scanner after active window" in record.message for record in caplog.records ) finally: # Allow the fallback restart to succeed for teardown, # then stop the scanner so we don't leak the watchdog # timer / background tasks into later tests. fail_until = 0 await scanner.async_stop() @pytest.mark.parametrize("exc", [FileNotFoundError("no dbus"), BleakError("nope")]) def test_create_bleak_scanner_wraps_init_error(exc: Exception) -> None: """``create_bleak_scanner`` wraps FileNotFoundError/BleakError as RuntimeError.""" with ( patch.object(scanner, "IS_LINUX", True), patch.object(scanner, "IS_MACOS", False), patch( "habluetooth.scanner.OriginalBleakScanner", side_effect=exc, ), pytest.raises(RuntimeError, match="Failed to initialize Bluetooth"), ): create_bleak_scanner(None, BluetoothScanningMode.ACTIVE, "hci0") @pytest.mark.asyncio @pytest.mark.skipif(NOT_POSIX) @pytest.mark.parametrize("exc", [TimeoutError("slow"), BleakError("nope")]) async def test_async_stop_scanner_logs_when_scanner_stop_raises( caplog: pytest.LogCaptureFixture, exc: Exception ) -> None: """``_async_stop_scanner`` logs and clears the scanner when ``.stop()`` raises.""" mock_scanner = MagicMock() mock_scanner.start = AsyncMock() mock_scanner.stop = AsyncMock(side_effect=exc) with 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() caplog.clear() await scanner.async_stop() assert "Error stopping scanner" in caplog.text assert scanner.scanner is None @pytest.mark.asyncio @pytest.mark.skipif(NOT_POSIX) async def test_async_force_stop_discovery_logs_on_timeout( caplog: pytest.LogCaptureFixture, ) -> None: """Force-stop logs an error when ``stop_discovery`` times out.""" ha_scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") ha_scanner.async_setup() with patch("habluetooth.scanner.stop_discovery", side_effect=TimeoutError("slow")): await ha_scanner._async_force_stop_discovery() assert "Timeout force stopping scanner" in caplog.text await ha_scanner.async_stop() @pytest.mark.asyncio @pytest.mark.skipif(NOT_POSIX) async def test_async_force_stop_discovery_logs_on_unexpected_error( caplog: pytest.LogCaptureFixture, ) -> None: """Force-stop logs an error when ``stop_discovery`` raises an unexpected error.""" ha_scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") ha_scanner.async_setup() with patch("habluetooth.scanner.stop_discovery", side_effect=BleakError("boom")): await ha_scanner._async_force_stop_discovery() assert "Failed to force stop scanner" in caplog.text await ha_scanner.async_stop() @pytest.mark.asyncio async def test_get_allocations_returns_none_without_slot_manager() -> None: """``HaScanner.get_allocations`` returns None when manager has no slot manager.""" ha_scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF") ha_scanner.async_setup() with patch.object(get_manager(), "slot_manager", None): assert ha_scanner.get_allocations() is None await ha_scanner.async_stop() @pytest.mark.asyncio async def test_discovered_properties_delegate_when_scanner_attached() -> None: """Discovered* delegate to the underlying bleak scanner when one is attached.""" device = generate_ble_device("AA:BB:CC:DD:EE:01", "x") adv = generate_advertisement_data(local_name="x") class _DiscoveredScanner(MockBleakScanner): @property def discovered_devices(self): return [device] @property def discovered_devices_and_advertisement_data(self): return {device.address: (device, adv)} with patch_bleak_scanner_factory(_DiscoveredScanner): ha_scanner = HaScanner( BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:FF" ) ha_scanner.async_setup() await ha_scanner.async_start() try: assert ha_scanner.discovered_devices == [device] assert ha_scanner.discovered_devices_and_advertisement_data == { device.address: (device, adv) } assert ha_scanner.get_discovered_device_advertisement_data( device.address ) == (device, adv) assert device.address in list(ha_scanner.discovered_addresses) finally: await ha_scanner.async_stop() @pytest.mark.asyncio async def test_detection_callback_coerces_non_str_name_and_non_int_tx_power() -> None: """ Defensive coercion: bleak occasionally returns non-str names / non-int tx_power. The advertisement path normalizes both so downstream code can rely on plain Python str / int rather than bytes-likes or numpy ints. Inspects ``manager._all_history`` (populated by ``_scanner_adv_received``) since the cython method itself isn't monkey-patchable. """ ha_scanner = HaScanner(BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:FF") ha_scanner.async_setup() class _StrSubclass(str): """str subclass — `type() is not str` so coercion fires.""" __slots__ = () class _IntLike: """Non-int that ``int()`` accepts (e.g. numpy.int64 stand-in).""" def __int__(self) -> int: return -7 address = "AA:BB:CC:DD:EE:F0" device = generate_ble_device(address, None) adv = generate_advertisement_data( local_name=_StrSubclass("weird"), tx_power=_IntLike(), ) ha_scanner._async_detection_callback(device, adv) info = get_manager()._all_history[address] # Both fields were coerced to the canonical Python type. assert type(info.name) is str assert info.name == "weird" assert type(info.tx_power) is int assert info.tx_power == -7 await ha_scanner.async_stop() @pytest.mark.asyncio async def test_start_attempt_timeout_resets_then_raises_on_exhaustion( caplog: pytest.LogCaptureFixture, ) -> None: """ Persistent start TimeoutError: attempt 2 resets the adapter, attempt 4 raises. Covers the in-between `attempt < START_ATTEMPTS` return-False branch (logged as a retry warning) and the `attempt == START_ATTEMPTS` raise. Subclasses HaScanner because the cython type is immutable so a bare patch.object can't override its methods. """ reset_calls: list[bool] = [] class _RecordingResetScanner(HaScanner): async def _async_reset_adapter(self, gone_silent: bool) -> None: reset_calls.append(gone_silent) class TimeoutMockBleakScanner(MockBleakScanner): async def start(self) -> None: msg = "simulated start timeout" raise TimeoutError(msg) with patch_bleak_scanner_factory(TimeoutMockBleakScanner): ha_scanner = _RecordingResetScanner( BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:FF" ) ha_scanner.async_setup() with ( caplog.at_level(logging.DEBUG, logger="habluetooth.scanner"), pytest.raises(ScannerStartError, match="Timed out starting Bluetooth"), ): await ha_scanner.async_start() # Attempt 2 (gone_silent=False) triggered exactly one adapter reset. assert reset_calls == [False] await ha_scanner.async_stop() @pytest.mark.asyncio async def test_async_restart_scanner_logs_when_start_raises( caplog: pytest.LogCaptureFixture, ) -> None: """ Watchdog restart swallows + logs ``ScannerStartError`` from ``_async_start``. Covers the ``except ScannerStartError`` branch in ``_async_restart_scanner`` so a single restart failure doesn't propagate out of the background task. Uses a subclass to override methods because cython types are immutable. """ class _RaisingRestartScanner(HaScanner): async def _async_start(self) -> None: msg = "simulated restart failure" raise ScannerStartError(msg) async def _async_stop_scanner(self) -> None: pass async def _async_reset_adapter(self, gone_silent: bool) -> None: pass ha_scanner = _RaisingRestartScanner( BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:FF" ) ha_scanner.async_setup() with caplog.at_level(logging.ERROR, logger="habluetooth.scanner"): await ha_scanner._async_restart_scanner() assert "Failed to restart Bluetooth scanner" in caplog.text assert "simulated restart failure" in caplog.text @pytest.fixture def force_non_linux_non_macos_scanner_mode() -> Generator[None, None, None]: """Force the non-Linux, non-macOS branch of the active-window entry.""" with ( patch("habluetooth.scanner.IS_LINUX", False), patch("habluetooth.scanner.IS_MACOS", False), ): yield @pytest.mark.usefixtures("force_non_linux_non_macos_scanner_mode") @pytest.mark.asyncio async def test_async_request_active_window_restart_path_happy() -> None: """Non-Linux active-window entry: full stop+restart leaves scanner in ACTIVE.""" with patch_bleak_scanner_factory(MockBleakScanner): ha_scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:F1") ha_scanner.async_setup() await ha_scanner.async_start() try: assert await ha_scanner.async_request_active_window(0.5) is True assert ha_scanner.current_mode is BluetoothScanningMode.ACTIVE assert ha_scanner._scan_mode_override is BluetoothScanningMode.ACTIVE assert ha_scanner._active_window_handle is not None finally: await ha_scanner.async_stop() @pytest.mark.usefixtures("force_non_linux_non_macos_scanner_mode") @pytest.mark.asyncio async def test_async_request_active_window_restart_path_scanner_start_error() -> None: """ Non-Linux entry: a ``ScannerStartError`` triggers the abort recovery path. ``_async_begin_active_window_via_restart`` catches the error and routes through ``_async_abort_active_window`` so the scanner comes back up in its underlying mode instead of being left stopped. """ starts = 0 class AlwaysFailAfterFirstMockBleakScanner(MockBleakScanner): async def start(self) -> None: nonlocal starts starts += 1 # First start (initial async_start) succeeds; every # subsequent start raises so all 4 retries of the # restart attempt exhaust, raising ScannerStartError, # which the abort path then suppresses. if starts > 1: msg = "simulated start failure" raise BleakError(msg) class _NoResetScanner(HaScanner): async def _async_reset_adapter(self, gone_silent: bool) -> None: pass with patch_bleak_scanner_factory(AlwaysFailAfterFirstMockBleakScanner): ha_scanner = _NoResetScanner( BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:F2" ) ha_scanner.async_setup() await ha_scanner.async_start() try: assert await ha_scanner.async_request_active_window(0.5) is False # Abort cleared the override; no end-of-window timer armed. assert ha_scanner._scan_mode_override is None assert ha_scanner._active_window_handle is None finally: await ha_scanner.async_stop() @pytest.mark.usefixtures("force_non_linux_non_macos_scanner_mode") @pytest.mark.asyncio async def test_async_request_active_window_restart_path_unexpected_error() -> None: """ Non-Linux entry: unexpected exceptions clear the override and re-raise. Mirrors the toggle-path test for the restart-path ``except BaseException`` branch in ``_async_begin_active_window_via_restart``: a non-ScannerStartError must not poison ``_scan_mode_override`` for the next start. """ raise_on_restart = False class _MaybeRaiseRestartScanner(HaScanner): async def _async_stop_then_start_under_lock(self) -> None: if raise_on_restart: msg = "simulated unexpected error" raise RuntimeError(msg) with patch_bleak_scanner_factory(MockBleakScanner): ha_scanner = _MaybeRaiseRestartScanner( BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:F4" ) ha_scanner.async_setup() await ha_scanner.async_start() try: raise_on_restart = True with pytest.raises(RuntimeError, match="simulated unexpected error"): await ha_scanner.async_request_active_window(0.5) assert ha_scanner._scan_mode_override is None finally: raise_on_restart = False await ha_scanner.async_stop() @pytest.mark.asyncio async def test_detection_callback_skips_last_detection_for_empty_advertisement() -> ( None ): """ Empty advertisements don't bump ``_last_detection``. Bleak occasionally hands us a callback with no name / data / service info, which we treat as a heartbeat from a failing adapter rather than a real ping. """ ha_scanner = HaScanner(BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:FF") ha_scanner.async_setup() ha_scanner._last_detection = -1.0 device = generate_ble_device("AA:BB:CC:DD:EE:F5", None) # All four fields explicitly empty so the truthy-check skips the # _last_detection bump (generate_advertisement_data defaults # local_name to "Unknown", which would otherwise trip it). adv = generate_advertisement_data( local_name=None, manufacturer_data={}, service_data={}, service_uuids=[], ) ha_scanner._async_detection_callback(device, adv) assert ha_scanner._last_detection == -1.0 await ha_scanner.async_stop() @pytest.mark.usefixtures("force_non_linux_non_macos_scanner_mode") @pytest.mark.asyncio async def test_async_request_active_window_restart_path_mode_mismatch() -> None: """ Non-Linux entry: a restart that doesn't land in ACTIVE clears the override. Simulates a backend that ignores ``_scan_mode_override`` by patching ``_async_stop_then_start_under_lock`` at the class level so it leaves ``current_mode`` unchanged at PASSIVE. The branch must clear the override and return False so the public method can report failure to the scheduler. """ noop_restart = False class _NoopRestartScanner(HaScanner): async def _async_stop_then_start_under_lock(self) -> None: if not noop_restart: await HaScanner._async_stop_then_start_under_lock(self) with patch_bleak_scanner_factory(MockBleakScanner): ha_scanner = _NoopRestartScanner( BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:F3" ) ha_scanner.async_setup() await ha_scanner.async_start() # Pretend a previous start left current_mode at PASSIVE. ha_scanner.set_current_mode(BluetoothScanningMode.PASSIVE) try: noop_restart = True assert await ha_scanner.async_request_active_window(0.5) is False assert ha_scanner._scan_mode_override is None assert ha_scanner._active_window_handle is None finally: noop_restart = False await ha_scanner.async_stop() @pytest.mark.asyncio async def test_describe_side_channel_state_no_adapter_idx() -> None: """Non-hci adapter names have no MGMT index so report the fallback path.""" ha_scanner = HaScanner( BluetoothScanningMode.PASSIVE, "Core Bluetooth", "AA:BB:CC:DD:EE:01" ) assert ( ha_scanner._describe_side_channel_state() == "no adapter_idx; bleak detection_callback path" ) @pytest.mark.asyncio async def test_describe_side_channel_state_no_side_channel() -> None: """When the manager never brought MGMT up we fall back to bleak's callback.""" manager = get_manager() manager.has_advertising_side_channel = False ha_scanner = HaScanner(BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:02") assert ( ha_scanner._describe_side_channel_state() == "MGMT side channel unavailable; bleak detection_callback path" ) @pytest.mark.asyncio async def test_describe_side_channel_state_unregistered() -> None: """Side channel is alive but the scanner isn't in _side_channel_scanners.""" manager = get_manager() manager.has_advertising_side_channel = True manager._side_channel_scanners.clear() ha_scanner = HaScanner(BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:03") assert ( ha_scanner._describe_side_channel_state() == "MGMT side channel up but hci0 unregistered" ) @pytest.mark.asyncio async def test_describe_side_channel_state_bound_to_other_scanner() -> None: """Another scanner owns the hciN slot in _side_channel_scanners.""" manager = get_manager() manager.has_advertising_side_channel = True other = HaScanner(BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:04") manager._side_channel_scanners[0] = other ha_scanner = HaScanner(BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:05") try: assert ( ha_scanner._describe_side_channel_state() == "MGMT side channel at hci0 bound to a different scanner" ) finally: manager._side_channel_scanners.clear() @pytest.mark.asyncio async def test_describe_side_channel_state_protocol_down() -> None: """Scanner is registered but the MGMT ctl is gone — socket died.""" manager = get_manager() manager.has_advertising_side_channel = True ha_scanner = HaScanner(BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:06") manager._side_channel_scanners[0] = ha_scanner manager._mgmt_ctl = None try: assert ( ha_scanner._describe_side_channel_state() == "MGMT side channel registered at hci0 but protocol down" ) finally: manager._side_channel_scanners.clear() @pytest.mark.asyncio async def test_describe_side_channel_state_transport_closed() -> None: """Scanner is registered, protocol exists, but the transport is gone.""" manager = get_manager() manager.has_advertising_side_channel = True ha_scanner = HaScanner(BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:07") manager._side_channel_scanners[0] = ha_scanner mgmt_ctl = Mock() mgmt_ctl.protocol = Mock(spec=BluetoothMGMTProtocol) mgmt_ctl.protocol.transport = None manager._mgmt_ctl = mgmt_ctl try: assert ( ha_scanner._describe_side_channel_state() == "MGMT side channel registered at hci0 but transport closed" ) finally: manager._side_channel_scanners.clear() manager._mgmt_ctl = None @pytest.mark.asyncio async def test_describe_side_channel_state_feeding() -> None: """Happy path: scanner registered, protocol and transport both live.""" manager = get_manager() manager.has_advertising_side_channel = True ha_scanner = HaScanner(BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:08") manager._side_channel_scanners[0] = ha_scanner mgmt_ctl = Mock() mgmt_ctl.protocol = Mock(spec=BluetoothMGMTProtocol) mgmt_ctl.protocol.transport = Mock() manager._mgmt_ctl = mgmt_ctl try: assert ( ha_scanner._describe_side_channel_state() == "MGMT side channel feeding hci0" ) finally: manager._side_channel_scanners.clear() manager._mgmt_ctl = None @pytest.mark.asyncio async def test_scanner_watchdog_log_includes_side_channel_state( caplog: pytest.LogCaptureFixture, ) -> None: """The 'gone quiet' restart log line carries the side-channel diagnostic.""" manager = get_manager() manager.has_advertising_side_channel = True class _NoRestartScanner(HaScanner): # Skip the actual restart so the test just exercises the log path. def _create_background_task(self, coro): coro.close() with patch_bleak_scanner_factory(MockBleakScanner): ha_scanner = _NoRestartScanner( BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:09" ) ha_scanner.async_setup() await ha_scanner.async_start() try: # Force the watchdog over its timeout without producing any # advertisements, and clear out the side-channel registration # to make the diagnostic message distinctive. manager._side_channel_scanners.clear() ha_scanner._last_detection = ( ha_scanner._last_detection - SCANNER_WATCHDOG_TIMEOUT - 1.0 ) with caplog.at_level(logging.DEBUG, logger="habluetooth.scanner"): ha_scanner._async_scanner_watchdog() assert "MGMT side channel up but hci0 unregistered" in caplog.text finally: await ha_scanner.async_stop() @pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_auto_scanner_current_mode_reports_passive_on_linux() -> None: """ AUTO must surface as PASSIVE in current_mode on Linux. AUTO is a habluetooth scheduling concept; the BlueZ radio is actually running in passive until the scheduler opens an active window. Remote scanners (ESPHome) already report the real radio state, and local adapters used to report AUTO and look stuck. """ with patch_bleak_scanner_factory(MockBleakScanner): ha_scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:A0") ha_scanner.async_setup() await ha_scanner.async_start() try: assert ha_scanner.requested_mode is BluetoothScanningMode.AUTO assert ha_scanner.current_mode is BluetoothScanningMode.PASSIVE finally: await ha_scanner.async_stop() @pytest.fixture def force_macos_scanner_mode() -> Generator[None, None, None]: """Force scanner.IS_LINUX=False / IS_MACOS=True for the AUTO->ACTIVE path.""" with ( patch("habluetooth.scanner.IS_LINUX", False), patch("habluetooth.scanner.IS_MACOS", True), ): yield @pytest.mark.usefixtures("force_macos_scanner_mode") @pytest.mark.asyncio async def test_auto_scanner_current_mode_reports_active_on_macos() -> None: """ AUTO must surface as ACTIVE in current_mode on macOS. CoreBluetooth has no passive mode so AUTO collapses to permanent active; current_mode should reflect that, not the AUTO scheduling label. """ with patch_bleak_scanner_factory(MockBleakScanner): ha_scanner = HaScanner( BluetoothScanningMode.AUTO, "Core Bluetooth", "AA:BB:CC:DD:EE:A1" ) ha_scanner.async_setup() await ha_scanner.async_start() try: assert ha_scanner.requested_mode is BluetoothScanningMode.AUTO assert ha_scanner.current_mode is BluetoothScanningMode.ACTIVE finally: await ha_scanner.async_stop() @pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_auto_scanner_current_mode_active_during_active_window() -> None: """ Opening an AUTO active window flips current_mode to ACTIVE. Split from the post-window assertion to avoid mypy narrowing ``current_mode`` to a single literal across both transitions. """ with patch( "habluetooth.scanner.OriginalBleakScanner", side_effect=lambda *_a, **_kw: MockBleakScanner(), ): ha_scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:A2") ha_scanner.async_setup() await ha_scanner.async_start() try: assert await ha_scanner.async_request_active_window(10.0) is True assert ha_scanner.current_mode is BluetoothScanningMode.ACTIVE assert ha_scanner._scan_mode_override is BluetoothScanningMode.ACTIVE finally: await ha_scanner.async_stop() @pytest.mark.usefixtures("force_linux_scanner_mode") @pytest.mark.asyncio async def test_auto_scanner_current_mode_passive_after_active_window() -> None: """ After an AUTO active window ends current_mode returns to PASSIVE. The end-of-window toggle restores the real radio state, which on Linux/BlueZ is passive. current_mode must follow the radio rather than reverting to the AUTO label. """ with patch( "habluetooth.scanner.OriginalBleakScanner", side_effect=lambda *_a, **_kw: MockBleakScanner(), ): ha_scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:A3") ha_scanner.async_setup() await ha_scanner.async_start() try: assert await ha_scanner.async_request_active_window(1e-9) is True for _ in range(6): await asyncio.sleep(0) assert ha_scanner.current_mode is BluetoothScanningMode.PASSIVE assert ha_scanner._scan_mode_override is None finally: await ha_scanner.async_stop() Bluetooth-Devices-habluetooth-75cbe37/tests/test_storage.py000066400000000000000000000443501521117704500242240ustar00rootroot00000000000000import time from unittest.mock import ANY import pytest from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from habluetooth.storage import ( DiscoveredDeviceAdvertisementData, DiscoveredDeviceAdvertisementDataDict, discovered_device_advertisement_data_from_dict, discovered_device_advertisement_data_to_dict, expire_stale_scanner_discovered_device_advertisement_data, ) def test_discovered_device_advertisement_data_to_dict(): """Test discovered device advertisement data to dict.""" result = discovered_device_advertisement_data_to_dict( DiscoveredDeviceAdvertisementData( True, 100, { "AA:BB:CC:DD:EE:FF": ( BLEDevice( address="AA:BB:CC:DD:EE:FF", name="Test Device", details={"details": "test"}, ), AdvertisementData( local_name="Test Device", manufacturer_data={0x004C: b"\x02\x15\xaa\xbb\xcc\xdd\xee\xff"}, tx_power=50, service_data={ "0000180d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00" }, service_uuids=["0000180d-0000-1000-8000-00805f9b34fb"], platform_data=("Test Device", ""), rssi=-50, ), ) }, {"AA:BB:CC:DD:EE:FF": 100000}, ) ) assert result == { "connectable": True, "discovered_device_advertisement_datas": { "AA:BB:CC:DD:EE:FF": { "advertisement_data": { "local_name": "Test Device", "manufacturer_data": {"76": "0215aabbccddeeff"}, "rssi": -50, "service_data": { "0000180d-0000-1000-8000-00805f9b34fb": "00000000" }, "service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"], "tx_power": 50, "platform_data": ["Test Device", ""], }, "device": { "address": "AA:BB:CC:DD:EE:FF", "details": {"details": "test"}, "name": "Test Device", "rssi": -50, # Now included for backward compatibility }, } }, "discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": ANY}, "expire_seconds": 100, "discovered_device_raw": {}, } def test_discovered_device_advertisement_data_from_dict(): now = time.time() result = discovered_device_advertisement_data_from_dict( { "connectable": True, "discovered_device_advertisement_datas": { "AA:BB:CC:DD:EE:FF": { "advertisement_data": { "local_name": "Test Device", "manufacturer_data": {"76": "0215aabbccddeeff"}, "rssi": -50, "service_data": { "0000180d-0000-1000-8000-00805f9b34fb": "00000000" }, "service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"], "tx_power": 50, "platform_data": ["Test Device", ""], }, "device": { "address": "AA:BB:CC:DD:EE:FF", "details": {"details": "test"}, "name": "Test Device", }, # type: ignore[typeddict-item] } }, "discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": now}, "expire_seconds": 100, "discovered_device_raw": { "AA:BB:CC:DD:EE:FF": "0215aabbccddeeff", }, } ) expected_ble_device = BLEDevice( address="AA:BB:CC:DD:EE:FF", name="Test Device", details={"details": "test"}, ) expected_advertisement_data = AdvertisementData( local_name="Test Device", manufacturer_data={0x004C: b"\x02\x15\xaa\xbb\xcc\xdd\xee\xff"}, tx_power=50, service_data={"0000180d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00"}, service_uuids=["0000180d-0000-1000-8000-00805f9b34fb"], platform_data=("Test Device", ""), rssi=-50, ) assert result is not None out_ble_device = result.discovered_device_advertisement_datas["AA:BB:CC:DD:EE:FF"][ 0 ] out_advertisement_data = result.discovered_device_advertisement_datas[ "AA:BB:CC:DD:EE:FF" ][1] assert out_ble_device.address == expected_ble_device.address assert out_ble_device.name == expected_ble_device.name assert out_ble_device.details == expected_ble_device.details # BLEDevice no longer has rssi attribute in bleak 1.0+ # rssi is only available in AdvertisementData assert out_advertisement_data == expected_advertisement_data assert result == DiscoveredDeviceAdvertisementData( connectable=True, expire_seconds=100, discovered_device_advertisement_datas={ "AA:BB:CC:DD:EE:FF": ( ANY, expected_advertisement_data, ) }, discovered_device_timestamps={"AA:BB:CC:DD:EE:FF": ANY}, discovered_device_raw={ "AA:BB:CC:DD:EE:FF": b"\x02\x15\xaa\xbb\xcc\xdd\xee\xff" }, ) def test_expire_stale_scanner_discovered_device_advertisement_data(): """Test expire_stale_scanner_discovered_device_advertisement_data.""" now = time.time() data = { "myscanner": DiscoveredDeviceAdvertisementDataDict( { "connectable": True, "discovered_device_advertisement_datas": { "AA:BB:CC:DD:EE:FF": { "advertisement_data": { "local_name": "Test Device", "manufacturer_data": {"76": "0215aabbccddeeff"}, "rssi": -50, "service_data": { "0000180d-0000-1000-8000-00805f9b34fb": "00000000" }, "service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"], "tx_power": 50, "platform_data": ["Test Device", ""], }, "device": { "address": "AA:BB:CC:DD:EE:FF", "details": {"details": "test"}, "name": "Test Device", }, # type: ignore[typeddict-item] }, "CC:DD:EE:FF:AA:BB": { "advertisement_data": { "local_name": "Test Device Expired", "manufacturer_data": {"76": "0215aabbccddeeff"}, "rssi": -50, "service_data": { "0000180d-0000-1000-8000-00805f9b34fb": "00000000" }, "service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"], "tx_power": 50, "platform_data": ["Test Device", ""], }, "device": { "address": "CC:DD:EE:FF:AA:BB", "details": {"details": "test"}, "name": "Test Device Expired", }, # type: ignore[typeddict-item] }, }, "discovered_device_raw": {}, "discovered_device_timestamps": { "AA:BB:CC:DD:EE:FF": now, "CC:DD:EE:FF:AA:BB": now - 101, }, "expire_seconds": 100, } ), "all_expired": DiscoveredDeviceAdvertisementDataDict( { "connectable": True, "discovered_device_advertisement_datas": { "CC:DD:EE:FF:AA:BB": { "advertisement_data": { "local_name": "Test Device Expired", "manufacturer_data": {"76": "0215aabbccddeeff"}, "rssi": -50, "service_data": { "0000180d-0000-1000-8000-00805f9b34fb": "00000000" }, "service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"], "tx_power": 50, "platform_data": ["Test Device", ""], }, "device": { "address": "CC:DD:EE:FF:AA:BB", "details": {"details": "test"}, "name": "Test Device Expired", }, # type: ignore[typeddict-item] } }, "discovered_device_raw": {}, "discovered_device_timestamps": {"CC:DD:EE:FF:AA:BB": now - 101}, "expire_seconds": 100, } ), } expire_stale_scanner_discovered_device_advertisement_data(data) assert len(data["myscanner"]["discovered_device_advertisement_datas"]) == 1 assert ( "CC:DD:EE:FF:AA:BB" not in data["myscanner"]["discovered_device_advertisement_datas"] ) assert "all_expired" not in data def test_expire_future_discovered_device_advertisement_data( caplog: pytest.LogCaptureFixture, ) -> None: """Test test_expire_future_discovered_device_advertisement_data.""" now = time.time() data = { "myscanner": DiscoveredDeviceAdvertisementDataDict( { "connectable": True, "discovered_device_advertisement_datas": { "AA:BB:CC:DD:EE:FF": { "advertisement_data": { "local_name": "Test Device", "manufacturer_data": {"76": "0215aabbccddeeff"}, "rssi": -50, "service_data": { "0000180d-0000-1000-8000-00805f9b34fb": "00000000" }, "service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"], "tx_power": 50, "platform_data": ["Test Device", ""], }, "device": { "address": "AA:BB:CC:DD:EE:FF", "details": {"details": "test"}, "name": "Test Device", }, # type: ignore[typeddict-item] }, "CC:DD:EE:FF:AA:BB": { "advertisement_data": { "local_name": "Test Device Expired", "manufacturer_data": {"76": "0215aabbccddeeff"}, "rssi": -50, "service_data": { "0000180d-0000-1000-8000-00805f9b34fb": "00000000" }, "service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"], "tx_power": 50, "platform_data": ["Test Device", ""], }, "device": { "address": "CC:DD:EE:FF:AA:BB", "details": {"details": "test"}, "name": "Test Device Expired", }, # type: ignore[typeddict-item] }, }, "discovered_device_timestamps": { "AA:BB:CC:DD:EE:FF": now, "CC:DD:EE:FF:AA:BB": now - 101, }, "discovered_device_raw": {}, "expire_seconds": 100, } ), "all_future": DiscoveredDeviceAdvertisementDataDict( { "connectable": True, "discovered_device_advertisement_datas": { "CC:DD:EE:FF:AA:BB": { "advertisement_data": { "local_name": "Test Device Expired", "manufacturer_data": {"76": "0215aabbccddeeff"}, "rssi": -50, "service_data": { "0000180d-0000-1000-8000-00805f9b34fb": "00000000" }, "service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"], "tx_power": 50, "platform_data": ["Test Device", ""], }, "device": { "address": "CC:DD:EE:FF:AA:BB", "details": {"details": "test"}, "name": "Test Device Expired", }, # type: ignore[typeddict-item] } }, "discovered_device_timestamps": {"CC:DD:EE:FF:AA:BB": now + 1000000}, "discovered_device_raw": {}, "expire_seconds": 100, } ), } expire_stale_scanner_discovered_device_advertisement_data(data) assert len(data["myscanner"]["discovered_device_advertisement_datas"]) == 1 assert ( "CC:DD:EE:FF:AA:BB" not in data["myscanner"]["discovered_device_advertisement_datas"] ) assert "all_future" not in data assert ( "for CC:DD:EE:FF:AA:BB on scanner all_future as it is the future" in caplog.text ) def test_discovered_device_advertisement_data_from_dict_corrupt(caplog): """Shape mismatches log a WARNING and discard the cache without a traceback.""" now = time.time() result = discovered_device_advertisement_data_from_dict( { "connectable": True, "discovered_device_advertisement_datas": { "AA:BB:CC:DD:EE:FF": { "advertisement_data": { "local_name": "Test Device", "manufacturer_data": {"76": "0215aabbccddeeff"}, "service_data": { "0000180d-0000-1000-8000-00805f9b34fb": "00000000" }, "service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"], }, "device": { # type: ignore[typeddict-item] "address": "AA:BB:CC:DD:EE:FF", "details": {"details": "test"}, }, } }, "discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": now}, "expire_seconds": 100, } ) assert result is None assert "Discovery cache shape mismatch" in caplog.text # The shape-mismatch path is logged at WARNING without a traceback so # operators can distinguish it from genuinely unexpected failures. records = [ r for r in caplog.records if "Discovery cache shape mismatch" in r.getMessage() ] assert len(records) == 1 assert records[0].levelname == "WARNING" assert records[0].exc_info is None def test_discovered_device_advertisement_data_from_dict_unexpected_error( caplog, monkeypatch ): """Unexpected errors keep the full traceback and are logged at ERROR.""" def boom(_data): msg = "boom" raise RuntimeError(msg) monkeypatch.setattr( "habluetooth.storage._deserialize_discovered_device_advertisement_datas", boom, ) result = discovered_device_advertisement_data_from_dict( { "connectable": True, "discovered_device_advertisement_datas": {}, "discovered_device_timestamps": {}, "expire_seconds": 100, "discovered_device_raw": {}, } ) assert result is None records = [ r for r in caplog.records if "Unexpected error deserializing" in r.getMessage() ] assert len(records) == 1 assert records[0].levelname == "ERROR" assert records[0].exc_info is not None def test_backward_compatibility_rssi_in_device_dict(): """Test that devices with RSSI in storage can still be loaded.""" now = time.time() # Simulate old storage format where RSSI was stored in the device dict result = discovered_device_advertisement_data_from_dict( { "connectable": True, "discovered_device_advertisement_datas": { "AA:BB:CC:DD:EE:FF": { "advertisement_data": { "local_name": "Test Device", "manufacturer_data": {"76": "0215aabbccddeeff"}, "rssi": -50, "service_data": { "0000180d-0000-1000-8000-00805f9b34fb": "00000000" }, "service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"], "tx_power": 50, "platform_data": ["Test Device", ""], }, "device": { "address": "AA:BB:CC:DD:EE:FF", "details": {"details": "test"}, "name": "Test Device", "rssi": -50, # Old format included RSSI here }, } }, "discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": now}, "expire_seconds": 100, "discovered_device_raw": {}, } ) # Should successfully deserialize without errors assert result is not None assert result.connectable is True assert result.expire_seconds == 100 # Check that the device was properly created ble_device, adv_data = result.discovered_device_advertisement_datas[ "AA:BB:CC:DD:EE:FF" ] assert ble_device.address == "AA:BB:CC:DD:EE:FF" assert ble_device.name == "Test Device" assert adv_data.rssi == -50 Bluetooth-Devices-habluetooth-75cbe37/tests/test_util.py000066400000000000000000000142111521117704500235260ustar00rootroot00000000000000"""Tests for habluetooth.util.""" from __future__ import annotations import asyncio from typing import TYPE_CHECKING from unittest.mock import AsyncMock, patch import pytest from habluetooth.util import ( async_reset_adapter, coalesce_concurrent_future, is_docker_env, ) if TYPE_CHECKING: from collections.abc import Iterator @pytest.fixture def mock_recover_adapter() -> Iterator[AsyncMock]: """Patch habluetooth.util.recover_adapter with an AsyncMock.""" mock = AsyncMock() with patch("habluetooth.util.recover_adapter", mock): yield mock @pytest.mark.asyncio async def test_async_reset_adapter_with_hci_adapter( mock_recover_adapter: AsyncMock, ) -> None: """An hciN adapter delegates to bluetooth_auto_recovery.recover_adapter.""" mock_recover_adapter.return_value = True assert await async_reset_adapter("hci3", "AA:BB:CC:DD:EE:FF", True) is True mock_recover_adapter.assert_awaited_once_with(3, "AA:BB:CC:DD:EE:FF", True) @pytest.mark.asyncio async def test_async_reset_adapter_returns_false_when_adapter_is_none( mock_recover_adapter: AsyncMock, ) -> None: """No adapter → recover_adapter is not invoked, returns False.""" assert await async_reset_adapter(None, "AA:BB:CC:DD:EE:FF", False) is False mock_recover_adapter.assert_not_called() @pytest.mark.asyncio async def test_async_reset_adapter_returns_false_for_non_hci_adapter( mock_recover_adapter: AsyncMock, ) -> None: """A non-hci adapter (e.g. CoreBluetooth) returns False without recovery.""" assert ( await async_reset_adapter("Core Bluetooth", "AA:BB:CC:DD:EE:FF", False) is False ) mock_recover_adapter.assert_not_called() def test_is_docker_env_true(monkeypatch: pytest.MonkeyPatch) -> None: """/.dockerenv present → True.""" is_docker_env.cache_clear() monkeypatch.setattr("habluetooth.util.Path.exists", lambda self: True) assert is_docker_env() is True is_docker_env.cache_clear() def test_is_docker_env_false(monkeypatch: pytest.MonkeyPatch) -> None: """/.dockerenv missing → False.""" is_docker_env.cache_clear() monkeypatch.setattr("habluetooth.util.Path.exists", lambda self: False) assert is_docker_env() is False is_docker_env.cache_clear() class _Coalesced: """Test fixture instance for the coalesce_concurrent_future decorator.""" def __init__(self) -> None: self._fut: asyncio.Future[int] | None = None self.call_count = 0 self.started = asyncio.Event() self.release = asyncio.Event() self.return_value = 42 self.raise_exc: BaseException | None = None @coalesce_concurrent_future("_fut") async def work(self) -> int: self.call_count += 1 self.started.set() await self.release.wait() if self.raise_exc is not None: raise self.raise_exc return self.return_value @pytest.mark.asyncio async def test_coalesce_concurrent_future_single_call_returns_result() -> None: """A lone caller runs the wrapped coroutine and gets its result.""" obj = _Coalesced() obj.release.set() assert await obj.work() == 42 assert obj.call_count == 1 assert obj._fut is None @pytest.mark.asyncio async def test_coalesce_concurrent_future_concurrent_callers_share_one_call() -> None: """Concurrent callers share a single underlying invocation.""" obj = _Coalesced() leader = asyncio.create_task(obj.work()) await obj.started.wait() waiter_a = asyncio.create_task(obj.work()) waiter_b = asyncio.create_task(obj.work()) await asyncio.sleep(0) obj.release.set() assert await leader == 42 assert await waiter_a == 42 assert await waiter_b == 42 assert obj.call_count == 1 assert obj._fut is None @pytest.mark.asyncio async def test_coalesce_concurrent_future_propagates_exception_to_waiters() -> None: """Leader exception is observed by every concurrent waiter.""" obj = _Coalesced() obj.raise_exc = RuntimeError("boom") leader = asyncio.create_task(obj.work()) await obj.started.wait() waiter = asyncio.create_task(obj.work()) await asyncio.sleep(0) obj.release.set() with pytest.raises(RuntimeError, match="boom"): await leader with pytest.raises(RuntimeError, match="boom"): await waiter assert obj._fut is None @pytest.mark.asyncio async def test_coalesce_concurrent_future_leader_cancellation_surfaces_to_waiters() -> ( None ): """Leader cancellation propagates to waiters, future is cleared.""" obj = _Coalesced() leader = asyncio.create_task(obj.work()) await obj.started.wait() waiter = asyncio.create_task(obj.work()) await asyncio.sleep(0) leader.cancel() with pytest.raises(asyncio.CancelledError): await leader with pytest.raises(asyncio.CancelledError): await waiter assert obj._fut is None @pytest.mark.asyncio async def test_coalesce_concurrent_future_waiter_cancel_does_not_strand_leader() -> ( None ): """Cancelling a waiter does not poison the shared future.""" obj = _Coalesced() leader = asyncio.create_task(obj.work()) await obj.started.wait() waiter_a = asyncio.create_task(obj.work()) waiter_b = asyncio.create_task(obj.work()) await asyncio.sleep(0) waiter_a.cancel() with pytest.raises(asyncio.CancelledError): await waiter_a obj.release.set() assert await leader == 42 assert await waiter_b == 42 assert obj._fut is None @pytest.mark.asyncio async def test_coalesce_concurrent_future_resets_between_sequential_calls() -> None: """Future state resets so a later call runs fresh.""" obj = _Coalesced() obj.release.set() assert await obj.work() == 42 assert obj._fut is None obj.return_value = 7 assert await obj.work() == 7 assert obj.call_count == 2 @pytest.mark.asyncio async def test_coalesce_concurrent_future_requires_attribute_initialised() -> None: """Missing attribute on the instance raises AttributeError.""" class _Missing: @coalesce_concurrent_future("_fut") async def work(self) -> int: return 1 with pytest.raises(AttributeError): await _Missing().work() Bluetooth-Devices-habluetooth-75cbe37/tests/test_wrappers.py000066400000000000000000002741671521117704500244360ustar00rootroot00000000000000"""Tests for bluetooth wrappers.""" from __future__ import annotations import asyncio import logging import sys from contextlib import contextmanager, suppress from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, Mock, patch import bleak import pytest from bleak.backends.device import BLEDevice from bleak.exc import BleakError from bleak_retry_connector import Allocations from habluetooth import HaBluetoothConnector from habluetooth import get_manager as _get_manager from habluetooth.const import BDADDR_LE_PUBLIC, BDADDR_LE_RANDOM from habluetooth.usage import ( install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher, ) from habluetooth.wrappers import ( FILTER_UUIDS, HaBleakScannerWrapper, _get_device_address_type, ) from . import ( HCI0_SOURCE_ADDRESS, InjectableRemoteScanner, generate_advertisement_data, generate_ble_device, inject_advertisement, patch_discovered_devices, ) if TYPE_CHECKING: from collections.abc import Awaitable, Callable, Generator from bleak.backends.scanner import AdvertisementData from habluetooth.manager import BluetoothManager @contextmanager def mock_shutdown(manager: BluetoothManager) -> Generator[None, None, None]: """Mock shutdown of the HomeAssistantBluetoothManager.""" manager.shutdown = True yield manager.shutdown = False class FakeScanner(InjectableRemoteScanner): """Wrappers-test scanner that empties _details for routing tests.""" def __init__( self, scanner_id: str, name: str, connector: Any, connectable: bool, ) -> None: """Initialize the scanner.""" super().__init__(scanner_id, name, connector, connectable) self._details: dict[str, str | HaBluetoothConnector] = {} def __repr__(self) -> str: """Return the representation.""" return f"FakeScanner({self.name})" class BaseFakeBleakClient: """Base class for fake bleak clients.""" def __init__(self, address_or_ble_device: BLEDevice | str, **kwargs: Any) -> None: """Initialize the fake bleak client.""" self._device_path = "/dev/test" self._device = address_or_ble_device assert isinstance(address_or_ble_device, BLEDevice) self._address = address_or_ble_device.address async def disconnect(self, *args, **kwargs): """Disconnect.""" async def get_services(self, *args, **kwargs): """Get services.""" return [] class FakeBleakClient(BaseFakeBleakClient): """Fake bleak client.""" async def connect(self, *args, **kwargs): """Connect.""" return True @property def is_connected(self): return False class FakeBleakClientFailsToConnect(BaseFakeBleakClient): """Fake bleak client that fails to connect.""" async def connect(self, *args, **kwargs): """Connect.""" return @property def is_connected(self): return False class FakeBleakClientRaisesOnConnect(BaseFakeBleakClient): """Fake bleak client that raises on connect.""" async def connect(self, *args, **kwargs): """Connect.""" msg = "Test exception" raise ConnectionError(msg) class FakeBleakClientCancelledOnConnect(BaseFakeBleakClient): """Fake bleak client that raises CancelledError on connect.""" async def connect(self, *args, **kwargs): """Connect.""" raise asyncio.CancelledError 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), ) def _make_detection_recorder() -> tuple[ list[tuple[BLEDevice, AdvertisementData]], Callable[[BLEDevice, AdvertisementData], None], ]: """Return ``(recorded, callback)``; callback appends ``(device, adv)``.""" recorded: list[tuple[BLEDevice, AdvertisementData]] = [] def _record(device: BLEDevice, advertisement_data: AdvertisementData) -> None: recorded.append((device, advertisement_data)) return recorded, _record def _make_async_detection_recorder() -> tuple[ list[tuple[BLEDevice, AdvertisementData]], Callable[[BLEDevice, AdvertisementData], Awaitable[None]], ]: """Async variant of :func:`_make_detection_recorder`.""" recorded: list[tuple[BLEDevice, AdvertisementData]] = [] async def _record(device: BLEDevice, advertisement_data: AdvertisementData) -> None: recorded.append((device, advertisement_data)) return recorded, _record @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 @pytest.fixture(name="mock_platform_client_that_cancels_on_connect") def mock_platform_client_that_cancels_on_connect_fixture(): """Fixture that mocks the platform client that raises CancelledError.""" with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientCancelledOnConnect, ): yield def _generate_scanners_with_fake_devices(): """Generate scanners with fake devices.""" manager = _get_manager() hci0_device_advs = {} for i in range(10): device, adv_data = _generate_ble_device_and_adv_data( "hci0", f"00:00:00:00:00:{i:02x}", rssi=-60 ) hci0_device_advs[device.address] = (device, adv_data) hci1_device_advs = {} for i in range(10): device, adv_data = _generate_ble_device_and_adv_data( "hci1", f"00:00:00:00:00:{i:02x}", rssi=-80 ) hci1_device_advs[device.address] = (device, adv_data) scanner_hci0 = FakeScanner("00:00:00:00:00:01", "hci0", None, True) scanner_hci1 = FakeScanner("00:00:00:00:00:02", "hci1", None, True) for device, adv_data in hci0_device_advs.values(): scanner_hci0.inject_advertisement(device, adv_data) for device, adv_data in hci1_device_advs.values(): scanner_hci1.inject_advertisement(device, adv_data) cancel_hci0 = manager.async_register_scanner(scanner_hci0, connection_slots=2) cancel_hci1 = manager.async_register_scanner(scanner_hci1, connection_slots=1) return hci0_device_advs, cancel_hci0, cancel_hci1 @pytest.mark.asyncio async def test_test_switch_adapters_when_out_of_slots( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, mock_platform_client: None, ) -> None: """Ensure we try another scanner when one runs out of slots.""" manager = _get_manager() # hci0 has an rssi of -60, hci1 has an rssi of -80 hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() # hci0 has 2 slots, hci1 has 1 slot with ( patch.object(manager.slot_manager, "release_slot") as release_slot_mock, patch.object( manager.slot_manager, "allocate_slot", return_value=True ) as allocate_slot_mock, ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] with patch.object(FakeBleakClient, "is_connected", return_value=True): client = bleak.BleakClient(ble_device) await client.connect() assert allocate_slot_mock.call_count == 1 assert release_slot_mock.call_count == 0 # All adapters are out of slots with ( patch.object(manager.slot_manager, "release_slot") as release_slot_mock, patch.object( manager.slot_manager, "allocate_slot", return_value=False ) as allocate_slot_mock, ): ble_device = hci0_device_advs["00:00:00:00:00:02"][0] client = bleak.BleakClient(ble_device) with pytest.raises( bleak.exc.BleakError, match=( r"No backend with an available connection slot that can reach " r"address .* was found:.*connectable=True" ), ): await client.connect() assert allocate_slot_mock.call_count == 2 assert release_slot_mock.call_count == 0 # When hci0 runs out of slots, we should try hci1 def _allocate_slot_mock(ble_device: BLEDevice) -> bool: return "hci1" in ble_device.details["path"] with ( patch.object(manager.slot_manager, "release_slot") as release_slot_mock, patch.object( # type: ignore[assignment] manager.slot_manager, "allocate_slot", _allocate_slot_mock ) as allocate_slot_mock, ): ble_device = hci0_device_advs["00:00:00:00:00:03"][0] with patch.object(FakeBleakClient, "is_connected", return_value=True): client = bleak.BleakClient(ble_device) await client.connect() assert release_slot_mock.call_count == 0 cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_no_backend_error_survives_diagnostics_failure( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, mock_platform_client: None, ) -> None: """A failing diagnostics builder must not mask the no-backend BleakError.""" manager = _get_manager() hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() with ( patch.object(manager.slot_manager, "allocate_slot", return_value=False), patch.object( manager, "async_address_reachability_diagnostics", side_effect=RuntimeError("boom"), ), ): ble_device = hci0_device_advs["00:00:00:00:00:02"][0] client = bleak.BleakClient(ble_device) with pytest.raises( bleak.exc.BleakError, match=( r"No backend with an available connection slot that can reach " r"address .* was found$" ), ): await client.connect() cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_backend_id_property( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, mock_platform_client: None, ) -> None: """Ensure BleakClient.backend_id is readable before and after connect.""" 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) # Before connect(), backend_id must be readable (regression for #399). assert client.backend_id == "" with patch.object(FakeBleakClient, "is_connected", return_value=True): await client.connect() # After connect(), backend_id reflects the selected backend. assert client.backend_id cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_release_slot_on_connect_failure( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, mock_platform_client_that_raises_on_connect: None, ) -> None: """Ensure the slot gets released on connection failure.""" manager = _get_manager() hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() # hci0 has 2 slots, hci1 has 1 slot with ( patch.object(manager.slot_manager, "release_slot") as release_slot_mock, patch.object( manager.slot_manager, "allocate_slot", return_value=True ) as allocate_slot_mock, ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) with pytest.raises(ConnectionError): await client.connect() assert allocate_slot_mock.call_count == 1 assert release_slot_mock.call_count == 1 cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_release_slot_on_connect_exception( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, mock_platform_client_that_raises_on_connect: None, ) -> None: """Ensure the slot gets released on connection exception.""" manager = _get_manager() hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() # hci0 has 2 slots, hci1 has 1 slot with ( patch.object(manager.slot_manager, "release_slot") as release_slot_mock, patch.object( manager.slot_manager, "allocate_slot", return_value=True ) as allocate_slot_mock, ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) with pytest.raises(ConnectionError) as exc_info: await client.connect() assert str(exc_info.value) == "Test exception" assert allocate_slot_mock.call_count == 1 assert release_slot_mock.call_count == 1 cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_release_slot_and_clear_backend_on_cancelled( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, mock_platform_client_that_cancels_on_connect: None, ) -> None: """Ensure CancelledError still releases the slot and clears _backend.""" manager = _get_manager() hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() 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(asyncio.CancelledError): await client.connect() assert allocate_slot_mock.call_count == 1 assert release_slot_mock.call_count == 1 # Regression: CancelledError used to leave _backend set because the # cleanup ran in `except Exception` rather than `finally`. assert client._backend is None cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_remote_scanner_connect_failure_skips_local_slot_release( enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """ Ensure remote-scanner connect failure clears _backend without slot release. Covers the not-taken branch of ``if not wrapped_backend.source:`` in ``HaBleakClientWrapper.connect()``. When the chosen backend belongs to a remote (proxy) scanner, ``wrapped_backend.source`` is truthy and ``manager.async_release_connection_slot`` must not run — slot accounting for proxies is owned by the firmware and reported via ``async_on_allocation_changed``. """ manager = _get_manager() fake_connector = HaBluetoothConnector( client=FakeBleakClientRaisesOnConnect, source="remote_scanner", can_connect=lambda: True, ) remote_scanner = FakeScanner( "remote_scanner", "ESPHome Device", fake_connector, True ) cancel_remote = manager.async_register_scanner(remote_scanner) device = generate_ble_device( "AA:BB:CC:DD:EE:FF", "Test Device", {"source": "remote_scanner"} ) adv_data = generate_advertisement_data(local_name="Test Device", rssi=-50) remote_scanner.inject_advertisement(device, adv_data) await asyncio.sleep(0) 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, ): client = bleak.BleakClient("AA:BB:CC:DD:EE:FF") with pytest.raises(ConnectionError): await client.connect() # Local-adapter slot manager must not be touched on the remote path. assert allocate_slot_mock.call_count == 0 assert release_slot_mock.call_count == 0 # Backend is cleared on every failure path. assert client._backend is None # _finished_connecting still runs, so the in-progress counter is empty. assert remote_scanner._connect_in_progress == {} cancel_remote() @pytest.mark.asyncio async def test_switch_adapters_on_failure( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """Ensure we try the next best adapter after a failure.""" hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) class FakeBleakClientFailsHCI0Only(BaseFakeBleakClient): """Fake bleak client that fails to connect on hci0.""" async def connect(self, *args: Any, **kwargs: Any) -> None: """Connect.""" assert isinstance(self._device, BLEDevice) if "/hci0/" in self._device.details["path"]: msg = "Failed to connect on hci0" raise BleakError(msg) @property def is_connected(self) -> bool: return True class FakeBleakClientFailsHCI1Only(BaseFakeBleakClient): """Fake bleak client that fails to connect on hci1.""" async def connect(self, *args: Any, **kwargs: Any) -> None: """Connect.""" assert isinstance(self._device, BLEDevice) if "/hci1/" in self._device.details["path"]: msg = "Failed to connect on hci1" raise BleakError(msg) @property def is_connected(self) -> bool: return True with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsHCI0Only, ): # Should try to connect to hci0 first with pytest.raises(BleakError): await client.connect() assert not client.is_connected # Should try to connect with hci0 again with pytest.raises(BleakError): await client.connect() assert not client.is_connected # After two tries we should switch to hci1 await client.connect() assert client.is_connected # ..and we remember that hci1 works as long as the client doesn't change await client.connect() assert client.is_connected # If we replace the client, we should remember hci0 is failing client = bleak.BleakClient(ble_device) await client.connect() assert client.is_connected with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsHCI1Only, ): # Should try to connect to hci1 first await client.connect() assert client.is_connected # Should work with hci0 on next attempt await client.connect() assert client.is_connected # Next attempt should also use hci0 await client.connect() assert client.is_connected cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_switch_adapters_on_connecting( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """Ensure we try the next best adapter after a failure.""" # hci0 has an rssi of -60, hci1 has an rssi of -80 hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) class FakeBleakClientSlowHCI0Connnect(BaseFakeBleakClient): """Fake bleak client that connects instantly on hci1 and slow on hci0.""" valid = False async def connect(self, *args: Any, **kwargs: Any) -> None: """Connect.""" assert isinstance(self._device, BLEDevice) if "/hci0/" in self._device.details["path"]: await asyncio.sleep(0.4) self.valid = True else: self.valid = True @property def is_connected(self) -> bool: return self.valid with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientSlowHCI0Connnect, ): task = asyncio.create_task(client.connect()) await asyncio.sleep(0.1) assert not task.done() task2 = asyncio.create_task(client.connect()) await asyncio.sleep(0.1) assert task2.done() await task2 assert client.is_connected task3 = asyncio.create_task(client.connect()) await asyncio.sleep(0.1) assert task3.done() await task3 assert client.is_connected await task assert client.is_connected cancel_hci0() cancel_hci1() @pytest.mark.asyncio @pytest.mark.usefixtures("enable_bluetooth", "install_bleak_catcher") async def test_single_adapter_connection_history( caplog: pytest.LogCaptureFixture, ) -> None: """Test connection history failure count.""" manager = _get_manager() scanner_hci0 = FakeScanner(HCI0_SOURCE_ADDRESS, "hci0", None, True) unsub_hci0 = manager.async_register_scanner(scanner_hci0, connection_slots=2) ble_device, adv_data = _generate_ble_device_and_adv_data( "hci0", "00:00:00:00:00:11", rssi=-60 ) scanner_hci0.inject_advertisement(ble_device, adv_data) service_info = manager.async_last_service_info( ble_device.address, connectable=False ) assert service_info is not None assert service_info.source == HCI0_SOURCE_ADDRESS client = bleak.BleakClient(ble_device) class FakeBleakClientFastConnect(BaseFakeBleakClient): """Fake bleak client that connects instantly on hci1 and slow on hci0.""" valid = False async def connect(self, *args: Any, **kwargs: Any) -> None: """Connect.""" assert isinstance(self._device, BLEDevice) self.valid = "/hci0/" in self._device.details["path"] @property def is_connected(self) -> bool: return self.valid with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFastConnect, ): await client.connect() unsub_hci0() @pytest.mark.asyncio async def test_passing_subclassed_str_as_address( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """Ensure the client wrapper can handle a subclassed str as the address.""" _, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() class SubclassedStr(str): __slots__ = () address = SubclassedStr("00:00:00:00:00:01") client = bleak.BleakClient(address) class FakeBleakClient(BaseFakeBleakClient): """Fake bleak client.""" async def connect(self, *args, **kwargs): """Connect.""" return @property def is_connected(self) -> bool: return True with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClient, ): await client.connect() cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_find_device_by_address( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """Ensure the client wrapper can handle a subclassed str as the address.""" _, _cancel_hci0, _cancel_hci1 = _generate_scanners_with_fake_devices() device = await bleak.BleakScanner.find_device_by_address("00:00:00:00:00:01") assert device.address == "00:00:00:00:00:01" device = await bleak.BleakScanner().find_device_by_address("00:00:00:00:00:01") assert device.address == "00:00:00:00:00:01" @pytest.mark.asyncio async def test_find_device_by_filter( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """Ensure find_device_by_filter finds a device matching the filter.""" _, _cancel_hci0, _cancel_hci1 = _generate_scanners_with_fake_devices() device = await bleak.BleakScanner.find_device_by_filter( lambda d, ad: d.address == "00:00:00:00:00:01" ) assert device is not None assert device.address == "00:00:00:00:00:01" @pytest.mark.asyncio async def test_find_device_by_filter_no_match( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """Ensure find_device_by_filter returns None when no device matches.""" _, _cancel_hci0, _cancel_hci1 = _generate_scanners_with_fake_devices() device = await bleak.BleakScanner.find_device_by_filter( lambda d, ad: d.address == "DE:AD:BE:EF:00:00" ) assert device is None @pytest.mark.asyncio async def test_find_device_by_name( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """Ensure find_device_by_name finds a device matching the name.""" _, _cancel_hci0, _cancel_hci1 = _generate_scanners_with_fake_devices() device = await bleak.BleakScanner.find_device_by_name("any") assert device is not None @pytest.mark.asyncio async def test_find_device_by_name_no_match( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """Ensure find_device_by_name returns None when no device matches.""" _, _cancel_hci0, _cancel_hci1 = _generate_scanners_with_fake_devices() device = await bleak.BleakScanner.find_device_by_name("nonexistent") assert device is None @pytest.mark.asyncio async def test_async_context_manager( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """Ensure HaBleakScannerWrapper supports async context manager protocol.""" _, _cancel_hci0, _cancel_hci1 = _generate_scanners_with_fake_devices() async with bleak.BleakScanner() as scanner: assert scanner.discovered_devices @pytest.mark.asyncio async def test_discovered_devices_and_advertisement_data( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """Ensure discovered_devices_and_advertisement_data works.""" _, _cancel_hci0, _cancel_hci1 = _generate_scanners_with_fake_devices() scanner = bleak.BleakScanner() result = scanner.discovered_devices_and_advertisement_data assert "00:00:00:00:00:01" in result device, adv_data = result["00:00:00:00:00:01"] assert device.address == "00:00:00:00:00:01" assert adv_data is not None @pytest.mark.asyncio async def test_advertisement_data_iterator( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """Ensure advertisement_data async iterator yields devices.""" _, _cancel_hci0, _cancel_hci1 = _generate_scanners_with_fake_devices() scanner = bleak.BleakScanner() devices_found: dict[str, tuple[BLEDevice, AdvertisementData]] = {} async for device, adv_data in scanner.advertisement_data(): devices_found[device.address] = (device, adv_data) if "00:00:00:00:00:01" in devices_found: break assert "00:00:00:00:00:01" in devices_found assert devices_found["00:00:00:00:00:01"][1] is not None @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, _device_detected = _make_detection_recorder() 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( detection_callback=_device_detected, filters={"UUIDs": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]}, ) await scanner.start() 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 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) == 2 # 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) == 2 @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_register_detection_callback_deprecated( register_hci0_scanner: None, ) -> None: """Test the deprecated register_detection_callback still works and warns.""" detected, _device_detected = _make_detection_recorder() 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"], ) with pytest.warns(DeprecationWarning, match="register_detection_callback"): cancel = scanner.register_detection_callback(_device_detected) inject_advertisement(switchbot_device, switchbot_adv) await asyncio.sleep(0) assert len(detected) == 1 cancel() 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_with_service_uuids( register_hci0_scanner: None, ) -> None: """Test wrapped instance with a service_uuids list as normal BleakScanner.""" detected, _device_detected = _make_detection_recorder() 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( detection_callback=_device_detected, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ) await _scanner.start() 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_callback_not_registered_until_start( register_hci0_scanner: None, ) -> None: """Detections are only delivered between start() and stop().""" detected, _device_detected = _make_detection_recorder() 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( detection_callback=_device_detected, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ) # Before start() no callbacks should fire even though a callback was # provided to the constructor. inject_advertisement(switchbot_device, switchbot_adv) await asyncio.sleep(0) assert len(detected) == 0 # After start() detections are delivered. await scanner.start() inject_advertisement(switchbot_device, switchbot_adv) await asyncio.sleep(0) assert len(detected) == 1 # After stop() the callback is deregistered and no longer fires. await scanner.stop() inject_advertisement(switchbot_device, switchbot_adv) await asyncio.sleep(0) assert len(detected) == 1 # start() again re-registers the callback. await scanner.start() inject_advertisement(switchbot_device, switchbot_adv) await asyncio.sleep(0) assert len(detected) == 2 @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_set_scanning_filter_reregisters_when_started( register_hci0_scanner: None, ) -> None: """set_scanning_filter re-registers and applies the new filter when started.""" detected, _device_detected = _make_detection_recorder() 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"}, ) 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 # No initial filter, so the started scanner matches everything. scanner = HaBleakScannerWrapper(detection_callback=_device_detected) await scanner.start() inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(empty_device, empty_adv) await asyncio.sleep(0) assert len(detected) == 2 # An effective filter change while started re-registers the callback with # the new filter, so only matching advertisements are delivered afterwards. scanner.set_scanning_filter(service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]) inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(empty_device, empty_adv) await asyncio.sleep(0) assert len(detected) == 3 @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_start_without_detection_callback( register_hci0_scanner: None, ) -> None: """start() is a no-op when no detection callback was provided.""" switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data(local_name="wohand") assert _get_manager() is not None scanner = HaBleakScannerWrapper() # start() reaches the detection-callback setup path but no callback was # registered, so it short-circuits without raising and nothing is delivered. await scanner.start() inject_advertisement(switchbot_device, switchbot_adv) await asyncio.sleep(0) await scanner.stop() @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, _device_detected = _make_async_detection_recorder() 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( detection_callback=_device_detected, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ) await _scanner.start() 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( detection_callback=_device_detected, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ) await _scanner.start() 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, _device_detected = _make_detection_recorder() 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( detection_callback=_device_detected, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ) await _scanner.start() 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, _device_detected = _make_detection_recorder() 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( detection_callback=_device_detected, filters={"UUIDs": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]}, ) await _scanner.start() inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(switchbot_device, switchbot_adv_2) await asyncio.sleep(0) assert len(detected) == 2 # The UUIDs list we created in the wrapped scanner with should be respected # and we should not get another callback inject_advertisement(empty_device, empty_adv) assert len(detected) == 2 @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_wrapped_instance_unsupported_filter( register_hci0_scanner: None, caplog: pytest.LogCaptureFixture, ) -> None: """Test we want when their filter is ineffective.""" assert _get_manager() is not None scanner = HaBleakScannerWrapper() scanner.set_scanning_filter( filters={ "unsupported": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], "DuplicateData": True, } ) assert "Only UUIDs filters are supported" in caplog.text @pytest.mark.asyncio async def test_client_with_services_parameter( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, mock_platform_client: None, ) -> None: """Test that services parameter is passed correctly to the backend.""" hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() ble_device = hci0_device_advs["00:00:00:00:00:01"][0] test_services = [ "00001800-0000-1000-8000-00805f9b34fb", "00001801-0000-1000-8000-00805f9b34fb", ] # Track what services were passed to the backend services_passed_to_backend = None class FakeBleakClientTracksServices(BaseFakeBleakClient): """Fake bleak client that tracks services parameter.""" def __init__( self, address_or_ble_device: BLEDevice | str, **kwargs: Any ) -> None: """Initialize and capture services.""" super().__init__(address_or_ble_device, **kwargs) nonlocal services_passed_to_backend services_passed_to_backend = kwargs.get("services") async def connect(self, *args, **kwargs): """Connect.""" return True @property def is_connected(self): return True with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientTracksServices, ): client = bleak.BleakClient(ble_device, services=test_services) await client.connect() # Verify services were normalized and passed as a set assert services_passed_to_backend is not None assert isinstance(services_passed_to_backend, set) assert services_passed_to_backend == { "00001800-0000-1000-8000-00805f9b34fb", "00001801-0000-1000-8000-00805f9b34fb", } cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_client_with_pair_parameter( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, mock_platform_client: None, ) -> None: """Test that pair parameter is set correctly on the wrapper.""" hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() ble_device = hci0_device_advs["00:00:00:00:00:01"][0] # Test default pair=False client = bleak.BleakClient(ble_device) assert client._pair_before_connect is False # Test pair=True client = bleak.BleakClient(ble_device, pair=True) assert client._pair_before_connect is True cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_client_services_normalization( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, mock_platform_client: None, ) -> None: """Test that service UUIDs are normalized correctly.""" hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() ble_device = hci0_device_advs["00:00:00:00:00:01"][0] # Test with short UUIDs that need normalization test_services = ["1800", "1801", "CBA20D00-224D-11E6-9FB8-0002A5D5C51B"] services_passed_to_backend = None class FakeBleakClientTracksServices(BaseFakeBleakClient): """Fake bleak client that tracks services parameter.""" def __init__( self, address_or_ble_device: BLEDevice | str, **kwargs: Any ) -> None: """Initialize and capture services.""" super().__init__(address_or_ble_device, **kwargs) nonlocal services_passed_to_backend services_passed_to_backend = kwargs.get("services") async def connect(self, *args, **kwargs): """Connect.""" return True @property def is_connected(self): return True with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientTracksServices, ): client = bleak.BleakClient(ble_device, services=test_services) await client.connect() # Verify services were normalized assert services_passed_to_backend is not None assert isinstance(services_passed_to_backend, set) assert services_passed_to_backend == { "00001800-0000-1000-8000-00805f9b34fb", "00001801-0000-1000-8000-00805f9b34fb", "cba20d00-224d-11e6-9fb8-0002a5d5c51b", # Should be lowercased } cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_client_with_none_services( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, mock_platform_client: None, ) -> None: """Test that None services parameter is handled correctly.""" hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() ble_device = hci0_device_advs["00:00:00:00:00:01"][0] services_passed_to_backend = "not_set" class FakeBleakClientTracksServices(BaseFakeBleakClient): """Fake bleak client that tracks services parameter.""" def __init__( self, address_or_ble_device: BLEDevice | str, **kwargs: Any ) -> None: """Initialize and capture services.""" super().__init__(address_or_ble_device, **kwargs) nonlocal services_passed_to_backend services_passed_to_backend = kwargs.get("services", "not_set") async def connect(self, *args, **kwargs): """Connect.""" return True @property def is_connected(self): return True with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientTracksServices, ): # Test with no services parameter (default None) client = bleak.BleakClient(ble_device) await client.connect() assert services_passed_to_backend is None # Reset the captured value services_passed_to_backend = "not_set" # type: ignore[unreachable] with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientTracksServices, ): # Test with explicit None client = bleak.BleakClient(ble_device, services=None) await client.connect() assert services_passed_to_backend is None cancel_hci0() cancel_hci1() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_passive_only_scanner_error_message() -> None: """Test error message when all scanners are passive-only (like Shelly).""" manager = _get_manager() # Register a passive-only scanner (connectable=False) scanner = FakeScanner( "passive_scanner_1", "shelly_plus1pm_e86bea01020c", None, False ) cancel = manager.async_register_scanner(scanner) # Inject an advertisement from this passive scanner device = generate_ble_device( "00:00:00:00:00:01", "Test Device", {"source": "passive_scanner_1"} ) adv_data = generate_advertisement_data( local_name="Test Device", service_uuids=[], rssi=-50, ) scanner.inject_advertisement(device, adv_data) await asyncio.sleep(0) # Let the advertisement be processed # Try to connect - should fail with our custom error message client = bleak.BleakClient("00:00:00:00:00:01") with pytest.raises( BleakError, match=r"00:00:00:00:00:01: No connectable Bluetooth adapters\. " r"Shelly devices are passive-only and cannot connect\. " r"Need local Bluetooth adapter or ESPHome proxy\. " r"Available: shelly_plus1pm_e86bea01020c \(passive_scanner_1\)", ): await client.connect() cancel() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_passive_scanner_with_active_scanner() -> None: """Test normal error when there's a mix of passive and active scanners.""" manager = _get_manager() # Register a passive-only scanner passive_scanner = FakeScanner("passive_scanner", "shelly_device", None, False) cancel_passive = manager.async_register_scanner(passive_scanner) # Register an active scanner with no available slots active_scanner = FakeScanner("active_scanner", "esphome_device", None, True) cancel_active = manager.async_register_scanner(active_scanner) # Inject advertisements from both scanners device1 = generate_ble_device( "00:00:00:00:00:02", "Test Device", {"source": "passive_scanner"} ) device2 = generate_ble_device( "00:00:00:00:00:02", "Test Device", {"source": "active_scanner"} ) adv_data = generate_advertisement_data( local_name="Test Device", service_uuids=[], rssi=-50, ) passive_scanner.inject_advertisement(device1, adv_data) active_scanner.inject_advertisement(device2, adv_data) await asyncio.sleep(0) # Let the advertisements be processed # Mock the slot allocation to fail (simulating no available slots) with patch.object(manager.slot_manager, "allocate_slot", return_value=False): # Should get the normal "no available slot" error, not the passive-only error client = bleak.BleakClient("00:00:00:00:00:02") with pytest.raises( BleakError, match=( "No backend with an available connection slot that can reach " "address 00:00:00:00:00:02 was found" ), ): await client.connect() cancel_passive() cancel_active() @pytest.mark.asyncio async def test_connection_params_loading_with_bluez_mgmt( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, caplog: pytest.LogCaptureFixture, ) -> None: """Test that connection parameters are loaded when mgmt API is available.""" manager = _get_manager() hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() # Mock the bluez mgmt controller mock_mgmt_ctl = Mock() mock_mgmt_ctl.load_conn_params.return_value = True class FakeBleakClientTracksConnect(BaseFakeBleakClient): """Fake bleak client that tracks connect.""" connected = False async def connect(self, *args, **kwargs): """Connect.""" self.connected = True # Simulate service discovery await asyncio.sleep(0) @property def is_connected(self) -> bool: return self.connected # Test with debug logging enabled with ( caplog.at_level(logging.DEBUG), patch.object(manager, "get_bluez_mgmt_ctl", return_value=mock_mgmt_ctl), patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientTracksConnect, ), ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) await client.connect() # Verify load_conn_params was called twice (fast before connect, medium after) assert mock_mgmt_ctl.load_conn_params.call_count == 2 # First call should be for FAST params first_call = mock_mgmt_ctl.load_conn_params.call_args_list[0] assert first_call[0][0] == 0 # adapter_idx assert first_call[0][1] == "00:00:00:00:00:01" # address assert first_call[0][2] == 1 # BDADDR_LE_PUBLIC (default) assert first_call[0][3].value == "fast" # ConnectParams.FAST # Second call should be for MEDIUM params second_call = mock_mgmt_ctl.load_conn_params.call_args_list[1] assert second_call[0][0] == 0 # adapter_idx assert second_call[0][1] == "00:00:00:00:00:01" # address assert second_call[0][2] == 1 # BDADDR_LE_PUBLIC assert second_call[0][3].value == "medium" # ConnectParams.MEDIUM # Verify debug logging assert "Loaded ConnectParams.FAST connection parameters" in caplog.text assert "Loaded ConnectParams.MEDIUM connection parameters" in caplog.text cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_connection_params_not_loaded_without_mgmt( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, caplog: pytest.LogCaptureFixture, ) -> None: """Test that connection parameters are not loaded when mgmt API is unavailable.""" manager = _get_manager() hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() class FakeBleakClientTracksConnect(BaseFakeBleakClient): """Fake bleak client that tracks connect.""" connected = False async def connect(self, *args, **kwargs): """Connect.""" self.connected = True await asyncio.sleep(0) @property def is_connected(self) -> bool: return self.connected with ( caplog.at_level(logging.DEBUG), patch.object(manager, "get_bluez_mgmt_ctl", return_value=None), patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientTracksConnect, ), ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) await client.connect() # Verify no connection parameters were loaded assert "connection parameters" not in caplog.text cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_get_device_address_type_random( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """Test _get_device_address_type returns BDADDR_LE_RANDOM for random address.""" _hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() # Create a device with random address type device = generate_ble_device( "00:00:00:00:00:02", "Test Device", { "path": "/org/bluez/hci0/dev_00_00_00_00_00_02", "props": {"AddressType": "random"}, }, ) assert _get_device_address_type(device) == BDADDR_LE_RANDOM cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_get_device_address_type_public( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """Test _get_device_address_type returns BDADDR_LE_PUBLIC for public address.""" hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() # Create a device with public address type (default) device = hci0_device_advs["00:00:00:00:00:01"][0] assert _get_device_address_type(device) == BDADDR_LE_PUBLIC cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_connection_params_loading_fails_silently( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, caplog: pytest.LogCaptureFixture, ) -> None: """Test that connection still succeeds even if loading params fails.""" manager = _get_manager() hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() # Mock the bluez mgmt controller to fail loading params mock_mgmt_ctl = Mock() mock_mgmt_ctl.load_conn_params.return_value = False class FakeBleakClientTracksConnect(BaseFakeBleakClient): """Fake bleak client that tracks connect.""" connected = False async def connect(self, *args, **kwargs): """Connect.""" self.connected = True await asyncio.sleep(0) @property def is_connected(self) -> bool: return self.connected with ( caplog.at_level(logging.DEBUG), patch.object(manager, "get_bluez_mgmt_ctl", return_value=mock_mgmt_ctl), patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientTracksConnect, ), ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) # Connection should succeed even though param loading failed await client.connect() # Verify load_conn_params was called assert mock_mgmt_ctl.load_conn_params.call_count == 2 # But no success message should be logged assert "Loaded" not in caplog.text cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_connection_params_no_adapter_idx( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, caplog: pytest.LogCaptureFixture, ) -> None: """Test that connection params are not loaded if scanner has no adapter_idx.""" manager = _get_manager() # Mock the bluez mgmt controller mock_mgmt_ctl = Mock() mock_mgmt_ctl.load_conn_params.return_value = True class FakeBleakClientTracksConnect(BaseFakeBleakClient): """Fake bleak client that tracks connect.""" connected = False async def connect(self, *args, **kwargs): """Connect.""" self.connected = True await asyncio.sleep(0) @property def is_connected(self) -> bool: return self.connected # Create a fake connector for the remote scanner fake_connector = HaBluetoothConnector( client=FakeBleakClientTracksConnect, source="any", can_connect=lambda: True ) # Create a scanner without adapter_idx (e.g., remote scanner) remote_scanner = FakeScanner( "remote_scanner", "ESPHome Device", fake_connector, True ) cancel_remote = manager.async_register_scanner(remote_scanner) # Inject advertisement device = generate_ble_device( "00:00:00:00:00:03", "Test Device", {"source": "remote_scanner"} ) adv_data = generate_advertisement_data( local_name="Test Device", service_uuids=[], rssi=-50, ) remote_scanner.inject_advertisement(device, adv_data) await asyncio.sleep(0) # Remote scanner should already have adapter_idx returning None with ( caplog.at_level(logging.DEBUG), patch.object(manager, "get_bluez_mgmt_ctl", return_value=mock_mgmt_ctl), ): client = bleak.BleakClient("00:00:00:00:00:03") await client.connect() # Verify load_conn_params was NOT called since adapter_idx is None assert mock_mgmt_ctl.load_conn_params.call_count == 0 cancel_remote() @pytest.mark.asyncio async def test_connection_path_scoring_with_slots_and_logging( caplog: pytest.LogCaptureFixture, ) -> None: """Test connection path scoring and logging reflects slot availability.""" manager = _get_manager() class FakeBleakClientNoConnect(BaseFakeBleakClient): """Fake bleak client that doesn't connect.""" async def connect(self, *args, **kwargs): """Don't actually connect.""" msg = "Test - connection not needed" raise BleakError(msg) # Create fake connectors fake_connector_1 = HaBluetoothConnector( client=FakeBleakClientNoConnect, source="scanner1", can_connect=lambda: True ) fake_connector_2 = HaBluetoothConnector( client=FakeBleakClientNoConnect, source="scanner2", can_connect=lambda: True ) fake_connector_3 = HaBluetoothConnector( client=FakeBleakClientNoConnect, source="scanner3", can_connect=lambda: True ) # Create scanners with different sources scanner1 = FakeScanner("scanner1", "Scanner 1", fake_connector_1, True) scanner2 = FakeScanner("scanner2", "Scanner 2", fake_connector_2, True) scanner3 = FakeScanner("scanner3", "Scanner 3", fake_connector_3, True) # Mock get_allocations for each scanner using patch.object with ( patch.object( scanner1, "get_allocations", return_value=Allocations( adapter="scanner1", slots=3, free=1, # Only 1 slot free - should get penalty allocated=["AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02"], ), ), patch.object( scanner2, "get_allocations", return_value=Allocations( adapter="scanner2", slots=3, free=2, # 2 slots free - no penalty allocated=["AA:BB:CC:DD:EE:03"], ), ), patch.object( scanner3, "get_allocations", return_value=Allocations( adapter="scanner3", slots=3, free=3, # All slots free - no penalty allocated=[], ), ), ): cancel1 = manager.async_register_scanner(scanner1) cancel2 = manager.async_register_scanner(scanner2) cancel3 = manager.async_register_scanner(scanner3) # Inject advertisements with different RSSI values device1 = generate_ble_device( "00:00:00:00:00:01", "Test Device", {"source": "scanner1"} ) adv_data1 = generate_advertisement_data(local_name="Test Device", rssi=-60) scanner1.inject_advertisement(device1, adv_data1) device2 = generate_ble_device( "00:00:00:00:00:01", "Test Device", {"source": "scanner2"} ) adv_data2 = generate_advertisement_data(local_name="Test Device", rssi=-65) scanner2.inject_advertisement(device2, adv_data2) device3 = generate_ble_device( "00:00:00:00:00:01", "Test Device", {"source": "scanner3"} ) adv_data3 = generate_advertisement_data(local_name="Test Device", rssi=-70) scanner3.inject_advertisement(device3, adv_data3) await asyncio.sleep(0) # Try to connect with logging enabled with caplog.at_level(logging.INFO): client = bleak.BleakClient("00:00:00:00:00:01") with suppress(BleakError): await client.connect() # Check that the log contains the connection paths with correct scoring log_text = caplog.text assert "Found 3 connection path(s)" in log_text # Extract the log line with connection paths for line in caplog.text.splitlines(): if "Found 3 connection path(s)" in line: # rssi_diff = best_rssi - second_best_rssi = -60 - (-65) = 5 # Scanner 1 has best RSSI (-60) but only 1 slot free, so with penalty: # score = -60 - (5 * 0.76) = -63.8 assert "Scanner 1" in line assert "(slots=1/3 free)" in line assert "(score=-63.8)" in line # Scanner 2 has RSSI -65 with 2 slots free, no penalty, so # the score is just -65. assert "Scanner 2" in line assert "(slots=2/3 free)" in line # Check for both -65 and -65.0 assert ("(score=-65)" in line) or ("(score=-65.0)" in line) # Scanner 3 has RSSI -70 with all slots free, no penalty, so # the score is just -70. assert "Scanner 3" in line assert "(slots=3/3 free)" in line # Check for both -70 and -70.0 assert ("(score=-70)" in line) or ("(score=-70.0)" in line) # Verify order: Scanner 1 should be first (best score -63.8), # then Scanner 2 (-65), then Scanner 3 (-70) scanner1_pos = line.find("Scanner 1") scanner2_pos = line.find("Scanner 2") scanner3_pos = line.find("Scanner 3") assert scanner1_pos < scanner2_pos < scanner3_pos, ( f"Expected Scanner 1 before Scanner 2 before Scanner 3, " f"but got positions {scanner1_pos}, {scanner2_pos}, {scanner3_pos}" ) break else: pytest.fail("Could not find connection path log line") cancel1() cancel2() cancel3() @pytest.mark.asyncio async def test_connection_path_scoring_no_slots_available( caplog: pytest.LogCaptureFixture, ) -> None: """Test that scanners with no free slots are excluded.""" manager = _get_manager() class FakeBleakClientNoConnect(BaseFakeBleakClient): """Fake bleak client that doesn't connect.""" async def connect(self, *args, **kwargs): """Don't actually connect.""" msg = "Test - connection not needed" raise BleakError(msg) # Create fake connectors fake_connector_1 = HaBluetoothConnector( client=FakeBleakClientNoConnect, source="scanner1", can_connect=lambda: True ) fake_connector_2 = HaBluetoothConnector( client=FakeBleakClientNoConnect, source="scanner2", can_connect=lambda: True ) # Create scanners scanner1 = FakeScanner("scanner1", "Scanner 1", fake_connector_1, True) scanner2 = FakeScanner("scanner2", "Scanner 2", fake_connector_2, True) # Mock get_allocations - scanner1 has no free slots with ( patch.object( scanner1, "get_allocations", return_value=Allocations( adapter="scanner1", slots=3, free=0, # No slots available - should be excluded allocated=[ "AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02", "AA:BB:CC:DD:EE:03", ], ), ), patch.object( scanner2, "get_allocations", return_value=Allocations( adapter="scanner2", slots=3, free=3, allocated=[], # All slots free ), ), ): cancel1 = manager.async_register_scanner(scanner1) cancel2 = manager.async_register_scanner(scanner2) # Inject advertisements device1 = generate_ble_device( "00:00:00:00:00:02", "Test Device", {"source": "scanner1"} ) adv_data1 = generate_advertisement_data( local_name="Test Device", rssi=-50 ) # Better RSSI scanner1.inject_advertisement(device1, adv_data1) device2 = generate_ble_device( "00:00:00:00:00:02", "Test Device", {"source": "scanner2"} ) adv_data2 = generate_advertisement_data( local_name="Test Device", rssi=-70 ) # Worse RSSI scanner2.inject_advertisement(device2, adv_data2) await asyncio.sleep(0) # Try to connect with logging enabled with caplog.at_level(logging.INFO): client = bleak.BleakClient("00:00:00:00:00:02") with suppress(BleakError): await client.connect() # Check that only scanner2 is in the connection paths log_text = caplog.text assert ( "Found 1 connection path(s)" in log_text or "Found 2 connection path(s)" in log_text ) # If both are shown, scanner1 should have bad score (NO_RSSI_VALUE = -127) for line in caplog.text.splitlines(): if "connection path(s)" in line: if "Scanner 1" in line: # Scanner 1 should show 0 free slots and bad score assert "(slots=0/3 free)" in line assert "(score=-127)" in line # NO_RSSI_VALUE # Scanner 2 should be present with normal score assert "Scanner 2" in line assert "(slots=3/3 free)" in line # Check for both -70 and -70.0 assert ("(score=-70)" in line) or ("(score=-70.0)" in line) break cancel1() cancel2() @pytest.mark.asyncio async def test_thundering_herd_connection_slots() -> None: # noqa: C901 """ Test thundering herd scenario with limited connection slots. Simulates 7 devices trying to connect simultaneously to 3 proxies: - Proxy 1 & 2: Good signal (-60 RSSI), 3 slots each - Proxy 3: Bad signal (-95 RSSI), 3 slots each Expected behavior: - First 6 devices should connect to proxy1 and proxy2 (3 each) - 7th device should connect to proxy3 (bad signal) when others are full """ manager = _get_manager() # Track which backend each device connected to connection_tracker = {} class FakeBleakClientThunderingHerd(BaseFakeBleakClient): """Fake bleak client for thundering herd test.""" def __init__(self, address_or_ble_device, *args, **kwargs): """Initialize with tracking.""" super().__init__(address_or_ble_device, *args, **kwargs) self._connected = False # Track the device and source if isinstance(address_or_ble_device, BLEDevice): self._address = address_or_ble_device.address self._source = address_or_ble_device.details.get("source") else: self._address = str(address_or_ble_device) self._source = None async def connect(self, *args, **kwargs): """Simulate connection and record which backend was used.""" # Small delay to simulate connection time await asyncio.sleep(0.01) self._connected = True # Record which backend this device connected to if self._address and self._source: connection_tracker[self._address] = self._source return True @property def is_connected(self) -> bool: """Return connection state.""" return self._connected # Create fake connectors for 3 proxies fake_connector_1 = HaBluetoothConnector( client=FakeBleakClientThunderingHerd, source="proxy1", can_connect=lambda: True, ) fake_connector_2 = HaBluetoothConnector( client=FakeBleakClientThunderingHerd, source="proxy2", can_connect=lambda: True, ) fake_connector_3 = HaBluetoothConnector( client=FakeBleakClientThunderingHerd, source="proxy3", can_connect=lambda: True, ) # Create 3 scanners (proxies) with 3 connection slots each proxy1 = FakeScanner("proxy1", "Proxy 1 (Good)", fake_connector_1, True) proxy2 = FakeScanner("proxy2", "Proxy 2 (Good)", fake_connector_2, True) proxy3 = FakeScanner("proxy3", "Proxy 3 (Bad)", fake_connector_3, True) # Track actual slot allocations dynamically proxy_allocations: dict[str, set[str]] = { "proxy1": set(), "proxy2": set(), "proxy3": set(), } def get_proxy_allocations(proxy_name: str) -> Allocations: """Get allocations for a specific proxy.""" allocated = proxy_allocations[proxy_name] return Allocations( adapter=proxy_name, slots=3, free=3 - len(allocated), allocated=list(allocated), ) # Mock methods to track allocations def make_add_connecting(proxy_name: str) -> Callable[[str], None]: def _add_connecting(addr: str) -> None: proxy_allocations[proxy_name].add(addr) return _add_connecting def make_finished_connecting(proxy_name: str) -> Callable[[str, bool], None]: def _finished_connecting(addr: str, success: bool) -> None: if not success: proxy_allocations[proxy_name].discard(addr) return _finished_connecting # Mock get_allocations and connection tracking with ( patch.object( proxy1, "get_allocations", lambda: get_proxy_allocations("proxy1") ), patch.object( proxy2, "get_allocations", lambda: get_proxy_allocations("proxy2") ), patch.object( proxy3, "get_allocations", lambda: get_proxy_allocations("proxy3") ), patch.object(proxy1, "_add_connecting", make_add_connecting("proxy1")), patch.object(proxy2, "_add_connecting", make_add_connecting("proxy2")), patch.object(proxy3, "_add_connecting", make_add_connecting("proxy3")), patch.object( proxy1, "_finished_connecting", make_finished_connecting("proxy1") ), patch.object( proxy2, "_finished_connecting", make_finished_connecting("proxy2") ), patch.object( proxy3, "_finished_connecting", make_finished_connecting("proxy3") ), ): cancel1 = manager.async_register_scanner(proxy1) cancel2 = manager.async_register_scanner(proxy2) cancel3 = manager.async_register_scanner(proxy3) # Create 7 devices to connect device_addresses = [f"AA:BB:CC:DD:EE:0{i}" for i in range(1, 8)] # Inject advertisements for all devices on all proxies for i, address in enumerate(device_addresses, 1): # Good signal on proxy1 device1 = generate_ble_device(address, f"Device {i}", {"source": "proxy1"}) adv_data1 = generate_advertisement_data(local_name=f"Device {i}", rssi=-60) proxy1.inject_advertisement(device1, adv_data1) # Good signal on proxy2 (exactly same as proxy1) device2 = generate_ble_device(address, f"Device {i}", {"source": "proxy2"}) adv_data2 = generate_advertisement_data(local_name=f"Device {i}", rssi=-60) proxy2.inject_advertisement(device2, adv_data2) # Bad signal on proxy3 device3 = generate_ble_device(address, f"Device {i}", {"source": "proxy3"}) adv_data3 = generate_advertisement_data(local_name=f"Device {i}", rssi=-95) proxy3.inject_advertisement(device3, adv_data3) await asyncio.sleep(0) # Clear the connection tracker before starting connection_tracker.clear() async def connect_device(address: str) -> tuple[str, str | None]: """Try to connect to a device.""" client = bleak.BleakClient(address) try: await client.connect() # The connection tracker should have recorded which backend was used return address, connection_tracker.get(address, "unknown") except BleakError: # Connection failed (no available backend) return address, None # Simulate thundering herd - all devices try to connect at once tasks = [connect_device(addr) for addr in device_addresses] results = await asyncio.gather(*tasks, return_exceptions=True) # Process results connection_results = {} for result in results: if isinstance(result, tuple): address, proxy = result connection_results[address] = proxy # Count connections per proxy proxy1_connections = [ addr for addr, p in connection_results.items() if p == "proxy1" ] proxy2_connections = [ addr for addr, p in connection_results.items() if p == "proxy2" ] proxy3_connections = [ addr for addr, p in connection_results.items() if p == "proxy3" ] failed_connections = [ addr for addr, p in connection_results.items() if p is None ] # Verify constraints # 1. No proxy should exceed its slot limit assert len(proxy1_connections) <= 3, ( f"Proxy1 exceeded slot limit: {len(proxy1_connections)} > 3" ) assert len(proxy2_connections) <= 3, ( f"Proxy2 exceeded slot limit: {len(proxy2_connections)} > 3" ) assert len(proxy3_connections) <= 3, ( f"Proxy3 exceeded slot limit: {len(proxy3_connections)} > 3" ) # 2. Good signal proxies should be preferred and fill up first good_proxy_total = len(proxy1_connections) + len(proxy2_connections) assert good_proxy_total == 6, ( f"Expected exactly 6 connections on good proxies, got {good_proxy_total}" ) # 3. All 7 devices should connect (6 to good proxies, 1 to bad proxy) total_connected = ( len(proxy1_connections) + len(proxy2_connections) + len(proxy3_connections) ) assert total_connected == 7, ( f"Expected all 7 devices to connect, but only {total_connected} did" ) # 4. The 7th device should go to proxy3 since good ones are full assert len(proxy3_connections) == 1, ( f"Expected exactly 1 connection on proxy3, got {len(proxy3_connections)}" ) # 5. Verify good distribution across proxy1 and proxy2 # Both should have roughly equal load (3 connections each) assert len(proxy1_connections) == 3, ( f"Expected proxy1 to have 3 connections, got {len(proxy1_connections)}" ) assert len(proxy2_connections) == 3, ( f"Expected proxy2 to have 3 connections, got {len(proxy2_connections)}" ) # 6. No connections should fail assert len(failed_connections) == 0, ( f"Expected no failed connections, but {len(failed_connections)} failed" ) # Clean up cancel1() cancel2() cancel3() @pytest.mark.asyncio async def test_backend_name_from_tuple( enable_bluetooth: None, install_bleak_catcher: None, mock_platform_client: None, ) -> None: """Test that backend name is extracted from tuple (bleak 2.0.0+).""" manager = _get_manager() hci0_device_advs, cancel_hci0, _ = _generate_scanners_with_fake_devices() with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=(FakeBleakClient, "TestBackend"), ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) # Access the wrapped backend through the client # The backend name should be extracted from the tuple wrapped_backend = client._async_get_best_available_backend_and_device(manager) assert wrapped_backend.backend_name == "TestBackend" assert wrapped_backend.client == FakeBleakClient cancel_hci0() @pytest.mark.asyncio async def test_backend_name_from_class( enable_bluetooth: None, install_bleak_catcher: None, mock_platform_client: None, ) -> None: """Test that backend name is derived from class name (pre-bleak 2.0.0).""" manager = _get_manager() hci0_device_advs, cancel_hci0, _ = _generate_scanners_with_fake_devices() with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClient, ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) # Access the wrapped backend through the client # The backend name should be derived from the class name wrapped_backend = client._async_get_best_available_backend_and_device(manager) assert wrapped_backend.backend_name == "type" assert wrapped_backend.client == FakeBleakClient cancel_hci0() @pytest.mark.skipif( sys.platform == "win32", reason="dbus_fast (BlueZ backend) does not import on Windows", ) @pytest.mark.asyncio async def test_connect_uses_real_bluez_backend_signature( enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """ Ensure backend kwargs satisfy the real bleak BlueZ signature. Regression: kwargs passed to the backend client must satisfy the real bleak backend signature, not just FakeBleakClient's ``**kwargs``. bleak 3.0 made ``bluez`` a required keyword-only arg on ``BleakClientBlueZDBus.__init__``; because HaBleakClientWrapper bypasses ``BleakClient.__init__`` and constructs the backend directly, the default supplied by the high-level constructor never reaches the backend. The rest of the suite mocks the platform backend with FakeBleakClient (which accepts ``**kwargs``) so signature drift slips through. This test wires in the real BlueZ backend class so a future required kwarg fails here. """ from bleak.backends.bluezdbus.client import ( # noqa: PLC0415 BleakClientBlueZDBus, ) hci0_device_advs, cancel_hci0, _ = _generate_scanners_with_fake_devices() ble_device = hci0_device_advs["00:00:00:00:00:01"][0] with ( patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=BleakClientBlueZDBus, ), patch.object(BleakClientBlueZDBus, "connect", AsyncMock(return_value=True)), patch.object(BleakClientBlueZDBus, "is_connected", True), patch.object(BleakClientBlueZDBus, "disconnect", AsyncMock(return_value=True)), ): client = bleak.BleakClient(ble_device) await client.connect() assert isinstance(client._backend, BleakClientBlueZDBus) await client.disconnect() cancel_hci0() @pytest.mark.asyncio async def test_backend_name_in_logs( enable_bluetooth: None, install_bleak_catcher: None, mock_platform_client: None, caplog: pytest.LogCaptureFixture, ) -> None: """Test that backend name appears in debug logs.""" caplog.set_level(logging.DEBUG) hci0_device_advs, cancel_hci0, _ = _generate_scanners_with_fake_devices() with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=(FakeBleakClient, "TestBackend"), ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] with patch.object(FakeBleakClient, "is_connected", return_value=True): client = bleak.BleakClient(ble_device) await client.connect() # Check that the backend name appears in the logs assert any( "[TestBackend]" in record.message for record in caplog.records if "Connecting via" in record.message ), f"Backend name not found in logs: {[r.message for r in caplog.records]}" assert any( "[TestBackend]" in record.message for record in caplog.records if "Connected via" in record.message ), f"Backend name not found in logs: {[r.message for r in caplog.records]}" await client.disconnect() cancel_hci0() @pytest.mark.asyncio async def test_set_connection_params_with_backend( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, caplog: pytest.LogCaptureFixture, ) -> None: """Test that set_connection_params delegates to backend when it has the method.""" hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() class FakeBleakClientWithConnParams(BaseFakeBleakClient): """Fake bleak client that supports set_connection_params.""" connected = False set_connection_params = AsyncMock() async def connect(self, *args, **kwargs): """Connect.""" self.connected = True await asyncio.sleep(0) @property def is_connected(self) -> bool: return self.connected with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientWithConnParams, ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) await client.connect() await client.set_connection_params(800, 800, 0, 300) FakeBleakClientWithConnParams.set_connection_params.assert_called_once_with( 800, 800, 0, 300 ) cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_set_connection_params_with_bluez_mgmt( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, caplog: pytest.LogCaptureFixture, ) -> None: """Test that set_connection_params routes to mgmt_ctl when no backend method.""" manager = _get_manager() hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() mock_mgmt_ctl = Mock() mock_mgmt_ctl.load_conn_params.return_value = True class FakeBleakClientTracksConnect(BaseFakeBleakClient): """Fake bleak client that tracks connect.""" connected = False async def connect(self, *args, **kwargs): """Connect.""" self.connected = True await asyncio.sleep(0) @property def is_connected(self) -> bool: return self.connected with ( patch.object(manager, "get_bluez_mgmt_ctl", return_value=mock_mgmt_ctl), patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientTracksConnect, ), ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) await client.connect() # Reset mock to isolate set_connection_params call from connect-time calls mock_mgmt_ctl.reset_mock() await client.set_connection_params(800, 800, 0, 300) mock_mgmt_ctl.load_conn_params_explicit.assert_called_once_with( 0, # adapter_idx for hci0 "00:00:00:00:00:01", # address 1, # BDADDR_LE_PUBLIC (default) 800, 800, 0, 300, ) cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_set_connection_params_no_mgmt_no_backend( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, caplog: pytest.LogCaptureFixture, ) -> None: """Test set_connection_params no-ops without mgmt or backend.""" manager = _get_manager() hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() class FakeBleakClientTracksConnect(BaseFakeBleakClient): """Fake bleak client that tracks connect.""" connected = False async def connect(self, *args, **kwargs): """Connect.""" self.connected = True await asyncio.sleep(0) @property def is_connected(self) -> bool: return self.connected with ( patch.object(manager, "get_bluez_mgmt_ctl", return_value=None), patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientTracksConnect, ), ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) await client.connect() # Should not raise any error await client.set_connection_params(800, 800, 0, 300) assert "does not support setting connection parameters" in caplog.text cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_clear_cache_with_backend( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """Test clear_cache delegates to the backend when it supports clear_cache.""" hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() class FakeBleakClientWithClearCache(BaseFakeBleakClient): """Fake bleak client that supports clear_cache.""" connected = False clear_cache = AsyncMock(return_value=True) async def connect(self, *args, **kwargs): """Connect.""" self.connected = True await asyncio.sleep(0) @property def is_connected(self) -> bool: return self.connected with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientWithClearCache, ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) await client.connect() assert await client.clear_cache() is True FakeBleakClientWithClearCache.clear_cache.assert_called_once_with() cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_clear_cache_without_backend( enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """Test clear_cache falls back to the library helper without a backend.""" client = bleak.BleakClient("00:00:00:00:00:01") with patch( "habluetooth.wrappers.clear_cache", AsyncMock(return_value=True), ) as mock_clear_cache: assert await client.clear_cache() is True mock_clear_cache.assert_awaited_once_with("00:00:00:00:00:01") @pytest.mark.asyncio async def test_set_disconnected_callback_with_backend( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """Test set_disconnected_callback forwards to the backend once connected.""" hci0_device_advs, cancel_hci0, cancel_hci1 = _generate_scanners_with_fake_devices() class FakeBleakClientTracksDisconnectCb(BaseFakeBleakClient): """Fake bleak client that records set_disconnected_callback.""" connected = False set_disconnected_callback = Mock() async def connect(self, *args, **kwargs): """Connect.""" self.connected = True await asyncio.sleep(0) @property def is_connected(self) -> bool: return self.connected with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientTracksDisconnectCb, ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) await client.connect() callback = Mock() client.set_disconnected_callback(callback) FakeBleakClientTracksDisconnectCb.set_disconnected_callback.assert_called_once() cancel_hci0() cancel_hci1() @pytest.mark.asyncio async def test_disconnect_without_backend( enable_bluetooth: None, install_bleak_catcher: None, ) -> None: """Test disconnect is a no-op when no backend has been connected.""" client = bleak.BleakClient("00:00:00:00:00:01") # Should return cleanly without raising even though connect() never ran. assert await client.disconnect() is None @pytest.mark.usefixtures("enable_bluetooth") def test_set_scanning_filter_applies_uuid_filter( caplog: pytest.LogCaptureFixture, ) -> None: """Test set_scanning_filter stores an effective UUID filter change.""" scanner = HaBleakScannerWrapper() uuid = "cba20d00-224d-11e6-9fb8-0002a5d5c51b" # A real UUID filter is an effective change (_map_filters returns True), # but the scanner is not started, so set_scanning_filter short-circuits on # the _started guard and only stores the mapped filter. scanner.set_scanning_filter(service_uuids=[uuid]) assert scanner._mapped_filters == {FILTER_UUIDS: {uuid}} assert "Only UUIDs filters are supported" not in caplog.text