pax_global_header00006660000000000000000000000064152222256670014523gustar00rootroot0000000000000052 comment=d7105557b712ad8fc90ae11e3340a118fdf87b3a Bluetooth-Devices-habluetooth-1390f03/000077500000000000000000000000001522222566700176335ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-1390f03/.all-contributorsrc000066400000000000000000000004621522222566700234660ustar00rootroot00000000000000{ "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-1390f03/.copier-answers.yml000066400000000000000000000010531522222566700233740ustar00rootroot00000000000000# 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-1390f03/.editorconfig000066400000000000000000000004441522222566700223120ustar00rootroot00000000000000# 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-1390f03/.github/000077500000000000000000000000001522222566700211735ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-1390f03/.github/ISSUE_TEMPLATE/000077500000000000000000000000001522222566700233565ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-1390f03/.github/ISSUE_TEMPLATE/1-bug_report.md000066400000000000000000000004221522222566700262040ustar00rootroot00000000000000--- 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-1390f03/.github/ISSUE_TEMPLATE/2-feature-request.md000066400000000000000000000006721522222566700271650ustar00rootroot00000000000000--- 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-1390f03/.github/actions/000077500000000000000000000000001522222566700226335ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-1390f03/.github/actions/setup-uv-python/000077500000000000000000000000001522222566700257425ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-1390f03/.github/actions/setup-uv-python/action.yml000066400000000000000000000055241522222566700277500ustar00rootroot00000000000000name: 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-1390f03/.github/dependabot.yml000066400000000000000000000013441522222566700240250ustar00rootroot00000000000000# 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-1390f03/.github/labels.toml000066400000000000000000000035151522222566700233360ustar00rootroot00000000000000[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-1390f03/.github/workflows/000077500000000000000000000000001522222566700232305ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-1390f03/.github/workflows/auto-merge.yml000066400000000000000000000011341522222566700260170ustar00rootroot00000000000000name: 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-1390f03/.github/workflows/ci.yml000066400000000000000000000304651522222566700243560ustar00rootroot00000000000000name: 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4 - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 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@fb8b3582c8e4def4969c97caa2f19720cb33a72f # 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4 - name: Set up uv uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.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@ece7cb06caefa5fff74198d8649806c4678c61a1 # v5 with: python-version: "3.14" cache: "poetry" allow-prereleases: true - name: Cache poetry venv id: cache-venv uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 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@a4a36bb07c0638b0b4ca52bf1f3dad1b4289e52f # v3 with: token: ${{ secrets.CODSPEED_TOKEN }} run: poetry run pytest --no-cov -vvvvv --codspeed mode: instrumentation # Dry run on PRs and non-main pushes. No environment, no publish # permissions, no OIDC, so PR runs carry no release blast radius. release-dry-run: needs: - test - lint if: github.ref_name != 'main' runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4 with: fetch-depth: 0 ref: ${{ github.ref }} - name: Create local branch name env: BRANCH: ${{ github.head_ref || github.ref_name }} run: git switch -C "$BRANCH" - name: Test release uses: python-semantic-release/python-semantic-release@350c48fcb3ffcdfd2e0a235206bc2ecea6b69df0 # v10.5.3 with: no_operation_mode: true # Real release, only on main. The release environment and write/OIDC # permissions are scoped to this job so they never apply to PR runs. release: needs: - test - lint if: github.ref_name == 'main' runs-on: ubuntu-latest environment: release concurrency: group: release-${{ 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 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4 with: fetch-depth: 0 ref: ${{ github.ref }} token: ${{ steps.app-token.outputs.token || github.token }} - name: Create local branch name env: BRANCH: ${{ github.ref_name }} run: git switch -C "$BRANCH" # On main branch: actual PSR + upload to PyPI & GitHub - name: Release uses: python-semantic-release/python-semantic-release@350c48fcb3ffcdfd2e0a235206bc2ecea6b69df0 # v10.5.3 id: release 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4 with: ref: ${{ needs.release.outputs.newest_release_tag }} fetch-depth: 0 # Used to host cibuildwheel - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # 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@294735312765b09d24a2fbec22660ce817587d55 # v4.1.0 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-1390f03/.github/workflows/hacktoberfest.yml000066400000000000000000000005341522222566700266010ustar00rootroot00000000000000name: 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-1390f03/.github/workflows/issue-manager.yml000066400000000000000000000013401522222566700265110ustar00rootroot00000000000000name: 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.8.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-1390f03/.github/workflows/poetry-upgrade.yml000066400000000000000000000003371522222566700267250ustar00rootroot00000000000000name: 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-1390f03/.gitignore000066400000000000000000000041141522222566700216230ustar00rootroot00000000000000# 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-1390f03/.gitpod.yml000066400000000000000000000003061522222566700217210ustar00rootroot00000000000000tasks: - 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-1390f03/.idea/000077500000000000000000000000001522222566700206135ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-1390f03/.idea/habluetooth.iml000066400000000000000000000005151522222566700236350ustar00rootroot00000000000000 Bluetooth-Devices-habluetooth-1390f03/.idea/watcherTasks.xml000066400000000000000000000052531522222566700240050ustar00rootroot00000000000000 Bluetooth-Devices-habluetooth-1390f03/.idea/workspace.xml000066400000000000000000000027361522222566700233430ustar00rootroot00000000000000 Bluetooth-Devices-habluetooth-1390f03/.pre-commit-config.yaml000066400000000000000000000027641522222566700241250ustar00rootroot00000000000000# 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.20 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 args: # "hass" is the Home Assistant instance variable, not a typo of "hash". - -L - hass - repo: https://github.com/pre-commit/mirrors-mypy rev: v2.1.0 hooks: - id: mypy additional_dependencies: [] Bluetooth-Devices-habluetooth-1390f03/.readthedocs.yml000066400000000000000000000010511522222566700227160ustar00rootroot00000000000000# 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-24.04 tools: python: "3.13" 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-1390f03/CHANGELOG.md000066400000000000000000001365431522222566700214600ustar00rootroot00000000000000# Changelog ## v6.26.5 (2026-07-04) ### Bug fixes - Require one and a half stale windows before a roam handoff ([`d495968`](https://github.com/Bluetooth-Devices/habluetooth/commit/d4959685247283b52201f24d36ad896c9bec521a)) ## v6.26.4 (2026-07-04) ### Bug fixes - Damp stale handoffs per device class and give rescues an accept grace ([`65d3bb5`](https://github.com/Bluetooth-Devices/habluetooth/commit/65d3bb593352505e9352e731652e96dd256bef15)) ## v6.26.3 (2026-07-04) ### Bug fixes - Replace the paused owner hold with rescue active scans ([`c443fba`](https://github.com/Bluetooth-Devices/habluetooth/commit/c443fba16eb12ae1d89c5d2b4b72641090e90520)) ## v6.26.2 (2026-07-01) ### Bug fixes - Keep a device on its owner while the owner is paused for a connection ([`0de6a60`](https://github.com/Bluetooth-Devices/habluetooth/commit/0de6a60d992cb41e178682af19dc2294aa4aee8e)) ## v6.26.1 (2026-07-01) ### Bug fixes - Damp owner reclaim across all recent owners, not just the last ([`d54cec1`](https://github.com/Bluetooth-Devices/habluetooth/commit/d54cec1d2d7a56a2dbf18d9efe5aa131f74061d4)) ## v6.26.0 (2026-06-25) ### Features - Bond on connect, encrypt bonded reconnects, and auto-confirm just works pairing ([`4d42391`](https://github.com/Bluetooth-Devices/habluetooth/commit/4d42391ee00f02da417b2893a0b7f96c65ae6852)) ### Documentation - Correct the stale att transport docstring and note the bond list limitation ([`4c2a97f`](https://github.com/Bluetooth-Devices/habluetooth/commit/4c2a97fb51b5d4abfa8a25131812eced3603c9ae)) ## v6.25.1 (2026-06-25) ### Bug fixes - Don't fold missing rssi into the smoothed average ([`de0836e`](https://github.com/Bluetooth-Devices/habluetooth/commit/de0836eff87b849fc265b773be48b382c28d164a)) ## v6.25.0 (2026-06-25) ### Features - Add asymmetric rssi switch hysteresis to stop reclaim flapping ([`cc183ec`](https://github.com/Bluetooth-Devices/habluetooth/commit/cc183ecafb1631919a6a206453c281c9ca7e97e4)) ## v6.24.0 (2026-06-24) ### Features - Smooth per-source rssi for advertisement owner selection ([`82d53cc`](https://github.com/Bluetooth-Devices/habluetooth/commit/82d53ccfd87a4aefca3070b07f07acd81ebede2c)) ## v6.23.1 (2026-06-24) ### Bug fixes - Drop the notify handler when the stop_notify cccd write fails ([`a71b47a`](https://github.com/Bluetooth-Devices/habluetooth/commit/a71b47aba72a39520af33d890d306028d8510b20)) - Keep a strong owner against a comparable scanner on stale ([`26be118`](https://github.com/Bluetooth-Devices/habluetooth/commit/26be1189bdbc0bf2c7f0c6b152ab80388df1d90f)) ## v6.23.0 (2026-06-24) ### Bug fixes - Stop the mgmt scanner watchdog from resurrecting a stopped scanner ([`8f9e038`](https://github.com/Bluetooth-Devices/habluetooth/commit/8f9e038c6b6c56f69d32eba5355ba7949f84f8d9)) ### Features - Escalate l2cap security on demand to match bluetoothd ([`bd8cd6b`](https://github.com/Bluetooth-Devices/habluetooth/commit/bd8cd6b1a7eed432610086c998164bc173e2849c)) ### Refactoring - Dedupe mgmt command result checks in the bluez channel ([`24e6513`](https://github.com/Bluetooth-Devices/habluetooth/commit/24e6513585999badedefe534d14417cf401290ab)) ## v6.22.0 (2026-06-24) ### Features - Fall back to bleak when l2cap sockets are not permitted ([`58dcd60`](https://github.com/Bluetooth-Devices/habluetooth/commit/58dcd608a53551a6a3039a039a808a912d2303ea)) ### Documentation - Add home assistant integration guide for the mgmt scanner ([`a90a8d0`](https://github.com/Bluetooth-Devices/habluetooth/commit/a90a8d0f4164a220e98733c44ef15d21e5d5d13f)) ## v6.21.0 (2026-06-24) ### Features - Add mgmt passkey and confirmation pairing replies ([`84531ac`](https://github.com/Bluetooth-Devices/habluetooth/commit/84531acebac9378ec4447f559c9dd4d6676ab6f3)) ## v6.20.0 (2026-06-24) ### Features - Add bond key serialization and persistence hooks ([`f8a885d`](https://github.com/Bluetooth-Devices/habluetooth/commit/f8a885dedaac4533367d45a4614114c3a810b0db)) ## v6.19.1 (2026-06-24) ### Bug fixes - Require durable staleness before a weaker scanner takes ownership ([`e35842e`](https://github.com/Bluetooth-Devices/habluetooth/commit/e35842e10560aa046ad7933ea0dfd7b469164499)) ## v6.19.0 (2026-06-24) ### Features - Wire mgmt pairing and bond restore into the gatt client ([`9f81fff`](https://github.com/Bluetooth-Devices/habluetooth/commit/9f81fffdd91e2c523333802c8aacb8f11f496689)) ### Testing - Cover cross-source advertisement arbitration ([`b675c51`](https://github.com/Bluetooth-Devices/habluetooth/commit/b675c51e9e16f8a58057697bd2287a71d4e4e786)) ## v6.18.0 (2026-06-24) ### Features - Warn when active scan is requested with a passive-only scanner ([`557d5c9`](https://github.com/Bluetooth-Devices/habluetooth/commit/557d5c9ae2388adc745151c354241d5ed694df16)) ## v6.17.0 (2026-06-24) ### Features - Capture new long term key and auth failed mgmt events ([`70eb674`](https://github.com/Bluetooth-Devices/habluetooth/commit/70eb67406d46d2dc6e19b7a2d66eabe5982ea851)) ## v6.16.0 (2026-06-24) ### Features - Add mgmt load long term keys for bond restore ([`b108fb9`](https://github.com/Bluetooth-Devices/habluetooth/commit/b108fb914e6b1d00a312d2b9f9b43820af4e3383)) - Add last active window timing to diagnostics ([`b4c4769`](https://github.com/Bluetooth-Devices/habluetooth/commit/b4c47696d2b59b85edc443a5280c80e707b507b6)) ## v6.15.0 (2026-06-23) ### Features - Add mgmt pair, unpair, and disconnect commands ([`ff963bf`](https://github.com/Bluetooth-Devices/habluetooth/commit/ff963bff2e06067a11344e2ab7e1525b99f3a8d5)) ## v6.14.0 (2026-06-23) ### Features - Add mgmt-based local scanner and factory ([`2d83455`](https://github.com/Bluetooth-Devices/habluetooth/commit/2d83455c2b5c5253157539a786565a3b009e208f)) ## v6.13.0 (2026-06-23) ### Features - Raise the maximum auto active scan window to 35s ([`eb05581`](https://github.com/Bluetooth-Devices/habluetooth/commit/eb05581216a5e6e2f334885aed0d11d3af48f0e6)) ## v6.12.0 (2026-06-23) ### Features - Add l2cap gatt client backend ([`f660749`](https://github.com/Bluetooth-Devices/habluetooth/commit/f660749b3117c08a91c4b1759de537241891d261)) ## v6.11.0 (2026-06-23) ### Features - Add l2cap att socket transport ([`2432667`](https://github.com/Bluetooth-Devices/habluetooth/commit/24326672059b69650e8cb7b722c50c91fa69502e)) ## v6.10.0 (2026-06-23) ### Features - Add att/gatt protocol codec ([`040e999`](https://github.com/Bluetooth-Devices/habluetooth/commit/040e999eed8f3361ee7b742c6c7c84917b4e7c91)) ### Refactoring - Move raw bluez advertisement adaptation into the bluez channel ([`08dadb9`](https://github.com/Bluetooth-Devices/habluetooth/commit/08dadb9ddcb5558f9547d20e0fd58524f94dfe98)) ## v6.9.0 (2026-06-22) ### Features - Add mgmt discovery commands to bluez channel ([`d352cbf`](https://github.com/Bluetooth-Devices/habluetooth/commit/d352cbf928cfea3e28bc51212efa6f971e372ebe)) ### Testing - Implement default_adapter on fakebluetoothadapters ([`def01e0`](https://github.com/Bluetooth-Devices/habluetooth/commit/def01e0a81cac030816dfc7a55289f6831f85cf0)) - Benchmark connecting-fallback resolution path ([`ac56ebe`](https://github.com/Bluetooth-Devices/habluetooth/commit/ac56ebe641bfab0b6e7ea5528b18afb7785b3ac2)) - Cover wrapper connect/param edge branches ([`48ca1ec`](https://github.com/Bluetooth-Devices/habluetooth/commit/48ca1ece7e9b39fba0c4671bc79ee4c6063806f0)) ### Refactoring - Split scanner into scanner_bleak with import shim ([`24d6ef3`](https://github.com/Bluetooth-Devices/habluetooth/commit/24d6ef3ac74bbedb338d4bbea98aeea0e5d7203c)) ## 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-1390f03/CLAUDE.md000066400000000000000000000225521522222566700211200ustar00rootroot00000000000000# 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-1390f03/CONTRIBUTING.md000066400000000000000000000074321522222566700220720ustar00rootroot00000000000000# 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-1390f03/LICENSE000066400000000000000000000261211522222566700206420ustar00rootroot00000000000000 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-1390f03/README.md000066400000000000000000000076041522222566700211210ustar00rootroot00000000000000# 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-1390f03/build_ext.py000066400000000000000000000044301522222566700221650ustar00rootroot00000000000000"""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_bleak.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-1390f03/docs/000077500000000000000000000000001522222566700205635ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-1390f03/docs/Makefile000066400000000000000000000013721522222566700222260ustar00rootroot00000000000000# 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-1390f03/docs/_static/000077500000000000000000000000001522222566700222115ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-1390f03/docs/_static/.gitkeep000066400000000000000000000000001522222566700236300ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-1390f03/docs/bond_persistence.md000066400000000000000000000057711522222566700244450ustar00rootroot00000000000000# Bond key persistence (experimental) > **Experimental.** This covers the DBus-free management/L2CAP path > (`HaScannerMgmt` + `HaMgmtClient`). The API is not stable and may change or be > renamed. Home Assistant does not select this path yet. When pairing over the BlueZ management socket, the kernel hands back a long-term key (LTK) per bond. `habluetooth` keeps these **in memory only** (it is a library and does no disk I/O), so without help they are lost on restart and the peer must be re-paired. To survive a restart, the consumer (Home Assistant) persists them and seeds them back on startup. Keys are **per adapter**: an LTK is tied to the local adapter identity it paired with, so it is only valid on that adapter. Persist keyed by adapter. ## What `habluetooth` provides - `LongTermKey` — the bonded key (`habluetooth.LongTermKey`). - `long_term_key_to_dict()` / `long_term_key_from_dict()` — JSON-safe (de)serialization (the `bytes` fields become hex strings), shaped like the discovery-cache helpers. - On `HaScannerMgmt`: - `export_long_term_keys() -> list[LongTermKey]` — snapshot for saving. - `restore_long_term_keys(keys)` — seed the store on startup. - `set_long_term_keys_changed_callback(callback)` — `callback` fires whenever the store changes (a pair captured a key, or an unpair removed one), so the consumer can schedule a debounced save. ## Wiring it in Home Assistant Mirror the existing remote-scanner storage (`Store` + `async_delay_save`), but keyed by adapter and holding a list of serialized keys: ```python from habluetooth import ( HaScannerMgmt, LongTermKeyDict, long_term_key_from_dict, long_term_key_to_dict, ) from homeassistant.helpers.storage import Store BOND_STORAGE_VERSION = 1 BOND_STORAGE_KEY = "bluetooth.bonds" SAVE_DELAY = 5 StoredBonds = dict[str, list[LongTermKeyDict]] # keyed by adapter (e.g. "hci0") class BondStorage: def __init__(self, hass): self._store: Store[StoredBonds] = Store( hass, BOND_STORAGE_VERSION, BOND_STORAGE_KEY ) self._data: StoredBonds = {} async def async_setup(self): self._data = await self._store.async_load() or {} def async_attach(self, scanner: HaScannerMgmt): adapter = scanner.adapter # Seed the scanner with any keys we saved last run. if saved := self._data.get(adapter): scanner.restore_long_term_keys( long_term_key_from_dict(key) for key in saved ) # Save (debounced) whenever the scanner's bonds change. scanner.set_long_term_keys_changed_callback( lambda: self._async_save(scanner) ) def _async_save(self, scanner: HaScannerMgmt): self._data[scanner.adapter] = [ long_term_key_to_dict(key) for key in scanner.export_long_term_keys() ] self._store.async_delay_save(lambda: self._data, SAVE_DELAY) ``` Call `async_attach()` when the scanner is created and `set_long_term_keys_changed_callback(None)` if it is torn down. Bluetooth-Devices-habluetooth-1390f03/docs/changelog.md000066400000000000000000000000601522222566700230300ustar00rootroot00000000000000(changelog)= ```{include} ../CHANGELOG.md ``` Bluetooth-Devices-habluetooth-1390f03/docs/conf.py000066400000000000000000000012201522222566700220550ustar00rootroot00000000000000# 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.26.5" # 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-1390f03/docs/contributing.md000066400000000000000000000000661522222566700236160ustar00rootroot00000000000000(contributing)= ```{include} ../CONTRIBUTING.md ``` Bluetooth-Devices-habluetooth-1390f03/docs/home_assistant_integration.md000066400000000000000000000127631522222566700265420ustar00rootroot00000000000000# Wiring the DBus-free local scanner into Home Assistant (experimental) > **Experimental.** The management/L2CAP path (`HaScannerMgmt`, `HaMgmtClient`, > `create_local_scanner`) is a work in progress; the API may change or be > renamed. Nothing here is on by default — Home Assistant still constructs the > bleak-backed `HaScanner` until this wiring lands. The DBus-free path drives a local adapter entirely over the BlueZ management socket and a raw L2CAP ATT channel (discovery, connect, pairing/bonding), bypassing bluetoothd/DBus. `habluetooth` exposes everything Home Assistant needs to opt in; the only change is in `homeassistant/components/bluetooth`. ## 1. Select the scanner via the factory Today the bluetooth component constructs the scanner directly (`homeassistant/components/bluetooth/__init__.py`): ```python scanner = HaScanner(mode, adapter, address) ``` Swap that for the factory. It returns `HaScannerMgmt` when the management path is usable, and falls back to the bleak-backed `HaScanner` otherwise, so it is safe everywhere: ```python from habluetooth import create_local_scanner scanner = create_local_scanner(mode, adapter, address) ``` The returned object is a `BaseHaScanner`, so the rest of the flow is unchanged: `scanner.async_setup()`, `async_register_scanner(hass, scanner, connection_slots=slots)`, then `await scanner.async_start()`. The advertisement side-channel, connection slots, and registration all work as before. ### When the factory picks the mgmt scanner `create_local_scanner` returns `HaScannerMgmt` only when **all** hold; otherwise it returns `HaScanner`: - the host is Linux, - the management socket is available and reports discovery capability (`CAP_NET_ADMIN`/`CAP_NET_RAW`), - the adapter is an `hciN` adapter, - a real adapter BD_ADDR is known (not `DEFAULT_ADDRESS`), - the mode is not `AUTO` (the mgmt scanner does not yet implement active-window promotion, so `AUTO` stays on the bleak path). Note Home Assistant already converts `AUTO` to `ACTIVE` for adapters that cannot do passive scanning before this call, so on those adapters the mgmt scanner is eligible. ### Recommended gating Because the path is experimental, gate the swap behind an opt-in (a config/YAML flag or an environment check) so it is off by default and a user can enable it per install. Fall back to the plain `HaScanner` constructor when the flag is off. ### Controller ownership (required before enabling the connect path) The LE long-term-key table belongs to the **controller and its kernel**, not to a process or container. `AF_BLUETOOTH`/HCI control sockets are not network-namespaced, so a `habluetooth` running in the Home Assistant container and a `bluetoothd` running on the host share the **same** `hciN` and the **same** kernel bond table. The typical deployment (HA in a container, host `bluetoothd`) is therefore a single shared controller, not two isolated ones. The mgmt **discovery** side already coexists with host `bluetoothd` (it only reads advertisement events; that is the existing side channel). The mgmt **connect/pair** path does not: `LOAD_LONG_TERM_KEYS`, mgmt pairing, and the L2CAP ACL/GATT are stateful on the shared controller and collide with a `bluetoothd` that is still managing the same `hciN`. In particular `LOAD_LONG_TERM_KEYS` replaces the controller's whole key list, clearing the bonds `bluetoothd` loaded; those keys cannot be recovered (DBus never exposes key material, and `bluetoothd`'s `/var/lib/bluetooth` store is not reachable from the HA container). So the model is **per-adapter partitioning**, never co-management of one `hciN`: - Controllers `bluetoothd` manages stay on the bleak/DBus path (today's behavior); their bonds remain `bluetoothd`'s and are untouched. - The mgmt connect path may own a controller only if `bluetoothd` is **not** managing it (for example a dedicated adapter the host bluetooth service is configured to ignore). It then creates its own bonds from the first pairing, so `LOAD_LONG_TERM_KEYS` only ever replaces its own list. > **Factory gap.** `create_local_scanner` currently gates only on mgmt discovery > capability and L2CAP availability; it has **no** signal for "this controller is > not also managed by `bluetoothd`." Until that exclusivity gate exists, treat > the connect path as safe only on a controller you know `bluetoothd` does not > manage, and keep the opt-in above scoped to such adapters. ## 2. Persist bonds across restarts Pairing over mgmt yields long-term keys that `habluetooth` keeps **in memory** only. To survive a restart without re-pairing, the component persists them with its own `Store` and seeds them back on startup. The scanner exposes the hooks; see [Bond key persistence](bond_persistence.md) for the full worked example. In short, for each scanner that is an `HaScannerMgmt`: ```python from habluetooth import HaScannerMgmt if isinstance(scanner, HaScannerMgmt): bond_storage.async_attach(scanner) # restore_long_term_keys + change callback ``` Attach **before** `async_start()` so restored keys are loaded before the first connection, and clear the change callback (`set_long_term_keys_changed_callback(None)`) when the scanner is torn down. ## What does not change - Scanner registration, connection-slot accounting, and the unavailable-tracking timer are inherited from `BaseHaScanner` and behave as before. - Remote (ESPHome) scanners and non-Linux/macOS local adapters are untouched; the factory returns the existing `HaScanner` for them. - No public API is removed; `HaScanner` remains available for the fallback path. Bluetooth-Devices-habluetooth-1390f03/docs/index.md000066400000000000000000000004241522222566700222140ustar00rootroot00000000000000# Welcome to habluetooth documentation! ```{toctree} :caption: Installation & Usage :maxdepth: 2 installation usage home_assistant_integration bond_persistence ``` ```{toctree} :caption: Project Info :maxdepth: 2 changelog contributing ``` ```{include} ../README.md ``` Bluetooth-Devices-habluetooth-1390f03/docs/installation.md000066400000000000000000000004151522222566700236060ustar00rootroot00000000000000(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-1390f03/docs/make.bat000066400000000000000000000013751522222566700221760ustar00rootroot00000000000000@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-1390f03/docs/usage.md000066400000000000000000000003261522222566700222120ustar00rootroot00000000000000(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-1390f03/examples/000077500000000000000000000000001522222566700214515ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-1390f03/examples/bluez_api.py000066400000000000000000000024011522222566700237720ustar00rootroot00000000000000import asyncio import logging from habluetooth import BluetoothManager, BluetoothScanningMode, 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-1390f03/poetry.lock000066400000000000000000005614751522222566700220510ustar00rootroot00000000000000# 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 = ["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.4.0" description = "Tools to enumerate and find Bluetooth Adapters" optional = false python-versions = ">=3.10" groups = ["main"] files = [ {file = "bluetooth_adapters-2.4.0-py3-none-any.whl", hash = "sha256:f2841c49e8750658ecb8c1d46d2c05a9de4740362eb83e3e7e501d3092c0377e"}, {file = "bluetooth_adapters-2.4.0.tar.gz", hash = "sha256:e8a61935a867deb8af981e986bdf504c4398895ff254eac626790184a4fdd1e3"}, ] [package.dependencies] aiooui = ">=0.1.1" bleak = ">=3.0.2" dbus-fast = {version = ">=5.0.14", markers = "platform_system == \"Linux\""} uart-devices = ">=0.1.0" usb-devices = ">=0.4.5" [package.extras] docs = ["Sphinx (>=8.1.3,<9)", "myst-parser (>=4.0.1,<4.1)", "sphinx-rtd-theme (>=3.1.0,<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.1" 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.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1"}, {file = "cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225"}, {file = "cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691"}, {file = "cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242"}, {file = "cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08"}, {file = "cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6"}, {file = "cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8"}, {file = "cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429"}, {file = "cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f"}, {file = "cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f"}, {file = "cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41"}, {file = "cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6"}, {file = "cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158"}, {file = "cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24"}, {file = "cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345"}, {file = "cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4"}, {file = "cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991"}, {file = "cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265"}, {file = "cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17"}, {file = "cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411"}, {file = "cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02"}, {file = "cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa"}, {file = "cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3"}, {file = "cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c"}, {file = "cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72"}, {file = "cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9"}, {file = "cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471"}, {file = "cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2"}, {file = "cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67"}, {file = "cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8"}, {file = "cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a"}, {file = "cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577"}, {file = "cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1"}, {file = "cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d"}, {file = "cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6"}, {file = "cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46"}, {file = "cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401"}, {file = "cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b"}, {file = "cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1"}, {file = "cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475"}, {file = "cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1"}, {file = "cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac"}, {file = "cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a"}, {file = "cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd"}, {file = "cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c"}, {file = "cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9"}, {file = "cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92"}, {file = "cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a"}, {file = "cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a"}, ] [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.22" description = "A faster version of dbus-next" optional = false python-versions = ">=3.10" groups = ["main", "dev"] files = [ {file = "dbus_fast-5.0.22-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:027b358d55164f722d7432d6734506751c89b5cee8d516e7282eff3eaa763f05"}, {file = "dbus_fast-5.0.22-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8244561e503cffcd5e6b1ff6544a44aa0b65b6856f2f64d04345ebf82f8ac48"}, {file = "dbus_fast-5.0.22-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32869678240f233ece5a1137960c0337699dbb39acc02c155929f52543f03fb"}, {file = "dbus_fast-5.0.22-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f74700b1b4fd58acb76f8790e2cd475084d0e59149e0cc035abd239435a339b7"}, {file = "dbus_fast-5.0.22-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:246780dcda59d26dd2fb1d9a15991bc8483e9c241cee8713920a39a363ad4d39"}, {file = "dbus_fast-5.0.22-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:58e4d41f9fea22cfd6dacdf03d53b932bbe15a825af5abad3ca79b91493a15c3"}, {file = "dbus_fast-5.0.22-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d8ca39f3f2ab34f88bb343db87c7cf8b07eefa046b23d8c38b3387f150ac609e"}, {file = "dbus_fast-5.0.22-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f0926e4cf49989b4ec8233e8fd462eb35a640fcfe81bb75d91675dd47489022b"}, {file = "dbus_fast-5.0.22-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846f9a6602b4383f989201f7851459fb225a8912cd24b38e63894748545c3040"}, {file = "dbus_fast-5.0.22-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:886b43446b6fdc3986befbbb88db1365b14e49dd0a7edf84c2c67ac66c7160a4"}, {file = "dbus_fast-5.0.22-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a699fca957acc845ddb12b47f741dba23ce147fdb93583e0c7e7bad3e9b2355"}, {file = "dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9a5b05fd4973862042e5bee2c5e8c5a15297e0b33a975bf25b44becf7bcb3618"}, {file = "dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:6c4dae5292a7924ec062815c34b49043d8386cd22e165f9fb4012de00997cdf1"}, {file = "dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b7d90a52be79acbaef257f3a81d5b9b9dec40f1bad29429ac5c7802684fb9b84"}, {file = "dbus_fast-5.0.22-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f72b77be63f7bb24cf42936ad10994d40f43fed691f857f7854b5882d6a5227c"}, {file = "dbus_fast-5.0.22-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f0bcad7f71d2304a68a5b0bc0d24c3fcc14710a2ffcf5f2a27521e3aece71ca"}, {file = "dbus_fast-5.0.22-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ffcf16034f71a801bd2108aeffb6337d104c9459e8b1a218d16a917c8a2d2e9"}, {file = "dbus_fast-5.0.22-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98de6d2c200d8182e1fd0bdde3206fa556b8fa14ebb752a044cd8daa87b4658c"}, {file = "dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b013437b66dc22b8d9aca5e0b0d46bf1980208a143409469fe482d9684a2a717"}, {file = "dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:855f15b7f7805171da2b82de1c317d01cfbb9fb8ac61fcc1e8dec54d8c69fab7"}, {file = "dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7d1c42963235cfc015a2d2b8c5fe42b65387493b4ad4ce0ec122601c805e6742"}, {file = "dbus_fast-5.0.22-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:26e26b409e1e6edf5e2a4df8d192625fb38876b074fb5c7d0a5b15c5792e549d"}, {file = "dbus_fast-5.0.22-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de10ff3b3cb2acb1c09fe17158a470519000d37bb5ee5fd69c4075e81ce8dcf5"}, {file = "dbus_fast-5.0.22-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:979761985fe343c701f2b7575285d6e370123f7231d4656209ef7824bb686bbb"}, {file = "dbus_fast-5.0.22-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb73f1d8374253b7c17d69e902cf2ded1bfb089cb6ae67c10b4e0bdfe1b8fe08"}, {file = "dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b67a02037eb58bcf9e445df60ea0d9d7346fd334abde3aa62e03c75823b53979"}, {file = "dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:83940ea00d7ee2f0c5bcb5d19d7d05e7949e52d467616a0b735d72e7285402ec"}, {file = "dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:279d212e9fb262d595af2e4b5b9e951bc00c73a5c8eeb50f158caa13705b9c84"}, {file = "dbus_fast-5.0.22-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:511655d915692f55b8c5f5a535acf80cca9c6d1a35384db7fcfdabaae05dd837"}, {file = "dbus_fast-5.0.22-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:703e0f8f9af52e8e053394ee2b578042be0c3d8ea2b1488f9db8cb14393cc13f"}, {file = "dbus_fast-5.0.22-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb1d7e8e65561d0fd438004fd9e0f981c8a862912fed58dd4e29db1936c39d73"}, {file = "dbus_fast-5.0.22-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:959fab6420897ab99410e67d6f9f9a7f6f4cedb6014700768f5e2d71dbff5dc6"}, {file = "dbus_fast-5.0.22-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:eb31c5ff339a7071b914617a69d5b7c6ba7d411da4b01a5f9b5b2fe51e9d1301"}, {file = "dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:856f0543c593f3480e93e67bcd1aa4ddc1d94a6076cfd3ad4e0f5e2b01b33dc3"}, {file = "dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:96d231d128c1f46f263790335897195dde9dac2f38571782db8ae1d8647bd548"}, {file = "dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:595bd3ccfd8318cbafff79f33a15709fee3728724fd61d5fa220080d73b574cb"}, {file = "dbus_fast-5.0.22-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:69a077c296eaab8c30160e861b5514c33d99d67d41d17dbf02e89aae44543b11"}, {file = "dbus_fast-5.0.22-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04bac97d0cb754a4d13037d0132517f1df28192d6e0568a0bf6df06623062285"}, {file = "dbus_fast-5.0.22-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3eb57d592d84b0bb90e0c077db7ecb61562f49cc9b86a3ef08cbe17243e9cc4f"}, {file = "dbus_fast-5.0.22-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:de4d235d1282ebb3ab65b6cddab84e914c045d92ceb381ddcbdbaf66bf1fb132"}, {file = "dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:048f34299fbe82d7b87c56f47e8bd83f62339a4517685abc6671d603a55d2c89"}, {file = "dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:92df9fb6d8adeb17b534621c2ee730295bbe1d0c2584d5c82b1db478e3f04e8f"}, {file = "dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7be4271e38251f1ad726962dec60da887c8ed352d157352e4fc27f56aece5c5d"}, {file = "dbus_fast-5.0.22-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b3db6388b80ca167299f2eb8eb60e74f07c6e33a1bc4320c42bd9e5ab192c587"}, {file = "dbus_fast-5.0.22-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a127ff97079fd052d2b5dd752abc3fc5678b0d15ce9d04be1796bcc7f90b8b03"}, {file = "dbus_fast-5.0.22-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7af3e81287163dbae29d583a19fdab717a896bb097946adde415439b0ee322fb"}, {file = "dbus_fast-5.0.22-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a160cb931f22c43ddc8efe6ff33f03d3111442b9c0007ba58269a717296d8536"}, {file = "dbus_fast-5.0.22-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7f2af2fc27f3f188387befad5901953863fc8fa6451202083c2b9ec9f970028f"}, {file = "dbus_fast-5.0.22.tar.gz", hash = "sha256:34dc67d7d21a12399828dd13e63b352750580beea54ea7c729e708f2d2905fef"}, ] 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 = ["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.1.1" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ {file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"}, {file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"}, ] [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 = ["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.3.1" description = "The little ASGI library that shines." optional = false python-versions = ">=3.10" groups = ["docs"] files = [ {file = "starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6"}, {file = "starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0"}, ] [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)", "httpx2 (>=2.0.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 = "600292eedbb6336b494fe432880eaa1f42f3a0329b005c9690c281eaf6eb4870" Bluetooth-Devices-habluetooth-1390f03/pyproject.toml000066400000000000000000000127501522222566700225540ustar00rootroot00000000000000[build-system] requires = ['setuptools>=77.0', 'Cython>=3', "poetry-core>=2.0.0"] build-backend = "poetry.core.masonry.api" [project] name = "habluetooth" version = "6.26.5" 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.4.0" bluetooth-auto-recovery = ">=1.6.4" async-interrupt = ">=1.1.1" dbus-fast = { version = ">=5.0.22", markers = "platform_system == 'Linux'" } btsocket = ">=0.3.0" [tool.poetry.group.dev.dependencies] pytest = ">=9.1.1,<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.22" [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-1390f03/renovate.json000066400000000000000000000001011522222566700223410ustar00rootroot00000000000000{ "extends": ["github>browniebroke/renovate-configs:python"] } Bluetooth-Devices-habluetooth-1390f03/src/000077500000000000000000000000001522222566700204225ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-1390f03/src/habluetooth/000077500000000000000000000000001522222566700227405ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-1390f03/src/habluetooth/__init__.py000066400000000000000000000054251522222566700250570ustar00rootroot00000000000000__version__ = "6.26.5" 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 .channels.bluez import LongTermKey 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_bleak import BluetoothScanningMode, HaScanner, ScannerStartError from .scanner_device import BluetoothScannerDevice from .scanner_mgmt import HaScannerMgmt, create_local_scanner from .storage import ( DiscoveredDeviceAdvertisementData, DiscoveredDeviceAdvertisementDataDict, DiscoveryStorageType, LongTermKeyDict, discovered_device_advertisement_data_from_dict, discovered_device_advertisement_data_to_dict, expire_stale_scanner_discovered_device_advertisement_data, long_term_key_from_dict, long_term_key_to_dict, ) 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", "HaScannerMgmt", "HaScannerModeChange", "HaScannerRegistration", "HaScannerRegistrationEvent", "HaScannerType", "LongTermKey", "LongTermKeyDict", "ScannerStartError", "create_local_scanner", "discovered_device_advertisement_data_from_dict", "discovered_device_advertisement_data_to_dict", "expire_stale_scanner_discovered_device_advertisement_data", "get_manager", "long_term_key_from_dict", "long_term_key_to_dict", "set_manager", ] Bluetooth-Devices-habluetooth-1390f03/src/habluetooth/advertisement_tracker.pxd000066400000000000000000000007571522222566700300530ustar00rootroot00000000000000import 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-1390f03/src/habluetooth/advertisement_tracker.py000066400000000000000000000072541522222566700277070ustar00rootroot00000000000000"""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-1390f03/src/habluetooth/auto_scheduler.pxd000066400000000000000000000117431522222566700264710ustar00rootroot00000000000000import 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 double _RESCUE_SCAN_ACCEPT_SECONDS 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 double _last_window_at cdef public double _last_window_duration 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(window_end=double) cpdef void _mark_window_open(self, double now, double duration) @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) @cython.locals(entries=dict) cpdef void advance_to(self, str address, double due_time) 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 dict _last_window_by_address cdef public dict _rescue_accept_after cdef public set _rescue_tasks 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( now=double, coarse_now=double, requests=set, ) cpdef void trigger_rescue( self, str address, str challenger_source, str owner_source ) @cython.locals(accept_after=double) cpdef void _record_rescue_accept(self, str address, double coverage_time) cpdef void _record_window_dispatch( self, str address, double dispatch_now, str source, double coverage_time, ) @cython.locals(address=str) cpdef void _mark_delegated_dispatch( self, str source, list addresses, double dispatch_now, double duration, double coverage_time, ) @cython.locals(coverage_time=double) cpdef void _record_rescue_accepts(self, list due_buckets) cpdef void _rescue_owner_side( self, str address, str owner_source, set requests, double now, double coarse_now ) cpdef void _rescue_task_done( self, str name, double duration, object task ) @cython.locals(duration=double) cpdef void _rescue_challenger_side( self, str address, object challenger, set requests, object loop, double now, double coarse_now, ) @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-1390f03/src/habluetooth/auto_scheduler.py000066400000000000000000002112201522222566700263160ustar00rootroot00000000000000""" 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). Rescue windows ============== When the manager defers a stale ownership handoff for a tracked address (the owner went silent past its interval but is not durably gone — issue #591), it calls ``trigger_rescue(address, challenger_source, owner_source)``. The owner side is served through the normal schedule (due times clamped to now, worker woken, connecting fallback included); the challenger side gets a directly delegated window so its next capture carries a fresh scan response; sides that are already ACTIVE and scanning need no window and just record coverage, and an ACTIVE owner counts as covered. Every dispatch or coverage site records the accept time in ``_rescue_accept_after`` (coverage + RESCUE_SCAN_ACCEPT_SECONDS, coarse monotonic so it compares with advertisement times); once a window has been actively scanning that long, a capture cannot be a delayed passive one, so the manager hands the device off on the first challenger advertisement that postdates the accept time while the owner stayed silent, without waiting for the window to close. Invariants ========== * At most one outstanding window per scanner, guaranteed by the single per-worker task; the ``_window_end`` check in ``_tick`` is a defense-in-depth guard for future callers, not the guarantor. * 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 when history exists; ``on_advertisement`` bootstraps on first sight otherwise and 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 functools import partial from typing import TYPE_CHECKING, Any from bleak_retry_connector import NO_RSSI_VALUE from bluetooth_data_tools import monotonic_time_coarse 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, RESCUE_SCAN_ACCEPT_SECONDS, ) from .models import BluetoothScanningMode if TYPE_CHECKING: from collections.abc import Iterable 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 _RESCUE_SCAN_ACCEPT_SECONDS = RESCUE_SCAN_ACCEPT_SECONDS # 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", "_last_window_at", "_last_window_duration", "_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 # When this worker last opened (or was delegated) an active # window, and that window's duration. Diagnostics-only; 0.0 # means "never". Set at dispatch (pre-await), like _window_end. self._last_window_at: float = 0.0 self._last_window_duration: float = 0.0 # 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. * A fallback the dispatcher delegates to *is* actively scanning, so its sweep floor is satisfied (see "Invariants" — any active window advances ``_sweep_last_completed``) regardless of how short the delegated window is. """ self._mark_window_open(now, window_end - now) def _mark_window_open(self, now: float, duration: float) -> None: """ Record a window opening at ``now`` running for ``duration`` seconds. Single write site for the window-bookkeeping quartet: bumps ``_window_end`` (tick suppression) and ``_sweep_last_completed`` (any active window satisfies the sweep floor; see "Invariants") with ``max`` so a longer pre-existing value survives. The diagnostics duration is stored exactly when this window's end wins (float back-derivation of ``end - now`` is inexact) and from the post-max end when a longer window was already open, so the pair stays consistent with the end actually in effect. """ window_end = now + duration if self._window_end < window_end: self._window_end = window_end self._last_window_duration = duration else: self._last_window_duration = self._window_end - now if self._sweep_last_completed < now: self._sweep_last_completed = now self._last_window_at = 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). Canonical statement of the pre-await rule: due times must be advanced before the scanner await, so a new owner that wakes mid-window sees the entries already advanced and an ownership flip can't double-fire a window. Every dispatch path defers to this rule. """ last_window_by_address = self._scheduler._last_window_by_address source = self._scanner.source for _address, entries, due in due_buckets: last_window_by_address[_address] = (from_time, source) 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 # Open the window and take the sweep credit pre-await (any # active window satisfies the 12 h floor; see "Invariants"). self._mark_window_open(now, duration) # Advance pre-await so a mid-window ownership flip can't # double-fire; see _advance_due. self._advance_due(due_buckets, now) # Rescue accept times for the covered addresses (coarse # clock; see _rescue_accept_after). self._scheduler._record_rescue_accepts(due_buckets) 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 coarse_now = monotonic_time_coarse() 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: # Covered by another ACTIVE scanner: no later dispatch # here, so the tick time is the window start; the accept # keeps the standard grace so the owner gets one re-hear # window. Delegated addresses are recorded below at their # dispatch time. self._scheduler._record_window_dispatch(address, now, None, coarse_now) 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 for fb, fb_due in fallback_groups.values(): duration = self._scheduler._coalesce_duration(fb_due) # 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. Using it would put # ``_window_end`` in the past (leaving the fallback worker's # tick suppression off during the delegated window) and # under-report ``last_active_window`` for these addresses. dispatch_now = loop.time() self._scheduler._mark_delegated_dispatch( fb.source, [request.address for request in fb_due], dispatch_now, duration, monotonic_time_coarse(), ) 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 advance_to(self, address: str, due_time: float) -> None: """Pull every due time for ``address`` forward to ``due_time``.""" entries = self._due_at.get(address) if entries is None: return for request in entries: if entries[request] > due_time: entries[request] = due_time 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__ = ( "_last_window_by_address", "_loop", "_manager", "_on_demand_sweep_end", "_on_demand_sweep_future", "_requests_by_address", "_rescue_accept_after", "_rescue_tasks", "_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) # address -> (loop.time(), source) of the last active window that # covered it. ``source`` is the scanner that ran the window (the # owner on the normal path, the fallback when delegated, or None # when another ACTIVE scanner already covered it). Compare source # to the current owner to see if the right scanner scanned it. # Diagnostics-only; pruned with requests. self._last_window_by_address: dict[str, tuple[float, str | None]] = {} # address -> coarse-monotonic time after which a capture from the # scanning side is trusted as an active capture (coverage time + # RESCUE_SCAN_ACCEPT_SECONDS at every site, so the owner always # gets one re-hear window). Written at every dispatch site plus # trigger_rescue; # read by the manager's stale-handoff deferral, which hands a # device off on the first challenger advertisement that postdates # this (issue #591). Coarse monotonic on purpose so it compares # directly with advertisement times (loop.time() is a different # clock). Pruned with requests, not with the device: a stale # accept time can never authorize a handoff because a fresh # episode's trigger time always postdates it (the manager # requires accept_after >= pending). self._rescue_accept_after: dict[str, float] = {} # Strong refs to in-flight challenger-side rescue windows started # by trigger_rescue (fire-and-forget create_task). self._rescue_tasks: set[asyncio.Task[bool]] = set() 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 @property def has_active_requests(self) -> bool: """Return True if any address has a registered active-scan request.""" return bool(self._requests_by_address) 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() self._last_window_by_address.clear() self._rescue_accept_after.clear() for task in self._rescue_tasks: task.cancel() self._rescue_tasks.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._last_window_by_address.pop(request.address, None) self._rescue_accept_after.pop(request.address, None) # A rescue episode's lifetime is bounded by the active-scan # need. Without this upward pop, a device that never goes # stale again would keep an orphaned episode alive forever, # disabling the no-episode fast path for every dispatch # (the manager cannot observe the unregister itself). self._manager._rescue_triggered.pop(request.address, None) 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 trigger_rescue( self, address: str, challenger_source: str, owner_source: str ) -> None: """ Serve a deferred stale handoff; scans start only on AUTO scanners. Called synchronously by the manager's advertisement arbitration (issue #591) when the stale handoff for a device that needs active scans is deferred instead of taken. The owner side is always served: an AUTO owner gets an immediate window (due times clamped to now, worker woken, so the normal tick path including the connecting fallback dispatches it and its next retained capture is a fresh scan response) and an ACTIVE owner counts as covered. On the challenger side a scanner that is already ACTIVE and scanning needs no window (its captures already carry the scan response; coverage is recorded so the accept grace starts), while an AUTO challenger gets a delegated window so its next capture carries a fresh scan response. Every accept time keeps the standard grace so the owner always gets one re-hear window before any handoff, and lands in ``_rescue_accept_after`` (coarse monotonic, comparable with advertisement times); the manager hands off on the first challenger advertisement that postdates it. A passive or stopped owner cannot get a window right now and is skipped without blocking the rescue. An owner that paused scanning for a connection never reaches the rescue at all: arbitration hands its devices off at the ``scanner.scanning`` gate before this is called (only owners still reporting scanning get here; their mid-connect windows are covered by the worker tick's connecting fallback). """ loop = self._loop if ( loop is None or not self._running or (requests := self._requests_by_address.get(address)) is None ): return now = loop.time() coarse_now = monotonic_time_coarse() self._rescue_owner_side(address, owner_source, requests, now, coarse_now) if (challenger := self._manager._sources.get(challenger_source)) is None: return if ( challenger.requested_mode is BluetoothScanningMode.ACTIVE and challenger.scanning ): # The challenger is already actively scanning: its captures # already carry the scan response, so no window is needed on # its side; record coverage now so the accept grace starts. self._record_rescue_accept(address, coarse_now) return self._rescue_challenger_side( address, challenger, requests, loop, now, coarse_now ) def _record_rescue_accept(self, address: str, coverage_time: float) -> None: """ Record a rescue accept time from a coverage time. The accept always sits ``RESCUE_SCAN_ACCEPT_SECONDS`` after the moment coverage was confirmed; folding the grace in here (rather than at the call sites) makes that invariant single-sourced. Keeps any later pre-existing accept (keep-max). """ accept_after = coverage_time + _RESCUE_SCAN_ACCEPT_SECONDS if self._rescue_accept_after.get(address, 0.0) < accept_after: self._rescue_accept_after[address] = accept_after def _record_rescue_accepts( self, due_buckets: list[ tuple[str, dict[ActiveScanRequest, float], list[ActiveScanRequest]] ], ) -> None: """Record a dispatched window's accept time for the addresses it covers.""" if not due_buckets or not self._manager._rescue_triggered: # No rescue episode anywhere: nothing will read the accept # times, so the ordinary active-scan cadence pays nothing. return coverage_time = monotonic_time_coarse() for bucket_address, _entries, _due in due_buckets: self._record_rescue_accept(bucket_address, coverage_time) def _record_window_dispatch( self, address: str, dispatch_now: float, source: str | None, coverage_time: float, ) -> None: """ Record one window dispatch covering ``address``. Writes the diagnostics record (last-write, ``loop.time()`` base) unconditionally; the rescue accept time (keep-max, coarse clock; see ``_rescue_accept_after``) is recorded only while a rescue episode is in flight anywhere, matching ``_record_rescue_accepts``, so ordinary dispatches pay nothing for rescue bookkeeping. The rescue paths themselves always run with the episode already recorded (the manager stamps ``_rescue_triggered`` before calling ``trigger_rescue``). """ self._last_window_by_address[address] = (dispatch_now, source) if self._manager._rescue_triggered: self._record_rescue_accept(address, coverage_time) def _mark_delegated_dispatch( self, source: str, addresses: list[str], dispatch_now: float, duration: float, coverage_time: float, ) -> None: """ Record a window delegated to ``source`` covering ``addresses``. Shared pre-dispatch bookkeeping for the connecting-fallback and rescue challenger paths; recording before the dispatch means a declined window still advances, so a stuck scanner cannot pin a handoff. The ``async_request_active_window`` call itself stays at the call site (awaited there vs fire-and-forget here). """ for address in addresses: self._record_window_dispatch(address, dispatch_now, source, coverage_time) if (worker := self._workers.get(source)) is not None: worker.note_window_dispatched(dispatch_now + duration, dispatch_now) def _rescue_owner_side( self, address: str, owner_source: str, requests: set[ActiveScanRequest], now: float, coarse_now: float, ) -> None: """Serve the owner's side of a rescue through the normal schedule.""" if (owner_scanner := self._manager._sources.get(owner_source)) is None: return if owner_source in self._workers: # AUTO owner: bring its due times to now and wake its # worker; the tick path dispatches the window (or routes # to a fallback when the owner is mid-connect) and # records the accept time. self._seed_requests(address, requests, now) self._schedule.advance_to(address, now) self._schedule.assign(address, owner_source) elif ( owner_scanner.requested_mode is BluetoothScanningMode.ACTIVE and owner_scanner.scanning ): # Already actively scanning: its side is covered, with the # standard accept grace so the owner still gets one re-hear # window before any handoff. self._record_rescue_accept(address, coarse_now) def _rescue_challenger_side( self, address: str, challenger: BaseHaScanner, requests: set[ActiveScanRequest], loop: asyncio.AbstractEventLoop, now: float, coarse_now: float, ) -> None: """ Delegate a rescue window to the challenger's scanner. Only an AUTO challenger needs (and can take) a window; a passive or mid-connect challenger cannot produce an active capture right now (an ACTIVE-and-scanning challenger short-circuited the rescue in ``trigger_rescue`` before either side was served). """ if ( challenger.requested_mode is not BluetoothScanningMode.AUTO or challenger._connections_in_progress() > 0 ): return # Delegate a window to the challenger directly (it is not the # schedule's owner, so the tick path will not serve it). duration = self._coalesce_duration(requests) self._mark_delegated_dispatch( challenger.source, [address], now, duration, coarse_now ) task = loop.create_task(challenger.async_request_active_window(duration)) self._rescue_tasks.add(task) task.add_done_callback( partial(self._rescue_task_done, challenger.name, duration) ) def _rescue_task_done( self, name: str, duration: float, task: asyncio.Task[bool] ) -> None: """ Reap a finished challenger rescue window task. Drops the strong ref and retrieves the exception of a failed window so it is logged like the awaited fallback dispatch path instead of surfacing as an asyncio unretrieved-exception error; cancellation (``stop()``) is not a failure. """ self._rescue_tasks.discard(task) if not task.cancelled() and (exc := task.exception()) is not None: _LOGGER.error( "%s: error running rescue active window of %.1fs: %s", name, duration, exc, exc_info=exc, ) 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: Iterable[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. ``last_window_at`` / ``last_window_duration`` (per worker) and ``last_active_window`` (per request address) record when an active window last opened, recorded at dispatch (pre-await), so a window the scanner then failed to open still shows here; pair with ``failed_window`` to tell the two apart. 0.0 / None means no active window has fired for that worker / address. ``last_active_window_source`` is the scanner that ran that window (None if another ACTIVE scanner covered it); compare it to ``owner_source`` to see whether the device's current owner is the one actually active-scanning it. """ 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, "last_window_at": worker._last_window_at, "last_window_duration": worker._last_window_duration, "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 last_window = self._last_window_by_address.get(address) last_active_window = last_window[0] if last_window is not None else None last_active_window_source = ( last_window[1] if last_window 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, "last_active_window": last_active_window, "last_active_window_source": last_active_window_source, } for request in bucket ] return { "running": self._running, "monotonic_time": now, "workers": workers, "requests": requests, } Bluetooth-Devices-habluetooth-1390f03/src/habluetooth/base_scanner.pxd000066400000000000000000000076641522222566700261150ustar00rootroot00000000000000 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-1390f03/src/habluetooth/base_scanner.py000066400000000000000000000760641522222566700257520ustar00rootroot00000000000000"""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-1390f03/src/habluetooth/central_manager.py000066400000000000000000000012351522222566700264350ustar00rootroot00000000000000"""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-1390f03/src/habluetooth/channels/000077500000000000000000000000001522222566700245335ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-1390f03/src/habluetooth/channels/__init__.py000066400000000000000000000000001522222566700266320ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-1390f03/src/habluetooth/channels/att.py000066400000000000000000000671211522222566700257040ustar00rootroot00000000000000""" ATT protocol client and GATT discovery for the kernel/L2CAP backend. This is the pure ATT/GATT logic for talking to a peripheral over an L2CAP ATT channel: it encodes ATT requests, decodes responses, and drives the central (client) subset of GATT, namely MTU exchange, primary-service / characteristic / descriptor discovery, reads (with long-read continuation), writes (with and without response), and notification / indication handling. It is deliberately transport-agnostic. Outbound PDUs are written through the ``send`` coroutine supplied at construction, and inbound PDUs are delivered by the transport via :meth:`ATTClient.data_received`. Nothing here opens a socket, so the whole module is unit-testable without Bluetooth hardware. The L2CAP transport that feeds it is :class:`habluetooth.channels.l2cap.L2CAPSocket`. ATT is strictly sequential: at most one request is outstanding at a time, so a single transaction lock plus one pending future is sufficient. Notifications and indications are demultiplexed out of the same inbound stream by opcode. """ from __future__ import annotations import asyncio import logging import struct from dataclasses import dataclass, field from typing import TYPE_CHECKING from bleak import BleakError if TYPE_CHECKING: from collections.abc import Awaitable, Callable _LOGGER = logging.getLogger(__name__) # -- ATT opcodes ---------------------------------------------------------- ATT_ERROR_RSP = 0x01 ATT_EXCHANGE_MTU_REQ = 0x02 ATT_EXCHANGE_MTU_RSP = 0x03 ATT_FIND_INFO_REQ = 0x04 ATT_FIND_INFO_RSP = 0x05 ATT_READ_BY_TYPE_REQ = 0x08 ATT_READ_BY_TYPE_RSP = 0x09 ATT_READ_REQ = 0x0A ATT_READ_RSP = 0x0B ATT_READ_BLOB_REQ = 0x0C ATT_READ_BLOB_RSP = 0x0D ATT_READ_BY_GROUP_TYPE_REQ = 0x10 ATT_READ_BY_GROUP_TYPE_RSP = 0x11 ATT_WRITE_REQ = 0x12 ATT_WRITE_RSP = 0x13 ATT_WRITE_CMD = 0x52 ATT_HANDLE_VALUE_NTF = 0x1B ATT_HANDLE_VALUE_IND = 0x1D ATT_HANDLE_VALUE_CFM = 0x1E # -- ATT error codes ------------------------------------------------------ ATT_ERR_READ_NOT_PERMITTED = 0x02 ATT_ERR_INSUFFICIENT_AUTHENTICATION = 0x05 ATT_ERR_REQUEST_NOT_SUPPORTED = 0x06 ATT_ERR_INVALID_OFFSET = 0x07 ATT_ERR_ATTRIBUTE_NOT_FOUND = 0x0A ATT_ERR_ATTRIBUTE_NOT_LONG = 0x0B ATT_ERR_INSUFFICIENT_ENCRYPTION = 0x0F # -- GATT well-known attribute types (16-bit, little-endian on the wire) -- GATT_PRIMARY_SERVICE = b"\x00\x28" # 0x2800 GATT_CHARACTERISTIC = b"\x03\x28" # 0x2803 CCCD_UUID = "00002902-0000-1000-8000-00805f9b34fb" # -- characteristic property flags (Core Vol 3 Part G 3.3.1.1) ------------ CHAR_PROP_BROADCAST = 0x01 CHAR_PROP_READ = 0x02 CHAR_PROP_WRITE_NO_RESPONSE = 0x04 CHAR_PROP_WRITE = 0x08 CHAR_PROP_NOTIFY = 0x10 CHAR_PROP_INDICATE = 0x20 CHAR_PROP_AUTH_SIGNED_WRITES = 0x40 CHAR_PROP_EXTENDED = 0x80 _PROPERTY_MAP: list[tuple[int, str]] = [ (CHAR_PROP_BROADCAST, "broadcast"), (CHAR_PROP_READ, "read"), (CHAR_PROP_WRITE_NO_RESPONSE, "write-without-response"), (CHAR_PROP_WRITE, "write"), (CHAR_PROP_NOTIFY, "notify"), (CHAR_PROP_INDICATE, "indicate"), (CHAR_PROP_AUTH_SIGNED_WRITES, "authenticated-signed-writes"), (CHAR_PROP_EXTENDED, "extended-properties"), ] DEFAULT_MTU = 23 PREFERRED_MTU = 247 ATT_TIMEOUT = 30.0 _BASE_UUID_SUFFIX = "-0000-1000-8000-00805f9b34fb" _ERROR_RSP = struct.Struct(" list[str]: """Expand a characteristic property bitmask into bleak property names.""" return [name for flag, name in _PROPERTY_MAP if properties & flag] def uuid_from_bytes(data: bytes) -> str: """ Convert a little-endian ATT UUID (2 or 16 bytes) to a string. 16-bit UUIDs are expanded against the Bluetooth base UUID so callers always receive a full 128-bit UUID string. Any other length is a malformed PDU and raises rather than producing a bogus UUID. """ if len(data) == 2: return f"0000{int.from_bytes(data, 'little'):04x}{_BASE_UUID_SUFFIX}" if len(data) == 16: # 128-bit UUID, stored little-endian on the wire. hexs = data[::-1].hex() return f"{hexs[0:8]}-{hexs[8:12]}-{hexs[12:16]}-{hexs[16:20]}-{hexs[20:32]}" msg = f"invalid ATT UUID length {len(data)}" raise BleakError(msg) class ATTError(BleakError): """An ATT error response from the peer.""" def __init__(self, req_opcode: int, handle: int, error_code: int) -> None: """Record the request opcode, handle, and ATT error code.""" self.req_opcode = req_opcode self.handle = handle self.error_code = error_code super().__init__( f"ATT error 0x{error_code:02x} " f"(req 0x{req_opcode:02x}, handle 0x{handle:04x})" ) # -- GATT model ----------------------------------------------------------- @dataclass(slots=True) class GattDescriptor: """A discovered GATT descriptor.""" handle: int uuid: str @dataclass(slots=True) class GattCharacteristic: """A discovered GATT characteristic and its descriptors.""" handle: int # declaration handle value_handle: int uuid: str properties: int end_handle: int = 0 descriptors: list[GattDescriptor] = field(default_factory=list) @dataclass(slots=True) class GattService: """A discovered GATT primary service and its characteristics.""" handle: int end_handle: int uuid: str characteristics: list[GattCharacteristic] = field(default_factory=list) class ATTClient: """ An ATT client driven over a transport-agnostic byte stream. ``send`` is awaited to write one ATT PDU; the transport must deliver each inbound PDU to :meth:`data_received` and report loss via :meth:`connection_lost`. ATT requests are serialized by a transaction lock, but write commands and indication confirmations are sent independently of an outstanding request (as ATT permits), so ``send`` may be called concurrently. Each call carries exactly one PDU, so on a datagram transport (an L2CAP ``SOCK_SEQPACKET`` socket) the writes stay framed; the injected ``send`` must therefore be safe to call concurrently and write each PDU atomically. """ def __init__( self, send: Callable[[bytes], Awaitable[None]], on_disconnect: Callable[[Exception | None], None] | None = None, escalate_security: Callable[[int], bool] | None = None, ) -> None: """Bind the client to a transport ``send`` coroutine.""" self._send = send self._on_disconnect = on_disconnect # Called with the ATT error code when a request is rejected for # insufficient encryption/authentication; returns True if it raised the # link security so the request can be re-issued once. Transport-agnostic: # the codec knows nothing about how security is applied. self._escalate_security = escalate_security self._txn_lock = asyncio.Lock() self._pending: asyncio.Future[bytes] | None = None self._mtu = DEFAULT_MTU self._notify_handlers: dict[int, Callable[[bytearray], None]] = {} # Strong references to fire-and-forget confirm tasks so they cannot be # garbage-collected before they run. self._background_tasks: set[asyncio.Task[None]] = set() # Set once the channel can no longer be trusted (disconnect or a request # timeout, after which a late response could be misattributed to a new # request since ATT has no transaction id). self._closed = False @property def mtu(self) -> int: """Return the negotiated ATT MTU (23 until exchanged).""" return self._mtu # -- inbound demux ----------------------------------------------------- def data_received(self, data: bytes) -> None: """Demultiplex one inbound ATT PDU into a notify or a response.""" if not data: return if self._closed: # The channel has been poisoned (e.g. a request timed out) or torn # down; the transport may still be open but is no longer trusted, so # drop late inbound PDUs rather than delivering a stale notification # or confirming an indication on a channel we are abandoning. return opcode = data[0] if opcode in (ATT_HANDLE_VALUE_NTF, ATT_HANDLE_VALUE_IND): # opcode (1) + handle (2); a shorter frame is malformed, so drop it # rather than fabricating a handle and dispatching an empty value. if len(data) < 3: _LOGGER.debug( "Dropping truncated ATT handle-value PDU 0x%02x (%d bytes)", opcode, len(data), ) return self._dispatch_notify(int.from_bytes(data[1:3], "little"), data[3:]) if opcode == ATT_HANDLE_VALUE_IND: # Acknowledge the indication; retain a strong reference so the # task is not garbage-collected before it runs, and surface any # send failure via the done callback rather than as an # unretrieved task exception. task = asyncio.get_running_loop().create_task(self._confirm()) self._background_tasks.add(task) task.add_done_callback(self._on_confirm_done) return if (fut := self._pending) is not None and not fut.done(): fut.set_result(bytes(data)) else: _LOGGER.debug("Dropping ATT PDU 0x%02x with no pending transaction", opcode) def connection_lost(self, exc: Exception | None) -> None: """Fail any in-flight transaction and notify the disconnect callback.""" if (fut := self._pending) is not None and not fut.done(): fut.set_exception(exc or BleakError("disconnected")) self._poison(exc) def _poison(self, exc: Exception | None) -> None: """Close the channel and notify the disconnect callback once.""" if self._closed: return self._closed = True if self._on_disconnect is not None: self._on_disconnect(exc) async def _confirm(self) -> None: await self._send(bytes([ATT_HANDLE_VALUE_CFM])) def _on_confirm_done(self, task: asyncio.Task[None]) -> None: self._background_tasks.discard(task) if not task.cancelled() and (exc := task.exception()) is not None: # A CFM send only fails when the transport is already failing, in # which case connection_lost fires and poisons the channel; this is # the supplementary detail, so debug is deliberate, not an oversight. _LOGGER.debug("Failed to confirm ATT indication: %s", exc) def _dispatch_notify(self, value_handle: int, value: bytes) -> None: if (cb := self._notify_handlers.get(value_handle)) is None: _LOGGER.debug( "Notification for unhandled value handle 0x%04x dropped", value_handle ) return # Isolate the subscriber: data_received runs in the transport read loop, # so a raising callback must not tear down inbound demux for the whole # connection (and for indications it would also skip the confirmation). try: cb(bytearray(value)) except Exception: _LOGGER.exception( "Error in notification handler for value handle 0x%04x", value_handle ) # -- transactions ------------------------------------------------------ def _raise_if_closed(self) -> None: if self._closed: msg = "ATT channel closed" raise BleakError(msg) async def _request(self, payload: bytes, expected_opcode: int) -> bytes: self._raise_if_closed() async with self._txn_lock: # The lock is held across an escalation retry so no other request # interleaves while the link security is being raised. escalated = False while True: fut: asyncio.Future[bytes] = asyncio.get_running_loop().create_future() self._pending = fut try: await self._send(payload) data = await asyncio.wait_for(fut, ATT_TIMEOUT) except TimeoutError as exc: # The timed-out request may still be in flight; ATT has no # transaction id, so a late response could be matched to the # next request. Poison the channel so nothing else is sent. self._poison(exc) msg = "ATT request timed out" raise BleakError(msg) from exc finally: self._pending = None opcode = data[0] if opcode == ATT_ERROR_RSP: if len(data) < 5: msg = "truncated ATT error response" raise BleakError(msg) req_op, handle, err = _ERROR_RSP.unpack_from(data, 1) if ( not escalated and err in ( ATT_ERR_INSUFFICIENT_AUTHENTICATION, ATT_ERR_INSUFFICIENT_ENCRYPTION, ) and self._escalate_security is not None and self._escalate_security(err) ): # The peer demanded stronger security; the transport # raised it, so re-issue the same request once. Matches # bluetoothd's one-shot retry on 0x05/0x0F. escalated = True continue raise ATTError(req_op, handle, err) if opcode != expected_opcode: msg = ( f"unexpected ATT opcode 0x{opcode:02x} " f"(wanted 0x{expected_opcode:02x})" ) raise BleakError(msg) return data async def exchange_mtu(self, mtu: int = PREFERRED_MTU) -> int: """Negotiate the ATT MTU and return the agreed value.""" payload = bytes([ATT_EXCHANGE_MTU_REQ]) + mtu.to_bytes(2, "little") try: data = await self._request(payload, ATT_EXCHANGE_MTU_RSP) except ATTError as err: # Only a peer that does not implement MTU exchange is a benign # fallback to the default MTU; surface any other error (e.g. an # encryption/authentication rejection) rather than reporting it as a # successful negotiation. if err.error_code != ATT_ERR_REQUEST_NOT_SUPPORTED: raise _LOGGER.debug( "Peer does not support MTU exchange; keeping default MTU %d", self._mtu, ) return self._mtu if len(data) < 3: msg = "truncated ATT MTU response" raise BleakError(msg) server_mtu = int.from_bytes(data[1:3], "little") self._mtu = max(DEFAULT_MTU, min(mtu, server_mtu)) return self._mtu # -- reads ------------------------------------------------------------- async def read(self, handle: int) -> bytes: """Read an attribute value, continuing with Read Blob if it is long.""" data = await self._request( bytes([ATT_READ_REQ]) + handle.to_bytes(2, "little"), ATT_READ_RSP ) value = bytearray(data[1:]) # A chunk that exactly fills the PDU may be truncated, so continue with # Read Blob; a short chunk means we have the whole value. Keying on the # last chunk (not the running total) avoids an extra round-trip on a # value whose length happens to be a multiple of the chunk size. while len(value) >= self._mtu - 1: # The Read Blob offset is a 16-bit field, so a value cannot extend # past 0xFFFF; stop with a clear error rather than letting a peer # that always returns a full chunk overflow offset.to_bytes(2). if len(value) > 0xFFFF: msg = "ATT value exceeds the 16-bit Read Blob offset range" raise BleakError(msg) blob = await self._read_blob(handle, len(value)) value += blob if len(blob) < self._mtu - 1: break return bytes(value) async def _read_blob(self, handle: int, offset: int) -> bytes: payload = ( bytes([ATT_READ_BLOB_REQ]) + handle.to_bytes(2, "little") + offset.to_bytes(2, "little") ) try: data = await self._request(payload, ATT_READ_BLOB_RSP) except ATTError as err: if err.error_code in (ATT_ERR_INVALID_OFFSET, ATT_ERR_ATTRIBUTE_NOT_LONG): # Normal end of a long read (a blob read at offset == length # returns INVALID_OFFSET); logged for field observability. _LOGGER.debug( "Read Blob for handle 0x%04x ended at offset %d: error 0x%02x", handle, offset, err.error_code, ) return b"" raise return data[1:] # -- writes ------------------------------------------------------------ def _check_write_length(self, value: bytes) -> None: # An ATT write carries at most MTU - 3 bytes of value; longer values # need the Prepare/Execute Write (long write) procedure, which this # codec does not implement, so reject them explicitly rather than emit # an over-MTU PDU the transport or peer will opaquely fail. if len(value) > self._mtu - 3: msg = ( f"value too long for an ATT write: {len(value)} > {self._mtu - 3} " "(long writes are not supported)" ) raise BleakError(msg) async def write(self, handle: int, value: bytes) -> None: """ Write an attribute value with a response (Write Request). Values longer than ``mtu - 3`` are rejected; long writes are not supported. """ self._check_write_length(value) payload = bytes([ATT_WRITE_REQ]) + handle.to_bytes(2, "little") + bytes(value) await self._request(payload, ATT_WRITE_RSP) async def write_command(self, handle: int, value: bytes) -> None: """ Write an attribute value without a response (Write Command). Values longer than ``mtu - 3`` are rejected; long writes are not supported. """ self._raise_if_closed() self._check_write_length(value) payload = bytes([ATT_WRITE_CMD]) + handle.to_bytes(2, "little") + bytes(value) await self._send(payload) # -- discovery primitives --------------------------------------------- async def read_by_group_type( self, start: int, end: int, group_uuid: bytes ) -> list[tuple[int, int, bytes]]: """Enumerate grouping attributes (e.g. primary services) in a range.""" results: list[tuple[int, int, bytes]] = [] while start <= end: payload = ( bytes([ATT_READ_BY_GROUP_TYPE_REQ]) + start.to_bytes(2, "little") + end.to_bytes(2, "little") + group_uuid ) try: data = await self._request(payload, ATT_READ_BY_GROUP_TYPE_RSP) except ATTError as err: if err.error_code == ATT_ERR_ATTRIBUTE_NOT_FOUND: break raise # Each entry is handle(2) + end_handle(2) + value; reject an # untrusted peer's malformed stride before it crashes range(). if len(data) < 2 or (length := data[1]) < 4: msg = "malformed read-by-group-type response" raise BleakError(msg) entries = data[2:] # A real server returns ATTRIBUTE_NOT_FOUND when nothing remains, so # a response carrying no full entry is malformed; reject it (like the # bad-stride case) rather than re-requesting and amplifying or # silently returning a partial discovery tree. A body that is not a # whole multiple of the stride is malformed for the same reason; a # trailing partial entry is rejected rather than silently dropped. if len(entries) < length or len(entries) % length: msg = "malformed read-by-group-type response (truncated entries)" raise BleakError(msg) last = start for i in range(0, len(entries), length): entry = entries[i : i + length] handle = int.from_bytes(entry[0:2], "little") end_handle = int.from_bytes(entry[2:4], "little") results.append((handle, end_handle, bytes(entry[4:length]))) last = end_handle # last >= start here, so the cursor strictly advances each round and # the loop is bounded by the handle space. An entry whose handle is # exactly the cursor is legitimate for sparse consecutive attributes # (the first match can sit at start), so we accept the bounded # worst case of one entry per round rather than truncate valid # discovery; the entry-less guard above already stops zero progress. if last >= 0xFFFF or last < start: break start = last + 1 return results async def read_by_type( self, start: int, end: int, type_uuid: bytes ) -> list[tuple[int, bytes]]: """Enumerate attributes of a given type (e.g. characteristics).""" results: list[tuple[int, bytes]] = [] while start <= end: payload = ( bytes([ATT_READ_BY_TYPE_REQ]) + start.to_bytes(2, "little") + end.to_bytes(2, "little") + type_uuid ) try: data = await self._request(payload, ATT_READ_BY_TYPE_RSP) except ATTError as err: if err.error_code == ATT_ERR_ATTRIBUTE_NOT_FOUND: break raise # Each entry is handle(2) + value; reject an untrusted peer's # malformed stride before it crashes range(). if len(data) < 2 or (length := data[1]) < 2: msg = "malformed read-by-type response" raise BleakError(msg) entries = data[2:] # An entry-less response is malformed (a real server sends # ATTRIBUTE_NOT_FOUND); a body that is not a whole multiple of the # stride is malformed too. Reject both rather than re-requesting or # silently dropping a trailing partial entry. if len(entries) < length or len(entries) % length: msg = "malformed read-by-type response (truncated entries)" raise BleakError(msg) last = start for i in range(0, len(entries), length): entry = entries[i : i + length] handle = int.from_bytes(entry[0:2], "little") results.append((handle, bytes(entry[2:length]))) last = handle if last >= 0xFFFF or last < start: break start = last + 1 return results async def find_information(self, start: int, end: int) -> list[tuple[int, str]]: """Enumerate attribute handles and UUIDs (used for descriptors).""" results: list[tuple[int, str]] = [] while start <= end: payload = ( bytes([ATT_FIND_INFO_REQ]) + start.to_bytes(2, "little") + end.to_bytes(2, "little") ) try: data = await self._request(payload, ATT_FIND_INFO_RSP) except ATTError as err: if err.error_code == ATT_ERR_ATTRIBUTE_NOT_FOUND: break raise # The format byte is 0x01 (16-bit) or 0x02 (128-bit); anything else # is a malformed PDU and must not be mis-parsed as a 128-bit UUID. fmt = data[1] if len(data) >= 2 else 0 if fmt == 0x01: uuid_len = 2 elif fmt == 0x02: uuid_len = 16 else: msg = f"invalid find-information format 0x{fmt:02x}" raise BleakError(msg) entry_len = 2 + uuid_len entries = data[2:] # An entry-less response is malformed (a real server sends # ATTRIBUTE_NOT_FOUND); a body that is not a whole multiple of the # entry size is malformed too. Reject both rather than re-requesting # or silently dropping a trailing partial entry. if len(entries) < entry_len or len(entries) % entry_len: msg = "malformed find-information response (truncated entries)" raise BleakError(msg) last = start for i in range(0, len(entries), entry_len): entry = entries[i : i + entry_len] handle = int.from_bytes(entry[0:2], "little") results.append((handle, uuid_from_bytes(entry[2:entry_len]))) last = handle if last >= 0xFFFF or last < start: break start = last + 1 return results # -- notifications ----------------------------------------------------- def set_notify_handler( self, value_handle: int, callback: Callable[[bytearray], None] ) -> None: """Route notifications/indications for a value handle to ``callback``.""" self._notify_handlers[value_handle] = callback def remove_notify_handler(self, value_handle: int) -> None: """Stop routing notifications for a value handle.""" self._notify_handlers.pop(value_handle, None) # -- high-level GATT discovery ---------------------------------------- async def discover(self) -> list[GattService]: """Discover the full primary-service / characteristic / descriptor tree.""" services = [ GattService(handle, end_handle, uuid_from_bytes(value)) for handle, end_handle, value in await self.read_by_group_type( 0x0001, 0xFFFF, GATT_PRIMARY_SERVICE ) ] for svc in services: chars: list[GattCharacteristic] = [] for decl_handle, value in await self.read_by_type( svc.handle, svc.end_handle, GATT_CHARACTERISTIC ): # A declaration value is properties(1) + value_handle(2) + # uuid(2|16); reject a malformed peer's short declaration rather # than indexing past the end of it. if len(value) < 5: msg = "malformed characteristic declaration" raise BleakError(msg) chars.append( GattCharacteristic( decl_handle, int.from_bytes(value[1:3], "little"), uuid_from_bytes(value[3:]), value[0], ) ) # Each characteristic ends just before the next one (or the service # end); descriptors live between the value handle and that end. for i, char in enumerate(chars): char.end_handle = ( chars[i + 1].handle - 1 if i + 1 < len(chars) else svc.end_handle ) if char.end_handle > char.value_handle: char.descriptors = [ GattDescriptor(handle, uuid) for handle, uuid in await self.find_information( char.value_handle + 1, char.end_handle ) ] svc.characteristics = chars return services Bluetooth-Devices-habluetooth-1390f03/src/habluetooth/channels/bluez.pxd000066400000000000000000000030571522222566700263760ustar00rootroot00000000000000 import cython from ..base_scanner cimport BaseHaScanner 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 dict _pairing_handlers 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", data="bytes", parse_offset="unsigned short", address_str=str, scanner=BaseHaScanner, 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-1390f03/src/habluetooth/channels/bluez.py000066400000000000000000001422721522222566700262360ustar00rootroot00000000000000from __future__ import annotations import asyncio import logging from asyncio import timeout as asyncio_timeout from contextlib import asynccontextmanager from dataclasses import dataclass from functools import lru_cache from struct import Struct from struct import error as struct_error from typing import TYPE_CHECKING, Any, cast from bluetooth_data_tools import monotonic_time_coarse 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 ..base_scanner import BaseHaScanner _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_LOAD_LONG_TERM_KEYS = 0x0013 MGMT_OP_DISCONNECT = 0x0014 MGMT_OP_GET_CONNECTIONS = 0x0015 MGMT_OP_PAIR_DEVICE = 0x0019 MGMT_OP_UNPAIR_DEVICE = 0x001B MGMT_OP_START_DISCOVERY = 0x0023 MGMT_OP_STOP_DISCOVERY = 0x0024 MGMT_OP_LOAD_CONN_PARAM = 0x0035 MGMT_OP_ADD_ADV_PATTERNS_MONITOR = 0x0052 MGMT_OP_REMOVE_ADV_MONITOR = 0x0053 # IO capability for pairing; NoInputNoOutput selects "Just Works" (no PIN or # passkey prompt), which is what a headless host can satisfy. IO_CAPABILITY_NO_INPUT_NO_OUTPUT = 0x03 # Management events MGMT_EV_CMD_COMPLETE = 0x0001 MGMT_EV_CMD_STATUS = 0x0002 MGMT_EV_NEW_LONG_TERM_KEY = 0x000A MGMT_EV_USER_CONFIRM_REQUEST = 0x000F MGMT_EV_USER_PASSKEY_REQUEST = 0x0010 MGMT_EV_AUTH_FAILED = 0x0011 MGMT_OP_USER_CONFIRM_REPLY = 0x001C MGMT_OP_USER_CONFIRM_NEG_REPLY = 0x001D MGMT_OP_USER_PASSKEY_REPLY = 0x001E MGMT_OP_USER_PASSKEY_NEG_REPLY = 0x001F # Discovery address-type bitmask: BR/EDR (0x01) | LE Public (0x02) | LE Random (0x04). # We only scan for LE devices, so we combine the two LE bits. SCAN_TYPE_LE = 0x06 # Management command status codes MGMT_STATUS_SUCCESS = 0x00 # Busy means another mgmt client (e.g. bluetoothd) already started discovery; # device-found events are still delivered to every open socket, so we treat it # as success for our purposes. MGMT_STATUS_BUSY = 0x0A # 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) def _command_succeeded(result: tuple[int, bytes] | None) -> bool: """Whether a mgmt command returned the SUCCESS status.""" return result is not None and result[0] == MGMT_STATUS_SUCCESS def _result_status_text(result: tuple[int, bytes] | None) -> str: """Describe a mgmt command result for logging: no response, or its status.""" return "no response" if result is None else f"status={result[0]:#x}" @dataclass(slots=True, frozen=True) class LongTermKey: """ A bonded LE long-term key (the mgmt ``mgmt_ltk_info`` record). These are captured from NEW_LONG_TERM_KEY events when pairing completes and fed back via :meth:`MGMTBluetoothCtl.load_long_term_keys` on reconnect so the kernel re-encrypts the link without re-pairing. ``rand`` is 8 bytes and ``value`` is 16 bytes. """ address: str address_type: int key_type: int central: bool # the mgmt API calls this "master" encryption_size: int ediv: int rand: bytes value: bytes @dataclass(slots=True, frozen=True) class NewLongTermKey: """A NEW_LONG_TERM_KEY event: a freshly bonded key to persist.""" store_hint: int # non-zero means the kernel suggests persisting the key key: LongTermKey @dataclass(slots=True, frozen=True) class AuthenticationFailed: """An AUTHENTICATION_FAILED event: pairing did not complete.""" address: str address_type: int status: int @dataclass(slots=True, frozen=True) class UserConfirmationRequest: """A USER_CONFIRMATION_REQUEST event: confirm a numeric comparison value.""" address: str address_type: int # Per the mgmt API: when set, the value is not relevant and the user should # just confirm the pairing (yes/no, no number to show); when 0, ``value`` # holds the number to display for comparison. confirm_hint: int value: int # the 6-digit numeric comparison value (when confirm_hint is 0) @dataclass(slots=True, frozen=True) class UserPasskeyRequest: """A USER_PASSKEY_REQUEST event: the peer wants a passkey entered.""" address: str address_type: int if TYPE_CHECKING: PairingEvent = ( NewLongTermKey | AuthenticationFailed | UserConfirmationRequest | UserPasskeyRequest ) PairingHandler = Callable[[PairingEvent], None] def _mgmt_address_bytes(address: str) -> bytes | None: """ Pack ``AA:BB:CC:DD:EE:FF`` into the 6 little-endian bytes mgmt expects. Returns ``None`` for a malformed address (the caller treats that as a soft failure rather than raising into the mgmt command path). """ try: addr_bytes = bytes.fromhex(address.replace(":", "")) except ValueError: return None if len(addr_bytes) != 6: return None return addr_bytes[::-1] @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 BlueZ details dict for a raw advertisement.""" base_path = f"/org/bluez/{adapter}" return { "path": f"{base_path}/dev_{address.replace(':', '_')}", "props": { "Adapter": base_path, }, } class BluetoothMGMTProtocol: """Bluetooth MGMT protocol.""" def __init__( self, connection_made_future: asyncio.Future[None], scanners: dict[int, BaseHaScanner], on_connection_lost: Callable[[], None], is_shutting_down: Callable[[], bool], sock: socket.socket, pairing_handlers: dict[tuple[int, str], PairingHandler], ) -> 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 # Shared by reference with MGMTBluetoothCtl; keyed by # (controller_idx, peer address) so pairing events route to the client # that registered for them. self._pairing_handlers = pairing_handlers self._on_connection_lost = on_connection_lost self._is_shutting_down = is_shutting_down # Keyed by (opcode, controller_idx) so the same command can be in flight # on more than one adapter at once without the responses colliding. self._pending_commands: dict[ tuple[int, 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, controller_idx: int ) -> AsyncIterator[asyncio.Future[tuple[int, bytes]]]: """ Context manager for handling command responses. Only one command per (opcode, controller_idx) may be in flight at a time: a second concurrent call with the same key overwrites the first waiter, which would then never resolve. The mgmt command wrappers never double-issue the same command on one adapter, so this is sufficient. Usage: async with protocol.command_response(opcode, controller_idx) as future: transport.write(command) status, data = await future """ future: asyncio.Future[tuple[int, bytes]] = ( asyncio.get_running_loop().create_future() ) key = (opcode, controller_idx) self._pending_commands[key] = future try: yield future finally: # Clean up if the future wasn't resolved self._pending_commands.pop(key, 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: # Fire-and-forget command, no waiter to resolve. self._handle_load_conn_param_response(status, controller_idx) elif ( future := self._pending_commands.get((opcode, controller_idx)) ) is not None: # Resolve whatever awaited this (opcode, controller_idx): # capability check, discovery, advertisement monitor, etc. del self._pending_commands[(opcode, controller_idx)] if not future.done(): response_data = ( header[9 : self._pos] if param_len > 3 else b"" ) future.set_result((status, response_data)) self._remove_from_buffer() continue elif event_code == MGMT_EV_NEW_LONG_TERM_KEY: self._handle_new_long_term_key(controller_idx, header[6 : self._pos]) self._remove_from_buffer() continue elif event_code == MGMT_EV_AUTH_FAILED: self._handle_auth_failed(controller_idx, header[6 : self._pos]) self._remove_from_buffer() continue elif event_code == MGMT_EV_USER_CONFIRM_REQUEST: self._handle_user_confirm_request(controller_idx, header[6 : self._pos]) self._remove_from_buffer() continue elif event_code == MGMT_EV_USER_PASSKEY_REQUEST: self._handle_user_passkey_request(controller_idx, header[6 : self._pos]) self._remove_from_buffer() continue else: self._remove_from_buffer() continue address = header[parse_offset : parse_offset + 6] rssi = header[parse_offset + 7] if rssi > 128: rssi -= 256 # Skip address_type (+6), flags (+8..+11) and AD_Data_Length # (+12..+13); the advertising data payload starts at +14. data = header[parse_offset + 14 : self._pos] self._remove_from_buffer() if (scanner := self._scanners.get(controller_idx)) is not None: # Adapt the raw BlueZ advertisement onto the generic ingestion # path. The BlueZ-specific bits (MAC byte order, object path) # live here in the channel rather than on the scanner classes, # which only expose the platform-agnostic _async_on_raw_advertisement. address_str = bytes_mac_to_str(address) scanner._async_on_raw_advertisement( address_str, rssi, data, make_bluez_details(address_str, scanner.adapter), monotonic_time_coarse(), ) def _handle_new_long_term_key(self, controller_idx: _int, param: _bytes) -> None: """Decode a NEW_LONG_TERM_KEY event and route it to its handler.""" if len(param) < _NEW_LTK_EV_SIZE: _LOGGER.debug("hci%u: truncated NEW_LONG_TERM_KEY event", controller_idx) return store_hint = param[0] addr, addr_type, key_type, central, enc_size, ediv, rand, value = ( LTK_INFO_UNPACK(param[1 : 1 + LTK_INFO_SIZE]) ) address = bytes_mac_to_str(addr) if (handler := self._pairing_handlers.get((controller_idx, address))) is None: return self._dispatch_pairing_event( handler, NewLongTermKey( store_hint, LongTermKey( address, addr_type, key_type, bool(central), enc_size, ediv, rand, value, ), ), ) def _handle_auth_failed(self, controller_idx: _int, param: _bytes) -> None: """Decode an AUTHENTICATION_FAILED event and route it to its handler.""" if len(param) < _AUTH_FAILED_EV_SIZE: _LOGGER.debug("hci%u: truncated AUTH_FAILED event", controller_idx) return address = bytes_mac_to_str(param[0:6]) if (handler := self._pairing_handlers.get((controller_idx, address))) is None: return self._dispatch_pairing_event( handler, AuthenticationFailed(address, param[6], param[7]) ) def _handle_user_confirm_request(self, controller_idx: _int, param: _bytes) -> None: """Decode a USER_CONFIRMATION_REQUEST event and route it to its handler.""" if len(param) < _USER_CONFIRM_REQ_EV_SIZE: _LOGGER.debug("hci%u: truncated USER_CONFIRM_REQUEST event", controller_idx) return address = bytes_mac_to_str(param[0:6]) if (handler := self._pairing_handlers.get((controller_idx, address))) is None: return value = int.from_bytes(param[8:12], "little") self._dispatch_pairing_event( handler, UserConfirmationRequest(address, param[6], param[7], value) ) def _handle_user_passkey_request(self, controller_idx: _int, param: _bytes) -> None: """Decode a USER_PASSKEY_REQUEST event and route it to its handler.""" if len(param) < _USER_PASSKEY_REQ_EV_SIZE: _LOGGER.debug("hci%u: truncated USER_PASSKEY_REQUEST event", controller_idx) return address = bytes_mac_to_str(param[0:6]) if (handler := self._pairing_handlers.get((controller_idx, address))) is None: return self._dispatch_pairing_event(handler, UserPasskeyRequest(address, param[6])) def _dispatch_pairing_event( self, handler: PairingHandler, event: PairingEvent ) -> None: """Call a pairing handler, isolating it from the socket read loop.""" # data_received runs in the read loop, so a raising handler must not tear # down event demux for the whole mgmt connection. try: handler(event) except Exception: _LOGGER.exception("Error in %s handler", type(event).__name__) 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, BaseHaScanner]) -> None: """Initialize the control class.""" # Internal state self.timeout = timeout self.protocol: BluetoothMGMTProtocol | None = None self.sock: socket.socket | None = None self.scanners = scanners # Pairing-event handlers keyed by (controller_idx, peer address); shared # by reference with each protocol instance so registrations survive a # socket reconnect. self._pairing_handlers: dict[tuple[int, str], PairingHandler] = {} self._reconnect_task: asyncio.Task[None] | None = None self._on_connection_lost_future: asyncio.Future[None] | None = None self._shutting_down = False # Set once setup() confirms NET_ADMIN/NET_RAW; the same privileges that # allow the capability probe allow driving discovery over the socket. self.can_discover = 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, self._pairing_handlers, ), 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, 0 ) 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.can_discover = True self._reconnect_task = asyncio.create_task(self.reconnect_task()) async def _send_command_await( self, opcode: int, adapter_idx: int, cmd_data: bytes ) -> tuple[int, bytes] | None: """ Send a mgmt command and await its command-complete/status response. Returns ``(status, response_params)``, or ``None`` if the socket is down, the write fails, or no response arrives within the timeout. """ if not self.protocol or not self.protocol.transport: _LOGGER.error("Cannot send mgmt command %#x: no connection", opcode) return None header = COMMAND_HEADER_PACK(opcode, adapter_idx, len(cmd_data)) try: async with self.protocol.command_response(opcode, adapter_idx) as future: # _write_to_socket can raise OSError or BluetoothSocketError when # the socket has gone away; treat any of these as a soft failure. self.protocol._write_to_socket(header + cmd_data) async with asyncio_timeout(self.timeout): return await future except (TimeoutError, OSError, BluetoothSocketError) as ex: _LOGGER.debug("hci%u: mgmt command %#x failed: %s", adapter_idx, opcode, ex) return None async def start_discovery(self, adapter_idx: int) -> bool: """ Start LE discovery on an adapter over the management socket. Returns True if discovery is running, including the case where another mgmt client (e.g. bluetoothd) already started it (BUSY); device-found events are delivered to every open socket regardless of who started discovery. A failure here means the adapter produces no advertisements, so it is logged at warning level. """ result = await self._send_command_await( MGMT_OP_START_DISCOVERY, adapter_idx, DISCOVERY_PACK(SCAN_TYPE_LE) ) if result is not None and result[0] in ( MGMT_STATUS_SUCCESS, MGMT_STATUS_BUSY, ): return True _LOGGER.warning( "hci%u: failed to start discovery: %s", adapter_idx, _result_status_text(result), ) return False async def stop_discovery(self, adapter_idx: int) -> bool: """ Stop LE discovery on an adapter over the management socket. Only MGMT_STATUS_SUCCESS means discovery actually stopped. Unlike start, a BUSY status here means the kernel did NOT stop discovery (e.g. another mgmt client is still discovering), so it is not treated as success. """ result = await self._send_command_await( MGMT_OP_STOP_DISCOVERY, adapter_idx, DISCOVERY_PACK(SCAN_TYPE_LE) ) if _command_succeeded(result): return True # Best-effort cleanup on a shared socket, so this stays at debug, but # log the no-response case too for parity with the other wrappers. _LOGGER.debug( "hci%u: stop discovery did not stop: %s", adapter_idx, _result_status_text(result), ) return False async def add_adv_pattern_monitor(self, adapter_idx: int) -> int | None: """ Register a match-all advertisement monitor for passive scanning. A pattern_count of 0 tells the controller to report every advertisement via ADV_MONITOR_DEVICE_FOUND. Returns the assigned monitor handle (for later removal), or None on failure. """ result = await self._send_command_await( MGMT_OP_ADD_ADV_PATTERNS_MONITOR, adapter_idx, b"\x00" ) if result is None: return None status, data = result if status == MGMT_STATUS_SUCCESS: if len(data) >= 2: return MONITOR_HANDLE_UNPACK(data[:2])[0] # Success without the 2-byte handle: the monitor may be registered # in the controller but we cannot track it for later removal. _LOGGER.warning( "hci%u: add advertisement monitor succeeded without a handle", adapter_idx, ) return None _LOGGER.warning( "hci%u: failed to add advertisement monitor: status=%#x", adapter_idx, status, ) return None async def remove_adv_monitor(self, adapter_idx: int, handle: int) -> bool: """Remove a previously registered advertisement monitor by handle.""" result = await self._send_command_await( MGMT_OP_REMOVE_ADV_MONITOR, adapter_idx, MONITOR_HANDLE_PACK(handle) ) if _command_succeeded(result): return True # A failed removal leaves the monitor registered controller-side, so # surface it at warning level to make a leaked handle diagnosable. _LOGGER.warning( "hci%u: failed to remove advertisement monitor %u: %s", adapter_idx, handle, _result_status_text(result), ) return False async def pair_device( self, adapter_idx: int, address: str, address_type: int, io_capability: int = IO_CAPABILITY_NO_INPUT_NO_OUTPUT, ) -> bool: """ Pair (bond) with a device over the management socket. ``io_capability`` defaults to NoInputNoOutput, which drives "Just Works" pairing; the kernel performs SMP and stores the keys. Returns True when the controller reports the pairing completed. """ addr = _mgmt_address_bytes(address) if addr is None: _LOGGER.error("hci%u: invalid address to pair: %s", adapter_idx, address) return False result = await self._send_command_await( MGMT_OP_PAIR_DEVICE, adapter_idx, PAIR_DEVICE_PACK(addr, address_type, io_capability), ) if _command_succeeded(result): return True _LOGGER.warning( "hci%u: failed to pair with %s: %s", adapter_idx, address, _result_status_text(result), ) return False async def unpair_device( self, adapter_idx: int, address: str, address_type: int, *, disconnect: bool = True, ) -> bool: """Remove the bond for a device (and disconnect it by default).""" addr = _mgmt_address_bytes(address) if addr is None: _LOGGER.error("hci%u: invalid address to unpair: %s", adapter_idx, address) return False result = await self._send_command_await( MGMT_OP_UNPAIR_DEVICE, adapter_idx, UNPAIR_DEVICE_PACK(addr, address_type, 1 if disconnect else 0), ) if _command_succeeded(result): return True _LOGGER.warning( "hci%u: failed to unpair %s: %s", adapter_idx, address, _result_status_text(result), ) return False async def disconnect_device( self, adapter_idx: int, address: str, address_type: int ) -> bool: """Force-disconnect a device at the controller over the mgmt socket.""" addr = _mgmt_address_bytes(address) if addr is None: _LOGGER.error( "hci%u: invalid address to disconnect: %s", adapter_idx, address ) return False result = await self._send_command_await( MGMT_OP_DISCONNECT, adapter_idx, DISCONNECT_PACK(addr, address_type) ) if _command_succeeded(result): return True _LOGGER.warning( "hci%u: failed to disconnect %s: %s", adapter_idx, address, _result_status_text(result), ) return False async def user_confirmation_reply( self, adapter_idx: int, address: str, address_type: int, *, accept: bool = True ) -> bool: """Answer a USER_CONFIRMATION_REQUEST (numeric comparison) for a peer.""" opcode = ( MGMT_OP_USER_CONFIRM_REPLY if accept else MGMT_OP_USER_CONFIRM_NEG_REPLY ) return await self._user_reply( opcode, adapter_idx, address, USER_REPLY_PACK, (address_type,) ) async def user_passkey_reply( self, adapter_idx: int, address: str, address_type: int, passkey: int ) -> bool: """Answer a USER_PASSKEY_REQUEST with the entered passkey for a peer.""" return await self._user_reply( MGMT_OP_USER_PASSKEY_REPLY, adapter_idx, address, USER_PASSKEY_REPLY_PACK, (address_type, passkey), ) async def user_passkey_negative_reply( self, adapter_idx: int, address: str, address_type: int ) -> bool: """Reject a USER_PASSKEY_REQUEST for a peer.""" return await self._user_reply( MGMT_OP_USER_PASSKEY_NEG_REPLY, adapter_idx, address, USER_REPLY_PACK, (address_type,), ) async def _user_reply( self, opcode: int, adapter_idx: int, address: str, pack: Callable[..., bytes], extra: tuple[int, ...], ) -> bool: """Send a USER_*_REPLY command for a peer and report success.""" addr = _mgmt_address_bytes(address) if addr is None: _LOGGER.error("hci%u: invalid address for reply: %s", adapter_idx, address) return False try: # An out-of-range address_type or passkey raises struct.error; keep # that a soft failure rather than letting it escape the command path. payload = pack(addr, *extra) except struct_error: _LOGGER.exception( "hci%u: invalid pairing reply parameters for %s", adapter_idx, address ) return False result = await self._send_command_await(opcode, adapter_idx, payload) if _command_succeeded(result): return True _LOGGER.warning( "hci%u: pairing reply %#x for %s failed: %s", adapter_idx, opcode, address, _result_status_text(result), ) return False async def load_long_term_keys( self, adapter_idx: int, keys: list[LongTermKey] ) -> bool: """ Restore bonded long-term keys for an adapter. Replaces the controller's LTK list (an empty list clears it) so the kernel can re-encrypt links to already-bonded peers without re-pairing. The mgmt API has no add-one primitive, so this writes the controller's whole list. That list belongs to the controller and its kernel, not to this process or container, so it is shared with every other mgmt client on the same kernel: if host bluetoothd is still managing this controller, loading our keys clears the bonds it loaded, and there is no way to import those back (DBus never exposes key material, and bluetoothd's on-disk store is not reachable from another container). The mgmt connect path therefore requires a controller bluetoothd is not managing; see the Home Assistant integration guide on per-adapter controller ownership. """ records: list[bytes] = [] for key in keys: addr = _mgmt_address_bytes(key.address) if addr is None: _LOGGER.error( "hci%u: invalid address in long-term key: %s", adapter_idx, key.address, ) return False if len(key.rand) != _LTK_RAND_LEN or len(key.value) != _LTK_VALUE_LEN: _LOGGER.error( "hci%u: malformed long-term key for %s", adapter_idx, key.address ) return False try: # Out-of-range integer fields (e.g. a corrupt key_type) raise # struct.error; keep that a soft failure rather than letting it # escape the command path. record = LTK_INFO_PACK( addr, key.address_type, key.key_type, 1 if key.central else 0, key.encryption_size, key.ediv, key.rand, key.value, ) except struct_error: _LOGGER.exception( "hci%u: out-of-range field in long-term key for %s", adapter_idx, key.address, ) return False records.append(record) cmd_data = LTK_COUNT_PACK(len(keys)) + b"".join(records) result = await self._send_command_await( MGMT_OP_LOAD_LONG_TERM_KEYS, adapter_idx, cmd_data ) if _command_succeeded(result): return True _LOGGER.warning( "hci%u: failed to load %d long-term key(s): %s", adapter_idx, len(keys), _result_status_text(result), ) return False def register_pairing_handler( self, adapter_idx: int, address: str, handler: PairingHandler ) -> Callable[[], None]: """ Route NEW_LONG_TERM_KEY / AUTHENTICATION_FAILED events for a peer. ``handler`` is called with a :class:`NewLongTermKey` or :class:`AuthenticationFailed` for events on ``adapter_idx`` from ``address``. Returns a callback that unregisters it. ``address`` must be the canonical colon-separated form (``AA:BB:CC:DD:EE:FF``); it is upper-cased to match the address the event decoder produces, but any other shape (e.g. no colons) will simply never match and the handler will not fire. Registration is last-wins: a second call for the same ``(adapter_idx, address)`` replaces the previous handler, and that previous registration's unregister callback becomes a no-op. Handlers do not stack. """ key = (adapter_idx, address.upper()) self._pairing_handlers[key] = handler def _unregister() -> None: if self._pairing_handlers.get(key) is handler: del self._pairing_handlers[key] return _unregister 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-1390f03/src/habluetooth/channels/l2cap.py000066400000000000000000000321511522222566700261100ustar00rootroot00000000000000""" L2CAP ATT socket transport for the kernel/L2CAP backend. This opens a raw L2CAP connection-oriented socket to a peripheral on the ATT fixed channel (CID ``0x0004``) and exposes it as an asyncio push transport: one SEQPACKET datagram in, one ATT PDU out. It is the thing that feeds :class:`habluetooth.channels.att.ATTClient` once a peer is connected. CPython's :mod:`socket` cannot express the Bluetooth ``sockaddr_l2`` (it has no field for ``l2_cid`` or ``l2_bdaddr_type``), so the address is packed by hand and ``bind``/``connect`` are issued through libc via :mod:`ctypes` on the raw file descriptor; everything after the connect handshake is ordinary asyncio (:meth:`~asyncio.loop.add_reader` for the inbound stream, :meth:`~asyncio.loop.sock_sendall` for backpressured writes). ``SOCK_SEQPACKET`` preserves datagram boundaries, so each ``recv`` yields exactly one ATT PDU and the codec never has to reframe the stream. The libc ``bind``/``connect`` shims are the only Linux/Bluetooth specific lines; the orchestration around them is exercised over an ordinary socket pair, so the module is unit-testable without Bluetooth hardware. """ from __future__ import annotations import asyncio import ctypes import errno import functools import logging import os import socket import struct from typing import TYPE_CHECKING from bleak import BleakError from bluetooth_data_tools import mac_to_int from ..const import BDADDR_LE_PUBLIC if TYPE_CHECKING: from collections.abc import Callable from typing import NoReturn _LOGGER = logging.getLogger(__name__) # -- Bluetooth socket constants ------------------------------------------- # CPython exposes these on Linux only (and not at all on python-build-standalone # builds without the bluetooth headers), so they are pinned here as literals # rather than read from the socket module. AF_BLUETOOTH = 31 BTPROTO_L2CAP = 0 ATT_CID = 0x0004 # setsockopt(SOL_BLUETOOTH, BT_SECURITY, struct bt_security{level, key_size}) SOL_BLUETOOTH = 274 BT_SECURITY = 4 BT_SECURITY_LOW = 1 BT_SECURITY_MEDIUM = 2 BT_SECURITY_HIGH = 3 # One SEQPACKET recv yields one ATT PDU; the largest legal ATT MTU is 517, so a # 1024 byte buffer cannot truncate a PDU. _RECV_BUFSIZE = 1024 class _SockaddrL2(ctypes.Structure): """The Linux ``struct sockaddr_l2`` (see ``include/net/bluetooth/l2cap.h``).""" _fields_ = ( ("l2_family", ctypes.c_ushort), ("l2_psm", ctypes.c_ushort), ("l2_bdaddr", ctypes.c_ubyte * 6), ("l2_cid", ctypes.c_ushort), ("l2_bdaddr_type", ctypes.c_ubyte), ) def str_to_bdaddr(address: str) -> bytes: """ Pack ``AA:BB:CC:DD:EE:FF`` into the 6 little-endian bytes the kernel wants. BD_ADDRs go on the wire least-significant-octet first, so the textual address is reversed. Raises ``ValueError`` for a malformed address. """ return mac_to_int(address).to_bytes(6, "little") def make_sockaddr_l2(address: str, cid: int, bdaddr_type: int) -> _SockaddrL2: """Build a ``sockaddr_l2`` for ``address`` on the given fixed channel.""" addr = _SockaddrL2() addr.l2_family = AF_BLUETOOTH addr.l2_psm = 0 addr.l2_bdaddr = (ctypes.c_ubyte * 6)(*str_to_bdaddr(address)) addr.l2_cid = cid addr.l2_bdaddr_type = bdaddr_type return addr def can_use_l2cap() -> bool: """ Whether a raw L2CAP socket can be opened on this host. Connections go over an ``AF_BLUETOOTH`` socket, whose creation needs ``CAP_NET_RAW`` (and a Linux kernel with Bluetooth). A process can have mgmt discovery permission yet lack this, so the scanner factory probes it and falls back to the bleak/DBus path rather than pick a scanner that can discover but never connect. The probe only opens and closes a socket. """ try: sock = socket.socket(AF_BLUETOOTH, socket.SOCK_SEQPACKET, BTPROTO_L2CAP) except OSError as err: # Could be a permission downgrade or a kernel without Bluetooth; log so # the fallback to the bleak path is not silent. _LOGGER.debug("L2CAP sockets unavailable (%s); using the bleak path", err) return False try: return True finally: sock.close() @functools.cache def _get_libc() -> ctypes.CDLL: # pragma: no cover - libc load is platform glue """Return a cached errno-aware libc handle for the raw bind/connect calls.""" return ctypes.CDLL(None, use_errno=True) def _bind_fd(fd: int, addr: _SockaddrL2) -> int: # pragma: no cover - syscall shim """``bind(2)`` the raw fd; return 0 on success or the errno on failure.""" if _get_libc().bind(fd, ctypes.byref(addr), ctypes.sizeof(addr)) != 0: return ctypes.get_errno() return 0 def _connect_fd(fd: int, addr: _SockaddrL2) -> int: # pragma: no cover - syscall """``connect(2)`` the raw fd; return 0, EINPROGRESS, or the errno.""" if _get_libc().connect(fd, ctypes.byref(addr), ctypes.sizeof(addr)) != 0: return ctypes.get_errno() return 0 class L2CAPSocket: """ An open L2CAP ATT channel to a single peer, driven by asyncio. Construct one with :meth:`create_connection`; it registers a reader so that every inbound PDU is handed to ``on_data`` and a transport failure (peer disconnect, socket error) is reported once to ``on_close``. Outbound PDUs go through :meth:`send`, which is backpressure aware. ``on_data`` runs in the event loop read callback, so it must not block. The loop's reader holds a reference to this socket (and thus to the callbacks), so the owner must call :meth:`close` to release it; a peer disconnect or read error closes it automatically. """ def __init__( self, sock: socket.socket, loop: asyncio.AbstractEventLoop, on_data: Callable[[bytes], None], on_close: Callable[[Exception | None], None], security_level: int = BT_SECURITY_LOW, ) -> None: """Wrap an already-connected socket and start reading from it.""" self._sock = sock self._loop = loop self._on_data = on_data self._on_close = on_close self._closed = False self._write_lock = asyncio.Lock() # The BT_SECURITY level currently requested on the socket; escalation # raises it (never lowers it) when the peer rejects an ATT operation. self._security_level = security_level loop.add_reader(sock.fileno(), self._read_ready) @property def security_level(self) -> int: """The BT_SECURITY level currently requested on the socket.""" return self._security_level def set_security_level(self, level: int) -> bool: """ Raise the socket's BT_SECURITY level, never lowering it. Returns True if the level was raised (so the caller can re-issue the rejected operation), False if it was already at least ``level`` or the kernel refused the change. Mirrors bluetoothd, which only escalates and never downgrades a live ATT channel. """ if level <= self._security_level: return False try: _set_bt_security(self._sock, level) except OSError as err: _LOGGER.debug("Failed to raise L2CAP security to level %d: %s", level, err) return False self._security_level = level return True @classmethod async def create_connection( cls, *, source: str, address: str, address_type: int, on_data: Callable[[bytes], None], on_close: Callable[[Exception | None], None], timeout: float, security_level: int = BT_SECURITY_LOW, sock: socket.socket | None = None, ) -> L2CAPSocket: """ Open an L2CAP ATT channel from adapter ``source`` to ``address``. ``source`` is the local adapter's public BD_ADDR (it selects which adapter the connection goes out of); ``address``/``address_type`` are the peer. ``security_level`` is the initial BT_SECURITY level; it defaults to ``BT_SECURITY_LOW`` (no encryption requested up front) to match bluetoothd, which then escalates on demand via :meth:`set_security_level` when the peer rejects an ATT operation. Pass 0 to leave it unset. ``sock`` is injectable for testing. """ loop = asyncio.get_running_loop() if sock is None: # pragma: no cover - real socket needs a Linux adapter sock = socket.socket(AF_BLUETOOTH, socket.SOCK_SEQPACKET, BTPROTO_L2CAP) try: sock.setblocking(False) if security_level: _set_bt_security(sock, security_level) _bind(sock, source) await _connect(sock, address, address_type, loop, timeout) except BaseException: sock.close() raise return cls(sock, loop, on_data, on_close, security_level=security_level) async def send(self, data: bytes) -> None: """ Write one ATT PDU, awaiting socket writability under backpressure. Serialized with a lock: ATTClient may call this concurrently (e.g. an indication confirmation or a write-without-response alongside a pending request), and ``loop.sock_sendall`` is not safe to run concurrently on one fd (a second backpressured call would replace the first's writer and stall it). SEQPACKET keeps each write framed as one PDU. """ async with self._write_lock: # Re-check under the lock: close() may have run while we were parked # on it (e.g. a peer disconnect tearing the socket down), and writing # to the closed fd must raise the documented BleakError, not an # incidental OSError from sock_sendall. if self._closed: msg = "L2CAP socket is closed" raise BleakError(msg) try: await self._loop.sock_sendall(self._sock, data) except OSError as exc: # Mirror the read path: a write-side transport failure tears the # channel down and notifies on_close once before surfacing to # the caller, rather than leaving it half-open until a read event # happens to detect the loss. self._fail(exc) raise def _read_ready(self) -> None: """Read one inbound PDU and hand it to ``on_data`` (reader callback).""" try: data = self._sock.recv(_RECV_BUFSIZE) except (BlockingIOError, InterruptedError): return except OSError as exc: self._fail(exc) return if not data: # A zero-length SEQPACKET read means the peer hung up. self._fail(None) return self._on_data(data) def _fail(self, exc: Exception | None) -> None: """Tear down once and report the loss to ``on_close`` exactly once.""" if self._closed: return self.close() self._on_close(exc) def close(self) -> None: """Stop reading and close the socket; idempotent.""" if self._closed: return self._closed = True self._loop.remove_reader(self._sock.fileno()) self._sock.close() def _bind(sock: socket.socket, source: str) -> None: """Bind the socket to the local adapter so the route is deterministic.""" addr = make_sockaddr_l2(source, ATT_CID, BDADDR_LE_PUBLIC) if (err := _bind_fd(sock.fileno(), addr)) != 0: _raise_errno(err) async def _connect( sock: socket.socket, address: str, address_type: int, loop: asyncio.AbstractEventLoop, timeout: float, ) -> None: """Issue the non-blocking connect and await the result via SO_ERROR.""" addr = make_sockaddr_l2(address, ATT_CID, address_type) err = _connect_fd(sock.fileno(), addr) if err in (errno.EINPROGRESS, errno.EALREADY, errno.EAGAIN): await _wait_connected(sock, loop, timeout) elif err != 0: _raise_errno(err) async def _wait_connected( sock: socket.socket, loop: asyncio.AbstractEventLoop, timeout: float, ) -> None: """Wait for the in-progress connect to settle, then check SO_ERROR.""" fut: asyncio.Future[None] = loop.create_future() fd = sock.fileno() loop.add_writer(fd, _set_result_if_pending, fut) try: async with asyncio.timeout(timeout): await fut finally: loop.remove_writer(fd) if (err := _so_error(sock)) != 0: _raise_errno(err) def _raise_errno(err: int) -> NoReturn: """Raise an ``OSError`` carrying ``err`` and its platform message.""" raise OSError(err, os.strerror(err)) def _set_bt_security( # pragma: no cover - BT-only setsockopt sock: socket.socket, level: int ) -> None: """Request a kernel-driven LE security level on the socket.""" sock.setsockopt(SOL_BLUETOOTH, BT_SECURITY, struct.pack("BB", level, 0)) def _so_error(sock: socket.socket) -> int: """Return the socket's pending error (``SO_ERROR``), 0 if none.""" return sock.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR) def _set_result_if_pending(fut: asyncio.Future[None]) -> None: """Resolve ``fut`` if it has not already completed (writer callback).""" if not fut.done(): fut.set_result(None) Bluetooth-Devices-habluetooth-1390f03/src/habluetooth/client_mgmt.py000066400000000000000000000573371522222566700256330ustar00rootroot00000000000000""" Kernel/L2CAP GATT client backend (bleak ``BaseBleakClient``). ``HaMgmtClient`` is a bleak-compatible client that talks to a peripheral over a raw L2CAP ATT channel instead of going through bluetoothd/DBus. It wires the :class:`~habluetooth.channels.l2cap.L2CAPSocket` transport to the :class:`~habluetooth.channels.att.ATTClient` codec, runs MTU exchange and GATT discovery on connect, and translates the discovered tree into bleak's unified GATT model so existing integrations can use it unchanged. ``create_local_scanner`` routes connections here for local adapters, but Home Assistant does not call that factory yet, so nothing reaches this backend in production today. Pairing is driven over the management socket (Just Works). ``connect(pair=True)`` and ``pair()`` bond with the peer and capture the long-term key so a later reconnect can restore it; on a reconnect for an already-bonded peer the socket requests encryption up front so the kernel re-encrypts the link, and any ATT operation the peer rejects for insufficient security escalates on demand. """ from __future__ import annotations import asyncio import contextlib import logging from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Protocol, cast from bleak import BleakError from bleak.backends.characteristic import BleakGATTCharacteristic from bleak.backends.client import BaseBleakClient from bleak.backends.descriptor import BleakGATTDescriptor from bleak.backends.service import BleakGATTService, BleakGATTServiceCollection from .channels.att import ( ATT_ERR_INSUFFICIENT_ENCRYPTION, CCCD_UUID, DEFAULT_MTU, PREFERRED_MTU, ATTClient, properties_to_strings, ) from .channels.bluez import ( AuthenticationFailed, NewLongTermKey, UserConfirmationRequest, ) from .channels.l2cap import ( BT_SECURITY_HIGH, BT_SECURITY_LOW, BT_SECURITY_MEDIUM, L2CAPSocket, ) from .const import BDADDR_LE_PUBLIC if TYPE_CHECKING: from collections.abc import Callable from contextlib import AbstractContextManager from bleak.args import SizedBuffer from bleak.assigned_numbers import CharacteristicPropertyName from bleak.backends.device import BLEDevice from .channels.att import GattService from .channels.bluez import ( LongTermKey, MGMTBluetoothCtl, UserPasskeyRequest, ) PairingEvent = ( NewLongTermKey | AuthenticationFailed | UserConfirmationRequest | UserPasskeyRequest ) class SupportsConnecting(Protocol): """The slice of a scanner the client needs: a scan-pause context manager.""" def connecting(self) -> AbstractContextManager[None]: """Pause scanning for the duration of a connection attempt.""" _LOGGER = logging.getLogger(__name__) # Fallback connect timeout; the bleak wrapper always supplies ``timeout``. DEFAULT_TIMEOUT = 10.0 # Client Characteristic Configuration Descriptor payloads (Core Vol 3 Part G). _CCCD_NOTIFY = b"\x01\x00" _CCCD_INDICATE = b"\x02\x00" _CCCD_OFF = b"\x00\x00" @dataclass(slots=True) class MgmtClientData: """Per-connection wiring the mgmt scanner hands to each client instance.""" adapter_address: str # local adapter BD_ADDR the L2CAP socket binds/sends from scanner: SupportsConnecting # provides connecting() to pause scanning # Optional slot bookkeeping: the mgmt scanner tracks live connections itself # (BleakSlotManager is DBus-path based and cannot see L2CAP connections), so # the client reports connect/disconnect by peer address here. register_connection: Callable[[str], None] | None = None unregister_connection: Callable[[str], None] | None = None # Pairing: the scanner supplies the mgmt controller, the adapter index, and a # per-adapter long-term-key store (bonds must outlive a per-connection client # instance so reconnects can restore them). adapter_idx: int | None = None mgmt: MGMTBluetoothCtl | None = None # get_long_term_keys returns every bonded key for the adapter (LOAD_LONG_TERM_ # KEYS replaces the controller's whole list, so a restore must send them all); # add_long_term_key stores one captured key; forget_long_term_keys drops all # keys for a peer on unpair. get_long_term_keys: Callable[[], list[LongTermKey]] | None = None add_long_term_key: Callable[[LongTermKey], None] | None = None forget_long_term_keys: Callable[[str], None] | None = None class HaMgmtClient(BaseBleakClient): """A bleak client that drives GATT over a raw L2CAP ATT channel.""" def __init__( self, address_or_ble_device: BLEDevice | str, *args: Any, client_data: MgmtClientData, **kwargs: Any, ) -> None: """Bind the client to its peer address and adapter wiring.""" kwargs.setdefault("timeout", DEFAULT_TIMEOUT) super().__init__(address_or_ble_device, *args, **kwargs) self._adapter_address = client_data.adapter_address self._scanner = client_data.scanner self._register_connection = client_data.register_connection self._unregister_connection = client_data.unregister_connection self._adapter_idx = client_data.adapter_idx self._mgmt = client_data.mgmt self._get_long_term_keys = client_data.get_long_term_keys self._add_long_term_key = client_data.add_long_term_key self._forget_long_term_keys = client_data.forget_long_term_keys details = getattr(address_or_ble_device, "details", None) or {} if "address_type" in details: self._address_type: int = details["address_type"] else: # Adverts always carry the peer address type, so the scanner should # populate it; fall back to public but log so a missing field is not # only visible as an opaque L2CAP connect timeout later. self._address_type = BDADDR_LE_PUBLIC _LOGGER.debug( "%s: no address_type in details; assuming LE public", self.address ) self._att: ATTClient | None = None self._sock: L2CAPSocket | None = None self._connected = False # Strong references to in-flight pairing reply tasks (e.g. an auto-sent # confirmation) so they are not garbage-collected before they send. self._pairing_tasks: set[asyncio.Task[Any]] = set() # -- properties -------------------------------------------------------- @property def is_connected(self) -> bool: """Whether the L2CAP/ATT channel is currently up.""" return self._connected @property def mtu_size(self) -> int: """The negotiated ATT MTU, or the default until it is exchanged.""" return self._att.mtu if self._att is not None else DEFAULT_MTU # -- connection -------------------------------------------------------- async def connect(self, pair: bool, **kwargs: Any) -> None: """Open the L2CAP channel, exchange MTU, and discover services.""" if self._connected: msg = "already connected" raise BleakError(msg) await self._restore_bond() # If we already hold a key for this peer, request encryption up front so # the reconnect encrypts like bluetoothd rather than running MTU exchange # and discovery in the clear until the first gated op is rejected. The # on-demand escalation in the codec still backstops everything else. security_level = ( BT_SECURITY_MEDIUM if self._has_stored_bond() else BT_SECURITY_LOW ) att = ATTClient( send=self._send_pdu, on_disconnect=self._handle_disconnect, escalate_security=self._escalate_security, ) self._att = att try: with self._scanner.connecting(): self._sock = await L2CAPSocket.create_connection( source=self._adapter_address, address=self.address, address_type=self._address_type, on_data=att.data_received, on_close=att.connection_lost, timeout=self._timeout, security_level=security_level, ) if pair: # Honor bleak's connect(pair=True): bond as part of # connecting so callers that pair-then-use get a real bond # (and an encrypted link) before MTU exchange and discovery. await self._pair_if_possible() await att.exchange_mtu(PREFERRED_MTU) services = await att.discover() except BaseException: self._handle_disconnect(None) raise self.services = self._build_services(services) self._connected = True if self._register_connection is not None: # Slot bookkeeping is best-effort; a failure must not undo a # connection that is already established. try: self._register_connection(self.address) except Exception: _LOGGER.exception( "%s: connection slot register callback failed", self.address ) async def disconnect(self) -> None: """ Close the channel; idempotent. Like the BlueZ backend this replaces (and unlike CoreBluetooth/WinRT), a deliberate disconnect also fires the disconnected callback. """ self._handle_disconnect(None) async def _send_pdu(self, data: bytes) -> None: """Write one ATT PDU through the transport (bound into the codec).""" # _att is created before the socket, but its send is only driven after # create_connection has returned and assigned _sock. if self._sock is None: # pragma: no cover - send only runs once connected msg = "transport not connected" raise BleakError(msg) await self._sock.send(data) def _escalate_security(self, att_error: int) -> bool: """ Raise the link security to satisfy an ATT auth/encryption rejection. Mirrors bluetoothd: insufficient encryption (0x0F) requests MEDIUM, insufficient authentication (0x05) steps up one level. The kernel only exposes LOW/MEDIUM/HIGH, so nothing above HIGH can be tried. Returns whether the level was raised, so the codec knows to re-issue once. """ if self._sock is None: # pragma: no cover - only called once connected return False if att_error == ATT_ERR_INSUFFICIENT_ENCRYPTION: target = BT_SECURITY_MEDIUM else: # ATT_ERR_INSUFFICIENT_AUTHENTICATION target = self._sock.security_level + 1 if target > BT_SECURITY_HIGH: return False raised = self._sock.set_security_level(target) if raised: _LOGGER.debug( "%s: raised link security to level %d after ATT error 0x%02x", self.address, target, att_error, ) return raised def _handle_disconnect(self, exc: Exception | None) -> None: """Tear the channel down once and notify on an unexpected drop.""" if exc is not None: # The codec only passes a cause on an unexpected drop; a deliberate # disconnect() passes None and stays silent. bleak's callback takes # no args, so this log is the only place the reason can surface. _LOGGER.debug("%s: L2CAP/ATT channel lost: %s", self.address, exc) was_connected = self._connected self._connected = False self._att = None if self._sock is not None: # Idempotent: a transport-level drop already closed it; a request # timeout poisons the codec without closing, so close it here. self._sock.close() self._sock = None if was_connected: if self._unregister_connection is not None: # Best-effort: a bookkeeping failure must not stop the # disconnected callback from firing. try: self._unregister_connection(self.address) except Exception: _LOGGER.exception( "%s: connection slot unregister callback failed", self.address, ) if self._disconnected_callback is not None: self._disconnected_callback() # -- GATT model -------------------------------------------------------- def _build_services( self, services: list[GattService] ) -> BleakGATTServiceCollection: """Translate the discovered tree into bleak's GATT collection.""" collection = BleakGATTServiceCollection() for svc in services: bleak_service = BleakGATTService(svc, svc.handle, svc.uuid) collection.add_service(bleak_service) for char in svc.characteristics: bleak_char = BleakGATTCharacteristic( char, # Reads/writes target the value handle, so that is the # handle bleak resolves operations against. char.value_handle, char.uuid, cast( "list[CharacteristicPropertyName]", properties_to_strings(char.properties), ), self._max_write_without_response_size, bleak_service, ) collection.add_characteristic(bleak_char) for desc in char.descriptors: collection.add_descriptor( BleakGATTDescriptor(desc, desc.handle, desc.uuid, bleak_char) ) return collection def _max_write_without_response_size(self) -> int: """Largest write-without-response payload for the current MTU.""" return self.mtu_size - 3 # -- GATT operations --------------------------------------------------- async def read_gatt_char( self, characteristic: BleakGATTCharacteristic, *, use_cached: bool = False, **kwargs: Any, ) -> bytearray: """Read a characteristic value by its value handle.""" return bytearray(await self._codec().read(characteristic.handle)) async def read_gatt_descriptor( self, descriptor: BleakGATTDescriptor, *, use_cached: bool = False, **kwargs: Any, ) -> bytearray: """Read a descriptor value by its handle.""" return bytearray(await self._codec().read(descriptor.handle)) async def write_gatt_char( self, characteristic: BleakGATTCharacteristic, data: SizedBuffer, response: bool, ) -> None: """Write a characteristic value, with or without a response.""" codec = self._codec() if response: await codec.write(characteristic.handle, bytes(data)) else: await codec.write_command(characteristic.handle, bytes(data)) async def write_gatt_descriptor( self, descriptor: BleakGATTDescriptor, data: SizedBuffer ) -> None: """Write a descriptor value with a response.""" await self._codec().write(descriptor.handle, bytes(data)) async def start_notify( self, characteristic: BleakGATTCharacteristic, callback: Callable[[bytearray], None], **kwargs: Any, ) -> None: """Enable notifications/indications by writing the CCCD and routing them.""" codec = self._codec() if "notify" in characteristic.properties: cccd_value = _CCCD_NOTIFY elif "indicate" in characteristic.properties: cccd_value = _CCCD_INDICATE else: msg = "characteristic does not support notify or indicate" raise BleakError(msg) cccd = characteristic.get_descriptor(CCCD_UUID) if cccd is None: msg = "characteristic has no client configuration descriptor" raise BleakError(msg) # Register before enabling so a notification racing in right after the # CCCD write is not lost; unwind if the write fails so no handler is left # registered for notifications that were never enabled. codec.set_notify_handler(characteristic.handle, callback) try: await codec.write(cccd.handle, cccd_value) except BaseException: codec.remove_notify_handler(characteristic.handle) raise async def stop_notify(self, characteristic: BleakGATTCharacteristic) -> None: """Disable notifications by clearing the CCCD and dropping the handler.""" codec = self._codec() cccd = characteristic.get_descriptor(CCCD_UUID) try: if cccd is not None: await codec.write(cccd.handle, _CCCD_OFF) finally: # Drop the handler even if disabling the CCCD failed, mirroring # start_notify's unwind, so a failed write leaves no stale handler # routing notifications for a subscription the caller stopped. codec.remove_notify_handler(characteristic.handle) # -- pairing ----------------------------------------------------------- async def _restore_bond(self) -> None: """Reload all bonded keys so the kernel re-encrypts known peers.""" if ( self._mgmt is None or self._adapter_idx is None or self._get_long_term_keys is None ): return # LOAD_LONG_TERM_KEYS replaces the controller's whole list, so send every # stored key; the kernel ignores keys for peers that are not connecting, # and we must not wipe other peers' bonds on this adapter. keys = self._get_long_term_keys() if keys and not await self._mgmt.load_long_term_keys(self._adapter_idx, keys): _LOGGER.warning( "%s: failed to restore %d bonded key(s); the link may not encrypt", self.address, len(keys), ) def _has_stored_bond(self) -> bool: """Whether a long-term key is stored for this peer.""" if self._get_long_term_keys is None: return False address = self.address.upper() return any(key.address.upper() == address for key in self._get_long_term_keys()) def _require_mgmt(self) -> tuple[MGMTBluetoothCtl, int]: """Return the mgmt controller and adapter index, or raise if unavailable.""" if self._mgmt is None or self._adapter_idx is None: msg = "pairing requires the management socket" raise BleakError(msg) return self._mgmt, self._adapter_idx async def _pair_if_possible(self) -> None: """Bond as part of connect when needed and mgmt is wired.""" if self._has_stored_bond(): # Already bonded: the proactive MEDIUM handles re-encryption, and # re-pairing a bonded peer is rejected (ALREADY_PAIRED), which would # otherwise fail the connect. return if self._mgmt is None or self._adapter_idx is None: _LOGGER.debug( "%s: connect(pair=True) but the management socket is not wired; " "skipping bond", self.address, ) return await self.pair() async def pair(self, *args: Any, **kwargs: Any) -> None: """ Bond with the peer over the management socket (Just Works). Auto-accepts a Just Works confirmation (the only interaction a NoInputNoOutput controller can satisfy) so such a peer does not stall the bond, but rejects a real numeric comparison it cannot verify; and fails fast on an authentication failure instead of waiting out the mgmt command timeout. The captured key is stored for reconnects. """ mgmt, adapter_idx = self._require_mgmt() address = self.address loop = asyncio.get_running_loop() auth_failed: asyncio.Future[AuthenticationFailed] = loop.create_future() def _capture(event: PairingEvent) -> None: if isinstance(event, NewLongTermKey): self._store_captured_key(event) elif isinstance(event, UserConfirmationRequest): # confirm_hint != 0 means "just confirm, no number to compare" # (the Just Works case a NoInputNoOutput controller can satisfy), # so accept it; confirm_hint == 0 carries a real numeric value we # cannot verify with no IO, so reject rather than blindly confirm # and defeat the comparison's MITM protection. Runs in the read # loop, so the async reply is scheduled with a strong reference. accept = bool(event.confirm_hint) task = loop.create_task( mgmt.user_confirmation_reply( adapter_idx, address, self._address_type, accept=accept ) ) self._pairing_tasks.add(task) task.add_done_callback(self._on_pairing_task_done) elif isinstance(event, AuthenticationFailed) and not auth_failed.done(): auth_failed.set_result(event) unregister = mgmt.register_pairing_handler(adapter_idx, address, _capture) pair_task = loop.create_task( mgmt.pair_device(adapter_idx, address, self._address_type) ) try: await asyncio.wait( (pair_task, auth_failed), return_when=asyncio.FIRST_COMPLETED ) if auth_failed.done() and not pair_task.done(): # Surface the failure now rather than waiting out pair_device's # mgmt timeout. event = auth_failed.result() msg = f"{address}: pairing failed (auth status 0x{event.status:02x})" raise BleakError(msg) if not await pair_task: msg = f"{address}: pairing failed" raise BleakError(msg) finally: unregister() if not pair_task.done(): pair_task.cancel() with contextlib.suppress(asyncio.CancelledError): await pair_task def _store_captured_key(self, event: NewLongTermKey) -> None: """Persist a captured long-term key, honoring the kernel's store hint.""" if not event.store_hint: # The kernel is signalling a key it does not want persisted. return if self._add_long_term_key is None: _LOGGER.warning( "%s: captured a long-term key but no key store is wired", self.address ) return self._add_long_term_key(event.key) def _on_pairing_task_done(self, task: asyncio.Task[Any]) -> None: """Drop a finished pairing reply task and log any escaped failure.""" self._pairing_tasks.discard(task) if not task.cancelled() and (exc := task.exception()) is not None: _LOGGER.debug("%s: pairing reply task failed: %s", self.address, exc) async def unpair(self) -> None: """Remove the bond over mgmt and forget the stored key.""" mgmt, adapter_idx = self._require_mgmt() if not await mgmt.unpair_device(adapter_idx, self.address, self._address_type): # Don't drop the local key if the kernel bond is still there, or the # store would silently desync from the controller. msg = f"{self.address}: unpair failed" raise BleakError(msg) if self._forget_long_term_keys is not None: self._forget_long_term_keys(self.address) def _codec(self) -> ATTClient: """Return the live ATT codec or raise if the channel is down.""" if self._att is None or not self._connected: msg = "not connected" raise BleakError(msg) return self._att Bluetooth-Devices-habluetooth-1390f03/src/habluetooth/const.py000066400000000000000000000237561522222566700244550ustar00rootroot00000000000000"""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 # When a different scanner's advertisement arrives after the owner's last # advertisement is stale, the owner is handed off immediately only if the # challenger is comparable-or-stronger (within ADV_RSSI_SWITCH_THRESHOLD of # the owner). A materially weaker scanner must wait until the owner has been # silent for stale_seconds * this factor (capped at # FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS) before it can take over. This # stops a far weaker proxy from stealing ownership on a single missed # interval and surfacing a stale capture for scan-response-only sensors # (e.g. Govee H5074) seen by many active proxies, while still letting a # device that genuinely moved into weak-only coverage hand off. DURABLY_GONE_STALE_FACTOR: Final = 2.5 # How many stale windows must elapse before the roaming rule (a # comparable-or-stronger challenger of a weak owner) may take a stale # device. Scanners duty-cycle their scan windows, so "the owner missed # an advertisement" is a per-scanner reception lottery, not evidence # the device moved; on a stationary network the first missed interval # routinely parks ownership on a weak scanner that the strong owner # then reclaims on the RSSI path, churning ownership with no data # benefit. Requiring one and a half stale windows means the owner must # effectively miss two reception opportunities before a roam, while a # genuinely departed device still roams well before the durably-gone # backstop (must stay below DURABLY_GONE_STALE_FACTOR or the roaming # rule can never fire). STALE_ROAM_FACTOR: Final = 1.5 # An owner whose last advertisement was at least this strong is treated as # close/stationary: a brief reception gap is almost certainly RF/scan-response # jitter rather than the device leaving, so a merely-comparable challenger # (not ADV_RSSI_SWITCH_THRESHOLD stronger) must wait for durable absence # before stealing ownership, instead of flapping the device on a single # missed interval. A weaker owner keeps the normal comparable-on-stale # handoff. Above the -100 owners used in the stale tests so they are # unaffected, and below typical close-proxy readings. STRONG_OWNER_STALE_RSSI: Final = -70 # Smoothing factor (EWMA alpha) for the per-(address, source) advertisement # RSSI used to choose a device's owning scanner. Ownership switches on the # smoothed value rather than a single sample, so a stationary device whose # RSSI to a fixed proxy fades by multipath does not flap between proxies on # momentary spikes; a genuine move still crosses once the average shifts. # Smaller = steadier but slower to follow a real move. At 0.3 a single spike # moves the smoothed value ~30% of the gap, so even a 30 dB spike shifts it # under the 16 dB switch threshold. RSSI_SMOOTHING_FACTOR: Final = 0.3 # 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 = 35.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 # How long a rescue-scan episode waits for its triggered active window # before re-triggering (issue #591). A stale handoff for a device that # needs active scans is deferred until the owner (and the challenger) # have had an active window; if none materialized within this long # (scanner busy, dispatch lost, window declined), the next arbitration # restarts the episode and re-triggers, so retries are spaced by this # interval. Long enough for a slow window dispatch to land, short # enough that the durably-gone backstop is not the only recovery. RESCUE_SCAN_RETRY_SECONDS: Final = 30.0 # How long a rescue active window must have been running before a # capture from the scanning side is trusted as an active capture. # Once the radio has been actively scanning this long, an incoming # advertisement cannot be a delayed passive capture that is still # missing its scan response, so the deferred handoff can proceed # without waiting for the full window to close. Every accept time # (including "already ACTIVE and scanning" coverage) sits this far # after coverage was confirmed, which doubles as a grace window: the # owner always gets one chance to re-hear the device and invalidate # the episode before any handoff, so a single missed advertising # interval can never flap ownership. Derived from # AUTO_WINDOW_MIN_DURATION so it can never exceed a window's length. RESCUE_SCAN_ACCEPT_SECONDS: Final = AUTO_WINDOW_MIN_DURATION 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 # Extra margin, on top of ADV_RSSI_SWITCH_THRESHOLD, that a challenger must beat # the current owner by to RECLAIM ownership it was just demoted from, on the # active RSSI path (both scanners reporting). This is asymmetric hysteresis: # smoothing removes single-sample noise, but a heavily-multipathed stationary # device whose smoothed RSSI to two proxies genuinely straddles the 16 dB line # still oscillates, because each leg of the A->B->A ping-pong only needs 16 dB. # Charging 16 + deadband only to the source that was just demoted widens the # band its smoothed signal must swing back through to reclaim, killing that # residual ping-pong, while a genuine one-way move (never the demoted source) # still hands off at the plain 16 dB threshold and is not slowed. The stale-path # "comparable" handoff deliberately does not use this; a silent owner should # still be easy to replace. ADV_RSSI_SWITCH_DEADBAND: Final = 6 # 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-1390f03/src/habluetooth/manager.pxd000066400000000000000000000133051522222566700250710ustar00rootroot00000000000000import 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 double _DURABLY_GONE_STALE_FACTOR cdef int _STRONG_OWNER_STALE_RSSI cdef double _RSSI_SMOOTHING_FACTOR cdef int _ADV_RSSI_SWITCH_DEADBAND cdef double _RESCUE_SCAN_RETRY_SECONDS cdef double _STALE_ROAM_FACTOR cdef frozenset _EMPTY_DEMOTED 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 _smoothed_rssi cdef public dict _demoted_sources cdef public dict _rescue_triggered 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 set _warned_passive_active_scan 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, elapsed=double, old_rssi=double, switch_margin=int, ) cdef bint _prefer_previous_adv_from_different_source( self, BluetoothServiceInfoBleak old, BluetoothServiceInfoBleak new, dict smoothed, double new_rssi, bint record_demotion ) @cython.locals(durably_gone=double) cdef bint _stale_challenger_wins( self, BluetoothServiceInfoBleak old, BluetoothServiceInfoBleak new, double new_rssi, double old_rssi, double elapsed, double stale_seconds, bint record_demotion, ) cdef void _end_rescue_episode(self, str address, bint record_demotion) cdef bint _rescue_stale_handoff( self, BluetoothServiceInfoBleak old, BluetoothServiceInfoBleak new, double elapsed, double stale_seconds, ) @cython.locals(demoted=set) cdef void _record_demotion( self, str address, str new_source, str old_source ) @cython.locals(scanner=BaseHaScanner) cdef bint _should_keep_previous_adv( self, BluetoothServiceInfoBleak old_info, BluetoothServiceInfoBleak new_info, dict smoothed, double new_rssi, bint record_demotion ) @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, smoothed_bucket=dict, prev_smoothed=object, prev_double=double, new_smoothed=double, rssi=int, ) 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-1390f03/src/habluetooth/manager.py000066400000000000000000002541311522222566700247320ustar00rootroot00000000000000"""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_DEADBAND, ADV_RSSI_SWITCH_THRESHOLD, CALLBACK_TYPE, DEFAULT_ACTIVE_SCAN_DURATION, DEFAULT_ACTIVE_SCAN_INTERVAL, DEFAULT_ON_DEMAND_SWEEP_DURATION, DURABLY_GONE_STALE_FACTOR, FAILED_ADAPTER_MAC, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, MIN_ACTIVE_SCAN_DURATION, MIN_ACTIVE_SCAN_INTERVAL, RESCUE_SCAN_RETRY_SECONDS, RSSI_SMOOTHING_FACTOR, STALE_ROAM_FACTOR, STRONG_OWNER_STALE_RSSI, UNAVAILABLE_TRACK_SECONDS, ) from .models import ( BluetoothReachabilityIntent, BluetoothScanningMode, 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 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 # Hot-path C copies of the public constants (declared cdef in the .pxd); # the public names stay patchable Python constants. _DURABLY_GONE_STALE_FACTOR = DURABLY_GONE_STALE_FACTOR _STRONG_OWNER_STALE_RSSI = STRONG_OWNER_STALE_RSSI _RSSI_SMOOTHING_FACTOR = RSSI_SMOOTHING_FACTOR _ADV_RSSI_SWITCH_DEADBAND = ADV_RSSI_SWITCH_DEADBAND _RESCUE_SCAN_RETRY_SECONDS = RESCUE_SCAN_RETRY_SECONDS _STALE_ROAM_FACTOR = STALE_ROAM_FACTOR # Shared empty set used as the default for the reclaim-hysteresis lookup, so the # hot path skips a None check and never allocates a throwaway set. _EMPTY_DEMOTED: frozenset[str] = frozenset() _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", "_demoted_sources", "_disappeared_callbacks", "_fallback_intervals", "_intervals", "_loop", "_mgmt_ctl", "_name_cache", "_non_connectable_scanners", "_recovery_lock", "_rescue_triggered", "_scanner_mode_change_callbacks", "_scanner_registration_callbacks", "_side_channel_scanners", "_smoothed_rssi", "_sources", "_subclass_discover_info", "_unavailable_callbacks", "_warned_passive_active_scan", "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] = {} # address -> source -> EWMA-smoothed advertisement RSSI. Only # populated for addresses seen from more than one source (the # arbitration that uses it only runs cross-source); single-proxy # devices never allocate a bucket. Evicted with the device. self._smoothed_rssi: dict[str, dict[str, float]] = {} # address -> the set of sources currently in the demoted state (each one # lost ownership and is not the current owner). Used for asymmetric # switch hysteresis: a challenger reclaiming ownership it recently lost # must clear an extra deadband, so a stationary device stops # ping-ponging between similar-signal proxies; a genuine one-way move to # a source that has never recently owned the device is not in the set # and pays nothing. Holding the whole set (not just the most-recent # loser) damps N-way contention too: in an A->B->C->A bounce A is still # in the set when it reclaims from C, so it is charged the deadband. The # current owner is never in its own set (removed on each win), so the # set is self-bounded by the proxy count. Evicted with the device and # per source on unregister. self._demoted_sources: dict[str, set[str]] = {} # address -> monotonic time a rescue-scan episode started for a # device that needs active scans (issue #591). When the stale # handoff is denied for such a device, an active window is # triggered on both the owner's and the challenger's scanners # instead of pinning ownership; the switch is deferred until the # challenger's advertisement postdates the rescue's accept time # (see _rescue_stale_handoff). The owner being heard again # invalidates the episode (old.time >= trigger), so a stale # record can never authorize a later instant switch. Evicted # with the device; an entry orphaned by its active-scan need being # unregistered is cleared on the device's next stale arbitration. self._rescue_triggered: dict[str, float] = {} # 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, BaseHaScanner] = {} 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]] ] = {} # Sources of passive-only scanners we've already warned about # while active scans are requested; deduped so one passive proxy # behind many devices warns once. self._warned_passive_active_scan: set[str] = set() 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) self._smoothed_rssi.pop(address, None) self._demoted_sources.pop(address, None) self._rescue_triggered.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, smoothed: dict[str, float] | None, new_rssi: float, record_demotion: bool, ) -> 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. ``smoothed`` is the address's per-source smoothed-RSSI bucket, which is None until the device is seen from more than one source; testing it first lets a single-proxy device exit before the source compares and narrows the bucket to non-None for the cross-source predicate. ``new_rssi`` is the already-computed smoothed value for new_info's source, threaded through to avoid re-looking it up. ``record_demotion`` is forwarded to the predicate so an RSSI-path switch can remember the demoted owner; it is True only for the all-history decision, not the connectable re-check. """ return ( smoothed is not None and 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, smoothed, new_rssi, record_demotion ) ) def _prefer_previous_adv_from_different_source( self, old: BluetoothServiceInfoBleak, new: BluetoothServiceInfoBleak, smoothed: dict[str, float], new_rssi: float, record_demotion: bool, ) -> bool: """Prefer previous advertisement from a different source if it is better.""" # Compare smoothed per-source RSSI so a momentary spike does not flip # ownership for a stationary device. ``new_rssi`` is the caller's # already-computed smoothed value for new.source; old's smoothed value # is looked up here, falling back to its instantaneous RSSI when this # source has no smoothed sample yet. old_rssi = smoothed.get(old.source, old.rssi or NO_RSSI_VALUE) 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 elapsed = new.time - old.time if elapsed > stale_seconds and self._stale_challenger_wins( old, new, new_rssi, old_rssi, elapsed, stale_seconds, record_demotion ): return False # Only a challenger that clears the plain THRESHOLD can win, so do the # cheap compare first and skip the reclaim-state lookup on every # arbitration that keeps the owner (the common case). if new_rssi - ADV_RSSI_SWITCH_THRESHOLD <= old_rssi: return True # Asymmetric hysteresis: a challenger reclaiming ownership it recently # lost must also clear DEADBAND. This stops a heavily-multipathed # stationary device whose smoothed RSSI straddles the threshold from # ping-ponging between similar-signal proxies, while a genuine one-way # move (never a recent owner) still hands off at THRESHOLD. The empty-set # default keeps this a single set membership test with no None check. switch_margin = ADV_RSSI_SWITCH_THRESHOLD if new.source in self._demoted_sources.get(new.address, _EMPTY_DEMOTED): switch_margin += _ADV_RSSI_SWITCH_DEADBAND if new_rssi - switch_margin > old_rssi: # The new source wins ownership on the active RSSI path. Record the # ownership change so a quick reclaim faces the deadband above (only # for the all-history decision, and not the stale handoff, so a # strong owner recovering from transient silence still reclaims at # THRESHOLD). if record_demotion: self._record_demotion(new.address, new.source, old.source) # If new advertisement is switch_margin more, prefer the new one # (smoothed RSSI when available). 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, switch_margin, old_rssi, ) return False return True def _stale_challenger_wins( self, old: BluetoothServiceInfoBleak, new: BluetoothServiceInfoBleak, new_rssi: float, old_rssi: float, elapsed: float, stale_seconds: float, record_demotion: bool, ) -> bool: """ Decide a stale handoff; True hands the device to the challenger. The owner has not been heard within its expected interval, so the challenger's advertisement is strictly newer than anything the owner has produced. What happens next depends on whether the device needs active scans (an integration registered an on-demand active-scan need via async_register_active_scan because the device's data rides SCAN_RSP): An owner silent past the durably-gone threshold loses to any challenger, for every device class: receive time is all we have (adverts carry no timestamp), so the durably-gone wait is what lets a device that truly moved into weak-only coverage still hand off. Short of that, a challenger of a strong owner must NOT take over on a single missed interval: the owner almost certainly re-hears the device on its next advertisement and either the handoff flaps straight back (a materially stronger reclaim) or cannot flap back and ping-pongs on alternating misses (a comparable pair) — the stationary-device flap issues #568/#580 fixed. A materially stronger challenger can still win at any time on the smoothed RSSI path below (deadband-protected, stale or not); what differs per device class is everything in between: * Passive device (no registered need): a comparable-or-stronger challenger of a weak owner takes over once the owner has been silent STALE_ROAM_FACTOR stale windows (ordinary roaming; payloads are identical across scanners, so its capture is as good as anyone's, and the extra half window keeps a per-scanner reception miss from roaming a stationary device). Anything else waits for durably-gone, which costs nothing data-wise for the same reason. * Active-need device: there is no roaming shortcut at all — even a comparable challenger of a weak owner may be an AUTO scanner in its passive phase whose capture carries no fresh scan response (issue #568). Instead of pinning ownership until the owner is durably gone (issue #591), trigger an active window on both the owner (a chance to re-hear the device) and the challenger (its next capture is a fresh scan response), and hand off on the first challenger advertisement past the accept time while the owner stayed silent (see _rescue_stale_handoff). Accept times always sit RESCUE_SCAN_ACCEPT_SECONDS after coverage so the owner is guaranteed one re-hear window before any handoff. An owner that paused scanning entirely never reaches this code; its devices were handed off at the scanning gate. The cheap comparisons decide first; the scheduler lookup that classifies the device as active-need runs only when they keep the owner, so the ordinary roaming and durably-gone switches never pay for it. """ durably_gone = min( stale_seconds * _DURABLY_GONE_STALE_FACTOR, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, ) if elapsed > durably_gone: # Unconditional backstop for every device class: someone has to # own a device whose owner has been silent this long, even if # the challenger's capture is not an active one. if self._debug: _LOGGER.debug( "%s (%s): Switching from %s to %s (time elapsed:%s > stale" " seconds:%s; durably-gone threshold:%s)", new.name, new.address, self._async_describe_source(old), self._async_describe_source(new), elapsed, stale_seconds, durably_gone, ) self._end_rescue_episode(new.address, record_demotion) return True if self._auto_scheduler._requests_by_address.get(new.address) is None: # Passive device: a comparable-or-stronger challenger of a weak # owner takes over once the owner has been silent for # STALE_ROAM_FACTOR stale windows (its capture is as good as # anyone's, payloads are identical across scanners). The extra # half window over plain stale means the owner must miss two # reception opportunities, not one, before roaming; a single # miss is a per-scanner duty-cycle lottery, not evidence the # device moved. Anything else waits for durably-gone above. # The episode ends either way; it also cleans up after an # active-scan need unregistered mid-episode (defense in depth, # the unregister itself clears it too). self._end_rescue_episode(new.address, record_demotion) if ( elapsed > stale_seconds * _STALE_ROAM_FACTOR and new_rssi >= old_rssi - ADV_RSSI_SWITCH_THRESHOLD and old_rssi < _STRONG_OWNER_STALE_RSSI ): if self._debug: _LOGGER.debug( "%s (%s): Switching from %s to %s (time elapsed:%s >" " roam threshold:%s; passive roaming, comparable" " challenger of a weak owner)", new.name, new.address, self._async_describe_source(old), self._async_describe_source(new), elapsed, stale_seconds * _STALE_ROAM_FACTOR, ) return True return False if not record_demotion: # Connectable re-check: connection routing cares about # liveness, not scan-response freshness, so the ordinary # roaming rule applies here exactly as it does for a passive # device (same STALE_ROAM_FACTOR gate); the connectable # history must repoint away from a silent scanner well before # durably-gone rather than route a connect attempt at a dead # radio. Only the all-history decision drives episode state. return ( elapsed > stale_seconds * _STALE_ROAM_FACTOR and new_rssi >= old_rssi - ADV_RSSI_SWITCH_THRESHOLD and old_rssi < _STRONG_OWNER_STALE_RSSI ) # Active-need all-history decision: never take the roaming # shortcut, even for a comparable challenger of a weak owner; the # challenger may be an AUTO scanner sitting in its passive phase # whose capture carries no fresh scan response (issue #568). # Every handoff before durably-gone goes through the rescue flow, # so it lands on a capture from an address a recent active window # covered. return self._rescue_stale_handoff(old, new, elapsed, stale_seconds) def _end_rescue_episode(self, address: str, record_demotion: bool) -> None: """ End any rescue episode on a stale arbitration outside the rescue. Runs on the durably-gone handoff and on every passive stale arbitration whether or not the device hands off (the passive case also cleans up an episode orphaned by an unregistered active-scan need). Only the all-history decision (record_demotion) owns episode state, and the empty-dict check keeps the common no-episode case to a single branch. """ if record_demotion and self._rescue_triggered: self._rescue_triggered.pop(address, None) def _rescue_stale_handoff( self, old: BluetoothServiceInfoBleak, new: BluetoothServiceInfoBleak, elapsed: float, stale_seconds: float, ) -> bool: """ Advance the rescue episode for a denied stale handoff; True = hand off. Runs for every stale challenger of a device with a registered active-scan need that is not durably gone. First denial starts an episode: the trigger time is recorded and the scheduler runs an active window on both the owner and the challenger. Later denials hand off once the rescue's accept time (the window has been actively scanning long enough that a capture cannot be a delayed passive one) postdates the trigger and the challenger's advertisement postdates the accept time while the owner stayed silent (old.time only advances when the owner wins, so the owner being heard invalidates the episode), and re-trigger if no window materialized within the retry wait. """ pending = self._rescue_triggered.get(new.address) if pending is None or old.time >= pending: # No episode, or the owner has been heard since the last # trigger: start a fresh episode. self._rescue_triggered[new.address] = new.time self._auto_scheduler.trigger_rescue(new.address, new.source, old.source) if self._debug: _LOGGER.debug( "%s (%s): Deferring switch from %s to %s; triggered" " rescue active scan (time elapsed:%s > stale" " seconds:%s)", new.name, new.address, self._async_describe_source(old), self._async_describe_source(new), elapsed, stale_seconds, ) return False accept_after = self._auto_scheduler._rescue_accept_after.get(new.address, 0.0) # The accept grace alone is empty for slow advertisers (a # 60s-interval sensor cannot re-advertise within it), so the # handoff additionally requires one full advertising interval # (stale_seconds minus the wobble) since the trigger; that # guarantees the owner one transmission opportunity to invalidate # the episode regardless of the device's cadence. if ( accept_after >= pending and new.time > accept_after and new.time - pending > stale_seconds - TRACKER_BUFFERING_WOBBLE_SECONDS ): # A rescue window's accept time postdates the trigger and the # owner is still silent: hand off. The accept time is the max # across both sides, so a deaf owner whose window never ran # does not block the handoff; being deaf is exactly the case # being handed off from. del self._rescue_triggered[new.address] if self._debug: _LOGGER.debug( "%s (%s): Switching from %s to %s (owner silent" " after rescue active scan; accept time %s," " triggered at %s)", new.name, new.address, self._async_describe_source(old), self._async_describe_source(new), accept_after, pending, ) return True if accept_after < pending and new.time - pending > _RESCUE_SCAN_RETRY_SECONDS: # The triggered window never materialized (scanner busy, # dispatch lost): restart the episode and try again. Advancing # the trigger time spaces retries by the retry interval instead # of re-triggering on every advertisement once it elapses. A # recorded accept means the episode is merely waiting out the # gates above; restarting would clobber it. Net effect of the # retry cadence: the rescue only accelerates handoffs for # advertisers faster than roughly the retry interval, and the # durably-gone backstop governs slower ones. self._rescue_triggered[new.address] = new.time self._auto_scheduler.trigger_rescue(new.address, new.source, old.source) return False def _record_demotion(self, address: str, new_source: str, old_source: str) -> None: """ Move ownership in the reclaim-hysteresis set for an active RSSI switch. The new source becomes owner (leaves the demoted set) and the old owner joins it. ``old_source`` is guaranteed registered by the predicate that calls this. Runs only on an actual switch, so the set create is cheap. """ demoted = self._demoted_sources.get(address) if demoted is None: demoted = set() self._demoted_sources[address] = demoted demoted.discard(new_source) demoted.add(old_source) 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 source = service_info.source old_service_info = self._all_history.get(service_info.address) # Maintain the smoothed RSSI used by cross-source owner arbitration # so a stationary device does not flap between similar-distance # proxies on momentary RSSI spikes. The bucket only exists once an # address is seen from more than one source; a single-proxy device # just takes the miss-lookup below and skips the rest (its # new_smoothed is never read, since the predicate bails when the # bucket is None). Runs before the same-payload short-circuit below # because RSSI changes even when the payload does not. # # A missing RSSI (proxies occasionally drop it) must NOT be folded in: # NO_RSSI_VALUE (-127) would poison the average and, because the EWMA # remembers it, drag the smoothed value down for several adverts # afterwards, manufacturing phantom weak readings that flap ownership. # So a no-RSSI advert keeps the last good smoothed value and writes # nothing; it never seeds or updates the average. new_smoothed = 0.0 # Fast path for proxy-free setups: when no address has ever been seen # from more than one source the whole map is empty, so skip the keyed # lookup and only pay an O(1) emptiness check. smoothed_bucket = ( self._smoothed_rssi.get(service_info.address) if self._smoothed_rssi else None ) if smoothed_bucket is not None: rssi = service_info.rssi or NO_RSSI_VALUE prev_smoothed = smoothed_bucket.get(source) if not service_info.rssi: # No RSSI: keep the last good smoothed value (or NO_RSSI_VALUE # with no history, which simply loses arbitration) and do not # write, so the dropped reading can never poison the average. if prev_smoothed is not None: new_smoothed = prev_smoothed else: new_smoothed = NO_RSSI_VALUE elif prev_smoothed is not None: # prev_double is a cdef double, so the EWMA stays C-only. prev_double = prev_smoothed new_smoothed = ( _RSSI_SMOOTHING_FACTOR * rssi + (1.0 - _RSSI_SMOOTHING_FACTOR) * prev_double ) smoothed_bucket[source] = new_smoothed else: new_smoothed = rssi smoothed_bucket[source] = new_smoothed elif ( old_service_info is not None and old_service_info.source is not source and old_service_info.source != source # Only seed against a source that is still registered. An old # source can linger in _all_history after it unregisters (the # unavailable sweep clears it later), and seeding its stale RSSI # would reintroduce the source the unregister cleanup just dropped, # defeating the "re-registered scanner starts fresh" guarantee. and old_service_info.source in self._sources ): # First cross-source sighting: seed the bucket from the existing # owner. Seed the new source only from a real reading; if it has no # RSSI, leave it unseeded so it loses arbitration via NO_RSSI_VALUE # (keeping the current owner) rather than priming the average with # -127 or stealing ownership without a signal. Its next real advert # seeds it cleanly. smoothed_bucket = { old_service_info.source: old_service_info.rssi or NO_RSSI_VALUE } if service_info.rssi: new_smoothed = service_info.rssi smoothed_bucket[source] = new_smoothed else: new_smoothed = NO_RSSI_VALUE self._smoothed_rssi[service_info.address] = smoothed_bucket # 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 is not None and self._should_keep_previous_adv( old_service_info, service_info, smoothed_bucket, new_smoothed, True ): # 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, smoothed_bucket, new_smoothed, False, ) ): 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) self._smoothed_rssi.pop(address, None) self._demoted_sources.pop(address, None) self._rescue_triggered.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._warned_passive_active_scan.discard(scanner.source) # Drop this source's smoothed RSSI from every address bucket so a # re-registered scanner starts fresh and a stale value can't win, and # remove any bucket left empty so the map can return to truly empty # (re-enabling the proxy-free fast path) instead of lingering until the # unavailable sweep clears it. source = scanner.source emptied: list[str] = [] for address, bucket in self._smoothed_rssi.items(): bucket.pop(source, None) if not bucket: emptied.append(address) for address in emptied: del self._smoothed_rssi[address] # Drop this source from every reclaim-hysteresis set so a re-registered # scanner is not penalized for an ownership it lost before it went away, # and remove any set left empty. emptied_demoted: list[str] = [] for address, demoted in self._demoted_sources.items(): demoted.discard(source) if not demoted: emptied_demoted.append(address) for address in emptied_demoted: del self._demoted_sources[address] 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 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) # Covers a scanner registered already in passive mode (no later # set_requested_mode to trigger scanner_mode_changed). self._async_warn_if_passive_with_active_scan(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..35s) 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) # An active scan now exists: warn about any passive-only scanner # that could own such a device and silently starve it. for scanner in self._sources.values(): self._async_warn_if_passive_with_active_scan(scanner) 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._async_warn_if_passive_with_active_scan(scanner) 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", ) def _async_warn_if_passive_with_active_scan(self, scanner: BaseHaScanner) -> None: """ Warn once if ``scanner`` is passive-only while active scans are wanted. A passive-only scanner never runs an active window, so if it becomes the closest scanner for a device that only answers on its scan response (and an integration has asked for active scans on that device) the device's data may be missing. Deduped per source; the entry is dropped on unregister or when the scanner leaves passive mode so a later relapse warns again. It is intentionally not reset when active-scan requests drop to zero: the warning is per-source config advice ("this scanner is passive, fix its mode"), so one warning per source is enough and a later request for a different device need not re-warn. """ source = scanner.source if scanner.requested_mode is not BluetoothScanningMode.PASSIVE: self._warned_passive_active_scan.discard(source) return if ( source in self._warned_passive_active_scan or not self._auto_scheduler.has_active_requests ): return self._warned_passive_active_scan.add(source) _LOGGER.warning( "Scanner %s is in passive-only mode but active scans have been " "requested for one or more devices; if it becomes the closest " "scanner for such a device the device will not be actively " "scanned and its data may be incomplete or missing. Set this " "scanner to active or auto", scanner.name, ) Bluetooth-Devices-habluetooth-1390f03/src/habluetooth/models.pxd000066400000000000000000000017571522222566700247520ustar00rootroot00000000000000import 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-1390f03/src/habluetooth/models.py000066400000000000000000000265651522222566700246130ustar00rootroot00000000000000"""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-1390f03/src/habluetooth/py.typed000066400000000000000000000000001522222566700244250ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-1390f03/src/habluetooth/scanner.py000066400000000000000000000012001522222566700247340ustar00rootroot00000000000000""" Backwards-compatible alias of the local bleak scanner module. The local bleak scanner moved to :mod:`habluetooth.scanner_bleak`; this module stays as an alias so existing ``habluetooth.scanner`` imports and patch targets keep working unchanged. """ from __future__ import annotations import sys from . import scanner_bleak # Make ``habluetooth.scanner`` resolve to the same module object as # ``habluetooth.scanner_bleak`` so attribute access and ``unittest.mock.patch`` # targets that reference ``habluetooth.scanner.`` operate on the real # implementation rather than a detached copy. sys.modules[__name__] = scanner_bleak Bluetooth-Devices-habluetooth-1390f03/src/habluetooth/scanner_bleak.pxd000066400000000000000000000014031522222566700262420ustar00rootroot00000000000000import 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 ) Bluetooth-Devices-habluetooth-1390f03/src/habluetooth/scanner_bleak.py000066400000000000000000001136101522222566700261030ustar00rootroot00000000000000"""A local bleak scanner.""" from __future__ import annotations import asyncio import contextlib import logging import math import platform 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 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 # Pin the logger name to the pre-split module so log levels, filters, and # log scrapers configured for "habluetooth.scanner" keep working unchanged # after the move to scanner_bleak. _LOGGER = logging.getLogger("habluetooth.scanner") 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 ) 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_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-1390f03/src/habluetooth/scanner_device.py000066400000000000000000000013441522222566700262640ustar00rootroot00000000000000"""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-1390f03/src/habluetooth/scanner_mgmt.py000066400000000000000000000453671522222566700260060ustar00rootroot00000000000000""" Local scanner driven entirely by the BlueZ management socket. EXPERIMENTAL: this module and its public names (``HaScannerMgmt`` and ``create_local_scanner``) are a work in progress. The API is not stable and is likely to change or be renamed; nothing in Home Assistant selects this scanner yet, so do not depend on it. ``HaScannerMgmt`` is the DBus-free counterpart to :class:`HaScanner`: it drives discovery through the kernel management socket (active discovery or a passive advertisement monitor) and routes connections through :class:`HaMgmtClient` over a raw L2CAP ATT channel. Advertisements arrive via the shared mgmt side channel, which the manager points at this scanner once it is registered (keyed by ``adapter_idx``), so the hot advert path is inherited unchanged from :class:`BaseHaScanner`. Connection slots are tracked here rather than in ``BleakSlotManager``: that manager counts connections by BlueZ DBus path, which an L2CAP connection never creates, so it cannot see mgmt connections. This scanner keeps its own set of live peer addresses and gates ``can_connect`` against the configured slot count (read from the slot manager purely as the limit). ``create_local_scanner`` is the factory Home Assistant will call instead of constructing a scanner directly; it returns this scanner when mgmt discovery is available and falls back to the bleak-backed :class:`HaScanner` otherwise. Home Assistant is not wired to the factory yet, so nothing selects this scanner in production. """ from __future__ import annotations import asyncio import logging from functools import partial from typing import TYPE_CHECKING, cast from bleak_retry_connector import Allocations from bluetooth_adapters import DEFAULT_ADDRESS from .base_scanner import BaseHaScanner from .central_manager import get_manager from .channels.l2cap import can_use_l2cap from .client_mgmt import HaMgmtClient, MgmtClientData from .models import BluetoothScanningMode, HaBluetoothConnector from .scanner_bleak import IS_LINUX, HaScanner, ScannerStartError, _resolve_radio_mode if TYPE_CHECKING: from collections.abc import Callable, Coroutine, Iterable from typing import Any from bleak.backends.client import BaseBleakClient from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from .channels.bluez import LongTermKey from .const import CALLBACK_TYPE _LOGGER = logging.getLogger(__name__) class _MgmtClientFactory(partial): # type: ignore[type-arg] """A functools.partial that binds per-connection data into HaMgmtClient.""" # The connect path derives the backend id from # ``type(scanner.connector.client).__name__`` (wrappers.py); a bare partial # reads as the generic "partial", so name this factory after the client it # builds for clear connect diagnostics. _MgmtClientFactory.__name__ = "HaMgmtClient" _MgmtClientFactory.__qualname__ = "HaMgmtClient" class HaScannerMgmt(BaseHaScanner): """A local scanner that discovers and connects over the BlueZ mgmt socket.""" def __init__(self, mode: BluetoothScanningMode, adapter: str, address: str) -> None: """Set up the scanner and wire connections to the mgmt GATT client.""" if address == DEFAULT_ADDRESS: # The L2CAP connect path binds to this address to pin connections to # the discovering adapter; without a real BD_ADDR it would bind to # BDADDR_ANY and route through the wrong radio. Fail fast rather than # mis-route (the factory already guards against this). msg = "HaScannerMgmt requires a real adapter address, not DEFAULT_ADDRESS" raise ValueError(msg) self.mac_address = address source = address # Per-adapter bond store; survives the per-connection client instances so # a reconnect can restore captured keys. Keyed by (address, central, ediv) # so a peer can hold more than one LTK without later events clobbering it. self._long_term_keys: dict[tuple[str, bool, int], LongTermKey] = {} suffix = adapter.removeprefix("hci") adapter_idx = ( int(suffix) if adapter.startswith("hci") and suffix.isdigit() else None ) connector = HaBluetoothConnector( # partial-bind the per-connection data; the wrapper calls # connector.client(device, ...) like any backend class. client=cast( "type[BaseBleakClient]", _MgmtClientFactory( HaMgmtClient, client_data=MgmtClientData( adapter_address=address, scanner=self, register_connection=self._register_connection, unregister_connection=self._unregister_connection, adapter_idx=adapter_idx, mgmt=get_manager().get_bluez_mgmt_ctl(), get_long_term_keys=self.export_long_term_keys, add_long_term_key=self._store_long_term_key, forget_long_term_keys=self._forget_long_term_keys, ), ), ), source=source, can_connect=self._can_connect, ) super().__init__(source, adapter, connector=connector, requested_mode=mode) self.connectable = True self.scanning = False self._start_stop_lock = asyncio.Lock() # Whether discovery is meant to be running. A watchdog restart can be # queued on the start/stop lock just before a stop takes it; without this # the restart would run after the stop and resurrect discovery, so the # restart consults it before re-issuing the command. self._started = False self._monitor_handle: int | None = None self._connections: set[str] = set() self._background_tasks: set[asyncio.Task[Any]] = set() # Fired when the bond store changes so the owner can persist it; the # store itself is in memory (this scanner is a library object and does # no disk I/O). self._long_term_keys_changed: Callable[[], None] | None = None # -- lifecycle --------------------------------------------------------- def async_setup(self) -> CALLBACK_TYPE: """Set up the scanner and return the teardown callback.""" super().async_setup() return self._unsetup def _unsetup(self) -> None: """Tear down the expiry timer and the watchdog.""" super()._unsetup() self._started = False self._async_stop_scanner_watchdog() # Cancel any in-flight restart so it cannot re-issue discovery after # teardown; the done callback drops each task from the set. for task in self._background_tasks: task.cancel() async def async_start(self) -> None: """Start mgmt discovery for this adapter.""" async with self._start_stop_lock: await self._async_start() async def _async_start(self) -> None: """Issue the discovery/monitor command and arm the watchdog.""" idx = self.adapter_idx mgmt = get_manager().get_bluez_mgmt_ctl() if idx is None or mgmt is None or not mgmt.can_discover: msg = f"{self.name}: mgmt discovery is not available" raise ScannerStartError(msg) mode = _resolve_radio_mode(self.requested_mode or BluetoothScanningMode.PASSIVE) if mode is BluetoothScanningMode.ACTIVE: if not await mgmt.start_discovery(idx): msg = f"{self.name}: failed to start mgmt discovery" raise ScannerStartError(msg) else: handle = await mgmt.add_adv_pattern_monitor(idx) if handle is None: msg = f"{self.name}: failed to add advertisement monitor" raise ScannerStartError(msg) self._monitor_handle = handle # Use the setter so mode-change subscribers are notified, matching # HaScanner. self.set_current_mode(mode) self.scanning = True self._started = True self._async_setup_scanner_watchdog() self._on_start_success() async def async_stop(self) -> None: """Stop mgmt discovery and the watchdog.""" async with self._start_stop_lock: self._started = False self._async_stop_scanner_watchdog() await self._async_stop_discovery() self.scanning = False async def _async_stop_discovery(self) -> None: """Stop the active discovery or remove the passive monitor.""" idx = self.adapter_idx mgmt = get_manager().get_bluez_mgmt_ctl() if idx is None or mgmt is None: # The controller went away with discovery possibly still running in # the kernel; log so the orphaned state is observable. _LOGGER.debug( "%s: cannot stop discovery, mgmt controller unavailable", self.name ) return if self._monitor_handle is not None: if await mgmt.remove_adv_monitor(idx, self._monitor_handle): self._monitor_handle = None else: # Keep the handle so a later stop can retry rather than leak it. _LOGGER.warning( "%s: failed to remove advertisement monitor %s", self.name, self._monitor_handle, ) elif not await mgmt.stop_discovery(idx): _LOGGER.warning("%s: failed to stop mgmt discovery", self.name) def _async_scanner_watchdog(self) -> None: """Restart discovery if the adapter has gone quiet.""" if not self._async_watchdog_triggered(): return if self._start_stop_lock.locked(): return _LOGGER.debug( "%s: mgmt scanner quiet for %ss, restarting discovery", self.name, self.time_since_last_detection(), ) self.scanning = False self._create_background_task(self._async_restart()) async def _async_restart(self) -> None: """Stop and start discovery again under the start/stop lock.""" async with self._start_stop_lock: if not self._started: # A stop ran while this restart was queued on the lock; do not # resurrect discovery that was deliberately stopped. _LOGGER.debug( "%s: stopped while restart was pending, skipping", self.name ) return await self._async_stop_discovery() if self._monitor_handle is not None: # The passive monitor could not be removed; do not re-add one or # we would stack (and leak) a second kernel monitor over the # still-registered handle. The watchdog re-fires next tick to # retry the removal. _LOGGER.debug( "%s: monitor removal pending, deferring discovery restart", self.name, ) return try: await self._async_start() except ScannerStartError: _LOGGER.exception("%s: failed to restart mgmt discovery", self.name) def _create_background_task(self, coro: Coroutine[Any, Any, None]) -> None: """Run a coroutine, keeping a reference so it is not GC'd mid-flight.""" task = asyncio.create_task(coro) self._background_tasks.add(task) task.add_done_callback(self._on_background_task_done) def _on_background_task_done(self, task: asyncio.Task[None]) -> None: """Drop the task reference and surface any escaped exception in logs.""" self._background_tasks.discard(task) if not task.cancelled() and (exc := task.exception()) is not None: # Otherwise this only shows up as asyncio's "Task exception was # never retrieved" at GC time, which is easy to miss. _LOGGER.error("%s: background task failed", self.name, exc_info=exc) # -- connection slots -------------------------------------------------- def _can_connect(self) -> bool: """ Whether a new connection fits within the adapter's slot budget. This is an advisory gate, not a reservation: the slot is only recorded once the client connects, so two attempts that both pass here before either registers can overshoot the budget by one. The bleak slot path has the same shape; it bounds, it does not strictly serialize. """ if not self.connectable: return False slots = self._slot_limit() return slots == 0 or len(self._connections) < slots def _slot_limit(self) -> int: """Configured connection-slot count for this adapter (0 if unregistered).""" return get_manager().slot_manager.get_allocations(self.adapter).slots def _register_connection(self, address: str) -> None: """Record a live connection (called by the client on connect).""" self._connections.add(address) def _unregister_connection(self, address: str) -> None: """Drop a connection (called by the client on disconnect).""" self._connections.discard(address) def _store_long_term_key(self, key: LongTermKey) -> None: """Persist a bonded key (called by the client after pairing).""" # Canonicalize the address so forget (which may be called with a # differently-cased address) always matches. stored_key = (key.address.upper(), key.central, key.ediv) if self._long_term_keys.get(stored_key) == key: return # unchanged; nothing to persist, symmetric with forget self._long_term_keys[stored_key] = key self._notify_long_term_keys_changed() def _forget_long_term_keys(self, address: str) -> None: """Drop every bonded key for a peer (called by the client on unpair).""" canonical = address.upper() remaining = { stored_key: value for stored_key, value in self._long_term_keys.items() if value.address.upper() != canonical } if remaining != self._long_term_keys: self._long_term_keys = remaining self._notify_long_term_keys_changed() def _notify_long_term_keys_changed(self) -> None: """Tell the owner the bond store changed so it can be persisted.""" if self._long_term_keys_changed is None: return try: self._long_term_keys_changed() except Exception: # A faulty persistence hook must not break a pair/unpair flow. _LOGGER.exception("%s: long-term key change callback failed", self.name) def set_long_term_keys_changed_callback( self, callback: Callable[[], None] | None ) -> None: """Register a callback fired whenever the bond store changes.""" self._long_term_keys_changed = callback def export_long_term_keys(self) -> list[LongTermKey]: """Snapshot the bonded keys so the owner can persist them.""" return list(self._long_term_keys.values()) def restore_long_term_keys(self, keys: Iterable[LongTermKey]) -> None: """Seed the bond store from persistence (e.g. on startup).""" for key in keys: self._long_term_keys[(key.address.upper(), key.central, key.ediv)] = key def get_allocations(self) -> Allocations | None: """Report slot usage from this scanner's own connection tracking.""" slots = self._slot_limit() if not slots: return None return Allocations( self.adapter, slots, # Clamp at 0: a TOCTOU overshoot past the advisory gate must not # report negative free slots. max(0, slots - len(self._connections)), # Sorted for stable output (the set order is arbitrary), so repeated # reads do not look like allocation changes. sorted(self._connections), ) # -- discovered-device read-out --------------------------------------- # BaseHaScanner leaves these abstract; only remote scanners implement them. # They are served from _previous_service_info (populated by the inherited # ingestion path) so the manager's unavailable tracking, connect path, and # diagnostics work once this scanner is registered. @property def discovered_devices(self) -> list[BLEDevice]: """Return the devices seen so far.""" return [info.device for info in self._previous_service_info.values()] @property def discovered_devices_and_advertisement_data( self, ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: """Return each discovered device with its latest advertisement.""" return { address: (info.device, info.advertisement) for address, info in self._previous_service_info.items() } @property def discovered_addresses(self) -> Iterable[str]: """Return the addresses seen so far.""" return self._previous_service_info def get_discovered_device_advertisement_data( self, address: str ) -> tuple[BLEDevice, AdvertisementData] | None: """Return the device and advertisement for a discovered address.""" if (info := self._previous_service_info.get(address)) is not None: return info.device, info.advertisement return None def create_local_scanner( mode: BluetoothScanningMode, adapter: str, address: str ) -> BaseHaScanner: """ Build the best local scanner for an adapter. Returns :class:`HaScannerMgmt` when mgmt discovery is available (Linux with a usable management socket, an ``hciN`` adapter, and a real adapter BD_ADDR), otherwise the bleak-backed :class:`HaScanner`. A real ``address`` is required because the L2CAP connect path binds to it to pin connections to the adapter that discovered the device; a missing address would bind to ``BDADDR_ANY`` and let the kernel pick a different radio. AUTO mode falls back to :class:`HaScanner`: the mgmt scanner does not yet implement active-window promotion (``async_request_active_window``), so it cannot satisfy AUTO's on-demand switch to active scanning. It also falls back when raw L2CAP sockets cannot be opened (``can_use_l2cap``): mgmt discovery and L2CAP connect need different permissions, so a process that can discover but not open an L2CAP socket must use the bleak/DBus path to stay connectable. """ manager = get_manager() mgmt = manager.get_bluez_mgmt_ctl() if ( IS_LINUX and mgmt is not None and mgmt.can_discover and adapter.startswith("hci") and address != DEFAULT_ADDRESS and mode is not BluetoothScanningMode.AUTO and can_use_l2cap() ): return HaScannerMgmt(mode, adapter, address) return HaScanner(mode, adapter, address) Bluetooth-Devices-habluetooth-1390f03/src/habluetooth/storage.py000066400000000000000000000275301522222566700247650ustar00rootroot00000000000000"""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 from .channels.bluez import LongTermKey _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] class LongTermKeyDict(TypedDict): """A bonded long-term key serialized for storage.""" address: str address_type: int key_type: int central: bool encryption_size: int ediv: int rand: str # hex value: str # hex def long_term_key_to_dict(key: LongTermKey) -> LongTermKeyDict: """Serialize a long-term key for persistence (bytes as hex).""" return LongTermKeyDict( address=key.address, address_type=key.address_type, key_type=key.key_type, central=key.central, encryption_size=key.encryption_size, ediv=key.ediv, rand=key.rand.hex(), value=key.value.hex(), ) def long_term_key_from_dict(data: LongTermKeyDict) -> LongTermKey: """Deserialize a long-term key from persistence.""" return LongTermKey( address=data["address"], address_type=data["address_type"], key_type=data["key_type"], central=data["central"], encryption_size=data["encryption_size"], ediv=data["ediv"], rand=bytes.fromhex(data["rand"]), value=bytes.fromhex(data["value"]), ) Bluetooth-Devices-habluetooth-1390f03/src/habluetooth/usage.py000066400000000000000000000033761522222566700244270ustar00rootroot00000000000000"""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-1390f03/src/habluetooth/util.py000066400000000000000000000063121522222566700242710ustar00rootroot00000000000000"""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-1390f03/src/habluetooth/wrappers.py000066400000000000000000000611451522222566700251640ustar00rootroot00000000000000"""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-1390f03/templates/000077500000000000000000000000001522222566700216315ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-1390f03/templates/CHANGELOG.md.j2000066400000000000000000000012351522222566700237550ustar00rootroot00000000000000# 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-1390f03/tests/000077500000000000000000000000001522222566700207755ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-1390f03/tests/__init__.py000066400000000000000000000217241522222566700231140ustar00rootroot00000000000000import 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_bleak.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_bleak.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-1390f03/tests/channels/000077500000000000000000000000001522222566700225705ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-1390f03/tests/channels/__init__.py000066400000000000000000000000001522222566700246670ustar00rootroot00000000000000Bluetooth-Devices-habluetooth-1390f03/tests/channels/test_att.py000066400000000000000000000752211522222566700250000ustar00rootroot00000000000000"""Tests for the ATT/GATT codec.""" from __future__ import annotations import asyncio from typing import TYPE_CHECKING from unittest.mock import patch import pytest from bleak import BleakError from habluetooth.channels.att import ( ATT_ERR_ATTRIBUTE_NOT_FOUND, ATT_ERR_INSUFFICIENT_AUTHENTICATION, ATT_ERR_INSUFFICIENT_ENCRYPTION, ATT_ERR_INVALID_OFFSET, ATT_ERR_READ_NOT_PERMITTED, ATT_ERR_REQUEST_NOT_SUPPORTED, ATT_ERROR_RSP, ATT_EXCHANGE_MTU_RSP, ATT_FIND_INFO_REQ, ATT_FIND_INFO_RSP, ATT_HANDLE_VALUE_CFM, ATT_HANDLE_VALUE_IND, ATT_HANDLE_VALUE_NTF, ATT_READ_BLOB_RSP, ATT_READ_BY_GROUP_TYPE_REQ, ATT_READ_BY_GROUP_TYPE_RSP, ATT_READ_BY_TYPE_REQ, ATT_READ_BY_TYPE_RSP, ATT_READ_RSP, ATT_WRITE_CMD, ATT_WRITE_RSP, CCCD_UUID, DEFAULT_MTU, ATTClient, ATTError, properties_to_strings, uuid_from_bytes, ) if TYPE_CHECKING: from collections.abc import Awaitable, Callable pytestmark = pytest.mark.asyncio class FakePeer: """A fake ATT peer that captures sent PDUs and auto-replies.""" def __init__( self, responder: Callable[[bytes], bytes | None] | None = None, *, escalate_security: Callable[[int], bool] | None = None, ) -> None: """Create the bound ATTClient and optionally script replies.""" self.sent: list[bytes] = [] self.responder = responder self.client = ATTClient(self.send, escalate_security=escalate_security) async def send(self, data: bytes) -> None: """Capture an outbound PDU and feed back the scripted response.""" self.sent.append(bytes(data)) if self.responder is not None: response = self.responder(bytes(data)) if response is not None: self.client.data_received(response) def _att_error(req_opcode: int, handle: int, code: int) -> bytes: return ( bytes([ATT_ERROR_RSP, req_opcode]) + handle.to_bytes(2, "little") + bytes([code]) ) # -- pure helpers --------------------------------------------------------- async def test_uuid_from_bytes_16_bit() -> None: """A 16-bit UUID expands against the Bluetooth base UUID.""" assert uuid_from_bytes(b"\x02\x29") == CCCD_UUID assert uuid_from_bytes(b"\x0d\x18") == "0000180d-0000-1000-8000-00805f9b34fb" async def test_uuid_from_bytes_128_bit() -> None: """A 128-bit UUID is byte-reversed and formatted.""" raw = bytes.fromhex("fb349b5f8000008000100000661a0a05") assert uuid_from_bytes(raw) == "050a1a66-0000-1000-8000-00805f9b34fb" async def test_properties_to_strings() -> None: """The property bitmask expands to bleak property names.""" # 0x12 sets the read and notify bits assert properties_to_strings(0x12) == ["read", "notify"] assert properties_to_strings(0x00) == [] # -- security escalation -------------------------------------------------- async def test_request_escalates_and_retries_on_encryption_error() -> None: """An insufficient-encryption error raises security and re-sends once.""" attempts = 0 def responder(req: bytes) -> bytes: nonlocal attempts attempts += 1 if attempts == 1: return _att_error(req[0], 0x0005, ATT_ERR_INSUFFICIENT_ENCRYPTION) return bytes([ATT_READ_RSP]) + b"\x01\x02" seen: list[int] = [] def escalate(err: int) -> bool: seen.append(err) return True peer = FakePeer(responder, escalate_security=escalate) assert await peer.client.read(0x0005) == b"\x01\x02" assert seen == [ATT_ERR_INSUFFICIENT_ENCRYPTION] assert len(peer.sent) == 2 # original request plus one retry async def test_request_does_not_retry_when_escalation_declines() -> None: """If security cannot be raised, the error is surfaced without a retry.""" peer = FakePeer( lambda req: _att_error(req[0], 0x0001, ATT_ERR_INSUFFICIENT_AUTHENTICATION), escalate_security=lambda _err: False, ) with pytest.raises(ATTError) as exc_info: await peer.client.read(0x0001) assert exc_info.value.error_code == ATT_ERR_INSUFFICIENT_AUTHENTICATION assert len(peer.sent) == 1 # not retried async def test_request_escalates_at_most_once() -> None: """A persistent security error is surfaced after a single retry.""" seen: list[int] = [] def escalate(err: int) -> bool: seen.append(err) return True peer = FakePeer( lambda req: _att_error(req[0], 0x0001, ATT_ERR_INSUFFICIENT_ENCRYPTION), escalate_security=escalate, ) with pytest.raises(ATTError): await peer.client.read(0x0001) assert len(seen) == 1 # escalated once assert len(peer.sent) == 2 # original request plus one retry async def test_request_without_escalation_surfaces_security_error() -> None: """With no escalation hook, a security error propagates immediately.""" peer = FakePeer( lambda req: _att_error(req[0], 0x0001, ATT_ERR_INSUFFICIENT_ENCRYPTION) ) with pytest.raises(ATTError) as exc_info: await peer.client.read(0x0001) assert exc_info.value.error_code == ATT_ERR_INSUFFICIENT_ENCRYPTION assert len(peer.sent) == 1 # -- MTU ------------------------------------------------------------------ async def test_exchange_mtu_negotiates_minimum() -> None: """The negotiated MTU is the minimum of ours and the server's.""" peer = FakePeer( lambda _req: bytes([ATT_EXCHANGE_MTU_RSP]) + (185).to_bytes(2, "little") ) assert await peer.client.exchange_mtu(247) == 185 assert peer.client.mtu == 185 async def test_exchange_mtu_unsupported_keeps_default() -> None: """A peer that does not support MTU exchange falls back to the default.""" peer = FakePeer(lambda req: _att_error(req[0], 0, ATT_ERR_REQUEST_NOT_SUPPORTED)) assert await peer.client.exchange_mtu(247) == DEFAULT_MTU assert peer.client.mtu == DEFAULT_MTU async def test_exchange_mtu_propagates_other_errors() -> None: """An MTU rejection other than 'not supported' is surfaced, not swallowed.""" peer = FakePeer( lambda req: _att_error(req[0], 0, ATT_ERR_INSUFFICIENT_AUTHENTICATION) ) with pytest.raises(ATTError) as exc_info: await peer.client.exchange_mtu(247) assert exc_info.value.error_code == ATT_ERR_INSUFFICIENT_AUTHENTICATION # -- reads ---------------------------------------------------------------- async def test_read_short_value() -> None: """A short value is returned without a Read Blob follow-up.""" peer = FakePeer(lambda _req: bytes([ATT_READ_RSP]) + b"\x01\x02\x03") assert await peer.client.read(0x0010) == b"\x01\x02\x03" async def test_read_long_value_continues_with_blob() -> None: """A value is read across multiple full Read Blob chunks until a short one.""" # With the default MTU (23) a full chunk is mtu - 1 = 22 bytes; a chunk # shorter than that ends the read without an extra empty round-trip. first = bytes(range(22)) # full -> blob read blob_full = bytes(range(100, 122)) # full (22) -> continue blob_short = b"\xaa\xbb\xcc\xdd" # short (4) -> stop def responder(req: bytes) -> bytes: if req[0] == 0x0A: # ATT_READ_REQ return bytes([ATT_READ_RSP]) + first offset = int.from_bytes(req[3:5], "little") if offset == 22: return bytes([ATT_READ_BLOB_RSP]) + blob_full return bytes([ATT_READ_BLOB_RSP]) + blob_short peer = FakePeer(responder) assert await peer.client.read(0x0010) == first + blob_full + blob_short # READ + two READ_BLOB; the short final chunk avoids a trailing empty read. assert len(peer.sent) == 3 async def test_read_blob_invalid_offset_stops() -> None: """An invalid-offset error during a long read ends the read cleanly.""" full = bytes(range(22)) def responder(req: bytes) -> bytes: if req[0] == 0x0A: # ATT_READ_REQ return bytes([ATT_READ_RSP]) + full return _att_error(req[0], 0x10, ATT_ERR_INVALID_OFFSET) peer = FakePeer(responder) assert await peer.client.read(0x0010) == full # -- writes --------------------------------------------------------------- async def test_write_with_response() -> None: """Write Request waits for the Write Response.""" peer = FakePeer(lambda _req: bytes([ATT_WRITE_RSP])) await peer.client.write(0x0011, b"\x01\x00") assert peer.sent[0][0] == 0x12 # ATT_WRITE_REQ assert peer.sent[0][1:3] == (0x0011).to_bytes(2, "little") assert peer.sent[0][3:] == b"\x01\x00" async def test_write_command_has_no_response() -> None: """Write Command is fire-and-forget.""" peer = FakePeer() await peer.client.write_command(0x0011, b"\x09") assert peer.sent == [bytes([ATT_WRITE_CMD]) + b"\x11\x00\x09"] # -- error handling ------------------------------------------------------- async def test_error_response_raises_att_error() -> None: """An ATT error response raises ATTError with the details.""" peer = FakePeer(lambda req: _att_error(req[0], 0x0010, ATT_ERR_READ_NOT_PERMITTED)) with pytest.raises(ATTError) as exc_info: await peer.client.read(0x0010) assert exc_info.value.error_code == ATT_ERR_READ_NOT_PERMITTED assert exc_info.value.handle == 0x0010 async def test_unexpected_opcode_raises() -> None: """A response with the wrong opcode raises a BleakError.""" peer = FakePeer(lambda _req: bytes([ATT_WRITE_RSP])) # wrong for a read with pytest.raises(BleakError, match="unexpected ATT opcode"): await peer.client.read(0x0010) # -- notifications -------------------------------------------------------- async def test_notification_dispatched_to_handler() -> None: """A handle value notification reaches the registered handler.""" peer = FakePeer() received: list[bytearray] = [] peer.client.set_notify_handler(0x0003, received.append) peer.client.data_received( bytes([ATT_HANDLE_VALUE_NTF]) + (0x0003).to_bytes(2, "little") + b"\x10\x20" ) assert received == [bytearray(b"\x10\x20")] async def test_indication_dispatched_and_confirmed() -> None: """An indication reaches the handler and is acknowledged.""" peer = FakePeer() received: list[bytearray] = [] peer.client.set_notify_handler(0x0005, received.append) peer.client.data_received( bytes([ATT_HANDLE_VALUE_IND]) + (0x0005).to_bytes(2, "little") + b"\x99" ) await asyncio.sleep(0) # let the fire-and-forget confirm run assert received == [bytearray(b"\x99")] assert peer.sent == [bytes([ATT_HANDLE_VALUE_CFM])] async def test_remove_notify_handler() -> None: """A removed handler no longer receives notifications.""" peer = FakePeer() received: list[bytearray] = [] peer.client.set_notify_handler(0x0003, received.append) peer.client.remove_notify_handler(0x0003) peer.client.data_received( bytes([ATT_HANDLE_VALUE_NTF]) + (0x0003).to_bytes(2, "little") + b"\x10" ) assert received == [] async def test_data_received_empty_is_ignored() -> None: """An empty inbound PDU is ignored.""" peer = FakePeer() peer.client.data_received(b"") # must not raise # -- connection loss ------------------------------------------------------ async def test_connection_lost_fails_pending_and_notifies() -> None: """Connection loss fails the in-flight request and calls the callback.""" disconnects: list[Exception | None] = [] peer = FakePeer() # no responder: the request will hang awaiting a reply client = ATTClient(peer.send, on_disconnect=disconnects.append) task = asyncio.create_task(client.read(0x0010)) await asyncio.sleep(0) # let the request go out and await the response err = OSError("link lost") client.connection_lost(err) with pytest.raises(OSError, match="link lost"): await task assert disconnects == [err] # -- discovery primitives ------------------------------------------------- async def test_read_by_group_type_parses_entries() -> None: """Primary-service discovery parses (handle, end, uuid) entries.""" def responder(req: bytes) -> bytes: start = int.from_bytes(req[1:3], "little") if start <= 0x0001: return bytes([ATT_READ_BY_GROUP_TYPE_RSP, 6]) + b"\x01\x00\x05\x00\x0d\x18" return _att_error(req[0], start, ATT_ERR_ATTRIBUTE_NOT_FOUND) peer = FakePeer(responder) # End the search at 0x0005 so the loop terminates on the range, not an error. results = await peer.client.read_by_group_type(0x0001, 0x0005, b"\x00\x28") assert results == [(0x0001, 0x0005, b"\x0d\x18")] assert len(peer.sent) == 1 async def test_read_by_type_parses_entries() -> None: """Characteristic discovery parses (handle, value) entries.""" def responder(req: bytes) -> bytes: start = int.from_bytes(req[1:3], "little") if start <= 0x0002: return bytes([ATT_READ_BY_TYPE_RSP, 7]) + b"\x02\x00\x12\x03\x00\x37\x2a" return _att_error(req[0], start, ATT_ERR_ATTRIBUTE_NOT_FOUND) peer = FakePeer(responder) # End at the entry handle so the loop terminates on the range. results = await peer.client.read_by_type(0x0001, 0x0002, b"\x03\x28") assert results == [(0x0002, b"\x12\x03\x00\x37\x2a")] assert len(peer.sent) == 1 async def test_find_information_parses_16_bit() -> None: """Descriptor discovery parses 16-bit UUID entries.""" def responder(req: bytes) -> bytes: start = int.from_bytes(req[1:3], "little") if start <= 0x0004: # format 0x01 (16-bit): handle 0x0004 -> CCCD return bytes([ATT_FIND_INFO_RSP, 0x01]) + b"\x04\x00\x02\x29" return _att_error(req[0], start, ATT_ERR_ATTRIBUTE_NOT_FOUND) peer = FakePeer(responder) results = await peer.client.find_information(0x0004, 0x0005) assert results == [(0x0004, CCCD_UUID)] # -- full discovery ------------------------------------------------------- async def test_discover_builds_service_tree() -> None: """ discover() walks services, characteristics and descriptors. The service has two characteristics: the first owns a CCCD descriptor (so descriptors are fetched) and the second's value handle is the service's last handle (so it has no descriptor range to scan). """ def responder(req: bytes) -> bytes: opcode = req[0] start = int.from_bytes(req[1:3], "little") if opcode == ATT_READ_BY_GROUP_TYPE_REQ: if start <= 0x0001: # service 0x0001-0x0006, uuid 0x180D return ( bytes([ATT_READ_BY_GROUP_TYPE_RSP, 6]) + b"\x01\x00\x06\x00\x0d\x18" ) return _att_error(opcode, start, ATT_ERR_ATTRIBUTE_NOT_FOUND) if opcode == ATT_READ_BY_TYPE_REQ: if start <= 0x0002: # two characteristics in one response (each 7 bytes): # A: decl 0x0002, props 0x12, value 0x0003, uuid 0x2A37 # B: decl 0x0005, props 0x02, value 0x0006, uuid 0x2A38 char_a = b"\x02\x00\x12\x03\x00\x37\x2a" char_b = b"\x05\x00\x02\x06\x00\x38\x2a" return bytes([ATT_READ_BY_TYPE_RSP, 7]) + char_a + char_b return _att_error(opcode, start, ATT_ERR_ATTRIBUTE_NOT_FOUND) if opcode == ATT_FIND_INFO_REQ: # Only characteristic A (value 0x0003, end 0x0004) has a descriptor. if start <= 0x0004: return bytes([ATT_FIND_INFO_RSP, 0x01]) + b"\x04\x00\x02\x29" return _att_error(opcode, start, ATT_ERR_ATTRIBUTE_NOT_FOUND) msg = f"unexpected opcode {opcode:#x}" raise AssertionError(msg) peer = FakePeer(responder) services = await peer.client.discover() assert len(services) == 1 svc = services[0] assert svc.handle == 0x0001 assert svc.end_handle == 0x0006 assert svc.uuid == "0000180d-0000-1000-8000-00805f9b34fb" assert len(svc.characteristics) == 2 char_a, char_b = svc.characteristics assert char_a.handle == 0x0002 assert char_a.value_handle == 0x0003 assert char_a.uuid == "00002a37-0000-1000-8000-00805f9b34fb" assert char_a.properties == 0x12 assert char_a.end_handle == 0x0004 # one before the next declaration assert properties_to_strings(char_a.properties) == ["read", "notify"] assert len(char_a.descriptors) == 1 assert char_a.descriptors[0].handle == 0x0004 assert char_a.descriptors[0].uuid == CCCD_UUID # The last characteristic's value handle is the service end, so no # descriptor range remains to scan. assert char_b.handle == 0x0005 assert char_b.value_handle == 0x0006 assert char_b.uuid == "00002a38-0000-1000-8000-00805f9b34fb" assert char_b.end_handle == 0x0006 assert char_b.descriptors == [] async def test_read_blob_propagates_other_errors() -> None: """A non-offset error during a long read propagates.""" full = bytes(range(22)) def responder(req: bytes) -> bytes: if req[0] == 0x0A: # ATT_READ_REQ return bytes([ATT_READ_RSP]) + full return _att_error(req[0], 0x10, ATT_ERR_READ_NOT_PERMITTED) peer = FakePeer(responder) with pytest.raises(ATTError): await peer.client.read(0x0010) @pytest.mark.parametrize( "call", [ lambda c: c.read_by_group_type(0x0001, 0xFFFF, b"\x00\x28"), lambda c: c.read_by_type(0x0001, 0xFFFF, b"\x03\x28"), lambda c: c.find_information(0x0001, 0xFFFF), ], ) async def test_discovery_propagates_non_not_found_errors( call: Callable[[ATTClient], Awaitable[object]], ) -> None: """A non-ATTR_NOT_FOUND error aborts discovery instead of ending it.""" peer = FakePeer(lambda req: _att_error(req[0], 0x0001, ATT_ERR_READ_NOT_PERMITTED)) with pytest.raises(ATTError): await call(peer.client) async def test_read_by_group_type_stops_at_max_handle() -> None: """An end handle of 0xFFFF ends the loop without another request.""" peer = FakePeer( lambda _req: ( bytes([ATT_READ_BY_GROUP_TYPE_RSP, 6]) + b"\x01\x00\xff\xff\x0d\x18" ) ) results = await peer.client.read_by_group_type(0x0001, 0xFFFF, b"\x00\x28") assert results == [(0x0001, 0xFFFF, b"\x0d\x18")] assert len(peer.sent) == 1 async def test_read_by_type_stops_at_max_handle() -> None: """A handle of 0xFFFF ends the loop without another request.""" peer = FakePeer( lambda _req: bytes([ATT_READ_BY_TYPE_RSP, 7]) + b"\xff\xff\x12\x03\x00\x37\x2a" ) results = await peer.client.read_by_type(0x0001, 0xFFFF, b"\x03\x28") assert results[0][0] == 0xFFFF assert len(peer.sent) == 1 async def test_find_information_128_bit_and_max_handle() -> None: """find_information parses 128-bit UUIDs and stops at the max handle.""" uuid128 = bytes.fromhex("fb349b5f8000008000100000661a0a05") peer = FakePeer( lambda _req: bytes([ATT_FIND_INFO_RSP, 0x02]) + b"\xff\xff" + uuid128 ) results = await peer.client.find_information(0x0001, 0xFFFF) assert results == [(0xFFFF, "050a1a66-0000-1000-8000-00805f9b34fb")] assert len(peer.sent) == 1 async def test_no_pending_request_paths() -> None: """A stray response and a bare connection loss are handled safely.""" peer = FakePeer() # A response with no in-flight request is ignored. peer.client.data_received(bytes([ATT_WRITE_RSP])) # Connection loss with no pending request and no callback does not raise. peer.client.connection_lost(None) # -- wire robustness on malformed input ----------------------------------- async def test_uuid_from_bytes_rejects_invalid_length() -> None: """A UUID that is neither 2 nor 16 bytes is rejected, not mis-formatted.""" with pytest.raises(BleakError, match="invalid ATT UUID length"): uuid_from_bytes(b"\x01\x02\x03\x04") async def test_read_by_group_type_rejects_malformed_length() -> None: """A zero/short length byte raises instead of crashing range().""" peer = FakePeer(lambda _req: bytes([ATT_READ_BY_GROUP_TYPE_RSP, 0])) with pytest.raises(BleakError, match="malformed read-by-group-type"): await peer.client.read_by_group_type(0x0001, 0xFFFF, b"\x00\x28") async def test_read_by_type_rejects_malformed_length() -> None: """A zero/short length byte raises instead of crashing range().""" peer = FakePeer(lambda _req: bytes([ATT_READ_BY_TYPE_RSP, 0])) with pytest.raises(BleakError, match="malformed read-by-type"): await peer.client.read_by_type(0x0001, 0xFFFF, b"\x03\x28") async def test_find_information_rejects_invalid_format() -> None: """A format byte other than 0x01/0x02 raises rather than mis-parsing.""" peer = FakePeer(lambda _req: bytes([ATT_FIND_INFO_RSP, 0x03]) + b"\x04\x00") with pytest.raises(BleakError, match="invalid find-information format"): await peer.client.find_information(0x0004, 0xFFFF) async def test_indication_confirm_failure_is_logged( caplog: pytest.LogCaptureFixture, ) -> None: """A failed indication confirmation is logged, not raised as a task error.""" async def send(data: bytes) -> None: if data == bytes([ATT_HANDLE_VALUE_CFM]): msg = "send failed" raise OSError(msg) client = ATTClient(send) client.set_notify_handler(0x0005, lambda _v: None) with caplog.at_level("DEBUG", logger="habluetooth.channels.att"): client.data_received( bytes([ATT_HANDLE_VALUE_IND]) + (0x0005).to_bytes(2, "little") + b"\x99" ) # Let the confirm task run and its done-callback fire. for _ in range(3): await asyncio.sleep(0) assert "Failed to confirm ATT indication" in caplog.text async def test_request_rejects_truncated_error_response() -> None: """A truncated ATT error response raises BleakError, not struct.error.""" # ATT_ERROR_RSP needs 5 bytes; return only 2. peer = FakePeer(lambda _req: bytes([ATT_ERROR_RSP, 0x0A])) with pytest.raises(BleakError, match="truncated ATT error response"): await peer.client.read(0x0010) async def test_discover_rejects_malformed_characteristic_declaration() -> None: """A characteristic declaration shorter than 5 bytes raises BleakError.""" def responder(req: bytes) -> bytes: opcode = req[0] start = int.from_bytes(req[1:3], "little") if opcode == ATT_READ_BY_GROUP_TYPE_REQ: if start <= 0x0001: return ( bytes([ATT_READ_BY_GROUP_TYPE_RSP, 6]) + b"\x01\x00\x05\x00\x0d\x18" ) return _att_error(opcode, start, ATT_ERR_ATTRIBUTE_NOT_FOUND) if opcode == ATT_READ_BY_TYPE_REQ: # length 2 survives read_by_type's guard but leaves an empty value. return bytes([ATT_READ_BY_TYPE_RSP, 2]) + b"\x02\x00" msg = f"unexpected opcode {opcode:#x}" raise AssertionError(msg) peer = FakePeer(responder) with pytest.raises(BleakError, match="malformed characteristic declaration"): await peer.client.discover() async def test_request_timeout_raises_bleak_error() -> None: """A request with no reply times out as a BleakError, not TimeoutError.""" peer = FakePeer() # no responder: the future never resolves with ( patch("habluetooth.channels.att.ATT_TIMEOUT", 0.01), pytest.raises(BleakError, match="ATT request timed out"), ): await peer.client.read(0x0010) async def test_notification_handler_exception_is_isolated( caplog: pytest.LogCaptureFixture, ) -> None: """A raising notification handler is logged, not propagated to data_received.""" def bad_handler(_value: bytearray) -> None: msg = "boom" raise ValueError(msg) peer = FakePeer() peer.client.set_notify_handler(0x0003, bad_handler) with caplog.at_level("ERROR", logger="habluetooth.channels.att"): # Must not raise out of data_received. peer.client.data_received( bytes([ATT_HANDLE_VALUE_NTF]) + (0x0003).to_bytes(2, "little") + b"\x10" ) assert "Error in notification handler" in caplog.text async def test_indication_handler_exception_still_confirms() -> None: """A raising indication handler does not block the confirmation.""" def bad_handler(_value: bytearray) -> None: msg = "boom" raise ValueError(msg) peer = FakePeer() peer.client.set_notify_handler(0x0005, bad_handler) peer.client.data_received( bytes([ATT_HANDLE_VALUE_IND]) + (0x0005).to_bytes(2, "little") + b"\x99" ) await asyncio.sleep(0) assert peer.sent == [bytes([ATT_HANDLE_VALUE_CFM])] @pytest.mark.parametrize( ("call", "empty_response"), [ ( lambda c: c.read_by_group_type(0x0001, 0xFFFF, b"\x00\x28"), bytes([ATT_READ_BY_GROUP_TYPE_RSP, 6]), # valid stride, no entries ), ( lambda c: c.read_by_type(0x0001, 0xFFFF, b"\x03\x28"), bytes([ATT_READ_BY_TYPE_RSP, 7]), ), ( lambda c: c.find_information(0x0001, 0xFFFF), bytes([ATT_FIND_INFO_RSP, 0x01]), ), ], ) async def test_discovery_rejects_entryless_response( call: Callable[[ATTClient], Awaitable[list[object]]], empty_response: bytes, ) -> None: """An entry-less response is rejected, not treated as a silent partial stop.""" peer = FakePeer(lambda _req: empty_response) with pytest.raises(BleakError, match="truncated entries"): await call(peer.client) # Exactly one request; the loop neither re-requests nor returns partial data. assert len(peer.sent) == 1 @pytest.mark.parametrize( ("call", "partial_response"), [ ( lambda c: c.read_by_group_type(0x0001, 0xFFFF, b"\x00\x28"), # stride 6: one full entry plus a 2 byte trailing partial. bytes([ATT_READ_BY_GROUP_TYPE_RSP, 6]) + b"\x01\x00\x05\x00\x0f\x18" + b"\x06\x00", ), ( lambda c: c.read_by_type(0x0001, 0xFFFF, b"\x03\x28"), # stride 7: one full entry plus a 3 byte trailing partial. bytes([ATT_READ_BY_TYPE_RSP, 7]) + b"\x02\x00\x02\x03\x00\x0f\x18" + b"\x04\x00\x02", ), ( lambda c: c.find_information(0x0001, 0xFFFF), # entry size 4: one full entry plus a 2 byte trailing partial. bytes([ATT_FIND_INFO_RSP, 0x01]) + b"\x04\x00\x02\x29" + b"\x05\x00", ), ], ) async def test_discovery_rejects_trailing_partial_entry( call: Callable[[ATTClient], Awaitable[list[object]]], partial_response: bytes, ) -> None: """A body that is not a whole multiple of the stride is rejected.""" peer = FakePeer(lambda _req: partial_response) with pytest.raises(BleakError, match="truncated entries"): await call(peer.client) assert len(peer.sent) == 1 async def test_exchange_mtu_rejects_truncated_response() -> None: """A truncated MTU response raises rather than negotiating off a stray byte.""" peer = FakePeer(lambda _req: bytes([ATT_EXCHANGE_MTU_RSP, 0x10])) # 2 bytes with pytest.raises(BleakError, match="truncated ATT MTU response"): await peer.client.exchange_mtu(247) async def test_timeout_poisons_channel() -> None: """A request timeout closes the channel so a late response can't desync it.""" disconnects: list[Exception | None] = [] peer = FakePeer() # no responder: the request times out client = ATTClient(peer.send, on_disconnect=disconnects.append) with ( patch("habluetooth.channels.att.ATT_TIMEOUT", 0.01), pytest.raises(BleakError, match="timed out"), ): await client.read(0x0010) assert len(disconnects) == 1 # The channel is poisoned: requests and write commands fail fast without # sending, so a write command does not hand a PDU to a dead socket. sent_before = len(peer.sent) with pytest.raises(BleakError, match="ATT channel closed"): await client.read(0x0011) with pytest.raises(BleakError, match="ATT channel closed"): await client.write_command(0x0011, b"\x01") assert len(peer.sent) == sent_before # A subsequent connection_lost does not notify a second time. client.connection_lost(None) assert len(disconnects) == 1 async def test_poisoned_channel_drops_inbound_pdus() -> None: """A poisoned channel drops late notifications and skips indication confirms.""" received: list[bytearray] = [] peer = FakePeer() # no responder: the request times out peer.client.set_notify_handler(0x0005, received.append) with ( patch("habluetooth.channels.att.ATT_TIMEOUT", 0.01), pytest.raises(BleakError, match="timed out"), ): await peer.client.read(0x0010) sent_before = len(peer.sent) # A late notification is not delivered to the subscriber. peer.client.data_received(bytes([ATT_HANDLE_VALUE_NTF, 0x05, 0x00, 0xAB])) assert received == [] # A late indication is dropped without scheduling a confirmation send. peer.client.data_received(bytes([ATT_HANDLE_VALUE_IND, 0x05, 0x00, 0xAB])) await asyncio.sleep(0) assert received == [] assert len(peer.sent) == sent_before async def test_write_rejects_oversized_value() -> None: """A write larger than mtu - 3 is rejected (no long-write support).""" peer = FakePeer() with pytest.raises(BleakError, match="value too long"): await peer.client.write(0x0011, bytes(21)) # default mtu 23 -> max 20 assert peer.sent == [] async def test_write_command_rejects_oversized_value() -> None: """A write command larger than mtu - 3 is rejected.""" peer = FakePeer() with pytest.raises(BleakError, match="value too long"): await peer.client.write_command(0x0011, bytes(21)) assert peer.sent == [] async def test_read_caps_value_at_offset_range() -> None: """An always-full long read is capped instead of overflowing the offset.""" full = bytes(range(22)) # a full chunk (mtu - 1) every time, never terminating def responder(req: bytes) -> bytes: if req[0] == 0x0A: # ATT_READ_REQ return bytes([ATT_READ_RSP]) + full return bytes([ATT_READ_BLOB_RSP]) + full peer = FakePeer(responder) with pytest.raises(BleakError, match="exceeds the 16-bit"): await peer.client.read(0x0010) async def test_truncated_notification_is_dropped() -> None: """A notification PDU too short to carry a handle is dropped, not fabricated.""" peer = FakePeer() received: list[bytearray] = [] peer.client.set_notify_handler(0x0005, received.append) # opcode + a single handle byte: too short to carry a 2-byte handle. peer.client.data_received(bytes([ATT_HANDLE_VALUE_NTF, 0x05])) assert received == [] async def test_truncated_indication_is_dropped() -> None: """A truncated indication is dropped and not confirmed.""" peer = FakePeer() peer.client.data_received(bytes([ATT_HANDLE_VALUE_IND, 0x05])) await asyncio.sleep(0) assert peer.sent == [] # no CFM sent for a malformed PDU Bluetooth-Devices-habluetooth-1390f03/tests/channels/test_bluez.py000066400000000000000000002514411522222566700253310ustar00rootroot00000000000000"""Tests for the BlueZ management API module.""" from __future__ import annotations import asyncio import logging from typing import TYPE_CHECKING, Any from unittest.mock import ANY, Mock, patch import pytest from btsocket.btmgmt_socket import BluetoothSocketError from habluetooth.channels.bluez import ( IO_CAPABILITY_NO_INPUT_NO_OUTPUT, MGMT_OP_ADD_ADV_PATTERNS_MONITOR, MGMT_OP_DISCONNECT, MGMT_OP_LOAD_LONG_TERM_KEYS, MGMT_OP_PAIR_DEVICE, MGMT_OP_REMOVE_ADV_MONITOR, MGMT_OP_START_DISCOVERY, MGMT_OP_STOP_DISCOVERY, MGMT_OP_UNPAIR_DEVICE, MGMT_OP_USER_CONFIRM_NEG_REPLY, MGMT_OP_USER_CONFIRM_REPLY, MGMT_OP_USER_PASSKEY_NEG_REPLY, MGMT_OP_USER_PASSKEY_REPLY, SCAN_TYPE_LE, AuthenticationFailed, BluetoothMGMTProtocol, LongTermKey, MGMTBluetoothCtl, NewLongTermKey, UserConfirmationRequest, UserPasskeyRequest, bytes_mac_to_str, make_bluez_details, ) 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_bleak import HaScanner if TYPE_CHECKING: from collections.abc import Callable from habluetooth.base_scanner import BaseHaScanner 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.adapter = "hci0" self.connectable = True # Mock the method that will be called self._async_on_raw_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, BaseHaScanner] = {} 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, BaseHaScanner] = {} 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, BaseHaScanner] = {} 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, BaseHaScanner] = {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) expected_address = bytes_mac_to_str(b"\xaa\xbb\xcc\xdd\xee\xff") mock_scanner._async_on_raw_advertisement.assert_called_once_with( expected_address, -56, ad_data, make_bluez_details(expected_address, "hci0"), ANY, ) 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, BaseHaScanner] = {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) expected_address = bytes_mac_to_str(b"\xaa\xbb\xcc\xdd\xee\xff") mock_scanner._async_on_raw_advertisement.assert_called_once_with( expected_address, 100, ad_data, make_bluez_details(expected_address, "hci0"), ANY, ) 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, BaseHaScanner] = {} 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, BaseHaScanner] = {} 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, BaseHaScanner] = {} 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, BaseHaScanner] = {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_advertisement.assert_not_called() # Send rest of data protocol.data_received(full_data[6:]) mock_scanner._async_on_raw_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, BaseHaScanner] = {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_advertisement.assert_not_called() # Send rest of data protocol.data_received(full_data[10:]) expected_address = bytes_mac_to_str(b"\xaa\xbb\xcc\xdd\xee\xff") mock_scanner._async_on_raw_advertisement.assert_called_once_with( expected_address, -56, ad_data, make_bluez_details(expected_address, "hci0"), ANY, ) 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, BaseHaScanner] = {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_advertisement.assert_not_called() # After all bytes are sent, callback should be called once mock_scanner._async_on_raw_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, BaseHaScanner] = {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_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, BaseHaScanner] = {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_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_advertisement.call_count == 2 calls = mock_scanner._async_on_raw_advertisement.call_args_list # First call first_address = bytes_mac_to_str(b"\x11\x22\x33\x44\x55\x66") assert calls[0][0] == ( first_address, -56, ad_data1, make_bluez_details(first_address, "hci0"), ANY, ) # Second call second_address = bytes_mac_to_str(b"\x77\x88\x99\xaa\xbb\xcc") assert calls[1][0] == ( second_address, 100, ad_data2, make_bluez_details(second_address, "hci0"), ANY, ) 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, BaseHaScanner] = {} 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, BaseHaScanner] = {} 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, BaseHaScanner] = {} 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, BaseHaScanner] = {} 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, BaseHaScanner] = {} 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, BaseHaScanner] = {} 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, BaseHaScanner] = {} # 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, BaseHaScanner] = {} 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, BaseHaScanner] = {} 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, BaseHaScanner] = {} 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, 0) 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, BaseHaScanner] = {} 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, 0) 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, BaseHaScanner] = {} 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, 0) 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, BaseHaScanner] = {} 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, 0) 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, controller_idx: 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, controller_idx: 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, controller_idx: 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, controller_idx: 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, controller_idx: 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) def _stub_command_response( status: int, data: bytes = b"" ) -> Callable[[int, int], object]: """Build a command_response stub that resolves with (status, data).""" def _command_response(opcode: int, controller_idx: int) -> object: future = asyncio.get_running_loop().create_future() future.set_result((status, data)) class MockContext: async def __aenter__(self) -> asyncio.Future[tuple[int, bytes]]: return future async def __aexit__(self, *args: object) -> None: pass return MockContext() return _command_response def _mgmt_ctl_with_mock_protocol() -> tuple[MGMTBluetoothCtl, Mock]: """Return a MGMTBluetoothCtl wired to a mock protocol/transport.""" mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={}) mock_protocol = Mock(spec=BluetoothMGMTProtocol) mock_protocol.transport = Mock() mock_protocol._write_to_socket = Mock() mgmt_ctl.protocol = mock_protocol return mgmt_ctl, mock_protocol @pytest.mark.asyncio @pytest.mark.parametrize("status", [0x00, 0x0A]) async def test_start_discovery_success(status: int) -> None: """start_discovery returns True on success or busy and sends the LE bitmask.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(status) assert await mgmt_ctl.start_discovery(0) is True sent = mock_protocol._write_to_socket.call_args[0][0] # header opcode (2) + controller_idx (2) + param_len (2) + address-type byte assert sent[0:2] == MGMT_OP_START_DISCOVERY.to_bytes(2, "little") assert sent[2:4] == b"\x00\x00" assert sent[6] == SCAN_TYPE_LE @pytest.mark.asyncio async def test_start_discovery_failure() -> None: """start_discovery returns False on an error status.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x0C) assert await mgmt_ctl.start_discovery(0) is False @pytest.mark.asyncio async def test_stop_discovery_success() -> None: """stop_discovery returns True and targets the requested controller.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x00) assert await mgmt_ctl.stop_discovery(2) is True sent = mock_protocol._write_to_socket.call_args[0][0] assert sent[0:2] == MGMT_OP_STOP_DISCOVERY.to_bytes(2, "little") assert sent[2:4] == (2).to_bytes(2, "little") @pytest.mark.asyncio async def test_stop_discovery_busy_is_not_success() -> None: """BUSY on stop means discovery did not stop, so it must report False.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x0A) assert await mgmt_ctl.stop_discovery(0) is False @pytest.mark.asyncio async def test_discovery_command_timeout() -> None: """A command whose response never arrives times out and reports failure.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mgmt_ctl.timeout = 0.01 def _never_resolving(opcode: int, controller_idx: int) -> object: future = asyncio.get_running_loop().create_future() # never resolved 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 = _never_resolving assert await mgmt_ctl.start_discovery(0) is False @pytest.mark.asyncio async def test_discovery_no_connection() -> None: """Discovery and monitor commands fail gracefully when the socket is down.""" mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={}) assert mgmt_ctl.protocol is None assert await mgmt_ctl.start_discovery(0) is False assert await mgmt_ctl.stop_discovery(0) is False assert await mgmt_ctl.add_adv_pattern_monitor(0) is None assert await mgmt_ctl.remove_adv_monitor(0, 7) is False @pytest.mark.asyncio @pytest.mark.parametrize("exc_type", [OSError, BluetoothSocketError]) async def test_discovery_command_response_error(exc_type: type[Exception]) -> None: """ A socket error while sending a command is handled, not raised. BluetoothSocketError is not an OSError subclass, so it must be caught explicitly; _write_to_socket can raise either one. """ mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() def _raising_command_response(opcode: int, controller_idx: int) -> object: class MockContext: async def __aenter__(self) -> asyncio.Future[tuple[int, bytes]]: msg = "socket gone" raise exc_type(msg) async def __aexit__(self, *args: object) -> None: pass return MockContext() mock_protocol.command_response = _raising_command_response assert await mgmt_ctl.start_discovery(0) is False assert await mgmt_ctl.add_adv_pattern_monitor(0) is None assert await mgmt_ctl.remove_adv_monitor(0, 7) is False @pytest.mark.asyncio async def test_command_response_skips_already_resolved_future() -> None: """A command-complete for an already-resolved future does not re-set it.""" future: asyncio.Future[None] = asyncio.get_running_loop().create_future() future.set_result(None) protocol = BluetoothMGMTProtocol( future, {}, Mock(), Mock(return_value=False), Mock(), {} ) async with protocol.command_response(MGMT_OP_START_DISCOVERY, 0) as fut: # Resolve early, then deliver a late response for the same command. fut.set_result((0x00, b"")) protocol.data_received( b"\x01\x00" # MGMT_EV_CMD_COMPLETE b"\x00\x00" # controller index 0 b"\x03\x00" # param_len + MGMT_OP_START_DISCOVERY.to_bytes(2, "little") + b"\x05" # different status, must be ignored ) assert fut.result() == (0x00, b"") @pytest.mark.asyncio async def test_command_response_for_unregistered_command_is_ignored() -> None: """A command-complete with no waiter is dropped without breaking later ones.""" future: asyncio.Future[None] = asyncio.get_running_loop().create_future() future.set_result(None) protocol = BluetoothMGMTProtocol( future, {}, Mock(), Mock(return_value=False), Mock(), {} ) # No command_response registered for this opcode/controller: must not raise. protocol.data_received( b"\x01\x00" # MGMT_EV_CMD_COMPLETE b"\x00\x00" # controller index 0 b"\x03\x00" # param_len + MGMT_OP_START_DISCOVERY.to_bytes(2, "little") + b"\x00" # status success ) # A subsequently registered command still resolves normally. async with protocol.command_response(MGMT_OP_STOP_DISCOVERY, 0) as fut: protocol.data_received( b"\x01\x00" b"\x00\x00" b"\x03\x00" + MGMT_OP_STOP_DISCOVERY.to_bytes(2, "little") + b"\x00" ) status, _ = await fut assert status == 0x00 @pytest.mark.asyncio async def test_add_adv_pattern_monitor_success() -> None: """add_adv_pattern_monitor returns the handle and sends a match-all monitor.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() # Command complete returns the assigned monitor handle (uint16 little-endian). mock_protocol.command_response = _stub_command_response(0x00, b"\x07\x00") assert await mgmt_ctl.add_adv_pattern_monitor(0) == 7 sent = mock_protocol._write_to_socket.call_args[0][0] assert sent[0:2] == MGMT_OP_ADD_ADV_PATTERNS_MONITOR.to_bytes(2, "little") # param_len 1, pattern_count 0 (match all) assert sent[4:6] == b"\x01\x00" assert sent[6] == 0x00 @pytest.mark.asyncio async def test_add_adv_pattern_monitor_failure() -> None: """add_adv_pattern_monitor returns None on an error status.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x0C, b"") assert await mgmt_ctl.add_adv_pattern_monitor(0) is None @pytest.mark.asyncio async def test_add_adv_pattern_monitor_success_without_handle() -> None: """A success response missing the 2-byte handle is reported as failure.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x00, b"") assert await mgmt_ctl.add_adv_pattern_monitor(0) is None @pytest.mark.asyncio async def test_remove_adv_monitor() -> None: """remove_adv_monitor returns True on success and sends the handle.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x00) assert await mgmt_ctl.remove_adv_monitor(0, 7) is True sent = mock_protocol._write_to_socket.call_args[0][0] assert sent[0:2] == MGMT_OP_REMOVE_ADV_MONITOR.to_bytes(2, "little") assert sent[6:8] == (7).to_bytes(2, "little") @pytest.mark.asyncio async def test_remove_adv_monitor_failure() -> None: """remove_adv_monitor returns False on an error status.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x0C) assert await mgmt_ctl.remove_adv_monitor(0, 7) is False @pytest.mark.asyncio async def test_per_controller_command_response_isolation() -> None: """Concurrent same-opcode commands on two controllers do not collide.""" future: asyncio.Future[None] = asyncio.get_running_loop().create_future() future.set_result(None) protocol = BluetoothMGMTProtocol( future, {}, Mock(), Mock(return_value=False), Mock(), {} ) async with ( protocol.command_response(MGMT_OP_START_DISCOVERY, 0) as fut0, protocol.command_response(MGMT_OP_START_DISCOVERY, 1) as fut1, ): # Command complete for controller 1 only. protocol.data_received( b"\x01\x00" # MGMT_EV_CMD_COMPLETE b"\x01\x00" # controller index 1 b"\x03\x00" # param_len + MGMT_OP_START_DISCOVERY.to_bytes(2, "little") + b"\x00" # status success ) assert fut1.done() assert not fut0.done() status, _ = await fut1 assert status == 0x00 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"}, } # -- pairing / disconnect commands ---------------------------------------- @pytest.mark.asyncio async def test_pair_device_success() -> None: """pair_device sends bdaddr + type + IO capability and returns True.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x00) assert await mgmt_ctl.pair_device(0, "AA:BB:CC:DD:EE:FF", BDADDR_LE_RANDOM) is True sent = mock_protocol._write_to_socket.call_args[0][0] assert sent[0:2] == MGMT_OP_PAIR_DEVICE.to_bytes(2, "little") assert sent[2:4] == b"\x00\x00" # controller idx assert sent[6:12] == bytes([0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA]) # reversed assert sent[12] == BDADDR_LE_RANDOM assert sent[13] == IO_CAPABILITY_NO_INPUT_NO_OUTPUT @pytest.mark.asyncio async def test_pair_device_failure() -> None: """pair_device returns False on a non-success status.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x03) assert await mgmt_ctl.pair_device(0, "AA:BB:CC:DD:EE:FF", BDADDR_LE_PUBLIC) is False @pytest.mark.asyncio async def test_unpair_device_success() -> None: """unpair_device sends bdaddr + type + disconnect flag and returns True.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x00) assert ( await mgmt_ctl.unpair_device(0, "AA:BB:CC:DD:EE:FF", BDADDR_LE_PUBLIC) is True ) sent = mock_protocol._write_to_socket.call_args[0][0] assert sent[0:2] == MGMT_OP_UNPAIR_DEVICE.to_bytes(2, "little") assert sent[6:12] == bytes([0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA]) assert sent[12] == BDADDR_LE_PUBLIC assert sent[13] == 1 # disconnect default @pytest.mark.asyncio async def test_unpair_device_without_disconnect() -> None: """unpair_device(disconnect=False) clears the disconnect flag.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x00) assert ( await mgmt_ctl.unpair_device( 0, "AA:BB:CC:DD:EE:FF", BDADDR_LE_PUBLIC, disconnect=False ) is True ) assert mock_protocol._write_to_socket.call_args[0][0][13] == 0 @pytest.mark.asyncio async def test_disconnect_device_success() -> None: """disconnect_device sends bdaddr + type and returns True.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x00) assert ( await mgmt_ctl.disconnect_device(0, "AA:BB:CC:DD:EE:FF", BDADDR_LE_RANDOM) is True ) sent = mock_protocol._write_to_socket.call_args[0][0] assert sent[0:2] == MGMT_OP_DISCONNECT.to_bytes(2, "little") assert sent[6:12] == bytes([0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA]) assert sent[12] == BDADDR_LE_RANDOM @pytest.mark.asyncio async def test_disconnect_device_no_response() -> None: """A command with no response (timeout) returns False.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.transport = None # no connection -> _send_command_await None assert ( await mgmt_ctl.disconnect_device(0, "AA:BB:CC:DD:EE:FF", BDADDR_LE_PUBLIC) is False ) @pytest.mark.asyncio async def test_unpair_device_failure() -> None: """unpair_device returns False on a non-success status.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x03) assert ( await mgmt_ctl.unpair_device(0, "AA:BB:CC:DD:EE:FF", BDADDR_LE_PUBLIC) is False ) @pytest.mark.asyncio @pytest.mark.parametrize( "command", ["pair_device", "unpair_device", "disconnect_device"], ) @pytest.mark.parametrize( "bad_address", ["AA:BB", "ZZ:BB:CC:DD:EE:FF"], # too short, and non-hex ) async def test_pairing_commands_reject_invalid_address( command: str, bad_address: str ) -> None: """A malformed address is a soft failure, not an exception.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x00) assert await getattr(mgmt_ctl, command)(0, bad_address, BDADDR_LE_PUBLIC) is False mock_protocol._write_to_socket.assert_not_called() # -- load long-term keys -------------------------------------------------- def _ltk(address: str = "AA:BB:CC:DD:EE:FF") -> LongTermKey: return LongTermKey( address=address, address_type=BDADDR_LE_RANDOM, key_type=1, central=False, encryption_size=16, ediv=0x1234, rand=bytes(range(8)), value=bytes(range(16)), ) @pytest.mark.asyncio async def test_load_long_term_keys_success() -> None: """load_long_term_keys packs the count and a 36 byte record per key.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x00) assert await mgmt_ctl.load_long_term_keys(0, [_ltk()]) is True sent = mock_protocol._write_to_socket.call_args[0][0] assert sent[0:2] == MGMT_OP_LOAD_LONG_TERM_KEYS.to_bytes(2, "little") assert sent[6:8] == (1).to_bytes(2, "little") # key_count record = sent[8:] assert len(record) == 36 assert record[0:6] == bytes([0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA]) # reversed assert record[6] == BDADDR_LE_RANDOM assert record[7] == 1 # key_type assert record[8] == 0 # central flag (False) assert record[9] == 16 # encryption_size assert record[10:12] == (0x1234).to_bytes(2, "little") # ediv assert record[12:20] == bytes(range(8)) # rand assert record[20:36] == bytes(range(16)) # value @pytest.mark.asyncio async def test_load_long_term_keys_empty_clears() -> None: """An empty key list sends a zero count (clears the controller's keys).""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x00) assert await mgmt_ctl.load_long_term_keys(0, []) is True sent = mock_protocol._write_to_socket.call_args[0][0] assert sent[6:8] == (0).to_bytes(2, "little") assert len(sent[8:]) == 0 @pytest.mark.asyncio async def test_load_long_term_keys_failure() -> None: """A non-success status returns False.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x03) assert await mgmt_ctl.load_long_term_keys(0, [_ltk()]) is False @pytest.mark.asyncio async def test_load_long_term_keys_rejects_invalid_address() -> None: """A malformed address in a key is a soft failure with nothing sent.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x00) assert await mgmt_ctl.load_long_term_keys(0, [_ltk("AA:BB")]) is False mock_protocol._write_to_socket.assert_not_called() @pytest.mark.asyncio @pytest.mark.parametrize( "key", [ LongTermKey("AA:BB:CC:DD:EE:FF", 1, 1, False, 16, 0, b"\x00" * 7, b"\x00" * 16), LongTermKey("AA:BB:CC:DD:EE:FF", 1, 1, False, 16, 0, b"\x00" * 8, b"\x00" * 15), ], ) async def test_load_long_term_keys_rejects_malformed_key(key: LongTermKey) -> None: """A key with the wrong rand/value length is rejected without sending.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x00) assert await mgmt_ctl.load_long_term_keys(0, [key]) is False mock_protocol._write_to_socket.assert_not_called() @pytest.mark.asyncio async def test_load_long_term_keys_rejects_out_of_range_field() -> None: """An out-of-range integer field is a soft failure, not a struct.error.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x00) bad = LongTermKey( "AA:BB:CC:DD:EE:FF", 1, 300, False, 16, 0, b"\x00" * 8, b"\x00" * 16 ) # key_type 300 does not fit in a byte assert await mgmt_ctl.load_long_term_keys(0, [bad]) is False mock_protocol._write_to_socket.assert_not_called() # -- pairing event capture ------------------------------------------------ def _new_ltk_frame( *, store_hint: int = 1, address: bytes = b"\xff\xee\xdd\xcc\xbb\xaa", # little-endian AA:BB:..:FF controller_idx: int = 0, ) -> bytes: ltk_info = ( address + bytes([0x01, 0x05, 0x00, 0x10]) # type, key_type, central, enc_size + (0x1234).to_bytes(2, "little") # ediv + bytes(range(8)) # rand + bytes(range(16)) # value ) param = bytes([store_hint]) + ltk_info header = ( (0x000A).to_bytes(2, "little") + controller_idx.to_bytes(2, "little") + len(param).to_bytes(2, "little") ) return header + param def _protocol_with_handlers( event_loop: asyncio.AbstractEventLoop, handlers: dict[tuple[int, str], Callable[..., None]], ) -> BluetoothMGMTProtocol: return BluetoothMGMTProtocol( event_loop.create_future(), {}, Mock(), Mock(return_value=False), Mock(), handlers, ) def test_data_received_new_long_term_key( event_loop: asyncio.AbstractEventLoop, ) -> None: """A NEW_LONG_TERM_KEY event is decoded and routed to its handler.""" received: list[object] = [] handlers = {(0, "AA:BB:CC:DD:EE:FF"): received.append} protocol = _protocol_with_handlers(event_loop, handlers) protocol.data_received(_new_ltk_frame()) assert len(received) == 1 event = received[0] assert isinstance(event, NewLongTermKey) assert event.store_hint == 1 key = event.key assert key == LongTermKey( address="AA:BB:CC:DD:EE:FF", address_type=0x01, key_type=0x05, central=False, encryption_size=0x10, ediv=0x1234, rand=bytes(range(8)), value=bytes(range(16)), ) def test_data_received_auth_failed(event_loop: asyncio.AbstractEventLoop) -> None: """An AUTHENTICATION_FAILED event is decoded and routed to its handler.""" received: list[object] = [] handlers = {(0, "AA:BB:CC:DD:EE:FF"): received.append} protocol = _protocol_with_handlers(event_loop, handlers) param = b"\xff\xee\xdd\xcc\xbb\xaa" + bytes([0x01, 0x05]) # type, status header = ( (0x0011).to_bytes(2, "little") + b"\x00\x00" + len(param).to_bytes(2, "little") ) protocol.data_received(header + param) assert received == [AuthenticationFailed("AA:BB:CC:DD:EE:FF", 0x01, 0x05)] def test_pairing_event_without_handler_is_dropped( event_loop: asyncio.AbstractEventLoop, ) -> None: """An event for an unregistered peer is dropped without error.""" protocol = _protocol_with_handlers(event_loop, {}) protocol.data_received(_new_ltk_frame()) # no handlers -> no-op auth_param = b"\xff\xee\xdd\xcc\xbb\xaa" + bytes([0x01, 0x05]) auth_header = ( (0x0011).to_bytes(2, "little") + b"\x00\x00" + len(auth_param).to_bytes(2, "little") ) protocol.data_received(auth_header + auth_param) # no handlers -> no-op def test_truncated_new_long_term_key_is_dropped( event_loop: asyncio.AbstractEventLoop, ) -> None: """A NEW_LONG_TERM_KEY event too short to hold a key is dropped.""" received: list[object] = [] handlers = {(0, "AA:BB:CC:DD:EE:FF"): received.append} protocol = _protocol_with_handlers(event_loop, handlers) param = b"\x01\xff\xee" # store_hint + 2 bytes (well short of 1 + 36) header = ( (0x000A).to_bytes(2, "little") + b"\x00\x00" + len(param).to_bytes(2, "little") ) protocol.data_received(header + param) assert received == [] def test_truncated_auth_failed_is_dropped( event_loop: asyncio.AbstractEventLoop, ) -> None: """An AUTHENTICATION_FAILED event shorter than 8 bytes is dropped.""" received: list[object] = [] handlers = {(0, "AA:BB:CC:DD:EE:FF"): received.append} protocol = _protocol_with_handlers(event_loop, handlers) param = b"\xff\xee\xdd" # 3 bytes, short of 8 header = ( (0x0011).to_bytes(2, "little") + b"\x00\x00" + len(param).to_bytes(2, "little") ) protocol.data_received(header + param) assert received == [] def test_pairing_handler_exception_is_isolated( event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture, ) -> None: """A raising handler is logged, not allowed to break the read loop.""" def boom(_event: object) -> None: msg = "handler exploded" raise RuntimeError(msg) handlers = {(0, "AA:BB:CC:DD:EE:FF"): boom} protocol = _protocol_with_handlers(event_loop, handlers) with caplog.at_level("ERROR", logger="habluetooth.channels.bluez"): # Neither dispatch path should raise out of data_received. protocol.data_received(_new_ltk_frame()) auth_param = b"\xff\xee\xdd\xcc\xbb\xaa" + bytes([0x01, 0x05]) auth_header = ( (0x0011).to_bytes(2, "little") + b"\x00\x00" + len(auth_param).to_bytes(2, "little") ) protocol.data_received(auth_header + auth_param) assert caplog.text.count("Error in") == 2 # both handlers logged @pytest.mark.asyncio async def test_register_pairing_handler_normalizes_and_unregisters() -> None: """register_pairing_handler normalizes the address and unregisters cleanly.""" mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={}) handler = Mock() unregister = mgmt_ctl.register_pairing_handler(0, "aa:bb:cc:dd:ee:ff", handler) assert mgmt_ctl._pairing_handlers[(0, "AA:BB:CC:DD:EE:FF")] is handler unregister() assert (0, "AA:BB:CC:DD:EE:FF") not in mgmt_ctl._pairing_handlers unregister() # idempotent, does not raise @pytest.mark.asyncio async def test_register_pairing_handler_last_wins() -> None: """A second registration replaces the first; the first's unregister no-ops.""" mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={}) first, second = Mock(), Mock() key = (0, "AA:BB:CC:DD:EE:FF") unregister_first = mgmt_ctl.register_pairing_handler(0, "AA:BB:CC:DD:EE:FF", first) mgmt_ctl.register_pairing_handler(0, "AA:BB:CC:DD:EE:FF", second) assert mgmt_ctl._pairing_handlers[key] is second unregister_first() # must not remove the newer handler assert mgmt_ctl._pairing_handlers[key] is second # -- interactive pairing replies + request events ------------------------- _REV_PEER = b"\xff\xee\xdd\xcc\xbb\xaa" # little-endian AA:BB:CC:DD:EE:FF _PEER_STR = "AA:BB:CC:DD:EE:FF" @pytest.mark.asyncio @pytest.mark.parametrize( ("accept", "opcode"), [(True, MGMT_OP_USER_CONFIRM_REPLY), (False, MGMT_OP_USER_CONFIRM_NEG_REPLY)], ) async def test_user_confirmation_reply(accept: bool, opcode: int) -> None: """Confirmation accept/reject send the right opcode with addr + type.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x00) assert ( await mgmt_ctl.user_confirmation_reply( 0, _PEER_STR, BDADDR_LE_RANDOM, accept=accept ) is True ) sent = mock_protocol._write_to_socket.call_args[0][0] assert sent[0:2] == opcode.to_bytes(2, "little") assert sent[6:12] == _REV_PEER assert sent[12] == BDADDR_LE_RANDOM @pytest.mark.asyncio async def test_user_passkey_reply() -> None: """Passkey reply carries the addr, type, and uint32 passkey.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x00) assert ( await mgmt_ctl.user_passkey_reply(0, _PEER_STR, BDADDR_LE_RANDOM, 123456) is True ) sent = mock_protocol._write_to_socket.call_args[0][0] assert sent[0:2] == MGMT_OP_USER_PASSKEY_REPLY.to_bytes(2, "little") assert sent[6:12] == _REV_PEER assert sent[12] == BDADDR_LE_RANDOM assert sent[13:17] == (123456).to_bytes(4, "little") @pytest.mark.asyncio async def test_user_passkey_negative_reply() -> None: """Passkey negative reply sends addr + type with the negative opcode.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x00) assert ( await mgmt_ctl.user_passkey_negative_reply(0, _PEER_STR, BDADDR_LE_PUBLIC) is True ) sent = mock_protocol._write_to_socket.call_args[0][0] assert sent[0:2] == MGMT_OP_USER_PASSKEY_NEG_REPLY.to_bytes(2, "little") @pytest.mark.asyncio async def test_user_reply_failure() -> None: """A non-success status returns False.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x03) assert ( await mgmt_ctl.user_confirmation_reply(0, _PEER_STR, BDADDR_LE_PUBLIC) is False ) @pytest.mark.asyncio async def test_user_reply_rejects_invalid_address() -> None: """A malformed address is a soft failure with nothing sent.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x00) assert await mgmt_ctl.user_passkey_reply(0, "AA:BB", BDADDR_LE_PUBLIC, 1) is False mock_protocol._write_to_socket.assert_not_called() def test_data_received_user_confirmation_request( event_loop: asyncio.AbstractEventLoop, ) -> None: """A USER_CONFIRMATION_REQUEST event decodes the comparison value.""" received: list[object] = [] protocol = _protocol_with_handlers(event_loop, {(0, _PEER_STR): received.append}) param = _REV_PEER + bytes([0x01, 0x01]) + (123456).to_bytes(4, "little") header = ( (0x000F).to_bytes(2, "little") + b"\x00\x00" + len(param).to_bytes(2, "little") ) protocol.data_received(header + param) assert received == [UserConfirmationRequest(_PEER_STR, 0x01, 0x01, 123456)] def test_data_received_user_passkey_request( event_loop: asyncio.AbstractEventLoop, ) -> None: """A USER_PASSKEY_REQUEST event routes to the handler.""" received: list[object] = [] protocol = _protocol_with_handlers(event_loop, {(0, _PEER_STR): received.append}) param = _REV_PEER + bytes([0x01]) header = ( (0x0010).to_bytes(2, "little") + b"\x00\x00" + len(param).to_bytes(2, "little") ) protocol.data_received(header + param) assert received == [UserPasskeyRequest(_PEER_STR, 0x01)] @pytest.mark.parametrize( ("event_code", "param"), [(0x000F, b"\xff\xee\xdd"), (0x0010, b"\xff\xee")], ) def test_truncated_user_request_is_dropped( event_loop: asyncio.AbstractEventLoop, event_code: int, param: bytes ) -> None: """A user-request event too short to parse is dropped, not mis-read.""" received: list[object] = [] protocol = _protocol_with_handlers(event_loop, {(0, _PEER_STR): received.append}) header = ( event_code.to_bytes(2, "little") + b"\x00\x00" + len(param).to_bytes(2, "little") ) protocol.data_received(header + param) assert received == [] @pytest.mark.parametrize( ("event_code", "param"), [ (0x000F, _REV_PEER + bytes([0x01, 0x01]) + (1).to_bytes(4, "little")), (0x0010, _REV_PEER + bytes([0x01])), ], ) def test_user_request_without_handler_is_dropped( event_loop: asyncio.AbstractEventLoop, event_code: int, param: bytes ) -> None: """A user-request event for an unregistered peer is dropped without error.""" protocol = _protocol_with_handlers(event_loop, {}) header = ( event_code.to_bytes(2, "little") + b"\x00\x00" + len(param).to_bytes(2, "little") ) protocol.data_received(header + param) # no handlers -> no-op @pytest.mark.asyncio async def test_user_reply_rejects_out_of_range_passkey() -> None: """A passkey that does not fit a uint32 is a soft failure, not a struct.error.""" mgmt_ctl, mock_protocol = _mgmt_ctl_with_mock_protocol() mock_protocol.command_response = _stub_command_response(0x00) assert ( await mgmt_ctl.user_passkey_reply(0, _PEER_STR, BDADDR_LE_PUBLIC, 2**32) is False ) mock_protocol._write_to_socket.assert_not_called() Bluetooth-Devices-habluetooth-1390f03/tests/channels/test_l2cap.py000066400000000000000000000400361522222566700252050ustar00rootroot00000000000000"""Tests for the L2CAP ATT socket transport.""" from __future__ import annotations import asyncio import errno import os import re import socket import sys from typing import TYPE_CHECKING from unittest.mock import Mock, patch import pytest from bleak import BleakError from habluetooth.channels.l2cap import ( AF_BLUETOOTH, ATT_CID, BT_SECURITY_HIGH, BT_SECURITY_LOW, BT_SECURITY_MEDIUM, L2CAPSocket, _set_result_if_pending, _wait_connected, can_use_l2cap, make_sockaddr_l2, str_to_bdaddr, ) from habluetooth.const import BDADDR_LE_RANDOM if TYPE_CHECKING: from collections.abc import Callable, Iterator pytestmark = [ pytest.mark.asyncio, # L2CAP is Linux only; the transport drives the socket with add_reader / # add_writer / sock_sendall, which the Windows proactor event loop does not # implement. The selector loops on Linux and macOS run these fine. pytest.mark.skipif( sys.platform == "win32", reason="L2CAP transport needs a selector event loop (Linux/macOS)", ), ] _SOURCE = "00:11:22:33:44:55" _PEER = "AA:BB:CC:DD:EE:FF" @pytest.fixture def pair() -> Iterator[tuple[socket.socket, socket.socket]]: """Yield a connected socket pair and close both ends on teardown.""" left, right = socket.socketpair() try: yield left, right finally: left.close() right.close() async def _connect( left: socket.socket, *, on_data: Callable[[bytes], None], on_close: Callable[[Exception | None], None], connect_result: int = 0, bind_result: int = 0, timeout: float = 1.0, ) -> L2CAPSocket: """Drive create_connection over an injected socket with patched syscalls.""" with ( patch("habluetooth.channels.l2cap._set_bt_security"), patch("habluetooth.channels.l2cap._bind_fd", return_value=bind_result), patch("habluetooth.channels.l2cap._connect_fd", return_value=connect_result), ): return await L2CAPSocket.create_connection( source=_SOURCE, address=_PEER, address_type=BDADDR_LE_RANDOM, on_data=on_data, on_close=on_close, timeout=timeout, sock=left, ) def _strerror(err: int) -> str: """Return the platform error string for ``err`` as a regex-safe pattern.""" return re.escape(os.strerror(err)) # -- address packing ------------------------------------------------------ async def test_str_to_bdaddr_reverses_octets() -> None: """A textual BD_ADDR is packed least-significant-octet first.""" assert str_to_bdaddr("AA:BB:CC:DD:EE:FF") == bytes( [0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA] ) @pytest.mark.parametrize("bad", ["AA:BB:CC", "", "AA:BB:CC:DD:EE:FF:00"]) async def test_str_to_bdaddr_rejects_malformed(bad: str) -> None: """An address without exactly six octets is rejected.""" with pytest.raises(ValueError, match="Invalid MAC address"): str_to_bdaddr(bad) async def test_make_sockaddr_l2_layout() -> None: """The packed sockaddr carries the family, channel, address, and type.""" addr = make_sockaddr_l2(_PEER, ATT_CID, BDADDR_LE_RANDOM) assert addr.l2_family == AF_BLUETOOTH assert addr.l2_psm == 0 assert bytes(addr.l2_bdaddr) == bytes([0xFF, 0xEE, 0xDD, 0xCC, 0xBB, 0xAA]) assert addr.l2_cid == ATT_CID assert addr.l2_bdaddr_type == BDADDR_LE_RANDOM # -- connect handshake ---------------------------------------------------- @pytest.mark.parametrize( ("security_level", "expected_calls"), [(2, 1), (0, 0)], ) async def test_create_connection_security_level( pair: tuple[socket.socket, socket.socket], security_level: int, expected_calls: int, ) -> None: """A non-zero security level requests kernel LE security; zero skips it.""" left, _right = pair with ( patch("habluetooth.channels.l2cap._set_bt_security") as set_security, patch("habluetooth.channels.l2cap._bind_fd", return_value=0), patch("habluetooth.channels.l2cap._connect_fd", return_value=0), ): sock = await L2CAPSocket.create_connection( source=_SOURCE, address=_PEER, address_type=BDADDR_LE_RANDOM, on_data=lambda _d: None, on_close=lambda _e: None, timeout=1.0, security_level=security_level, sock=left, ) sock.close() assert set_security.call_count == expected_calls async def test_default_security_level_is_low( pair: tuple[socket.socket, socket.socket], ) -> None: """The connection starts at BT_SECURITY_LOW to match bluetoothd.""" left, _right = pair sock = await _connect(left, on_data=lambda _d: None, on_close=lambda _e: None) assert sock.security_level == BT_SECURITY_LOW sock.close() async def test_set_security_level_raises_and_tracks( pair: tuple[socket.socket, socket.socket], ) -> None: """Raising the level applies the socket option and records the new level.""" left, _right = pair sock = await _connect(left, on_data=lambda _d: None, on_close=lambda _e: None) with patch("habluetooth.channels.l2cap._set_bt_security") as set_security: assert sock.set_security_level(BT_SECURITY_MEDIUM) is True assert sock.security_level == BT_SECURITY_MEDIUM set_security.assert_called_once_with(sock._sock, BT_SECURITY_MEDIUM) sock.close() async def test_set_security_level_never_downgrades( pair: tuple[socket.socket, socket.socket], ) -> None: """A level at or below the current one is a no-op and is not applied.""" left, _right = pair sock = await _connect(left, on_data=lambda _d: None, on_close=lambda _e: None) with patch("habluetooth.channels.l2cap._set_bt_security"): sock.set_security_level(BT_SECURITY_HIGH) with patch("habluetooth.channels.l2cap._set_bt_security") as set_security: assert sock.set_security_level(BT_SECURITY_MEDIUM) is False # lower assert sock.set_security_level(BT_SECURITY_HIGH) is False # equal set_security.assert_not_called() assert sock.security_level == BT_SECURITY_HIGH sock.close() async def test_set_security_level_handles_oserror( pair: tuple[socket.socket, socket.socket], ) -> None: """A kernel refusal leaves the tracked level unchanged and returns False.""" left, _right = pair sock = await _connect(left, on_data=lambda _d: None, on_close=lambda _e: None) with patch( "habluetooth.channels.l2cap._set_bt_security", side_effect=OSError(errno.EPERM, "denied"), ): assert sock.set_security_level(BT_SECURITY_MEDIUM) is False assert sock.security_level == BT_SECURITY_LOW sock.close() async def test_create_connection_immediate_success( pair: tuple[socket.socket, socket.socket], ) -> None: """A connect that succeeds at once yields a live, readable socket.""" left, right = pair received: list[bytes] = [] closed: list[Exception | None] = [] got = asyncio.Event() def on_data(data: bytes) -> None: received.append(data) got.set() sock = await _connect(left, on_data=on_data, on_close=closed.append) right.send(b"\x1b\x05\x00\xab") await got.wait() assert received == [b"\x1b\x05\x00\xab"] assert closed == [] sock.close() async def test_create_connection_in_progress_then_writable( pair: tuple[socket.socket, socket.socket], ) -> None: """An EINPROGRESS connect completes once the socket reports writable.""" left, _right = pair sock = await _connect( left, on_data=lambda _d: None, on_close=lambda _e: None, connect_result=errno.EINPROGRESS, ) assert sock._closed is False sock.close() async def test_create_connection_so_error_fails_and_closes( pair: tuple[socket.socket, socket.socket], ) -> None: """A non-zero SO_ERROR after connect surfaces and closes the socket.""" left, _right = pair with ( patch( "habluetooth.channels.l2cap._so_error", return_value=errno.EHOSTUNREACH, ), pytest.raises(OSError, match=_strerror(errno.EHOSTUNREACH)) as exc_info, ): await _connect( left, on_data=lambda _d: None, on_close=lambda _e: None, connect_result=errno.EINPROGRESS, ) assert exc_info.value.errno == errno.EHOSTUNREACH assert left.fileno() == -1 # the injected socket was closed on failure @pytest.mark.parametrize( ("connect_kwargs", "expected_errno"), [ ({"connect_result": errno.EHOSTUNREACH}, errno.EHOSTUNREACH), ({"bind_result": errno.EPERM}, errno.EPERM), ], ) async def test_create_connection_syscall_error_closes( pair: tuple[socket.socket, socket.socket], connect_kwargs: dict[str, int], expected_errno: int, ) -> None: """A hard bind/connect error is raised and the socket is closed.""" left, _right = pair with pytest.raises(OSError, match=_strerror(expected_errno)) as exc_info: await _connect( left, on_data=lambda _d: None, on_close=lambda _e: None, **connect_kwargs, ) assert exc_info.value.errno == expected_errno assert left.fileno() == -1 async def test_wait_connected_times_out( pair: tuple[socket.socket, socket.socket], ) -> None: """If the socket never reports writable, the connect times out.""" loop = asyncio.get_running_loop() left, _right = pair with ( patch.object(loop, "add_writer"), pytest.raises(TimeoutError), ): await _wait_connected(left, loop, 0.01) # -- send / receive / teardown ------------------------------------------- async def test_send_writes_one_pdu( pair: tuple[socket.socket, socket.socket], ) -> None: """Send writes the PDU to the peer end of the channel.""" left, right = pair sock = await _connect(left, on_data=lambda _d: None, on_close=lambda _e: None) await sock.send(b"\x02\x17\x00") assert right.recv(64) == b"\x02\x17\x00" sock.close() async def test_send_serializes_concurrent_writes( pair: tuple[socket.socket, socket.socket], ) -> None: """Concurrent sends are serialized so one cannot stall another mid-write.""" left, _right = pair sock = await _connect(left, on_data=lambda _d: None, on_close=lambda _e: None) order: list[tuple[str, bytes]] = [] first_entered = asyncio.Event() release_first = asyncio.Event() async def fake_sendall(_sock: socket.socket, data: bytes) -> None: order.append(("enter", data)) if data == b"A": first_entered.set() await release_first.wait() order.append(("exit", data)) with patch.object(sock._loop, "sock_sendall", fake_sendall): first = asyncio.create_task(sock.send(b"A")) await first_entered.wait() second = asyncio.create_task(sock.send(b"B")) await asyncio.sleep(0) # The second send is blocked on the lock while the first is in flight. assert order == [("enter", b"A")] release_first.set() await asyncio.gather(first, second) assert order == [ ("enter", b"A"), ("exit", b"A"), ("enter", b"B"), ("exit", b"B"), ] sock.close() async def test_send_rechecks_closed_after_acquiring_lock( pair: tuple[socket.socket, socket.socket], ) -> None: """A send parked on the write lock raises BleakError if close() intervenes.""" left, _right = pair sock = await _connect(left, on_data=lambda _d: None, on_close=lambda _e: None) in_flight = asyncio.Event() release = asyncio.Event() async def fake_sendall(_sock: socket.socket, _data: bytes) -> None: in_flight.set() await release.wait() with patch.object(sock._loop, "sock_sendall", fake_sendall): first = asyncio.create_task(sock.send(b"A")) await in_flight.wait() second = asyncio.create_task(sock.send(b"B")) await asyncio.sleep(0) # the second send parks on the held lock sock.close() # tear down while the second send is waiting release.set() await first # the first send already passed the closed check with pytest.raises(BleakError, match="closed"): await second async def test_send_error_tears_down_channel( pair: tuple[socket.socket, socket.socket], ) -> None: """A write-side socket error closes the channel and notifies on_close once.""" left, _right = pair closed: list[Exception | None] = [] sock = await _connect(left, on_data=lambda _d: None, on_close=closed.append) boom = OSError("epipe") async def fake_sendall(_sock: socket.socket, _data: bytes) -> None: raise boom with ( patch.object(sock._loop, "sock_sendall", fake_sendall), pytest.raises(OSError, match="epipe"), ): await sock.send(b"A") assert sock._closed is True assert closed == [boom] async def test_send_after_close_raises( pair: tuple[socket.socket, socket.socket], ) -> None: """Writing to a closed channel raises rather than touching the socket.""" left, _right = pair sock = await _connect(left, on_data=lambda _d: None, on_close=lambda _e: None) sock.close() with pytest.raises(BleakError, match="closed"): await sock.send(b"\x02\x17\x00") async def test_peer_disconnect_reports_close_once( pair: tuple[socket.socket, socket.socket], ) -> None: """A peer hangup is reported to on_close exactly once with no error.""" left, right = pair closed: list[Exception | None] = [] hung_up = asyncio.Event() def on_close(exc: Exception | None) -> None: closed.append(exc) hung_up.set() sock = await _connect(left, on_data=lambda _d: None, on_close=on_close) right.close() await hung_up.wait() assert closed == [None] assert sock._closed is True async def test_read_ready_ignores_would_block( pair: tuple[socket.socket, socket.socket], ) -> None: """A spurious wakeup with no data is a no-op, not a disconnect.""" left, _right = pair received: list[bytes] = [] closed: list[Exception | None] = [] sock = await _connect(left, on_data=received.append, on_close=closed.append) sock._sock = Mock(recv=Mock(side_effect=BlockingIOError)) sock._read_ready() assert received == [] assert closed == [] assert sock._closed is False async def test_read_ready_reports_socket_error( pair: tuple[socket.socket, socket.socket], ) -> None: """A read error tears the channel down and reports the exception once.""" left, _right = pair closed: list[Exception | None] = [] sock = await _connect(left, on_data=lambda _d: None, on_close=closed.append) fd = sock._sock.fileno() boom = OSError("boom") sock._sock = Mock(recv=Mock(side_effect=boom), fileno=Mock(return_value=fd)) sock._read_ready() assert closed == [boom] assert sock._closed is True async def test_close_is_idempotent( pair: tuple[socket.socket, socket.socket], ) -> None: """Closing twice is safe and does not double-report.""" left, _right = pair closed: list[Exception | None] = [] sock = await _connect(left, on_data=lambda _d: None, on_close=closed.append) sock.close() sock.close() # A subsequent failure does not call on_close again. sock._fail(None) assert closed == [] async def test_set_result_if_pending_is_idempotent() -> None: """Resolving an already-finished future is a no-op.""" loop = asyncio.get_running_loop() fut: asyncio.Future[None] = loop.create_future() _set_result_if_pending(fut) _set_result_if_pending(fut) assert fut.result() is None async def test_can_use_l2cap_true_when_socket_opens() -> None: """can_use_l2cap returns True when an L2CAP socket can be created.""" sock = Mock() with patch("socket.socket", return_value=sock) as make_socket: assert can_use_l2cap() is True make_socket.assert_called_once() sock.close.assert_called_once() async def test_can_use_l2cap_false_without_permission() -> None: """can_use_l2cap returns False when the socket cannot be opened.""" with patch("socket.socket", side_effect=PermissionError): assert can_use_l2cap() is False Bluetooth-Devices-habluetooth-1390f03/tests/conftest.py000066400000000000000000000222471522222566700232030ustar00rootroot00000000000000from 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_bleak as bluetooth_scanner class FakeBluetoothAdapters(BluetoothAdapters): @property def adapters(self) -> dict[str, AdapterDetails]: return {} @property def default_adapter(self) -> str: return "hci0" 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_bleak.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_bleak.platform.system", return_value="Darwin", ), patch( "bluetooth_adapters.systems.platform.system", return_value="Darwin", ), patch("habluetooth.scanner_bleak.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-1390f03/tests/test_advertisement_tracker.py000066400000000000000000000061251522222566700267770ustar00rootroot00000000000000"""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-1390f03/tests/test_auto_scheduler.py000066400000000000000000010561531522222566700254270ustar00rootroot00000000000000"""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 bluetooth_data_tools import monotonic_time_coarse 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, RESCUE_SCAN_ACCEPT_SECONDS, ) 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_window_honors_35s_request() -> None: """A 35s scan_duration is dispatched as-is, not clamped down.""" 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=35.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 == [35.0] 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] # The delegated window is recorded against the fallback that ran # it, not the connecting owner. entry = sched.async_diagnostics()["requests"][address][0] assert entry["last_active_window_source"] == fallback.source assert entry["last_active_window"] is not None 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["last_window_at"] == 0.0 assert worker_diag["last_window_duration"] == 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() assert entry["last_active_window"] is None assert entry["last_active_window_source"] is None finally: cancel1() cancel2() register_cancel() # After cancellation the address falls out of both indexes. post = sched.async_diagnostics() assert post["requests"] == {} @pytest.mark.asyncio async def test_async_diagnostics_last_active_window() -> None: """A fired window populates per-worker and per-address last-window timing.""" 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, scan_duration=5.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 tick_at = loop.time() await _run_worker_tick(sched, scanner.source) assert scanner.active_window_calls == [5.0] diagnostics = sched.async_diagnostics() worker_diag = diagnostics["workers"][scanner.source] assert worker_diag["last_window_at"] == pytest.approx(tick_at, abs=0.5) assert worker_diag["last_window_duration"] == 5.0 entry = diagnostics["requests"][address][0] assert entry["last_active_window"] == pytest.approx(tick_at, abs=0.5) assert entry["last_active_window_source"] == scanner.source finally: cancel() register_cancel() # Pruned with the request so the map cannot grow without bound. assert sched._last_window_by_address == {} @pytest.mark.asyncio async def test_has_active_requests() -> None: """has_active_requests reflects whether any active-scan request exists.""" manager = get_manager() sched = manager._auto_scheduler before = sched.has_active_requests cancel = manager.async_register_active_scan("11:22:33:44:55:66") during = sched.has_active_requests cancel() after = sched.has_active_requests assert before is False assert during is True assert after is False @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) @pytest.mark.asyncio async def test_trigger_rescue_auto_owner_and_auto_challenger() -> None: """ trigger_rescue serves both sides of a deferred stale handoff. The AUTO owner's due times are clamped to now so its worker fires a window on its next tick; the AUTO challenger gets a directly delegated window and the rescue completion is recorded. """ manager = get_manager() sched = manager._auto_scheduler loop = asyncio.get_running_loop() address = "11:22:33:44:66:01" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=7.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:91:01", BluetoothScanningMode.AUTO) challenger = _DiscoverableAutoScanner( "AA:00:00:00:91:02", BluetoothScanningMode.AUTO ) c_owner = manager.async_register_scanner(owner) c_challenger = manager.async_register_scanner(challenger) try: _inject_with_rssi(owner, address, rssi=-50) manager._rescue_triggered[address] = 0.1 before = monotonic_time_coarse() sched.trigger_rescue(address, challenger.source, owner.source) # Owner side: every due time clamped to now so the next tick fires. now = loop.time() assert all(due <= now for due in sched._schedule._due_at[address].values()) # Challenger side: a directly delegated window. for task in list(sched._rescue_tasks): await task await asyncio.sleep(0) assert challenger.active_window_calls == [7.0] assert not sched._rescue_tasks # Accept time recorded at dispatch + RESCUE_SCAN_ACCEPT_SECONDS, # comparable with adv times; the handoff does not wait for the # full window to close. assert ( sched._rescue_accept_after[address] >= before + RESCUE_SCAN_ACCEPT_SECONDS ) assert sched._rescue_accept_after[address] < before + 7.0 # Owner side actually fires on its tick and re-records completion. await _run_worker_tick(sched, owner.source) assert owner.active_window_calls == [7.0] finally: cancel() c_owner() c_challenger() @pytest.mark.asyncio async def test_trigger_rescue_active_challenger_counts_covered() -> None: """A weaker ACTIVE challenger gets no window; its side is covered now.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:66:02" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=7.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:92:01", BluetoothScanningMode.AUTO) challenger = _DiscoverableAutoScanner( "AA:00:00:00:92:02", BluetoothScanningMode.ACTIVE ) c_owner = manager.async_register_scanner(owner) c_challenger = manager.async_register_scanner(challenger) try: _inject_with_rssi(owner, address, rssi=-50) before = monotonic_time_coarse() sched.trigger_rescue(address, challenger.source, owner.source) after = monotonic_time_coarse() # Already actively scanning: no window, no task, covered with # the standard accept grace. assert challenger.active_window_calls == [] assert owner.active_window_calls == [] assert not sched._rescue_tasks assert ( before + RESCUE_SCAN_ACCEPT_SECONDS <= sched._rescue_accept_after[address] <= after + RESCUE_SCAN_ACCEPT_SECONDS ) # The owner side is still served: its scheduled due times are # clamped to now so its worker fires a window on the next tick. loop_now = asyncio.get_running_loop().time() assert all(due <= loop_now for due in sched._schedule._due_at[address].values()) finally: cancel() c_owner() c_challenger() @pytest.mark.asyncio async def test_trigger_rescue_active_owner_counts_covered() -> None: """An ACTIVE owner is already scanning; its side completes now.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:66:03" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=7.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:93:01", BluetoothScanningMode.ACTIVE) challenger = _DiscoverableAutoScanner( "AA:00:00:00:93:02", BluetoothScanningMode.PASSIVE ) c_owner = manager.async_register_scanner(owner) c_challenger = manager.async_register_scanner(challenger) try: _inject_with_rssi(owner, address, rssi=-50) manager._rescue_triggered[address] = 0.1 before = monotonic_time_coarse() sched.trigger_rescue(address, challenger.source, owner.source) after = monotonic_time_coarse() # PASSIVE challenger gets no window; the owner side is covered # with the standard accept grace. assert challenger.active_window_calls == [] assert owner.active_window_calls == [] assert ( before + RESCUE_SCAN_ACCEPT_SECONDS <= sched._rescue_accept_after[address] <= after + RESCUE_SCAN_ACCEPT_SECONDS ) finally: cancel() c_owner() c_challenger() @pytest.mark.asyncio async def test_trigger_rescue_connecting_challenger_not_delegated() -> None: """A mid-connect challenger cannot run a window; no completion is faked.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:66:04" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=7.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:94:01", BluetoothScanningMode.AUTO) challenger = _DiscoverableAutoScanner( "AA:00:00:00:94:02", BluetoothScanningMode.AUTO ) c_owner = manager.async_register_scanner(owner) c_challenger = manager.async_register_scanner(challenger) try: _inject_with_rssi(owner, address, rssi=-50) challenger._add_connecting(address) sched.trigger_rescue(address, challenger.source, owner.source) assert challenger.active_window_calls == [] assert not sched._rescue_tasks # Owner is AUTO: its completion only lands when its tick runs, so # nothing has been recorded yet. assert address not in sched._rescue_accept_after finally: challenger._finished_connecting(address, connected=False) cancel() c_owner() c_challenger() @pytest.mark.asyncio async def test_trigger_rescue_noop_untracked_or_not_started() -> None: """Untracked addresses and a never-started scheduler are safe no-ops.""" manager = get_manager() sched = manager._auto_scheduler sched.trigger_rescue("FF:FF:FF:FF:FF:FF", "AA:00:00:00:95:01", "AA:00:00:00:95:02") assert "FF:FF:FF:FF:FF:FF" not in sched._rescue_accept_after fresh = type(sched)(manager) fresh.trigger_rescue("FF:FF:FF:FF:FF:FF", "AA:00:00:00:95:01", "AA:00:00:00:95:02") assert fresh._rescue_accept_after == {} @pytest.mark.asyncio async def test_remove_request_prunes_rescue_accept_after() -> None: """The last request for an address prunes all rescue state.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:66:05" cancel = manager.async_register_active_scan(address, scan_interval=120.0) sched._rescue_accept_after[address] = 1.0 manager._rescue_triggered[address] = 1.0 cancel() assert address not in sched._rescue_accept_after # The episode's lifetime is bounded by the need: a device that never # goes stale again must not keep an orphaned episode alive. assert address not in manager._rescue_triggered @pytest.mark.asyncio async def test_stop_clears_rescue_state() -> None: """stop() drops rescue completions and cancels in-flight rescue windows.""" manager = get_manager() sched = manager._auto_scheduler sched._rescue_accept_after["11:22:33:44:66:06"] = 1.0 task = asyncio.get_running_loop().create_task(asyncio.sleep(60, result=True)) sched._rescue_tasks.add(task) sched.stop() assert sched._rescue_accept_after == {} assert not sched._rescue_tasks assert task.cancelled() or task.cancelling() with contextlib.suppress(asyncio.CancelledError): await task @pytest.mark.asyncio async def test_worker_tick_records_rescue_accept_after() -> None: """The owner tick path records accept times only during a rescue.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:66:07" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=5.0 ) scanner = _DiscoverableAutoScanner("AA:00:00:00:97:01", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject_with_rssi(scanner, address, rssi=-50) _make_due(sched, address) # No rescue episode anywhere: the ordinary cadence records nothing. await _run_worker_tick(sched, scanner.source) assert scanner.active_window_calls == [5.0] assert address not in sched._rescue_accept_after # With an episode in flight the tick records the accept time. manager._rescue_triggered[address] = 0.1 _make_due(sched, address) before = monotonic_time_coarse() await _run_worker_tick(sched, scanner.source) assert scanner.active_window_calls == [5.0, 5.0] assert ( sched._rescue_accept_after[address] >= before + RESCUE_SCAN_ACCEPT_SECONDS ) manager._rescue_triggered.pop(address, None) finally: cancel() register_cancel() @pytest.mark.asyncio async def test_fallback_dispatch_records_rescue_accept_after() -> None: """A window delegated to a fallback still records the rescue completion.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:66:08" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=7.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:98:01", BluetoothScanningMode.AUTO) fallback = _DiscoverableAutoScanner("AA:00:00:00:98: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) fallback.add_discovered(address, rssi=-70) owner._add_connecting(address) _make_due(sched, address) manager._rescue_triggered[address] = 0.1 before = monotonic_time_coarse() await _run_worker_tick(sched, owner.source) assert fallback.active_window_calls == [7.0] assert ( sched._rescue_accept_after[address] >= before + RESCUE_SCAN_ACCEPT_SECONDS ) finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_fallback() @pytest.mark.asyncio async def test_covered_by_active_records_rescue_accept_after() -> None: """Owner mid-connect but an ACTIVE scanner covers: completion is now.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:66:09" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=7.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:99:01", BluetoothScanningMode.AUTO) active = _DiscoverableAutoScanner("AA:00:00:00:99:02", 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=-70) owner._add_connecting(address) _make_due(sched, address) manager._rescue_triggered[address] = 0.1 before = monotonic_time_coarse() await _run_worker_tick(sched, owner.source) after = monotonic_time_coarse() assert active.active_window_calls == [] assert ( before + RESCUE_SCAN_ACCEPT_SECONDS <= sched._rescue_accept_after[address] <= after + RESCUE_SCAN_ACCEPT_SECONDS ) finally: owner._finished_connecting(address, connected=False) cancel() c_owner() c_active() @pytest.mark.asyncio async def test_trigger_rescue_unregistered_sides_are_skipped() -> None: """Unregistered owner or challenger sources are safe no-ops per side.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:66:10" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=7.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:9A:01", BluetoothScanningMode.ACTIVE) c_owner = manager.async_register_scanner(owner) try: _inject_with_rssi(owner, address, rssi=-50) manager._rescue_triggered[address] = 0.1 # Challenger never registered: only the owner side is served. before = monotonic_time_coarse() sched.trigger_rescue(address, "AA:00:00:00:9A:99", owner.source) assert sched._rescue_accept_after[address] >= before # Neither side registered: nothing is recorded and the owner # side's unregistered guard is exercised. recorded = sched._rescue_accept_after[address] + 1000.0 sched._rescue_accept_after[address] = recorded sched.trigger_rescue(address, "AA:00:00:00:9A:99", "AA:00:00:00:9A:98") assert sched._rescue_accept_after[address] == recorded # A later accept time is never rolled back by an earlier one: the # ACTIVE owner side records coverage plus the accept grace, which # loses to the later value. sched.trigger_rescue(address, "AA:00:00:00:9A:99", owner.source) assert sched._rescue_accept_after[address] == recorded finally: cancel() c_owner() @pytest.mark.asyncio async def test_trigger_rescue_auto_challenger_without_worker() -> None: """A dispatch to an AUTO challenger whose worker is gone still works.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:66:11" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=7.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:9B:01", BluetoothScanningMode.ACTIVE) challenger = _DiscoverableAutoScanner( "AA:00:00:00:9B:02", BluetoothScanningMode.AUTO ) c_owner = manager.async_register_scanner(owner) c_challenger = manager.async_register_scanner(challenger) try: _inject_with_rssi(owner, address, rssi=-50) worker = sched._workers.pop(challenger.source) sched.trigger_rescue(address, challenger.source, owner.source) for task in list(sched._rescue_tasks): await task assert challenger.active_window_calls == [7.0] sched._workers[challenger.source] = worker finally: cancel() c_owner() c_challenger() @pytest.mark.asyncio async def test_schedule_advance_to_clamps_only_later_entries() -> None: """advance_to pulls later due times forward and ignores unknown addresses.""" manager = get_manager() sched = manager._auto_scheduler schedule = sched._schedule address = "11:22:33:44:66:12" cancel_a = manager.async_register_active_scan(address, scan_interval=120.0) cancel_b = manager.async_register_active_scan(address, scan_interval=300.0) scanner = _DiscoverableAutoScanner("AA:00:00:00:9C:01", BluetoothScanningMode.AUTO) register_cancel = manager.async_register_scanner(scanner) try: _inject_with_rssi(scanner, address, rssi=-50) entries = schedule._due_at[address] assert len(entries) == 2 earlier, later = sorted(entries.values()) midpoint = (earlier + later) / 2 schedule.advance_to(address, midpoint) assert sorted(entries.values()) == [earlier, midpoint] # Unknown address is a no-op. schedule.advance_to("FF:FF:FF:FF:FF:FF", midpoint) finally: cancel_a() cancel_b() register_cancel() @pytest.mark.asyncio async def test_trigger_rescue_challenger_window_failure_is_logged( caplog: pytest.LogCaptureFixture, ) -> None: """A failed challenger rescue window is logged, not left unretrieved.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:66:13" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=7.0 ) class _RaisingScanner(_DiscoverableAutoScanner): async def async_request_active_window(self, duration: float) -> bool: msg = "boom" raise ValueError(msg) owner = _DiscoverableAutoScanner("AA:00:00:00:9D:01", BluetoothScanningMode.ACTIVE) challenger = _RaisingScanner("AA:00:00:00:9D:02", BluetoothScanningMode.AUTO) c_owner = manager.async_register_scanner(owner) c_challenger = manager.async_register_scanner(challenger) try: _inject_with_rssi(owner, address, rssi=-50) with caplog.at_level(logging.ERROR, logger="habluetooth.auto_scheduler"): sched.trigger_rescue(address, challenger.source, owner.source) for task in list(sched._rescue_tasks): with contextlib.suppress(ValueError): await task # Let the done callbacks run. await asyncio.sleep(0) assert not sched._rescue_tasks assert any( "error running rescue active window" in record.getMessage() and challenger.source in record.getMessage() for record in caplog.records ) finally: cancel() c_owner() c_challenger() @pytest.mark.asyncio async def test_trigger_rescue_cancelled_window_logs_nothing( caplog: pytest.LogCaptureFixture, ) -> None: """A challenger rescue window cancelled by stop() produces no error log.""" manager = get_manager() sched = manager._auto_scheduler address = "11:22:33:44:66:14" cancel = manager.async_register_active_scan( address, scan_interval=120.0, scan_duration=7.0 ) owner = _DiscoverableAutoScanner("AA:00:00:00:9E:01", BluetoothScanningMode.ACTIVE) challenger = _DiscoverableAutoScanner( "AA:00:00:00:9E:02", BluetoothScanningMode.AUTO ) # Block the challenger's window so the task is still in flight when # the scheduler stops. challenger._block_event = asyncio.Event() c_owner = manager.async_register_scanner(owner) c_challenger = manager.async_register_scanner(challenger) try: _inject_with_rssi(owner, address, rssi=-50) manager._rescue_triggered[address] = 0.1 with caplog.at_level(logging.ERROR, logger="habluetooth.auto_scheduler"): sched.trigger_rescue(address, challenger.source, owner.source) await asyncio.sleep(0) task = next(iter(sched._rescue_tasks)) sched.stop() with contextlib.suppress(asyncio.CancelledError): await task # Let the done callback run. await asyncio.sleep(0) assert not sched._rescue_tasks assert not any( "error running rescue active window" in record.getMessage() for record in caplog.records ) finally: cancel() c_owner() c_challenger() Bluetooth-Devices-habluetooth-1390f03/tests/test_base_scanner.py000066400000000000000000001434121522222566700250360ustar00rootroot00000000000000"""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-1390f03/tests/test_benchmark_auto_scheduler.py000066400000000000000000000323601522222566700274320ustar00rootroot00000000000000"""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 () class _DiscoverableAutoScanner(_AutoScanner): """ AUTO scanner that reports a configurable discovered set. ``_resolve_fallback_for_address`` reads candidate scanners via ``manager.async_scanner_devices_by_address``, which filters on ``get_discovered_device_advertisement_data``. The plain ``_AutoScanner`` exposes nothing, so the fallback resolver would iterate an empty candidate list; this subclass lets a device be seen by several scanners so the resolver does real scan-and-score work. """ __slots__ = ("_discovered",) def __init__(self, source: str) -> None: super().__init__(source) self._discovered: dict[str, tuple[BLEDevice, AdvertisementData]] = {} def add_discovered(self, address: str, rssi: int) -> None: 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_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) def _setup_fallback_mesh( num_scanners: int, num_devices: int, seen_by: int ) -> tuple[list[_DiscoverableAutoScanner], list[CALLBACK_TYPE], list[str]]: """ Register ``num_scanners`` discoverable AUTO scanners over a dense mesh. Each of ``num_devices`` addresses is marked discovered by the first ``seen_by`` scanners with descending RSSI, so the fallback resolver iterates ``seen_by`` candidates per address and exercises the RSSI-scoring branch (``rssi > best_rssi``) on every step. The first scanner is the connecting owner the resolver excludes. """ manager = get_manager() scanners: list[_DiscoverableAutoScanner] = [] scanner_cancels: list[CALLBACK_TYPE] = [] for i in range(num_scanners): scanner = _DiscoverableAutoScanner(_make_source(i)) scanners.append(scanner) scanner_cancels.append(manager.async_register_scanner(scanner)) addresses: list[str] = [] for i in range(num_devices): address = _make_address(i) addresses.append(address) for j in range(seen_by): # Descending RSSI so the best candidate is the last one # visited — the resolver can't early-exit its scan. scanners[j].add_discovered(address, rssi=-50 - j) return scanners, scanner_cancels, addresses @pytest.mark.asyncio async def test_resolve_fallback_8_scanners_25_devices_dense_mesh( benchmark: BenchmarkFixture, ) -> None: """ Connecting-fallback resolution over a dense 8-scanner mesh. When a device's owning scanner is mid-connect, every due address is routed through ``_resolve_fallback_for_address``, which iterates the scanners that currently see the address and picks the highest-RSSI non-connecting AUTO fallback. This is the only sync auto-scheduler hot path with no benchmark; the timer (``_next_event_at`` / ``_collect_due_buckets``) and ingestion (``on_advertisement``) benchmarks never enter it. The scenario mirrors a connect storm in a dense proxy mesh: 25 devices each visible to all 8 scanners, so each resolution scans 7 candidates (owner excluded) and scores every one. Descending RSSI means the resolver visits the full candidate list rather than short-circuiting, capturing the worst-case scan cost. """ scanners, scanner_cancels, addresses = _setup_fallback_mesh( num_scanners=8, num_devices=25, seen_by=8 ) manager = get_manager() scheduler = manager._auto_scheduler owner_source = scanners[0].source @benchmark def run() -> None: for address in addresses: scheduler._resolve_fallback_for_address(address, owner_source) for cancel in scanner_cancels: cancel() Bluetooth-Devices-habluetooth-1390f03/tests/test_benchmark_base_scanner.py000066400000000000000000001257611522222566700270570ustar00rootroot00000000000000"""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_bleak 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 from habluetooth.base_scanner import BaseHaScanner 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_cross_source_keep_previous( benchmark: BenchmarkFixture, ) -> None: """ Inject 100 advertisements from a second, weaker scanner for an owned device. The single-source ``test_inject_100_*`` benchmarks never reach the cross-scanner arbitration path, because every advertisement shares the owner's source and ``_should_keep_previous_adv`` short-circuits on the source check. Here a strong scanner owns the device and a weaker second scanner advertises it repeatedly, so each injection runs ``_should_keep_previous_adv`` -> ``_prefer_previous_adv_from_different_source`` and keeps the owner (no ownership flip), exercising the hot path that a multi-proxy setup hits on every cross-source advertisement. """ manager = get_manager() # Match production: debug logging is off, so the switch path does not # pay for _async_describe_source formatting. manager._debug = False address = "44:44:33:11:23:45" owner_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, ) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner_a = BaseHaRemoteScanner("esp32_a", "esp32_a", connector, True) scanner_b = BaseHaRemoteScanner("esp32_b", "esp32_b", connector, True) unsetup_a = scanner_a.async_setup() unsetup_b = scanner_b.async_setup() cancel_a = manager.async_register_scanner(scanner_a) cancel_b = manager.async_register_scanner(scanner_b) _name = "wohand" _service_uuids = owner_adv.service_uuids _service_data = owner_adv.service_data _manufacturer_data = owner_adv.manufacturer_data _tx_power = owner_adv.tx_power _now = monotonic_time_coarse() # Seed ownership on scanner_a with a strong, fresh advertisement. scanner_a._async_on_advertisement( address, -60, _name, _service_uuids, _service_data, _manufacturer_data, _tx_power, {"scanner_specific_data": "a"}, _now, ) _details_b = {"scanner_specific_data": "b"} @benchmark def run(): for _ in range(100): # Weaker and fresh from a different source: the owner is kept, # so arbitration runs without flipping ownership. scanner_b._async_on_advertisement( address, -90, _name, _service_uuids, _service_data, _manufacturer_data, _tx_power, _details_b, _now, ) cancel_a() cancel_b() unsetup_a() unsetup_b() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_inject_100_cross_source_switch(benchmark: BenchmarkFixture) -> None: """ Inject 100 advertisements that flip ownership between two scanners. Two equal-strength scanners alternate advertising the device with ever-later timestamps, so each advertisement is stale relative to the current owner and ownership switches on every injection. This drives the other half of the arbitration hot path (``_prefer_previous_adv_from_different_source`` returning False, the switch decision) on every iteration, complementing the keep-previous benchmark above. The source changes every injection, which resets the interval tracker, so the fallback stale window stays in effect. """ manager = get_manager() # Match production: debug logging is off, so the switch path does not # pay for the _LOGGER.debug + _async_describe_source formatting that # otherwise dominates this benchmark. manager._debug = False address = "44:44:33:11:23:46" 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, ) connector = HaBluetoothConnector( MockBleakClient, "mock_bleak_client", lambda: False ) scanner_a = BaseHaRemoteScanner("esp32_a", "esp32_a", connector, True) scanner_b = BaseHaRemoteScanner("esp32_b", "esp32_b", connector, True) unsetup_a = scanner_a.async_setup() unsetup_b = scanner_b.async_setup() cancel_a = manager.async_register_scanner(scanner_a) cancel_b = manager.async_register_scanner(scanner_b) # Small known stale window so a +100s step per injection is always stale. manager.async_set_fallback_availability_interval(address, 10) _name = "wohand" _service_uuids = adv.service_uuids _service_data = adv.service_data _manufacturer_data = adv.manufacturer_data _tx_power = adv.tx_power _details = {"scanner_specific_data": "x"} _time = [monotonic_time_coarse()] # Seed ownership on scanner_a; the first loop injection (scanner_b) flips it. scanner_a._async_on_advertisement( address, -60, _name, _service_uuids, _service_data, _manufacturer_data, _tx_power, _details, _time[0], ) scanners = (scanner_b, scanner_a) @benchmark def run(): for i in range(100): _time[0] += 100.0 scanners[i & 1]._async_on_advertisement( address, -60, _name, _service_uuids, _service_data, _manufacturer_data, _tx_power, _details, _time[0], ) cancel_a() cancel_b() unsetup_a() unsetup_b() @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, BaseHaScanner] = {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, BaseHaScanner] = {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-1390f03/tests/test_benchmark_manager.py000066400000000000000000000225461522222566700260430ustar00rootroot00000000000000"""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-1390f03/tests/test_central_manager.py000066400000000000000000000022301522222566700255250ustar00rootroot00000000000000"""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-1390f03/tests/test_client_mgmt.py000066400000000000000000001114021522222566700247070ustar00rootroot00000000000000"""Tests for the kernel/L2CAP GATT client backend.""" from __future__ import annotations import asyncio import logging from contextlib import contextmanager from typing import TYPE_CHECKING from unittest.mock import patch import pytest from bleak import BleakError from bleak.backends.device import BLEDevice from habluetooth.channels.att import ( ATT_ERR_INSUFFICIENT_AUTHENTICATION, ATT_ERR_INSUFFICIENT_ENCRYPTION, ) from habluetooth.channels.bluez import ( AuthenticationFailed, LongTermKey, NewLongTermKey, UserConfirmationRequest, UserPasskeyRequest, ) from habluetooth.channels.l2cap import ( BT_SECURITY_HIGH, BT_SECURITY_LOW, BT_SECURITY_MEDIUM, ) from habluetooth.client_mgmt import HaMgmtClient, MgmtClientData from habluetooth.const import BDADDR_LE_RANDOM if TYPE_CHECKING: from collections.abc import Callable, Generator from bleak.backends.characteristic import BleakGATTCharacteristic _CHAR_UUID = "00002a37-0000-1000-8000-00805f9b34fb" _SERVICE_UUID = "0000180d-0000-1000-8000-00805f9b34fb" _CCCD_DESC_UUID = "00002902-0000-1000-8000-00805f9b34fb" pytestmark = pytest.mark.asyncio _ADAPTER = "00:11:22:33:44:55" _PEER = "AA:BB:CC:DD:EE:FF" # Opcodes used to build server responses. _ERROR_RSP = 0x01 _MTU_RSP = 0x03 _FIND_INFO_RSP = 0x05 _READ_BY_TYPE_RSP = 0x09 _READ_RSP = 0x0B _READ_BY_GROUP_TYPE_RSP = 0x11 _WRITE_RSP = 0x13 _NTF = 0x1B _ERR_ATTRIBUTE_NOT_FOUND = 0x0A # Discovered layout: heart-rate service 0x180D with one characteristic 0x2A37 # (value handle 0x0003) and, when present, a CCCD at 0x0004. _CHAR_READ_NOTIFY = 0x12 _VALUE = b"\x01\x02\x03" def _error(req_opcode: int, handle: int, code: int) -> bytes: return ( bytes([_ERROR_RSP, req_opcode]) + handle.to_bytes(2, "little") + bytes([code]) ) def make_responder( char_props: int = _CHAR_READ_NOTIFY, *, has_cccd: bool = True, fail_write: bool = False, ) -> Callable[[bytes], bytes | None]: """Build a minimal ATT server for one service/characteristic.""" svc_end = 0x0004 if has_cccd else 0x0003 def services(req: bytes) -> bytes: # READ_BY_GROUP_TYPE_REQ if int.from_bytes(req[1:3], "little") > 0x0001: return _error(0x10, 0x0001, _ERR_ATTRIBUTE_NOT_FOUND) entry = ( (0x0001).to_bytes(2, "little") + svc_end.to_bytes(2, "little") + (0x180D).to_bytes(2, "little") ) return bytes([_READ_BY_GROUP_TYPE_RSP, len(entry)]) + entry def characteristics(req: bytes) -> bytes: # READ_BY_TYPE_REQ if int.from_bytes(req[1:3], "little") > 0x0002: return _error(0x08, 0x0002, _ERR_ATTRIBUTE_NOT_FOUND) value = ( bytes([char_props]) + (0x0003).to_bytes(2, "little") + (0x2A37).to_bytes(2, "little") ) entry = (0x0002).to_bytes(2, "little") + value return bytes([_READ_BY_TYPE_RSP, len(entry)]) + entry def descriptors(req: bytes) -> bytes: # FIND_INFORMATION_REQ if int.from_bytes(req[1:3], "little") > 0x0004: return _error(0x04, 0x0004, _ERR_ATTRIBUTE_NOT_FOUND) entry = (0x0004).to_bytes(2, "little") + (0x2902).to_bytes(2, "little") return bytes([_FIND_INFO_RSP, 0x01]) + entry write_rsp = ( _error(0x12, 0x0004, 0x03) if fail_write else bytes([_WRITE_RSP]) ) # WRITE_REQ: an error (write not permitted) or success handlers: dict[int, Callable[[bytes], bytes | None]] = { 0x02: lambda _req: bytes([_MTU_RSP]) + (247).to_bytes(2, "little"), 0x10: services, 0x08: characteristics, 0x04: descriptors, 0x0A: lambda _req: bytes([_READ_RSP]) + _VALUE, # READ_REQ 0x12: lambda _req: write_rsp, 0x52: lambda _req: None, # WRITE_CMD (no response) } def responder(req: bytes) -> bytes | None: if (handler := handlers.get(req[0])) is None: msg = f"unexpected request opcode 0x{req[0]:02x}" raise AssertionError(msg) return handler(req) return responder class FakeTransport: """Stand-in for L2CAPSocket that answers ATT PDUs from a responder.""" def __init__( self, responder: Callable[[bytes], bytes | None], on_data: Callable[[bytes], None], on_close: Callable[[Exception | None], None], ) -> None: self._responder = responder self._on_data = on_data self._on_close = on_close self.sent: list[bytes] = [] self.closed = False self.security_level = BT_SECURITY_LOW self.security_requests: list[int] = [] def set_security_level(self, level: int) -> bool: """Raise the tracked security level, never lowering it.""" self.security_requests.append(level) if level <= self.security_level: return False self.security_level = level return True async def send(self, data: bytes) -> None: self.sent.append(bytes(data)) if (resp := self._responder(bytes(data))) is not None: self._on_data(resp) def close(self) -> None: self.closed = True def inject(self, data: bytes) -> None: """Simulate a server-initiated PDU (e.g. a notification).""" self._on_data(data) def drop(self, exc: Exception | None = None) -> None: """Simulate an unexpected transport failure.""" self._on_close(exc) class FakeScanner: """Minimal scanner exposing the connecting() pause context manager.""" def __init__(self) -> None: self.connecting_calls = 0 @contextmanager def connecting(self) -> Generator[None, None, None]: self.connecting_calls += 1 yield def _ble_device() -> BLEDevice: return BLEDevice( _PEER, "test-device", {"source": _ADAPTER, "address_type": BDADDR_LE_RANDOM}, ) @contextmanager def _patch_transport( responder: Callable[[bytes], bytes | None], holder: dict[str, FakeTransport], ) -> Generator[None, None, None]: async def fake_create_connection( *, on_data: Callable[[bytes], None], on_close: Callable[[Exception | None], None], **_kwargs: object, ) -> FakeTransport: transport = FakeTransport(responder, on_data, on_close) holder["transport"] = transport return transport with patch( "habluetooth.client_mgmt.L2CAPSocket.create_connection", fake_create_connection, ): yield def _make_client( disconnected_callback: Callable[[], None] | None = None, ) -> tuple[HaMgmtClient, FakeScanner]: scanner = FakeScanner() client = HaMgmtClient( _ble_device(), client_data=MgmtClientData(adapter_address=_ADAPTER, scanner=scanner), timeout=5.0, disconnected_callback=disconnected_callback, ) return client, scanner async def _connect( holder: dict[str, FakeTransport], responder: Callable[[bytes], bytes | None] | None = None, disconnected_callback: Callable[[], None] | None = None, ) -> tuple[HaMgmtClient, FakeScanner]: client, scanner = _make_client(disconnected_callback) with _patch_transport(responder or make_responder(), holder): await client.connect(False) return client, scanner def _char(client: HaMgmtClient) -> BleakGATTCharacteristic: """Return the discovered 0x2A37 characteristic (asserts it was discovered).""" assert client.services is not None char = client.services.get_characteristic(_CHAR_UUID) assert char is not None return char # -- connect / discover --------------------------------------------------- async def test_connect_discovers_services() -> None: """Connect opens the channel, exchanges MTU, and builds the GATT tree.""" holder: dict[str, FakeTransport] = {} client, scanner = await _connect(holder) assert client.is_connected is True assert client.mtu_size == 247 assert scanner.connecting_calls == 1 services = client.services assert services is not None assert services.get_service(_SERVICE_UUID) is not None char = _char(client) assert char.handle == 0x0003 # value handle drives operations assert "notify" in char.properties assert char.get_descriptor(_CCCD_DESC_UUID) is not None assert char.max_write_without_response_size == 244 # MTU 247 - 3 async def test_connect_rejects_when_already_connected() -> None: """A second connect on a live client raises rather than re-linking.""" holder: dict[str, FakeTransport] = {} client, _scanner = await _connect(holder) with pytest.raises(BleakError, match="already connected"): await client.connect(False) async def test_connect_pair_true_without_mgmt_skips_bond() -> None: """connect(pair=True) with no mgmt wired still connects, skipping the bond.""" holder: dict[str, FakeTransport] = {} client, _scanner = _make_client() # no mgmt wired with _patch_transport(make_responder(), holder): await client.connect(True) assert client.is_connected is True async def test_connect_failure_tears_down() -> None: """A transport failure during connect leaves the client disconnected.""" client, _scanner = _make_client() async def boom(**_kwargs: object) -> FakeTransport: raise OSError(113, "No route to host") with ( patch("habluetooth.client_mgmt.L2CAPSocket.create_connection", boom), pytest.raises(OSError, match="No route to host"), ): await client.connect(False) assert client.is_connected is False assert client.mtu_size == 23 # -- GATT operations ------------------------------------------------------ async def test_read_gatt_char() -> None: """Reading a characteristic returns the value as a bytearray.""" holder: dict[str, FakeTransport] = {} client, _scanner = await _connect(holder) assert await client.read_gatt_char(_char(client)) == bytearray(_VALUE) async def test_read_gatt_descriptor() -> None: """Reading a descriptor returns the value as a bytearray.""" holder: dict[str, FakeTransport] = {} client, _scanner = await _connect(holder) cccd = _char(client).get_descriptor(_CCCD_DESC_UUID) assert cccd is not None assert await client.read_gatt_descriptor(cccd) == bytearray(_VALUE) async def test_write_gatt_char_with_response() -> None: """A write with response uses a Write Request.""" holder: dict[str, FakeTransport] = {} client, _scanner = await _connect(holder) await client.write_gatt_char(_char(client), b"\xaa", response=True) assert holder["transport"].sent[-1] == b"\x12\x03\x00\xaa" # WRITE_REQ async def test_write_gatt_char_without_response() -> None: """A write without response uses a Write Command (no reply expected).""" holder: dict[str, FakeTransport] = {} client, _scanner = await _connect(holder) await client.write_gatt_char(_char(client), b"\xbb", response=False) assert holder["transport"].sent[-1] == b"\x52\x03\x00\xbb" # WRITE_CMD async def test_write_gatt_descriptor() -> None: """Writing a descriptor uses a Write Request against its handle.""" holder: dict[str, FakeTransport] = {} client, _scanner = await _connect(holder) cccd = _char(client).get_descriptor(_CCCD_DESC_UUID) assert cccd is not None await client.write_gatt_descriptor(cccd, b"\x01\x00") assert holder["transport"].sent[-1] == b"\x12\x04\x00\x01\x00" # -- security escalation -------------------------------------------------- async def test_read_escalates_security_then_succeeds() -> None: """A read rejected for encryption raises link security and retries once.""" holder: dict[str, FakeTransport] = {} base = make_responder() attempts = 0 def responder(req: bytes) -> bytes | None: nonlocal attempts if req[0] == 0x0A: # READ_REQ attempts += 1 if attempts == 1: handle = int.from_bytes(req[1:3], "little") return _error(0x0A, handle, ATT_ERR_INSUFFICIENT_ENCRYPTION) return base(req) client, _scanner = await _connect(holder, responder) assert await client.read_gatt_char(_char(client)) == bytearray(_VALUE) assert holder["transport"].security_requests == [BT_SECURITY_MEDIUM] assert holder["transport"].security_level == BT_SECURITY_MEDIUM async def test_escalate_security_steps_up_on_authentication() -> None: """Insufficient authentication steps the level up one, capped at HIGH.""" client, _scanner = _make_client() transport = FakeTransport(make_responder(), lambda _d: None, lambda _e: None) transport.security_level = BT_SECURITY_MEDIUM client._sock = transport # type: ignore[assignment] assert client._escalate_security(ATT_ERR_INSUFFICIENT_AUTHENTICATION) is True assert transport.security_level == BT_SECURITY_HIGH # The kernel exposes nothing above HIGH, so a further step declines. assert client._escalate_security(ATT_ERR_INSUFFICIENT_AUTHENTICATION) is False assert transport.security_level == BT_SECURITY_HIGH async def test_escalate_security_declines_when_already_encrypted() -> None: """An encryption error at MEDIUM cannot be satisfied by encryption again.""" client, _scanner = _make_client() transport = FakeTransport(make_responder(), lambda _d: None, lambda _e: None) transport.security_level = BT_SECURITY_MEDIUM client._sock = transport # type: ignore[assignment] assert client._escalate_security(ATT_ERR_INSUFFICIENT_ENCRYPTION) is False assert transport.security_level == BT_SECURITY_MEDIUM # -- notifications -------------------------------------------------------- async def test_start_notify_writes_cccd_and_routes() -> None: """start_notify writes the notify CCCD and delivers inbound notifications.""" holder: dict[str, FakeTransport] = {} client, _scanner = await _connect(holder) received: list[bytearray] = [] await client.start_notify(_char(client), received.append) assert holder["transport"].sent[-1] == b"\x12\x04\x00\x01\x00" # CCCD notify holder["transport"].inject(bytes([_NTF]) + (0x0003).to_bytes(2, "little") + b"\x09") assert received == [bytearray(b"\x09")] async def test_start_notify_uses_indicate_when_only_indicate() -> None: """An indicate-only characteristic gets the indicate CCCD value.""" holder: dict[str, FakeTransport] = {} client, _scanner = await _connect(holder, make_responder(0x20)) # indicate only await client.start_notify(_char(client), lambda _d: None) assert holder["transport"].sent[-1] == b"\x12\x04\x00\x02\x00" # CCCD indicate async def test_start_notify_rejects_unsupported_characteristic() -> None: """A characteristic without notify/indicate cannot be subscribed.""" holder: dict[str, FakeTransport] = {} client, _scanner = await _connect(holder, make_responder(0x02)) # read only with pytest.raises(BleakError, match="notify or indicate"): await client.start_notify(_char(client), lambda _d: None) async def test_start_notify_rejects_missing_cccd() -> None: """A notifying characteristic with no CCCD descriptor is rejected.""" holder: dict[str, FakeTransport] = {} client, _scanner = await _connect(holder, make_responder(has_cccd=False)) with pytest.raises(BleakError, match="client configuration descriptor"): await client.start_notify(_char(client), lambda _d: None) async def test_stop_notify_clears_cccd_and_handler() -> None: """stop_notify clears the CCCD and stops routing notifications.""" holder: dict[str, FakeTransport] = {} client, _scanner = await _connect(holder) char = _char(client) received: list[bytearray] = [] await client.start_notify(char, received.append) await client.stop_notify(char) assert holder["transport"].sent[-1] == b"\x12\x04\x00\x00\x00" # CCCD off holder["transport"].inject(bytes([_NTF]) + (0x0003).to_bytes(2, "little") + b"\x09") assert received == [] # handler removed, notification dropped async def test_start_notify_unwinds_handler_on_cccd_write_failure() -> None: """If the CCCD write fails, no notify handler is left registered.""" holder: dict[str, FakeTransport] = {} client, _scanner = await _connect(holder, make_responder(fail_write=True)) char = _char(client) received: list[bytearray] = [] with pytest.raises(BleakError): await client.start_notify(char, received.append) # The handler was unwound, so a stray notification is not delivered. holder["transport"].inject(bytes([_NTF]) + (0x0003).to_bytes(2, "little") + b"\x09") assert received == [] async def test_stop_notify_drops_handler_when_cccd_write_fails() -> None: """A failed CCCD-off write still drops the handler, then surfaces the error.""" holder: dict[str, FakeTransport] = {} fail = {"on": False} base = make_responder() def responder(req: bytes) -> bytes | None: if req[0] == 0x12 and fail["on"]: # WRITE_REQ return _error(0x12, int.from_bytes(req[1:3], "little"), 0x03) return base(req) client, _scanner = await _connect(holder, responder) char = _char(client) received: list[bytearray] = [] await client.start_notify(char, received.append) ntf = bytes([_NTF]) + (0x0003).to_bytes(2, "little") + b"\x09" holder["transport"].inject(ntf) assert received == [bytearray(b"\x09")] # handler is live fail["on"] = True with pytest.raises(BleakError): await client.stop_notify(char) holder["transport"].inject(ntf) assert received == [bytearray(b"\x09")] # handler dropped despite the failure async def test_stop_notify_without_cccd_only_drops_handler() -> None: """stop_notify on a characteristic with no CCCD just drops the handler.""" holder: dict[str, FakeTransport] = {} client, _scanner = await _connect(holder, make_responder(has_cccd=False)) char = _char(client) before = len(holder["transport"].sent) await client.stop_notify(char) assert len(holder["transport"].sent) == before # no CCCD write attempted # -- disconnect / teardown ------------------------------------------------ async def test_disconnect_closes_transport() -> None: """Disconnect closes the socket and reports not connected.""" holder: dict[str, FakeTransport] = {} client, _scanner = await _connect(holder) await client.disconnect() assert holder["transport"].closed is True assert client.is_connected is False async def test_connection_register_callbacks_fire() -> None: """The client reports connect/disconnect to the scanner's slot callbacks.""" holder: dict[str, FakeTransport] = {} registered: list[str] = [] unregistered: list[str] = [] client = HaMgmtClient( _ble_device(), client_data=MgmtClientData( adapter_address=_ADAPTER, scanner=FakeScanner(), register_connection=registered.append, unregister_connection=unregistered.append, ), timeout=5.0, ) with _patch_transport(make_responder(), holder): await client.connect(False) assert registered == [_PEER] assert unregistered == [] await client.disconnect() assert unregistered == [_PEER] async def test_register_callback_failure_does_not_break_connect() -> None: """A raising slot register callback is swallowed; the connection survives.""" holder: dict[str, FakeTransport] = {} def boom(_address: str) -> None: msg = "bookkeeping broke" raise RuntimeError(msg) client = HaMgmtClient( _ble_device(), client_data=MgmtClientData( adapter_address=_ADAPTER, scanner=FakeScanner(), register_connection=boom, ), timeout=5.0, ) with _patch_transport(make_responder(), holder): await client.connect(False) assert client.is_connected is True async def test_unregister_callback_failure_still_fires_disconnect() -> None: """A raising slot unregister callback does not block the disconnect callback.""" holder: dict[str, FakeTransport] = {} fired: list[bool] = [] def boom(_address: str) -> None: msg = "bookkeeping broke" raise RuntimeError(msg) client = HaMgmtClient( _ble_device(), client_data=MgmtClientData( adapter_address=_ADAPTER, scanner=FakeScanner(), unregister_connection=boom, ), timeout=5.0, disconnected_callback=lambda: fired.append(True), ) with _patch_transport(make_responder(), holder): await client.connect(False) holder["transport"].drop(OSError("reset")) assert fired == [True] assert client.is_connected is False async def test_disconnect_fires_disconnected_callback() -> None: """A deliberate disconnect fires the callback, matching the BlueZ backend.""" holder: dict[str, FakeTransport] = {} fired: list[bool] = [] client, _scanner = await _connect( holder, disconnected_callback=lambda: fired.append(True) ) await client.disconnect() assert fired == [True] async def test_unexpected_drop_fires_disconnected_callback() -> None: """A transport drop tears down the client and fires the bleak callback.""" holder: dict[str, FakeTransport] = {} fired: list[bool] = [] client, _scanner = await _connect( holder, disconnected_callback=lambda: fired.append(True) ) holder["transport"].drop(OSError("reset")) assert fired == [True] assert client.is_connected is False async def test_unexpected_drop_logs_cause( caplog: pytest.LogCaptureFixture, ) -> None: """An unexpected drop logs the originating cause for diagnosability.""" holder: dict[str, FakeTransport] = {} _client, _scanner = await _connect(holder) with caplog.at_level(logging.DEBUG, logger="habluetooth.client_mgmt"): holder["transport"].drop(OSError("connection reset by peer")) assert "channel lost" in caplog.text assert "connection reset by peer" in caplog.text async def test_operations_raise_after_disconnect() -> None: """GATT operations on a disconnected client raise a BleakError.""" holder: dict[str, FakeTransport] = {} client, _scanner = await _connect(holder) char = _char(client) await client.disconnect() with pytest.raises(BleakError, match="not connected"): await client.read_gatt_char(char) class FakeMgmt: """Records the pairing/bond mgmt commands the client issues.""" def __init__( self, *, pair_ok: bool = True, load_ok: bool = True, unpair_ok: bool = True ) -> None: self.pair_ok = pair_ok self.load_ok = load_ok self.unpair_ok = unpair_ok self.paired: list[tuple[int, str, int]] = [] self.unpaired: list[tuple[int, str]] = [] self.loaded: list[tuple[int, list[LongTermKey]]] = [] self.confirmations: list[tuple[int, str, int, bool]] = [] self.confirm_raises = False self.event_to_emit: object | None = None # delivered during pair_device self._handlers: dict[tuple[int, str], Callable[[object], None]] = {} async def user_confirmation_reply( self, idx: int, address: str, address_type: int, *, accept: bool = True ) -> bool: if self.confirm_raises: msg = "reply failed" raise OSError(msg) self.confirmations.append((idx, address, address_type, accept)) return True def register_pairing_handler( self, idx: int, address: str, handler: Callable[[object], None] ) -> Callable[[], None]: self._handlers[(idx, address)] = handler def _unregister() -> None: self._handlers.pop((idx, address), None) return _unregister async def pair_device( self, idx: int, address: str, address_type: int, *_a: object ) -> bool: self.paired.append((idx, address, address_type)) if self.event_to_emit is not None: # Simulate the kernel pushing a pairing event during pairing. self._handlers[(idx, address)](self.event_to_emit) return self.pair_ok async def unpair_device( self, idx: int, address: str, address_type: int, **_k: object ) -> bool: self.unpaired.append((idx, address)) return self.unpair_ok async def load_long_term_keys(self, idx: int, keys: list[LongTermKey]) -> bool: self.loaded.append((idx, list(keys))) return self.load_ok class _Store: """An in-memory per-adapter long-term-key store for tests.""" def __init__(self, keys: list[LongTermKey] | None = None) -> None: self.keys: list[LongTermKey] = list(keys or []) def all(self) -> list[LongTermKey]: return list(self.keys) def add(self, key: LongTermKey) -> None: self.keys.append(key) def forget(self, address: str) -> None: self.keys = [key for key in self.keys if key.address != address] def _ltk(address: str = _PEER) -> LongTermKey: return LongTermKey( address, BDADDR_LE_RANDOM, 0x05, False, 16, 0x1234, bytes(8), bytes(16) ) def _make_pairing_client( mgmt: FakeMgmt, store: _Store | None, *, adapter_idx: int | None = 0, ) -> HaMgmtClient: return HaMgmtClient( _ble_device(), client_data=MgmtClientData( adapter_address=_ADAPTER, scanner=FakeScanner(), adapter_idx=adapter_idx, mgmt=mgmt, # type: ignore[arg-type] get_long_term_keys=store.all if store else None, add_long_term_key=store.add if store else None, forget_long_term_keys=store.forget if store else None, ), timeout=5.0, ) async def test_pair_captures_long_term_key() -> None: """pair() issues PAIR_DEVICE and persists the captured key.""" mgmt = FakeMgmt() mgmt.event_to_emit = NewLongTermKey(1, _ltk()) store = _Store() client = _make_pairing_client(mgmt, store) await client.pair() assert mgmt.paired == [(0, _PEER, BDADDR_LE_RANDOM)] assert store.keys == [_ltk()] async def test_pair_ignores_zero_store_hint() -> None: """A key the kernel asks not to persist (store_hint 0) is not stored.""" mgmt = FakeMgmt() mgmt.event_to_emit = NewLongTermKey(0, _ltk()) store = _Store() client = _make_pairing_client(mgmt, store) await client.pair() assert store.keys == [] async def test_pair_ignores_non_key_event() -> None: """An AUTHENTICATION_FAILED event during pairing stores no key.""" mgmt = FakeMgmt(pair_ok=False) mgmt.event_to_emit = AuthenticationFailed(_PEER, BDADDR_LE_RANDOM, 0x05) store = _Store() client = _make_pairing_client(mgmt, store) with pytest.raises(BleakError, match="pairing failed"): await client.pair() assert store.keys == [] async def test_pair_warns_when_no_store_wired( caplog: pytest.LogCaptureFixture, ) -> None: """A captured key with no store wired is logged, not silently dropped.""" mgmt = FakeMgmt() mgmt.event_to_emit = NewLongTermKey(1, _ltk()) client = _make_pairing_client(mgmt, None) with caplog.at_level("WARNING", logger="habluetooth.client_mgmt"): await client.pair() assert "no key store" in caplog.text async def test_pair_failure_raises() -> None: """A failed PAIR_DEVICE raises and captures no key.""" mgmt = FakeMgmt(pair_ok=False) store = _Store() client = _make_pairing_client(mgmt, store) with pytest.raises(BleakError, match="pairing failed"): await client.pair() assert store.keys == [] async def test_pair_requires_mgmt() -> None: """Without the mgmt socket, pairing raises a clear error.""" client, _scanner = _make_client() # no mgmt/adapter_idx wired with pytest.raises(BleakError, match="requires the management socket"): await client.pair() with pytest.raises(BleakError, match="requires the management socket"): await client.unpair() async def test_pair_auto_confirms_just_works() -> None: """A just-works confirm (confirm_hint set) is accepted so the bond proceeds.""" mgmt = FakeMgmt() mgmt.event_to_emit = UserConfirmationRequest(_PEER, BDADDR_LE_RANDOM, 1, 0) client = _make_pairing_client(mgmt, _Store()) await client.pair() # Let the scheduled reply task run. await asyncio.gather(*client._pairing_tasks) assert mgmt.confirmations == [(0, _PEER, BDADDR_LE_RANDOM, True)] async def test_pair_rejects_numeric_comparison() -> None: """A real numeric comparison (confirm_hint 0) is rejected, not blindly confirmed.""" mgmt = FakeMgmt() mgmt.event_to_emit = UserConfirmationRequest(_PEER, BDADDR_LE_RANDOM, 0, 123456) client = _make_pairing_client(mgmt, _Store()) await client.pair() await asyncio.gather(*client._pairing_tasks) assert mgmt.confirmations == [(0, _PEER, BDADDR_LE_RANDOM, False)] async def test_pair_logs_failed_confirmation_reply( caplog: pytest.LogCaptureFixture, ) -> None: """A confirmation reply that fails to send is logged, not lost.""" mgmt = FakeMgmt() mgmt.confirm_raises = True mgmt.event_to_emit = UserConfirmationRequest(_PEER, BDADDR_LE_RANDOM, 0, 123456) client = _make_pairing_client(mgmt, _Store()) with caplog.at_level(logging.DEBUG, logger="habluetooth.client_mgmt"): await client.pair() await asyncio.gather(*client._pairing_tasks, return_exceptions=True) assert "pairing reply task failed" in caplog.text async def test_pair_ignores_passkey_request() -> None: """A passkey request, unsatisfiable with NoInputNoOutput, is simply ignored.""" mgmt = FakeMgmt() mgmt.event_to_emit = UserPasskeyRequest(_PEER, BDADDR_LE_RANDOM) client = _make_pairing_client(mgmt, _Store()) await client.pair() # completes; the event is not acted on assert mgmt.confirmations == [] async def test_pair_fails_fast_on_auth_failed() -> None: """An AUTH_FAILED event ends pairing before the mgmt command resolves.""" class _BlockingPairMgmt: """A mgmt stand-in whose pair_device blocks until released.""" def __init__(self) -> None: self.started = asyncio.Event() self._release = asyncio.Event() self._handlers: dict[tuple[int, str], Callable[[object], None]] = {} def register_pairing_handler( self, idx: int, address: str, handler: Callable[[object], None] ) -> Callable[[], None]: self._handlers[(idx, address)] = handler def _unregister() -> None: self._handlers.pop((idx, address), None) return _unregister async def pair_device( self, idx: int, address: str, address_type: int, *_a: object ) -> bool: self.started.set() await self._release.wait() # never released in this test return True def deliver(self, idx: int, address: str, event: object) -> None: self._handlers[(idx, address)](event) mgmt = _BlockingPairMgmt() client = _make_pairing_client(mgmt, _Store()) # type: ignore[arg-type] task = asyncio.create_task(client.pair()) await mgmt.started.wait() mgmt.deliver(0, _PEER, AuthenticationFailed(_PEER, BDADDR_LE_RANDOM, 0x05)) with pytest.raises(BleakError, match="auth status 0x05"): await task async def test_connect_pair_true_bonds_when_mgmt_wired() -> None: """connect(pair=True) with mgmt wired bonds as part of connecting.""" holder: dict[str, FakeTransport] = {} mgmt = FakeMgmt() mgmt.event_to_emit = NewLongTermKey(1, _ltk()) store = _Store() client = _make_pairing_client(mgmt, store) with _patch_transport(make_responder(), holder): await client.connect(True) assert client.is_connected is True assert mgmt.paired == [(0, _PEER, BDADDR_LE_RANDOM)] assert store.keys == [_ltk()] async def test_connect_pair_true_skips_rebond_when_already_bonded() -> None: """connect(pair=True) on an already-bonded peer does not re-pair.""" holder: dict[str, FakeTransport] = {} mgmt = FakeMgmt() store = _Store([_ltk()]) # already bonded client = _make_pairing_client(mgmt, store) with _patch_transport(make_responder(), holder): await client.connect(True) assert client.is_connected is True assert mgmt.paired == [] # no redundant PAIR_DEVICE that would fail the connect async def test_connect_encrypts_proactively_for_bonded_peer() -> None: """A reconnect with a stored key opens the socket at MEDIUM up front.""" captured: dict[str, int] = {} mgmt = FakeMgmt() store = _Store([_ltk()]) # a bond already exists for this peer client = _make_pairing_client(mgmt, store) base = make_responder() async def fake_create_connection( *, on_data: Callable[[bytes], None], on_close: Callable[[Exception | None], None], security_level: int, **_kwargs: object, ) -> FakeTransport: captured["security_level"] = security_level transport = FakeTransport(base, on_data, on_close) transport.security_level = security_level return transport with patch( "habluetooth.client_mgmt.L2CAPSocket.create_connection", fake_create_connection ): await client.connect(False) assert captured["security_level"] == BT_SECURITY_MEDIUM async def test_connect_unbonded_opens_low_security() -> None: """With no stored key, the socket opens at LOW and escalates on demand.""" captured: dict[str, int] = {} mgmt = FakeMgmt() client = _make_pairing_client(mgmt, _Store()) # empty store base = make_responder() async def fake_create_connection( *, on_data: Callable[[bytes], None], on_close: Callable[[Exception | None], None], security_level: int, **_kwargs: object, ) -> FakeTransport: captured["security_level"] = security_level return FakeTransport(base, on_data, on_close) with patch( "habluetooth.client_mgmt.L2CAPSocket.create_connection", fake_create_connection ): await client.connect(False) assert captured["security_level"] == BT_SECURITY_LOW async def test_unpair_removes_bond() -> None: """unpair() issues UNPAIR_DEVICE and forgets the stored key.""" mgmt = FakeMgmt() store = _Store([_ltk()]) client = _make_pairing_client(mgmt, store) await client.unpair() assert mgmt.unpaired == [(0, _PEER)] assert store.keys == [] async def test_unpair_without_store() -> None: """unpair() works when no key store was wired.""" mgmt = FakeMgmt() client = _make_pairing_client(mgmt, None) await client.unpair() assert mgmt.unpaired == [(0, _PEER)] async def test_unpair_failure_keeps_stored_key() -> None: """A failed UNPAIR_DEVICE raises and leaves the stored key in place.""" mgmt = FakeMgmt(unpair_ok=False) store = _Store([_ltk()]) client = _make_pairing_client(mgmt, store) with pytest.raises(BleakError, match="unpair failed"): await client.unpair() assert store.keys == [_ltk()] # not desynced from the controller async def test_connect_restores_all_adapter_bonds() -> None: """connect() reloads every stored key (LOAD replaces the whole list).""" holder: dict[str, FakeTransport] = {} mgmt = FakeMgmt() other = LongTermKey( "11:22:33:44:55:66", BDADDR_LE_RANDOM, 0x05, True, 16, 0x9999, bytes(8), bytes(16), ) store = _Store([_ltk(), other]) client = _make_pairing_client(mgmt, store) with _patch_transport(make_responder(), holder): await client.connect(False) # All keys are sent, not just the connecting peer's, so other bonds survive. assert mgmt.loaded == [(0, [_ltk(), other])] async def test_connect_warns_when_restore_fails( caplog: pytest.LogCaptureFixture, ) -> None: """A failed key restore is logged rather than silently proceeding.""" holder: dict[str, FakeTransport] = {} mgmt = FakeMgmt(load_ok=False) store = _Store([_ltk()]) client = _make_pairing_client(mgmt, store) with ( _patch_transport(make_responder(), holder), caplog.at_level("WARNING", logger="habluetooth.client_mgmt"), ): await client.connect(False) assert "failed to restore" in caplog.text async def test_connect_without_bond_loads_nothing() -> None: """connect() does not load keys when nothing is bonded on the adapter.""" holder: dict[str, FakeTransport] = {} mgmt = FakeMgmt() client = _make_pairing_client(mgmt, _Store()) with _patch_transport(make_responder(), holder): await client.connect(False) assert mgmt.loaded == [] async def test_mtu_size_default_before_connect() -> None: """Before connect the MTU reports the ATT default.""" client, _scanner = _make_client() assert client.mtu_size == 23 async def test_address_type_falls_back_to_public_when_missing() -> None: """A device without an address_type in details defaults to LE public.""" scanner = FakeScanner() device = BLEDevice(_PEER, "test-device", {"source": _ADAPTER}) # no address_type client = HaMgmtClient( device, client_data=MgmtClientData(adapter_address=_ADAPTER, scanner=scanner), timeout=5.0, ) assert client._address_type == 0x01 # BDADDR_LE_PUBLIC Bluetooth-Devices-habluetooth-1390f03/tests/test_init.py000066400000000000000000000407771522222566700233700ustar00rootroot00000000000000from 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-1390f03/tests/test_manager.py000066400000000000000000005055341522222566700240340ustar00rootroot00000000000000"""Tests for the manager.""" import asyncio import logging import time from collections.abc import Callable, 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 bluetooth_data_tools import monotonic_time_coarse 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 habluetooth.const import RESCUE_SCAN_ACCEPT_SECONDS 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() _PASSIVE_WARNING = "is in passive-only mode" @pytest.mark.asyncio async def test_active_scan_warns_about_existing_passive_scanner( caplog: pytest.LogCaptureFixture, ) -> None: """Registering an active scan warns when a passive-only scanner exists.""" manager = get_manager() passive = FakeScanner( "AA:BB:CC:DD:EE:01", "hci1", requested_mode=BluetoothScanningMode.PASSIVE ) cancel_scanner = manager.async_register_scanner(passive) try: with caplog.at_level(logging.WARNING): cancel_scan = manager.async_register_active_scan("AA:BB:CC:DD:EE:99") assert _PASSIVE_WARNING in caplog.text assert passive.name in caplog.text finally: cancel_scan() cancel_scanner() @pytest.mark.asyncio async def test_scanner_going_passive_after_active_scan_warns( caplog: pytest.LogCaptureFixture, ) -> None: """A scanner becoming passive while active scans exist warns once.""" manager = get_manager() cancel_scan = manager.async_register_active_scan("AA:BB:CC:DD:EE:99") auto = FakeScanner( "AA:BB:CC:DD:EE:02", "hci2", requested_mode=BluetoothScanningMode.AUTO ) cancel_scanner = manager.async_register_scanner(auto) try: with caplog.at_level(logging.WARNING): # Current-mode toggles on an AUTO scanner must not warn. auto.set_current_mode(BluetoothScanningMode.ACTIVE) assert _PASSIVE_WARNING not in caplog.text # Flipping the requested mode to passive warns. auto.set_requested_mode(BluetoothScanningMode.PASSIVE) assert caplog.text.count(_PASSIVE_WARNING) == 1 assert auto.name in caplog.text finally: cancel_scanner() cancel_scan() @pytest.mark.asyncio async def test_passive_scanner_warning_dedupes_per_source( caplog: pytest.LogCaptureFixture, ) -> None: """One passive scanner warns once across many active-scan registrations.""" manager = get_manager() passive = FakeScanner( "AA:BB:CC:DD:EE:03", "hci3", requested_mode=BluetoothScanningMode.PASSIVE ) cancel_scanner = manager.async_register_scanner(passive) cancels: list[Callable[[], None]] = [] try: with caplog.at_level(logging.WARNING): cancels.extend( manager.async_register_active_scan(addr) for addr in ("AA:BB:CC:DD:EE:91", "AA:BB:CC:DD:EE:92") ) assert caplog.text.count(_PASSIVE_WARNING) == 1 finally: for cancel in cancels: cancel() cancel_scanner() @pytest.mark.asyncio async def test_active_scan_no_warning_for_auto_or_active_scanner( caplog: pytest.LogCaptureFixture, ) -> None: """No warning when the only scanners are auto or active.""" manager = get_manager() auto = FakeScanner( "AA:BB:CC:DD:EE:04", "hci4", requested_mode=BluetoothScanningMode.AUTO ) active = FakeScanner( "AA:BB:CC:DD:EE:05", "hci5", requested_mode=BluetoothScanningMode.ACTIVE ) cancel_auto = manager.async_register_scanner(auto) cancel_active = manager.async_register_scanner(active) try: with caplog.at_level(logging.WARNING): cancel_scan = manager.async_register_active_scan("AA:BB:CC:DD:EE:99") assert _PASSIVE_WARNING not in caplog.text finally: cancel_scan() cancel_auto() cancel_active() @pytest.mark.asyncio async def test_passive_scanner_warning_resets_after_unregister( caplog: pytest.LogCaptureFixture, ) -> None: """Re-registering a passive scanner warns again after it is unregistered.""" manager = get_manager() cancel_scan = manager.async_register_active_scan("AA:BB:CC:DD:EE:99") try: with caplog.at_level(logging.WARNING): cancel_scanner = manager.async_register_scanner( FakeScanner( "AA:BB:CC:DD:EE:06", "hci6", requested_mode=BluetoothScanningMode.PASSIVE, ) ) assert caplog.text.count(_PASSIVE_WARNING) == 1 cancel_scanner() caplog.clear() with caplog.at_level(logging.WARNING): cancel_scanner2 = manager.async_register_scanner( FakeScanner( "AA:BB:CC:DD:EE:06", "hci6", requested_mode=BluetoothScanningMode.PASSIVE, ) ) assert caplog.text.count(_PASSIVE_WARNING) == 1 cancel_scanner2() finally: cancel_scan() @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, ) # One missed interval is a per-scanner reception lottery: still hci0. 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 + 23, HCI1_SOURCE_ADDRESS, ) # Past the roam gate (1.5 stale windows): 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_stale_does_not_switch_to_much_weaker_scanner( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """ A far weaker scanner must not steal a strong owner on a single stale interval. Regression for #568: scan-response-only sensors behind many active proxies oscillated because a weaker proxy holding a stale capture won the receive-time staleness check on a single missed interval. This device has a registered active-scan need, so the rescue flow governs; a materially weaker passive challenger instead waits for durably-gone (see test_stale_passive_weaker_scanner_waits_for_durably_gone). """ address = "44:44:33:11:23:42" start = 50.0 cancel_active = get_manager().async_register_active_scan(address) strong = generate_ble_device(address, "strong_hci0") strong_adv = generate_advertisement_data( local_name="strong_hci0", service_uuids=[], rssi=-46 ) inject_advertisement_with_time_and_source( strong, strong_adv, start, HCI0_SOURCE_ADDRESS ) get_manager().async_set_fallback_availability_interval(address, 10) # stale=15 weak = generate_ble_device(address, "weak_hci1") weak_adv = generate_advertisement_data( local_name="weak_hci1", service_uuids=[], rssi=-90 ) # Just past the normal stale window: a 44 dB weaker scanner must NOT win. inject_advertisement_with_time_and_source( weak, weak_adv, start + 10 + TRACKER_BUFFERING_WOBBLE_SECONDS + 1, HCI1_SOURCE_ADDRESS, ) assert get_manager().async_ble_device_from_address(address, True) is strong cancel_active() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_stale_switches_to_weaker_scanner_once_durably_gone( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """ A device that moved into weak-only coverage still hands off after a longer wait. The device needs active scans, so the weaker scanner is held off on a single stale interval but takes over once the owner has been silent for stale_seconds * DURABLY_GONE_STALE_FACTOR. """ address = "44:44:33:11:23:43" start = 50.0 cancel_active = get_manager().async_register_active_scan(address) strong = generate_ble_device(address, "strong_hci0") strong_adv = generate_advertisement_data( local_name="strong_hci0", service_uuids=[], rssi=-46 ) inject_advertisement_with_time_and_source( strong, strong_adv, start, HCI0_SOURCE_ADDRESS ) # stale_seconds = 15, durably-gone = 15 * 2.5 = 37.5 get_manager().async_set_fallback_availability_interval(address, 10) weak = generate_ble_device(address, "weak_hci1") weak_adv = generate_advertisement_data( local_name="weak_hci1", service_uuids=[], rssi=-90 ) # Within the durably-gone window: still the strong owner. inject_advertisement_with_time_and_source( weak, weak_adv, start + 30, HCI1_SOURCE_ADDRESS ) assert get_manager().async_ble_device_from_address(address, True) is strong # Past the durably-gone window: the weak scanner finally takes over. inject_advertisement_with_time_and_source( weak, weak_adv, start + 40, HCI1_SOURCE_ADDRESS ) assert get_manager().async_ble_device_from_address(address, True) is weak cancel_active() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio @pytest.mark.parametrize("debug", [True, False]) async def test_stale_switches_to_comparable_scanner_at_normal_window( register_hci0_scanner: None, register_hci1_scanner: None, debug: bool, ) -> None: """ A comparable scanner takes over a weak passive owner after the roam gate. The owner is weak (below STRONG_OWNER_STALE_RSSI), so its silence is treated as the device possibly being gone, but a single missed interval is a per-scanner reception lottery; the comparable scanner may only take over once STALE_ROAM_FACTOR stale windows elapsed. A strong owner is protected regardless (see test_stale_keeps_strong_owner_against_comparable_scanner). Runs with debug logging both on and off to cover the log branches. """ address = "44:44:33:11:23:44" start = 50.0 get_manager()._debug = debug owner = generate_ble_device(address, "owner_hci0") owner_adv = generate_advertisement_data( local_name="owner_hci0", service_uuids=[], rssi=-90 ) inject_advertisement_with_time_and_source( owner, owner_adv, start, HCI0_SOURCE_ADDRESS ) get_manager().async_set_fallback_availability_interval(address, 10) # Weak owner, comparable challenger: kept on a single missed interval # (a per-scanner reception miss must not roam a stationary device). comparable = generate_ble_device(address, "comparable_hci1") comparable_adv = generate_advertisement_data( local_name="comparable_hci1", service_uuids=[], rssi=-95 ) inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 10 + TRACKER_BUFFERING_WOBBLE_SECONDS + 1, HCI1_SOURCE_ADDRESS, ) assert get_manager().async_ble_device_from_address(address, True) is owner # Past STALE_ROAM_FACTOR stale windows (1.5 * 15 = 22.5): the ordinary # roaming handoff proceeds. inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 23, HCI1_SOURCE_ADDRESS ) assert get_manager().async_ble_device_from_address(address, True) is comparable @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_stale_keeps_strong_owner_against_comparable_scanner( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """ A strong/close owner is not stolen by a comparable scanner on a stale interval. Regression for the stationary-device flap: a strong owner (>= STRONG_OWNER_STALE_RSSI) that briefly goes quiet is almost certainly still there, so for a device that needs active scans a merely-comparable scanner must wait for durable absence (or a completed rescue window) rather than steal on one missed interval. """ address = "44:44:33:11:23:47" start = 50.0 cancel_active = get_manager().async_register_active_scan(address) strong = generate_ble_device(address, "strong_hci0") strong_adv = generate_advertisement_data( local_name="strong_hci0", service_uuids=[], rssi=-50 ) inject_advertisement_with_time_and_source( strong, strong_adv, start, HCI0_SOURCE_ADDRESS ) get_manager().async_set_fallback_availability_interval(address, 10) # stale=15 comparable = generate_ble_device(address, "comparable_hci1") comparable_adv = generate_advertisement_data( local_name="comparable_hci1", service_uuids=[], rssi=-60 ) # Just past the normal stale window: a comparable scanner must NOT steal # the strong owner. inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 10 + TRACKER_BUFFERING_WOBBLE_SECONDS + 1, HCI1_SOURCE_ADDRESS, ) assert get_manager().async_ble_device_from_address(address, True) is strong cancel_active() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_stale_strong_owner_yields_to_comparable_when_durably_gone( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """A strong owner that is durably silent still hands off to a comparable scanner.""" address = "44:44:33:11:23:48" start = 50.0 cancel_active = get_manager().async_register_active_scan(address) strong = generate_ble_device(address, "strong_hci0") strong_adv = generate_advertisement_data( local_name="strong_hci0", service_uuids=[], rssi=-50 ) inject_advertisement_with_time_and_source( strong, strong_adv, start, HCI0_SOURCE_ADDRESS ) # stale_seconds = 15, durably-gone = 15 * 2.5 = 37.5 get_manager().async_set_fallback_availability_interval(address, 10) comparable = generate_ble_device(address, "comparable_hci1") comparable_adv = generate_advertisement_data( local_name="comparable_hci1", service_uuids=[], rssi=-60 ) # Within the durably-gone window: strong owner kept. inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 30, HCI1_SOURCE_ADDRESS ) assert get_manager().async_ble_device_from_address(address, True) is strong # Past durably-gone: the comparable scanner finally takes over. inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 40, HCI1_SOURCE_ADDRESS ) assert get_manager().async_ble_device_from_address(address, True) is comparable cancel_active() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_stale_strong_owner_yields_to_stronger_scanner( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """A strong owner still yields immediately to a materially stronger scanner.""" address = "44:44:33:11:23:49" start = 50.0 strong = generate_ble_device(address, "strong_hci0") strong_adv = generate_advertisement_data( local_name="strong_hci0", service_uuids=[], rssi=-50 ) inject_advertisement_with_time_and_source( strong, strong_adv, start, HCI0_SOURCE_ADDRESS ) get_manager().async_set_fallback_availability_interval(address, 10) # >16 dB stronger: the RSSI path takes over immediately, strong owner or not. stronger = generate_ble_device(address, "stronger_hci1") stronger_adv = generate_advertisement_data( local_name="stronger_hci1", service_uuids=[], rssi=-20 ) inject_advertisement_with_time_and_source( stronger, stronger_adv, start + 10 + TRACKER_BUFFERING_WOBBLE_SECONDS + 1, HCI1_SOURCE_ADDRESS, ) assert get_manager().async_ble_device_from_address(address, True) is stronger @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_stale_passive_weaker_scanner_waits_for_durably_gone( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """ A far weaker challenger cannot steal a passive device on one missed interval. A hair-past-stale capture by a materially weaker scanner would flap ownership right back on the owner's next advertisement, so it waits for the durably-gone handoff instead. """ address = "44:44:33:11:23:60" start = 50.0 # Exercise the non-debug switch path; the sibling passive test runs # with the test-default debug logging on. get_manager()._debug = False strong = generate_ble_device(address, "strong_hci0") strong_adv = generate_advertisement_data( local_name="strong_hci0", service_uuids=[], rssi=-46 ) inject_advertisement_with_time_and_source( strong, strong_adv, start, HCI0_SOURCE_ADDRESS ) # stale_seconds = 15, durably-gone = 37.5 get_manager().async_set_fallback_availability_interval(address, 10) weak = generate_ble_device(address, "weak_hci1") weak_adv = generate_advertisement_data( local_name="weak_hci1", service_uuids=[], rssi=-90 ) # Just past the normal stale window: the far weaker scanner must NOT # steal ownership on a single missed interval. inject_advertisement_with_time_and_source( weak, weak_adv, start + 10 + TRACKER_BUFFERING_WOBBLE_SECONDS + 1, HCI1_SOURCE_ADDRESS, ) assert get_manager().async_ble_device_from_address(address, True) is strong # Past durably-gone: the weak scanner finally takes over. inject_advertisement_with_time_and_source( weak, weak_adv, start + 40, HCI1_SOURCE_ADDRESS ) assert get_manager().async_ble_device_from_address(address, True) is weak @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_stale_passive_strong_owner_keeps_comparable_until_durably_gone( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """ A comparable challenger cannot steal a passive strong owner on one miss. A comparable pair cannot reclaim on the RSSI path, so a stale steal would ping-pong on alternating missed intervals (issue #580); the challenger waits for the durably-gone handoff instead. """ address = "44:44:33:11:23:61" start = 50.0 strong = generate_ble_device(address, "strong_hci0") strong_adv = generate_advertisement_data( local_name="strong_hci0", service_uuids=[], rssi=-50 ) inject_advertisement_with_time_and_source( strong, strong_adv, start, HCI0_SOURCE_ADDRESS ) # stale_seconds = 15, durably-gone = 37.5 get_manager().async_set_fallback_availability_interval(address, 10) comparable = generate_ble_device(address, "comparable_hci1") comparable_adv = generate_advertisement_data( local_name="comparable_hci1", service_uuids=[], rssi=-60 ) inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 10 + TRACKER_BUFFERING_WOBBLE_SECONDS + 1, HCI1_SOURCE_ADDRESS, ) assert get_manager().async_ble_device_from_address(address, True) is strong # Past durably-gone: the comparable scanner takes over. inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 40, HCI1_SOURCE_ADDRESS ) assert get_manager().async_ble_device_from_address(address, True) is comparable @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio @pytest.mark.parametrize("debug", [True, False]) async def test_stale_active_need_defers_then_switches_after_rescue_window( register_hci0_scanner: None, register_hci1_scanner: None, debug: bool, ) -> None: """ An active-need device defers the stale handoff until a rescue window ran. The first challenger advertisement past the stale window starts a rescue episode (ownership kept); once the rescue's accept time is recorded and the owner stays silent, the next challenger advertisement that postdates it takes ownership. Runs with debug logging both on and off to cover both sides of the logging branches. """ manager = get_manager() manager._debug = debug address = "44:44:33:11:23:62" start = 50.0 cancel_active = manager.async_register_active_scan(address) strong = generate_ble_device(address, "strong_hci0") strong_adv = generate_advertisement_data( local_name="strong_hci0", service_uuids=[], rssi=-50 ) inject_advertisement_with_time_and_source( strong, strong_adv, start, HCI0_SOURCE_ADDRESS ) # stale_seconds = 15, durably-gone = 37.5 manager.async_set_fallback_availability_interval(address, 10) comparable = generate_ble_device(address, "comparable_hci1") comparable_adv = generate_advertisement_data( local_name="comparable_hci1", service_uuids=[], rssi=-60 ) # Past the stale window: deferred, episode started, owner kept. inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 16, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is strong assert manager._rescue_triggered[address] == start + 16 # A rescue window covering the address completed while the owner # stayed silent (the fake scanners cannot run real windows, so the # completion is recorded directly). manager._auto_scheduler._rescue_accept_after[address] = start + 20 # Still before the completion: owner kept. inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 19, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is strong # Past the accept time but within one advertising interval of the # trigger: still deferred (the owner gets a full re-hear cycle). inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 25, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is strong # Past the accept time and one full interval past the trigger: hand off. inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 27, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is comparable assert address not in manager._rescue_triggered cancel_active() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_stale_active_need_no_roaming_shortcut_for_weak_owner( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """ An active-need device never takes the roaming shortcut. A comparable challenger of a weak owner switches instantly for a passive device, but for an active-need device the challenger may not have actively scanned recently, so the handoff defers through the rescue and lands only past the accept time. """ manager = get_manager() address = "44:44:33:11:23:77" start = 50.0 cancel_active = manager.async_register_active_scan(address) weak_owner = generate_ble_device(address, "weak_owner_hci0") weak_owner_adv = generate_advertisement_data( local_name="weak_owner_hci0", service_uuids=[], rssi=-90 ) inject_advertisement_with_time_and_source( weak_owner, weak_owner_adv, start, HCI0_SOURCE_ADDRESS ) # stale_seconds = 15, durably-gone = 37.5 manager.async_set_fallback_availability_interval(address, 10) comparable = generate_ble_device(address, "comparable_hci1") comparable_adv = generate_advertisement_data( local_name="comparable_hci1", service_uuids=[], rssi=-95 ) # Comparable challenger of a weak owner just past stale: deferred, # episode started (no instant roaming handoff). inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 16, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is weak_owner assert manager._rescue_triggered[address] == start + 16 # A rescue window covered the address; the handoff needs the accept # time passed and one full advertising interval since the trigger. manager._auto_scheduler._rescue_accept_after[address] = start + 21 inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 27, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is comparable assert address not in manager._rescue_triggered cancel_active() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_stale_active_need_owner_reheard_invalidates_episode( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """ The owner being heard again invalidates a rescue episode. A completion recorded for an old episode must not authorize an instant switch in a later stale episode; the owner re-hearing the device resets the state so the next episode starts fresh. """ manager = get_manager() address = "44:44:33:11:23:63" start = 50.0 cancel_active = manager.async_register_active_scan(address) strong = generate_ble_device(address, "strong_hci0") strong_adv = generate_advertisement_data( local_name="strong_hci0", service_uuids=[], rssi=-50 ) inject_advertisement_with_time_and_source( strong, strong_adv, start, HCI0_SOURCE_ADDRESS ) manager.async_set_fallback_availability_interval(address, 10) comparable = generate_ble_device(address, "comparable_hci1") comparable_adv = generate_advertisement_data( local_name="comparable_hci1", service_uuids=[], rssi=-60 ) # Episode starts. inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 16, HCI1_SOURCE_ADDRESS ) assert manager._rescue_triggered[address] == start + 16 manager._auto_scheduler._rescue_accept_after[address] = start + 17 # The owner re-hears the device: the episode is now moot. inject_advertisement_with_time_and_source( strong, strong_adv, start + 18, HCI0_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is strong # Next stale episode: the old completion (start + 17) predates the # owner's last adv, so this must start a new episode, not switch. inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 35, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is strong assert manager._rescue_triggered[address] == start + 35 cancel_active() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_stale_active_need_retriggers_when_window_never_ran( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """A rescue that never produced a window is re-triggered after the retry wait.""" manager = get_manager() address = "44:44:33:11:23:64" start = 50.0 strong = generate_ble_device(address, "strong_hci0") strong_adv = generate_advertisement_data( local_name="strong_hci0", service_uuids=[], rssi=-50 ) inject_advertisement_with_time_and_source( strong, strong_adv, start, HCI0_SOURCE_ADDRESS ) # stale_seconds = 65, durably-gone = 162.5: room for the 30s retry. manager.async_set_fallback_availability_interval(address, 60) comparable = generate_ble_device(address, "comparable_hci1") comparable_adv = generate_advertisement_data( local_name="comparable_hci1", service_uuids=[], rssi=-60 ) # A cdef class method cannot be patched in the compiled build, so # observe the trigger calls by swapping in a mock scheduler through # the writable _auto_scheduler slot. Only rejected challenger advs # flow while it is installed, so the winning-adv path that needs the # real scheduler is never hit. real_scheduler = manager._auto_scheduler mock_scheduler = Mock() mock_scheduler._requests_by_address = {address: {Mock()}} mock_scheduler._rescue_accept_after = {} manager._auto_scheduler = mock_scheduler try: # Episode starts: first trigger. inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 70, HCI1_SOURCE_ADDRESS ) assert mock_scheduler.trigger_rescue.call_count == 1 # Within the retry wait: no second trigger. inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 90, HCI1_SOURCE_ADDRESS ) assert mock_scheduler.trigger_rescue.call_count == 1 # Past the retry wait with no completion recorded: re-trigger. inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 105, HCI1_SOURCE_ADDRESS ) assert mock_scheduler.trigger_rescue.call_count == 2 # The retry restarted the episode, so the next adv within the # retry wait does not re-trigger again. inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 120, HCI1_SOURCE_ADDRESS ) assert mock_scheduler.trigger_rescue.call_count == 2 # And another full retry wait later it does. inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 140, HCI1_SOURCE_ADDRESS ) assert mock_scheduler.trigger_rescue.call_count == 3 finally: manager._auto_scheduler = real_scheduler assert manager.async_ble_device_from_address(address, True) is strong @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio @pytest.mark.parametrize("debug", [True, False]) async def test_stale_active_need_comparable_active_challenger_defers( register_hci0_scanner: None, debug: bool, ) -> None: """ A comparable ACTIVE challenger of a strong owner defers with grace. Its side of the rescue is covered immediately but the accept time keeps the standard grace, so the owner gets one re-hear window; only a challenger advertisement past the accept time takes ownership. Runs with debug logging both on and off to cover the log branches. """ manager = get_manager() manager._debug = debug address = "44:44:33:11:23:67" cancel_active = manager.async_register_active_scan(address) active_scanner = FakeScanner( "AA:BB:CC:DD:EE:22", "hci2", requested_mode=BluetoothScanningMode.ACTIVE, ) active_scanner.connectable = True cancel_scanner = manager.async_register_scanner(active_scanner, connection_slots=5) now = monotonic_time_coarse() strong = generate_ble_device(address, "strong_hci0") strong_adv = generate_advertisement_data( local_name="strong_hci0", service_uuids=[], rssi=-50 ) inject_advertisement_with_time_and_source( strong, strong_adv, now - 16, HCI0_SOURCE_ADDRESS ) # stale_seconds = 15, durably-gone = 37.5 manager.async_set_fallback_availability_interval(address, 10) comparable = generate_ble_device(address, "comparable_hci2") comparable_adv = generate_advertisement_data( local_name="comparable_hci2", service_uuids=[], rssi=-60 ) # Just past the stale window: deferred, the challenger's side of the # rescue is covered with the accept grace. inject_advertisement_with_time_and_source( comparable, comparable_adv, now, "AA:BB:CC:DD:EE:22" ) assert manager.async_ble_device_from_address(address, True) is strong assert manager._rescue_triggered[address] == now accept = manager._auto_scheduler._rescue_accept_after[address] assert accept >= now + RESCUE_SCAN_ACCEPT_SECONDS - 1 # Within the grace window: still the owner. inject_advertisement_with_time_and_source( comparable, comparable_adv, now + 2, "AA:BB:CC:DD:EE:22" ) assert manager.async_ble_device_from_address(address, True) is strong # Past the accept but within one advertising interval: still deferred. inject_advertisement_with_time_and_source( comparable, comparable_adv, accept + 1, "AA:BB:CC:DD:EE:22" ) assert manager.async_ble_device_from_address(address, True) is strong # Past the accept and one full interval past the trigger: hand off. inject_advertisement_with_time_and_source( comparable, comparable_adv, max(accept + 1, now + 11), "AA:BB:CC:DD:EE:22" ) assert manager.async_ble_device_from_address(address, True) is comparable assert address not in manager._rescue_triggered cancel_active() cancel_scanner() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_stale_active_need_weaker_active_challenger_defers() -> None: """ A materially weaker ACTIVE challenger defers instead of stealing. Its side of the rescue counts as covered immediately, so it wins on its next advertisement only if the owner stays silent; the owner being heard again keeps the device where it is. """ manager = get_manager() address = "44:44:33:11:23:75" cancel_active = manager.async_register_active_scan(address) owner_scanner = FakeScanner("AA:BB:CC:DD:EE:99", "hci9") owner_scanner.connectable = True active_scanner = FakeScanner( "AA:BB:CC:DD:EE:AA", "hci10", requested_mode=BluetoothScanningMode.ACTIVE, ) active_scanner.connectable = True cancel_owner = manager.async_register_scanner(owner_scanner, connection_slots=5) cancel_challenger = manager.async_register_scanner( active_scanner, connection_slots=5 ) now = monotonic_time_coarse() strong = generate_ble_device(address, "strong_hci9") strong_adv = generate_advertisement_data( local_name="strong_hci9", service_uuids=[], rssi=-50 ) inject_advertisement_with_time_and_source( strong, strong_adv, now - 16, "AA:BB:CC:DD:EE:99" ) # stale_seconds = 15, durably-gone = 37.5 manager.async_set_fallback_availability_interval(address, 10) weak = generate_ble_device(address, "weak_hci10") weak_adv = generate_advertisement_data( local_name="weak_hci10", service_uuids=[], rssi=-90 ) # Far weaker ACTIVE challenger just past the stale window: deferred, # its rescue side counts as covered with the accept grace. inject_advertisement_with_time_and_source(weak, weak_adv, now, "AA:BB:CC:DD:EE:AA") assert manager.async_ble_device_from_address(address, True) is strong assert manager._rescue_triggered[address] == now accept = manager._auto_scheduler._rescue_accept_after[address] assert accept >= now + RESCUE_SCAN_ACCEPT_SECONDS - 1 # Within the grace window: still the owner. inject_advertisement_with_time_and_source( weak, weak_adv, now + 2, "AA:BB:CC:DD:EE:AA" ) assert manager.async_ble_device_from_address(address, True) is strong # Past the accept and one full interval past the trigger: hand off. inject_advertisement_with_time_and_source( weak, weak_adv, max(accept + 1, now + 11), "AA:BB:CC:DD:EE:AA" ) assert manager.async_ble_device_from_address(address, True) is weak assert address not in manager._rescue_triggered cancel_active() cancel_owner() cancel_challenger() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_owner_paused_for_connection_hands_off_immediately( register_hci1_scanner: None, ) -> None: """ An owner that paused scanning for a connection is not kept in arbitration. The #590 hold was reverted (issue #591): pinning ownership to a deaf scanner rejected fresh challenger data while recording nothing. A scanner that pauses scanning to connect hands its devices to any challenger immediately; the stale damping and the rescue flow only arbitrate between scanners that are still scanning. """ manager = get_manager() address = "44:44:33:11:23:68" start = 50.0 cancel_active = manager.async_register_active_scan(address) owner_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3") owner_scanner.connectable = True cancel_scanner = manager.async_register_scanner(owner_scanner, connection_slots=5) strong = generate_ble_device(address, "strong_hci3") strong_adv = generate_advertisement_data( local_name="strong_hci3", service_uuids=[], rssi=-50 ) inject_advertisement_with_time_and_source( strong, strong_adv, start, "AA:BB:CC:DD:EE:33" ) manager.async_set_fallback_availability_interval(address, 10) weak = generate_ble_device(address, "weak_hci1") weak_adv = generate_advertisement_data( local_name="weak_hci1", service_uuids=[], rssi=-90 ) with owner_scanner.connecting(): assert owner_scanner.scanning is False # Not even stale (elapsed 1 < 15) and far weaker: hands off # immediately, and no rescue episode is started. inject_advertisement_with_time_and_source( weak, weak_adv, start + 1, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is weak assert address not in manager._rescue_triggered cancel_active() cancel_scanner() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_stale_active_need_retry_does_not_clobber_matured_accept( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """ The retry never restarts an episode whose window already ran. Once an accept time is recorded, the episode is only waiting out the accept and interval gates; restarting it would push the trigger time forward and make the gates unreachable for slow advertisers. """ manager = get_manager() address = "44:44:33:11:23:79" start = 50.0 strong = generate_ble_device(address, "strong_hci0") strong_adv = generate_advertisement_data( local_name="strong_hci0", service_uuids=[], rssi=-50 ) inject_advertisement_with_time_and_source( strong, strong_adv, start, HCI0_SOURCE_ADDRESS ) # stale_seconds = 35, durably-gone = 87.5 manager.async_set_fallback_availability_interval(address, 30) comparable = generate_ble_device(address, "comparable_hci1") comparable_adv = generate_advertisement_data( local_name="comparable_hci1", service_uuids=[], rssi=-60 ) real_scheduler = manager._auto_scheduler mock_scheduler = Mock() mock_scheduler._requests_by_address = {address: {Mock()}} mock_scheduler._rescue_accept_after = {} manager._auto_scheduler = mock_scheduler try: # Episode starts. inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 36, HCI1_SOURCE_ADDRESS ) assert mock_scheduler.trigger_rescue.call_count == 1 assert manager._rescue_triggered[address] == start + 36 # A window ran; its accept time is still in the future. mock_scheduler._rescue_accept_after[address] = start + 80 # Past the retry wait, but the accept exists: no restart, the # trigger time is preserved so the gates can mature. inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 70, HCI1_SOURCE_ADDRESS ) assert mock_scheduler.trigger_rescue.call_count == 1 assert manager._rescue_triggered[address] == start + 36 assert manager.async_ble_device_from_address(address, True) is strong finally: manager._auto_scheduler = real_scheduler manager._rescue_triggered.pop(address, None) @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_stale_active_need_durably_gone_ends_episode( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """Durably-gone still hands off even when the rescue window never completed.""" manager = get_manager() address = "44:44:33:11:23:65" start = 50.0 cancel_active = manager.async_register_active_scan(address) strong = generate_ble_device(address, "strong_hci0") strong_adv = generate_advertisement_data( local_name="strong_hci0", service_uuids=[], rssi=-50 ) inject_advertisement_with_time_and_source( strong, strong_adv, start, HCI0_SOURCE_ADDRESS ) # stale_seconds = 15, durably-gone = 37.5 manager.async_set_fallback_availability_interval(address, 10) comparable = generate_ble_device(address, "comparable_hci1") comparable_adv = generate_advertisement_data( local_name="comparable_hci1", service_uuids=[], rssi=-60 ) inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 16, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is strong assert address in manager._rescue_triggered # Past durably-gone with no rescue completion: hand off anyway. inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 40, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is comparable assert address not in manager._rescue_triggered cancel_active() class _AutoWindowScanner(FakeScanner): """AUTO-capable fake scanner recording windows with discoverable data.""" def __init__(self, source: str, adapter: str, mode: BluetoothScanningMode) -> None: super().__init__(source, adapter, requested_mode=mode) self.connectable = True self.active_window_calls: list[float] = [] self._discovered: dict[str, tuple[BLEDevice, AdvertisementData]] = {} async def async_request_active_window(self, duration: float) -> bool: self.active_window_calls.append(duration) return True @property def discovered_devices_and_advertisement_data( self, ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: return self._discovered @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_rescue_end_to_end_owner_mid_connect_fallback_window() -> None: """ The full rescue chain resolves a mid-connect AUTO owner. The AUTO owner still reports scanning while a connect attempt is in progress, so the challenger's stale advertisement is deferred and a rescue is triggered. The owner's worker tick cannot flip the owner's radio, delegates the window to a fallback scanner, and records the accept time; the first challenger advertisement past it takes ownership. """ manager = get_manager() sched = manager._auto_scheduler address = "44:44:33:11:23:69" cancel_active = manager.async_register_active_scan(address) owner = _AutoWindowScanner("AA:BB:CC:DD:EE:55", "hci5", BluetoothScanningMode.AUTO) fallback = _AutoWindowScanner( "AA:BB:CC:DD:EE:66", "hci6", BluetoothScanningMode.AUTO ) challenger = _AutoWindowScanner( "AA:BB:CC:DD:EE:77", "hci7", BluetoothScanningMode.PASSIVE ) cancels = [ manager.async_register_scanner(scanner, connection_slots=5) for scanner in (owner, fallback, challenger) ] now = monotonic_time_coarse() strong = generate_ble_device(address, "strong") strong_adv = generate_advertisement_data( local_name="strong", service_uuids=[], rssi=-50 ) inject_advertisement_with_time_and_source( strong, strong_adv, now - 16, owner.source ) # stale_seconds = 15, durably-gone = 37.5 manager.async_set_fallback_availability_interval(address, 10) fallback._discovered[address] = ( generate_ble_device(address, "x"), generate_advertisement_data(local_name="x", rssi=-70), ) owner._add_connecting(address) try: weak = generate_ble_device(address, "weak") weak_adv = generate_advertisement_data( local_name="weak", service_uuids=[], rssi=-60 ) # Past the stale window: deferred, rescue triggered. inject_advertisement_with_time_and_source( weak, weak_adv, now, challenger.source ) assert manager.async_ble_device_from_address(address, True) is strong assert manager._rescue_triggered[address] == now # The owner's worker tick delegates the rescue window to the # fallback scanner because the owner is mid-connect. await sched._workers[owner.source]._tick() assert owner.active_window_calls == [] assert fallback.active_window_calls accept = sched._rescue_accept_after[address] assert accept >= now # First challenger adv past the accept time and one advertising # interval past the trigger: hand off. inject_advertisement_with_time_and_source( weak, weak_adv, max(accept + 1, now + 11), challenger.source ) assert manager.async_ble_device_from_address(address, True) is weak assert address not in manager._rescue_triggered finally: owner._finished_connecting(address, connected=False) cancel_active() for cancel in cancels: cancel() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_rescue_episode_is_per_address_any_challenger_completes( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """ A rescue episode is keyed by address, not by the challenger. Challenger A starts the episode; once the accept time passes, a different challenger's advertisement completes the handoff. """ manager = get_manager() address = "44:44:33:11:23:70" start = 50.0 cancel_active = manager.async_register_active_scan(address) other = FakeScanner("AA:BB:CC:DD:EE:88", "hci8") other.connectable = True cancel_other = manager.async_register_scanner(other, connection_slots=5) strong = generate_ble_device(address, "strong_hci0") strong_adv = generate_advertisement_data( local_name="strong_hci0", service_uuids=[], rssi=-50 ) inject_advertisement_with_time_and_source( strong, strong_adv, start, HCI0_SOURCE_ADDRESS ) manager.async_set_fallback_availability_interval(address, 10) challenger_a = generate_ble_device(address, "challenger_hci1") challenger_a_adv = generate_advertisement_data( local_name="challenger_hci1", service_uuids=[], rssi=-60 ) inject_advertisement_with_time_and_source( challenger_a, challenger_a_adv, start + 16, HCI1_SOURCE_ADDRESS ) assert manager._rescue_triggered[address] == start + 16 manager._auto_scheduler._rescue_accept_after[address] = start + 20 challenger_b = generate_ble_device(address, "challenger_hci8") challenger_b_adv = generate_advertisement_data( local_name="challenger_hci8", service_uuids=[], rssi=-62 ) inject_advertisement_with_time_and_source( challenger_b, challenger_b_adv, start + 27, "AA:BB:CC:DD:EE:88" ) assert manager.async_ble_device_from_address(address, True) is challenger_b assert address not in manager._rescue_triggered cancel_active() cancel_other() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_connectable_recheck_does_not_drive_rescue_episode( register_hci0_scanner: None, register_hci1_scanner: None, register_non_connectable_scanner: None, ) -> None: """ The connectable re-check never starts a rescue episode. All-history is owned by a fresh non-connectable source, so the all-history decision keeps without entering the stale branch; the connectable re-check then sees a stale connectable owner and denies the handoff, but with record_demotion=False it must not start an episode. """ manager = get_manager() address = "44:44:33:11:23:71" start = 50.0 cancel_active = manager.async_register_active_scan(address) connectable_owner = generate_ble_device(address, "conn_hci0") connectable_owner_adv = generate_advertisement_data( local_name="conn_hci0", service_uuids=[], rssi=-60 ) inject_advertisement_with_time_and_source( connectable_owner, connectable_owner_adv, start, HCI0_SOURCE_ADDRESS ) manager.async_set_fallback_availability_interval(address, 10) # A much stronger non-connectable source takes all-history on the # RSSI path; the connectable history stays with hci0. nc_device = generate_ble_device(address, "nc") nc_adv = generate_advertisement_data(local_name="nc", service_uuids=[], rssi=-30) inject_advertisement_with_time_and_source_connectable( nc_device, nc_adv, start + 10, NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, False ) assert manager.async_ble_device_from_address(address, False) is nc_device assert manager.async_ble_device_from_address(address, True) is connectable_owner # A weak connectable challenger: all-history keeps (fresh strong # owner); the connectable re-check hits the stale branch and denies, # and must not start an episode. challenger = generate_ble_device(address, "challenger_hci1") challenger_adv = generate_advertisement_data( local_name="challenger_hci1", service_uuids=[], rssi=-80 ) inject_advertisement_with_time_and_source( challenger, challenger_adv, start + 17, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, False) is nc_device assert manager.async_ble_device_from_address(address, True) is connectable_owner assert address not in manager._rescue_triggered cancel_active() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_connectable_recheck_roams_active_need_device_at_stale( register_hci0_scanner: None, register_hci1_scanner: None, register_non_connectable_scanner: None, ) -> None: """ The connectable re-check keeps the roaming rule for active-need devices. Connection routing cares about liveness, not scan-response freshness: when the weak connectable owner goes silent, the connectable history must repoint to a comparable live challenger at the stale window instead of routing connect attempts at a dead radio until durably gone, and it must not drive rescue episode state. """ manager = get_manager() address = "44:44:33:11:23:78" start = 50.0 cancel_active = manager.async_register_active_scan(address) connectable_owner = generate_ble_device(address, "conn_hci0") connectable_owner_adv = generate_advertisement_data( local_name="conn_hci0", service_uuids=[], rssi=-80 ) inject_advertisement_with_time_and_source( connectable_owner, connectable_owner_adv, start, HCI0_SOURCE_ADDRESS ) manager.async_set_fallback_availability_interval(address, 10) # A much stronger non-connectable source takes all-history on the # RSSI path; the connectable history stays with hci0. nc_device = generate_ble_device(address, "nc") nc_adv = generate_advertisement_data(local_name="nc", service_uuids=[], rssi=-30) inject_advertisement_with_time_and_source_connectable( nc_device, nc_adv, start + 10, NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, False ) assert manager.async_ble_device_from_address(address, True) is connectable_owner # A comparable connectable challenger of the weak owner within the # roam gate: the connectable history is kept (single reception miss). challenger = generate_ble_device(address, "challenger_hci1") challenger_adv = generate_advertisement_data( local_name="challenger_hci1", service_uuids=[], rssi=-85 ) inject_advertisement_with_time_and_source( challenger, challenger_adv, start + 17, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is connectable_owner # Past the roam gate (1.5 * 15 = 22.5 since the connectable owner's # last adv): all-history keeps (fresh strong NC owner), but the # connectable re-check roams to the live challenger. inject_advertisement_with_time_and_source( challenger, challenger_adv, start + 24, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, False) is nc_device assert manager.async_ble_device_from_address(address, True) is challenger assert address not in manager._rescue_triggered cancel_active() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_rescue_registration_is_case_normalized( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """A lowercase active-scan registration still drives the rescue flow.""" manager = get_manager() address = "44:44:33:11:23:72" start = 50.0 cancel_active = manager.async_register_active_scan(address.lower()) strong = generate_ble_device(address, "strong_hci0") strong_adv = generate_advertisement_data( local_name="strong_hci0", service_uuids=[], rssi=-50 ) inject_advertisement_with_time_and_source( strong, strong_adv, start, HCI0_SOURCE_ADDRESS ) manager.async_set_fallback_availability_interval(address, 10) comparable = generate_ble_device(address, "comparable_hci1") comparable_adv = generate_advertisement_data( local_name="comparable_hci1", service_uuids=[], rssi=-60 ) inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 16, HCI1_SOURCE_ADDRESS ) # Deferred (episode started), not handed off: the lowercase # registration matched the uppercase advertisement address. assert manager.async_ble_device_from_address(address, True) is strong assert manager._rescue_triggered[address] == start + 16 cancel_active() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_rescue_accept_time_boundaries( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """ The accept gates are accept_after >= pending and new.time > accept_after. An advertisement at exactly the accept time stays deferred (strict greater-than); an accept time equal to the trigger time counts (greater-or-equal). """ manager = get_manager() start = 50.0 manager_sched = manager._auto_scheduler # Part 1: adv at exactly the accept time stays deferred. address = "44:44:33:11:23:73" cancel_a = manager.async_register_active_scan(address) strong = generate_ble_device(address, "strong_hci0") strong_adv = generate_advertisement_data( local_name="strong_hci0", service_uuids=[], rssi=-50 ) inject_advertisement_with_time_and_source( strong, strong_adv, start, HCI0_SOURCE_ADDRESS ) manager.async_set_fallback_availability_interval(address, 10) comparable = generate_ble_device(address, "comparable_hci1") comparable_adv = generate_advertisement_data( local_name="comparable_hci1", service_uuids=[], rssi=-60 ) inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 16, HCI1_SOURCE_ADDRESS ) manager_sched._rescue_accept_after[address] = start + 20 inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 20, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is strong # Past the accept but within one advertising interval of the trigger: # still deferred. inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 24, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is strong cancel_a() # Part 2: an accept time equal to the trigger time counts. address = "44:44:33:11:23:74" cancel_b = manager.async_register_active_scan(address) strong2 = generate_ble_device(address, "strong2_hci0") strong2_adv = generate_advertisement_data( local_name="strong2_hci0", service_uuids=[], rssi=-50 ) inject_advertisement_with_time_and_source( strong2, strong2_adv, start, HCI0_SOURCE_ADDRESS ) manager.async_set_fallback_availability_interval(address, 10) comparable2 = generate_ble_device(address, "comparable2_hci1") comparable2_adv = generate_advertisement_data( local_name="comparable2_hci1", service_uuids=[], rssi=-60 ) inject_advertisement_with_time_and_source( comparable2, comparable2_adv, start + 16, HCI1_SOURCE_ADDRESS ) manager_sched._rescue_accept_after[address] = start + 16 inject_advertisement_with_time_and_source( comparable2, comparable2_adv, start + 27, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is comparable2 cancel_b() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_passive_stale_arbitration_clears_orphaned_episode( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """ A device that lost its active-scan need drops its episode on arbitration. The orphaned entry must not linger; it would keep the scheduler's no-episode fast path off for every window dispatch. """ manager = get_manager() address = "44:44:33:11:23:76" start = 50.0 strong = generate_ble_device(address, "strong_hci0") strong_adv = generate_advertisement_data( local_name="strong_hci0", service_uuids=[], rssi=-50 ) inject_advertisement_with_time_and_source( strong, strong_adv, start, HCI0_SOURCE_ADDRESS ) manager.async_set_fallback_availability_interval(address, 10) # Episode left behind by an active-scan need that was since unregistered. manager._rescue_triggered[address] = start weak = generate_ble_device(address, "weak_hci1") weak_adv = generate_advertisement_data( local_name="weak_hci1", service_uuids=[], rssi=-90 ) # Weaker passive challenger past stale: kept, and the orphaned # episode is cleaned up. inject_advertisement_with_time_and_source( weak, weak_adv, start + 16, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is strong assert address not in manager._rescue_triggered @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_clear_advertisement_history_clears_rescue_state( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """async_clear_advertisement_history drops any in-flight rescue episode.""" manager = get_manager() address = "44:44:33:11:23:66" manager._rescue_triggered[address] = 123.0 manager.async_clear_advertisement_history(address) assert address not in manager._rescue_triggered def _rssi_adv(rssi: int) -> AdvertisementData: return generate_advertisement_data( local_name="smoothing", service_uuids=[], rssi=rssi ) @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_rssi_smoothing_ignores_single_spike( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """ A one-off RSSI spike from a challenger does not flip a stationary owner. Instantaneously the spike clears the 16 dB threshold, but the smoothed per-source RSSI does not, so ownership stays put. """ manager = get_manager() address = "44:44:33:11:23:50" t = 50.0 owner = generate_ble_device(address, "owner_hci0") challenger = generate_ble_device(address, "challenger_hci1") inject_advertisement_with_time_and_source( owner, _rssi_adv(-50), t, HCI0_SOURCE_ADDRESS ) # Challenger first seen weaker (-60), so it enters the smoothing bucket. inject_advertisement_with_time_and_source( challenger, _rssi_adv(-60), t + 1, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is owner # Single spike to -30 (instantaneously 20 dB stronger than the owner): # smoothed hci1 = 0.3*-30 + 0.7*-60 = -51, still below -50 + threshold, # so it must NOT switch. inject_advertisement_with_time_and_source( challenger, _rssi_adv(-30), t + 2, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is owner @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_rssi_smoothing_switches_on_sustained_stronger( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """A sustained stronger challenger wins once its smoothed RSSI crosses.""" manager = get_manager() address = "44:44:33:11:23:51" t = 50.0 owner = generate_ble_device(address, "owner_hci0") challenger = generate_ble_device(address, "challenger_hci1") inject_advertisement_with_time_and_source( owner, _rssi_adv(-50), t, HCI0_SOURCE_ADDRESS ) inject_advertisement_with_time_and_source( challenger, _rssi_adv(-55), t + 1, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is owner # Sustained -20 (30 dB stronger): the smoothed value climbs past the # threshold after a few samples and ownership flips. for i in range(8): inject_advertisement_with_time_and_source( challenger, _rssi_adv(-20), t + 2 + i, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is challenger @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_rssi_smoothing_first_sighting_switches_immediately( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """A first-seen challenger with a real margin switches at once (instantaneous).""" manager = get_manager() address = "44:44:33:11:23:52" t = 50.0 owner = generate_ble_device(address, "owner_hci0") challenger = generate_ble_device(address, "challenger_hci1") inject_advertisement_with_time_and_source( owner, _rssi_adv(-50), t, HCI0_SOURCE_ADDRESS ) # No prior smoothed history for the challenger: it seeds at its # instantaneous -20, which is a genuine 30 dB margin, so it wins now. inject_advertisement_with_time_and_source( challenger, _rssi_adv(-20), t + 1, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is challenger @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_rssi_smoothing_bucket_evicted_with_history( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """The smoothed-RSSI bucket is allocated cross-source and cleared with history.""" manager = get_manager() address = "44:44:33:11:23:53" t = 50.0 owner = generate_ble_device(address, "owner_hci0") challenger = generate_ble_device(address, "challenger_hci1") inject_advertisement_with_time_and_source( owner, _rssi_adv(-50), t, HCI0_SOURCE_ADDRESS ) # Single source so far: no bucket allocated. assert address not in manager._smoothed_rssi inject_advertisement_with_time_and_source( challenger, _rssi_adv(-60), t + 1, HCI1_SOURCE_ADDRESS ) # Cross-source seen: bucket now holds both sources. assert set(manager._smoothed_rssi[address]) == { HCI0_SOURCE_ADDRESS, HCI1_SOURCE_ADDRESS, } manager.async_clear_advertisement_history(address) assert address not in manager._smoothed_rssi @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_rssi_smoothing_source_evicted_on_unregister( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """Unregistering a scanner drops its source from every smoothed bucket.""" manager = get_manager() address = "44:44:33:11:23:54" t = 50.0 owner = generate_ble_device(address, "owner_hci0") challenger = generate_ble_device(address, "challenger_hci1") inject_advertisement_with_time_and_source( owner, _rssi_adv(-50), t, HCI0_SOURCE_ADDRESS ) inject_advertisement_with_time_and_source( challenger, _rssi_adv(-60), t + 1, HCI1_SOURCE_ADDRESS ) assert HCI1_SOURCE_ADDRESS in manager._smoothed_rssi[address] def _unregister(source: str) -> None: for scanner in list(manager._sources.values()): if scanner.source == source: manager._async_unregister_scanner_internal( manager._connectable_scanners, scanner, None ) _unregister(HCI1_SOURCE_ADDRESS) # One source remains, so the bucket survives without the dropped source. assert HCI1_SOURCE_ADDRESS not in manager._smoothed_rssi[address] assert HCI0_SOURCE_ADDRESS in manager._smoothed_rssi[address] # Dropping the last source empties the bucket; the address is removed so # the map can return to truly empty and re-enable the proxy-free fast path. _unregister(HCI0_SOURCE_ADDRESS) assert address not in manager._smoothed_rssi assert not manager._smoothed_rssi @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_rssi_smoothing_skips_unregistered_old_source( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """ A new bucket is not seeded against a source that has unregistered. An old source lingers in _all_history after it unregisters (the unavailable sweep clears it later); seeding its stale RSSI would reintroduce the source the unregister cleanup just dropped. """ manager = get_manager() address = "44:44:33:11:23:55" t = 50.0 owner = generate_ble_device(address, "owner_hci0") challenger = generate_ble_device(address, "challenger_hci1") # Seen from hci0 only, so no bucket exists yet. inject_advertisement_with_time_and_source( owner, _rssi_adv(-50), t, HCI0_SOURCE_ADDRESS ) assert address not in manager._smoothed_rssi # hci0 unregisters, but its advertisement lingers in _all_history. for scanner in list(manager._sources.values()): if scanner.source == HCI0_SOURCE_ADDRESS: manager._async_unregister_scanner_internal( manager._connectable_scanners, scanner, None ) assert manager._all_history[address].source == HCI0_SOURCE_ADDRESS # hci1 now advertises; the dead hci0 source must not be seeded, so no # bucket is created for what is now effectively a single-source device. inject_advertisement_with_time_and_source( challenger, _rssi_adv(-60), t + 1, HCI1_SOURCE_ADDRESS ) assert address not in manager._smoothed_rssi @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_rssi_smoothing_ignores_missing_rssi( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """ A dropped RSSI (0) is not folded into the smoothed average. Proxies occasionally report no RSSI. Folding NO_RSSI_VALUE (-127) into the EWMA would poison the value and, because the average remembers it, drag it down for several adverts, manufacturing phantom weak readings. A no-RSSI advert must instead keep the last good smoothed value and write nothing. """ manager = get_manager() address = "44:44:33:11:23:5A" t = 50.0 owner = generate_ble_device(address, "owner_hci0") challenger = generate_ble_device(address, "challenger_hci1") inject_advertisement_with_time_and_source( owner, _rssi_adv(-50), t, HCI0_SOURCE_ADDRESS ) # First cross-source sighting seeds the bucket at the real reading. inject_advertisement_with_time_and_source( challenger, _rssi_adv(-45), t + 1, HCI1_SOURCE_ADDRESS ) assert manager._smoothed_rssi[address][HCI1_SOURCE_ADDRESS] == -45 # A burst of no-RSSI adverts must leave the smoothed value pinned, not walk # it toward -127. for i in range(3): inject_advertisement_with_time_and_source( challenger, _rssi_adv(0), t + 2 + i, HCI1_SOURCE_ADDRESS ) assert manager._smoothed_rssi[address][HCI1_SOURCE_ADDRESS] == -45 # The owner is unaffected by the dropped readings. assert manager.async_ble_device_from_address(address, True) is owner # A new source whose very first advert has no RSSI is not seeded into the # existing bucket (it loses arbitration via NO_RSSI_VALUE instead). third = generate_ble_device(address, "third_hci2") inject_advertisement_with_time_and_source(third, _rssi_adv(0), t + 6, "hci2") assert "hci2" not in manager._smoothed_rssi[address] assert manager.async_ble_device_from_address(address, True) is owner @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_rssi_smoothing_no_bucket_seeded_from_missing_rssi( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """ A no-RSSI first cross-source sighting does not seed or steal ownership. The bucket is seeded from the existing owner only; the no-RSSI source is left unseeded so it loses arbitration (NO_RSSI_VALUE) and the owner is kept, rather than priming the average with -127 or taking ownership with no signal. The source's next real advert seeds it cleanly. """ manager = get_manager() address = "44:44:33:11:23:5B" t = 50.0 owner = generate_ble_device(address, "owner_hci0") challenger = generate_ble_device(address, "challenger_hci1") inject_advertisement_with_time_and_source( owner, _rssi_adv(-50), t, HCI0_SOURCE_ADDRESS ) # First cross-source advert has no RSSI: the challenger is not seeded and # does not take ownership. inject_advertisement_with_time_and_source( challenger, _rssi_adv(0), t + 1, HCI1_SOURCE_ADDRESS ) assert manager._smoothed_rssi[address] == {HCI0_SOURCE_ADDRESS: -50} assert manager.async_ble_device_from_address(address, True) is owner # A later real reading seeds the challenger cleanly. inject_advertisement_with_time_and_source( challenger, _rssi_adv(-45), t + 2, HCI1_SOURCE_ADDRESS ) assert manager._smoothed_rssi[address] == { HCI0_SOURCE_ADDRESS: -50, HCI1_SOURCE_ADDRESS: -45, } @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_rssi_deadband_does_not_slow_one_way_move( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """ A fresh challenger still takes ownership at the plain threshold. The deadband only applies to a source reclaiming ownership it was just demoted from; a challenger that has never owned the address pays only ADV_RSSI_SWITCH_THRESHOLD (16 dB), so a 17 dB one-way move is not slowed. The demoted owner is recorded for the asymmetric reclaim check. """ manager = get_manager() address = "44:44:33:11:23:56" t = 50.0 owner = generate_ble_device(address, "owner_hci0") challenger = generate_ble_device(address, "challenger_hci1") inject_advertisement_with_time_and_source( owner, _rssi_adv(-50), t, HCI0_SOURCE_ADDRESS ) # 17 dB stronger and never previously the owner: switches on the plain # 16 dB threshold, no deadband. inject_advertisement_with_time_and_source( challenger, _rssi_adv(-33), t + 1, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is challenger # hci0 was demoted, so a quick reclaim by it now faces the deadband. assert HCI0_SOURCE_ADDRESS in manager._demoted_sources[address] # Per-address eviction clears the reclaim record. manager.async_clear_advertisement_history(address) assert address not in manager._demoted_sources @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_rssi_deadband_damps_reclaim( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """ A just-demoted source must clear THRESHOLD + DEADBAND to reclaim ownership. hci0 owns, hci1 takes over (one-way, plain threshold), then hci0 tries to reclaim: a 20 dB margin sits inside the 16 + 6 deadband and must not switch back, while a sustained 24 dB margin clears it and reclaims. Without the asymmetric deadband the 20 dB reclaim would flip ownership immediately. """ manager = get_manager() address = "44:44:33:11:23:57" t = 50.0 owner = generate_ble_device(address, "owner_hci0") challenger = generate_ble_device(address, "challenger_hci1") inject_advertisement_with_time_and_source( owner, _rssi_adv(-50), t, HCI0_SOURCE_ADDRESS ) # hci1 takes over (fresh challenger, plain 16 dB threshold). inject_advertisement_with_time_and_source( challenger, _rssi_adv(-30), t + 1, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is challenger assert HCI0_SOURCE_ADDRESS in manager._demoted_sources[address] # hci0 reclaims at a sustained 20 dB margin (smoothed converges to -10 vs # hci1's -30): inside the 16 + 6 deadband, so ownership stays with hci1. for i in range(15): inject_advertisement_with_time_and_source( owner, _rssi_adv(-10), t + 2 + i, HCI0_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is challenger # A sustained 24 dB margin (smoothed converges to -6) clears the deadband # and hci0 reclaims. for i in range(15): inject_advertisement_with_time_and_source( owner, _rssi_adv(-6), t + 20 + i, HCI0_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is owner # The reclaim demotes hci1 in turn. assert HCI1_SOURCE_ADDRESS in manager._demoted_sources[address] # Per-source eviction on unregister drops the reclaim record for that source. for scanner in list(manager._sources.values()): if scanner.source == HCI1_SOURCE_ADDRESS: manager._async_unregister_scanner_internal( manager._connectable_scanners, scanner, None ) assert address not in manager._demoted_sources def _assert_nway_reclaim_damped(address: str, sources: list[str]) -> None: """ Drive ownership through every source in order, then reclaim from the first. Each current owner is faded so the next fresh source takes over, which leaves every earlier owner in the demoted set. The first source then reclaims: a sustained 19 dB margin (inside 16 + 6) must not switch because it is still demoted, while a sustained 25 dB margin clears the deadband and wins. Single-slot hysteresis (only the most-recent loser) would let the first source back at the plain 16 dB, so this fails without the set. """ manager = get_manager() devices = [generate_ble_device(address, f"dev_{source}") for source in sources] now = 50.0 inject_advertisement_with_time_and_source( devices[0], _rssi_adv(-40), now, sources[0] ) for k in range(1, len(sources)): for _ in range(12): now += 1 inject_advertisement_with_time_and_source( devices[k - 1], _rssi_adv(-60), now, sources[k - 1] ) now += 1 inject_advertisement_with_time_and_source( devices[k], _rssi_adv(-40), now, sources[k] ) assert manager.async_ble_device_from_address(address, True) is devices[-1] assert manager._demoted_sources[address] == set(sources[:-1]) # Reclaim inside the deadband: blocked because the first source is demoted. for _ in range(15): now += 1 inject_advertisement_with_time_and_source( devices[0], _rssi_adv(-21), now, sources[0] ) assert manager.async_ble_device_from_address(address, True) is devices[-1] # Reclaim past the deadband: wins. for _ in range(15): now += 1 inject_advertisement_with_time_and_source( devices[0], _rssi_adv(-15), now, sources[0] ) assert manager.async_ble_device_from_address(address, True) is devices[0] def _register_connectable_source(source: str) -> Callable[[], None]: scanner = FakeScanner(source, source) scanner.connectable = True return get_manager().async_register_scanner(scanner, connection_slots=5) @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_rssi_deadband_damps_three_way_reclaim( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """A reclaim after a 3-way bounce is still charged the deadband.""" cancel = _register_connectable_source("hci2") try: _assert_nway_reclaim_damped( "44:44:33:11:23:5C", [HCI0_SOURCE_ADDRESS, HCI1_SOURCE_ADDRESS, "hci2"], ) finally: cancel() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_rssi_deadband_damps_four_way_reclaim( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """A reclaim by the earliest owner after a 4-way bounce still pays the deadband.""" cancels = [ _register_connectable_source("hci2"), _register_connectable_source("hci3"), ] try: _assert_nway_reclaim_damped( "44:44:33:11:23:5D", [HCI0_SOURCE_ADDRESS, HCI1_SOURCE_ADDRESS, "hci2", "hci3"], ) finally: for cancel in cancels: cancel() @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_rssi_deadband_not_charged_after_stale_handoff( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """ A stale-path handoff records no demotion, so the owner reclaims at THRESHOLD. The deadband must only damp active RSSI ping-pong; a weak owner that goes silent and is taken over on the stale path must not then be charged the deadband to recover, which would leave the rightful owner stuck on a weaker proxy (the regression #570/#580 guard against). Only an active RSSI switch records a demotion. """ manager = get_manager() address = "44:44:33:11:23:58" start = 50.0 owner = generate_ble_device(address, "owner_hci0") owner_adv = generate_advertisement_data( local_name="owner_hci0", service_uuids=[], rssi=-90 ) inject_advertisement_with_time_and_source( owner, owner_adv, start, HCI0_SOURCE_ADDRESS ) manager.async_set_fallback_availability_interval(address, 10) # Weak owner goes silent; a comparable (weaker) scanner takes over on # the stale path once the roam gate elapsed, not the active RSSI path. comparable = generate_ble_device(address, "comparable_hci1") comparable_adv = generate_advertisement_data( local_name="comparable_hci1", service_uuids=[], rssi=-95 ) inject_advertisement_with_time_and_source( comparable, comparable_adv, start + 23, HCI1_SOURCE_ADDRESS ) assert manager.async_ble_device_from_address(address, True) is comparable # The stale handoff is not an RSSI-path switch, so no reclaim penalty is set. assert address not in manager._demoted_sources @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.asyncio async def test_rssi_deadband_connectable_recheck_records_no_demotion( register_hci0_scanner: None, register_hci1_scanner: None, ) -> None: """ A connectable re-check switch does not record an all-history demotion. When the best (all-history) advertisement stays put but the connectable history switches sources on RSSI, that switch must not write to _demoted_sources; it is not an all-history ownership change, so a future challenger of the all-history owner is not wrongly treated as a reclaim. """ manager = get_manager() address = "44:44:33:11:23:59" now = 50.0 # hci1 owns the best (all-history) path with a strong non-connectable adv. best = generate_ble_device(address, "best_hci1") inject_advertisement_with_time_and_source_connectable( best, _rssi_adv(-30), now, HCI1_SOURCE_ADDRESS, False ) # hci0 is the only connectable path, but weak. weak_conn = generate_ble_device(address, "weak_conn_hci0") inject_advertisement_with_time_and_source_connectable( weak_conn, _rssi_adv(-90), now + 1, HCI0_SOURCE_ADDRESS, True ) assert manager.async_ble_device_from_address(address, True) is weak_conn # A third source sends a much stronger connectable adv: it loses the # all-history compare to hci1 but wins the connectable re-check over hci0. strong_conn = generate_ble_device(address, "strong_conn_hci2") inject_advertisement_with_time_and_source_connectable( strong_conn, _rssi_adv(-50), now + 2, "hci2", True ) # all-history owner unchanged; connectable history switched to the new path. assert manager.async_ble_device_from_address(address, False) is best assert manager.async_ble_device_from_address(address, True) is strong_conn # The connectable-only switch records no all-history demotion. assert address not in manager._demoted_sources @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-1390f03/tests/test_models.py000066400000000000000000000233571522222566700237030ustar00rootroot00000000000000from __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-1390f03/tests/test_name_cache.py000066400000000000000000000443611522222566700244610ustar00rootroot00000000000000"""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-1390f03/tests/test_scanner.py000066400000000000000000003363001522222566700240440ustar00rootroot00000000000000"""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 import habluetooth.scanner as scanner_shim from habluetooth import ( SCANNER_WATCHDOG_INTERVAL, SCANNER_WATCHDOG_TIMEOUT, BaseHaScanner, BluetoothManager, BluetoothScanningMode, BluetoothServiceInfoBleak, HaScanner, HaScannerType, ScannerStartError, get_manager, set_manager, ) from habluetooth import scanner_bleak as scanner from habluetooth.channels.bluez import ( BluetoothMGMTProtocol, MGMTBluetoothCtl, bytes_mac_to_str, make_bluez_details, ) from habluetooth.scanner_bleak import ( InvalidMessageError, create_bleak_scanner, ) 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_bleak.stop_discovery"), patch("habluetooth.scanner_bleak.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_bleak.IS_LINUX", True), patch("habluetooth.scanner_bleak.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_scanner_module_aliases_scanner_bleak() -> None: """habluetooth.scanner is a back-compat alias of the scanner_bleak module.""" assert scanner_shim is scanner 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_bleak.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_bleak.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_bleak.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_bleak.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_bleak.is_docker_env", return_value=True), patch( "habluetooth.scanner_bleak.OriginalBleakScanner.start", side_effect=FileNotFoundError, ), patch( "habluetooth.scanner_bleak.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_bleak.is_docker_env", return_value=False), patch( "habluetooth.scanner_bleak.OriginalBleakScanner.start", side_effect=FileNotFoundError, ), patch( "habluetooth.scanner_bleak.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_bleak.is_docker_env", return_value=False), patch( "habluetooth.scanner_bleak.OriginalBleakScanner.start", side_effect=asyncio.CancelledError, ), patch( "habluetooth.scanner_bleak.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_bleak.is_docker_env", return_value=False), patch("habluetooth.scanner_bleak.OriginalBleakScanner.start", _start), patch( "habluetooth.scanner_bleak.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_bleak.is_docker_env", return_value=True), patch( "habluetooth.scanner_bleak.OriginalBleakScanner.start", side_effect=BrokenPipeError, ), patch( "habluetooth.scanner_bleak.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_bleak.is_docker_env", return_value=False), patch( "habluetooth.scanner_bleak.OriginalBleakScanner.start", side_effect=BrokenPipeError, ), patch( "habluetooth.scanner_bleak.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_bleak.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_bleak.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_bleak.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_bleak.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 async def test_async_start_completes_when_all_attempts_return_false() -> None: """ All start attempts returning False still finishes the start sequence. Covers the ``_async_start`` loop fall-through where every attempt reports a recoverable failure and the loop is exhausted without breaking. """ success_calls = 0 class _AllAttemptsFailScanner(HaScanner): async def _async_start_attempt(self, attempt: int) -> bool: return False async def _async_on_successful_start(self) -> None: nonlocal success_calls success_calls += 1 ha_scanner = _AllAttemptsFailScanner( BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF" ) ha_scanner.async_setup() await ha_scanner._async_start() assert success_calls == 1 @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_bleak.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_bleak.ADAPTER_INIT_TIME", 0, ), patch_bluetooth_time( start_time_monotonic, ), patch( "habluetooth.scanner_bleak.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_bleak.ADAPTER_INIT_TIME", 0, ), patch_bluetooth_time( start_time_monotonic, ), patch( "habluetooth.scanner_bleak.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_bleak.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_bleak.IS_LINUX", True, ), patch( "habluetooth.scanner_bleak.ADAPTER_INIT_TIME", 0, ), patch_bluetooth_time( start_time_monotonic, ), patch( "habluetooth.scanner_bleak.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_bleak.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 # 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" ) # Replicate what the BlueZ channel does on a raw advertisement: convert # the address and build the details, then call the generic handler. test_address_str = bytes_mac_to_str(test_address) scanner._async_on_raw_advertisement( test_address_str, test_rssi, test_data, make_bluez_details(test_address_str, scanner.adapter), time.monotonic(), ) # 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_bleak.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_bleak.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_bleak.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_bleak.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_bleak.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_bleak.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_bleak.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_bleak.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_bleak.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_bleak.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_bleak.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_bleak.IS_LINUX", True), patch_bleak_scanner_factory(_PassiveFallbackScanner), patch("habluetooth.scanner_bleak.async_reset_adapter", AsyncMock()), patch("habluetooth.scanner_bleak.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_bleak.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_bleak.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_bleak.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_bleak.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_bleak.IS_LINUX", False), patch("habluetooth.scanner_bleak.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_bleak.IS_LINUX", False), patch("habluetooth.scanner_bleak.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_bleak.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_bleak.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-1390f03/tests/test_scanner_mgmt.py000066400000000000000000000556621522222566700251010ustar00rootroot00000000000000"""Tests for the mgmt-based local scanner and the create_local_scanner factory.""" from __future__ import annotations import asyncio from typing import TYPE_CHECKING from unittest.mock import patch import pytest from bluetooth_adapters import DEFAULT_ADDRESS from bluetooth_data_tools import monotonic_time_coarse from habluetooth import ( BluetoothScanningMode, HaScanner, HaScannerMgmt, create_local_scanner, get_manager, ) from habluetooth.channels.bluez import LongTermKey from habluetooth.scanner_bleak import ScannerStartError if TYPE_CHECKING: from collections.abc import Iterator pytestmark = pytest.mark.asyncio _ADAPTER = "hci0" _ADAPTER_IDX = 0 _ADDRESS = "00:11:22:33:44:55" _PEER = "AA:BB:CC:DD:EE:FF" class FakeMgmt: """Stand-in for MGMTBluetoothCtl recording the discovery calls made.""" def __init__(self, *, can_discover: bool = True) -> None: self.can_discover = can_discover self.started: list[int] = [] self.stopped: list[int] = [] self.monitors_added: list[int] = [] self.monitors_removed: list[tuple[int, int]] = [] self.start_ok = True self.stop_ok = True self.remove_ok = True self.monitor_handle: int | None = 7 async def start_discovery(self, idx: int) -> bool: self.started.append(idx) return self.start_ok async def stop_discovery(self, idx: int) -> bool: self.stopped.append(idx) return self.stop_ok async def add_adv_pattern_monitor(self, idx: int) -> int | None: self.monitors_added.append(idx) return self.monitor_handle async def remove_adv_monitor(self, idx: int, handle: int) -> bool: self.monitors_removed.append((idx, handle)) return self.remove_ok @pytest.fixture def use_mgmt() -> Iterator[FakeMgmt]: """Point the manager at a fake mgmt controller that can discover.""" fake = FakeMgmt() with patch.object(get_manager(), "get_bluez_mgmt_ctl", return_value=fake): yield fake def _scanner( mode: BluetoothScanningMode = BluetoothScanningMode.ACTIVE, ) -> HaScannerMgmt: return HaScannerMgmt(mode, _ADAPTER, _ADDRESS) # -- factory -------------------------------------------------------------- async def test_factory_falls_back_without_mgmt() -> None: """With no mgmt controller, the factory returns the bleak scanner.""" with patch.object(get_manager(), "get_bluez_mgmt_ctl", return_value=None): scanner = create_local_scanner(BluetoothScanningMode.ACTIVE, _ADAPTER, _ADDRESS) assert type(scanner) is HaScanner async def test_factory_falls_back_when_cannot_discover() -> None: """A mgmt controller without discovery capability still uses bleak.""" fake = FakeMgmt(can_discover=False) with ( patch("habluetooth.scanner_mgmt.IS_LINUX", True), patch.object(get_manager(), "get_bluez_mgmt_ctl", return_value=fake), ): scanner = create_local_scanner(BluetoothScanningMode.ACTIVE, _ADAPTER, _ADDRESS) assert type(scanner) is HaScanner async def test_factory_falls_back_for_non_hci_adapter(use_mgmt: FakeMgmt) -> None: """A non-hci adapter has no controller index, so it uses bleak.""" with patch("habluetooth.scanner_mgmt.IS_LINUX", True): scanner = create_local_scanner( BluetoothScanningMode.ACTIVE, "CoreBluetooth", _ADDRESS ) assert type(scanner) is HaScanner async def test_factory_falls_back_without_real_address(use_mgmt: FakeMgmt) -> None: """Without a real adapter BD_ADDR the mgmt scanner cannot pin connects.""" with patch("habluetooth.scanner_mgmt.IS_LINUX", True): scanner = create_local_scanner( BluetoothScanningMode.ACTIVE, _ADAPTER, DEFAULT_ADDRESS ) assert type(scanner) is HaScanner async def test_factory_falls_back_for_auto_mode(use_mgmt: FakeMgmt) -> None: """AUTO needs active-window promotion the mgmt scanner lacks, so use bleak.""" with patch("habluetooth.scanner_mgmt.IS_LINUX", True): scanner = create_local_scanner(BluetoothScanningMode.AUTO, _ADAPTER, _ADDRESS) assert type(scanner) is HaScanner async def test_factory_falls_back_without_l2cap(use_mgmt: FakeMgmt) -> None: """Without permission to open L2CAP sockets, connections need the bleak path.""" with ( patch("habluetooth.scanner_mgmt.IS_LINUX", True), patch("habluetooth.scanner_mgmt.can_use_l2cap", return_value=False), ): scanner = create_local_scanner(BluetoothScanningMode.ACTIVE, _ADAPTER, _ADDRESS) assert type(scanner) is HaScanner async def test_factory_returns_mgmt_scanner_when_available( use_mgmt: FakeMgmt, ) -> None: """On Linux with a discovering mgmt socket and an hci adapter, use mgmt.""" with ( patch("habluetooth.scanner_mgmt.IS_LINUX", True), patch("habluetooth.scanner_mgmt.can_use_l2cap", return_value=True), ): scanner = create_local_scanner(BluetoothScanningMode.ACTIVE, _ADAPTER, _ADDRESS) assert type(scanner) is HaScannerMgmt async def test_init_rejects_default_address() -> None: """Direct construction without a real BD_ADDR fails fast.""" with pytest.raises(ValueError, match="real adapter address"): HaScannerMgmt(BluetoothScanningMode.ACTIVE, _ADAPTER, DEFAULT_ADDRESS) # -- construction --------------------------------------------------------- async def test_init_is_connectable_with_connector() -> None: """The scanner is connectable and routes through a mgmt connector.""" scanner = _scanner() assert scanner.connectable is True assert scanner.connector is not None assert scanner.connector.source == _ADDRESS assert scanner.connector.can_connect == scanner._can_connect # The connect path derives the backend id from this; it should read as the # client, not the generic "partial". assert type(scanner.connector.client).__name__ == "HaMgmtClient" # -- discovery lifecycle -------------------------------------------------- async def test_async_start_active_uses_start_discovery(use_mgmt: FakeMgmt) -> None: """Active mode starts kernel discovery and marks the scanner scanning.""" scanner = _scanner(BluetoothScanningMode.ACTIVE) scanner.async_setup() await scanner.async_start() assert use_mgmt.started == [_ADAPTER_IDX] assert scanner.scanning is True assert scanner.current_mode is BluetoothScanningMode.ACTIVE await scanner.async_stop() async def test_start_notifies_mode_change(use_mgmt: FakeMgmt) -> None: """Starting sets the radio mode through the manager notification path.""" scanner = _scanner(BluetoothScanningMode.ACTIVE) scanner.async_setup() with patch.object(get_manager(), "scanner_mode_changed") as notify: await scanner.async_start() notify.assert_called_once_with(scanner) await scanner.async_stop() async def test_async_start_passive_uses_monitor(use_mgmt: FakeMgmt) -> None: """Passive mode adds an advertisement monitor and records its handle.""" scanner = _scanner(BluetoothScanningMode.PASSIVE) scanner.async_setup() await scanner.async_start() assert use_mgmt.monitors_added == [_ADAPTER_IDX] assert scanner._monitor_handle == 7 assert scanner.scanning is True await scanner.async_stop() async def test_async_start_raises_when_unavailable() -> None: """Starting without mgmt discovery raises ScannerStartError.""" scanner = _scanner() scanner.async_setup() with ( patch.object(get_manager(), "get_bluez_mgmt_ctl", return_value=None), pytest.raises(ScannerStartError, match="not available"), ): await scanner.async_start() async def test_async_start_raises_when_discovery_fails(use_mgmt: FakeMgmt) -> None: """A rejected start_discovery surfaces as ScannerStartError.""" use_mgmt.start_ok = False scanner = _scanner(BluetoothScanningMode.ACTIVE) scanner.async_setup() with pytest.raises(ScannerStartError, match="failed to start"): await scanner.async_start() async def test_async_start_raises_when_monitor_fails(use_mgmt: FakeMgmt) -> None: """A rejected monitor registration surfaces as ScannerStartError.""" use_mgmt.monitor_handle = None scanner = _scanner(BluetoothScanningMode.PASSIVE) scanner.async_setup() with pytest.raises(ScannerStartError, match="advertisement monitor"): await scanner.async_start() async def test_async_stop_active_stops_discovery(use_mgmt: FakeMgmt) -> None: """Stopping an active scanner stops kernel discovery.""" scanner = _scanner(BluetoothScanningMode.ACTIVE) scanner.async_setup() await scanner.async_start() await scanner.async_stop() assert use_mgmt.stopped == [_ADAPTER_IDX] assert scanner.scanning is False async def test_async_stop_passive_removes_monitor(use_mgmt: FakeMgmt) -> None: """Stopping a passive scanner removes its advertisement monitor.""" scanner = _scanner(BluetoothScanningMode.PASSIVE) scanner.async_setup() await scanner.async_start() await scanner.async_stop() assert use_mgmt.monitors_removed == [(_ADAPTER_IDX, 7)] assert scanner._monitor_handle is None # -- connection slots ----------------------------------------------------- async def test_can_connect_respects_slot_limit() -> None: """can_connect tracks live connections against the configured slot count.""" scanner = _scanner() get_manager().slot_manager.register_adapter(_ADAPTER, 1) assert scanner._can_connect() is True scanner._register_connection(_PEER) assert scanner._can_connect() is False scanner._unregister_connection(_PEER) assert scanner._can_connect() is True async def test_can_connect_unlimited_without_registered_slots() -> None: """With no slot count registered, connections are not slot-gated.""" scanner = _scanner() assert scanner._can_connect() is True scanner._register_connection(_PEER) assert scanner._can_connect() is True async def test_can_connect_false_when_not_connectable() -> None: """A non-connectable scanner never reports it can connect.""" scanner = _scanner() scanner.connectable = False assert scanner._can_connect() is False async def test_get_allocations_reflects_tracked_connections() -> None: """get_allocations reports free/allocated from the scanner's own tracking.""" scanner = _scanner() assert scanner.get_allocations() is None # no slots registered yet get_manager().slot_manager.register_adapter(_ADAPTER, 3) scanner._register_connection(_PEER) allocations = scanner.get_allocations() assert allocations is not None assert allocations.slots == 3 assert allocations.free == 2 assert allocations.allocated == [_PEER] async def test_get_allocations_clamps_free_at_zero() -> None: """An overshoot past the advisory gate reports zero free, never negative.""" scanner = _scanner() get_manager().slot_manager.register_adapter(_ADAPTER, 1) scanner._register_connection(_PEER) scanner._register_connection("11:22:33:44:55:66") # overshoot the 1 slot allocations = scanner.get_allocations() assert allocations is not None assert allocations.free == 0 async def test_get_allocations_sorts_allocated() -> None: """Allocated addresses are reported in a stable sorted order.""" scanner = _scanner() get_manager().slot_manager.register_adapter(_ADAPTER, 5) scanner._register_connection("CC:CC:CC:CC:CC:CC") scanner._register_connection("AA:AA:AA:AA:AA:AA") scanner._register_connection("BB:BB:BB:BB:BB:BB") allocations = scanner.get_allocations() assert allocations is not None assert allocations.allocated == [ "AA:AA:AA:AA:AA:AA", "BB:BB:BB:BB:BB:BB", "CC:CC:CC:CC:CC:CC", ] # -- watchdog ------------------------------------------------------------- async def test_watchdog_restarts_discovery_when_quiet(use_mgmt: FakeMgmt) -> None: """The watchdog restarts discovery after the adapter goes quiet.""" scanner = _scanner(BluetoothScanningMode.ACTIVE) scanner.async_setup() await scanner.async_start() assert use_mgmt.started == [_ADAPTER_IDX] with patch.object(scanner, "_async_watchdog_triggered", return_value=True): scanner._async_scanner_watchdog() assert scanner.scanning is False # Let the restart background task run to completion. for task in list(scanner._background_tasks): await task assert use_mgmt.stopped == [_ADAPTER_IDX] # stopped during restart assert use_mgmt.started == [_ADAPTER_IDX, _ADAPTER_IDX] # then started again await scanner.async_stop() async def test_watchdog_noop_when_not_triggered(use_mgmt: FakeMgmt) -> None: """The watchdog does nothing while advertisements are still arriving.""" scanner = _scanner(BluetoothScanningMode.ACTIVE) scanner.async_setup() await scanner.async_start() with patch.object(scanner, "_async_watchdog_triggered", return_value=False): scanner._async_scanner_watchdog() assert use_mgmt.stopped == [] assert use_mgmt.started == [_ADAPTER_IDX] await scanner.async_stop() async def test_watchdog_skips_when_restart_in_progress(use_mgmt: FakeMgmt) -> None: """A triggered watchdog does not stack a second restart on the lock.""" scanner = _scanner(BluetoothScanningMode.ACTIVE) scanner.async_setup() await scanner.async_start() async with scanner._start_stop_lock: with patch.object(scanner, "_async_watchdog_triggered", return_value=True): scanner._async_scanner_watchdog() assert scanner._background_tasks == set() # no restart scheduled await scanner.async_stop() async def test_watchdog_restart_logs_on_failure( use_mgmt: FakeMgmt, caplog: pytest.LogCaptureFixture ) -> None: """A failed restart is logged, not raised out of the background task.""" scanner = _scanner(BluetoothScanningMode.ACTIVE) scanner.async_setup() await scanner.async_start() use_mgmt.start_ok = False # the restart's start_discovery will fail with ( patch.object(scanner, "_async_watchdog_triggered", return_value=True), caplog.at_level("ERROR", logger="habluetooth.scanner_mgmt"), ): scanner._async_scanner_watchdog() for task in list(scanner._background_tasks): await task assert "failed to restart" in caplog.text await scanner.async_stop() async def test_restart_does_not_stack_monitor_on_failed_removal( use_mgmt: FakeMgmt, ) -> None: """If a passive monitor cannot be removed, the restart does not add another.""" use_mgmt.remove_ok = False scanner = _scanner(BluetoothScanningMode.PASSIVE) scanner.async_setup() await scanner.async_start() assert use_mgmt.monitors_added == [_ADAPTER_IDX] with patch.object(scanner, "_async_watchdog_triggered", return_value=True): scanner._async_scanner_watchdog() for task in list(scanner._background_tasks): await task # Removal failed, so no second monitor was registered (no stacking/leak), # and the handle is preserved for a retry on the next tick. assert use_mgmt.monitors_added == [_ADAPTER_IDX] assert scanner._monitor_handle == 7 await scanner.async_stop() async def test_restart_does_not_resurrect_a_stopped_scanner( use_mgmt: FakeMgmt, ) -> None: """A restart queued before a stop must not re-issue discovery afterwards.""" scanner = _scanner(BluetoothScanningMode.ACTIVE) scanner.async_setup() await scanner.async_start() await scanner.async_stop() use_mgmt.started.clear() use_mgmt.stopped.clear() # Simulates the queued restart task running after the stop released the lock. await scanner._async_restart() assert use_mgmt.started == [] # discovery was not restarted assert scanner.scanning is False async def test_unsetup_cancels_pending_background_task(use_mgmt: FakeMgmt) -> None: """Teardown cancels an in-flight restart so it cannot restart discovery.""" scanner = _scanner(BluetoothScanningMode.ACTIVE) unsetup = scanner.async_setup() await scanner.async_start() started = asyncio.Event() async def _blocker() -> None: started.set() await asyncio.sleep(3600) scanner._create_background_task(_blocker()) await started.wait() task = next(iter(scanner._background_tasks)) unsetup() await asyncio.gather(task, return_exceptions=True) assert task.cancelled() await scanner.async_stop() async def test_background_task_failure_is_logged( caplog: pytest.LogCaptureFixture, ) -> None: """An exception escaping a background task is logged, not lost at GC.""" scanner = _scanner() async def boom() -> None: msg = "kaboom" raise RuntimeError(msg) with caplog.at_level("ERROR", logger="habluetooth.scanner_mgmt"): scanner._create_background_task(boom()) task = next(iter(scanner._background_tasks)) await asyncio.gather(task, return_exceptions=True) await asyncio.sleep(0) # let the done-callback run assert "background task failed" in caplog.text assert scanner._background_tasks == set() # reference dropped async def test_unsetup_stops_watchdog(use_mgmt: FakeMgmt) -> None: """The teardown callback cancels the watchdog timer.""" scanner = _scanner(BluetoothScanningMode.ACTIVE) unsetup = scanner.async_setup() await scanner.async_start() armed = scanner._cancel_watchdog # capture before asserting to avoid narrowing assert armed is not None # armed by the start unsetup() assert scanner._cancel_watchdog is None await scanner.async_stop() async def test_stop_discovery_noop_without_mgmt() -> None: """Stopping with no mgmt controller is a safe no-op.""" scanner = _scanner() with patch.object(get_manager(), "get_bluez_mgmt_ctl", return_value=None): await scanner._async_stop_discovery() # does not raise async def test_stop_active_warns_on_failure( use_mgmt: FakeMgmt, caplog: pytest.LogCaptureFixture ) -> None: """A failed stop_discovery is logged and the scanner still stops scanning.""" use_mgmt.stop_ok = False scanner = _scanner(BluetoothScanningMode.ACTIVE) scanner.async_setup() await scanner.async_start() with caplog.at_level("WARNING", logger="habluetooth.scanner_mgmt"): await scanner.async_stop() assert "failed to stop mgmt discovery" in caplog.text assert scanner.scanning is False async def test_stop_passive_keeps_handle_on_failed_removal( use_mgmt: FakeMgmt, caplog: pytest.LogCaptureFixture ) -> None: """A failed monitor removal keeps the handle so a later stop can retry.""" use_mgmt.remove_ok = False scanner = _scanner(BluetoothScanningMode.PASSIVE) scanner.async_setup() await scanner.async_start() with caplog.at_level("WARNING", logger="habluetooth.scanner_mgmt"): await scanner.async_stop() assert "failed to remove advertisement monitor" in caplog.text assert scanner._monitor_handle == 7 # not cleared, so a retry is possible async def test_registered_scanner_reports_discovered_devices( use_mgmt: FakeMgmt, ) -> None: """Once registered, the inherited ingestion feeds the read-out overrides.""" scanner = _scanner() scanner.async_setup() unregister = get_manager().async_register_scanner(scanner) try: assert list(scanner.discovered_addresses) == [] assert scanner.discovered_devices == [] assert scanner.discovered_devices_and_advertisement_data == {} assert scanner.get_discovered_device_advertisement_data(_PEER) is None scanner._async_on_advertisement( _PEER, -60, "dev", [], {}, {}, None, {}, monotonic_time_coarse() ) assert _PEER in scanner.discovered_addresses assert _PEER in scanner.discovered_devices_and_advertisement_data result = scanner.get_discovered_device_advertisement_data(_PEER) assert result is not None assert result[0].address == _PEER assert scanner.discovered_devices[0].address == _PEER finally: unregister() async def test_long_term_key_store() -> None: """The scanner stores, lists, and forgets bonded keys per peer.""" scanner = _scanner() key_a = LongTermKey(_PEER, 1, 0x05, False, 16, 0x1234, bytes(8), bytes(16)) # Same peer, distinct (central, ediv) so both survive rather than clobber. key_a2 = LongTermKey(_PEER, 1, 0x05, True, 16, 0x5678, bytes(8), bytes(16)) other = LongTermKey( "11:22:33:44:55:66", 1, 0x05, False, 16, 0x1, bytes(8), bytes(16) ) scanner._store_long_term_key(key_a) scanner._store_long_term_key(key_a2) scanner._store_long_term_key(other) assert set(scanner.export_long_term_keys()) == {key_a, key_a2, other} # Forget is case-insensitive: stored addresses are canonical upper-case. scanner._forget_long_term_keys(_PEER.lower()) assert scanner.export_long_term_keys() == [other] scanner._forget_long_term_keys(_PEER) # idempotent async def test_long_term_key_export_and_restore() -> None: """Keys can be exported for persistence and seeded back on startup.""" scanner = _scanner() key = LongTermKey(_PEER, 1, 0x05, False, 16, 0x1234, bytes(8), bytes(16)) scanner.restore_long_term_keys([key]) assert scanner.export_long_term_keys() == [key] async def test_long_term_keys_changed_callback() -> None: """The change callback fires on store and on an actual forget, not a no-op.""" scanner = _scanner() changes: list[int] = [] scanner.set_long_term_keys_changed_callback(lambda: changes.append(1)) key = LongTermKey(_PEER, 1, 0x05, False, 16, 0x1234, bytes(8), bytes(16)) scanner._store_long_term_key(key) assert len(changes) == 1 scanner._store_long_term_key(key) # identical -> no fire (symmetric with forget) assert len(changes) == 1 updated = LongTermKey(_PEER, 1, 0x05, False, 16, 0x1234, bytes(8), bytes(range(16))) scanner._store_long_term_key(updated) # same tuple, new value -> fires assert len(changes) == 2 scanner._forget_long_term_keys("00:00:00:00:00:00") # nothing removed -> no fire assert len(changes) == 2 scanner._forget_long_term_keys(_PEER) # removes -> fires assert len(changes) == 3 scanner.set_long_term_keys_changed_callback(None) # cleared scanner._store_long_term_key(key) assert len(changes) == 3 async def test_long_term_keys_changed_callback_failure_is_isolated( caplog: pytest.LogCaptureFixture, ) -> None: """A raising persistence callback is logged, not propagated into pairing.""" scanner = _scanner() def boom() -> None: msg = "persistence broke" raise RuntimeError(msg) scanner.set_long_term_keys_changed_callback(boom) key = LongTermKey(_PEER, 1, 0x05, False, 16, 0x1234, bytes(8), bytes(16)) with caplog.at_level("ERROR", logger="habluetooth.scanner_mgmt"): scanner._store_long_term_key(key) # must not raise assert "change callback failed" in caplog.text async def test_slot_limit_zero_when_adapter_unregistered() -> None: """An adapter with no registered slot count reports 0 (unlimited).""" scanner = _scanner() assert scanner._slot_limit() == 0 assert scanner._can_connect() is True Bluetooth-Devices-habluetooth-1390f03/tests/test_storage.py000066400000000000000000000456651522222566700240720ustar00rootroot00000000000000import time from unittest.mock import ANY import pytest from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from habluetooth.channels.bluez import LongTermKey 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, long_term_key_from_dict, long_term_key_to_dict, ) 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 def test_long_term_key_roundtrip() -> None: """A long-term key serializes to JSON-able dict and back unchanged.""" key = LongTermKey( address="AA:BB:CC:DD:EE:FF", address_type=1, key_type=5, central=True, encryption_size=16, ediv=0x1234, rand=bytes(range(8)), value=bytes(range(16)), ) data = long_term_key_to_dict(key) assert data["rand"] == "0001020304050607" # bytes as hex assert data["value"] == "000102030405060708090a0b0c0d0e0f" assert data["central"] is True assert long_term_key_from_dict(data) == key Bluetooth-Devices-habluetooth-1390f03/tests/test_util.py000066400000000000000000000142111522222566700233620ustar00rootroot00000000000000"""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-1390f03/tests/test_wrappers.py000066400000000000000000003031001522222566700242460ustar00rootroot00000000000000"""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 @pytest.mark.asyncio @pytest.mark.usefixtures("enable_bluetooth") async def test_set_connection_params_no_backend_no_scanner_noop( caplog: pytest.LogCaptureFixture, ) -> None: """ set_connection_params is a silent no-op before any backend is chosen. Before connect() runs there is no backend, no connected scanner/device, and no mgmt path, so the call must return without raising and without emitting the unsupported-backend warning (that warning is only for a connected backend that lacks the capability). """ client = bleak.BleakClient("00:00:00:00:00:01") await client.set_connection_params(800, 800, 0, 300) assert "does not support setting connection parameters" not in caplog.text @pytest.mark.asyncio @pytest.mark.usefixtures("enable_bluetooth") async def test_set_disconnected_callback_without_backend() -> None: """ set_disconnected_callback stores the callback before a backend exists. Called prior to connect(), there is no backend to forward to, so the wrapper must accept and retain the callback without raising. """ client = bleak.BleakClient("00:00:00:00:00:01") callback = Mock() # Must not raise even though connect() never ran. client.set_disconnected_callback(callback) # The callback is retained for later (pre-backend) use. assert client._HaBleakClientWrapper__disconnected_callback is callback @pytest.mark.asyncio async def test_connect_as_retry_client_skips_warning( two_adapters: None, enable_bluetooth: None, install_bleak_catcher: None, mock_platform_client: None, caplog: pytest.LogCaptureFixture, ) -> None: """ A retry-client connect skips the bleak-retry-connector warning. Passing ``_is_retry_client=True`` marks the wrapper as driven by bleak-retry-connector's establish_connection, so the "called without bleak-retry-connector" guidance warning must not fire. Pinning the logger above DEBUG also exercises the non-debug connect path (the debug-only description lookup and connect/connected log lines are skipped). """ 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, _is_retry_client=True) wrappers_logger = logging.getLogger("habluetooth.wrappers") previous_level = wrappers_logger.level wrappers_logger.setLevel(logging.INFO) try: with patch.object(FakeBleakClient, "is_connected", return_value=True): await client.connect() assert client.is_connected finally: wrappers_logger.setLevel(previous_level) assert "without bleak-retry-connector" not in caplog.text cancel_hci0() cancel_hci1() @pytest.mark.asyncio @pytest.mark.usefixtures("enable_bluetooth") async def test_connect_no_scanners_raises_no_backend() -> None: """ connect() raises the generic no-backend error when no scanners exist. With no scanners registered at all, the passive-only diagnostic branch is skipped (it only applies when scanners exist but none are connectable) and the wrapper falls through to the generic no-available-slot error. """ client = bleak.BleakClient("00:00:00:00:00:01") with pytest.raises( BleakError, match=( "No backend with an available connection slot that can reach " "address 00:00:00:00:00:01 was found" ), ): await client.connect()