pax_global_header 0000666 0000000 0000000 00000000064 15211177045 0014515 g ustar 00root root 0000000 0000000 52 comment=1ba76eeac0515911afba8967569d40cb1de0486c
Bluetooth-Devices-habluetooth-75cbe37/ 0000775 0000000 0000000 00000000000 15211177045 0017777 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-75cbe37/.all-contributorsrc 0000664 0000000 0000000 00000000462 15211177045 0023632 0 ustar 00root root 0000000 0000000 {
"projectName": "habluetooth",
"projectOwner": "bluetooth-devices",
"repoType": "github",
"repoHost": "https://github.com",
"files": [
"README.md"
],
"imageSize": 80,
"commit": true,
"commitConvention": "angular",
"contributors": [],
"contributorsPerLine": 7,
"skipCi": true
}
Bluetooth-Devices-habluetooth-75cbe37/.copier-answers.yml 0000664 0000000 0000000 00000001053 15211177045 0023540 0 ustar 00root root 0000000 0000000 # Changes here will be overwritten by Copier
_commit: 0b42cfd
_src_path: gh:browniebroke/pypackage-template
add_me_as_contributor: false
copyright_year: '2023'
documentation: true
email: bluetooth@koston.org
full_name: J. Nick Koston
github_username: bluetooth-devices
has_cli: false
initial_commit: true
open_source_license: Apache Software License 2.0
package_name: habluetooth
project_name: habluetooth
project_short_description: High availability Bluetooth
project_slug: habluetooth
run_poetry_install: true
setup_github: true
setup_pre_commit: true
Bluetooth-Devices-habluetooth-75cbe37/.editorconfig 0000664 0000000 0000000 00000000444 15211177045 0022456 0 ustar 00root root 0000000 0000000 # http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
insert_final_newline = true
charset = utf-8
end_of_line = lf
[*.bat]
indent_style = tab
end_of_line = crlf
[LICENSE]
insert_final_newline = false
[Makefile]
indent_style = tab
Bluetooth-Devices-habluetooth-75cbe37/.github/ 0000775 0000000 0000000 00000000000 15211177045 0021337 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-75cbe37/.github/ISSUE_TEMPLATE/ 0000775 0000000 0000000 00000000000 15211177045 0023522 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-75cbe37/.github/ISSUE_TEMPLATE/1-bug_report.md 0000664 0000000 0000000 00000000422 15211177045 0026350 0 ustar 00root root 0000000 0000000 ---
name: Bug report
about: Create a report to help us improve
labels: bug
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
**Additional context**
Add any other context about the problem here.
Bluetooth-Devices-habluetooth-75cbe37/.github/ISSUE_TEMPLATE/2-feature-request.md 0000664 0000000 0000000 00000000672 15211177045 0027331 0 ustar 00root root 0000000 0000000 ---
name: Feature request
about: Suggest an idea for this project
labels: enhancement
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Additional context**
Add any other context or screenshots about the feature request here.
Bluetooth-Devices-habluetooth-75cbe37/.github/actions/ 0000775 0000000 0000000 00000000000 15211177045 0022777 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-75cbe37/.github/actions/setup-uv-python/ 0000775 0000000 0000000 00000000000 15211177045 0026106 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-75cbe37/.github/actions/setup-uv-python/action.yml 0000664 0000000 0000000 00000005524 15211177045 0030114 0 ustar 00root root 0000000 0000000 name: Set up uv and managed Python
description: >-
Pins uv and proactively installs the requested Python so cached venvs
resolve their interpreter symlinks in jobs that only restore the venv.
setup-uv alone only sets UV_PYTHON, it does not actually fetch the
interpreter until uv first uses it, so jobs that just activate a cached
venv blow up with broken symlinks on cache hit. setup-uv v8.1.0 also
fetches its version manifest from raw.githubusercontent.com on every run
even when version is pinned, so the setup step is retried a couple of
times to ride out raw.githubusercontent.com flakes.
inputs:
python-version:
description: The Python version uv should install and use.
required: true
uv-version:
description: The uv version setup-uv should install.
required: true
outputs:
python-version:
description: The Python version uv reports as installed.
value: ${{ steps.uv3.outputs.python-version || steps.uv2.outputs.python-version || steps.uv1.outputs.python-version }}
runs:
using: composite
steps:
- name: Set up uv (attempt 1)
id: uv1
continue-on-error: true
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: ${{ inputs.uv-version }}
python-version: ${{ inputs.python-version }}
# Persist astral's managed Python across jobs so 'uv venv' / poetry
# env use below is fast on the second job onwards.
cache-python: true
# Jobs that only configure the toolchain (no deps to cache) would
# otherwise abort with "Nothing to cache" on the post step.
ignore-nothing-to-cache: true
- name: Set up uv (attempt 2)
id: uv2
if: steps.uv1.outcome == 'failure'
continue-on-error: true
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: ${{ inputs.uv-version }}
python-version: ${{ inputs.python-version }}
cache-python: true
ignore-nothing-to-cache: true
- name: Set up uv (attempt 3)
id: uv3
if: steps.uv1.outcome == 'failure' && steps.uv2.outcome == 'failure'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: ${{ inputs.uv-version }}
python-version: ${{ inputs.python-version }}
cache-python: true
ignore-nothing-to-cache: true
- name: Install Python interpreter
shell: bash
env:
PYTHON_VERSION: ${{ inputs.python-version }}
# 'uv python install' on Windows blows up with a reparse-point tag
# mismatch (os error 4394) when cache-python: true already restored
# the install dir, so only install when find says we have nothing yet.
run: |
if ! uv python find "${PYTHON_VERSION}" >/dev/null 2>&1; then
uv python install "${PYTHON_VERSION}"
fi
Bluetooth-Devices-habluetooth-75cbe37/.github/dependabot.yml 0000664 0000000 0000000 00000001344 15211177045 0024171 0 ustar 00root root 0000000 0000000 # To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
commit-message:
prefix: "chore(ci): "
groups:
github-actions:
patterns:
- "*"
- package-ecosystem: "pip" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
Bluetooth-Devices-habluetooth-75cbe37/.github/labels.toml 0000664 0000000 0000000 00000003515 15211177045 0023502 0 ustar 00root root 0000000 0000000 [breaking]
color = "ffcc00"
name = "breaking"
description = "Breaking change."
[bug]
color = "d73a4a"
name = "bug"
description = "Something isn't working"
[dependencies]
color = "0366d6"
name = "dependencies"
description = "Pull requests that update a dependency file"
[github_actions]
color = "000000"
name = "github_actions"
description = "Update of github actions"
[documentation]
color = "1bc4a5"
name = "documentation"
description = "Improvements or additions to documentation"
[duplicate]
color = "cfd3d7"
name = "duplicate"
description = "This issue or pull request already exists"
[enhancement]
color = "a2eeef"
name = "enhancement"
description = "New feature or request"
["good first issue"]
color = "7057ff"
name = "good first issue"
description = "Good for newcomers"
["help wanted"]
color = "008672"
name = "help wanted"
description = "Extra attention is needed"
[invalid]
color = "e4e669"
name = "invalid"
description = "This doesn't seem right"
[nochangelog]
color = "555555"
name = "nochangelog"
description = "Exclude pull requests from changelog"
[question]
color = "d876e3"
name = "question"
description = "Further information is requested"
[removed]
color = "e99695"
name = "removed"
description = "Removed piece of functionalities."
[tests]
color = "bfd4f2"
name = "tests"
description = "CI, CD and testing related changes"
[wontfix]
color = "ffffff"
name = "wontfix"
description = "This will not be worked on"
[discussion]
color = "c2e0c6"
name = "discussion"
description = "Some discussion around the project"
[hacktoberfest]
color = "ffa663"
name = "hacktoberfest"
description = "Good issues for Hacktoberfest"
[answered]
color = "0ee2b6"
name = "answered"
description = "Automatically closes as answered after a delay"
[waiting]
color = "5f7972"
name = "waiting"
description = "Automatically closes if no answer after a delay"
Bluetooth-Devices-habluetooth-75cbe37/.github/workflows/ 0000775 0000000 0000000 00000000000 15211177045 0023374 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-75cbe37/.github/workflows/auto-merge.yml 0000664 0000000 0000000 00000001134 15211177045 0026163 0 ustar 00root root 0000000 0000000 name: Dependabot auto-merge
on: pull_request_target
permissions:
pull-requests: write
contents: write
jobs:
dependabot:
runs-on: ubuntu-latest
if: ${{ github.actor == 'dependabot[bot]' }}
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v3
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Enable auto-merge for Dependabot PRs
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{github.event.pull_request.html_url}}
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
Bluetooth-Devices-habluetooth-75cbe37/.github/workflows/ci.yml 0000664 0000000 0000000 00000027627 15211177045 0024530 0 ustar 00root root 0000000 0000000 name: CI
on:
push:
branches:
- main
pull_request:
concurrency:
group: ${{ github.head_ref || github.run_id }}
cancel-in-progress: true
env:
POETRY_VIRTUALENVS_IN_PROJECT: "true"
UV_PYTHON_PREFERENCE: only-managed
UV_VERSION: "0.11.16"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5
with:
python-version: 3.13
- uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
# Make sure the PR title follows the conventional commits convention:
# https://www.conventionalcommits.org
# PRs are squash-merged, so the PR title becomes the commit on main and
# drives python-semantic-release's version bump.
pr-title:
name: Lint PR Title
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
permissions:
pull-requests: read
steps:
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
subjectPattern: ^(?![A-Z]).+$
subjectPatternError: |
The subject "{subject}" found in the pull request title "{title}"
didn't match the configured pattern. Please ensure that the subject
starts with a lowercase character.
test:
strategy:
fail-fast: false
matrix:
python-version:
- "3.11"
- "3.12"
- "3.13"
- "3.14"
- "3.14t"
os:
- ubuntu-latest
- macOS-latest
- windows-latest
extension:
- "skip_cython"
- "use_cython"
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
- name: Set up uv and Python ${{ matrix.python-version }}
id: python
uses: ./.github/actions/setup-uv-python
with:
uv-version: ${{ env.UV_VERSION }}
python-version: ${{ matrix.python-version }}
- name: Install poetry
run: uv tool install poetry
shell: bash
- name: Cache poetry venv
id: cache-venv
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
.venv
src/habluetooth/**/*.so
key: venv-v3-${{ runner.os }}-py${{ steps.python.outputs.python-version }}-${{ matrix.extension }}-${{ hashFiles('poetry.lock', 'pyproject.toml', 'build_ext.py', 'src/habluetooth/**/*.py', 'src/habluetooth/**/*.pxd') }}
- name: Install Dependencies
if: steps.cache-venv.outputs.cache-hit != 'true'
env:
PYTHON_VERSION: ${{ matrix.python-version }}
run: |
poetry env use "$(uv python find "${PYTHON_VERSION}")"
if [ "${{ matrix.extension }}" = "skip_cython" ]; then
SKIP_CYTHON=1 poetry install --only=main,dev
else
REQUIRE_CYTHON=1 poetry install --only=main,dev
fi
shell: bash
- name: Test with Pytest
run: poetry run pytest --cov-report=xml
shell: bash
- name: Upload coverage to Codecov
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
benchmark:
# Keep actions/setup-python here so codspeed history stays comparable;
# astral's managed Python ships PGO/LTO/BOLT/mimalloc which would shift
# the baseline.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
- name: Set up uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
- name: Install poetry
run: uv tool install poetry
- name: Setup Python 3.14
id: setup-python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5
with:
python-version: "3.14"
cache: "poetry"
allow-prereleases: true
- name: Cache poetry venv
id: cache-venv
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
.venv
src/habluetooth/**/*.so
key: venv-v2-${{ runner.os }}-benchmark-py${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('poetry.lock', 'pyproject.toml', 'build_ext.py', 'src/habluetooth/**/*.py', 'src/habluetooth/**/*.pxd') }}
- name: Install Dependencies
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
REQUIRE_CYTHON=1 poetry install --only=main,dev
shell: bash
- name: Run benchmarks
uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v3
with:
token: ${{ secrets.CODSPEED_TOKEN }}
run: poetry run pytest --no-cov -vvvvv --codspeed
mode: instrumentation
release:
needs:
- test
- lint
runs-on: ubuntu-latest
# Only enter the protected 'release' environment when actually releasing
# from main; PR dry-runs would otherwise be blocked by the env's
# main-only branch policy.
environment: ${{ github.ref_name == 'main' && 'release' || '' }}
concurrency:
group: release-${{ github.head_ref || github.ref }}
cancel-in-progress: false
permissions:
id-token: write
contents: write
outputs:
released: ${{ steps.release.outputs.released }}
newest_release_tag: ${{ steps.release.outputs.tag }}
steps:
# Mint a short-lived installation token for the release-bot GitHub
# App, which is in the main ruleset's bypass_actors list so PSR's
# version-bump commit/tag push isn't blocked by required checks.
# Per PSR's docs, the same token must be passed to actions/checkout
# (via `token`) so its persisted http.extraheader doesn't override
# PSR's URL-embedded auth at push time and re-attribute the push
# to github-actions[bot].
- name: Generate release App token
if: github.ref_name == 'main'
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
with:
fetch-depth: 0
ref: ${{ github.ref }}
token: ${{ steps.app-token.outputs.token || github.token }}
- name: Create local branch name
run: git switch -C ${{ github.head_ref || github.ref_name }}
# Do a dry run of PSR
- name: Test release
uses: python-semantic-release/python-semantic-release@350c48fcb3ffcdfd2e0a235206bc2ecea6b69df0 # v10.5.3
if: github.ref_name != 'main'
with:
no_operation_mode: true
# On main branch: actual PSR + upload to PyPI & GitHub
- name: Release
uses: python-semantic-release/python-semantic-release@350c48fcb3ffcdfd2e0a235206bc2ecea6b69df0 # v10.5.3
id: release
if: github.ref_name == 'main'
with:
github_token: ${{ steps.app-token.outputs.token }}
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
if: steps.release.outputs.released == 'true'
- name: Publish package distributions to GitHub Releases
uses: python-semantic-release/upload-to-gh-release@0a92b5d7ebfc15a84f9801ebd1bf706343d43711 # main
if: steps.release.outputs.released == 'true'
with:
github_token: ${{ steps.app-token.outputs.token }}
build_wheels:
needs: [release]
if: needs.release.outputs.released == 'true'
name: Wheels for ${{ matrix.os }} (${{ matrix.musl == 'musllinux' && 'musllinux' || 'manylinux' }}) ${{ matrix.qemu }} ${{ matrix.pyver }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [windows-latest, ubuntu-24.04-arm, ubuntu-latest, macos-latest]
qemu: [""]
musl: [""]
pyver: [""]
include:
- os: ubuntu-latest
musl: "musllinux"
- os: ubuntu-24.04-arm
musl: "musllinux"
# qemu is slow, make a single
# runner per Python version
- os: ubuntu-latest
qemu: armv7l
musl: "musllinux"
pyver: cp311
- os: ubuntu-latest
qemu: armv7l
musl: "musllinux"
pyver: cp312
- os: ubuntu-latest
qemu: armv7l
musl: "musllinux"
pyver: cp313
- os: ubuntu-latest
qemu: armv7l
musl: "musllinux"
pyver: cp314
- os: ubuntu-latest
qemu: armv7l
musl: "musllinux"
pyver: cp314t
# qemu is slow, make a single
# runner per Python version
- os: ubuntu-latest
qemu: armv7l
musl: ""
pyver: cp311
- os: ubuntu-latest
qemu: armv7l
musl: ""
pyver: cp312
- os: ubuntu-latest
qemu: armv7l
musl: ""
pyver: cp313
- os: ubuntu-latest
qemu: armv7l
musl: ""
pyver: cp314
- os: ubuntu-latest
qemu: armv7l
musl: ""
pyver: cp314t
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
with:
ref: ${{ needs.release.outputs.newest_release_tag }}
fetch-depth: 0
# Used to host cibuildwheel
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5
with:
python-version: "3.12"
- name: Set up QEMU
if: ${{ matrix.qemu }}
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
with:
platforms: all
# This should be temporary
# xref https://github.com/docker/setup-qemu-action/issues/188
# xref https://github.com/tonistiigi/binfmt/issues/215
image: tonistiigi/binfmt:qemu-v8.1.5
id: qemu
- name: Prepare emulation
if: ${{ matrix.qemu }}
run: |
if [[ -n "${{ matrix.qemu }}" ]]; then
# Build emulated architectures only if QEMU is set,
# use default "auto" otherwise
echo "CIBW_ARCHS_LINUX=${{ matrix.qemu }}" >> $GITHUB_ENV
fi
- name: Limit to a specific Python version on slow QEMU
if: ${{ matrix.pyver }}
run: |
if [[ -n "${{ matrix.pyver }}" ]]; then
echo "CIBW_BUILD=${{ matrix.pyver }}-*" >> $GITHUB_ENV
fi
- name: Build wheels
uses: pypa/cibuildwheel@8d2b08b68458a16aeb24b64e68a09ab1c8e82084 # v3.4.1
env:
CIBW_SKIP: cp36-* cp37-* cp38-* cp39-* cp310-* pp* ${{ matrix.musl == 'musllinux' && '*manylinux*' || '*musllinux*' }}
REQUIRE_CYTHON: 1
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4
with:
name: wheels-${{ matrix.os }}-${{ matrix.musl }}-${{ matrix.pyver }}-${{ matrix.qemu }}
path: ./wheelhouse/*.whl
upload_pypi:
needs: [build_wheels]
runs-on: ubuntu-latest
environment: release
permissions:
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v4
with:
# unpacks default artifact into dist/
# if `name: artifact` is omitted, the action will create extra parent dir
path: dist
pattern: wheels-*
merge-multiple: true
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
Bluetooth-Devices-habluetooth-75cbe37/.github/workflows/hacktoberfest.yml 0000664 0000000 0000000 00000000534 15211177045 0026745 0 ustar 00root root 0000000 0000000 name: Hacktoberfest
on:
schedule:
# Run every day in October
- cron: "0 0 * 10 *"
# Run on the 1st of November to revert
- cron: "0 13 1 11 *"
jobs:
hacktoberfest:
runs-on: ubuntu-latest
steps:
- uses: browniebroke/hacktoberfest-labeler-action@v2.6.0
with:
github_token: ${{ secrets.GH_PAT }}
Bluetooth-Devices-habluetooth-75cbe37/.github/workflows/issue-manager.yml 0000664 0000000 0000000 00000001340 15211177045 0026655 0 ustar 00root root 0000000 0000000 name: Issue Manager
on:
schedule:
- cron: "0 0 * * *"
issue_comment:
types:
- created
issues:
types:
- labeled
pull_request_target:
types:
- labeled
workflow_dispatch:
jobs:
issue-manager:
runs-on: ubuntu-latest
steps:
- uses: tiangolo/issue-manager@0.6.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
config: >
{
"answered": {
"message": "Assuming the original issue was solved, it will be automatically closed now."
},
"waiting": {
"message": "Automatically closing. To re-open, please provide the additional information requested."
}
}
Bluetooth-Devices-habluetooth-75cbe37/.github/workflows/poetry-upgrade.yml 0000664 0000000 0000000 00000000337 15211177045 0027071 0 ustar 00root root 0000000 0000000 name: Upgrader
on:
workflow_dispatch:
schedule:
- cron: "1 5 23 * *"
jobs:
upgrade:
uses: browniebroke/github-actions/.github/workflows/poetry-upgrade.yml@v1
secrets:
gh_pat: ${{ secrets.GH_PAT }}
Bluetooth-Devices-habluetooth-75cbe37/.gitignore 0000664 0000000 0000000 00000004114 15211177045 0021767 0 ustar 00root root 0000000 0000000 # Created by .ignore support plugin (hsz.mobi)
### Python template
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
*.c
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder {{package_name}} settings
.spyderproject
.spyproject
# Rope {{package_name}} settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
Bluetooth-Devices-habluetooth-75cbe37/.gitpod.yml 0000664 0000000 0000000 00000000306 15211177045 0022065 0 ustar 00root root 0000000 0000000 tasks:
- command: |
pip install poetry
PIP_USER=false poetry install
- command: |
pip install pre-commit
pre-commit install
PIP_USER=false pre-commit install-hooks
Bluetooth-Devices-habluetooth-75cbe37/.idea/ 0000775 0000000 0000000 00000000000 15211177045 0020757 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-75cbe37/.idea/habluetooth.iml 0000664 0000000 0000000 00000000515 15211177045 0024001 0 ustar 00root root 0000000 0000000
Bluetooth-Devices-habluetooth-75cbe37/.idea/watcherTasks.xml 0000664 0000000 0000000 00000005253 15211177045 0024151 0 ustar 00root root 0000000 0000000
Bluetooth-Devices-habluetooth-75cbe37/.idea/workspace.xml 0000664 0000000 0000000 00000002736 15211177045 0023507 0 ustar 00root root 0000000 0000000
Bluetooth-Devices-habluetooth-75cbe37/.pre-commit-config.yaml 0000664 0000000 0000000 00000002564 15211177045 0024267 0 ustar 00root root 0000000 0000000 # See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
exclude: "CHANGELOG.md|.copier-answers.yml|.all-contributorsrc"
default_stages: [pre-commit]
ci:
autofix_commit_msg: "chore(pre-commit.ci): auto fixes"
autoupdate_commit_msg: "chore(pre-commit.ci): pre-commit autoupdate"
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: debug-statements
- id: check-builtin-literals
- id: check-case-conflict
- id: check-docstring-first
- id: check-json
- id: check-toml
- id: check-xml
- id: check-yaml
- id: detect-private-key
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/python-poetry/poetry
rev: 2.4.1
hooks:
- id: poetry-check
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
hooks:
- id: prettier
args: ["--tab-width", "2"]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.15
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
- repo: https://github.com/codespell-project/codespell
rev: v2.4.2
hooks:
- id: codespell
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v2.1.0
hooks:
- id: mypy
additional_dependencies: []
Bluetooth-Devices-habluetooth-75cbe37/.readthedocs.yml 0000664 0000000 0000000 00000001051 15211177045 0023062 0 ustar 00root root 0000000 0000000 # Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Set the version of Python and other tools you might need
build:
os: ubuntu-20.04
tools:
python: "3.12"
jobs:
post_create_environment:
# Install poetry
- pip install poetry
post_install:
# Install dependencies
- VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --with docs
# Build documentation in the docs directory with Sphinx
sphinx:
configuration: docs/conf.py
Bluetooth-Devices-habluetooth-75cbe37/CHANGELOG.md 0000664 0000000 0000000 00000117610 15211177045 0021616 0 ustar 00root root 0000000 0000000 # Changelog
## v6.8.3 (2026-06-07)
### Bug fixes
- Promote non-connectable advertisement when a registered connectable path exists ([`59171a8`](https://github.com/Bluetooth-Devices/habluetooth/commit/59171a89b18e7855c1b038e9d951a5975b003204))
### Testing
- Cover manager.py error-handling and edge branches ([`30cd335`](https://github.com/Bluetooth-Devices/habluetooth/commit/30cd335f13e9f1964f869da682bc8899b8bf23cf))
## v6.8.2 (2026-06-07)
### Bug fixes
- Register scanner detection callback on start, not init ([`3924b4c`](https://github.com/Bluetooth-Devices/habluetooth/commit/3924b4cc0b352ac1af23045fdb2f20ec023e3888))
### Testing
- Benchmark auto-scheduler on_advertisement ingestion path ([`8232243`](https://github.com/Bluetooth-Devices/habluetooth/commit/82322437ad58427d14c6842338bf3369b0075faf))
- Cover untested hableakclientwrapper public methods ([`ec71cd4`](https://github.com/Bluetooth-Devices/habluetooth/commit/ec71cd4f90af8be11142752094de130ff59fb527))
## v6.8.1 (2026-06-01)
### Bug fixes
- Show scanner name in reachability diagnostics via line ([`632e840`](https://github.com/Bluetooth-Devices/habluetooth/commit/632e84038f90610b4235f06b481ca22b68ee0893))
## v6.8.0 (2026-05-29)
### Features
- Add async_address_reachability_diagnostics for unreachable devices ([`2429ce8`](https://github.com/Bluetooth-Devices/habluetooth/commit/2429ce8ea7caa62652363decad115511a63b7eeb))
## v6.7.9 (2026-05-27)
### Performance improvements
- Per-worker owned-due-at view for o(owned) wakes ([`d50defb`](https://github.com/Bluetooth-Devices/habluetooth/commit/d50defb3dfc4563373b8beb8d38b0d14ce911644))
## v6.7.8 (2026-05-27)
### Bug fixes
- Propagate refresh errors to concurrent adapter refresh waiters ([`ba75c66`](https://github.com/Bluetooth-Devices/habluetooth/commit/ba75c6645326babfa42443c3ebc34fe0760ee3c1))
## v6.7.7 (2026-05-26)
### Performance improvements
- Materialize discovered_addresses once per cycle in _async_check_unavailable ([`e29434e`](https://github.com/Bluetooth-Devices/habluetooth/commit/e29434e1f363b08f200c9b4daf9deb9a676c732c))
## v6.7.6 (2026-05-26)
### Bug fixes
- Make scanner unregister idempotent ([`44722dd`](https://github.com/Bluetooth-Devices/habluetooth/commit/44722dd276c3054e8bb1cb87ddb67b9d4b2716f3))
### Testing
- Benchmarks for auto_scheduler per-worker owned-needs (#506) ([`5d4b90c`](https://github.com/Bluetooth-Devices/habluetooth/commit/5d4b90c16cab38047381800d90c88ea31df34fff))
## v6.7.5 (2026-05-26)
### Bug fixes
- Correct class name in _discovered_device_timestamps deprecation warning ([`ba8632d`](https://github.com/Bluetooth-Devices/habluetooth/commit/ba8632d21938d5fac8f902f0991fbec3bbabea2b))
### Testing
- Benchmarks for _async_check_unavailable (issue #505) ([`baff661`](https://github.com/Bluetooth-Devices/habluetooth/commit/baff66138f06229979d7f4ea563fdfa43e2f886d))
## v6.7.4 (2026-05-25)
### Bug fixes
- Report passive or active for local scanner current_mode under auto ([`e2bd7c4`](https://github.com/Bluetooth-Devices/habluetooth/commit/e2bd7c429661fd94657aee4980d3c63ee554a88a))
## v6.7.3 (2026-05-25)
### Bug fixes
- Bluez backend construction under bleak 3.x ([`05e2ff7`](https://github.com/Bluetooth-Devices/habluetooth/commit/05e2ff7f8ab5ed47bfb27984162009bf765bf14f))
## v6.7.2 (2026-05-24)
### Bug fixes
- Fast-return when on-demand sweep dispatches nothing ([`2f7316d`](https://github.com/Bluetooth-Devices/habluetooth/commit/2f7316d0b1d43052c255cb8429f9bc8ea8f9ea4b))
## v6.7.1 (2026-05-24)
### Bug fixes
- Address late review comments on async_request_active_scan ([`5ddc407`](https://github.com/Bluetooth-Devices/habluetooth/commit/5ddc40766b2be02999984b029edca2c3b82dcdc3))
- Coalesce near-future due entries into the current window ([`e556e32`](https://github.com/Bluetooth-Devices/habluetooth/commit/e556e32511ca041938f1ad6e152c5fd7bc8de52e))
## v6.7.0 (2026-05-23)
### Features
- Expose async_request_active_scan for on-demand discovery ([`96d9153`](https://github.com/Bluetooth-Devices/habluetooth/commit/96d9153ec47bcb3161b3f57d01758be2bf84a951))
## v6.6.1 (2026-05-23)
### Bug fixes
- Shorten initial sweep delay from 10m to 4m ([`0b24ff7`](https://github.com/Bluetooth-Devices/habluetooth/commit/0b24ff7fa1577ed0c29b4d91734257cd5d45d9dd))
## v6.6.0 (2026-05-23)
### Features
- Expose diagnostics for sweeps, windows, and watched devices ([`6a6e084`](https://github.com/Bluetooth-Devices/habluetooth/commit/6a6e08492954e7b624e435fb3cda96921b11f9a2))
## v6.5.0 (2026-05-23)
### Features
- Route active-window scans around a connecting scanner ([`2d1471d`](https://github.com/Bluetooth-Devices/habluetooth/commit/2d1471d297a395a44131495697df3d40ea12aa0f))
## v6.4.0 (2026-05-23)
### Features
- Log mgmt side channel state when watchdog fires ([`edad0e7`](https://github.com/Bluetooth-Devices/habluetooth/commit/edad0e7a35a5f2b6b21a3bd370f1fc7515df3e0c))
### Testing
- Collapse repeated detection-recorder closures into a helper ([`3f18867`](https://github.com/Bluetooth-Devices/habluetooth/commit/3f18867114ee927e6fff57e7c0771302aa1d0762))
- Extract injectableremotescanner base shared across test files ([`12f312d`](https://github.com/Bluetooth-Devices/habluetooth/commit/12f312d47c272c1695c25f3df3ce108a28c449af))
## v6.3.1 (2026-05-23)
### Bug fixes
- Narrow deserialize exception so shape mismatches don't hide bugs ([`89197d6`](https://github.com/Bluetooth-Devices/habluetooth/commit/89197d69bef50ea4b082900c5647a9ba65e39e80))
### Refactoring
- Extract _should_keep_previous_adv from hot path ([`c4c862f`](https://github.com/Bluetooth-Devices/habluetooth/commit/c4c862f39154f3d2fb79a0438bcecffcce9942de))
- Dedup source-keyed callback register/dispatch trios ([`d8df7b3`](https://github.com/Bluetooth-Devices/habluetooth/commit/d8df7b3fe6355833bfcfed98008b273cea25cbac))
### Testing
- Factor inline mockbleakscanner classes into a shared base ([`6c590d6`](https://github.com/Bluetooth-Devices/habluetooth/commit/6c590d6d0efcc2e6c36135d8200afc5c06371e01))
- Cover the lines codecov flagged on the auto-scan-mode merge ([`a561579`](https://github.com/Bluetooth-Devices/habluetooth/commit/a561579c4ed0fa630b447241e9ecf8adfafbd688))
## v6.3.0 (2026-05-22)
### Features
- Add auto scanning mode with on-demand active windows ([`89c374e`](https://github.com/Bluetooth-Devices/habluetooth/commit/89c374eda9f95f228e3b72d68988e514aa9a97ef))
### Testing
- Cover scanner.py error-path branches ([`4f870d5`](https://github.com/Bluetooth-Devices/habluetooth/commit/4f870d538e95a0039c0959d04a99a9b8f555096f))
- Bump pytest-timeout to 60s for benchmark suite ([`4abde2e`](https://github.com/Bluetooth-Devices/habluetooth/commit/4abde2e6214ea6562002eb6825b7357f7811185c))
- Add pytest-timeout with 5s default timeout ([`3d52e76`](https://github.com/Bluetooth-Devices/habluetooth/commit/3d52e76c118eb0a764341299d902dbe099eab235))
## v6.2.1 (2026-05-22)
### Bug fixes
- Scope cp314 wheel build to non free threaded abi ([`84162aa`](https://github.com/Bluetooth-Devices/habluetooth/commit/84162aa94b61cab70bdebec4dcbf1d0fd0204884))
## v6.2.0 (2026-05-22)
### Bug fixes
- Use release-bot app token so psr push bypasses main ruleset ([`4595999`](https://github.com/Bluetooth-Devices/habluetooth/commit/4595999cf92d51dfe21797dde2e2e4cd27f09246))
- Unify connect cleanup + phase 2 lifecycle audit findings (#340) ([`6578751`](https://github.com/Bluetooth-Devices/habluetooth/commit/6578751020aec5571e309b851f359157fdde9ba7))
### Testing
- Rename test_update_name_cache_* to test_seed_name_cache_* ([`29a8bca`](https://github.com/Bluetooth-Devices/habluetooth/commit/29a8bca04dc56855dd1a620d7b7d5c98ca121c21))
- Cover manager.py uncovered branches ([`47d4b71`](https://github.com/Bluetooth-Devices/habluetooth/commit/47d4b71899c7086b72de53f9aa1384836735ca99))
- Silence pytest collection and deprecated-shim warnings ([`6ae3eda`](https://github.com/Bluetooth-Devices/habluetooth/commit/6ae3eda86d6abd606aa4963b850373cb54ac61fd))
- Cover baseharemotescanner restore/diagnostics/mode no-ops ([`62e7be2`](https://github.com/Bluetooth-Devices/habluetooth/commit/62e7be200fc58c9a8ff6fc78a6c048741afa747e))
- Cover util and central_manager helpers ([`84b9d4d`](https://github.com/Bluetooth-Devices/habluetooth/commit/84b9d4dcb860a9905c9fa8f1132ec1e59b89ee20))
### Features
- Share device name cache across scanners ([`bb76920`](https://github.com/Bluetooth-Devices/habluetooth/commit/bb769205c7cb6172071f22a2a105a98447a53269))
- Track lifetime connect counters per scanner ([`416a438`](https://github.com/Bluetooth-Devices/habluetooth/commit/416a43867c1dc53c4d145a92fca7a4af4b2a380e))
- Surface connect-in-progress and failure counters in scanner diagnostics ([`32db633`](https://github.com/Bluetooth-Devices/habluetooth/commit/32db63362dfd8544e32423ad0c7924ea04048b26))
- Support bleak 3.0+ without deprecation warnings ([`6bfa77f`](https://github.com/Bluetooth-Devices/habluetooth/commit/6bfa77f13d15441c3a9ddb4eb886253630b5ce86))
### Performance improvements
- Parallelize cython extension compilation ([`a39c93b`](https://github.com/Bluetooth-Devices/habluetooth/commit/a39c93bb5d9ad331a6ac058fb980dd8d66a0cdb2))
### Documentation
- Add claude.md guide for ai assistants ([`b12f98a`](https://github.com/Bluetooth-Devices/habluetooth/commit/b12f98ac3a1cc275bc8844913c27cdbd30116c68))
## v6.1.0 (2026-04-19)
### Features
- Restore register_detection_callback as a deprecated shim ([`7fcc147`](https://github.com/Bluetooth-Devices/habluetooth/commit/7fcc147b7e00249c3489c8ce70eac7fd4725372c))
## v6.0.1 (2026-04-17)
### Bug fixes
- Initialize _backend_id so hableakclientwrapper.backend_id works ([`8919d40`](https://github.com/Bluetooth-Devices/habluetooth/commit/8919d402c967ddda3850a2a0f86e804a972af076))
## v6.0.0 (2026-04-04)
### Features
- Remove basebleakscanner inheritance and register_detection_callback ([`87b89f1`](https://github.com/Bluetooth-Devices/habluetooth/commit/87b89f158226c69d1ac2561bbd43d730b699c6b9))
## v5.14.0 (2026-04-04)
### Features
- Implement advertisement_data async iterator in hableakscannerwrapper ([`e6e3727`](https://github.com/Bluetooth-Devices/habluetooth/commit/e6e3727cc774efd6f8ddd1c1140c9ac42bdd6aee))
- Implement discovered_devices_and_advertisement_data in hableakscannerwrapper ([`7ecd2d8`](https://github.com/Bluetooth-Devices/habluetooth/commit/7ecd2d8a4ce5397d39b737cd7367217ee4e196d1))
## v5.13.0 (2026-04-04)
### Features
- Implement async context manager protocol in hableakscannerwrapper ([`c9341d2`](https://github.com/Bluetooth-Devices/habluetooth/commit/c9341d29f8f9335ede23908b450d3ba57a0db20d))
- Implement find_device_by_name in hableakscannerwrapper ([`151b71c`](https://github.com/Bluetooth-Devices/habluetooth/commit/151b71c56f62b9952be5a7a4e5899034795169e7))
## v5.12.0 (2026-04-04)
### Features
- Implement find_device_by_filter in hableakscannerwrapper ([`ac2e258`](https://github.com/Bluetooth-Devices/habluetooth/commit/ac2e2584271eff55615420f38fde2f0231f10320))
## v5.11.2 (2026-03-27)
### Bug fixes
- Ensure we don't call cleanup twice ([`fbbe17e`](https://github.com/Bluetooth-Devices/habluetooth/commit/fbbe17e13e63e6c914d8e58f07a0d14e3f914474))
## v5.11.1 (2026-03-22)
### Bug fixes
- Revert "feat: add python 3.14 support" ([`2cc4541`](https://github.com/Bluetooth-Devices/habluetooth/commit/2cc454145415da40001f181805c197c1035ca82a))
## v5.11.0 (2026-03-21)
### Features
- Add async_clear_advertisement_history to bluetoothmanager ([`1fb9e01`](https://github.com/Bluetooth-Devices/habluetooth/commit/1fb9e019dd358c9c352212d89ff0fdfeb7247972))
- Add python 3.14 support ([`cd51eb8`](https://github.com/Bluetooth-Devices/habluetooth/commit/cd51eb8c4074ab83697434b1178d4df0eec30a61))
## v5.10.3 (2026-03-21)
### Bug fixes
- Ensure bluez helper is cython compiled ([`8be8853`](https://github.com/Bluetooth-Devices/habluetooth/commit/8be885394247e4b045fe57ebec0b5f852cec255b))
## v5.10.2 (2026-03-15)
### Bug fixes
- Remove macos-13 from wheel build matrix ([`13166b2`](https://github.com/Bluetooth-Devices/habluetooth/commit/13166b2a00b776738f27097ae40742dc03b4364d))
## v5.10.1 (2026-03-15)
### Performance improvements
- Skip raw advertisement parsing when data unchanged ([`84e1b33`](https://github.com/Bluetooth-Devices/habluetooth/commit/84e1b33733732dbd788cc243a72dc49aa80df84e))
### Testing
- Add dedup coverage tests for scanner_adv_received ([`002f40e`](https://github.com/Bluetooth-Devices/habluetooth/commit/002f40e3860173fd846183bf0cf0db67078b0b80))
- Add bluez raw, bleak, and mgmt end-to-end advertisement benchmarks ([`f3684f7`](https://github.com/Bluetooth-Devices/habluetooth/commit/f3684f7ed884bb0df5959ebe203c139170d34318))
## v5.10.0 (2026-03-15)
### Features
- Split scanner_adv_received into cpdef entry + cdef internal ([`f05ea67`](https://github.com/Bluetooth-Devices/habluetooth/commit/f05ea67493ff9806dd4e389dcf4ae2d03874d831))
### Performance improvements
- Use len() for dict truthiness check in hot path ([`33d8a67`](https://github.com/Bluetooth-Devices/habluetooth/commit/33d8a676f9617a01b7e20e654d0231938028007b))
## v5.9.1 (2026-03-07)
### Bug fixes
- Warn when connection params cannot be set ([`e9e4ed0`](https://github.com/Bluetooth-Devices/habluetooth/commit/e9e4ed0e47d24192eaf4edf247950e6b37bf91bb))
## v5.9.0 (2026-03-07)
### Features
- Add ble connection parameters api ([`53f1e3e`](https://github.com/Bluetooth-Devices/habluetooth/commit/53f1e3ec1c3609c48143d335876e3b84a1e3b43b))
## v5.8.0 (2025-12-02)
### Features
- Support bleak 2.0 ([`15ca6d3`](https://github.com/Bluetooth-Devices/habluetooth/commit/15ca6d33ab0ce02a00b223fa7659f62b27291fca))
## v5.7.0 (2025-10-04)
### Features
- Python 3.14 support ([`3ef7243`](https://github.com/Bluetooth-Devices/habluetooth/commit/3ef7243b2fe996970a3bd18a279a550f10233ede))
## v5.6.4 (2025-09-13)
### Bug fixes
- Workaround kernel abi inconsistency in bluetooth mgmt socket send behavior ([`affc097`](https://github.com/Bluetooth-Devices/habluetooth/commit/affc0971edf310bfd6f5a9f880fa488b4ed5a215))
### Unknown
## v5.6.3 (2025-09-13)
### Bug fixes
- High cpu usage by replacing async context manager with setup/cleanup pattern to avoid cython bug ([`8aa021a`](https://github.com/Bluetooth-Devices/habluetooth/commit/8aa021aee970fc01ccdb3d7f3242eee7fc3802cf))
## v5.6.2 (2025-09-09)
### Bug fixes
- Resolve crash when compiled with cython 3.1 ([`bac6dcf`](https://github.com/Bluetooth-Devices/habluetooth/commit/bac6dcffaba3940ff7331739866801729eee2032))
## v5.6.1 (2025-09-09)
### Bug fixes
- Rebuild wheels ([`bcc77be`](https://github.com/Bluetooth-Devices/habluetooth/commit/bcc77be98b6d3da33b8d74f1dda899e9823aa652))
## v5.6.0 (2025-09-08)
### Features
- Callback on scanner start success ([`782b717`](https://github.com/Bluetooth-Devices/habluetooth/commit/782b717044beff9758774c54cecbe3af9520e78b))
## v5.5.1 (2025-09-08)
### Bug fixes
- Handle case where two scanners have the exact same rssi ([`f51f700`](https://github.com/Bluetooth-Devices/habluetooth/commit/f51f700bdf41d508072c9ba1109a4bf7c7e5c57f))
## v5.5.0 (2025-09-08)
### Features
- Log slots when connecting ([`a3881c4`](https://github.com/Bluetooth-Devices/habluetooth/commit/a3881c4cccac52d668518dc8632104688dbf2788))
## v5.4.0 (2025-09-08)
### Features
- Consider connection slots when selecting connection path ([`fb938fc`](https://github.com/Bluetooth-Devices/habluetooth/commit/fb938fc608fd9f846c3eedd70ff245d5b299b93c))
## v5.3.1 (2025-09-06)
### Bug fixes
- Detect missing net_admin/net_raw capabilities and fallback to bluez-only mode ([`1cf17c0`](https://github.com/Bluetooth-Devices/habluetooth/commit/1cf17c094fc2bce65e703d230259f3c2c66720e7))
## v5.3.0 (2025-08-31)
### Features
- Include scanner type in details ([`0b558f1`](https://github.com/Bluetooth-Devices/habluetooth/commit/0b558f155e650b26a1b8c996d7292f62906cc3b4))
## v5.2.1 (2025-08-29)
### Bug fixes
- Incorrect advertising interval calculation when scanner pauses for connections ([`1c8db59`](https://github.com/Bluetooth-Devices/habluetooth/commit/1c8db59b8da69439d253bc356fe7be5321c7fb23))
## v5.2.0 (2025-08-28)
### Features
- Add methods to set current and requested mode to scanner ([`3cf872c`](https://github.com/Bluetooth-Devices/habluetooth/commit/3cf872c899beadd9594a5749a5ce433d943852f4))
## v5.1.0 (2025-08-19)
### Features
- Warn when connections are established without bleak-retry-connector ([`ba23681`](https://github.com/Bluetooth-Devices/habluetooth/commit/ba23681de0a4056da130ce7d2e8d7c25dbb18ad0))
### Unknown
## v5.0.2 (2025-08-12)
### Bug fixes
- Solve performance regression while connecting in linux ([`47b5b7e`](https://github.com/Bluetooth-Devices/habluetooth/commit/47b5b7e4e00b2be7922a2101a690e37de0970e5e))
## v5.0.1 (2025-08-09)
### Bug fixes
- Ensure connect works without debug logging ([`4da091d`](https://github.com/Bluetooth-Devices/habluetooth/commit/4da091dc3ee512d499c82d179420f7fbc0539457))
## v5.0.0 (2025-08-09)
### Features
- Add bt management side channel ([`e345308`](https://github.com/Bluetooth-Devices/habluetooth/commit/e3453089f510548ec7605fe83aa2078028ef2468))
## v4.0.2 (2025-08-06)
### Bug fixes
- Add clear error when only passive bluetooth adapters are available ([`bbb494c`](https://github.com/Bluetooth-Devices/habluetooth/commit/bbb494c99bd87e8e090a922a28cf7e083b191bc4))
## v4.0.1 (2025-07-03)
### Bug fixes
- Small cleanups for bleak 1.x support ([`3cf74f4`](https://github.com/Bluetooth-Devices/habluetooth/commit/3cf74f492354c452469907fb84a74cac9c7edcd3))
## v4.0.0 (2025-07-03)
### Features
- Support bleak 1.x ([`a739199`](https://github.com/Bluetooth-Devices/habluetooth/commit/a739199cf6d56f5db316b149134c11eabfab9f1c))
## v3.49.0 (2025-06-03)
### Features
- Add raw_advertisement_data to diagnostics ([`a77933b`](https://github.com/Bluetooth-Devices/habluetooth/commit/a77933b8dd0195ab827907ea918c572cd1686750))
## v3.48.2 (2025-05-03)
### Bug fixes
- Remove duplicate _connecting slot from basehascanner ([`230bb03`](https://github.com/Bluetooth-Devices/habluetooth/commit/230bb038eea8ae07a3fe798ec15792489d06cd66))
## v3.48.1 (2025-05-03)
### Bug fixes
- Pin cython to <3.1 ([`21dc734`](https://github.com/Bluetooth-Devices/habluetooth/commit/21dc7340c548713c4539d8d8a067a2a574623906))
## v3.48.0 (2025-05-03)
### Features
- Refactor scanner history to live on the scanner itself ([`ea0d2fc`](https://github.com/Bluetooth-Devices/habluetooth/commit/ea0d2fc088832a1b3f8c7859c82e2e05bf1261f9))
## v3.47.1 (2025-05-03)
### Bug fixes
- Ensure logging does not fail when there is only a single scanner ([`d81378e`](https://github.com/Bluetooth-Devices/habluetooth/commit/d81378e6b4adedead6d04ab23be7b655cd3785fb))
## v3.47.0 (2025-05-03)
### Bug fixes
- Require bluetooth-auto-recovery >= 1.5.1 ([`8164ce5`](https://github.com/Bluetooth-Devices/habluetooth/commit/8164ce512084fe898cb80c5e44f664dde4751113))
### Features
- Avoid thundering heard of connections ([`943cc20`](https://github.com/Bluetooth-Devices/habluetooth/commit/943cc2043731f8d6fbb541f4d7ffcd37d8c6b4f3))
## v3.46.0 (2025-05-03)
### Features
- Improve recovery when adapter has gone silent and needs a usb reset ([`a4dd395`](https://github.com/Bluetooth-Devices/habluetooth/commit/a4dd395b7a8e70cb0ae94d97422d35eb638daaa5))
## v3.45.0 (2025-04-29)
### Features
- Improve performance of _async_on_advertisement_internal ([`be0b5a6`](https://github.com/Bluetooth-Devices/habluetooth/commit/be0b5a6d0da07f2f881984c92c0c7671117d3e5a))
## v3.44.0 (2025-04-28)
### Features
- Save the raw data in storage ([`eaf4107`](https://github.com/Bluetooth-Devices/habluetooth/commit/eaf41072ecc915b2de23ad3c9a03148f4b313f17))
## v3.43.0 (2025-04-28)
### Features
- Migrate storage code from bluetooth_adapters ([`5d671f9`](https://github.com/Bluetooth-Devices/habluetooth/commit/5d671f95b9a7964bfa871c7b42061a71a98ce80e))
## v3.42.0 (2025-04-27)
### Features
- Add raw field to bluetoothserviceinfobleak ([`343f18b`](https://github.com/Bluetooth-Devices/habluetooth/commit/343f18bfbbf3ebbee31e64beab60b2686700797f))
## v3.41.0 (2025-04-27)
### Features
- Add new _async_on_raw_advertisement base scanner api ([`fb2a487`](https://github.com/Bluetooth-Devices/habluetooth/commit/fb2a487c06cf102c17509410f916b5c06728df98))
## v3.40.0 (2025-04-27)
### Features
- Require bluetooth-data-tools 1.28.0 or later ([`e154136`](https://github.com/Bluetooth-Devices/habluetooth/commit/e154136db9f15d33c6de3d89bf9e4e53e03c690a))
## v3.39.0 (2025-04-17)
### Features
- Improve performance of _async_on_advertisement ([`0fc0500`](https://github.com/Bluetooth-Devices/habluetooth/commit/0fc0500d74cdc3d320111df979bef784a51a2eac))
## v3.38.1 (2025-04-14)
### Bug fixes
- Add missing dbus-fast dep on linux ([`5746448`](https://github.com/Bluetooth-Devices/habluetooth/commit/57464488482626577e9f84c42ab1ff100b7857b3))
## v3.38.0 (2025-03-22)
### Bug fixes
- Use project.license key ([`1decf97`](https://github.com/Bluetooth-Devices/habluetooth/commit/1decf9704f7db33bc8094880651321c1b58420c8))
### Features
- Improve performance of previous source checks ([`8d96528`](https://github.com/Bluetooth-Devices/habluetooth/commit/8d96528f605231f3089319c789390d784c45b4c5))
## v3.37.0 (2025-03-21)
### Features
- Improve performance of _prefer_previous_adv_from_different_source ([`73ec210`](https://github.com/Bluetooth-Devices/habluetooth/commit/73ec2107375be217ffb0310194be8c3d4f20e150))
## v3.36.0 (2025-03-21)
### Features
- Improve performance of filtering apple data ([`9f56840`](https://github.com/Bluetooth-Devices/habluetooth/commit/9f568405ae987de0fb3953d6ae7b39eabacde9ef))
## v3.35.0 (2025-03-21)
### Features
- Optimize previous local name matching ([`fadb722`](https://github.com/Bluetooth-Devices/habluetooth/commit/fadb722b8ded2bc15bd56b641a963d4c4d19838e))
## v3.34.1 (2025-03-21)
### Bug fixes
- Revert adding _async_on_advertisements ([`4bc3cb8`](https://github.com/Bluetooth-Devices/habluetooth/commit/4bc3cb89baf52570deec4f27ed3cd935249525ec))
## v3.34.0 (2025-03-21)
### Features
- Rename _async_on_raw_advertisement to _async_on_raw_advertisements ([`b3acb88`](https://github.com/Bluetooth-Devices/habluetooth/commit/b3acb882d888a33567ece3e7f9d0fa1d2b4c6acd))
## v3.33.0 (2025-03-21)
### Features
- Add _async_on_raw_advertisement ([`24d128f`](https://github.com/Bluetooth-Devices/habluetooth/commit/24d128fe4854135647e9a41c7eeaf1784fbda0bf))
## v3.32.0 (2025-03-15)
### Features
- Improve performance of dispatching discovery info to subclasses ([`d0fae7d`](https://github.com/Bluetooth-Devices/habluetooth/commit/d0fae7ddd9158903f6621888cc4c75480822ae35))
## v3.31.0 (2025-03-15)
### Features
- Avoid building on demand advertisementdata if there are no bleak callbacks ([`ae977b9`](https://github.com/Bluetooth-Devices/habluetooth/commit/ae977b9d53c29c581ff6394a2078d2a2b01066dd))
## v3.30.0 (2025-03-15)
### Features
- Improve performance of on demand advertisementdata construction ([`ab005cb`](https://github.com/Bluetooth-Devices/habluetooth/commit/ab005cbef5e2ece74a0facd502fca7173ba2b1fc))
## v3.29.0 (2025-03-15)
### Features
- Improve performance for device with large manufacturer data history ([`ec1f6aa`](https://github.com/Bluetooth-Devices/habluetooth/commit/ec1f6aa7989cea2a589029362461dba4f7a8f0db))
## v3.28.0 (2025-03-15)
### Features
- Improve performance of local name checks ([`9f57d2f`](https://github.com/Bluetooth-Devices/habluetooth/commit/9f57d2fcc23595b376d5785162c21633514f44bd))
## v3.27.0 (2025-03-14)
### Features
- Improve performance of base_scanner ([`5b8c59c`](https://github.com/Bluetooth-Devices/habluetooth/commit/5b8c59c7ffadead5997fa457b07ff37ec8ec31b5))
## v3.26.0 (2025-03-14)
### Features
- Improve manager performance ([`e0bdace`](https://github.com/Bluetooth-Devices/habluetooth/commit/e0bdace8180ff3ac450447be99f700fd647fb659))
## v3.25.1 (2025-03-13)
### Bug fixes
- Downgrade scanner gone quiet logger to debug ([`d450ffc`](https://github.com/Bluetooth-Devices/habluetooth/commit/d450ffca38dec015f44b5be08af484fe8ca09866))
## v3.25.0 (2025-03-05)
### Bug fixes
- Use trusted publishing for wheels ([`c726687`](https://github.com/Bluetooth-Devices/habluetooth/commit/c726687affb0025037676b76cf4ecefdef0da23f))
### Features
- Add armv7l to wheel builds ([`e394707`](https://github.com/Bluetooth-Devices/habluetooth/commit/e394707b6b7ffc54e6dc5b8c038a08c5404f1777))
- Reduce wheel sizes ([`5e6b644`](https://github.com/Bluetooth-Devices/habluetooth/commit/5e6b64476ff2db7a215d1b0d58ef01c04b839d34))
## v3.24.1 (2025-02-27)
### Bug fixes
- Update scanner discover signature for newer bleak ([`a071cb8`](https://github.com/Bluetooth-Devices/habluetooth/commit/a071cb8e3f921da30055b94a74a4b0aa339e53de))
## v3.24.0 (2025-02-22)
### Features
- Improve logging of scanner failures and time_since_last_detection ([`f0ff045`](https://github.com/Bluetooth-Devices/habluetooth/commit/f0ff04586849bda3933fbe98e8e1335c308999c4))
## v3.23.0 (2025-02-21)
### Features
- Add debug logging for connection paths ([`562d469`](https://github.com/Bluetooth-Devices/habluetooth/commit/562d46912e7596febc3ebcc0301280e6f334172b))
## v3.22.1 (2025-02-20)
### Bug fixes
- Try to force stop discovery if its stuck on ([`e28d836`](https://github.com/Bluetooth-Devices/habluetooth/commit/e28d836d28f0b8062831ee209ba54a7735c4d5ae))
## v3.22.0 (2025-02-18)
### Features
- Allow remote scanners to set current and requested mode ([`a39ba18`](https://github.com/Bluetooth-Devices/habluetooth/commit/a39ba184e0d01f983133534e4fd7c1b6202210fb))
## v3.21.1 (2025-02-04)
### Bug fixes
- Update poetry to v2 ([`aefe36e`](https://github.com/Bluetooth-Devices/habluetooth/commit/aefe36e2507566224267f371511c1f1c748a37a9))
## v3.21.0 (2025-02-01)
### Features
- Reduce remote scanner adv processing overhead ([`7bf302b`](https://github.com/Bluetooth-Devices/habluetooth/commit/7bf302bac3855cf7e229dd2744acce513b2e2ee4))
## v3.20.1 (2025-02-01)
### Bug fixes
- Remove unused centralbluetoothmanager in models ([`7466034`](https://github.com/Bluetooth-Devices/habluetooth/commit/74660343b30fec50b927fdddd92e72eacb4da6cf))
- Precision loss when comparing advs from different sources ([`02279a9`](https://github.com/Bluetooth-Devices/habluetooth/commit/02279a95ca5b590768bd631bf39ee507a64db7ad))
## v3.20.0 (2025-02-01)
### Features
- Reduce adv tracker overhead ([`69168a6`](https://github.com/Bluetooth-Devices/habluetooth/commit/69168a64572ab3fba696d2afedeb015953afb0cc))
## v3.19.0 (2025-02-01)
### Features
- Reduce overhead to convert non-connectable bluetoothserviceinfobleak to connectable ([`37fc839`](https://github.com/Bluetooth-Devices/habluetooth/commit/37fc839d5fc73ff6f784ec8041606be82d58322b))
## v3.18.0 (2025-02-01)
### Features
- Refactor scanner_adv_received to reduce ref counting ([`a1945ce`](https://github.com/Bluetooth-Devices/habluetooth/commit/a1945cedc2373082814e8f4b4426a50c79788305))
## v3.17.1 (2025-01-31)
### Bug fixes
- Ensure allocations are available if the adapter never makes any connections ([`b3dfa48`](https://github.com/Bluetooth-Devices/habluetooth/commit/b3dfa48dba2482c16f61fceaf9a0f58ea55df982))
## v3.17.0 (2025-01-31)
### Features
- Remove the need to call set_manager to set up ([`1312bf7`](https://github.com/Bluetooth-Devices/habluetooth/commit/1312bf7d978ff585e66d99bde766e85773fce006))
## v3.16.0 (2025-01-31)
### Features
- Allow bluetoothmanager to be created with defaults ([`70b2f69`](https://github.com/Bluetooth-Devices/habluetooth/commit/70b2f6952fbd3ecd499a4c66ec305869158a428e))
## v3.15.0 (2025-01-31)
### Features
- Include findmy packets in wanted adverts ([`5217850`](https://github.com/Bluetooth-Devices/habluetooth/commit/5217850934bfed5d8e70f8b43c84cd97cf53cdac))
## v3.14.0 (2025-01-29)
### Features
- Add allocations to diagnostics ([`aa41088`](https://github.com/Bluetooth-Devices/habluetooth/commit/aa4108872478720ab4cbcf52c5add015441fe72d))
## v3.13.0 (2025-01-28)
### Features
- Add async_register_scanner_registration_callback and async_current_scanners to the manager ([`99fcb46`](https://github.com/Bluetooth-Devices/habluetooth/commit/99fcb46a73ea6cb8f01817263d01a342365be78f))
## v3.12.0 (2025-01-22)
### Features
- Add support for connection allocations for non-connectable scanners ([`d76b7c9`](https://github.com/Bluetooth-Devices/habluetooth/commit/d76b7c9624b6c4e6beedc1bd56dd1a3c0df70eec))
## v3.11.2 (2025-01-22)
### Bug fixes
- Re-release again for failed arm runners ([`af2bb50`](https://github.com/Bluetooth-Devices/habluetooth/commit/af2bb50879713378a32339e490a57b56083a4fa7))
## v3.11.1 (2025-01-22)
### Bug fixes
- Re-release due to failed github action ([`90e2192`](https://github.com/Bluetooth-Devices/habluetooth/commit/90e2192ff75c13ccf610fd06a61e64d60dfd1a18))
## v3.11.0 (2025-01-22)
### Features
- Add api for getting current slot allocations ([`0a9bef9`](https://github.com/Bluetooth-Devices/habluetooth/commit/0a9bef927c5f29c3e724fb60aa06706b6d896f82))
## v3.10.0 (2025-01-21)
### Features
- Add support for getting callbacks when adapter allocations change ([`c6fd2ba`](https://github.com/Bluetooth-Devices/habluetooth/commit/c6fd2babf0c6438ff85220edef95df3d3b4fae9c))
## v3.9.2 (2025-01-20)
### Bug fixes
- Increase rssi switch value to 16 ([`db367db`](https://github.com/Bluetooth-Devices/habluetooth/commit/db367dbef3fa883348a72cf17e29d9c26a09de53))
## v3.9.1 (2025-01-20)
### Bug fixes
- Increase rssi switch threshold for advertisements ([`297c269`](https://github.com/Bluetooth-Devices/habluetooth/commit/297c2693f9a2c007f0e70175c24416c8bb7da099))
## v3.9.0 (2025-01-17)
### Features
- Switch to native arm runners for wheel builds ([`bf7e98b`](https://github.com/Bluetooth-Devices/habluetooth/commit/bf7e98b099597916bb7566eb03472023f8acef97))
## v3.8.0 (2025-01-10)
### Features
- Add async_register_disappeared_callback ([`ec1d445`](https://github.com/Bluetooth-Devices/habluetooth/commit/ec1d4456ca15c6fca3248f2e5d73fcb1ba9d36c6))
## v3.7.0 (2025-01-05)
### Bug fixes
- Publish workflow ([`341c8a4`](https://github.com/Bluetooth-Devices/habluetooth/commit/341c8a4b72fb2818a3bed44632048d8570fc3b67))
### Features
- Start building wheels for python 3.13 ([`26dd831`](https://github.com/Bluetooth-Devices/habluetooth/commit/26dd831c28f3c0dfe0745769749e795e7937c7df))
- Add codspeed benchmarks ([`5905fbd`](https://github.com/Bluetooth-Devices/habluetooth/commit/5905fbd2c54adea04c0e55fe8a299f771e6f31ed))
### Unknown
## v3.6.0 (2024-10-20)
### Features
- Speed up creation of advertisementdata namedtuple ([`28f7e60`](https://github.com/Bluetooth-Devices/habluetooth/commit/28f7e6093c3985da16e537bc9d989d839ad80c56))
## v3.5.0 (2024-10-05)
### Features
- Add support for python 3.13 ([`b8a4783`](https://github.com/Bluetooth-Devices/habluetooth/commit/b8a4783a43f6e771321974d2c085e5e0dda9e195))
## v3.4.1 (2024-09-22)
### Bug fixes
- Ensure build system required cython 3 ([`dc85d2f`](https://github.com/Bluetooth-Devices/habluetooth/commit/dc85d2fd1b8c8e4d8eb4515aa60af06782fc8722))
## v3.4.0 (2024-09-02)
### Features
- Add a fast cython init path for bluetoothserviceinfobleak ([`f532ed2`](https://github.com/Bluetooth-Devices/habluetooth/commit/f532ed215b429f0bbd14dacc30f87c53f22af245))
## v3.3.2 (2024-08-20)
### Bug fixes
- Disable 3.13 wheels ([`9e8bbff`](https://github.com/Bluetooth-Devices/habluetooth/commit/9e8bbff6179e08bd6e05341ff48fff3adc5c6157))
## v3.3.1 (2024-08-20)
### Bug fixes
- Bump cibuildwheel to fix wheel builds ([`68d838a`](https://github.com/Bluetooth-Devices/habluetooth/commit/68d838a1d2adab9efe1fb5eba65e81b5dcc9a351))
## v3.3.0 (2024-08-20)
### Bug fixes
- Cleanup advertisementmonitor mapper ([`7d3483d`](https://github.com/Bluetooth-Devices/habluetooth/commit/7d3483d87d3e03c19cf528a1838acce5b194533e))
### Features
- Override devicefound and devicelost for passive monitoring ([`a802859`](https://github.com/Bluetooth-Devices/habluetooth/commit/a8028596bf3576a35750ae8575f173c75f918f28))
## v3.2.0 (2024-07-27)
### Features
- Small speed ups to scanner detection callback ([`7a5129a`](https://github.com/Bluetooth-Devices/habluetooth/commit/7a5129a40a12382c089453880210c41bb0f28a32))
## v3.1.3 (2024-06-24)
### Bug fixes
- Wheel builds ([`b9a8eec`](https://github.com/Bluetooth-Devices/habluetooth/commit/b9a8eec4f79c2098c0ec318b6b1ff7e3376febf2))
## v3.1.2 (2024-06-24)
### Bug fixes
- Fix license classifier ([`04aaaa1`](https://github.com/Bluetooth-Devices/habluetooth/commit/04aaaa186c755b869c8d75678f563f6a9c089829))
## v3.1.1 (2024-05-23)
### Bug fixes
- Missing classmethod decorator on find_device_by_address ([`aa08b13`](https://github.com/Bluetooth-Devices/habluetooth/commit/aa08b136660cddea7c356274c21f20b6d0eef1fa))
## v3.1.0 (2024-05-22)
### Features
- Speed up dispatching bleak callbacks ([`cbc8b26`](https://github.com/Bluetooth-Devices/habluetooth/commit/cbc8b26f90b9ea4f2a8569c0625b527dd37ef180))
## v3.0.1 (2024-05-03)
### Bug fixes
- Ensure lazy advertisement uses none when name is not present ([`c300f73`](https://github.com/Bluetooth-Devices/habluetooth/commit/c300f73ba82d3549ea4c156ef11023e9478c8b6c))
## v3.0.0 (2024-05-02)
### Features
- Make generation of advertisementdata lazy ([`25f8437`](https://github.com/Bluetooth-Devices/habluetooth/commit/25f843795927ad663a1d5ef1fa9472ec366b9da5))
## v2.8.1 (2024-05-02)
### Bug fixes
- Add missing find_device_by_address mapping ([`cc8e57e`](https://github.com/Bluetooth-Devices/habluetooth/commit/cc8e57eef7b97a6f2a30488a64d156cb5023c6c6))
## v2.8.0 (2024-04-17)
### Features
- Add support for recovering failed adapters after reboot ([`04948c3`](https://github.com/Bluetooth-Devices/habluetooth/commit/04948c337adf0f7b291e4e33618a7eae6dc4ebc2))
## v2.7.0 (2024-04-17)
### Features
- Improve fallback to passive mode when active mode fails ([`17ecc01`](https://github.com/Bluetooth-Devices/habluetooth/commit/17ecc012e096bec0113efea9ceb6a21bb50023fe))
## v2.6.0 (2024-04-17)
### Features
- Speed up stopping the scanner when its stuck setting up ([`bba8b51`](https://github.com/Bluetooth-Devices/habluetooth/commit/bba8b514490d98dca1020bbfefd9dc1e6a79af5f))
## v2.5.3 (2024-04-17)
### Bug fixes
- Ensure scanner is stopped on cancellation ([`a21d70a`](https://github.com/Bluetooth-Devices/habluetooth/commit/a21d70a1ac88135eade61c0abc8912c5b04a6b8b))
## v2.5.2 (2024-04-16)
### Bug fixes
- Ensure discovered_devices returns an empty list for offline scanners ([`2350543`](https://github.com/Bluetooth-Devices/habluetooth/commit/23505437c98529f692ab2dc0f5c3bdb5c9b7e3bd))
## v2.5.1 (2024-04-16)
### Bug fixes
- Wheel builds ([`5bd671a`](https://github.com/Bluetooth-Devices/habluetooth/commit/5bd671a159292dffe30a69639411926d0bc28123))
## v2.5.0 (2024-04-16)
### Features
- Fallback to passive scanning if active cannot start ([`3fae981`](https://github.com/Bluetooth-Devices/habluetooth/commit/3fae98162e6b0279375823a3b6e60ee51b87c1bb))
## v2.4.2 (2024-02-29)
### Bug fixes
- Android beacons in passive mode with flags 0x02 ([`8330e18`](https://github.com/Bluetooth-Devices/habluetooth/commit/8330e187550ec00ed415d3650a2c231921fb8ae7))
## v2.4.1 (2024-02-23)
### Bug fixes
- Avoid concurrent refreshes of adapters ([`d355b17`](https://github.com/Bluetooth-Devices/habluetooth/commit/d355b1768705706dec7062ad5d6267089d87a88e))
## v2.4.0 (2024-01-22)
### Features
- Improve error reporting resolution suggestions ([`afff5ba`](https://github.com/Bluetooth-Devices/habluetooth/commit/afff5ba4dfd8a5582174b367ae5ed9c9953b81e9))
## v2.3.1 (2024-01-22)
### Bug fixes
- Ensure unavailable callbacks can be removed from fired callbacks ([`65e7706`](https://github.com/Bluetooth-Devices/habluetooth/commit/65e7706ef4cdb99f9df5a00f666ab1d30e92e3b1))
## v2.3.0 (2024-01-22)
### Features
- Reduce overhead to remove callbacks by using sets to store callbacks ([`05ceb85`](https://github.com/Bluetooth-Devices/habluetooth/commit/05ceb85901b17f72988068997c7f39bc0179dca2))
## v2.2.0 (2024-01-14)
### Features
- Improve remote scanner performance ([`c549b1c`](https://github.com/Bluetooth-Devices/habluetooth/commit/c549b1cf9bbbda0c39dfce92d2888d5b990211da))
## v2.1.0 (2024-01-10)
### Features
- Add support for windows ([`788dd77`](https://github.com/Bluetooth-Devices/habluetooth/commit/788dd77ffac6664083821d5ba8b264725a3baaff))
## v2.0.2 (2024-01-04)
### Bug fixes
- Handle subclassed str in the client wrapper ([`f18a30e`](https://github.com/Bluetooth-Devices/habluetooth/commit/f18a30e48fe064993dc64f3af01c5d64b676a82f))
## v2.0.1 (2023-12-31)
### Bug fixes
- Switching scanners too quickly ([`bd53685`](https://github.com/Bluetooth-Devices/habluetooth/commit/bd536854457bd8b27f9e91921965b88b0ff798c3))
## v2.0.0 (2023-12-21)
### Features
- Simplify async_register_scanner by removing connectable argument ([`10ac6da`](https://github.com/Bluetooth-Devices/habluetooth/commit/10ac6da0672c121b5f0246ed688e98111adc7339))
## v1.0.0 (2023-12-12)
### Features
- Eliminate the need to pass the new_info_callback ([`65c54a6`](https://github.com/Bluetooth-Devices/habluetooth/commit/65c54a68500be6053677511ffd21ce3dca4b6991))
## v0.11.1 (2023-12-11)
### Bug fixes
- Do not schedule an expire when restoring devices ([`144cf15`](https://github.com/Bluetooth-Devices/habluetooth/commit/144cf15050a68cca66e7a2e24a5ddc7b87c32e41))
## v0.11.0 (2023-12-11)
### Features
- Relocate bluetoothserviceinfobleak ([`4f4f32d`](https://github.com/Bluetooth-Devices/habluetooth/commit/4f4f32d78d6abe21e28171f54ff5f3b17c8fb702))
## v0.10.0 (2023-12-07)
### Features
- Small speed ups to base_scanner ([`e1ff7e9`](https://github.com/Bluetooth-Devices/habluetooth/commit/e1ff7e9fb91a274b1a4bf6943a26e2a3f19780e7))
## v0.9.0 (2023-12-06)
### Features
- Speed up processing incoming service infos ([`55f6522`](https://github.com/Bluetooth-Devices/habluetooth/commit/55f6522ffc2adaf7e203ff4d2c1b13adc5d8c6a2))
## v0.8.0 (2023-12-06)
### Features
- Auto build the cythonized manager ([`c3441e5`](https://github.com/Bluetooth-Devices/habluetooth/commit/c3441e5095d62e6e70c2c879c4b5c109a87f463c))
- Add cython implementation for manager ([`266a602`](https://github.com/Bluetooth-Devices/habluetooth/commit/266a6022fb433ef9399f72e87b18b86897524784))
## v0.7.0 (2023-12-05)
### Features
- Port bluetooth manager from ha ([`757640a`](https://github.com/Bluetooth-Devices/habluetooth/commit/757640a7b7f60072588168501148ba750316f170))
## v0.6.1 (2023-12-04)
### Bug fixes
- Add missing cythonize for the adv tracker ([`8140195`](https://github.com/Bluetooth-Devices/habluetooth/commit/8140195a27ef83ea89ca643a5899d80839e574ae))
## v0.6.0 (2023-12-04)
### Features
- Port advertisement_tracker ([`378667b`](https://github.com/Bluetooth-Devices/habluetooth/commit/378667bce851b5076ee79ff223a72501c5575325))
## v0.5.1 (2023-12-04)
### Bug fixes
- Remove slots to keep hascanner patchable ([`d068f48`](https://github.com/Bluetooth-Devices/habluetooth/commit/d068f480d292619a1fc49a1256be98bdc6efadd6))
## v0.5.0 (2023-12-03)
### Features
- Port local scanner support from ha ([`1b1d0e4`](https://github.com/Bluetooth-Devices/habluetooth/commit/1b1d0e4bc17a44a1b20382da6ae28ea8e50e80b7))
## v0.4.0 (2023-12-03)
### Features
- Add more typing for incoming bluetooth data ([`de590e5`](https://github.com/Bluetooth-Devices/habluetooth/commit/de590e5c886801ff4a87f99c118be8855f337bd0))
## v0.3.0 (2023-12-03)
### Features
- Refactor to be able to use __pyx_pyobject_fastcall ([`e15074b`](https://github.com/Bluetooth-Devices/habluetooth/commit/e15074b172242f44f641e5232ebdf6297537a2b8))
- Add basic pxd ([`fd97d07`](https://github.com/Bluetooth-Devices/habluetooth/commit/fd97d07db7c0e8e0e877e1544fd0e392d14448b3))
## v0.2.0 (2023-12-03)
### Features
- Add cython pxd for base_scanner ([`0195710`](https://github.com/Bluetooth-Devices/habluetooth/commit/0195710bc25c8c3cc68b17a8f31cf281494fdc22))
## v0.1.0 (2023-12-03)
### Features
- Port base scanner from ha ([`e01a57b`](https://github.com/Bluetooth-Devices/habluetooth/commit/e01a57b6e0003ea8fe64b8e6e11ce09a35c1ada2))
## v0.0.1 (2023-12-02)
### Bug fixes
- Reserve name ([`5493984`](https://github.com/Bluetooth-Devices/habluetooth/commit/5493984483902039ca396498122e6094524bbae6))
Bluetooth-Devices-habluetooth-75cbe37/CLAUDE.md 0000664 0000000 0000000 00000022552 15211177045 0021264 0 ustar 00root root 0000000 0000000 # CLAUDE.md — habluetooth
Guide for AI assistants working in this repo. Skim it before editing.
## What this is
`habluetooth` is the Bluetooth core library used by Home Assistant. It wraps
[`bleak`](https://github.com/hbldh/bleak) with multi-scanner orchestration,
advertisement tracking, a per-scanner discovery cache, and Cython-compiled hot
paths. The package is published to PyPI and consumed by Home Assistant plus
related glue libraries (`bleak-retry-connector`, `bleak-esphome`,
`bluetooth-adapters`, etc.).
It is **not** a daemon. It runs in-process inside HA's event loop and
coordinates multiple `BaseHaScanner` instances (local USB/UART adapters via
BlueZ, ESPHome remote scanners, etc.).
## Layout
```
src/habluetooth/
manager.py BluetoothManager — central orchestrator, dispatch, scoring
base_scanner.py BaseHaScanner / BaseHaRemoteScanner — shared scanner logic
scanner.py HaScanner — local bleak scanner (BlueZ / CoreBluetooth)
scanner_device.py BluetoothScannerDevice dataclass
advertisement_tracker.py Per-device advertising interval estimator
models.py BluetoothServiceInfo(Bleak), HaScannerDetails, enums
storage.py Discovery-cache (de)serialization — TypedDict ↔ dataclass
wrappers.py HaBleakClientWrapper / HaBleakScannerWrapper (public API)
channels/bluez.py Low-level BlueZ raw-advertisement channel (Cython)
central_manager.py Module-level singleton holder (get_manager / set_manager)
const.py Timeouts, thresholds, connection-parameter presets
usage.py, util.py Misc helpers
tests/ pytest suite (asyncio + freezegun + codspeed)
docs/ Sphinx documentation (readthedocs)
build_ext.py Cython build script invoked by Poetry
```
Each "hot" module has a paired `.pxd` declaring its Cython attributes. See
[Cython rules](#cython) below.
## Core concepts
- **BluetoothManager** (`manager.py`) is the single in-process orchestrator.
It is held by `central_manager.CentralBluetoothManager.manager` and accessed
via `get_manager()` / `set_manager()`. There is no DI: tests typically set
it directly.
- **Scanners** subclass `BaseHaScanner` (with the local-vs-remote split in
`BaseHaRemoteScanner`). Each scanner reports advertisements to the manager
via the `_async_on_advertisement` path; the manager dedupes, scores, and
fans out to registered Bleak callbacks.
- **Advertisement tracker** (`advertisement_tracker.py`) learns each device's
advertising interval and feeds expiry decisions. Until it has
`ADVERTISING_TIMES_NEEDED` samples it uses a fallback timeout.
- **Wrappers** (`wrappers.py`) — `HaBleakClientWrapper` /
`HaBleakScannerWrapper` are the _public_ Bleak-compatible facade. External
callers (HA integrations) talk to these, not to scanners directly.
- **Allocations** — for proxy scanners (ESPHome) the manager tracks per-source
slot allocations via `async_on_allocation_changed`. This state is push-only
and trusted; habluetooth does **not** independently verify slot counts. See
[Allocations are unverified](#allocations) below.
## Storage / "schema"
There is no SQL. The persistence layer is `storage.py`:
- HA's `Store` writes a JSON blob (`DiscoveryStorageType`) to disk.
- Round-trip: in-memory timestamps are `time.monotonic()`; on serialize they
are converted to wall-time via
`_get_monotonic_time_diff = time.time() - time.monotonic()`, then inverted
on load.
- There is **no `version` field** as of 6.1.0. Backward compatibility is
shape-based (`data.get(KEY, default)` + strip-on-read for removed
`BLEDevice` fields). If you touch the schema in a load-bearing way,
consider proposing a version key.
- `discovered_device_advertisement_data_from_dict` / `..._to_dict` are the
only legitimate entry points — don't hand-roll the format.
## Cython
`build_ext.py` cythonizes these modules:
```
advertisement_tracker.py
base_scanner.py
manager.py
models.py
scanner.py
channels/bluez.py
```
**Rules:**
1. When changing the attributes of a class declared `cdef class` in a `.pxd`,
update the matching `.pxd` or the build breaks.
2. Type annotations in `.py` are advisory; the `.pxd` is authoritative for
Cython. `cdef public object foo` in `.pxd` corresponds to an instance
attribute on the Python side.
3. Set `SKIP_CYTHON=1` to install without compilation (faster local dev,
matches one half of the CI matrix). Set `REQUIRE_CYTHON=1` to fail if
compilation fails (matches the other half).
4. Avoid implicit Cython type narrowing for objects that must remain Python
types. `models.py` declares `_float = float`, `_str = str`, `_int = int`
for exactly this reason — use the underscore aliases when you need to
guarantee a Python object.
## Development workflow
Setup:
```bash
poetry install # cython build
SKIP_CYTHON=1 poetry install # pure-python (faster)
```
Run tests:
```bash
poetry run pytest # full suite
poetry run pytest tests/test_manager.py # single file
poetry run pytest -k allocation # by keyword
```
Lint / format (pre-commit covers ruff, ruff-format, mypy, codespell, prettier,
poetry-check):
```bash
pre-commit run -a
```
Type checking (strict mypy — see `pyproject.toml`):
```bash
poetry run mypy src
```
Tests use `pytest-asyncio` (no auto-mode — mark coroutines explicitly) and
`freezegun` for time control. `pytest-codspeed` powers the benchmark file
(`tests/test_benchmark_base_scanner.py`).
## Coding conventions
- **Python ≥ 3.11**, target `py311` for ruff/black. Code may use 3.11+
features (PEP 604 unions, `Self`, etc.).
- `from __future__ import annotations` at the top of every module.
- Imports sorted by isort (ruff `I`); first-party = `habluetooth`, `tests`.
- Black formatting, line length 88.
- Public API is exported from `habluetooth/__init__.py` — when adding a
symbol, also add it to `__all__`.
- Docstrings: ruff enforces `D` rules with a small ignore list (see
`pyproject.toml`). Module/package/`__init__` docstrings are not required;
public function/class docstrings are.
- `mypy` is strict (`disallow_untyped_defs`, `disallow_any_generics`,
`warn_unreachable`, `warn_unused_ignores`). Tests are exempted via override.
- Logging: module-level `_LOGGER = logging.getLogger(__name__)`. Never print.
- No `assert` in production code (ruff `S101`). Tests are exempted.
## Commit / PR conventions
- **Conventional Commits PR title, lowercase subject.** PRs are
squash-merged, so the **PR title** becomes the commit on `main` and is the
only string that has to parse as a Conventional Commit. The repo enforces
this via the `pr-title` CI job in `ci.yml` using
`amannn/action-semantic-pull-request`. Accepted types: `feat`, `fix`,
`chore`, `ci`, `docs`, `refactor`, `test`, `perf`, `build`, etc. The
subject (text after `type(scope):`) must start lowercase (enforced by
`subjectPattern: ^(?![A-Z]).+$`). Per-commit messages on the PR branch are
**not** linted; they get collapsed at squash-merge.
- Releases are fully automated by `python-semantic-release` from the commit
log. Anything that should land in the changelog must use `feat:` or `fix:`
(or a breaking-change footer). `chore*` and `ci*` are excluded.
- The version lives in three places, kept in sync by semantic-release:
`pyproject.toml`, `src/habluetooth/__init__.py:__version__`,
`docs/conf.py:release`. **Do not bump versions by hand.**
- PRs target `main`. CI runs the matrix `{3.11, 3.12, 3.13, 3.14, 3.14t} ×
{linux, macos, windows} × {skip_cython, use_cython}` — flaky breakage in
one cell usually means a Cython annotation got too aggressive.
## Gotchas
- **Allocations are unverified.** `_allocations[source]` is updated solely
from `async_on_allocation_changed` (called by external glue when a proxy
reports slot state). habluetooth has no parallel "currently-connected"
counter and does not cross-check. When a proxy gets stuck, the only
observable symptom is the per-source score collapsing to `NO_RSSI_VALUE`
in `wrappers.py`.
- **`_connect_in_progress` is the only per-scanner "busy" signal.** There is
no counter of active or completed connections — only "does this scanner
have a connect attempt in flight right now".
- **bleak 3.0 deprecations:**
- `BleakScanner(adapter="hciN")` is gone; use `BleakScanner(bluez={"adapter":
"hciN"})`. When also passing `PASSIVE_SCANNER_ARGS` (itself a
`BlueZScannerArgs`), merge — don't overwrite.
- `BLEDevice(..., rssi=-NN)` is gone; bleak 3.0 only accepts
`(address, name, details)`. RSSI lives on `AdvertisementData` only.
- **Test deprecation hygiene:**
```bash
pytest tests/ -W "error::DeprecationWarning" \
-W "ignore::DeprecationWarning:asyncio"
```
turns each deprecation into a failure (while ignoring asyncio's internal
ones).
- **Time source.** Everything in hot paths uses
`bluetooth_data_tools.monotonic_time_coarse()` — do not mix with
`time.time()` or `time.monotonic()` except at storage boundaries.
## When in doubt
- Public API contracts live in `__init__.py`'s `__all__` and in `wrappers.py`.
Breaking those is a major version bump.
- Internal refactors are fine as long as the public surface, the storage
schema, and the scanner-callback signatures don't move.
- Tests are the source of truth for expected behavior — if a test is awkward
to write, the API probably needs to change, not the test.
Bluetooth-Devices-habluetooth-75cbe37/CONTRIBUTING.md 0000664 0000000 0000000 00000007432 15211177045 0022236 0 ustar 00root root 0000000 0000000 # Contributing
Contributions are welcome, and they are greatly appreciated! Every little helps, and credit will always be given.
You can contribute in many ways:
## Types of Contributions
### Report Bugs
Report bugs to [our issue page][gh-issues]. If you are reporting a bug, please include:
- Your operating system name and version.
- Any details about your local setup that might be helpful in troubleshooting.
- Detailed steps to reproduce the bug.
### Fix Bugs
Look through the GitHub issues for bugs. Anything tagged with "bug" and "help wanted" is open to whoever wants to implement it.
### Implement Features
Look through the GitHub issues for features. Anything tagged with "enhancement" and "help wanted" is open to whoever wants to implement it.
### Write Documentation
habluetooth could always use more documentation, whether as part of the official habluetooth docs, in docstrings, or even on the web in blog posts, articles, and such.
### Submit Feedback
The best way to send feedback [our issue page][gh-issues] on GitHub. If you are proposing a feature:
- Explain in detail how it would work.
- Keep the scope as narrow as possible, to make it easier to implement.
- Remember that this is a volunteer-driven project, and that contributions are welcome 😊
## Get Started!
Ready to contribute? Here's how to set yourself up for local development.
1. Fork the repo on GitHub.
2. Clone your fork locally:
```shell
$ git clone git@github.com:your_name_here/habluetooth.git
```
3. Install the project dependencies with [Poetry](https://python-poetry.org):
```shell
$ poetry install
```
4. Create a branch for local development:
```shell
$ git checkout -b name-of-your-bugfix-or-feature
```
Now you can make your changes locally.
5. When you're done making changes, check that your changes pass our tests:
```shell
$ poetry run pytest
```
6. Linting is done through [pre-commit](https://pre-commit.com). Provided you have the tool installed globally, you can run them all as one-off:
```shell
$ pre-commit run -a
```
Or better, install the hooks once and have them run automatically each time you commit:
```shell
$ pre-commit install
```
7. Commit your changes and push your branch to GitHub:
```shell
$ git add .
$ git commit -m "feat(something): your detailed description of your changes"
$ git push origin name-of-your-bugfix-or-feature
```
Note: the commit message should follow [the conventional commits](https://www.conventionalcommits.org). We run [`commitlint` on CI](https://github.com/marketplace/actions/commit-linter) to validate it, and if you've installed pre-commit hooks at the previous step, the message will be checked at commit time.
8. Submit a pull request through the GitHub website or using the GitHub CLI (if you have it installed):
```shell
$ gh pr create --fill
```
## Pull Request Guidelines
We like to have the pull request open as soon as possible, that's a great place to discuss any piece of work, even unfinished. You can use draft pull request if it's still a work in progress. Here are a few guidelines to follow:
1. Include tests for feature or bug fixes.
2. Update the documentation for significant features.
3. Ensure tests are passing on CI.
## Tips
To run a subset of tests:
```shell
$ pytest tests
```
## Making a new release
The deployment should be automated and can be triggered from the Semantic Release workflow in GitHub. The next version will be based on [the commit logs](https://python-semantic-release.readthedocs.io/en/latest/commit-log-parsing.html#commit-log-parsing). This is done by [python-semantic-release](https://python-semantic-release.readthedocs.io/en/latest/index.html) via a GitHub action.
[gh-issues]: https://github.com/bluetooth-devices/habluetooth/issues
Bluetooth-Devices-habluetooth-75cbe37/LICENSE 0000664 0000000 0000000 00000026121 15211177045 0021006 0 ustar 00root root 0000000 0000000
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2023 J. Nick Koston
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Bluetooth-Devices-habluetooth-75cbe37/README.md 0000664 0000000 0000000 00000007604 15211177045 0021265 0 ustar 00root root 0000000 0000000 # habluetooth
---
**Documentation**: https://habluetooth.readthedocs.io
**Source Code**: https://github.com/bluetooth-devices/habluetooth
---
High availability Bluetooth
## Installation
Install this via pip (or your favourite package manager):
`pip install habluetooth`
## Contributors ✨
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
## Credits
This package was created with
[Copier](https://copier.readthedocs.io/) and the
[browniebroke/pypackage-template](https://github.com/browniebroke/pypackage-template)
project template.
Bluetooth-Devices-habluetooth-75cbe37/build_ext.py 0000664 0000000 0000000 00000004422 15211177045 0022332 0 ustar 00root root 0000000 0000000 """Build optional cython modules."""
import logging
import os
from distutils.command.build_ext import build_ext
from typing import Any
try:
from setuptools import Extension
except ImportError:
from distutils.core import Extension
_LOGGER = logging.getLogger(__name__)
TO_CYTHONIZE = [
"src/habluetooth/advertisement_tracker.py",
"src/habluetooth/auto_scheduler.py",
"src/habluetooth/base_scanner.py",
"src/habluetooth/manager.py",
"src/habluetooth/models.py",
"src/habluetooth/scanner.py",
"src/habluetooth/channels/bluez.py",
]
EXTENSIONS = [
Extension(
ext.removeprefix("src/").removesuffix(".py").replace("/", "."),
[ext],
language="c",
extra_compile_args=["-O3", "-g0"],
)
for ext in TO_CYTHONIZE
]
class BuildExt(build_ext):
"""Build extension."""
def build_extensions(self) -> None:
"""Build extensions."""
if self.parallel is None: # type: ignore[has-type, unused-ignore]
self.parallel = os.cpu_count() or 1
try:
super().build_extensions()
except Exception as ex: # nosec # noqa: BLE001
# Cython is optional; any compile failure (missing C compiler,
# platform mismatch, etc.) should fall back to the pure-Python
# install rather than break the build.
_LOGGER.debug("Failed to build extensions: %s", ex, exc_info=True)
def build(setup_kwargs: Any) -> None:
"""Build optional cython modules."""
if os.environ.get("SKIP_CYTHON"):
return
try:
# Cython is optional; defer the import so the SKIP_CYTHON
# branch above never has to find it on sys.path.
from Cython.Build import cythonize # noqa: PLC0415
setup_kwargs.update(
{
"ext_modules": cythonize(
EXTENSIONS,
compiler_directives={"language_level": "3"}, # Python 3
annotate=bool(os.environ.get("CYTHON_ANNOTATE")),
),
"cmdclass": {"build_ext": BuildExt},
}
)
setup_kwargs["exclude_package_data"] = {
pkg: ["*.c"] for pkg in setup_kwargs["packages"]
}
except Exception:
if os.environ.get("REQUIRE_CYTHON"):
raise
Bluetooth-Devices-habluetooth-75cbe37/docs/ 0000775 0000000 0000000 00000000000 15211177045 0020727 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-75cbe37/docs/Makefile 0000664 0000000 0000000 00000001372 15211177045 0022372 0 ustar 00root root 0000000 0000000 # Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
.PHONY: help livehtml Makefile
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
# Build, watch and serve docs with live reload
livehtml:
sphinx-autobuild -b html -c . $(SOURCEDIR) $(BUILDDIR)/html
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
Bluetooth-Devices-habluetooth-75cbe37/docs/_static/ 0000775 0000000 0000000 00000000000 15211177045 0022355 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-75cbe37/docs/_static/.gitkeep 0000664 0000000 0000000 00000000000 15211177045 0023774 0 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-75cbe37/docs/changelog.md 0000664 0000000 0000000 00000000060 15211177045 0023174 0 ustar 00root root 0000000 0000000 (changelog)=
```{include} ../CHANGELOG.md
```
Bluetooth-Devices-habluetooth-75cbe37/docs/conf.py 0000664 0000000 0000000 00000001217 15211177045 0022227 0 ustar 00root root 0000000 0000000 # Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# Project information
project = "habluetooth"
copyright = "2023, J. Nick Koston"
author = "J. Nick Koston"
release = "6.8.3"
# General configuration
extensions = [
"myst_parser",
]
# The suffix of source filenames.
source_suffix = [
".rst",
".md",
]
templates_path = [
"_templates",
]
exclude_patterns = [
"_build",
"Thumbs.db",
".DS_Store",
]
# Options for HTML output
html_theme = "furo"
html_static_path = ["_static"]
Bluetooth-Devices-habluetooth-75cbe37/docs/contributing.md 0000664 0000000 0000000 00000000066 15211177045 0023762 0 ustar 00root root 0000000 0000000 (contributing)=
```{include} ../CONTRIBUTING.md
```
Bluetooth-Devices-habluetooth-75cbe37/docs/index.md 0000664 0000000 0000000 00000000350 15211177045 0022356 0 ustar 00root root 0000000 0000000 # Welcome to habluetooth documentation!
```{toctree}
:caption: Installation & Usage
:maxdepth: 2
installation
usage
```
```{toctree}
:caption: Project Info
:maxdepth: 2
changelog
contributing
```
```{include} ../README.md
```
Bluetooth-Devices-habluetooth-75cbe37/docs/installation.md 0000664 0000000 0000000 00000000415 15211177045 0023752 0 ustar 00root root 0000000 0000000 (installation)=
# Installation
The package is published on [PyPI](https://pypi.org/project/habluetooth/) and can be installed with `pip` (or any equivalent):
```bash
pip install habluetooth
```
Next, see the {ref}`section about usage ` to see how to use it.
Bluetooth-Devices-habluetooth-75cbe37/docs/make.bat 0000664 0000000 0000000 00000001375 15211177045 0022342 0 ustar 00root root 0000000 0000000 @ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.https://www.sphinx-doc.org/
exit /b 1
)
if "%1" == "" goto help
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd
Bluetooth-Devices-habluetooth-75cbe37/docs/usage.md 0000664 0000000 0000000 00000000326 15211177045 0022356 0 ustar 00root root 0000000 0000000 (usage)=
# Usage
Assuming that you've followed the {ref}`installations steps `, you're now ready to use this package.
Start by importing it:
```python
import habluetooth
```
TODO: Document usage
Bluetooth-Devices-habluetooth-75cbe37/examples/ 0000775 0000000 0000000 00000000000 15211177045 0021615 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-75cbe37/examples/bluez_api.py 0000664 0000000 0000000 00000002440 15211177045 0024141 0 ustar 00root root 0000000 0000000 import asyncio
import logging
from habluetooth import BluetoothManager, BluetoothScanningMode
from habluetooth.scanner import HaScanner
int_ = int
class LoggingHaScanner(HaScanner):
"""Logging ha scanner."""
def _async_on_raw_bluez_advertisement(
self,
address: bytes,
address_type: int_,
rssi: int_,
flags: int_,
data: bytes,
) -> None:
"""Handle raw advertisement data."""
print(
f"address={address!r}, address_type={address_type}, "
f"rssi={rssi}, flags={flags}, data={data!r}"
)
async def main() -> None:
"""Main function to test the Bluetooth management API."""
# Set up logging
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger("habluetooth")
logger.setLevel(logging.DEBUG)
manager = BluetoothManager()
await manager.async_setup()
# Create an instance of MGMTBluetoothCtl
scanner = LoggingHaScanner(
BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF"
)
manager.async_register_scanner(scanner)
try:
await asyncio.Event().wait()
finally:
# Close the management interface when done
manager.async_stop()
if __name__ == "__main__":
# Run the main function
asyncio.run(main())
Bluetooth-Devices-habluetooth-75cbe37/poetry.lock 0000664 0000000 0000000 00000561470 15211177045 0022210 0 ustar 00root root 0000000 0000000 # This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand.
[[package]]
name = "accessible-pygments"
version = "0.0.5"
description = "A collection of accessible pygments styles"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7"},
{file = "accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872"},
]
[package.dependencies]
pygments = ">=1.5"
[package.extras]
dev = ["pillow", "pkginfo (>=1.10)", "playwright", "pre-commit", "setuptools", "twine (>=5.0)"]
tests = ["hypothesis", "pytest"]
[[package]]
name = "aiooui"
version = "0.1.9"
description = "Async OUI lookups"
optional = false
python-versions = "<4.0,>=3.9"
groups = ["main"]
files = [
{file = "aiooui-0.1.9-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:64d904b43f14dd1d8d9fcf1684d9e2f558bc5e0bd68dc10023c93355c9027907"},
{file = "aiooui-0.1.9-py3-none-any.whl", hash = "sha256:737a5e62d8726540218c2b70e5f966d9912121e4644f3d490daf8f3c18b182e5"},
{file = "aiooui-0.1.9.tar.gz", hash = "sha256:e8c8bc59ab352419e0747628b4cce7c4e04d492574c1971e223401126389c5d8"},
]
[[package]]
name = "alabaster"
version = "1.0.0"
description = "A light, configurable Sphinx theme"
optional = false
python-versions = ">=3.10"
groups = ["docs"]
files = [
{file = "alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b"},
{file = "alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e"},
]
[[package]]
name = "anyio"
version = "4.9.0"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
optional = false
python-versions = ">=3.9"
groups = ["dev", "docs"]
files = [
{file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"},
{file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"},
]
[package.dependencies]
idna = ">=2.8"
sniffio = ">=1.1"
typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
[package.extras]
doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"]
test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""]
trio = ["trio (>=0.26.1)"]
[[package]]
name = "async-interrupt"
version = "1.2.2"
description = "Context manager to raise an exception when a future is done"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "async_interrupt-1.2.2-py3-none-any.whl", hash = "sha256:0a8deb884acfb5fe55188a693ae8a4381bbbd2cb6e670dac83869489513eec2c"},
{file = "async_interrupt-1.2.2.tar.gz", hash = "sha256:be4331a029b8625777905376a6dc1370984c8c810f30b79703f3ee039d262bf7"},
]
[[package]]
name = "babel"
version = "2.17.0"
description = "Internationalization utilities"
optional = false
python-versions = ">=3.8"
groups = ["docs"]
files = [
{file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"},
{file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"},
]
[package.extras]
dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""]
[[package]]
name = "beautifulsoup4"
version = "4.13.4"
description = "Screen-scraping library"
optional = false
python-versions = ">=3.7.0"
groups = ["docs"]
files = [
{file = "beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b"},
{file = "beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195"},
]
[package.dependencies]
soupsieve = ">1.2"
typing-extensions = ">=4.0.0"
[package.extras]
cchardet = ["cchardet"]
chardet = ["chardet"]
charset-normalizer = ["charset-normalizer"]
html5lib = ["html5lib"]
lxml = ["lxml"]
[[package]]
name = "bleak"
version = "3.0.2"
description = "Bluetooth Low Energy platform Agnostic Klient"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "bleak-3.0.2-py3-none-any.whl", hash = "sha256:39092feb9e83f1df5ad2f88e837723c7211c982ce9e9cda6235104bc2ebe0d0d"},
{file = "bleak-3.0.2.tar.gz", hash = "sha256:c2229cb8238d5876b4bd05c74bf7a1aea1f88da39d2e51ac9dfd5cc319d5265f"},
]
[package.dependencies]
dbus-fast = {version = ">=1.83.0", markers = "sys_platform == \"linux\""}
pyobjc-core = {version = ">=10.3", markers = "sys_platform == \"darwin\""}
pyobjc-framework-corebluetooth = {version = ">=10.3", markers = "sys_platform == \"darwin\""}
pyobjc-framework-libdispatch = {version = ">=10.3", markers = "sys_platform == \"darwin\""}
typing-extensions = {version = ">=4.7.0", markers = "python_full_version < \"3.12.0\""}
winrt-runtime = {version = ">=3.1", markers = "sys_platform == \"win32\""}
winrt-windows-devices-bluetooth = {version = ">=3.1", markers = "sys_platform == \"win32\""}
winrt-windows-devices-bluetooth-advertisement = {version = ">=3.1", markers = "sys_platform == \"win32\""}
winrt-windows-devices-bluetooth-genericattributeprofile = {version = ">=3.1", markers = "sys_platform == \"win32\""}
winrt-windows-devices-enumeration = {version = ">=3.1", markers = "sys_platform == \"win32\""}
winrt-windows-devices-radios = {version = ">=3.1", markers = "sys_platform == \"win32\""}
winrt-windows-foundation = {version = ">=3.1", markers = "sys_platform == \"win32\""}
winrt-windows-foundation-collections = {version = ">=3.1", markers = "sys_platform == \"win32\""}
winrt-windows-storage-streams = {version = ">=3.1", markers = "sys_platform == \"win32\""}
[package.extras]
pythonista = ["bleak-pythonista (>=0.1.1)"]
[[package]]
name = "bleak-retry-connector"
version = "4.6.1"
description = "A connector for Bleak Clients that handles transient connection failures"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "bleak_retry_connector-4.6.1-py3-none-any.whl", hash = "sha256:018ba421babe05785e5a6497c73f3894772fad0f7fa5b054d48beb3d180ce0c4"},
{file = "bleak_retry_connector-4.6.1.tar.gz", hash = "sha256:ac2d19362f96757708dff2b0fedfefd5a8d8efb724027a777e54cc8ac2fc5a3d"},
]
[package.dependencies]
bleak = ">=2"
bluetooth-adapters = {version = ">=0.15.2", markers = "platform_system == \"Linux\""}
dbus-fast = {version = ">=4.3.0", markers = "platform_system == \"Linux\""}
[[package]]
name = "bluetooth-adapters"
version = "2.2.0"
description = "Tools to enumerate and find Bluetooth Adapters"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "bluetooth_adapters-2.2.0-py3-none-any.whl", hash = "sha256:52d6cf4d9c28bbf987ee5a27a3fed33f37edac3e0de202da6fbc2e6925adf1e3"},
{file = "bluetooth_adapters-2.2.0.tar.gz", hash = "sha256:b6011cdaf68b6d075b5a90c85c5102844799fdc377758d78142a638c7b0c06fb"},
]
[package.dependencies]
aiooui = ">=0.1.1"
bleak = ">=1"
dbus-fast = {version = ">=1.21.0", markers = "platform_system == \"Linux\""}
uart-devices = ">=0.1.0"
usb-devices = ">=0.4.5"
[package.extras]
docs = ["Sphinx (>=5,<8)", "myst-parser (>=0.18,<3.1)", "sphinx-rtd-theme (>=1,<4)"]
[[package]]
name = "bluetooth-auto-recovery"
version = "1.6.4"
description = "Recover bluetooth adapters that are in an stuck state"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "bluetooth_auto_recovery-1.6.4-py3-none-any.whl", hash = "sha256:39485c41e17a2d4887c1fbf04b4e2fd37f0f3c7898db388753e026ca3addf055"},
{file = "bluetooth_auto_recovery-1.6.4.tar.gz", hash = "sha256:c69a9f3b5e00239ab005d808aa5e7afa3c36a82f86e9531b6c7682bde1bc3ecc"},
]
[package.dependencies]
bluetooth-adapters = ">=0.16.0"
btsocket = ">=0.2.0"
PyRIC = ">=0.1.6.3"
usb-devices = ">=0.4.1"
[package.extras]
docs = ["Sphinx (>=5,<8)", "myst-parser (>=0.18,<3.1)", "sphinx-rtd-theme (>=1,<4)"]
[[package]]
name = "bluetooth-data-tools"
version = "1.29.18"
description = "Tools for converting bluetooth data and packets"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "bluetooth_data_tools-1.29.18-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:fd5408d54eac9f5ecf34193a40e1badb905ae8b3a1801b92252e365064888c11"},
{file = "bluetooth_data_tools-1.29.18.tar.gz", hash = "sha256:87f678cc7b4963cb3ba73064dd72155f915bec4b21f22acd997848ddc0b1c67b"},
]
[package.dependencies]
cryptography = ">=47.0.0"
[package.extras]
docs = ["Sphinx (>=5,<9)", "myst-parser (>=0.18,<4.1)", "sphinx-rtd-theme (>=1,<4)"]
[[package]]
name = "btsocket"
version = "0.3.0"
description = "Python library for BlueZ Bluetooth Management API"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "btsocket-0.3.0-py2.py3-none-any.whl", hash = "sha256:949821c1b580a88e73804ad610f5173d6ae258e7b4e389da4f94d614344f1a9c"},
{file = "btsocket-0.3.0.tar.gz", hash = "sha256:7ea495de0ff883f0d9f8eea59c72ca7fed492994df668fe476b84d814a147a0d"},
]
[package.extras]
dev = ["bumpversion", "coverage", "pycodestyle", "pygments", "sphinx", "sphinx-rtd-theme", "twine"]
docs = ["pygments", "sphinx", "sphinx-rtd-theme"]
rel = ["bumpversion", "twine"]
test = ["coverage", "pycodestyle"]
[[package]]
name = "certifi"
version = "2025.6.15"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
groups = ["docs"]
files = [
{file = "certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057"},
{file = "certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b"},
]
[[package]]
name = "cffi"
version = "2.0.0"
description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "platform_python_implementation != \"PyPy\""
files = [
{file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
{file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"},
{file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"},
{file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"},
{file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"},
{file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"},
{file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"},
{file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"},
{file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"},
{file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"},
{file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"},
{file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"},
{file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"},
{file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"},
{file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"},
{file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"},
{file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"},
{file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"},
{file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"},
{file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"},
{file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"},
{file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"},
{file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"},
{file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"},
{file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"},
{file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"},
{file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"},
{file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"},
{file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"},
{file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"},
{file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"},
{file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"},
{file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"},
{file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"},
{file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"},
{file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"},
{file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"},
{file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"},
{file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"},
{file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"},
{file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"},
{file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"},
{file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"},
{file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"},
{file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"},
{file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"},
{file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"},
{file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"},
{file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"},
{file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"},
{file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"},
{file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"},
{file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"},
{file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"},
{file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"},
{file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"},
{file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"},
{file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"},
{file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"},
{file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"},
{file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"},
{file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"},
{file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"},
{file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"},
{file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"},
{file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"},
{file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"},
{file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"},
{file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"},
{file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"},
{file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"},
{file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"},
{file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"},
{file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"},
{file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"},
{file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"},
{file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"},
{file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"},
{file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"},
{file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"},
{file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"},
{file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"},
{file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"},
{file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"},
]
[package.dependencies]
pycparser = {version = "*", markers = "implementation_name != \"PyPy\""}
[[package]]
name = "charset-normalizer"
version = "3.4.2"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7"
groups = ["docs"]
files = [
{file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"},
{file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"},
{file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6"},
{file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d"},
{file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86"},
{file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c"},
{file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0"},
{file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef"},
{file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6"},
{file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366"},
{file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db"},
{file = "charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a"},
{file = "charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509"},
{file = "charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2"},
{file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645"},
{file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd"},
{file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8"},
{file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f"},
{file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7"},
{file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9"},
{file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544"},
{file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82"},
{file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0"},
{file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5"},
{file = "charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a"},
{file = "charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28"},
{file = "charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7"},
{file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3"},
{file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a"},
{file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214"},
{file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a"},
{file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd"},
{file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981"},
{file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c"},
{file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b"},
{file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d"},
{file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f"},
{file = "charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c"},
{file = "charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e"},
{file = "charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0"},
{file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf"},
{file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e"},
{file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1"},
{file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c"},
{file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691"},
{file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0"},
{file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b"},
{file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff"},
{file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b"},
{file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148"},
{file = "charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7"},
{file = "charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980"},
{file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184"},
{file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa"},
{file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344"},
{file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da"},
{file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02"},
{file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d"},
{file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4"},
{file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f"},
{file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64"},
{file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f"},
{file = "charset_normalizer-3.4.2-cp37-cp37m-win32.whl", hash = "sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58"},
{file = "charset_normalizer-3.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2"},
{file = "charset_normalizer-3.4.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb"},
{file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a"},
{file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45"},
{file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5"},
{file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1"},
{file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027"},
{file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b"},
{file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455"},
{file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01"},
{file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58"},
{file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681"},
{file = "charset_normalizer-3.4.2-cp38-cp38-win32.whl", hash = "sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7"},
{file = "charset_normalizer-3.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a"},
{file = "charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4"},
{file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7"},
{file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836"},
{file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597"},
{file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7"},
{file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f"},
{file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba"},
{file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12"},
{file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518"},
{file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5"},
{file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3"},
{file = "charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471"},
{file = "charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e"},
{file = "charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0"},
{file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"},
]
[[package]]
name = "click"
version = "8.2.1"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.10"
groups = ["docs"]
files = [
{file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"},
{file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"},
]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["dev", "docs"]
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
markers = {dev = "sys_platform == \"win32\""}
[[package]]
name = "coverage"
version = "7.10.6"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "coverage-7.10.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70e7bfbd57126b5554aa482691145f798d7df77489a177a6bef80de78860a356"},
{file = "coverage-7.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301"},
{file = "coverage-7.10.6-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c61fc91ab80b23f5fddbee342d19662f3d3328173229caded831aa0bd7595460"},
{file = "coverage-7.10.6-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10356fdd33a7cc06e8051413140bbdc6f972137508a3572e3f59f805cd2832fd"},
{file = "coverage-7.10.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80b1695cf7c5ebe7b44bf2521221b9bb8cdf69b1f24231149a7e3eb1ae5fa2fb"},
{file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2e4c33e6378b9d52d3454bd08847a8651f4ed23ddbb4a0520227bd346382bbc6"},
{file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c8a3ec16e34ef980a46f60dc6ad86ec60f763c3f2fa0db6d261e6e754f72e945"},
{file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7d79dabc0a56f5af990cc6da9ad1e40766e82773c075f09cc571e2076fef882e"},
{file = "coverage-7.10.6-cp310-cp310-win32.whl", hash = "sha256:86b9b59f2b16e981906e9d6383eb6446d5b46c278460ae2c36487667717eccf1"},
{file = "coverage-7.10.6-cp310-cp310-win_amd64.whl", hash = "sha256:e132b9152749bd33534e5bd8565c7576f135f157b4029b975e15ee184325f528"},
{file = "coverage-7.10.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c706db3cabb7ceef779de68270150665e710b46d56372455cd741184f3868d8f"},
{file = "coverage-7.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e0c38dc289e0508ef68ec95834cb5d2e96fdbe792eaccaa1bccac3966bbadcc"},
{file = "coverage-7.10.6-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:752a3005a1ded28f2f3a6e8787e24f28d6abe176ca64677bcd8d53d6fe2ec08a"},
{file = "coverage-7.10.6-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:689920ecfd60f992cafca4f5477d55720466ad2c7fa29bb56ac8d44a1ac2b47a"},
{file = "coverage-7.10.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec98435796d2624d6905820a42f82149ee9fc4f2d45c2c5bc5a44481cc50db62"},
{file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b37201ce4a458c7a758ecc4efa92fa8ed783c66e0fa3c42ae19fc454a0792153"},
{file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2904271c80898663c810a6b067920a61dd8d38341244a3605bd31ab55250dad5"},
{file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5aea98383463d6e1fa4e95416d8de66f2d0cb588774ee20ae1b28df826bcb619"},
{file = "coverage-7.10.6-cp311-cp311-win32.whl", hash = "sha256:e3fb1fa01d3598002777dd259c0c2e6d9d5e10e7222976fc8e03992f972a2cba"},
{file = "coverage-7.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:f35ed9d945bece26553d5b4c8630453169672bea0050a564456eb88bdffd927e"},
{file = "coverage-7.10.6-cp311-cp311-win_arm64.whl", hash = "sha256:99e1a305c7765631d74b98bf7dbf54eeea931f975e80f115437d23848ee8c27c"},
{file = "coverage-7.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b2dd6059938063a2c9fee1af729d4f2af28fd1a545e9b7652861f0d752ebcea"},
{file = "coverage-7.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:388d80e56191bf846c485c14ae2bc8898aa3124d9d35903fef7d907780477634"},
{file = "coverage-7.10.6-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:90cb5b1a4670662719591aa92d0095bb41714970c0b065b02a2610172dbf0af6"},
{file = "coverage-7.10.6-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:961834e2f2b863a0e14260a9a273aff07ff7818ab6e66d2addf5628590c628f9"},
{file = "coverage-7.10.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf9a19f5012dab774628491659646335b1928cfc931bf8d97b0d5918dd58033c"},
{file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:99c4283e2a0e147b9c9cc6bc9c96124de9419d6044837e9799763a0e29a7321a"},
{file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:282b1b20f45df57cc508c1e033403f02283adfb67d4c9c35a90281d81e5c52c5"},
{file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cdbe264f11afd69841bd8c0d83ca10b5b32853263ee62e6ac6a0ab63895f972"},
{file = "coverage-7.10.6-cp312-cp312-win32.whl", hash = "sha256:a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d"},
{file = "coverage-7.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:856986eadf41f52b214176d894a7de05331117f6035a28ac0016c0f63d887629"},
{file = "coverage-7.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80"},
{file = "coverage-7.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6"},
{file = "coverage-7.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80"},
{file = "coverage-7.10.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003"},
{file = "coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27"},
{file = "coverage-7.10.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4"},
{file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d"},
{file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc"},
{file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc"},
{file = "coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e"},
{file = "coverage-7.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32"},
{file = "coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2"},
{file = "coverage-7.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b"},
{file = "coverage-7.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393"},
{file = "coverage-7.10.6-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27"},
{file = "coverage-7.10.6-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df"},
{file = "coverage-7.10.6-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb"},
{file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282"},
{file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4"},
{file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21"},
{file = "coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0"},
{file = "coverage-7.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5"},
{file = "coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b"},
{file = "coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e"},
{file = "coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb"},
{file = "coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034"},
{file = "coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1"},
{file = "coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a"},
{file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb"},
{file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d"},
{file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747"},
{file = "coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5"},
{file = "coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713"},
{file = "coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32"},
{file = "coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65"},
{file = "coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6"},
{file = "coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0"},
{file = "coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e"},
{file = "coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5"},
{file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7"},
{file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5"},
{file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0"},
{file = "coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7"},
{file = "coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930"},
{file = "coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b"},
{file = "coverage-7.10.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90558c35af64971d65fbd935c32010f9a2f52776103a259f1dee865fe8259352"},
{file = "coverage-7.10.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8953746d371e5695405806c46d705a3cd170b9cc2b9f93953ad838f6c1e58612"},
{file = "coverage-7.10.6-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c83f6afb480eae0313114297d29d7c295670a41c11b274e6bca0c64540c1ce7b"},
{file = "coverage-7.10.6-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7eb68d356ba0cc158ca535ce1381dbf2037fa8cb5b1ae5ddfc302e7317d04144"},
{file = "coverage-7.10.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b15a87265e96307482746d86995f4bff282f14b027db75469c446da6127433b"},
{file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fc53ba868875bfbb66ee447d64d6413c2db91fddcfca57025a0e7ab5b07d5862"},
{file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:efeda443000aa23f276f4df973cb82beca682fd800bb119d19e80504ffe53ec2"},
{file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9702b59d582ff1e184945d8b501ffdd08d2cee38d93a2206aa5f1365ce0b8d78"},
{file = "coverage-7.10.6-cp39-cp39-win32.whl", hash = "sha256:2195f8e16ba1a44651ca684db2ea2b2d4b5345da12f07d9c22a395202a05b23c"},
{file = "coverage-7.10.6-cp39-cp39-win_amd64.whl", hash = "sha256:f32ff80e7ef6a5b5b606ea69a36e97b219cd9dc799bcf2963018a4d8f788cfbf"},
{file = "coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3"},
{file = "coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90"},
]
[package.extras]
toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
[[package]]
name = "cryptography"
version = "48.0.0"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false
python-versions = "!=3.9.0,!=3.9.1,>=3.9"
groups = ["main"]
files = [
{file = "cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6"},
{file = "cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c"},
{file = "cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3"},
{file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5"},
{file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c"},
{file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f"},
{file = "cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25"},
{file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602"},
{file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c"},
{file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5"},
{file = "cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321"},
{file = "cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74"},
{file = "cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4"},
{file = "cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7"},
{file = "cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec"},
{file = "cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18"},
{file = "cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20"},
{file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff"},
{file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c"},
{file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db"},
{file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741"},
{file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166"},
{file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336"},
{file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057"},
{file = "cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae"},
{file = "cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c"},
{file = "cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f"},
{file = "cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12"},
{file = "cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86"},
{file = "cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e"},
{file = "cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f"},
{file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7"},
{file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832"},
{file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c"},
{file = "cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a"},
{file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a"},
{file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a"},
{file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239"},
{file = "cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c"},
{file = "cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4"},
{file = "cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd"},
{file = "cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8"},
{file = "cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855"},
{file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b"},
{file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13"},
{file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb"},
{file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355"},
{file = "cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a"},
{file = "cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920"},
]
[package.dependencies]
cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""}
[package.extras]
ssh = ["bcrypt (>=3.1.5)"]
[[package]]
name = "dbus-fast"
version = "5.0.16"
description = "A faster version of dbus-next"
optional = false
python-versions = ">=3.10"
groups = ["main", "dev"]
files = [
{file = "dbus_fast-5.0.16-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:872bed67d7c0e58663d4e0ed5be44e7e56800222dd2ef20796be6ba40aaf8443"},
{file = "dbus_fast-5.0.16-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc1b3de7c70cb25cb4799fd098aae23caca89e957f199a02997c2c280f20a1a8"},
{file = "dbus_fast-5.0.16-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0601912b5f7015870f3987216c7d0cec49278adb8a282fc879e171a73b7f8f4"},
{file = "dbus_fast-5.0.16-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d57b49994666998dfdacaac726f6c788855ebd1942e3d40b91bf72b9bd64c079"},
{file = "dbus_fast-5.0.16-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b92b773075adc85f24a628875e8b57f4d1f1fbe63ec18db1bb9f136f26a6f621"},
{file = "dbus_fast-5.0.16-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:7fb55b9658f9a285bd52b6209e54a89613da7f1134d505aa089299ed99268c1a"},
{file = "dbus_fast-5.0.16-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c775272eccf2aa3948c72509c590f84a9a9bebb3bc7ee68452ad029209670c63"},
{file = "dbus_fast-5.0.16-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0a846c0103073f78cecd70b7cd229d4f37b739164797de4d7943d0591dac4393"},
{file = "dbus_fast-5.0.16-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddac5066da9e1e38d434e41372392ac62ab8ce80e4e9616a0504999749b9ec46"},
{file = "dbus_fast-5.0.16-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d41f51d2a420f7274d4f48b7a4f3e56b70ee8ca87ad666854007de102025ee3"},
{file = "dbus_fast-5.0.16-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:651347df9079744395c6404e0ed03a3704485d21833765db7ad71390c7c807a6"},
{file = "dbus_fast-5.0.16-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f5017cb3c32b07622f9c01a1285526183988ae1dd4e5fedc7390650a3cfbca2"},
{file = "dbus_fast-5.0.16-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:bff101277226e71aa744d72b968efcdde4e185dc0d3571b8168edb99a2f72d3a"},
{file = "dbus_fast-5.0.16-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3367dfeba013db0f97004b4c93f4a21c39728f2bbe0fbef74dbbeb6e078e47fa"},
{file = "dbus_fast-5.0.16-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f35c2ce7ab95ecc8a378119a024691b33693f07068bfaa3642d32d2f1a0e28a8"},
{file = "dbus_fast-5.0.16-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:85a7771883fd5beaa5e92b27772722db5e3d4dc2f7e04a1812d3bf01b7de440d"},
{file = "dbus_fast-5.0.16-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c42dc349ea1e52e4bd17462d5029c7650d76e8ccd2fbd136b6a0384abae61004"},
{file = "dbus_fast-5.0.16-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:941b926581ef458dfde70711badbb5ab1bfba523b527f1b73004c21cdf9976ec"},
{file = "dbus_fast-5.0.16-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:42a7901d5f053490d3d59c5a9b9a13b1d8322836458efcb93040ea360aced572"},
{file = "dbus_fast-5.0.16-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8deec1d4dd41336672b30b596d60ed78898e923f3b06f9ef60affc1a5dec656c"},
{file = "dbus_fast-5.0.16-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8b2c92f82af04d59c50cbccf65f995fa2e321ce7850e3c1b613e94680a8044e"},
{file = "dbus_fast-5.0.16-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:608f3e5f217a8f24c4d487667a2eae2bcd52899fd421bd0d05fc81462090936f"},
{file = "dbus_fast-5.0.16-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:831a6f6a865260395fa59f80d36c73b1e270b268603f53422552a813bf61c529"},
{file = "dbus_fast-5.0.16-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4eee863c411c0c1c783518f30c3de50339316fc745ac2abcc0778046e4adac40"},
{file = "dbus_fast-5.0.16-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:790c1310d38369e659b45ae5b95b6bce24ae51e011634142324cb6ebc8e11398"},
{file = "dbus_fast-5.0.16-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f0707f8473cd8e5be0ac1aceed7532e2fcb4b5b97584f2878f98cbe61049a184"},
{file = "dbus_fast-5.0.16-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:df3f3ee7cf61be297be9d3ce2074f74da44b13cdb413cc821e398902d2f38036"},
{file = "dbus_fast-5.0.16-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0b800db6ffaec39554f1489b54482e33a8b6247325974cf82659504a3816b956"},
{file = "dbus_fast-5.0.16-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a844384792afdec27f98ec9708e715399c539837fae576e532661b94a52f812a"},
{file = "dbus_fast-5.0.16-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874bbd1fff34054c10b1062676334fcf170f32a0f0f9f21d2a9d1bf22ef70b35"},
{file = "dbus_fast-5.0.16-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07554554373441f562d1469bd73d024dd3144c9725375bbffbe8b3375bf464fe"},
{file = "dbus_fast-5.0.16-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6fc1831c702725eb4875d69c877700d95e7c3fa8542f4edaad318c6ba3e66f2"},
{file = "dbus_fast-5.0.16-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:8447d1ab3cd7dc9976805dda5903453d0fdb8aaab870536f3bcaf6b1f58800a3"},
{file = "dbus_fast-5.0.16-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d36ff2f0f6a5fd6dae96a09f48783ee7de51bf12d0da18a13edf19766bd0b99a"},
{file = "dbus_fast-5.0.16-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fc43f5a7e7db86728e9925757a53a717eb3fec27de54e7bbc688cd65b8e9028c"},
{file = "dbus_fast-5.0.16-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:56e64ff9d1a1f503e07a940f861bf6dc80f24602425cb6f384d69bdc7fe7071f"},
{file = "dbus_fast-5.0.16-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f95648d4904426c1538df894b4f327843374b760f0491479d36eb115885ee4d"},
{file = "dbus_fast-5.0.16-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d8943d0b92ec9f49e37aed6224515ce161eb2b5df5e9fd3e1d692f35efbe48b"},
{file = "dbus_fast-5.0.16-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2f96e7da851b0dc9e0bc1e00d8b9cd6f227469b6e48e1b2ad474e44ff91841b"},
{file = "dbus_fast-5.0.16-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2832c5eefcc5b4e0623a27e20751ca34006d7fe73b21d58ef859aa2cef0a8ec8"},
{file = "dbus_fast-5.0.16-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d2c9ae45a01d5f2e27a9508159d6e3c34e6714b485884000839daf5320d76da9"},
{file = "dbus_fast-5.0.16-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e46276642f787a345f127389ff36a90ba9e4df6537b852d8afdc12e2b2f9bb91"},
{file = "dbus_fast-5.0.16-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f41ca52046e41775c0784148c7b6b636539722bcc1412bae3139ac7295883265"},
{file = "dbus_fast-5.0.16-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:61768afef41db9adac0ca7849a161a57b55cae34a9db49e3d39ed21727c3e321"},
{file = "dbus_fast-5.0.16-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2fc70c34595475bd5cbb523ddd4d119b6f1a5ebb03205b2a99b663d4120dd99"},
{file = "dbus_fast-5.0.16-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb3a62a2af18b8e4592555dbf92b66a66735121cf47a1664415076d0260721bd"},
{file = "dbus_fast-5.0.16-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:cc7a034d2c828ab796d58e42beef1469cdfdf6a00269efd4777c856c753cba9a"},
{file = "dbus_fast-5.0.16-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3346139351e86d81be963dddb2d0c25da2f0a9c60d40db7e66cb0cc5cf41674e"},
{file = "dbus_fast-5.0.16.tar.gz", hash = "sha256:24d0a86f32acb209a41806d20cf8a9207e4d46760e23c32479d1951d43739080"},
]
markers = {main = "sys_platform == \"linux\" or platform_system == \"Linux\""}
[[package]]
name = "docutils"
version = "0.21.2"
description = "Docutils -- Python Documentation Utilities"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"},
{file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"},
]
[[package]]
name = "freezegun"
version = "1.5.5"
description = "Let your Python tests travel through time"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2"},
{file = "freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a"},
]
[package.dependencies]
python-dateutil = ">=2.7"
[[package]]
name = "furo"
version = "2025.12.19"
description = "A clean customisable Sphinx documentation theme."
optional = false
python-versions = ">=3.8"
groups = ["docs"]
files = [
{file = "furo-2025.12.19-py3-none-any.whl", hash = "sha256:bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f"},
{file = "furo-2025.12.19.tar.gz", hash = "sha256:188d1f942037d8b37cd3985b955839fea62baa1730087dc29d157677c857e2a7"},
]
[package.dependencies]
accessible-pygments = ">=0.0.5"
beautifulsoup4 = "*"
pygments = ">=2.7"
sphinx = ">=7.0,<10.0"
sphinx-basic-ng = ">=1.0.0b2"
[[package]]
name = "h11"
version = "0.16.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false
python-versions = ">=3.8"
groups = ["docs"]
files = [
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
]
[[package]]
name = "idna"
version = "3.15"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.8"
groups = ["dev", "docs"]
files = [
{file = "idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8"},
{file = "idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc"},
]
[package.extras]
all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "imagesize"
version = "1.4.1"
description = "Getting image size from png/jpeg/jpeg2000/gif file"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
groups = ["docs"]
files = [
{file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"},
{file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"},
]
[[package]]
name = "iniconfig"
version = "2.1.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"},
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
]
[[package]]
name = "jinja2"
version = "3.1.6"
description = "A very fast and expressive template engine."
optional = false
python-versions = ">=3.7"
groups = ["docs"]
files = [
{file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"},
{file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"},
]
[package.dependencies]
MarkupSafe = ">=2.0"
[package.extras]
i18n = ["Babel (>=2.7)"]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
description = "Python port of markdown-it. Markdown parsing, done right!"
optional = false
python-versions = ">=3.10"
groups = ["dev", "docs"]
files = [
{file = "markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"},
{file = "markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"},
]
[package.dependencies]
mdurl = ">=0.1,<1.0"
[package.extras]
benchmarking = ["psutil", "pytest", "pytest-benchmark"]
compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"]
linkify = ["linkify-it-py (>=1,<3)"]
plugins = ["mdit-py-plugins (>=0.5.0)"]
profiling = ["gprof2dot"]
rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"]
testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "pytest-timeout", "requests"]
[[package]]
name = "markupsafe"
version = "3.0.2"
description = "Safely add untrusted strings to HTML/XML markup."
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"},
{file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"},
{file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"},
{file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"},
{file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"},
{file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"},
{file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"},
{file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"},
{file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"},
{file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"},
{file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"},
{file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"},
{file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"},
{file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"},
{file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"},
{file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"},
{file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"},
{file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"},
{file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"},
{file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"},
{file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"},
{file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"},
{file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"},
{file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"},
{file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"},
{file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"},
{file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"},
{file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"},
{file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"},
{file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"},
{file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"},
{file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"},
{file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"},
{file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"},
{file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"},
{file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"},
{file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"},
{file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"},
{file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"},
{file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"},
{file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"},
{file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"},
{file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"},
{file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"},
{file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"},
{file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"},
{file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"},
{file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"},
{file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"},
{file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"},
{file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"},
{file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"},
]
[[package]]
name = "mdit-py-plugins"
version = "0.6.1"
description = "Collection of plugins for markdown-it-py"
optional = false
python-versions = ">=3.10"
groups = ["docs"]
files = [
{file = "mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d"},
{file = "mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0"},
]
[package.dependencies]
markdown-it-py = ">=2.0.0,<5.0.0"
[package.extras]
code-style = ["pre-commit"]
rtd = ["myst-parser", "sphinx-book-theme"]
testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "pytest-timeout"]
[[package]]
name = "mdurl"
version = "0.1.2"
description = "Markdown URL utilities"
optional = false
python-versions = ">=3.7"
groups = ["dev", "docs"]
files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
]
[[package]]
name = "myst-parser"
version = "5.1.0"
description = "An extended [CommonMark](https://spec.commonmark.org/) compliant parser,"
optional = false
python-versions = ">=3.11"
groups = ["docs"]
files = [
{file = "myst_parser-5.1.0-py3-none-any.whl", hash = "sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a"},
{file = "myst_parser-5.1.0.tar.gz", hash = "sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02"},
]
[package.dependencies]
docutils = ">=0.20,<0.23"
jinja2 = "*"
markdown-it-py = ">=4.2,<5.0"
mdit-py-plugins = ">=0.6.1,<1.0"
pyyaml = "*"
sphinx = ">=8,<10"
[package.extras]
code-style = ["pre-commit (>=4.0,<5.0)"]
linkify = ["linkify-it-py (>=2.0,<3.0)"]
rtd = ["ipython", "sphinx (>=8)", "sphinx-autodoc2 (>=0.5.0,<0.6.0)", "sphinx-book-theme (>=1.1,<2.0)", "sphinx-copybutton", "sphinx-design", "sphinx-pyscript", "sphinx-tippy (>=0.4.3)", "sphinx-togglebutton", "sphinxext-opengraph (>=0.13.0,<0.14.0)", "sphinxext-rediraffe (>=0.3.0,<0.4.0)"]
testing = ["beautifulsoup4", "coverage[toml]", "defusedxml", "pygments (<2.21)", "pytest (>=9,<10)", "pytest-cov", "pytest-param-files (>=0.6.0,<0.7.0)", "pytest-regressions", "sphinx-pytest (>=0.3.0,<0.4.0)"]
testing-docutils = ["pygments", "pytest (>=9,<10)", "pytest-param-files (>=0.6.0,<0.7.0)"]
[[package]]
name = "packaging"
version = "25.0"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
groups = ["dev", "docs"]
files = [
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
]
[[package]]
name = "pluggy"
version = "1.6.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"]
[[package]]
name = "pycparser"
version = "2.22"
description = "C parser in Python"
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""
files = [
{file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
{file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
]
[[package]]
name = "pygments"
version = "2.20.0"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.9"
groups = ["dev", "docs"]
files = [
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
]
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyobjc-core"
version = "11.1"
description = "Python<->ObjC Interoperability Module"
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "sys_platform == \"darwin\""
files = [
{file = "pyobjc_core-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4c7536f3e94de0a3eae6bb382d75f1219280aa867cdf37beef39d9e7d580173c"},
{file = "pyobjc_core-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ec36680b5c14e2f73d432b03ba7c1457dc6ca70fa59fd7daea1073f2b4157d33"},
{file = "pyobjc_core-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:765b97dea6b87ec4612b3212258024d8496ea23517c95a1c5f0735f96b7fd529"},
{file = "pyobjc_core-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:18986f83998fbd5d3f56d8a8428b2f3e0754fd15cef3ef786ca0d29619024f2c"},
{file = "pyobjc_core-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8849e78cfe6595c4911fbba29683decfb0bf57a350aed8a43316976ba6f659d2"},
{file = "pyobjc_core-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cb9ed17a8d84a312a6e8b665dd22393d48336ea1d8277e7ad20c19a38edf731"},
{file = "pyobjc_core-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:f2455683e807f8541f0d83fbba0f5d9a46128ab0d5cc83ea208f0bec759b7f96"},
{file = "pyobjc_core-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4a99e6558b48b8e47c092051e7b3be05df1c8d0617b62f6fa6a316c01902d157"},
{file = "pyobjc_core-11.1.tar.gz", hash = "sha256:b63d4d90c5df7e762f34739b39cc55bc63dbcf9fb2fb3f2671e528488c7a87fe"},
]
[[package]]
name = "pyobjc-framework-cocoa"
version = "11.1"
description = "Wrappers for the Cocoa frameworks on macOS"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "sys_platform == \"darwin\""
files = [
{file = "pyobjc_framework_cocoa-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b27a5bdb3ab6cdeb998443ff3fce194ffae5f518c6a079b832dbafc4426937f9"},
{file = "pyobjc_framework_cocoa-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7b9a9b8ba07f5bf84866399e3de2aa311ed1c34d5d2788a995bdbe82cc36cfa0"},
{file = "pyobjc_framework_cocoa-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806de56f06dfba8f301a244cce289d54877c36b4b19818e3b53150eb7c2424d0"},
{file = "pyobjc_framework_cocoa-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54e93e1d9b0fc41c032582a6f0834befe1d418d73893968f3f450281b11603da"},
{file = "pyobjc_framework_cocoa-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:fd5245ee1997d93e78b72703be1289d75d88ff6490af94462b564892e9266350"},
{file = "pyobjc_framework_cocoa-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:aede53a1afc5433e1e7d66568cc52acceeb171b0a6005407a42e8e82580b4fc0"},
{file = "pyobjc_framework_cocoa-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:1b5de4e1757bb65689d6dc1f8d8717de9ec8587eb0c4831c134f13aba29f9b71"},
{file = "pyobjc_framework_cocoa-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bbee71eeb93b1b31ffbac8560b59a0524a8a4b90846a260d2c4f2188f3d4c721"},
{file = "pyobjc_framework_cocoa-11.1.tar.gz", hash = "sha256:87df76b9b73e7ca699a828ff112564b59251bb9bbe72e610e670a4dc9940d038"},
]
[package.dependencies]
pyobjc-core = ">=11.1"
[[package]]
name = "pyobjc-framework-corebluetooth"
version = "11.1"
description = "Wrappers for the framework CoreBluetooth on macOS"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "sys_platform == \"darwin\""
files = [
{file = "pyobjc_framework_corebluetooth-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ab509994503a5f0ec0f446a7ccc9f9a672d5a427d40dba4563dd00e8e17dfb06"},
{file = "pyobjc_framework_corebluetooth-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:433b8593eb1ea8b6262b243ec903e1de4434b768ce103ebe15aac249b890cc2a"},
{file = "pyobjc_framework_corebluetooth-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:36bef95a822c68b72f505cf909913affd61a15b56eeaeafea7302d35a82f4f05"},
{file = "pyobjc_framework_corebluetooth-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:992404b03033ecf637e9174caed70cb22fd1be2a98c16faa699217678e62a5c7"},
{file = "pyobjc_framework_corebluetooth-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ebb8648f5e33d98446eb1d6c4654ba4fcc15d62bfcb47fa3bbd5596f6ecdb37c"},
{file = "pyobjc_framework_corebluetooth-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:e84cbf52006a93d937b90421ada0bc4a146d6d348eb40ae10d5bd2256cc92206"},
{file = "pyobjc_framework_corebluetooth-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:4da1106265d7efd3f726bacdf13ba9528cc380fb534b5af38b22a397e6908291"},
{file = "pyobjc_framework_corebluetooth-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e9fa3781fea20a31b3bb809deaeeab3bdc7b86602a1fd829f0e86db11d7aa577"},
{file = "pyobjc_framework_corebluetooth-11.1.tar.gz", hash = "sha256:1deba46e3fcaf5e1c314f4bbafb77d9fe49ec248c493ad00d8aff2df212d6190"},
]
[package.dependencies]
pyobjc-core = ">=11.1"
pyobjc-framework-Cocoa = ">=11.1"
[[package]]
name = "pyobjc-framework-libdispatch"
version = "11.1"
description = "Wrappers for libdispatch on macOS"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "sys_platform == \"darwin\""
files = [
{file = "pyobjc_framework_libdispatch-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9c598c073a541b5956b5457b94bd33b9ce19ef8d867235439a0fad22d6beab49"},
{file = "pyobjc_framework_libdispatch-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2ddca472c2cbc6bb192e05b8b501d528ce49333abe7ef0eef28df3133a8e18b7"},
{file = "pyobjc_framework_libdispatch-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dc9a7b8c2e8a63789b7cf69563bb7247bde15353208ef1353fff0af61b281684"},
{file = "pyobjc_framework_libdispatch-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c4e219849f5426745eb429f3aee58342a59f81e3144b37aa20e81dacc6177de1"},
{file = "pyobjc_framework_libdispatch-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a9357736cb47b4a789f59f8fab9b0d10b0a9c84f9876367c398718d3de085888"},
{file = "pyobjc_framework_libdispatch-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:cd08f32ea7724906ef504a0fd40a32e2a0be4d64b9239530a31767ca9ccfc921"},
{file = "pyobjc_framework_libdispatch-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:5d9985b0e050cae72bf2c6a1cc8180ff4fa3a812cd63b2dc59e09c6f7f6263a1"},
{file = "pyobjc_framework_libdispatch-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cfe515f4c3ea66c13fce4a527230027517b8b779b40bbcb220ff7cdf3ad20bc4"},
{file = "pyobjc_framework_libdispatch-11.1.tar.gz", hash = "sha256:11a704e50a0b7dbfb01552b7d686473ffa63b5254100fdb271a1fe368dd08e87"},
]
[package.dependencies]
pyobjc-core = ">=11.1"
pyobjc-framework-Cocoa = ">=11.1"
[[package]]
name = "pyric"
version = "0.1.6.3"
description = "Python Wireless Library"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "PyRIC-0.1.6.3.tar.gz", hash = "sha256:b539b01cafebd2406c00097f94525ea0f8ecd1dd92f7731f43eac0ef16c2ccc9"},
]
[[package]]
name = "pytest"
version = "9.0.3"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"},
{file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"},
]
[package.dependencies]
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
iniconfig = ">=1.0.1"
packaging = ">=22"
pluggy = ">=1.5,<2"
pygments = ">=2.7.2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
description = "Pytest support for asyncio"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1"},
{file = "pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42"},
]
[package.dependencies]
pytest = ">=8.4,<10"
typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""}
[package.extras]
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)", "sphinx-tabs (>=3.5)"]
testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
[[package]]
name = "pytest-codspeed"
version = "5.0.3"
description = "Pytest plugin to create CodSpeed benchmarks"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest_codspeed-5.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:005348ea52ace3ede2e2f595913912ad2564cca7b124211a88dc78a9cb1fca63"},
{file = "pytest_codspeed-5.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbe6a4a00b449b6ba2771f644cbc38bdf55acf5c812e60e5659110e19dd9f510"},
{file = "pytest_codspeed-5.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac4344f34bbcdd17f6f8c30dbac3da2f80d223dd112e568fd7f7c2cd4cbc693"},
{file = "pytest_codspeed-5.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f56d0339cd98d26f6e561987be25bdd2761a5d53d8f73493b1ebe02d0d451093"},
{file = "pytest_codspeed-5.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c682f6645d4eb472f3bd95dbda1805e3af4243610572cb7d6bf94a88e8a0b6c"},
{file = "pytest_codspeed-5.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f852bee785a7a124cb1720b1915670c6742af87747dc4d838f3ffdbd365ce9d9"},
{file = "pytest_codspeed-5.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2eeb25fb1ac3f73c4de50e739e78fea396b89782bdb740bf2a7cd2df21f8d4ee"},
{file = "pytest_codspeed-5.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73c5c9d98a3372a42611989ccfa437cce3842431ac6d6b9ab42c4f0e59c070f7"},
{file = "pytest_codspeed-5.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2e0ab65df73e837666d12357280ca50ff6d6ac03ea5266703be518b68170edf"},
{file = "pytest_codspeed-5.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6524c57fec279a22ffef6112af404036afc71b4704758ae9f0abda429b8478d4"},
{file = "pytest_codspeed-5.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c383c9121deb58a69f174188e9e4488ffc0daced0ed276abf87747182511901"},
{file = "pytest_codspeed-5.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4bcdb4b6522738152885ef067e0c8524d5699828d780fb6f464cdb3db44369c"},
{file = "pytest_codspeed-5.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:25464363c7f9b9bd5022e969c0addba616fa40ac9b8f0fc9e030c4538863b32d"},
{file = "pytest_codspeed-5.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efd43f82ea03ced8488a767ded9473f050791ab7783ea8654107e1e0ac66af40"},
{file = "pytest_codspeed-5.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:782f9985b6f6b45b8bc20152d206d3a52b56dd088ba81cb70a71f0b39841be9e"},
{file = "pytest_codspeed-5.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9aa0815b90196f3c20d736ea8691381e97f12bbe8c7d87af10a351e434b452cb"},
{file = "pytest_codspeed-5.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:85505c96a3477c346ec2d2b7dced8478f4c651e2b1666ee102d53a832b511853"},
{file = "pytest_codspeed-5.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20eba63765be9d1b6cacbbfad84b87d49eb04b357a7045a0899880da181f81e3"},
{file = "pytest_codspeed-5.0.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ec9fa6f0af0a9feb0e0bd517fb59ef28f806fbd50c0c6900ac26cbb4d080eba5"},
{file = "pytest_codspeed-5.0.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8df77b3409f54f4a268f77f3ff74992fe1d995cdbaf2cecf8ad74d32db217ce7"},
{file = "pytest_codspeed-5.0.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5d8695a227ea1c3a41d25db5b3fe720bf1b4808bd38862be811a4efd902c792"},
{file = "pytest_codspeed-5.0.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bf4cc4178cbace8f4d2bd240408276bc4da3850ac5fcb5fb5f8a74ab417615bb"},
{file = "pytest_codspeed-5.0.3-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abe793da40f87295d33988673d34f06ea569848b44490b847552cd416816258a"},
{file = "pytest_codspeed-5.0.3-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3a9ed38dfa776443b86f4b49a982e8443d0953db4974bd2673d63cc904ae1ad"},
{file = "pytest_codspeed-5.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bce0a6ea93a5b19658f713312bb67554c19283ab15b454a1e3e55a13e78130f8"},
{file = "pytest_codspeed-5.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a2097247985f5d915a94b80c5552d10979ca858c859fc3edef1bf2baa5c9b7a"},
{file = "pytest_codspeed-5.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e192905a2230f9956e6160732f76577836953a4a1fb2b1e7be74e51ac7b2a0"},
{file = "pytest_codspeed-5.0.3-py3-none-any.whl", hash = "sha256:fe2ea83c924c2250675b75686c3ee456b8cf0208d83d552e182a195fdf467378"},
{file = "pytest_codspeed-5.0.3.tar.gz", hash = "sha256:91afef90e6a96b013495e4702ef5d6358614a449e71008cdc194ef668778b92f"},
]
[package.dependencies]
pytest = ">=3.8"
rich = ">=13.8.1"
[package.extras]
compat = ["pytest-benchmark (>=5.0.0,<5.1.0)", "pytest-xdist (>=3.6.1,<3.7.0)"]
[[package]]
name = "pytest-cov"
version = "7.1.0"
description = "Pytest plugin for measuring coverage."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678"},
{file = "pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2"},
]
[package.dependencies]
coverage = {version = ">=7.10.6", extras = ["toml"]}
pluggy = ">=1.2"
pytest = ">=7"
[package.extras]
testing = ["process-tests", "pytest-xdist", "virtualenv"]
[[package]]
name = "pytest-timeout"
version = "2.4.0"
description = "pytest plugin to abort hanging tests"
optional = false
python-versions = ">=3.7"
groups = ["dev"]
files = [
{file = "pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2"},
{file = "pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a"},
]
[package.dependencies]
pytest = ">=7.0.0"
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
description = "Extensions to the standard Python datetime module"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
groups = ["dev"]
files = [
{file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
{file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
]
[package.dependencies]
six = ">=1.5"
[[package]]
name = "pyyaml"
version = "6.0.2"
description = "YAML parser and emitter for Python"
optional = false
python-versions = ">=3.8"
groups = ["docs"]
files = [
{file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"},
{file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"},
{file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"},
{file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"},
{file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"},
{file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"},
{file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"},
{file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"},
{file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"},
{file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"},
{file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"},
{file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"},
{file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"},
{file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"},
{file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"},
{file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"},
{file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"},
{file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"},
{file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"},
{file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"},
{file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"},
{file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"},
{file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"},
{file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"},
{file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"},
{file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"},
{file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"},
{file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"},
{file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"},
{file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"},
{file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"},
{file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"},
{file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"},
{file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"},
{file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"},
{file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"},
{file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"},
{file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"},
{file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"},
{file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"},
{file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"},
{file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"},
{file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"},
{file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"},
{file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"},
{file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"},
{file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"},
{file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"},
{file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"},
{file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"},
{file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"},
{file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"},
{file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"},
]
[[package]]
name = "requests"
version = "2.33.0"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.10"
groups = ["docs"]
files = [
{file = "requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b"},
{file = "requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652"},
]
[package.dependencies]
certifi = ">=2023.5.7"
charset_normalizer = ">=2,<4"
idna = ">=2.5,<4"
urllib3 = ">=1.26,<3"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
test = ["PySocks (>=1.5.6,!=1.5.7)", "pytest (>=3)", "pytest-cov", "pytest-httpbin (==2.1.0)", "pytest-mock", "pytest-xdist"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
[[package]]
name = "rich"
version = "14.0.0"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
optional = false
python-versions = ">=3.8.0"
groups = ["dev"]
files = [
{file = "rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0"},
{file = "rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725"},
]
[package.dependencies]
markdown-it-py = ">=2.2.0"
pygments = ">=2.13.0,<3.0.0"
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "roman-numerals"
version = "4.1.0"
description = "Manipulate well-formed Roman numerals"
optional = false
python-versions = ">=3.10"
groups = ["docs"]
files = [
{file = "roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7"},
{file = "roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2"},
]
[[package]]
name = "six"
version = "1.17.0"
description = "Python 2 and 3 compatibility utilities"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
groups = ["dev"]
files = [
{file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
{file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
]
[[package]]
name = "sniffio"
version = "1.3.1"
description = "Sniff out which async library your code is running under"
optional = false
python-versions = ">=3.7"
groups = ["dev", "docs"]
files = [
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
]
[[package]]
name = "snowballstemmer"
version = "3.0.1"
description = "This package provides 32 stemmers for 30 languages generated from Snowball algorithms."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*"
groups = ["docs"]
files = [
{file = "snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064"},
{file = "snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895"},
]
[[package]]
name = "soupsieve"
version = "2.7"
description = "A modern CSS selector implementation for Beautiful Soup."
optional = false
python-versions = ">=3.8"
groups = ["docs"]
files = [
{file = "soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4"},
{file = "soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a"},
]
[[package]]
name = "sphinx"
version = "9.0.4"
description = "Python documentation generator"
optional = false
python-versions = ">=3.11"
groups = ["docs"]
files = [
{file = "sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb"},
{file = "sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3"},
]
[package.dependencies]
alabaster = ">=0.7.14"
babel = ">=2.13"
colorama = {version = ">=0.4.6", markers = "sys_platform == \"win32\""}
docutils = ">=0.20,<0.23"
imagesize = ">=1.3"
Jinja2 = ">=3.1"
packaging = ">=23.0"
Pygments = ">=2.17"
requests = ">=2.30.0"
roman-numerals = ">=1.0.0"
snowballstemmer = ">=2.2"
sphinxcontrib-applehelp = ">=1.0.7"
sphinxcontrib-devhelp = ">=1.0.6"
sphinxcontrib-htmlhelp = ">=2.0.6"
sphinxcontrib-jsmath = ">=1.0.1"
sphinxcontrib-qthelp = ">=1.0.6"
sphinxcontrib-serializinghtml = ">=1.1.9"
[[package]]
name = "sphinx-autobuild"
version = "2025.8.25"
description = "Rebuild Sphinx documentation on changes, with hot reloading in the browser."
optional = false
python-versions = ">=3.11"
groups = ["docs"]
files = [
{file = "sphinx_autobuild-2025.8.25-py3-none-any.whl", hash = "sha256:b750ac7d5a18603e4665294323fd20f6dcc0a984117026d1986704fa68f0379a"},
{file = "sphinx_autobuild-2025.8.25.tar.gz", hash = "sha256:9cf5aab32853c8c31af572e4fecdc09c997e2b8be5a07daf2a389e270e85b213"},
]
[package.dependencies]
colorama = ">=0.4.6"
Sphinx = "*"
starlette = ">=0.35"
uvicorn = ">=0.25"
watchfiles = ">=0.20"
websockets = ">=11"
[package.extras]
test = ["httpx", "pytest (>=6)"]
[[package]]
name = "sphinx-basic-ng"
version = "1.0.0b2"
description = "A modern skeleton for Sphinx themes."
optional = false
python-versions = ">=3.7"
groups = ["docs"]
files = [
{file = "sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b"},
{file = "sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9"},
]
[package.dependencies]
sphinx = ">=4.0"
[package.extras]
docs = ["furo", "ipython", "myst-parser", "sphinx-copybutton", "sphinx-inline-tabs"]
[[package]]
name = "sphinxcontrib-applehelp"
version = "2.0.0"
description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"},
{file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"},
]
[package.extras]
lint = ["mypy", "ruff (==0.5.5)", "types-docutils"]
standalone = ["Sphinx (>=5)"]
test = ["pytest"]
[[package]]
name = "sphinxcontrib-devhelp"
version = "2.0.0"
description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"},
{file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"},
]
[package.extras]
lint = ["mypy", "ruff (==0.5.5)", "types-docutils"]
standalone = ["Sphinx (>=5)"]
test = ["pytest"]
[[package]]
name = "sphinxcontrib-htmlhelp"
version = "2.1.0"
description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"},
{file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"},
]
[package.extras]
lint = ["mypy", "ruff (==0.5.5)", "types-docutils"]
standalone = ["Sphinx (>=5)"]
test = ["html5lib", "pytest"]
[[package]]
name = "sphinxcontrib-jsmath"
version = "1.0.1"
description = "A sphinx extension which renders display math in HTML via JavaScript"
optional = false
python-versions = ">=3.5"
groups = ["docs"]
files = [
{file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"},
{file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"},
]
[package.extras]
test = ["flake8", "mypy", "pytest"]
[[package]]
name = "sphinxcontrib-qthelp"
version = "2.0.0"
description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"},
{file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"},
]
[package.extras]
lint = ["mypy", "ruff (==0.5.5)", "types-docutils"]
standalone = ["Sphinx (>=5)"]
test = ["defusedxml (>=0.7.1)", "pytest"]
[[package]]
name = "sphinxcontrib-serializinghtml"
version = "2.0.0"
description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"},
{file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"},
]
[package.extras]
lint = ["mypy", "ruff (==0.5.5)", "types-docutils"]
standalone = ["Sphinx (>=5)"]
test = ["pytest"]
[[package]]
name = "starlette"
version = "1.0.1"
description = "The little ASGI library that shines."
optional = false
python-versions = ">=3.10"
groups = ["dev", "docs"]
files = [
{file = "starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd"},
{file = "starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f"},
]
[package.dependencies]
anyio = ">=3.6.2,<5"
typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""}
[package.extras]
full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"]
[[package]]
name = "typing-extensions"
version = "4.14.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev", "docs"]
files = [
{file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"},
{file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"},
]
markers = {main = "python_version == \"3.11\" or sys_platform == \"win32\"", dev = "python_version < \"3.13\""}
[[package]]
name = "uart-devices"
version = "0.1.1"
description = "UART Devices for Linux"
optional = false
python-versions = "<4.0,>=3.9"
groups = ["main"]
files = [
{file = "uart_devices-0.1.1-py3-none-any.whl", hash = "sha256:55bc8cce66465e90b298f0910e5c496bc7be021341c5455954cf61c6253dc123"},
{file = "uart_devices-0.1.1.tar.gz", hash = "sha256:3a52c4ae0f5f7400ebe1ae5f6e2a2d40cc0b7f18a50e895236535c4e53c6ed34"},
]
[[package]]
name = "urllib3"
version = "2.7.0"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.10"
groups = ["docs"]
files = [
{file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"},
{file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"},
]
[package.extras]
brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
[[package]]
name = "usb-devices"
version = "0.4.5"
description = "Tools for mapping, describing, and resetting USB devices"
optional = false
python-versions = ">=3.9,<4.0"
groups = ["main"]
files = [
{file = "usb_devices-0.4.5-py3-none-any.whl", hash = "sha256:8a415219ef1395e25aa0bddcad484c88edf9673acdeae8a07223ca7222a01dcf"},
{file = "usb_devices-0.4.5.tar.gz", hash = "sha256:9b5c7606df2bc791c6c45b7f76244a0cbed83cb6fa4c68791a143c03345e195d"},
]
[[package]]
name = "uvicorn"
version = "0.35.0"
description = "The lightning-fast ASGI server."
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"},
{file = "uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01"},
]
[package.dependencies]
click = ">=7.0"
h11 = ">=0.8"
[package.extras]
standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"]
[[package]]
name = "watchfiles"
version = "1.1.0"
description = "Simple, modern and high performance file watching and code reload in python."
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "watchfiles-1.1.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:27f30e14aa1c1e91cb653f03a63445739919aef84c8d2517997a83155e7a2fcc"},
{file = "watchfiles-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3366f56c272232860ab45c77c3ca7b74ee819c8e1f6f35a7125556b198bbc6df"},
{file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8412eacef34cae2836d891836a7fff7b754d6bcac61f6c12ba5ca9bc7e427b68"},
{file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df670918eb7dd719642e05979fc84704af913d563fd17ed636f7c4783003fdcc"},
{file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7642b9bc4827b5518ebdb3b82698ada8c14c7661ddec5fe719f3e56ccd13c97"},
{file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:199207b2d3eeaeb80ef4411875a6243d9ad8bc35b07fc42daa6b801cc39cc41c"},
{file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a479466da6db5c1e8754caee6c262cd373e6e6c363172d74394f4bff3d84d7b5"},
{file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:935f9edd022ec13e447e5723a7d14456c8af254544cefbc533f6dd276c9aa0d9"},
{file = "watchfiles-1.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8076a5769d6bdf5f673a19d51da05fc79e2bbf25e9fe755c47595785c06a8c72"},
{file = "watchfiles-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:86b1e28d4c37e89220e924305cd9f82866bb0ace666943a6e4196c5df4d58dcc"},
{file = "watchfiles-1.1.0-cp310-cp310-win32.whl", hash = "sha256:d1caf40c1c657b27858f9774d5c0e232089bca9cb8ee17ce7478c6e9264d2587"},
{file = "watchfiles-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:a89c75a5b9bc329131115a409d0acc16e8da8dfd5867ba59f1dd66ae7ea8fa82"},
{file = "watchfiles-1.1.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c9649dfc57cc1f9835551deb17689e8d44666315f2e82d337b9f07bd76ae3aa2"},
{file = "watchfiles-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:406520216186b99374cdb58bc48e34bb74535adec160c8459894884c983a149c"},
{file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb45350fd1dc75cd68d3d72c47f5b513cb0578da716df5fba02fff31c69d5f2d"},
{file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11ee4444250fcbeb47459a877e5e80ed994ce8e8d20283857fc128be1715dac7"},
{file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bda8136e6a80bdea23e5e74e09df0362744d24ffb8cd59c4a95a6ce3d142f79c"},
{file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b915daeb2d8c1f5cee4b970f2e2c988ce6514aace3c9296e58dd64dc9aa5d575"},
{file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed8fc66786de8d0376f9f913c09e963c66e90ced9aa11997f93bdb30f7c872a8"},
{file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe4371595edf78c41ef8ac8df20df3943e13defd0efcb732b2e393b5a8a7a71f"},
{file = "watchfiles-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b7c5f6fe273291f4d414d55b2c80d33c457b8a42677ad14b4b47ff025d0893e4"},
{file = "watchfiles-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7738027989881e70e3723c75921f1efa45225084228788fc59ea8c6d732eb30d"},
{file = "watchfiles-1.1.0-cp311-cp311-win32.whl", hash = "sha256:622d6b2c06be19f6e89b1d951485a232e3b59618def88dbeda575ed8f0d8dbf2"},
{file = "watchfiles-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:48aa25e5992b61debc908a61ab4d3f216b64f44fdaa71eb082d8b2de846b7d12"},
{file = "watchfiles-1.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:00645eb79a3faa70d9cb15c8d4187bb72970b2470e938670240c7998dad9f13a"},
{file = "watchfiles-1.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9dc001c3e10de4725c749d4c2f2bdc6ae24de5a88a339c4bce32300a31ede179"},
{file = "watchfiles-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9ba68ec283153dead62cbe81872d28e053745f12335d037de9cbd14bd1877f5"},
{file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130fc497b8ee68dce163e4254d9b0356411d1490e868bd8790028bc46c5cc297"},
{file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50a51a90610d0845a5931a780d8e51d7bd7f309ebc25132ba975aca016b576a0"},
{file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc44678a72ac0910bac46fa6a0de6af9ba1355669b3dfaf1ce5f05ca7a74364e"},
{file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a543492513a93b001975ae283a51f4b67973662a375a403ae82f420d2c7205ee"},
{file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ac164e20d17cc285f2b94dc31c384bc3aa3dd5e7490473b3db043dd70fbccfd"},
{file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7590d5a455321e53857892ab8879dce62d1f4b04748769f5adf2e707afb9d4f"},
{file = "watchfiles-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:37d3d3f7defb13f62ece99e9be912afe9dd8a0077b7c45ee5a57c74811d581a4"},
{file = "watchfiles-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7080c4bb3efd70a07b1cc2df99a7aa51d98685be56be6038c3169199d0a1c69f"},
{file = "watchfiles-1.1.0-cp312-cp312-win32.whl", hash = "sha256:cbcf8630ef4afb05dc30107bfa17f16c0896bb30ee48fc24bf64c1f970f3b1fd"},
{file = "watchfiles-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:cbd949bdd87567b0ad183d7676feb98136cde5bb9025403794a4c0db28ed3a47"},
{file = "watchfiles-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0a7d40b77f07be87c6faa93d0951a0fcd8cbca1ddff60a1b65d741bac6f3a9f6"},
{file = "watchfiles-1.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30"},
{file = "watchfiles-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a"},
{file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc"},
{file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b"},
{file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895"},
{file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a"},
{file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b"},
{file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c"},
{file = "watchfiles-1.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b"},
{file = "watchfiles-1.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb"},
{file = "watchfiles-1.1.0-cp313-cp313-win32.whl", hash = "sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9"},
{file = "watchfiles-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7"},
{file = "watchfiles-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5"},
{file = "watchfiles-1.1.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1"},
{file = "watchfiles-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339"},
{file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633"},
{file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011"},
{file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670"},
{file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf"},
{file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4"},
{file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20"},
{file = "watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef"},
{file = "watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb"},
{file = "watchfiles-1.1.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9974d2f7dc561cce3bb88dfa8eb309dab64c729de85fba32e98d75cf24b66297"},
{file = "watchfiles-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c68e9f1fcb4d43798ad8814c4c1b61547b014b667216cb754e606bfade587018"},
{file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95ab1594377effac17110e1352989bdd7bdfca9ff0e5eeccd8c69c5389b826d0"},
{file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fba9b62da882c1be1280a7584ec4515d0a6006a94d6e5819730ec2eab60ffe12"},
{file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3434e401f3ce0ed6b42569128b3d1e3af773d7ec18751b918b89cd49c14eaafb"},
{file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa257a4d0d21fcbca5b5fcba9dca5a78011cb93c0323fb8855c6d2dfbc76eb77"},
{file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd1b3879a578a8ec2076c7961076df540b9af317123f84569f5a9ddee64ce92"},
{file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62cc7a30eeb0e20ecc5f4bd113cd69dcdb745a07c68c0370cea919f373f65d9e"},
{file = "watchfiles-1.1.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:891c69e027748b4a73847335d208e374ce54ca3c335907d381fde4e41661b13b"},
{file = "watchfiles-1.1.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:12fe8eaffaf0faa7906895b4f8bb88264035b3f0243275e0bf24af0436b27259"},
{file = "watchfiles-1.1.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bfe3c517c283e484843cb2e357dd57ba009cff351edf45fb455b5fbd1f45b15f"},
{file = "watchfiles-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9ccbf1f129480ed3044f540c0fdbc4ee556f7175e5ab40fe077ff6baf286d4e"},
{file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba0e3255b0396cac3cc7bbace76404dd72b5438bf0d8e7cefa2f79a7f3649caa"},
{file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4281cd9fce9fc0a9dbf0fc1217f39bf9cf2b4d315d9626ef1d4e87b84699e7e8"},
{file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d2404af8db1329f9a3c9b79ff63e0ae7131986446901582067d9304ae8aaf7f"},
{file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e78b6ed8165996013165eeabd875c5dfc19d41b54f94b40e9fff0eb3193e5e8e"},
{file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:249590eb75ccc117f488e2fabd1bfa33c580e24b96f00658ad88e38844a040bb"},
{file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05686b5487cfa2e2c28ff1aa370ea3e6c5accfe6435944ddea1e10d93872147"},
{file = "watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d0e10e6f8f6dc5762adee7dece33b722282e1f59aa6a55da5d493a97282fedd8"},
{file = "watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db"},
{file = "watchfiles-1.1.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:865c8e95713744cf5ae261f3067861e9da5f1370ba91fc536431e29b418676fa"},
{file = "watchfiles-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42f92befc848bb7a19658f21f3e7bae80d7d005d13891c62c2cd4d4d0abb3433"},
{file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa0cc8365ab29487eb4f9979fd41b22549853389e22d5de3f134a6796e1b05a4"},
{file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:90ebb429e933645f3da534c89b29b665e285048973b4d2b6946526888c3eb2c7"},
{file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c588c45da9b08ab3da81d08d7987dae6d2a3badd63acdb3e206a42dbfa7cb76f"},
{file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c55b0f9f68590115c25272b06e63f0824f03d4fc7d6deed43d8ad5660cabdbf"},
{file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd17a1e489f02ce9117b0de3c0b1fab1c3e2eedc82311b299ee6b6faf6c23a29"},
{file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da71945c9ace018d8634822f16cbc2a78323ef6c876b1d34bbf5d5222fd6a72e"},
{file = "watchfiles-1.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:51556d5004887045dba3acdd1fdf61dddea2be0a7e18048b5e853dcd37149b86"},
{file = "watchfiles-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04e4ed5d1cd3eae68c89bcc1a485a109f39f2fd8de05f705e98af6b5f1861f1f"},
{file = "watchfiles-1.1.0-cp39-cp39-win32.whl", hash = "sha256:c600e85f2ffd9f1035222b1a312aff85fd11ea39baff1d705b9b047aad2ce267"},
{file = "watchfiles-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:3aba215958d88182e8d2acba0fdaf687745180974946609119953c0e112397dc"},
{file = "watchfiles-1.1.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a6fd40bbb50d24976eb275ccb55cd1951dfb63dbc27cae3066a6ca5f4beabd5"},
{file = "watchfiles-1.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9f811079d2f9795b5d48b55a37aa7773680a5659afe34b54cc1d86590a51507d"},
{file = "watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2726d7bfd9f76158c84c10a409b77a320426540df8c35be172444394b17f7ea"},
{file = "watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df32d59cb9780f66d165a9a7a26f19df2c7d24e3bd58713108b41d0ff4f929c6"},
{file = "watchfiles-1.1.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0ece16b563b17ab26eaa2d52230c9a7ae46cf01759621f4fbbca280e438267b3"},
{file = "watchfiles-1.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:51b81e55d40c4b4aa8658427a3ee7ea847c591ae9e8b81ef94a90b668999353c"},
{file = "watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2bcdc54ea267fe72bfc7d83c041e4eb58d7d8dc6f578dfddb52f037ce62f432"},
{file = "watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:923fec6e5461c42bd7e3fd5ec37492c6f3468be0499bc0707b4bbbc16ac21792"},
{file = "watchfiles-1.1.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7b3443f4ec3ba5aa00b0e9fa90cf31d98321cbff8b925a7c7b84161619870bc9"},
{file = "watchfiles-1.1.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7049e52167fc75fc3cc418fc13d39a8e520cbb60ca08b47f6cedb85e181d2f2a"},
{file = "watchfiles-1.1.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54062ef956807ba806559b3c3d52105ae1827a0d4ab47b621b31132b6b7e2866"},
{file = "watchfiles-1.1.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a7bd57a1bb02f9d5c398c0c1675384e7ab1dd39da0ca50b7f09af45fa435277"},
{file = "watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575"},
]
[package.dependencies]
anyio = ">=3.0.0"
[[package]]
name = "websockets"
version = "15.0.1"
description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
optional = false
python-versions = ">=3.9"
groups = ["docs"]
files = [
{file = "websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b"},
{file = "websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205"},
{file = "websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a"},
{file = "websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e"},
{file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf"},
{file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb"},
{file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d"},
{file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9"},
{file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c"},
{file = "websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256"},
{file = "websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41"},
{file = "websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431"},
{file = "websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57"},
{file = "websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905"},
{file = "websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562"},
{file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792"},
{file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413"},
{file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8"},
{file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3"},
{file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf"},
{file = "websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85"},
{file = "websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065"},
{file = "websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3"},
{file = "websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665"},
{file = "websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2"},
{file = "websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215"},
{file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5"},
{file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65"},
{file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe"},
{file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4"},
{file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597"},
{file = "websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9"},
{file = "websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7"},
{file = "websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931"},
{file = "websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675"},
{file = "websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151"},
{file = "websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22"},
{file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f"},
{file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8"},
{file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375"},
{file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d"},
{file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4"},
{file = "websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa"},
{file = "websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561"},
{file = "websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5"},
{file = "websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a"},
{file = "websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b"},
{file = "websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770"},
{file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb"},
{file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054"},
{file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee"},
{file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed"},
{file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880"},
{file = "websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411"},
{file = "websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4"},
{file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3"},
{file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1"},
{file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475"},
{file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9"},
{file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04"},
{file = "websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122"},
{file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940"},
{file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e"},
{file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9"},
{file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b"},
{file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f"},
{file = "websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123"},
{file = "websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f"},
{file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"},
]
[[package]]
name = "winrt-runtime"
version = "3.2.1"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "sys_platform == \"win32\""
files = [
{file = "winrt_runtime-3.2.1-cp310-cp310-win32.whl", hash = "sha256:25a2d1e2b45423742319f7e10fa8ca2e7063f01284b6e85e99d805c4b50bbfb3"},
{file = "winrt_runtime-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:dc81d5fb736bf1ddecf743928622253dce4d0aac9a57faad776d7a3834e13257"},
{file = "winrt_runtime-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:363f584b1e9fcb601e3e178636d8877e6f0537ac3c96ce4a96f06066f8ff0eae"},
{file = "winrt_runtime-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9e9b64f1ba631cc4b9fe60b8ff16fef3f32c7ce2fcc84735a63129ff8b15c022"},
{file = "winrt_runtime-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c0a9046ae416808420a358c51705af8ae100acd40bc578be57ddfdd51cbb0f9c"},
{file = "winrt_runtime-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:e94f3cb40ea2d723c44c82c16d715c03c6b3bd977d135b49535fdd5415fd9130"},
{file = "winrt_runtime-3.2.1-cp312-cp312-win32.whl", hash = "sha256:762b3d972a2f7037f7db3acbaf379dd6d8f6cda505f71f66c6b425d1a1eae2f1"},
{file = "winrt_runtime-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:06510db215d4f0dc45c00fbb1251c6544e91742a0ad928011db33b30677e1576"},
{file = "winrt_runtime-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:14562c29a087ccad38e379e585fef333e5c94166c807bdde67b508a6261aa195"},
{file = "winrt_runtime-3.2.1-cp313-cp313-win32.whl", hash = "sha256:44e2733bc709b76c554aee6c7fe079443b8306b2e661e82eecfebe8b9d71e4d1"},
{file = "winrt_runtime-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:3c1fdcaeedeb2920dc3b9039db64089a6093cad2be56a3e64acc938849245a6d"},
{file = "winrt_runtime-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:28f3dab083412625ff4d2b46e81246932e6bebddf67bea7f05e01712f54e6159"},
{file = "winrt_runtime-3.2.1-cp314-cp314-win32.whl", hash = "sha256:9b6298375468ac2f6815d0c008a059fc16508c8f587e824c7936ed9216480dad"},
{file = "winrt_runtime-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:e36e587ab5fd681ee472cd9a5995743f75107a1a84d749c64f7e490bc86bc814"},
{file = "winrt_runtime-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:35d6241a2ebd5598e4788e69768b8890ee1eee401a819865767a1fbdd3e9a650"},
{file = "winrt_runtime-3.2.1-cp39-cp39-win32.whl", hash = "sha256:07c0cb4a53a4448c2cb7597b62ae8c94343c289eeebd8f83f946eb2c817bde01"},
{file = "winrt_runtime-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:1856325ca3354b45e0789cf279be9a882134085d34214946db76110d98391efa"},
{file = "winrt_runtime-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:cf237858de1d62e4c9b132c66b52028a7a3e8534e8ab90b0e29a68f24f7be39d"},
{file = "winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea"},
]
[package.dependencies]
typing_extensions = ">=4.12.2"
[[package]]
name = "winrt-windows-devices-bluetooth"
version = "3.2.1"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "sys_platform == \"win32\""
files = [
{file = "winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win32.whl", hash = "sha256:49489351037094a088a08fbdf0f99c94e3299b574edb211f717c4c727770af78"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:20f6a21029034c18ea6a6b6df399671813b071102a0d6d8355bb78cf4f547cdb"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:69c523814eab795bc1bf913292309cb1025ef0a67d5fc33863a98788995e551d"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win32.whl", hash = "sha256:f4082a00b834c1e34b961e0612f3e581356bdb38c5798bd6842f88ec02e5152b"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:44277a3f2cc5ac32ce9b4b2d96c5c5f601d394ac5f02cc71bcd551f738660e2d"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:0803a417403a7d225316b9b0c4fe3f8446579d6a22f2f729a2c21f4befc74a80"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win32.whl", hash = "sha256:18c833ec49e7076127463679e85efc59f61785ade0dc185c852586b21be1f31c"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:9b6702c462b216c91e32388023a74d0f87210cef6fd5d93b7191e9427ce2faca"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:419fd1078c7749119f6b4bbf6be4e586e03a0ed544c03b83178f1d85f1b3d148"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win32.whl", hash = "sha256:12b0a16fb36ce0b42243ca81f22a6b53fbb344ed7ea07a6eeec294604f0505e4"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6703dfbe444ee22426738830fb305c96a728ea9ccce905acfdf811d81045fdb3"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2cf8a0bfc9103e32dc7237af15f84be06c791f37711984abdca761f6318bbdb2"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win32.whl", hash = "sha256:de36ded53ca3ba12fc6dd4deb14b779acc391447726543815df4800348aad63a"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3295d932cc93259d5ccb23a41e3a3af4c78ce5d6a6223b2b7638985f604fa34c"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1f61c178766a1bbce0669f44790c6161ff4669404c477b4aedaa576348f9e102"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp39-cp39-win32.whl", hash = "sha256:32fc355bfdc5d6b3b1875df16eaf12f9b9fc0445e01177833c27d9a4fc0d50b6"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:b886ef1fc0ed49163ae6c2422dd5cb8dd4709da7972af26c8627e211872818d0"},
{file = "winrt_windows_devices_bluetooth-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:8643afa53f9fb8fe3b05967227f86f0c8e1d7b822289e60a848c6368acc977d2"},
{file = "winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505"},
]
[package.dependencies]
winrt-runtime = ">=3.2.1.0,<3.3.0.0"
[package.extras]
all = ["winrt-Windows.Devices.Bluetooth.GenericAttributeProfile[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Devices.Bluetooth.Rfcomm[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Devices.Enumeration[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Devices.Radios[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Networking[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage.Streams[all] (>=3.2.1.0,<3.3.0.0)"]
[[package]]
name = "winrt-windows-devices-bluetooth-advertisement"
version = "3.2.1"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "sys_platform == \"win32\""
files = [
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win32.whl", hash = "sha256:a758c5f81a98cc38347fdfb024ce62720969480e8c5b98e402b89d2b09b32866"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:f982ef72e729ddd60cdb975293866e84bb838798828933012a57ee4bf12b0ea1"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:e88a72e1e09c7ccc899a9e6d2ab3fc0f43b5dd4509bcc49ec4abf65b55ab015f"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win32.whl", hash = "sha256:fe17c2cf63284646622e8b2742b064bf7970bbf53cfab02062136c67fa6b06c9"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:78e99dd48b4d89b71b7778c5085fdba64e754dd3ebc54fd09c200fe5222c6e09"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6d5d2295474deab444fc4311580c725a2ca8a814b0f3344d0779828891d75401"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win32.whl", hash = "sha256:901933cc40de5eb7e5f4188897c899dd0b0f577cb2c13eab1a63c7dfe89b08c4"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:e6c66e7d4f4ca86d2c801d30efd2b9673247b59a2b4c365d9e11650303d68d89"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:447d19defd8982d39944642eb7ebe89e4e20259ec9734116cf88879fb2c514ff"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4122348ea525a914e85615647a0b54ae8b2f42f92cdbf89c5a12eea53ef6ed90"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:b66410c04b8dae634a7e4b615c3b7f8adda9c7d4d6902bcad5b253da1a684943"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:07af19b1d252ddb9dd3eb2965118bc2b7cabff4dda6e499341b765e5038ca61d"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win32.whl", hash = "sha256:2985565c265b3f9eab625361b0e40e88c94b03d89f5171f36146f2e88b3ee214"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d102f3fac64fde32332e370969dfbc6f37b405d8cc055d9da30d14d07449a3c2"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:ffeb5e946cd42c32c6999a62e240d6730c653cdfb7b49c7839afba375e20a62a"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp39-cp39-win32.whl", hash = "sha256:6c4747d2e5b0e2ef24e9b84a848cf8fc50fb5b268a2086b5ee8680206d1e0197"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:18d4c5d8b80ee2d29cc13c2fc1353fdb3c0f620c8083701c9b9ecf5e6c503c8d"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:75dd856611d847299078d56aee60e319df52975b931c992cd1d32ad5143fe772"},
{file = "winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc"},
]
[package.dependencies]
winrt-runtime = ">=3.2.1.0,<3.3.0.0"
[package.extras]
all = ["winrt-Windows.Devices.Bluetooth[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage.Streams[all] (>=3.2.1.0,<3.3.0.0)"]
[[package]]
name = "winrt-windows-devices-bluetooth-genericattributeprofile"
version = "3.2.1"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "sys_platform == \"win32\""
files = [
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win32.whl", hash = "sha256:af4914d7b30b49232092cd3b934e3ed6f5d3b1715ba47238541408ee595b7f46"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:0e557dd52fc80392b8bd7c237e1153a50a164b3983838b4ac674551072efc9ed"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:64cff62baa6b7aadd6c206e61d149113fdcda17360feb6e9d05bc8bbda4b9fde"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win32.whl", hash = "sha256:832cf65d035a11e6dbfef4fd66abdcc46be7e911ec96e2e72e98e12d8d5b9d3c"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:8179638a6c721b0bbf04ba251ef98d5e02d9a17f0cce377398e42c4fbb441415"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:70b7edfca3190b89ae38bf60972b11978311b6d933d3142ae45560c955dbf5c7"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win32.whl", hash = "sha256:ef894d21e0a805f3e114940254636a8045335fa9de766c7022af5d127dfad557"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:db05de95cd1b24a51abb69cb936a8b17e9214e015757d0b37e3a5e207ddceb3d"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d4e131cf3d15fc5ad81c1bcde3509ac171298217381abed6bdf687f29871984"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win32.whl", hash = "sha256:b1879c8dcf46bd2110b9ad4b0b185f4e2a5f95170d014539203a5fee2b2115f0"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d8d89f01e9b6931fb48217847caac3227a0aeb38a5b7782af71c2e7b262ec30"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:4e71207bb89798016b1795bb15daf78afe45529f2939b3b9e78894cfe650b383"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win32.whl", hash = "sha256:d5f83739ca370f0baf52b0400aebd6240ab80150081fbfba60fd6e7b2e7b4c5f"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:13786a5853a933de140d456cd818696e1121c7c296ae7b7af262fc5d2cffb851"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:5140682da2860f6a55eb6faf9e980724dc457c2e4b4b35a10e1cebd8fc97d892"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp39-cp39-win32.whl", hash = "sha256:963339a0161f9970b577a6193924be783978d11693da48b41a025f61b3c5562a"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:d43615c5dfa939dd30fe80dc0649434a13cc7cf0294ad0d7283d5a9f48c6ce86"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:8e70fa970997e2e67a8a4172bc00b0b2a79b5ff5bb2668f79cf10b3fd63d3974"},
{file = "winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71"},
]
[package.dependencies]
winrt-runtime = ">=3.2.1.0,<3.3.0.0"
[package.extras]
all = ["winrt-Windows.Devices.Bluetooth[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Devices.Enumeration[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage.Streams[all] (>=3.2.1.0,<3.3.0.0)"]
[[package]]
name = "winrt-windows-devices-enumeration"
version = "3.2.1"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "sys_platform == \"win32\""
files = [
{file = "winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win32.whl", hash = "sha256:40dac777d8f45b41449f3ff1ae70f0d457f1ede53f53962a6e2521b651533db5"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:a101ec3e0ad0a0783032fdcd5dc48e7cd68ee034cbde4f903a8c7b391532c71a"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:3296a3863ac086928ff3f3dc872b2a2fb971dab728817424264f3ca547504e9e"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9f29465a6c6b0456e4330d4ad09eccdd53a17e1e97695c2e57db0d4666cc0011"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2a725d04b4cb43aa0e2af035f73a60d16a6c0ff165fcb6b763383e4e33a975fd"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6365ef5978d4add26678827286034acf474b6b133aa4054e76567d12194e6817"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win32.whl", hash = "sha256:1db22b0292b93b0688d11ad932ad1f3629d4f471310281a2fbfe187530c2c1f3"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:a73bc88d7f510af454f2b392985501c96f39b89fd987140708ccaec1588ceebc"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:2853d687803f0dd76ae1afe3648abc0453e09dff0e7eddbb84b792eddb0473ca"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win32.whl", hash = "sha256:14a71cdcc84f624c209cbb846ed6bd9767a9a9437b2bf26b48ac9a91599da6e9"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6ca40d334734829e178ad46375275c4f7b5d6d2d4fc2e8879690452cbfb36015"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2d14d187f43e4409c7814b7d1693c03a270e77489b710d92fcbbaeca5de260d4"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win32.whl", hash = "sha256:e087364273ed7c717cd0191fed4be9def6fdf229fe9b536a4b8d0228f7814106"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:0da1ddb8285d97a6775c36265d7157acf1bbcb88bcc9a7ce9a4549906c822472"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:09bf07e74e897e97a49a9275d0a647819254ddb74142806bbbcf4777ed240a22"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp39-cp39-win32.whl", hash = "sha256:986e8d651b769a0e60d2834834bdd3f6959f6a88caa0c9acb917797e6b43a588"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:10da7d403ac4afd385fe13bd5808c9a5dd616a8ef31ca5c64cea3f87673661c1"},
{file = "winrt_windows_devices_enumeration-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:679e471d21ac22cb50de1bf4dfc4c0c3f5da9f3e3fbc7f08dcacfe9de9d6dd58"},
{file = "winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf"},
]
[package.dependencies]
winrt-runtime = ">=3.2.1.0,<3.3.0.0"
[package.extras]
all = ["winrt-Windows.ApplicationModel.Background[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Security.Credentials[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage.Streams[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.UI.Popups[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.UI[all] (>=3.2.1.0,<3.3.0.0)"]
[[package]]
name = "winrt-windows-devices-radios"
version = "3.2.1"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "sys_platform == \"win32\""
files = [
{file = "winrt_windows_devices_radios-3.2.1-cp310-cp310-win32.whl", hash = "sha256:f97766fd551d06c102155d51b2922f96663dee045e1f8d57177def0a2149cb78"},
{file = "winrt_windows_devices_radios-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:104b737fa1279a3b6a88ba3c6236157afc1de03c472657c45e5176ad7a209e23"},
{file = "winrt_windows_devices_radios-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:55b02877d2de06ca6f0f6140611a9af9d0c65710e28f1afdeaac1040433b1837"},
{file = "winrt_windows_devices_radios-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7c02790472414b6cda00d24a8cd23bca18e4b7474ddad4f9264f4484b891807e"},
{file = "winrt_windows_devices_radios-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f87745486d313ba1e7562ca97f25ad436ec01ad4b3b9ea349fb6b6f25cb41104"},
{file = "winrt_windows_devices_radios-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6cee6f946ff3a3571850d1ca745edaee7c331d06ca321873e650779654effc4a"},
{file = "winrt_windows_devices_radios-3.2.1-cp312-cp312-win32.whl", hash = "sha256:c3e683ce682338a5a5ed465f735e223ba7a22f16d0bbea2d070962bc7657edbb"},
{file = "winrt_windows_devices_radios-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:a116e552a3f38607b9be558fb2e7de9b4450d1f9080069944d74d80cdda1873e"},
{file = "winrt_windows_devices_radios-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4c28822f9251c9d547324f596b5c2581f050254ded05e5b786c650a3502744c1"},
{file = "winrt_windows_devices_radios-3.2.1-cp313-cp313-win32.whl", hash = "sha256:ae4a0065927fcd2d10215223f8a46be6fb89bad71cb4edd25dae3d01c137b3a8"},
{file = "winrt_windows_devices_radios-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:bf1a975f46a2aa271ffea1340be0c7e64985050d07433e701343dddc22a72290"},
{file = "winrt_windows_devices_radios-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:10b298ed154c5824cea2de174afce1694ed2aabfb58826de814074027ffef96f"},
{file = "winrt_windows_devices_radios-3.2.1-cp314-cp314-win32.whl", hash = "sha256:21452e1cae50e44cd1d5e78159e1b9986ac3389b66458ad89caa196ce5eca2d6"},
{file = "winrt_windows_devices_radios-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:6a8413e586fe597c6849607885cca7e0549da33ae5699165d11f7911534c6eaf"},
{file = "winrt_windows_devices_radios-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:39129fd9d09103adb003575f59881c1a5a70a43310547850150b46c6f4020312"},
{file = "winrt_windows_devices_radios-3.2.1-cp39-cp39-win32.whl", hash = "sha256:59b868d45ff22afad21b0b0d1466ec43e54543c4e4c6f1efcc2d4adc77053bd5"},
{file = "winrt_windows_devices_radios-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:dbfcbb977f60f19c852204987ace0cd6f7a432d735882a45b3074fdbfd3fdb5a"},
{file = "winrt_windows_devices_radios-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:659e07e6aa5542587ccfc4d4e2cc6e1ef0869606c867a3e95fc82cc8aeaf1f81"},
{file = "winrt_windows_devices_radios-3.2.1.tar.gz", hash = "sha256:4dc9b9d1501846049eb79428d64ec698d6476c27a357999b78a8331072e18a0b"},
]
[package.dependencies]
winrt-runtime = ">=3.2.1.0,<3.3.0.0"
[package.extras]
all = ["winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)"]
[[package]]
name = "winrt-windows-foundation"
version = "3.2.1"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "sys_platform == \"win32\""
files = [
{file = "winrt_windows_foundation-3.2.1-cp310-cp310-win32.whl", hash = "sha256:677e98165dcbbf7a2367f905bc61090ef2c568b6e465f87cf7276df4734f3b0b"},
{file = "winrt_windows_foundation-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:a8f27b4f0fdb73ccc4a3e24bc8010a6607b2bdd722fa799eafce7daa87d19d39"},
{file = "winrt_windows_foundation-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:d900c6165fab4ea589811efa2feed27b532e1b6f505f63bf63e2052b8cb6bdc4"},
{file = "winrt_windows_foundation-3.2.1-cp311-cp311-win32.whl", hash = "sha256:d1b5970241ccd61428f7330d099be75f4f52f25e510d82c84dbbdaadd625e437"},
{file = "winrt_windows_foundation-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f3762be2f6e0f2aedf83a0742fd727290b397ffe3463d963d29211e4ebb53a7e"},
{file = "winrt_windows_foundation-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:806c77818217b3476e6c617293b3d5b0ff8a9901549dc3417586f6799938d671"},
{file = "winrt_windows_foundation-3.2.1-cp312-cp312-win32.whl", hash = "sha256:867642ccf629611733db482c4288e17b7919f743a5873450efb6d69ae09fdc2b"},
{file = "winrt_windows_foundation-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:45550c5b6c2125cde495c409633e6b1ea5aa1677724e3b95eb8140bfccbe30c9"},
{file = "winrt_windows_foundation-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:94f4661d71cb35ebc52be7af112f2eeabdfa02cb05e0243bf9d6bd2cafaa6f37"},
{file = "winrt_windows_foundation-3.2.1-cp313-cp313-win32.whl", hash = "sha256:3998dc58ed50ecbdbabace1cdef3a12920b725e32a5806d648ad3f4829d5ba46"},
{file = "winrt_windows_foundation-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6e98617c1e46665c7a56ce3f5d28e252798416d1ebfee3201267a644a4e3c479"},
{file = "winrt_windows_foundation-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2a8c1204db5c352f6a563130a5a41d25b887aff7897bb677d4ff0b660315aad4"},
{file = "winrt_windows_foundation-3.2.1-cp314-cp314-win32.whl", hash = "sha256:35e973ab3c77c2a943e139302256c040e017fd6ff1a75911c102964603bba1da"},
{file = "winrt_windows_foundation-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:a22a7ebcec0d262e60119cff728f32962a02df60471ded8b2735a655eccc0ef5"},
{file = "winrt_windows_foundation-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3be7fbae829b98a6a946db4fbaf356b11db1fbcbb5d4f37e7a73ac6b25de8b87"},
{file = "winrt_windows_foundation-3.2.1-cp39-cp39-win32.whl", hash = "sha256:14d5191725301498e4feb744d91f5b46ce317bf3d28370efda407d5c87f4423b"},
{file = "winrt_windows_foundation-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:de5e4f61d253a91ba05019dbf4338c43f962bdad935721ced5e7997933994af5"},
{file = "winrt_windows_foundation-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:ebbf6e8168398c9ed0c72c8bdde95a406b9fbb9a23e3705d4f0fe28e5a209705"},
{file = "winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656"},
]
[package.dependencies]
winrt-runtime = ">=3.2.1.0,<3.3.0.0"
[package.extras]
all = ["winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)"]
[[package]]
name = "winrt-windows-foundation-collections"
version = "3.2.1"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "sys_platform == \"win32\""
files = [
{file = "winrt_windows_foundation_collections-3.2.1-cp310-cp310-win32.whl", hash = "sha256:46948484addfc4db981dab35688d4457533ceb54d4954922af41503fddaa8389"},
{file = "winrt_windows_foundation_collections-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:899eaa3a93c35bfb1857d649e8dd60c38b978dda7cedd9725fcdbcebba156fd6"},
{file = "winrt_windows_foundation_collections-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:c36eb49ad1eba1b32134df768bb47af13cabb9b59f974a3cea37843e2d80e0e6"},
{file = "winrt_windows_foundation_collections-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9b272d9936e7db4840881c5dcf921eb26789ae4ef23fb6ec15e13e19a16254e7"},
{file = "winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c646a5d442dd6540ade50890081ca118b41f073356e19032d0a5d7d0d38fbc89"},
{file = "winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:2c4630027c93cdd518b0cf4cc726b8fbdbc3388e36d02aa1de190a0fc18ca523"},
{file = "winrt_windows_foundation_collections-3.2.1-cp312-cp312-win32.whl", hash = "sha256:15704eef3125788f846f269cf54a3d89656fa09a1dc8428b70871f717d595ad6"},
{file = "winrt_windows_foundation_collections-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:550dfb8c82fe74d9e0728a2a16a9175cc9e34ca2b8ef758d69b2a398894b698b"},
{file = "winrt_windows_foundation_collections-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:810ad4bd11ab4a74fdbcd3ed33b597ef7c0b03af73fc9d7986c22bcf3bd24f84"},
{file = "winrt_windows_foundation_collections-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4267a711b63476d36d39227883aeb3fb19ac92b88a9fc9973e66fbce1fd4aed9"},
{file = "winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:5e12a6e75036ee90484c33e204b85fb6785fcc9e7c8066ad65097301f48cdd10"},
{file = "winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:34b556255562f1b36d07fba933c2bcd9f0db167fa96727a6cbb4717b152ad7a2"},
{file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win32.whl", hash = "sha256:33188ed2d63e844c8adfbb82d1d3d461d64aaf78d225ce9c5930421b413c45ab"},
{file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:d4cfece7e9c0ead2941e55a1da82f20d2b9c8003bb7a8853bb7f999b539f80a4"},
{file = "winrt_windows_foundation_collections-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:3884146fea13727510458f6a14040b7632d5d90127028b9bfd503c6c655d0c01"},
{file = "winrt_windows_foundation_collections-3.2.1-cp39-cp39-win32.whl", hash = "sha256:20610f098b84c87765018cbc71471092197881f3b92e5d06158fad3bfcea2563"},
{file = "winrt_windows_foundation_collections-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:e9739775320ac4c0238e1775d94a54e886d621f9995977e65d4feb8b3778c111"},
{file = "winrt_windows_foundation_collections-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:e4c6bddb1359d5014ceb45fe2ecd838d4afeb1184f2ea202c2d21037af0d08a3"},
{file = "winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5"},
]
[package.dependencies]
winrt-runtime = ">=3.2.1.0,<3.3.0.0"
[package.extras]
all = ["winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)"]
[[package]]
name = "winrt-windows-storage-streams"
version = "3.2.1"
description = "Python projection of Windows Runtime (WinRT) APIs"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "sys_platform == \"win32\""
files = [
{file = "winrt_windows_storage_streams-3.2.1-cp310-cp310-win32.whl", hash = "sha256:89bb2d667ebed6861af36ed2710757456e12921ee56347946540320dacf6c003"},
{file = "winrt_windows_storage_streams-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:48a78e5dc7d3488eb77e449c278bc6d6ac28abcdda7df298462c4112d7635d00"},
{file = "winrt_windows_storage_streams-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:da71231d4a554f9f15f1249b4990c6431176f6dfb0e3385c7caa7896f4ca24d6"},
{file = "winrt_windows_storage_streams-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7dace2f9e364422255d0e2f335f741bfe7abb1f4d4f6003622b2450b87c91e69"},
{file = "winrt_windows_storage_streams-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:b02fa251a7eef6081eca1a5f64ecf349cfd1ac0ac0c5a5a30be52897d060bed5"},
{file = "winrt_windows_storage_streams-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:efdf250140340a75647e8e8ad002782d91308e9fdd1e19470a5b9cc969ae4780"},
{file = "winrt_windows_storage_streams-3.2.1-cp312-cp312-win32.whl", hash = "sha256:77c1f0e004b84347b5bd705e8f0fc63be8cd29a6093be13f1d0869d0d97b7d78"},
{file = "winrt_windows_storage_streams-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:e4508ee135af53e4fc142876abbf4bc7c2a95edfc7d19f52b291a8499cacd6dc"},
{file = "winrt_windows_storage_streams-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:040cb94e6fb26b0d00a00e8b88b06fadf29dfe18cf24ed6cb3e69709c3613307"},
{file = "winrt_windows_storage_streams-3.2.1-cp313-cp313-win32.whl", hash = "sha256:401bb44371720dc43bd1e78662615a2124372e7d5d9d65dfa8f77877bbcb8163"},
{file = "winrt_windows_storage_streams-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:202c5875606398b8bfaa2a290831458bb55f2196a39c1d4e5fa88a03d65ef915"},
{file = "winrt_windows_storage_streams-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:ca3c5ec0aab60895006bf61053a1aca6418bc7f9a27a34791ba3443b789d230d"},
{file = "winrt_windows_storage_streams-3.2.1-cp314-cp314-win32.whl", hash = "sha256:5cd0dbad86fcc860366f6515fce97177b7eaa7069da261057be4813819ba37ee"},
{file = "winrt_windows_storage_streams-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3c5bf41d725369b9986e6d64bad7079372b95c329897d684f955d7028c7f27a0"},
{file = "winrt_windows_storage_streams-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:293e09825559d0929bbe5de01e1e115f7a6283d8996ab55652e5af365f032987"},
{file = "winrt_windows_storage_streams-3.2.1-cp39-cp39-win32.whl", hash = "sha256:1c630cfdece58fcf82e4ed86c826326123529836d6d4d855ae8e9ceeff67b627"},
{file = "winrt_windows_storage_streams-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:d7ff22434a4829d616a04b068a191ac79e008f6c27541bb178c1f6f1fe7a1657"},
{file = "winrt_windows_storage_streams-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:fa90244191108f85f6f7afb43a11d365aca4e0722fe8adc62fb4d2c678d0993d"},
{file = "winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f"},
]
[package.dependencies]
winrt-runtime = ">=3.2.1.0,<3.3.0.0"
[package.extras]
all = ["winrt-Windows.Foundation.Collections[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Foundation[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.Storage[all] (>=3.2.1.0,<3.3.0.0)", "winrt-Windows.System[all] (>=3.2.1.0,<3.3.0.0)"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.11,<3.15"
content-hash = "4fb1b805a1709e73be58458a201916e619c1b1923cdc3ad281932299d7be3042"
Bluetooth-Devices-habluetooth-75cbe37/pyproject.toml 0000664 0000000 0000000 00000012747 15211177045 0022726 0 ustar 00root root 0000000 0000000 [build-system]
requires = ['setuptools>=77.0', 'Cython>=3', "poetry-core>=2.0.0"]
build-backend = "poetry.core.masonry.api"
[project]
name = "habluetooth"
version = "6.8.3"
license = "Apache-2.0"
description = "High availability Bluetooth"
authors = [{ name = "J. Nick Koston", email = "bluetooth@koston.org" }]
readme = "README.md"
requires-python = ">=3.11"
[project.urls]
"Repository" = "https://github.com/bluetooth-devices/habluetooth"
"Documentation" = "https://habluetooth.readthedocs.io"
"Bug Tracker" = "https://github.com/bluetooth-devices/habluetooth/issues"
"Changelog" = "https://github.com/bluetooth-devices/habluetooth/blob/main/CHANGELOG.md"
[tool.poetry]
classifiers = [
"Development Status :: 2 - Pre-Alpha",
"Intended Audience :: Developers",
"Natural Language :: English",
"Operating System :: OS Independent",
"Topic :: Software Development :: Libraries",
]
packages = [
{ include = "habluetooth", from = "src" },
]
[tool.poetry.build]
generate-setup-file = true
script = "build_ext.py"
[tool.poetry.dependencies]
python = ">=3.11,<3.15"
bleak = ">=3.0.2"
bleak-retry-connector = ">=4.6.1"
bluetooth-data-tools = ">=1.29.18"
bluetooth-adapters = ">=2.2.0"
bluetooth-auto-recovery = ">=1.6.4"
async-interrupt = ">=1.1.1"
dbus-fast = { version = ">=5.0.16", markers = "platform_system == 'Linux'" }
btsocket = ">=0.3.0"
[tool.poetry.group.dev.dependencies]
pytest = ">=9.0.3,<10"
pytest-cov = ">=7.1.0,<8"
pytest-asyncio = ">=1.4.0,<1.5.0"
pytest-codspeed = ">=5.0.3,<6.0.0"
pytest-timeout = ">=2.3.1"
freezegun = "^1.5.5"
dbus-fast = ">=5.0.16"
[tool.poetry.group.docs]
optional = true
[tool.poetry.group.docs.dependencies]
myst-parser = ">=5.1.0"
sphinx = ">=4.0"
furo = ">=2023.5.20"
sphinx-autobuild = ">=2021.3.14"
[tool.semantic_release]
version_toml = ["pyproject.toml:project.version"]
version_variables = [
"src/habluetooth/__init__.py:__version__",
"docs/conf.py:release",
]
build_command = "pip install poetry && poetry build"
[tool.semantic_release.changelog]
exclude_commit_patterns = [
"chore*",
"ci*",
]
[tool.semantic_release.changelog.environment]
keep_trailing_newline = true
[tool.semantic_release.branches.main]
match = "main"
[tool.semantic_release.branches.noop]
match = "(?!main$)"
prerelease = true
[tool.pytest.ini_options]
addopts = "-v -Wdefault --cov=habluetooth --cov-report=term-missing:skip-covered"
pythonpath = ["src"]
log_cli="true"
log_level="NOTSET"
timeout = 5
[tool.coverage.run]
branch = true
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"@overload",
"if TYPE_CHECKING",
"raise NotImplementedError",
'if __name__ == "__main__":',
]
[tool.ruff]
target-version = "py311"
line-length = 88
[tool.ruff.lint]
ignore = [
"E721", # type checks for cython
"D203", # 1 blank line required before class docstring
"D212", # Multi-line docstring summary should start at the first line
"D100", # Missing docstring in public module
"D104", # Missing docstring in public package
"D107", # Missing docstring in `__init__`
"D401", # First line of docstring should be in imperative mood
"ASYNC109", # ``timeout`` is part of our bleak-compatible public API
"TRY003", # too many to fix; long messages outside the exception class
]
select = [
"A", # flake8-builtins
"ASYNC", # flake8-async
"B", # flake8-bugbear
"BLE", # flake8-blind-except
"C4", # flake8-comprehensions
"C90", # mccabe complexity
"D", # flake8-docstrings
"DTZ", # flake8-datetimez
"E", # pycodestyle
"EM", # flake8-errmsg
"ERA", # eradicate
"EXE", # flake8-executable
"F", # pyflake
"FA", # flake8-future-annotations
"FIX", # flake8-fixme
"FLY", # flynt
"FURB", # refurb
"G", # flake8-logging-format
"I", # isort
"ICN", # flake8-import-conventions
"INP", # flake8-no-pep420
"ISC", # flake8-implicit-str-concat
"LOG", # flake8-logging
"N", # pep8-naming
"NPY", # numpy-specific rules
"PERF", # Perflint
"PGH", # pygrep-hooks
"PIE", # flake8-pie
"PLC", # pylint convention
"PLW", # pylint warnings
"PT", # flake8-pytest-style
"PTH", # flake8-use-pathlib
"PYI", # flake8-pyi
"Q", # flake8-quotes
"RET", # return
"RSE", # flake8-raise
"RUF", # ruff specific
"S", # flake8-bandit
"SIM", # simplify
"SLOT", # flake8-slots
"T10", # flake8-debugger
"T20", # flake8-print
"TC", # flake8-type-checking
"TD", # flake8-todos
"TRY", # tryceratops
"UP", # pyupgrade
"W", # pycodestyle
"YTT", # flake8-2020
]
[tool.ruff.lint.per-file-ignores]
"tests/**/*" = [
"D100",
"D101",
"D102",
"D103",
"D104",
"S101",
]
"setup.py" = ["D100"]
"conftest.py" = ["D100"]
# Sphinx config: ``copyright`` is the Sphinx convention, file is not a package
"docs/conf.py" = ["D100", "A001", "INP001"]
# Example scripts intentionally use print and aren't a package
"examples/*" = ["INP001", "T201"]
[tool.ruff.lint.isort]
known-first-party = ["habluetooth", "tests"]
[tool.mypy]
check_untyped_defs = true
disallow_any_generics = true
disallow_incomplete_defs = true
disallow_untyped_defs = true
mypy_path = "src/"
no_implicit_optional = true
show_error_codes = true
warn_unreachable = true
warn_unused_ignores = true
exclude = [
'docs/.*',
'setup.py',
]
[[tool.mypy.overrides]]
module = "tests.*"
allow_untyped_defs = true
[[tool.mypy.overrides]]
module = "docs.*"
ignore_errors = true
Bluetooth-Devices-habluetooth-75cbe37/renovate.json 0000664 0000000 0000000 00000000101 15211177045 0022505 0 ustar 00root root 0000000 0000000 {
"extends": ["github>browniebroke/renovate-configs:python"]
}
Bluetooth-Devices-habluetooth-75cbe37/src/ 0000775 0000000 0000000 00000000000 15211177045 0020566 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/ 0000775 0000000 0000000 00000000000 15211177045 0023104 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/__init__.py 0000664 0000000 0000000 00000004704 15211177045 0025222 0 ustar 00root root 0000000 0000000 __version__ = "6.8.3"
from bleak_retry_connector import Allocations
from .advertisement_tracker import (
TRACKER_BUFFERING_WOBBLE_SECONDS,
AdvertisementTracker,
)
from .base_scanner import BaseHaRemoteScanner, BaseHaScanner
from .central_manager import get_manager, set_manager
from .const import (
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT,
UNAVAILABLE_TRACK_SECONDS,
)
from .manager import BluetoothManager
from .models import (
BluetoothReachabilityIntent,
BluetoothServiceInfo,
BluetoothServiceInfoBleak,
HaBluetoothConnector,
HaBluetoothSlotAllocations,
HaScannerDetails,
HaScannerModeChange,
HaScannerRegistration,
HaScannerRegistrationEvent,
HaScannerType,
)
from .scanner import BluetoothScanningMode, HaScanner, ScannerStartError
from .scanner_device import BluetoothScannerDevice
from .storage import (
DiscoveredDeviceAdvertisementData,
DiscoveredDeviceAdvertisementDataDict,
DiscoveryStorageType,
discovered_device_advertisement_data_from_dict,
discovered_device_advertisement_data_to_dict,
expire_stale_scanner_discovered_device_advertisement_data,
)
from .wrappers import HaBleakClientWrapper, HaBleakScannerWrapper
__all__ = [
"CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS",
"FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS",
"SCANNER_WATCHDOG_INTERVAL",
"SCANNER_WATCHDOG_TIMEOUT",
"TRACKER_BUFFERING_WOBBLE_SECONDS",
"UNAVAILABLE_TRACK_SECONDS",
"AdvertisementTracker",
"Allocations",
"BaseHaRemoteScanner",
"BaseHaScanner",
"BluetoothManager",
"BluetoothReachabilityIntent",
"BluetoothScannerDevice",
"BluetoothScanningMode",
"BluetoothServiceInfo",
"BluetoothServiceInfoBleak",
"DiscoveredDeviceAdvertisementData",
"DiscoveredDeviceAdvertisementDataDict",
"DiscoveryStorageType",
"HaBleakClientWrapper",
"HaBleakScannerWrapper",
"HaBluetoothConnector",
"HaBluetoothSlotAllocations",
"HaScanner",
"HaScannerDetails",
"HaScannerModeChange",
"HaScannerRegistration",
"HaScannerRegistrationEvent",
"HaScannerType",
"ScannerStartError",
"discovered_device_advertisement_data_from_dict",
"discovered_device_advertisement_data_to_dict",
"expire_stale_scanner_discovered_device_advertisement_data",
"get_manager",
"set_manager",
]
Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/advertisement_tracker.pxd 0000664 0000000 0000000 00000000757 15211177045 0030217 0 ustar 00root root 0000000 0000000 import cython
from .models cimport BluetoothServiceInfoBleak
cdef unsigned int _ADVERTISING_TIMES_NEEDED
cdef class AdvertisementTracker:
cdef public dict intervals
cdef public dict fallback_intervals
cdef public dict sources
cdef public dict _timings
@cython.locals(timings=list)
cpdef void async_collect(self, BluetoothServiceInfoBleak service_info)
cpdef void async_remove_address(self, object address)
cpdef void async_scanner_paused(self, str source)
Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/advertisement_tracker.py 0000664 0000000 0000000 00000007254 15211177045 0030053 0 ustar 00root root 0000000 0000000 """The advertisement tracker."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .models import BluetoothServiceInfoBleak
ADVERTISING_TIMES_NEEDED = 16
_ADVERTISING_TIMES_NEEDED = ADVERTISING_TIMES_NEEDED
# Each scanner may buffer incoming packets so
# we need to give a bit of leeway before we
# mark a device unavailable
TRACKER_BUFFERING_WOBBLE_SECONDS = 5
_str = str
class AdvertisementTracker:
"""Tracker to determine the interval that a device is advertising."""
__slots__ = ("_timings", "fallback_intervals", "intervals", "sources")
def __init__(self) -> None:
"""Initialize the tracker."""
self.intervals: dict[str, float] = {}
self.fallback_intervals: dict[str, float] = {}
self.sources: dict[str, str] = {}
self._timings: dict[str, list[float]] = {}
def async_diagnostics(self) -> dict[str, dict[str, Any]]:
"""Return diagnostics."""
return {
"intervals": self.intervals,
"fallback_intervals": self.fallback_intervals,
"sources": self.sources,
"timings": self._timings,
}
def async_collect(self, service_info: BluetoothServiceInfoBleak) -> None:
"""
Collect timings for the tracker.
For performance reasons, it is the responsibility of the
caller to check if the device already has an interval set or
the source has changed before calling this function.
"""
self.sources[service_info.address] = service_info.source
if not (timings := self._timings.get(service_info.address)):
self._timings[service_info.address] = [service_info.time]
return
timings.append(service_info.time)
if len(timings) != _ADVERTISING_TIMES_NEEDED:
return
max_time_between_advertisements = timings[1] - timings[0]
for i in range(2, len(timings)):
time_between_advertisements = timings[i] - timings[i - 1]
if time_between_advertisements > max_time_between_advertisements:
max_time_between_advertisements = time_between_advertisements
# We now know the maximum time between advertisements
self.intervals[service_info.address] = max_time_between_advertisements
del self._timings[service_info.address]
def async_remove_address(self, address: _str) -> None:
"""Remove the tracker."""
self.intervals.pop(address, None)
self.sources.pop(address, None)
self._timings.pop(address, None)
def async_remove_fallback_interval(self, address: str) -> None:
"""Remove fallback interval."""
self.fallback_intervals.pop(address, None)
def async_remove_source(self, source: str) -> None:
"""Remove the tracker."""
for address, tracked_source in list(self.sources.items()):
if tracked_source == source:
self.async_remove_address(address)
def async_scanner_paused(self, source: str) -> None:
"""
Clear timing collection data when scanner is paused.
When a scanner pauses to establish a connection, it stops listening
for advertisements. If we don't clear the timing data, the next
advertisement after the connection attempt will create an incorrectly
large interval measurement (time_after_connection - time_before_connection)
which doesn't represent the actual advertising interval of the device.
"""
# Only iterate through timing data (typically much smaller than sources)
for address in list(self._timings):
if self.sources.get(address) == source:
del self._timings[address]
Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/auto_scheduler.pxd 0000664 0000000 0000000 00000006451 15211177045 0026635 0 ustar 00root root 0000000 0000000 import cython
from .models cimport BluetoothServiceInfoBleak
cdef double _AUTO_INITIAL_SWEEP_DELAY
cdef double _AUTO_REDISCOVERY_INTERVAL
cdef double _AUTO_REDISCOVERY_SWEEP_DURATION
cdef double _AUTO_WINDOW_MAX_DURATION
cdef double _AUTO_WINDOW_MIN_DURATION
cdef double _AUTO_CONNECTING_DEFER
cdef double _AUTO_COALESCE_LOOKAHEAD
cdef double _ON_DEMAND_EXTENSION_SLOP
cdef int NO_RSSI_VALUE
cdef double _clamp_window_duration(double duration) noexcept
cdef class ActiveScanRequest:
cdef public str address
cdef public double scan_interval
cdef public double scan_duration
cdef class _ScannerWorker:
cdef public object _scheduler
cdef public object _scanner
cdef public object _manager
cdef public object _wake
cdef public object _task
cdef public double _window_end
cdef public double _sweep_last_completed
cdef public bint _failed_window
cdef public bint _warned_no_fallback
cdef public dict _owned_due_at
cpdef void start(self, object loop, double initial_offset=*)
cpdef void stop(self)
cpdef void wake(self)
cpdef void _attach_owned(self, str address, dict entries)
cpdef void _detach_owned(self, str address)
cpdef void _clear_owned(self)
cpdef void note_window_dispatched(self, double window_end, double now)
@cython.locals(
next_at=double,
earliest=double,
)
cpdef double _next_event_at(self, double now)
@cython.locals(
threshold=double,
any_immediate=bint,
t=double,
)
cpdef tuple _collect_due_buckets(self, double now)
cpdef void _advance_due(self, list due_buckets, double from_time)
cdef class _ScanSchedule:
cdef public dict _due_at
cdef public dict _workers
cdef public dict _owner_by_address
cpdef bint seed(self, str address, ActiveScanRequest request, double due_time)
cpdef void drop(self, str address, ActiveScanRequest request)
cpdef void assign(self, str address, str new_source)
cpdef void unown(self, str address)
cpdef void clear_source(self, str source)
cpdef void attach_worker(self, str source)
cpdef void clear(self)
cdef class AutoScanScheduler:
cdef public object _manager
cdef public dict _requests_by_address
cdef public _ScanSchedule _schedule
cdef public dict _workers
cdef public object _loop
cdef public bint _running
cdef public object _on_demand_sweep_future
cdef public double _on_demand_sweep_end
cpdef void add_request(self, ActiveScanRequest request)
cpdef void remove_request(self, ActiveScanRequest request)
cpdef void add_scanner(self, object scanner)
@cython.locals(
source=str,
)
cpdef void remove_scanner(self, object scanner)
@cython.locals(
address=str,
requests=set,
)
cpdef void on_advertisement(self, BluetoothServiceInfoBleak service_info)
@cython.locals(
request=ActiveScanRequest,
)
cpdef void _seed_requests(
self, str address, set requests, double now
)
cpdef void start(self, object loop)
cpdef void stop(self)
@cython.locals(
best_rssi=int,
rssi=int,
adv_rssi=object,
scanner=object,
mode=object,
)
cpdef tuple _resolve_fallback_for_address(
self, str address, str exclude_source
)
Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/auto_scheduler.py 0000664 0000000 0000000 00000150511 15211177045 0026467 0 ustar 00root root 0000000 0000000 """
Auto-mode active-window scheduler.
Coordinates on-demand ACTIVE scans for AUTO-mode scanners. A scanner
defaults to PASSIVE; the manager flips it to ACTIVE for ``duration``
seconds on demand when an integration has asked for active scans on a
specific device address.
Per-device active windows fire on **exactly one** scanner at a time:
whichever scanner the manager currently considers the device's owner
(``manager.async_last_service_info(address).source``). If three other
AUTO scanners can also see the device, they stay PASSIVE for that
window. Ownership can flip across scanners over time as RSSI changes;
the next-due window then fires on the new owner (see "Migration"
below). The rediscovery sweep is a floor for AUTO scanners that
haven't active-scanned in ``AUTO_REDISCOVERY_INTERVAL`` (12 h); any
active window — per-device, sweep, or a window delegated to this
scanner by the connecting-fallback path — advances
``_sweep_last_completed``, so the sweep only fires on scanners that
would otherwise stay idle.
Flow
====
add_request(req) on_advertisement(adv)
| |
| _schedule.seed(...) | _schedule.seed(...) per req;
| _schedule.assign(addr, | _schedule.assign(addr,
| history.source) | adv.source) wakes owner
v v
+----------------------------------------------+
| AutoScanScheduler |
| _requests_by_address |
| addr -> set of ActiveScanRequest |
| _workers |
| source -> _ScannerWorker |
| _schedule: _ScanSchedule |
| _due_at |
| addr -> {request: next_due_time} |
| _owner_by_address |
| addr -> source |
+----------------------------------------------+
|
| aliased per-worker view:
| worker._owned_due_at[addr]
| is _due_at[addr] for entries
| this worker owns
v
+----------------------------------------------+
| _ScannerWorker._run loop |
| |
| sleep on _wake with timeout = |
| _next_event_at(now) - now |
| await _tick() |
| |
| _tick (sync collect, one await): |
| 1. _collect_due_buckets iterates |
| _owned_due_at (owned subset only); |
| per-address history call is just for |
| orphan/drift resync, not the owner |
| check (ownership is the view itself) |
| 2. sweep_due = sweep cadence elapsed |
| 3. if owner has connect in progress: |
| dispatch per-address to a |
| fallback scanner; return |
| 4. duration = max(due durations, |
| SWEEP_DURATION if sweep_due) |
| 5. _advance_due (pre-await) so the |
| new owner of any of these |
| addresses can't double-fire; |
| _sweep_last_completed = now (any |
| active scan satisfies the floor) |
| 6. ONE await: |
| scanner.async_request_active_window |
+----------------------------------------------+
Migration
=========
When a device moves from scanner A to scanner B (RSSI flip; manager
swaps ``_all_history[addr].source`` from A to B), the scheduler picks
up the new owner without any address-level rescheduling:
1. The manager's ``_scanner_adv_received`` updates ``_all_history``
and then calls ``auto_scheduler.on_advertisement(service_info)``
*before* the same-payload short-circuit, so the flip is visible to
the scheduler even for static-payload beacons.
2. ``on_advertisement`` calls ``_schedule.assign(adv.address,
adv.source)``. The schedule detaches the entry from A's
``_owned_due_at``, attaches it to B's ``_owned_due_at`` (same dict
object, just a different worker holds the alias), and wakes B's
worker. A's worker no longer sees the address at all; B's does.
3. On B's next ``_tick``, ``_collect_due_buckets`` iterates its
``_owned_due_at``, finds the entry, and dispatches it.
4. If the flip lands mid-window on A, the pre-await ``_advance_due``
in step 5 of ``_tick`` already advanced the entry's due time, so
B can't double-fire. The rare orphan/drift branches in
``_collect_due_buckets`` handle the edge case where the manager's
history disagrees with the cached owner view.
Connecting fallback
===================
A scanner mid-connect can't service the active-window mode flip
(the radio is busy). At tick time, if
``scanner._connections_in_progress() > 0``, the owner's worker
routes each due address via
``AutoScanScheduler._resolve_fallback_for_address``:
* A non-connecting ACTIVE scanner that sees the address is treated
as "covered" — the device is already being actively scanned, no
flip needed.
* Otherwise, the highest-RSSI non-connecting AUTO scanner that
sees the address is picked as a fallback. Calls are coalesced
per fallback so each scanner receives at most one
``async_request_active_window`` per tick.
* No usable fallback: warn (rate-limited), advance the address by
``_AUTO_CONNECTING_DEFER`` so the next tick retries soon after
the connect typically completes.
``_ScannerWorker.note_window_dispatched`` is called on the fallback
worker before the await to mark its radio as currently active —
this advances both ``_window_end`` (suppressing the fallback's own
ticks during the delegated window) and ``_sweep_last_completed``
(any active window satisfies the sweep floor).
Invariants
==========
* At most one outstanding window per scanner (``_window_end`` guards
re-entry into ``_tick``).
* Per-device windows fire only on the scanner whose ``source`` matches
the device's most recent advertisement source; other scanners that
see the same device skip it.
* Any active window (per-device, sweep, or delegated) advances the
scanner's ``_sweep_last_completed`` to ``now``. The rediscovery
sweep therefore fires only on AUTO scanners that haven't had
*any* active scan in ``AUTO_REDISCOVERY_INTERVAL`` (12 h).
* A registration kick-starts tracking immediately; ``on_advertisement``
is the fallback that re-creates the entry if a worker ``unown``'d it
because the device's history was missing at tick time.
* Every accepted advertisement on a tracked address wakes the source's
worker so an ownership flip on the same scanner triggers a
re-evaluation of ``_next_event_at``.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from typing import TYPE_CHECKING, Any
from bleak_retry_connector import NO_RSSI_VALUE
from .const import (
AUTO_COALESCE_LOOKAHEAD,
AUTO_INITIAL_SWEEP_DELAY,
AUTO_REDISCOVERY_INTERVAL,
AUTO_REDISCOVERY_SWEEP_DURATION,
AUTO_WINDOW_MAX_DURATION,
AUTO_WINDOW_MIN_DURATION,
)
from .models import BluetoothScanningMode
if TYPE_CHECKING:
from .base_scanner import BaseHaScanner
from .manager import BluetoothManager
from .models import BluetoothServiceInfoBleak
# Locally aliased so the Cython .pxd can declare them as C-typed constants;
# the unaliased names stay importable from this module for Python callers.
_AUTO_INITIAL_SWEEP_DELAY = AUTO_INITIAL_SWEEP_DELAY
_AUTO_REDISCOVERY_INTERVAL = AUTO_REDISCOVERY_INTERVAL
_AUTO_REDISCOVERY_SWEEP_DURATION = AUTO_REDISCOVERY_SWEEP_DURATION
_AUTO_WINDOW_MAX_DURATION = AUTO_WINDOW_MAX_DURATION
_AUTO_WINDOW_MIN_DURATION = AUTO_WINDOW_MIN_DURATION
_AUTO_COALESCE_LOOKAHEAD = AUTO_COALESCE_LOOKAHEAD
# Retry delay when the owner is mid-connect: short enough that we
# retry shortly after a typical connect completes (~10s), long enough
# that we don't busy-loop while it's in flight.
_AUTO_CONNECTING_DEFER = 30.0
# Joiner-extension threshold: a desired end-time within this margin
# of the in-flight window's end does not trigger an extension.
# Absorbs sub-second task-start jitter for same-duration callers.
_ON_DEMAND_EXTENSION_SLOP = 1.0
_LOGGER = logging.getLogger(__name__)
def _clamp_window_duration(duration: float) -> float:
"""Clamp a window duration into ``[MIN, MAX]``."""
if duration < _AUTO_WINDOW_MIN_DURATION:
return _AUTO_WINDOW_MIN_DURATION
if duration > _AUTO_WINDOW_MAX_DURATION:
return _AUTO_WINDOW_MAX_DURATION
return duration
class ActiveScanRequest:
"""
A registered need for on-demand active scans on one address.
``scan_interval`` and ``scan_duration`` must be finite positive
floats. ``async_register_active_scan`` enforces this at the
public boundary; direct constructors must honor the same contract.
"""
__slots__ = ("address", "scan_duration", "scan_interval")
def __init__(
self,
address: str,
scan_interval: float,
scan_duration: float,
) -> None:
self.address = address
self.scan_interval = scan_interval
self.scan_duration = scan_duration
class _ScannerWorker:
"""One persistent task per AUTO scanner; sleeps until next due event."""
__slots__ = (
"_failed_window",
"_manager",
"_owned_due_at",
"_scanner",
"_scheduler",
"_sweep_last_completed",
"_task",
"_wake",
"_warned_no_fallback",
"_window_end",
)
def __init__(
self,
scheduler: AutoScanScheduler,
scanner: BaseHaScanner,
manager: BluetoothManager,
) -> None:
self._scheduler = scheduler
self._scanner = scanner
self._manager = manager
self._wake: asyncio.Event = asyncio.Event()
self._task: asyncio.Task[None] | None = None
self._window_end: float = 0.0
self._sweep_last_completed: float = 0.0
self._failed_window: bool = False
self._warned_no_fallback: bool = False
# Subset of _due_at owned by this worker; inner dicts aliased so
# in-place advances stay visible. Mutated only via _attach_owned
# / _detach_owned / _clear_owned, driven by _ScanSchedule.
self._owned_due_at: dict[str, dict[ActiveScanRequest, float]] = {}
def start(
self, loop: asyncio.AbstractEventLoop, initial_offset: float = 0.0
) -> None:
"""
Start the worker; first sweep at AUTO_INITIAL_SWEEP_DELAY + offset.
``initial_offset`` staggers first sweeps across concurrently-
registered scanners so they don't all flip ACTIVE at once.
"""
self._sweep_last_completed = (
loop.time()
+ _AUTO_INITIAL_SWEEP_DELAY
+ initial_offset
- _AUTO_REDISCOVERY_INTERVAL
)
self._task = loop.create_task(self._run())
def stop(self) -> None:
"""Cancel the worker task."""
if self._task is not None and not self._task.done():
self._task.cancel()
def wake(self) -> None:
"""Interrupt the worker's sleep so it re-evaluates pending work."""
self._wake.set()
def _attach_owned(
self, address: str, entries: dict[ActiveScanRequest, float]
) -> None:
"""Attach an owned ``_due_at`` bucket; called only by _ScanSchedule."""
self._owned_due_at[address] = entries
def _detach_owned(self, address: str) -> None:
"""Detach an owned bucket; called only by _ScanSchedule."""
del self._owned_due_at[address]
def _clear_owned(self) -> None:
"""Drop all owned buckets; called only by _ScanSchedule."""
self._owned_due_at.clear()
def note_window_dispatched(self, window_end: float, now: float) -> None:
"""
Record that another worker delegated an active window here.
Bumps ``_window_end`` to suppress redundant ticks during the
delegated window, and ``_sweep_last_completed`` so the window
counts as this worker's sweep. Both use ``max`` to preserve a
longer pre-existing value.
Known best-effort caveats; revisit if profiling shows they
matter:
* If this worker is mid-``_tick`` when we set ``_window_end``,
its ``finally`` resets ``_window_end`` to 0 on exit, wiping
our bump. The optimization is then skipped: this worker
ticks normally during the delegated window. Correctness is
preserved (scanner-level ``_active_window_handle`` extends
the radio window idempotently; each worker advances its
``_owned_due_at`` entries on its own tick), only the
intended "skip your own ticks during my window" hint is
lost. ``_sweep_last_completed`` lives outside the
``finally`` and survives.
* The rediscovery sweep only exists to give AUTO scanners
that never see an active window a periodic active-scan
floor. A fallback the dispatcher delegates to *is*
actively scanning, so it doesn't need the floor —
``_sweep_last_completed`` is bumped to ``now`` on every
delegation so its separately-scheduled 12 h sweep stays
deferred while delegated windows are happening, which is
the right answer regardless of how short the delegated
window is.
"""
if self._window_end < window_end:
self._window_end = window_end
if self._sweep_last_completed < now:
self._sweep_last_completed = now
def _next_event_at(self, now: float) -> float:
"""
Return the earliest loop-time at which this worker has work.
O(owned), no async_last_service_info call.
"""
if self._window_end > now:
return self._window_end
next_at = self._sweep_last_completed + _AUTO_REDISCOVERY_INTERVAL
for entries in self._owned_due_at.values():
earliest = min(entries.values())
if earliest < next_at:
next_at = earliest
return next_at
async def _run(self) -> None:
"""Sleep until next event or wake, then process due work."""
while True:
loop = self._scheduler._loop
if loop is None:
return
now = loop.time()
next_at = self._next_event_at(now)
self._wake.clear()
delay = max(0.0, next_at - now)
if delay > 0:
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(self._wake.wait(), timeout=delay)
if not self._scheduler._running:
return
await self._tick()
def _collect_due_buckets(
self, now: float
) -> tuple[
list[tuple[str, dict[ActiveScanRequest, float], list[ActiveScanRequest]]],
list[ActiveScanRequest],
bool,
]:
"""
Return (due_buckets, all_due, any_immediate) for owned addresses.
Collects entries due within ``_AUTO_COALESCE_LOOKAHEAD``; caller
gates on ``any_immediate or sweep_due``. Prunes orphans and
resyncs on owner drift.
"""
source = self._scanner.source
last_service_info = self._manager.async_last_service_info
threshold = now + _AUTO_COALESCE_LOOKAHEAD
due_buckets: list[
tuple[str, dict[ActiveScanRequest, float], list[ActiveScanRequest]]
] = []
all_due: list[ActiveScanRequest] = []
any_immediate = False
for address, entries in self._owned_due_at.copy().items():
history = last_service_info(address, False)
if history is None:
self._scheduler._schedule.unown(address)
continue
if history.source != source:
self._scheduler._schedule.assign(address, history.source)
continue
due: list[ActiveScanRequest] = []
for r, t in entries.items():
if t <= threshold:
due.append(r)
if t <= now:
any_immediate = True
if not due:
continue
due_buckets.append((address, entries, due))
all_due.extend(due)
return due_buckets, all_due, any_immediate
def _advance_due(
self,
due_buckets: list[
tuple[str, dict[ActiveScanRequest, float], list[ActiveScanRequest]]
],
from_time: float,
) -> None:
"""
Advance all buckets by ``from_time + scan_interval`` (pre-await).
Called before the scanner await so a mid-window ownership
flip can't let a new owner double-fire.
"""
for _address, entries, due in due_buckets:
for request in due:
entries[request] = from_time + request.scan_interval
async def _tick(self) -> None:
"""
Fire one coalesced window covering due per-device + sweep work.
Collection is sync; only the scanner call is awaited. The
window duration is the max of every due per-device duration
and (if sweep is due) the sweep duration. ``scan_interval``
runs from window start (now), not window end. Failure of the
scanner call still advances the due times (``_advance_due``
ran pre-await) so a stuck scanner can't busy-loop the worker.
If the owner is mid-connect at tick time, dispatch is routed
to alternate scanners via ``_dispatch_to_fallback``.
"""
loop = self._scheduler._loop
if loop is None:
return
now = loop.time()
# Defense-in-depth re-entry guard: unreachable on the current
# call path (single per-worker task, finally clears
# _window_end) but kept for future callers of _tick.
if self._window_end > now:
return
self._window_end = 0.0
try:
due_buckets, all_due, any_immediate = self._collect_due_buckets(now)
sweep_due = now >= self._sweep_last_completed + _AUTO_REDISCOVERY_INTERVAL
# Gate on any_immediate (per-device hit now) or sweep_due
# (12 h floor). Soon-due-only entries ride a window that
# one of those triggers, but never trigger one alone.
if not any_immediate and not sweep_due:
return
if self._scanner._connections_in_progress() > 0:
# Per-address advance happens inside _dispatch_to_fallback
# so no-fallback addresses get a short retry interval
# rather than the full scan_interval.
await self._dispatch_to_fallback(due_buckets, sweep_due, now)
return
duration = self._scheduler._coalesce_duration(all_due) if all_due else 0.0
if sweep_due and duration < _AUTO_REDISCOVERY_SWEEP_DURATION:
duration = _AUTO_REDISCOVERY_SWEEP_DURATION
self._window_end = now + duration
# Advance pre-await: a new owner that wakes mid-window
# must see the entries already advanced, otherwise an
# RSSI flip would let the new owner fire a duplicate
# window.
self._advance_due(due_buckets, now)
# Any active window is functionally a sweep — the
# rediscovery sweep exists only to give AUTO scanners
# that haven't actively scanned in 12 h a floor, so
# there's no point in scheduling a separate one when
# the radio is about to scan anyway.
self._sweep_last_completed = now
try:
await self._scanner.async_request_active_window(duration)
except Exception as ex: # pylint: disable=broad-except
# First failure per recovery-cycle gets a traceback;
# subsequent failures collapse to a one-liner so a
# persistently broken scanner can't spam the log.
# Flag clears on the next success so failure-after-
# recovery captures a stack again.
if self._failed_window:
_LOGGER.warning(
"%s: error running active window of %.1fs: %s",
self._scanner.name,
duration,
ex,
)
else:
self._failed_window = True
_LOGGER.exception(
"%s: error running active window of %.1fs",
self._scanner.name,
duration,
)
else:
self._failed_window = False
except Exception: # pylint: disable=broad-except
# Sync-phase failure (collect/advance/coalesce). Log so
# the worker doesn't die silently, then continue.
_LOGGER.exception(
"%s: unexpected error in auto-window tick", self._scanner.name
)
finally:
self._window_end = 0.0
async def _dispatch_to_fallback( # noqa: C901
self,
due_buckets: list[
tuple[str, dict[ActiveScanRequest, float], list[ActiveScanRequest]]
],
sweep_due: bool,
now: float,
) -> None:
"""
Owner is mid-connect: route per-address windows to alternates.
Per-address outcomes (advance done in-line so a mid-dispatch
ownership flip can't double-fire):
* ACTIVE scanner sees it -> covered, advance by scan_interval.
* AUTO fallback found -> dispatch, advance by scan_interval.
* Neither -> warn (rate-limited), advance only by
``_AUTO_CONNECTING_DEFER`` so the next tick retries soon
after the connect typically completes.
Sweep is per-scanner; defer via ``_sweep_last_completed`` so
the next tick retries without spinning the loop.
"""
fallback_groups: dict[str, tuple[BaseHaScanner, list[ActiveScanRequest]]] = {}
no_fallback_addresses: list[str] = []
exclude_source = self._scanner.source
had_any_progress = False
retry_at = now + _AUTO_CONNECTING_DEFER
for address, entries, due in due_buckets:
covered, fallback = self._scheduler._resolve_fallback_for_address(
address, exclude_source
)
if not covered and fallback is None:
for request in due:
entries[request] = retry_at
no_fallback_addresses.append(address)
continue
for request in due:
entries[request] = now + request.scan_interval
had_any_progress = True
if fallback is None:
continue
existing = fallback_groups.get(fallback.source)
if existing is None:
fallback_groups[fallback.source] = (fallback, list(due))
else:
existing[1].extend(due)
if no_fallback_addresses:
if not self._warned_no_fallback:
self._warned_no_fallback = True
_LOGGER.warning(
"%s: connect in progress and no fallback scanner for %s;"
" retrying in %.1fs",
self._scanner.name,
", ".join(no_fallback_addresses),
_AUTO_CONNECTING_DEFER,
)
elif had_any_progress:
self._warned_no_fallback = False
if sweep_due:
self._sweep_last_completed = (
now - _AUTO_REDISCOVERY_INTERVAL + _AUTO_CONNECTING_DEFER
)
# Entries were advanced by ``scan_interval`` and the fallback
# worker was notified before this await; a failing dispatch
# is treated like a successful one (no soon-retry) for the
# same reason the owner path advances on failure — a stuck
# fallback must not busy-loop the worker. The next normal
# tick will pick the address up at its full cadence.
# Dispatches are awaited sequentially on purpose: typical HA
# setups have 0-2 fallbacks per tick, the BlueZ stop/start
# path serializes at the daemon anyway, and a per-fallback
# try/except keeps a stuck one from masking errors on the
# others. ``asyncio.gather`` would parallelize but adds task
# creation cost and ExceptionGroup handling for no win at
# this scale.
loop = self._scheduler._loop
if TYPE_CHECKING:
assert loop is not None
workers = self._scheduler._workers
fb_worker: _ScannerWorker | None
for fb, fb_due in fallback_groups.values():
duration = self._scheduler._coalesce_duration(fb_due)
fb_worker = workers.get(fb.source)
if fb_worker is not None:
# Sample loop.time() per iteration: each prior
# ``async_request_active_window`` await can take
# seconds (scanner stop/restart on Linux), so the
# owner's tick-start ``now`` is stale for later
# fallbacks and would put ``_window_end`` in the
# past — leaving the fallback worker's tick
# suppression off during the delegated window.
dispatch_now = loop.time()
fb_worker.note_window_dispatched(dispatch_now + duration, dispatch_now)
try:
await fb.async_request_active_window(duration)
except Exception:
_LOGGER.exception(
"%s: error dispatching fallback active window of %.1fs to %s",
self._scanner.name,
duration,
fb.name,
)
class _ScanSchedule:
"""
Per-address scan schedule: due times, owner, and per-worker view.
Owns ``_due_at`` (when each request is due), ``_owner_by_address``
(which scanner currently sees each address), and maintains an aliased
subset of ``_due_at`` on each worker's ``_owned_due_at`` so workers
iterate only the addresses they actually own.
"""
__slots__ = ("_due_at", "_owner_by_address", "_workers")
def __init__(self, workers: dict[str, _ScannerWorker]) -> None:
"""Bind to the scheduler's ``_workers`` dict."""
self._workers = workers
self._due_at: dict[str, dict[ActiveScanRequest, float]] = {}
self._owner_by_address: dict[str, str] = {}
def seed(self, address: str, request: ActiveScanRequest, due_time: float) -> bool:
"""Seed ``request`` at ``address``; return True if newly inserted."""
existing = self._due_at.setdefault(address, {})
if request in existing:
return False
existing[request] = due_time
return True
def drop(self, address: str, request: ActiveScanRequest) -> None:
"""Drop ``request`` at ``address``; ``unown`` if it was the last one."""
entries = self._due_at.get(address)
if entries is None:
return
entries.pop(request, None)
if not entries:
self.unown(address)
def assign(self, address: str, new_source: str) -> None:
"""Move ownership of ``address`` to ``new_source`` and wake its worker."""
new_worker = self._workers.get(new_source)
old_source = self._owner_by_address.get(address)
if old_source != new_source:
if old_source is not None:
old_worker = self._workers.get(old_source)
if old_worker is not None:
old_worker._detach_owned(address)
self._owner_by_address[address] = new_source
if new_worker is not None:
new_worker._attach_owned(address, self._due_at[address])
if new_worker is not None:
new_worker.wake()
def unown(self, address: str) -> None:
"""Forget ``address`` entirely; drops due_at, owner, and worker view."""
del self._due_at[address]
old_worker = self._workers.get(self._owner_by_address.pop(address))
if old_worker is not None:
old_worker._detach_owned(address)
def clear_source(self, source: str) -> None:
"""Drop owner mappings and ``_due_at`` entries owned by ``source``."""
worker = self._workers.get(source)
if worker is not None:
# AUTO source: iterate the worker's own view, O(owned-by-source).
for address in list(worker._owned_due_at):
del self._owner_by_address[address]
del self._due_at[address]
worker._clear_owned()
return
# Non-AUTO source (no worker): a PASSIVE / ACTIVE scanner can
# still own an address via ``on_advertisement``, so scan
# ``_owner_by_address`` to find what it owns.
for address in list(self._owner_by_address):
if self._owner_by_address[address] == source:
del self._owner_by_address[address]
del self._due_at[address]
def attach_worker(self, source: str) -> None:
"""Attach pre-assigned entries to a newly-registered worker."""
worker = self._workers[source]
for address, owner in self._owner_by_address.items():
if owner == source:
worker._attach_owned(address, self._due_at[address])
def clear(self) -> None:
"""Reset all schedule state and every worker's owned view."""
for worker in self._workers.values():
worker._clear_owned()
self._owner_by_address.clear()
self._due_at.clear()
class AutoScanScheduler:
"""Coordinates on-demand active windows across AUTO-mode scanners."""
__slots__ = (
"_loop",
"_manager",
"_on_demand_sweep_end",
"_on_demand_sweep_future",
"_requests_by_address",
"_running",
"_schedule",
"_workers",
)
def __init__(self, manager: BluetoothManager) -> None:
"""Initialize the scheduler bound to a manager."""
self._manager = manager
self._requests_by_address: dict[str, set[ActiveScanRequest]] = {}
self._workers: dict[str, _ScannerWorker] = {}
self._schedule = _ScanSchedule(self._workers)
self._loop: asyncio.AbstractEventLoop | None = None
self._running = False
self._on_demand_sweep_future: asyncio.Future[None] | None = None
self._on_demand_sweep_end: float = 0.0
def start(self, loop: asyncio.AbstractEventLoop) -> None:
"""
Bind to the event loop and spawn one worker per AUTO scanner.
Idempotent: no-op if already running. A genuine restart is
``stop()`` (which flips ``_running`` to False) then
``start(new_loop)``. Also replays any pre-start
``_requests_by_address`` into ``_due_at`` so embedders that
register before ``async_setup`` still get the kick-start
cadence; same history-gating as ``add_request``.
"""
if self._running:
return
self._loop = loop
self._running = True
for scanner in self._manager.async_current_scanners():
if (
scanner.requested_mode is BluetoothScanningMode.AUTO
and scanner.source not in self._workers
):
self._spawn_worker(scanner)
now = loop.time()
last_service_info = self._manager.async_last_service_info
for address, requests in self._requests_by_address.items():
history = last_service_info(address, False)
if history is None:
continue
self._seed_requests(address, requests, now)
self._schedule.assign(address, history.source)
def stop(self) -> None:
"""
Cancel all worker tasks (fire-and-forget).
Sync to match ``BluetoothManager.async_stop``;
``worker.stop()`` cancels without awaiting. Nulls ``_loop``
too so post-stop ``add_request`` / ``on_advertisement`` fall
back to the record-only path instead of seeding ``_due_at``
with timestamps from the cancelled loop. Clears ``_due_at``
so a later ``start(new_loop)`` re-seeds from
``_requests_by_address`` against the new loop's clock base;
leaving stale due-times would let them fire instantly (or
never) under a loop with a different ``time()`` origin.
In-place restart (``stop()`` then ``start(new_loop)``)
needs an ``await asyncio.sleep(0)`` between them so
cancelled tasks finish before new workers spawn on the same
sources; HA's flow never does this.
Also resolves any in-flight on-demand sweep future since the
leader is a caller task that ``worker.stop()`` cannot reach.
"""
self._running = False
for worker in self._workers.values():
worker.stop()
self._schedule.clear()
self._workers.clear()
# done() guard mirrors the leader's finally for symmetry;
# a future left non-None after completion would otherwise
# raise InvalidStateError here.
future = self._on_demand_sweep_future
if future is not None and not future.done():
future.set_result(None)
self._on_demand_sweep_future = None
self._on_demand_sweep_end = 0.0
self._loop = None
def add_scanner(self, scanner: BaseHaScanner) -> None:
"""
Register an AUTO-mode scanner; spawn its worker if running.
Skips when ``_running`` or ``_loop`` are unset (both cleared
by ``stop()``), so a post-stop registration doesn't spawn a
worker that would have to exit on its first iteration.
"""
if scanner.requested_mode is not BluetoothScanningMode.AUTO:
return
if self._loop is None or not self._running or scanner.source in self._workers:
return
self._spawn_worker(scanner)
def remove_scanner(self, scanner: BaseHaScanner) -> None:
"""
Stop the worker for a scanner leaving the manager.
Also prunes ``_due_at`` entries the scanner currently owns so
a removed-and-not-rediscovered device doesn't keep a tracked
entry pinned until the next history flip / age-out.
"""
source = scanner.source
self._schedule.clear_source(source)
worker = self._workers.pop(source, None)
if worker is not None:
worker.stop()
def _spawn_worker(self, scanner: BaseHaScanner) -> None:
assert self._loop is not None # noqa: S101
worker = _ScannerWorker(self, scanner, self._manager)
# Stagger first sweeps so concurrently-registered scanners
# don't all flip ACTIVE at once. Modulo into the initial-sweep
# window so the Nth offset is bounded; past
# AUTO_INITIAL_SWEEP_DELAY/SWEEP_DURATION scanners offsets
# repeat, harmless since BLE radios don't interfere when
# multiple are active.
offset = (
len(self._workers) * _AUTO_REDISCOVERY_SWEEP_DURATION
) % _AUTO_INITIAL_SWEEP_DELAY
worker.start(self._loop, offset)
source = scanner.source
self._workers[source] = worker
# Attach entries pre-assigned before this scanner registered.
self._schedule.attach_worker(source)
def add_request(self, request: ActiveScanRequest) -> None:
"""
Register an active-scan request and start tracking.
First window fires ``scan_interval`` after registration if
history exists; otherwise ``on_advertisement`` bootstraps on
first sight. ``ActiveScanRequest`` compares by identity so
each public ``async_register_active_scan`` call adds an
independent cadence; cancellation is per-registration.
Pre-``start()`` calls just record the request (``start()``
replays them).
"""
self._requests_by_address.setdefault(request.address, set()).add(request)
if self._loop is None:
return
history = self._manager.async_last_service_info(request.address, False)
if history is None:
# No history: skip the seed (the next tick would prune
# it anyway); on_advertisement will bootstrap on first
# sight.
return
if not self._schedule.seed(
request.address, request, self._loop.time() + request.scan_interval
):
return
self._schedule.assign(request.address, history.source)
def remove_request(self, request: ActiveScanRequest) -> None:
"""Drop the request from ``_requests_by_address`` and the schedule."""
if (bucket := self._requests_by_address.get(request.address)) is not None:
bucket.discard(request)
if not bucket:
del self._requests_by_address[request.address]
self._schedule.drop(request.address, request)
def on_advertisement(self, service_info: BluetoothServiceInfoBleak) -> None:
"""
Hot path. Track requests for the ad's address; wake the owner.
Wake is unconditional (when the address has requests) so it
covers both bootstrap (entry created) and ownership flip
(existing entry, this scanner is now the owner and must
re-evaluate ``_next_event_at``). ``Event.set`` is cheap
enough to fire per tracked-address advertisement.
"""
if not self._requests_by_address or self._loop is None:
return
address = service_info.address
requests = self._requests_by_address.get(address)
if requests is None:
return
self._seed_requests(address, requests, self._loop.time())
self._schedule.assign(address, service_info.source)
def _seed_requests(
self,
address: str,
requests: set[ActiveScanRequest],
now: float,
) -> None:
"""
Insert any not-yet-tracked requests with next-due = now + interval.
Shared by ``on_advertisement`` and the ``start()`` replay
loop. Leaves existing entries' due times untouched.
"""
for request in requests:
self._schedule.seed(address, request, now + request.scan_interval)
def _resolve_fallback_for_address(
self, address: str, exclude_source: str
) -> tuple[bool, BaseHaScanner | None]:
"""
Return ``(covered, best_auto_fallback)`` for a due address.
``covered``: a non-connecting ACTIVE scanner sees the
address (already actively scanned; caller drops silently).
``best_auto_fallback``: highest-RSSI non-connecting AUTO
scanner seeing the address, excluding the owner. PASSIVE is
never a valid fallback. Early-returns on the first ACTIVE
coverage since the caller short-circuits on ``covered``.
"""
best: BaseHaScanner | None = None
best_rssi = 0
for device in self._manager.async_scanner_devices_by_address(address, False):
scanner = device.scanner
if scanner.source == exclude_source:
continue
if scanner._connections_in_progress() > 0:
continue
mode = scanner.requested_mode
if mode is BluetoothScanningMode.ACTIVE:
return True, None
if mode is not BluetoothScanningMode.AUTO:
continue
# adv_rssi is held as object so a None value doesn't
# trip the int conversion that ``rssi=int`` in
# @cython.locals would do on direct assignment.
adv_rssi = device.advertisement.rssi
rssi = NO_RSSI_VALUE if adv_rssi is None else adv_rssi
if best is None or rssi > best_rssi:
best_rssi = rssi
best = scanner
return False, best
def _coalesce_duration(self, entries: list[ActiveScanRequest]) -> float:
"""
Pick max requested duration, clamped to [MIN, MAX].
Hot path; trusts ``scan_duration`` to be a finite positive
float (``async_register_active_scan`` enforces this at the
boundary).
"""
return _clamp_window_duration(
max(
(e.scan_duration for e in entries),
default=_AUTO_WINDOW_MIN_DURATION,
)
)
async def _flip_scanners_for_sweep(self, duration: float) -> bool: # noqa: C901
"""
Flip every non-busy AUTO scanner into a ``duration``-second window.
Returns ``True`` if at least one scanner actually opened a
window (per-scanner result was ``True``), ``False`` if no
AUTO workers are registered, every one is mid-connect, or
every dispatched scanner declined / raised so the caller
can short-circuit any post-flip sleep on a window that
never opened.
``return_exceptions=True`` plus the per-scanner log keeps one
stuck adapter from aborting the bus-wide sweep while still
surfacing its failure. Mid-connect scanners are skipped —
unlike the periodic ``_tick`` path this does not route to a
fallback; on-demand is best-effort. Re-flipping with a
longer duration extends the radio's open window in place
(``BaseHaScanner.async_request_active_window`` contract),
so the same helper serves both leader and joiner-extension.
Caller must guard ``self._loop is not None``.
Pre-await bumps ``_window_end`` to suppress the worker's own
tick during the window; ``_sweep_last_completed`` is bumped
post-await only on ``True`` so a declined/raised flip leaves
the 12 h rediscovery floor unsatisfied for that scanner.
Per-target ``_window_end`` is reverted to its pre-bump value
on a non-``True`` result (when our bump still holds) so a
declined / raised scanner does not stay locked out of its
own ticks for the on-demand duration with no actual radio
window open.
Best-effort caveat (concurrent revert): when a leader's flip
and a joiner's extension flip both visit the same worker and
both decline, the leader's exact-equality revert guard sees
the joiner's bump and skips, while the joiner's revert
restores to its observed ``previous_window_end`` (the
leader's bumped value). The worker stays bumped to the
leader's intended end despite no radio window opening; it
self-heals on the next tick at that end. Symmetric to the
``_window_end`` caveat in ``note_window_dispatched``.
"""
if TYPE_CHECKING:
assert self._loop is not None
now = self._loop.time()
window_end = now + duration
targets: list[tuple[_ScannerWorker, BaseHaScanner, float]] = []
for worker in self._workers.values():
scanner = worker._scanner
if scanner._connections_in_progress() > 0:
continue
previous_window_end = worker._window_end
if previous_window_end < window_end:
worker._window_end = window_end
targets.append((worker, scanner, previous_window_end))
if not targets:
return False
results = await asyncio.gather(
*(
scanner.async_request_active_window(duration)
for _, scanner, _ in targets
),
return_exceptions=True,
)
any_opened = False
for (worker, scanner, previous_window_end), result in zip(
targets, results, strict=True
):
if result is True:
any_opened = True
if worker._sweep_last_completed < now:
worker._sweep_last_completed = now
continue
# No window opened for this scanner; if our bump still
# holds, revert so the worker can tick normally. A
# concurrent extension that pushed past us, or a _tick
# finally that cleared to 0, is left alone.
if worker._window_end == window_end:
worker._window_end = previous_window_end
if isinstance(result, Exception):
_LOGGER.warning(
"%s: error running on-demand active window of %.1fs: %s",
scanner.name,
duration,
result,
)
elif isinstance(result, BaseException):
# CancelledError etc. from a scanner that internally
# cancelled; best-effort, log distinctly from a
# genuine False-decline so logs do not mislead.
_LOGGER.debug(
"%s: cancelled during on-demand active window of %.1fs",
scanner.name,
duration,
)
else:
_LOGGER.debug(
"%s: declined on-demand active window of %.1fs",
scanner.name,
duration,
)
return any_opened
async def async_request_active_scan(self, duration: float) -> None:
"""
Flip every AUTO scanner to ACTIVE for ``duration`` seconds.
Public entry is ``BluetoothManager.async_request_active_scan``
(validates finite/positive); this method clamps to
``[MIN, MAX]``.
Concurrent callers dedupe on ``_on_demand_sweep_future``
(synchronous check-and-set, atomic under cooperative
scheduling — exactly one window per bus). A joiner whose
``desired_end`` exceeds the current end extends the
in-flight window: re-flip the scanners and push
``_on_demand_sweep_end`` out; the leader's sleep loop
re-reads it on each wake, so an extension just makes the
leader sleep again. ``_ON_DEMAND_EXTENSION_SLOP`` on the
extension threshold absorbs task-start jitter so same-
duration concurrent callers do not trigger bogus extensions.
Cancellation: leader's cancel propagates to its caller and
joiners wake to ``None`` (best-effort — they get whatever
radio activity happened). Joiners ``await asyncio.shield``
the future so a cancelled joiner cannot cancel the shared
future and take down the siblings or the leader's
``set_result``.
Fast-return: when the leader's flip neither opens a window
itself (no AUTO workers, every one mid-connect, or every
dispatched scanner declined / raised) nor sees a concurrent
joiner that did (``_on_demand_sweep_end`` was not pushed
past the leader's ``desired_end`` during the await), it
skips the sleep loop and returns immediately rather than
blocking the caller for a window that never opens. An
extension whose re-flip opens nothing reverts its eager
``_on_demand_sweep_end`` push for the same reason.
"""
# Capture loop locally so a concurrent stop() (which nulls
# self._loop) during the sleep loop or the flip-await cannot
# turn a re-read into AttributeError.
loop = self._loop
if loop is None:
return
duration = _clamp_window_duration(duration)
now = loop.time()
desired_end = now + duration
in_flight = self._on_demand_sweep_future
if in_flight is not None:
if desired_end - self._on_demand_sweep_end > _ON_DEMAND_EXTENSION_SLOP:
previous_end = self._on_demand_sweep_end
self._on_demand_sweep_end = desired_end
# asyncio.shield the extension flip so a cancelled
# joiner does not leave the shared end pushed out
# past a partial re-flip; either all non-busy
# scanners receive the longer duration or none do.
flipped = await asyncio.shield(
self._flip_scanners_for_sweep(desired_end - now)
)
# No scanner opened or extended a window for us
# (every worker mid-connect, or every dispatched
# scanner declined / raised); revert the eager push
# so the leader does not sleep past the in-flight
# radio window for nothing. Guarded so a peer joiner
# that pushed end further during our shielded await
# is not clobbered.
if not flipped and self._on_demand_sweep_end == desired_end:
self._on_demand_sweep_end = previous_end
await asyncio.shield(in_flight)
return
future = loop.create_future()
self._on_demand_sweep_future = future
self._on_demand_sweep_end = desired_end
try:
flipped = await self._flip_scanners_for_sweep(duration)
if not flipped and self._on_demand_sweep_end <= desired_end:
# No scanner opened a window bus-wide (no AUTO
# workers, every one mid-connect, or every dispatched
# scanner declined / raised) and no joiner that
# interleaved during our await opened or extended a
# window past our end; skip the sleep loop rather
# than block callers for a window that never opens.
# A joiner that succeeded would have pushed
# `_on_demand_sweep_end` past `desired_end`; honor it
# by falling through to the sleep loop so the leader
# sleeps until that joiner's end (the joiner is
# parked on the shared future and would otherwise be
# cut short by the leader's finally).
return
while True:
remaining = self._on_demand_sweep_end - loop.time()
if remaining <= 0:
break
await asyncio.sleep(remaining)
finally:
# Identity check so a stop+start(new_loop) cycle does
# not let this orphan leader clobber the fresh state.
if self._on_demand_sweep_future is future:
self._on_demand_sweep_future = None
self._on_demand_sweep_end = 0.0
# stop() may have already resolved the future.
if not future.done():
future.set_result(None)
def async_diagnostics(self) -> dict[str, Any]:
"""
Return a snapshot of scheduler state for diagnostics.
Per-worker timing fields and ``monotonic_time`` are raw
``loop.time()`` values so callers can compute deltas; before
``start()`` (or after ``stop()``) ``_loop`` is None and these
are reported as 0.0.
"""
loop = self._loop
now = loop.time() if loop is not None else 0.0
workers: dict[str, dict[str, Any]] = {}
for source, worker in self._workers.items():
workers[source] = {
"name": worker._scanner.name,
"window_end": worker._window_end,
"sweep_last_completed": worker._sweep_last_completed,
"next_sweep_at": (
worker._sweep_last_completed + _AUTO_REDISCOVERY_INTERVAL
),
"next_event_at": (
worker._next_event_at(now) if loop is not None else 0.0
),
"failed_window": worker._failed_window,
"warned_no_fallback": worker._warned_no_fallback,
}
last_service_info = self._manager.async_last_service_info
requests: dict[str, list[dict[str, Any]]] = {}
for address, bucket in self._requests_by_address.items():
entries = self._schedule._due_at.get(address, {})
history = last_service_info(address, False)
owner_source = history.source if history is not None else None
requests[address] = [
{
"scan_interval": request.scan_interval,
"scan_duration": request.scan_duration,
"next_due": entries.get(request),
"owner_source": owner_source,
}
for request in bucket
]
return {
"running": self._running,
"monotonic_time": now,
"workers": workers,
"requests": requests,
}
Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/base_scanner.pxd 0000664 0000000 0000000 00000007664 15211177045 0026261 0 ustar 00root root 0000000 0000000
import cython
from .models cimport BluetoothServiceInfoBleak
from .manager cimport BluetoothManager
cdef object parse_advertisement_data_bytes
cdef object NO_RSSI_VALUE
cdef object BluetoothServiceInfoBleak
cdef object AdvertisementData
cdef object BLEDevice
cdef bint TYPE_CHECKING
cdef class BaseHaScanner:
cdef public str adapter
cdef public bint connectable
cdef public str source
cdef public object connector
cdef public unsigned int _connecting
cdef public str name
cdef public bint scanning
cdef public double _last_detection
cdef public object _start_time
cdef public object _cancel_watchdog
cdef public object _loop
cdef BluetoothManager _manager
cdef public object details
cdef public object current_mode
cdef public object requested_mode
cdef public dict _previous_service_info
cdef public double _expire_seconds
cdef public dict _details
cdef public object _cancel_track
cdef public dict _connect_failures
cdef public dict _connect_in_progress
cdef public unsigned int _connect_completed_total
cdef public unsigned int _connect_failed_total
cdef public double _last_connect_completed_time
cpdef void _clear_connection_history(self) except *
cpdef void _finished_connecting(self, str address, bint connected) except *
cdef void _increase_count(self, dict target, str address) except *
cdef void _add_connect_failure(self, str address) except *
cpdef void _add_connecting(self, str address) except *
cdef void _remove_connecting(self, str address) except *
cdef void _clear_connect_failure(self, str address) except *
@cython.locals(
in_progress=Py_ssize_t,
count=Py_ssize_t
)
cpdef _connections_in_progress(self)
cpdef _connection_failures(self, str address)
@cython.locals(
score=double,
scanner_connections_in_progress=Py_ssize_t,
previous_failures=Py_ssize_t
)
cpdef _score_connection_paths(self, int rssi_diff, object scanner_device)
cpdef tuple get_discovered_device_advertisement_data(self, str address)
cpdef float time_since_last_detection(self)
@cython.locals(info=BluetoothServiceInfoBleak)
cdef dict _build_discovered_device_advertisement_datas(self)
@cython.locals(info=BluetoothServiceInfoBleak)
cdef dict _build_discovered_device_timestamps(self)
@cython.locals(parsed=tuple, prev_info=BluetoothServiceInfoBleak, info=BluetoothServiceInfoBleak)
cpdef void _async_on_raw_advertisement(
self,
str address,
int rssi,
bytes raw,
dict details,
double advertisement_monotonic_time
)
@cython.locals(
prev_name=str,
prev_discovery=tuple,
has_local_name=bint,
has_manufacturer_data=bint,
has_service_data=bint,
has_service_uuids=bint,
sub_value=bytes,
super_value=bytes,
info=BluetoothServiceInfoBleak,
prev_info=BluetoothServiceInfoBleak
)
cdef void _async_on_advertisement_internal(
self,
str address,
int rssi,
str local_name,
list service_uuids,
dict service_data,
dict manufacturer_data,
object tx_power,
dict details,
double advertisement_monotonic_time,
bytes raw
)
cpdef void _async_on_advertisement(
self,
str address,
int rssi,
str local_name,
list service_uuids,
dict service_data,
dict manufacturer_data,
object tx_power,
dict details,
double advertisement_monotonic_time
)
@cython.locals(now=double, timestamp=double, info=BluetoothServiceInfoBleak)
cpdef void _async_expire_devices(self)
cpdef void _schedule_expire_devices(self)
cdef class BaseHaRemoteScanner(BaseHaScanner):
@cython.locals(info=BluetoothServiceInfoBleak)
cpdef tuple get_discovered_device_advertisement_data(self, str address)
Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/base_scanner.py 0000664 0000000 0000000 00000076064 15211177045 0026116 0 ustar 00root root 0000000 0000000 """Base classes for HA Bluetooth scanners for bluetooth."""
from __future__ import annotations
import asyncio
import logging
import warnings
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, Final, final
from bleak.backends.device import BLEDevice
from bleak_retry_connector import NO_RSSI_VALUE, Allocations
from bluetooth_adapters import adapter_human_name
from bluetooth_data_tools import monotonic_time_coarse, parse_advertisement_data_bytes
from .central_manager import get_manager
from .const import (
CALLBACK_TYPE,
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT,
)
from .models import (
BluetoothScanningMode,
BluetoothServiceInfoBleak,
HaBluetoothConnector,
HaScannerDetails,
HaScannerType,
)
from .storage import DiscoveredDeviceAdvertisementData
if TYPE_CHECKING:
from collections.abc import Generator, Iterable
from bleak.backends.scanner import AdvertisementData
from .scanner_device import BluetoothScannerDevice
SCANNER_WATCHDOG_INTERVAL_SECONDS: Final = SCANNER_WATCHDOG_INTERVAL.total_seconds()
_LOGGER = logging.getLogger(__name__)
_bytes = bytes
_float = float
_int = int
_str = str
class BaseHaScanner:
"""Base class for high availability BLE scanners."""
__slots__ = (
"_cancel_track",
"_cancel_watchdog",
"_connect_completed_total",
"_connect_failed_total",
"_connect_failures",
"_connect_in_progress",
"_connecting",
"_details",
"_expire_seconds",
"_last_connect_completed_time",
"_last_detection",
"_loop",
"_manager",
"_previous_service_info",
"_start_time",
"adapter",
"connectable",
"connector",
"current_mode",
"details",
"name",
"requested_mode",
"scanning",
"source",
)
def __init__(
self,
source: str,
adapter: str,
connector: HaBluetoothConnector | None = None,
connectable: bool = False,
requested_mode: BluetoothScanningMode | None = None,
current_mode: BluetoothScanningMode | None = None,
) -> None:
"""Initialize the scanner."""
self.connectable = connectable
self.source = source
self.connector = connector
self._connecting = 0
self.adapter = adapter
self.name = adapter_human_name(adapter, source) if adapter != source else source
self.scanning: bool = True
self.requested_mode = requested_mode
self.current_mode = current_mode
self._last_detection = 0.0
self._start_time = 0.0
self._cancel_watchdog: asyncio.TimerHandle | None = None
self._loop: asyncio.AbstractEventLoop | None = None
self._manager = get_manager()
# Determine scanner type based on class type
scanner_type = HaScannerType.UNKNOWN
if isinstance(self, BaseHaRemoteScanner):
scanner_type = HaScannerType.REMOTE
# Try to get adapter type from manager's cached adapters
elif (
(adapters := self._manager.get_cached_bluetooth_adapters())
and (adapter_details := adapters.get(adapter))
and (adapter_type := adapter_details.get("adapter_type"))
):
if adapter_type == "usb":
scanner_type = HaScannerType.USB
elif adapter_type == "uart":
scanner_type = HaScannerType.UART
self.details = HaScannerDetails(
source=self.source,
connectable=self.connectable,
name=self.name,
adapter=self.adapter,
scanner_type=scanner_type,
)
self._previous_service_info: dict[str, BluetoothServiceInfoBleak] = {}
# Scanners only care about connectable devices. The manager
# will handle taking care of availability for non-connectable devices
self._expire_seconds = CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
self._details: dict[str, str | HaBluetoothConnector] = {"source": source}
self._cancel_track: asyncio.TimerHandle | None = None
self._connect_failures: dict[str, int] = {}
self._connect_in_progress: dict[str, int] = {}
self._connect_completed_total: int = 0
self._connect_failed_total: int = 0
self._last_connect_completed_time: float = 0.0
def _on_start_success(self) -> None:
"""
Called when the scanner successfully starts.
Notifies the manager that this scanner has started.
"""
if self._manager:
self._manager.on_scanner_start(self)
def _clear_connection_history(self) -> None:
"""Clear the connection history for a scanner."""
self._connect_failures.clear()
self._connect_in_progress.clear()
self._connect_completed_total = 0
self._connect_failed_total = 0
self._last_connect_completed_time = 0.0
def _finished_connecting(self, address: str, connected: bool) -> None:
"""Finished connecting."""
self._remove_connecting(address)
if connected:
self._connect_completed_total += 1
self._last_connect_completed_time = monotonic_time_coarse()
self._clear_connect_failure(address)
else:
self._connect_failed_total += 1
self._add_connect_failure(address)
def _increase_count(self, target: dict[str, int], address: str) -> None:
"""Increase the reference count."""
if address in target:
target[address] += 1
else:
target[address] = 1
def _add_connect_failure(self, address: str) -> None:
"""Add a connect failure."""
self._increase_count(self._connect_failures, address)
def _add_connecting(self, address: str) -> None:
"""Add a connecting."""
self._increase_count(self._connect_in_progress, address)
# Clear timing collection data when scanner pauses for connection
# to prevent collecting invalid advertising interval data
self._manager._advertisement_tracker.async_scanner_paused(self.source)
def _remove_connecting(self, address: str) -> None:
"""Remove a connecting."""
if address not in self._connect_in_progress:
_LOGGER.warning(
"Removing a non-existing connecting %s %s", self.name, address
)
return
self._connect_in_progress[address] -= 1
if not self._connect_in_progress[address]:
del self._connect_in_progress[address]
def _clear_connect_failure(self, address: str) -> None:
"""Clear a connect failure."""
self._connect_failures.pop(address, None)
def get_allocations(self) -> Allocations | None:
"""
Get current connection slot allocations for this scanner.
Returns:
Allocations object with free/limit/allocated info, or None if not available.
Note:
Subclasses should override this method to provide their allocation info.
For local adapters, this will be overridden in HaScanner to query
BleakSlotManager.
For remote scanners, they should override to return their own tracking.
"""
return None
def _score_connection_paths(
self, rssi_diff: _int, scanner_device: BluetoothScannerDevice
) -> float:
"""Score the connection paths considering slot availability."""
address = scanner_device.ble_device.address
score = scanner_device.advertisement.rssi or NO_RSSI_VALUE
scanner_connections_in_progress = len(self._connect_in_progress)
previous_failures = self._connect_failures.get(address, 0)
# Use a minimum rssi_diff of 1 to ensure penalties are meaningful
# even when scanners have identical RSSI
effective_rssi_diff = max(rssi_diff, 1)
# Penalize scanners with connections in progress
if scanner_connections_in_progress:
# Very large penalty for multiple connections in progress
# to avoid overloading the adapter
score -= effective_rssi_diff * scanner_connections_in_progress * 1.01
# Penalize based on previous failures
if previous_failures:
score -= effective_rssi_diff * previous_failures * 0.51
# Consider connection slot availability
allocation = self.get_allocations()
if allocation and allocation.slots > 0:
if allocation.free == 0:
# No slots available - return NO_RSSI_VALUE to indicate unavailable
return NO_RSSI_VALUE
if allocation.free == 1:
# Last slot available - small penalty to prefer adapters with more slots
score -= effective_rssi_diff * 0.76
return score
def _connections_in_progress(self) -> int:
"""Return if the connection is in progress."""
in_progress = 0
for count in self._connect_in_progress.values():
in_progress += count
return in_progress
def _connection_failures(self, address: str) -> int:
"""Return the number of failures."""
return self._connect_failures.get(address, 0)
def connections_in_progress(self) -> int:
"""
Return the number of per-address connection attempts in progress.
This sums the in-flight connect attempts tracked per address; it is a
different counter from ``connecting_count`` (the scanning-pause counter).
"""
return self._connections_in_progress()
def connection_failures(self, address: str) -> int:
"""Return the number of failed connection attempts for an address."""
return self._connection_failures(address)
@property
def connecting_count(self) -> int:
"""
Return the number of connections currently pausing scanning.
This is the scanning-pause counter incremented for the duration of the
``connecting()`` context manager; while it is non-zero ``scanning`` is
False. It is distinct from ``connections_in_progress()``, which counts
per-address connect attempts.
"""
return self._connecting
def time_since_last_detection(self) -> float:
"""Return the time since the last detection."""
return monotonic_time_coarse() - self._last_detection
@property
def adapter_idx(self) -> int | None:
"""Return the adapter index if this is an hci adapter, None otherwise."""
if self.adapter and self.adapter.startswith("hci"):
return int(self.adapter.removeprefix("hci"))
return None
def async_setup(self) -> CALLBACK_TYPE:
"""Set up the scanner."""
self._loop = asyncio.get_running_loop()
self._schedule_expire_devices()
return self._unsetup
def _async_stop_scanner_watchdog(self) -> None:
"""Stop the scanner watchdog."""
if self._cancel_watchdog:
self._cancel_watchdog.cancel()
self._cancel_watchdog = None
def _async_setup_scanner_watchdog(self) -> None:
"""If something has restarted or updated, we need to restart the scanner."""
self._start_time = self._last_detection = monotonic_time_coarse()
if not self._cancel_watchdog:
self._schedule_watchdog()
def _schedule_watchdog(self) -> None:
"""Schedule the watchdog."""
loop = self._loop
if TYPE_CHECKING:
assert loop is not None
self._cancel_watchdog = loop.call_at(
loop.time() + SCANNER_WATCHDOG_INTERVAL_SECONDS,
self._async_call_scanner_watchdog,
)
@final
def _async_call_scanner_watchdog(self) -> None:
"""Call the scanner watchdog and schedule the next one."""
self._async_scanner_watchdog()
self._schedule_watchdog()
def _async_watchdog_triggered(self) -> bool:
"""Check if the watchdog has been triggered."""
time_since_last_detection = self.time_since_last_detection()
_LOGGER.debug(
"%s: Scanner watchdog time_since_last_detection: %s",
self.name,
time_since_last_detection,
)
return time_since_last_detection > SCANNER_WATCHDOG_TIMEOUT
def _async_scanner_watchdog(self) -> None:
"""
Check if the scanner is running.
Override this method if you need to do something else when the watchdog
is triggered.
"""
if self._async_watchdog_triggered():
_LOGGER.debug(
(
"%s: Bluetooth scanner has gone quiet for %ss, check logs on the"
" scanner device for more information"
),
self.name,
self.time_since_last_detection(),
)
self.scanning = False
return
self.scanning = not self._connecting
def _unsetup(self) -> None:
"""Unset up the scanner."""
self._cancel_expire_devices()
@contextmanager
def connecting(self) -> Generator[None, None, None]:
"""Context manager to track connecting state."""
self._connecting += 1
self.scanning = not self._connecting
try:
yield
finally:
self._connecting -= 1
self.scanning = not self._connecting
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
raise NotImplementedError
@property
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Return a list of discovered devices and their advertisement data."""
raise NotImplementedError
@property
def discovered_addresses(self) -> Iterable[str]:
"""Return an iterable of discovered devices."""
raise NotImplementedError
def get_discovered_device_advertisement_data(
self, address: str
) -> tuple[BLEDevice, AdvertisementData] | None:
"""Return the advertisement data for a discovered device."""
raise NotImplementedError
async def async_diagnostics(self) -> dict[str, Any]:
"""Return diagnostic information about the scanner."""
device_adv_datas = self.discovered_devices_and_advertisement_data.values()
return {
"name": self.name,
"connectable": self.connectable,
"start_time": self._start_time,
"source": self.source,
"scanning": self.scanning,
"requested_mode": self.requested_mode,
"current_mode": self.current_mode,
"type": self.__class__.__name__,
"last_detection": self._last_detection,
"monotonic_time": monotonic_time_coarse(),
"connect_in_progress": dict(self._connect_in_progress),
"connect_failures": dict(self._connect_failures),
"connect_completed_total": self._connect_completed_total,
"connect_failed_total": self._connect_failed_total,
"last_connect_completed_time": self._last_connect_completed_time,
"discovered_devices_and_advertisement_data": [
{
"name": device.name,
"address": device.address,
"rssi": advertisement_data.rssi,
"advertisement_data": advertisement_data,
"details": device.details,
}
for device, advertisement_data in device_adv_datas
],
}
def restore_discovered_devices(
self, history: DiscoveredDeviceAdvertisementData
) -> None:
"""Restore discovered devices from a previous run."""
discovered_device_timestamps = history.discovered_device_timestamps
self._previous_service_info = {
address: BluetoothServiceInfoBleak(
device.name or address,
address,
adv.rssi,
adv.manufacturer_data,
adv.service_data,
adv.service_uuids,
self.source,
device,
adv,
self.connectable,
discovered_device_timestamps[address],
adv.tx_power,
history.discovered_device_raw.get(address),
)
for address, (
device,
adv,
) in history.discovered_device_advertisement_datas.items()
}
# Expire anything that is too old
self._async_expire_devices()
# Seed the cross-scanner name cache with each restored entry so that
# names learned by an active scanner in a previous run are immediately
# available to passive scanners on restart, before any active scanner
# has had a chance to re-observe them.
for address, info in self._previous_service_info.items():
self._manager.seed_name_cache(address, info.name)
def serialize_discovered_devices(
self,
) -> DiscoveredDeviceAdvertisementData:
"""Serialize discovered devices to be stored."""
return DiscoveredDeviceAdvertisementData(
self.connectable,
self._expire_seconds,
self._build_discovered_device_advertisement_datas(),
self._build_discovered_device_timestamps(),
self._build_discovered_device_raw(),
)
@property
def _discovered_device_timestamps(self) -> dict[str, float]:
"""Return a dict of discovered device timestamps."""
warnings.warn(
"BaseHaScanner._discovered_device_timestamps is deprecated "
"and will be removed in a future version of habluetooth, use "
"BaseHaScanner.discovered_device_timestamps instead",
FutureWarning,
stacklevel=2,
)
return self._build_discovered_device_timestamps()
@property
def discovered_device_timestamps(self) -> dict[str, float]:
"""Return a dict of discovered device timestamps."""
return self._build_discovered_device_timestamps()
def _build_discovered_device_advertisement_datas(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Return a list of discovered devices and advertisement data."""
return {
address: (info.device, info._advertisement_internal())
for address, info in self._previous_service_info.items()
}
def _build_discovered_device_timestamps(self) -> dict[str, float]:
"""Return a dict of discovered device timestamps."""
return {
address: info.time for address, info in self._previous_service_info.items()
}
def _build_discovered_device_raw(self) -> dict[str, bytes | None]:
"""Return a dict of discovered device raw advertisement data."""
return {
address: info.raw for address, info in self._previous_service_info.items()
}
def _async_on_raw_advertisement(
self,
address: _str,
rssi: _int,
raw: _bytes,
details: dict[str, Any],
advertisement_monotonic_time: _float,
) -> None:
if (
prev_info := self._previous_service_info.get(address)
) is not None and prev_info.raw == raw:
# Raw advertisement data unchanged — skip parsing and merge
# logic, reuse the previous parsed data directly.
self.scanning = not self._connecting
self._last_detection = advertisement_monotonic_time
info = BluetoothServiceInfoBleak.__new__(BluetoothServiceInfoBleak)
info.device = prev_info.device
info.name = prev_info.name
info.manufacturer_data = prev_info.manufacturer_data
info.service_data = prev_info.service_data
info.service_uuids = prev_info.service_uuids
info.address = address
info.rssi = rssi
info.source = self.source
info._advertisement = None
info.connectable = self.connectable
info.time = advertisement_monotonic_time
info.tx_power = prev_info.tx_power
info.raw = prev_info.raw
self._previous_service_info[address] = info
self._manager._scanner_adv_received(info)
return
parsed = parse_advertisement_data_bytes(raw)
self._async_on_advertisement_internal(
address,
rssi,
parsed[0],
parsed[1],
parsed[2],
parsed[3],
parsed[4],
details,
advertisement_monotonic_time,
raw,
)
def _async_on_advertisement(
self,
address: _str,
rssi: _int,
local_name: _str | None,
service_uuids: list[str],
service_data: dict[str, bytes],
manufacturer_data: dict[int, bytes],
tx_power: _int | None,
details: dict[Any, Any],
advertisement_monotonic_time: _float,
) -> None:
self._async_on_advertisement_internal(
address,
rssi,
local_name,
service_uuids,
service_data,
manufacturer_data,
tx_power,
details,
advertisement_monotonic_time,
None,
)
def _async_on_advertisement_internal( # noqa: C901
self,
address: _str,
rssi: _int,
local_name: _str | None,
service_uuids: list[str],
service_data: dict[str, bytes],
manufacturer_data: dict[int, bytes],
tx_power: _int | None,
details: dict[Any, Any],
advertisement_monotonic_time: _float,
raw: _bytes | None,
) -> None:
"""Call the registered callback."""
self.scanning = not self._connecting
self._last_detection = advertisement_monotonic_time
info = BluetoothServiceInfoBleak.__new__(BluetoothServiceInfoBleak)
if (prev_info := self._previous_service_info.get(address)) is None:
# We expect this is the rare case and since py3.11+ has
# near zero cost try on success, and we can avoid .get()
# which is slower than [] we use the try/except pattern.
info.device = BLEDevice(
address,
local_name,
{**self._details, **details},
)
info.manufacturer_data = manufacturer_data
info.service_data = service_data
info.service_uuids = service_uuids
info.name = local_name or address
else:
# Merge the new data with the old data
# to function the same as BlueZ which
# merges the dicts on PropertiesChanged
info.device = prev_info.device
prev_name = prev_info.device.name
#
# Bleak updates the BLEDevice via create_or_update_device.
# We need to do the same to ensure integrations that already
# have the BLEDevice object get the updated details when they
# change.
#
# https://github.com/hbldh/bleak/blob/222618b7747f0467dbb32bd3679f8cfaa19b1668/bleak/backends/scanner.py#L203
if prev_name is not None and (
prev_name is local_name
or not local_name
or len(prev_name) > len(local_name)
):
info.name = prev_name
else:
info.device.name = local_name
info.name = local_name or address
has_service_uuids = bool(service_uuids)
if (
has_service_uuids
and service_uuids is not prev_info.service_uuids
and service_uuids != prev_info.service_uuids
):
info.service_uuids = list({*service_uuids, *prev_info.service_uuids})
elif not has_service_uuids:
info.service_uuids = prev_info.service_uuids
else:
info.service_uuids = service_uuids
has_service_data = bool(service_data)
if has_service_data and service_data is not prev_info.service_data:
for uuid, sub_value in service_data.items():
if (
super_value := prev_info.service_data.get(uuid)
) is None or super_value != sub_value:
info.service_data = {
**prev_info.service_data,
**service_data,
}
break
else:
info.service_data = prev_info.service_data
elif not has_service_data:
info.service_data = prev_info.service_data
else:
info.service_data = service_data
has_manufacturer_data = bool(manufacturer_data)
if (
has_manufacturer_data
and manufacturer_data is not prev_info.manufacturer_data
):
for id_, sub_value in manufacturer_data.items():
if (
super_value := prev_info.manufacturer_data.get(id_)
) is None or super_value != sub_value:
info.manufacturer_data = {
**prev_info.manufacturer_data,
**manufacturer_data,
}
break
else:
info.manufacturer_data = prev_info.manufacturer_data
elif not has_manufacturer_data:
info.manufacturer_data = prev_info.manufacturer_data
else:
info.manufacturer_data = manufacturer_data
info.address = address
info.rssi = rssi
info.source = self.source
info._advertisement = None
info.connectable = self.connectable
info.time = advertisement_monotonic_time
info.tx_power = tx_power
info.raw = raw
self._previous_service_info[address] = info
self._manager._scanner_adv_received(info)
def _async_expire_devices(self) -> None:
"""Expire old devices."""
now = monotonic_time_coarse()
expired = [
address
for address, info in self._previous_service_info.items()
if now - info.time > self._expire_seconds
]
for address in expired:
del self._previous_service_info[address]
def _cancel_expire_devices(self) -> None:
"""Cancel the expiration of old devices."""
if self._cancel_track:
self._cancel_track.cancel()
self._cancel_track = None
def _schedule_expire_devices(self) -> None:
"""Schedule the expiration of old devices."""
loop = self._loop
if TYPE_CHECKING:
assert loop is not None
self._cancel_expire_devices()
self._cancel_track = loop.call_at(
loop.time() + 30, self._async_expire_devices_schedule_next
)
def _async_expire_devices_schedule_next(self) -> None:
"""Expire old devices and schedule the next expiration."""
self._async_expire_devices()
self._schedule_expire_devices()
def set_requested_mode(self, mode: BluetoothScanningMode | None) -> None:
"""Set the requested scanning mode and notify the manager."""
if self.requested_mode != mode:
self.requested_mode = mode
self._manager.scanner_mode_changed(self)
def set_current_mode(self, mode: BluetoothScanningMode | None) -> None:
"""Set the current scanning mode and notify the manager."""
if self.current_mode != mode:
self.current_mode = mode
self._manager.scanner_mode_changed(self)
async def async_request_active_window(self, duration: float) -> bool:
"""
Run an active scan for ``duration`` seconds, then restore prior mode.
Default no-op returning False. Subclasses that can flip the
underlying adapter / proxy into active scanning on demand
should override; ``True`` indicates the override actually
flipped the radio, ``False`` that the request was ignored.
The auto scheduler branches on the return value: per-device
``_due_at`` entries still advance by ``scan_interval``
regardless (to avoid busy-looping a stuck scanner), but a
``True`` is what advances ``_sweep_last_completed`` (satisfies
the 12 h rediscovery floor) and counts toward the on-demand
sweep's "at least one window opened" predicate that lets the
leader's caller actually wait for the window. A ``False`` /
raised result reverts the on-demand pre-bumped
``_window_end`` so the worker is not locked out of its own
ticks for a window that never opened. Implementations should
therefore return ``True`` only when the radio actually
entered active mode for the requested duration.
"""
_LOGGER.debug(
"%s: scanner does not support on-demand active windows", self.name
)
return False
class BaseHaRemoteScanner(BaseHaScanner):
"""Base class for a high availability remote BLE scanner."""
def _unsetup(self) -> None:
"""Unset up the scanner."""
super()._unsetup()
self._async_stop_scanner_watchdog()
def async_setup(self) -> CALLBACK_TYPE:
"""Set up the scanner."""
super().async_setup()
self._async_setup_scanner_watchdog()
return self._unsetup
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
infos = self._previous_service_info.values()
return [device_advertisement_data.device for device_advertisement_data in infos]
@property
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Return a list of discovered devices and advertisement data."""
return self._build_discovered_device_advertisement_datas()
@property
def discovered_addresses(self) -> Iterable[str]:
"""Return an iterable of discovered devices."""
return self._previous_service_info
def get_discovered_device_advertisement_data(
self, address: str
) -> tuple[BLEDevice, AdvertisementData] | None:
"""Return the advertisement data for a discovered device."""
if (info := self._previous_service_info.get(address)) is not None:
return info.device, info.advertisement
return None
async def async_diagnostics(self) -> dict[str, Any]:
"""Return diagnostic information about the scanner."""
now = monotonic_time_coarse()
discovered_device_timestamps = self._build_discovered_device_timestamps()
return await super().async_diagnostics() | {
"discovered_device_timestamps": discovered_device_timestamps,
"raw_advertisement_data": {
address: info.raw
for address, info in self._previous_service_info.items()
},
"time_since_last_device_detection": {
address: now - timestamp
for address, timestamp in discovered_device_timestamps.items()
},
}
Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/central_manager.py 0000664 0000000 0000000 00000001235 15211177045 0026601 0 ustar 00root root 0000000 0000000 """Central manager for bluetooth."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .manager import BluetoothManager
class CentralBluetoothManager:
"""Central Bluetooth Manager."""
manager: BluetoothManager | None = None
def get_manager() -> BluetoothManager:
"""Get the BluetoothManager."""
if CentralBluetoothManager.manager is None:
msg = "BluetoothManager has not been set"
raise RuntimeError(msg)
return CentralBluetoothManager.manager
def set_manager(manager: BluetoothManager) -> None:
"""Set the BluetoothManager."""
CentralBluetoothManager.manager = manager
Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/channels/ 0000775 0000000 0000000 00000000000 15211177045 0024677 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/channels/__init__.py 0000664 0000000 0000000 00000000000 15211177045 0026776 0 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/channels/bluez.pxd 0000664 0000000 0000000 00000003007 15211177045 0026535 0 ustar 00root root 0000000 0000000
import cython
from ..scanner cimport HaScanner
cdef bint TYPE_CHECKING
cdef unsigned short DEVICE_FOUND
cdef unsigned short ADV_MONITOR_DEVICE_FOUND
cdef unsigned short MGMT_OP_GET_CONNECTIONS
cdef unsigned short MGMT_OP_LOAD_CONN_PARAM
cdef unsigned short MGMT_EV_CMD_COMPLETE
cdef unsigned short MGMT_EV_CMD_STATUS
cdef class BluetoothMGMTProtocol:
cdef public object transport
cdef object connection_made_future
cdef bytes _buffer
cdef unsigned int _buffer_len
cdef unsigned int _pos
cdef dict _scanners
cdef object _on_connection_lost
cdef object _is_shutting_down
cdef dict _pending_commands
cdef public object _sock
@cython.locals(bytes_data=bytes)
cdef void _add_to_buffer(self, object data) except *
@cython.locals(end_of_frame_pos="unsigned int", cstr="const unsigned char *")
cdef void _remove_from_buffer(self) except *
@cython.locals(
header="const unsigned char *",
event_code="unsigned short",
controller_idx="unsigned short",
param_len="unsigned short",
rssi="short",
flags="unsigned int",
data="bytes",
parse_offset="unsigned short",
scanner=HaScanner,
opcode="unsigned short",
status="unsigned char",
param_offset="unsigned short",
param_count="unsigned short"
)
cpdef void data_received(self, object data) except *
cdef void _handle_load_conn_param_response(
self, unsigned char status, unsigned short controller_idx
) except *
Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/channels/bluez.py 0000664 0000000 0000000 00000055543 15211177045 0026406 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import asyncio
import logging
from asyncio import timeout as asyncio_timeout
from contextlib import asynccontextmanager
from struct import Struct
from typing import TYPE_CHECKING, cast
from btsocket import btmgmt_socket
from btsocket.btmgmt_socket import BluetoothSocketError
from ..const import (
FAST_CONN_LATENCY,
FAST_CONN_TIMEOUT,
FAST_MAX_CONN_INTERVAL,
FAST_MIN_CONN_INTERVAL,
MEDIUM_CONN_LATENCY,
MEDIUM_CONN_TIMEOUT,
MEDIUM_MAX_CONN_INTERVAL,
MEDIUM_MIN_CONN_INTERVAL,
ConnectParams,
)
if TYPE_CHECKING:
import socket
from collections.abc import AsyncIterator, Callable
from ..scanner import HaScanner
_LOGGER = logging.getLogger(__name__)
_int = int
_bytes = bytes
# Everything is little endian
HEADER_SIZE = 6
# Header is event_code (2 bytes), controller_idx (2 bytes), param_len (2 bytes)
DEVICE_FOUND = 0x0012
ADV_MONITOR_DEVICE_FOUND = 0x002F
# Management commands
MGMT_OP_GET_CONNECTIONS = 0x0015
MGMT_OP_LOAD_CONN_PARAM = 0x0035
# Management events
MGMT_EV_CMD_COMPLETE = 0x0001
MGMT_EV_CMD_STATUS = 0x0002
# Pre-compiled struct formats for performance
COMMAND_HEADER = Struct(" None:
"""Set the future result if not done."""
if future is not None and not future.done():
future.set_result(None)
class BluetoothMGMTProtocol:
"""Bluetooth MGMT protocol."""
def __init__(
self,
connection_made_future: asyncio.Future[None],
scanners: dict[int, HaScanner],
on_connection_lost: Callable[[], None],
is_shutting_down: Callable[[], bool],
sock: socket.socket,
) -> None:
"""Initialize the protocol."""
self.transport: asyncio.Transport | None = None
self.connection_made_future = connection_made_future
self._buffer: bytes | None = None
self._buffer_len = 0
self._pos = 0
self._scanners = scanners
self._on_connection_lost = on_connection_lost
self._is_shutting_down = is_shutting_down
self._pending_commands: dict[int, asyncio.Future[tuple[int, bytes]]] = {}
self._sock = sock
def connection_made(self, transport: asyncio.BaseTransport) -> None:
"""Handle connection made."""
_set_future_if_not_done(self.connection_made_future)
self.transport = cast("asyncio.Transport", transport)
def _write_to_socket(self, data: bytes) -> None:
"""
Write data directly to the socket, bypassing asyncio transport.
This works around a kernel bug where sendto() on Bluetooth management
sockets returns 0 instead of the number of bytes sent on some platforms
(e.g., Odroid M1 with kernel 6.12.43). When asyncio sees 0, it thinks
the send failed and retries forever.
Since mgmt sockets are SOCK_RAW, sends are atomic - either the entire
packet is sent or nothing is sent.
"""
try:
n = self._sock.send(data)
# On buggy kernels, n might be 0 even though the data was sent
# We treat 0 as success for mgmt sockets
if n == 0 and len(data) > 0:
# Kernel bug: returned 0 but data was actually sent
_LOGGER.debug(
"Bluetooth mgmt socket returned 0 for %d bytes (kernel bug fix)",
len(data),
)
except Exception:
_LOGGER.exception("Failed to write to mgmt socket")
raise
@asynccontextmanager
async def command_response(
self, opcode: int
) -> AsyncIterator[asyncio.Future[tuple[int, bytes]]]:
"""
Context manager for handling command responses.
Usage:
async with protocol.command_response(opcode) as future:
transport.write(command)
status, data = await future
"""
future: asyncio.Future[tuple[int, bytes]] = (
asyncio.get_running_loop().create_future()
)
self._pending_commands[opcode] = future
try:
yield future
finally:
# Clean up if the future wasn't resolved
self._pending_commands.pop(opcode, None)
def _add_to_buffer(self, data: bytes | bytearray | memoryview) -> None:
"""Add data to the buffer."""
# Protractor sends a bytearray, so we need to convert it to bytes
# https://github.com/esphome/issues/issues/5117
# type(data) should not be isinstance(data, bytes) because we want to
# to explicitly check for bytes and not for subclasses of bytes
bytes_data = bytes(data) if type(data) is not bytes else data
if self._buffer_len == 0:
# This is the best case scenario, we don't have to copy the data
# and can just use the buffer directly. This is the most common
# case as well.
self._buffer = bytes_data
else:
if TYPE_CHECKING:
assert self._buffer is not None, "Buffer should be set"
# This is the worst case scenario, we have to copy the bytes_data
# and can't just use the buffer directly. This is also very
# uncommon since we usually read the entire frame at once.
self._buffer += bytes_data
self._buffer_len += len(bytes_data)
def _remove_from_buffer(self) -> None:
"""Remove data from the buffer."""
end_of_frame_pos = self._pos
self._buffer_len -= end_of_frame_pos
if self._buffer_len == 0:
# This is the best case scenario, we can just set the buffer to None
# and don't have to copy the data. This is the most common case as well.
self._buffer = None
return
if TYPE_CHECKING:
assert self._buffer is not None, "Buffer should be set"
# This is the worst case scenario, we have to copy the data
# and can't just use the buffer directly. This should only happen
# when we read multiple frames at once because the event loop
# is blocked and we cannot pull the data out of the buffer fast enough.
cstr = self._buffer
# Important: we must use the explicit length for the slice
# since Cython will stop at any '\0' character if we don't
self._buffer = cstr[end_of_frame_pos : self._buffer_len + end_of_frame_pos]
def data_received(self, data: _bytes) -> None: # noqa: C901
"""Handle data received."""
self._add_to_buffer(data)
while self._buffer_len >= 6:
if TYPE_CHECKING:
assert self._buffer is not None, "Buffer should be set"
self._pos = 6
header = self._buffer
event_code = header[0] | (header[1] << 8)
controller_idx = header[2] | (header[3] << 8)
param_len = header[4] | (header[5] << 8)
if self._buffer_len < self._pos + param_len:
# We don't have the entire frame yet, so we need to wait
# for more data to arrive.
return
self._pos += param_len
if event_code == DEVICE_FOUND:
parse_offset = 6
elif event_code == ADV_MONITOR_DEVICE_FOUND:
parse_offset = 8
elif event_code in {MGMT_EV_CMD_COMPLETE, MGMT_EV_CMD_STATUS}:
# Handle management command responses
if param_len >= 3:
opcode = header[6] | (header[7] << 8)
status = header[8]
if opcode == MGMT_OP_LOAD_CONN_PARAM:
self._handle_load_conn_param_response(status, controller_idx)
elif (
opcode == MGMT_OP_GET_CONNECTIONS
and opcode in self._pending_commands
):
# Handle GET_CONNECTIONS response for capability check
future = self._pending_commands.pop(opcode)
if not future.done():
# Return status and any response data
response_data = (
header[9 : self._pos] if param_len > 3 else b""
)
future.set_result((status, response_data))
self._remove_from_buffer()
continue
else:
self._remove_from_buffer()
continue
address = header[parse_offset : parse_offset + 6]
address_type = header[parse_offset + 6]
rssi = header[parse_offset + 7]
if rssi > 128:
rssi -= 256
flags = (
header[parse_offset + 8]
| (header[parse_offset + 9] << 8)
| (header[parse_offset + 10] << 16)
| (header[parse_offset + 11] << 24)
)
# Skip AD_Data_Length (2 bytes) at parse_offset+12 and +13
data = header[parse_offset + 14 : self._pos]
self._remove_from_buffer()
if (scanner := self._scanners.get(controller_idx)) is not None:
# We have a scanner for this controller, so we can
# pass the data to it.
scanner._async_on_raw_bluez_advertisement(
address,
address_type,
rssi,
flags,
data,
)
def _handle_load_conn_param_response(
self, status: _int, controller_idx: _int
) -> None:
"""Handle MGMT_OP_LOAD_CONN_PARAM response."""
if status != 0:
_LOGGER.warning(
"hci%u: Failed to load conn params: status=%d",
controller_idx,
status,
)
else:
_LOGGER.debug(
"hci%u: Connection parameters loaded successfully",
controller_idx,
)
def connection_lost(self, exc: Exception | None) -> None:
"""Handle connection lost."""
# Only suppress warnings during shutdown, not info messages
if exc:
if not self._is_shutting_down():
_LOGGER.warning("Bluetooth management socket connection lost: %s", exc)
else:
_LOGGER.info("Bluetooth management socket connection closed")
self.transport = None
self._on_connection_lost()
class MGMTBluetoothCtl:
"""Class to control interfaces using the BlueZ management API."""
def __init__(self, timeout: float, scanners: dict[int, HaScanner]) -> None:
"""Initialize the control class."""
# Internal state
self.timeout = timeout
self.protocol: BluetoothMGMTProtocol | None = None
self.sock: socket.socket | None = None
self.scanners = scanners
self._reconnect_task: asyncio.Task[None] | None = None
self._on_connection_lost_future: asyncio.Future[None] | None = None
self._shutting_down = False
def close(self) -> None:
"""Close the management interface."""
self._shutting_down = True
if self._reconnect_task:
self._reconnect_task.cancel()
if self.protocol and self.protocol.transport:
self.protocol.transport.close()
self.protocol = None
btmgmt_socket.close(self.sock)
def _on_connection_lost(self) -> None:
"""Handle connection lost."""
if self._shutting_down:
_LOGGER.debug("Bluetooth management socket connection lost during shutdown")
else:
_LOGGER.debug("Bluetooth management socket connection lost, reconnecting")
_set_future_if_not_done(self._on_connection_lost_future)
self._on_connection_lost_future = None
async def reconnect_task(self) -> None:
"""Monitor the connection and reconnect if needed."""
while not self._shutting_down:
if self._on_connection_lost_future:
await self._on_connection_lost_future
if self._shutting_down:
break # type: ignore[unreachable]
_LOGGER.debug("Reconnecting to Bluetooth management socket")
try:
await self._establish_connection()
except CONNECTION_ERRORS:
_LOGGER.debug("Bluetooth management socket connection timed out")
# If we get a timeout, we should try to reconnect
# after a short delay
await asyncio.sleep(1)
async def _establish_connection(self) -> None:
"""Establish a connection to the Bluetooth management socket."""
_LOGGER.debug("Establishing Bluetooth management socket connection")
self.sock = btmgmt_socket.open()
loop = asyncio.get_running_loop()
connection_made_future: asyncio.Future[None] = loop.create_future()
try:
async with asyncio_timeout(self.timeout):
# _create_connection_transport accessed
# directly to avoid SOCK_STREAM check
# see https://bugs.python.org/issue38285
_, protocol = await loop._create_connection_transport( # type: ignore[attr-defined]
self.sock,
lambda: BluetoothMGMTProtocol(
connection_made_future,
self.scanners,
self._on_connection_lost,
lambda: self._shutting_down,
self.sock,
),
None,
None,
)
await connection_made_future
except TimeoutError:
btmgmt_socket.close(self.sock)
raise
_LOGGER.debug("Bluetooth management socket connection established")
self.protocol = cast("BluetoothMGMTProtocol", protocol)
self._on_connection_lost_future = loop.create_future()
def _has_mgmt_capabilities_from_status(self, status: int) -> bool:
"""
Check if a MGMT command status indicates we have capabilities.
Returns True if we have capabilities, False otherwise.
Status codes:
- 0x00 = Success (we have permissions)
- 0x01 = Unknown Command (might happen if kernel is too old)
- 0x0D = Invalid Parameters
- 0x10 = Not Powered (for some operations)
- 0x11 = Invalid Index (adapter doesn't exist but we have permissions)
- 0x14 = Permission Denied (missing NET_ADMIN/NET_RAW)
"""
if status == 0x14: # Permission denied
_LOGGER.debug(
"MGMT capability check failed with permission denied - "
"missing NET_ADMIN/NET_RAW"
)
return False
if status in (0x00, 0x11): # Success or Invalid Index
_LOGGER.debug("MGMT capability check passed (status: %#x)", status)
return True
# Unknown status - log it and assume no permissions to be safe
_LOGGER.debug(
"MGMT capability check returned unexpected status %#x - "
"assuming missing permissions",
status,
)
return False
async def _check_capabilities(self) -> bool:
"""
Check if we have the necessary capabilities to use MGMT.
Returns True if we have capabilities, False otherwise.
"""
if not self.protocol or not self.protocol.transport:
return False
# Try GET_CONNECTIONS for adapter 0 - this is a read-only command
# that requires NET_ADMIN privileges but doesn't change any state
header = COMMAND_HEADER_PACK(
MGMT_OP_GET_CONNECTIONS, # opcode
0, # controller index 0 (hci0)
0, # no parameters
)
try:
return await self._do_mgmt_op_get_connections(header)
except (TimeoutError, OSError) as ex:
_LOGGER.debug(
"MGMT capability check failed: %s - likely missing NET_ADMIN/NET_RAW",
ex,
)
return False
async def _do_mgmt_op_get_connections(self, header: bytes) -> bool:
"""Send a MGMT_OP_GET_CONNECTIONS command and check capabilities."""
if TYPE_CHECKING:
assert self.protocol is not None
assert self.protocol.transport is not None
async with self.protocol.command_response(
MGMT_OP_GET_CONNECTIONS
) as response_future:
self.protocol._write_to_socket(header)
# Wait for response with timeout
async with asyncio_timeout(5.0):
status, _ = await response_future
return self._has_mgmt_capabilities_from_status(status)
async def setup(self) -> None:
"""Set up management interface."""
await self._establish_connection()
# Check if we actually have the capabilities to use MGMT
if not await self._check_capabilities():
# Mark as shutting down to prevent reconnection attempts
self._shutting_down = True
# Close the connection and raise an error to trigger fallback
if self.protocol and self.protocol.transport:
self.protocol.transport.close()
btmgmt_socket.close(self.sock)
msg = "Missing NET_ADMIN/NET_RAW capabilities for Bluetooth management"
raise PermissionError(msg)
self._reconnect_task = asyncio.create_task(self.reconnect_task())
def load_conn_params(
self,
adapter_idx: int,
address: str,
address_type: int,
params: ConnectParams,
) -> bool:
"""
Load connection parameters for a specific device.
Args:
adapter_idx: Adapter index (e.g., 0 for hci0)
address: Device MAC address (e.g., "AA:BB:CC:DD:EE:FF")
address_type: BDADDR_LE_PUBLIC (1) or BDADDR_LE_RANDOM (2)
params: Connection parameters to load (ConnectParams.FAST or
ConnectParams.MEDIUM)
Returns:
True if command was sent successfully
"""
if not self.protocol or not self.protocol.transport:
_LOGGER.error("Cannot load conn params: no connection")
return False
# Parse MAC address
addr_bytes = bytes.fromhex(address.replace(":", ""))
if len(addr_bytes) != 6:
_LOGGER.error("Invalid MAC address: %s", address)
return False
# Build command structure (C definitions from BlueZ mgmt-api.txt):
# struct mgmt_cp_load_conn_param {
# uint16_t param_count;
# struct mgmt_conn_param params[0];
# };
# struct mgmt_conn_param {
# struct mgmt_addr_info addr;
# uint16_t min_interval;
# uint16_t max_interval;
# uint16_t latency;
# uint16_t timeout;
# };
# struct mgmt_addr_info {
# bdaddr_t bdaddr;
# uint8_t type;
# };
# Get the appropriate connection parameters based on the enum
if params is ConnectParams.FAST:
min_interval = FAST_MIN_CONN_INTERVAL
max_interval = FAST_MAX_CONN_INTERVAL
latency = FAST_CONN_LATENCY
timeout = FAST_CONN_TIMEOUT
else: # params is ConnectParams.MEDIUM:
min_interval = MEDIUM_MIN_CONN_INTERVAL
max_interval = MEDIUM_MAX_CONN_INTERVAL
latency = MEDIUM_CONN_LATENCY
timeout = MEDIUM_CONN_TIMEOUT
# Pack the command
cmd_data = CONN_PARAM_PACK(
1, # param_count = 1
addr_bytes[::-1], # bdaddr (reversed for little endian)
address_type, # address type
min_interval, # min_interval
max_interval, # max_interval
latency, # latency
timeout, # timeout
)
# Send the command
try:
header = COMMAND_HEADER_PACK(
MGMT_OP_LOAD_CONN_PARAM, # opcode
adapter_idx, # controller index
len(cmd_data), # parameter length
)
self.protocol._write_to_socket(header + cmd_data)
_LOGGER.debug(
"Loaded conn params for %s: interval=%d-%d, latency=%d, timeout=%d",
address,
min_interval,
max_interval,
latency,
timeout,
)
except Exception:
_LOGGER.exception("Failed to load conn params")
return False
else:
return True
def load_conn_params_explicit(
self,
adapter_idx: int,
address: str,
address_type: int,
min_interval: int,
max_interval: int,
latency: int,
timeout: int,
) -> bool:
"""
Load explicit connection parameters for a specific device.
Args:
adapter_idx: Adapter index (e.g., 0 for hci0)
address: Device MAC address (e.g., "AA:BB:CC:DD:EE:FF")
address_type: BDADDR_LE_PUBLIC (1) or BDADDR_LE_RANDOM (2)
min_interval: Minimum connection interval (units of 1.25ms)
max_interval: Maximum connection interval (units of 1.25ms)
latency: Connection latency (number of events)
timeout: Supervision timeout (units of 10ms)
Returns:
True if command was sent successfully
"""
if not self.protocol or not self.protocol.transport:
_LOGGER.error("Cannot load conn params: no connection")
return False
# Parse MAC address
addr_bytes = bytes.fromhex(address.replace(":", ""))
if len(addr_bytes) != 6:
_LOGGER.error("Invalid MAC address: %s", address)
return False
# Pack the command
cmd_data = CONN_PARAM_PACK(
1, # param_count = 1
addr_bytes[::-1], # bdaddr (reversed for little endian)
address_type, # address type
min_interval,
max_interval,
latency,
timeout,
)
# Send the command
try:
header = COMMAND_HEADER_PACK(
MGMT_OP_LOAD_CONN_PARAM, # opcode
adapter_idx, # controller index
len(cmd_data), # parameter length
)
self.protocol._write_to_socket(header + cmd_data)
_LOGGER.debug(
"Loaded explicit conn params for %s:"
" interval=%d-%d, latency=%d, timeout=%d",
address,
min_interval,
max_interval,
latency,
timeout,
)
except Exception:
_LOGGER.exception("Failed to load conn params")
return False
else:
return True
Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/const.py 0000664 0000000 0000000 00000011740 15211177045 0024607 0 ustar 00root root 0000000 0000000 """Constants."""
from __future__ import annotations
from collections.abc import Callable
from datetime import timedelta
from enum import Enum
from typing import Final
CALLBACK_TYPE = Callable[[], None]
SOURCE_LOCAL: Final = "local"
START_TIMEOUT = 15
STOP_TIMEOUT = 5
# The maximum time between advertisements for a device to be considered
# stale when the advertisement tracker cannot determine the interval.
#
# We have to set this quite high as we don't know
# when devices fall out of the ESPHome device (and other non-local scanners)'s
# stack like we do with BlueZ so its safer to assume its available
# since if it does go out of range and it is in range
# of another device the timeout is much shorter and it will
# switch over to using that adapter anyways.
#
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 60 * 15
# The maximum time between advertisements for a device to be considered
# stale when the advertisement tracker can determine the interval for
# connectable devices.
#
# BlueZ uses 180 seconds by default but we give it a bit more time
# to account for the esp32's bluetooth stack being a bit slower
# than BlueZ's.
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 195
# We must recover before we hit the 180s mark
# where the device is removed from the stack
# or the devices will go unavailable. Since
# we only check every 30s, we need this number
# to be
# 180s Time when device is removed from stack
# - 30s check interval
# - 30s scanner restart time * 2
#
SCANNER_WATCHDOG_TIMEOUT: Final = 90
# How often to check if the scanner has reached
# the SCANNER_WATCHDOG_TIMEOUT without seeing anything
SCANNER_WATCHDOG_INTERVAL: Final = timedelta(seconds=30)
UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5
# AUTO scanning mode: each scanner gets its first sweep
# AUTO_INITIAL_SWEEP_DELAY after joining, then every
# AUTO_REDISCOVERY_INTERVAL, serialized across scanners.
AUTO_INITIAL_SWEEP_DELAY: Final = 60 * 4
AUTO_REDISCOVERY_INTERVAL: Final = 60 * 60 * 12
AUTO_REDISCOVERY_SWEEP_DURATION: Final = 15.0
# Per-callback scan_duration is clamped into this range. The floor
# matches the validation in async_register_active_scan; the ceiling is
# the longest single ACTIVE flip we'll ever do for one device tick.
AUTO_WINDOW_MIN_DURATION: Final = 5.0
AUTO_WINDOW_MAX_DURATION: Final = 30.0
# Per-device entries due within AUTO_COALESCE_LOOKAHEAD of now are
# pulled into the current window so staggered registrations sync up
# instead of triggering back-to-back active flips. Must exceed
# AUTO_WINDOW_MAX_DURATION so a window can never outlive its
# lookahead; the slop absorbs loop.time drift between bucket
# collection and window open under a blocked event loop.
AUTO_COALESCE_LOOKAHEAD_SLOP: Final = 5.0
AUTO_COALESCE_LOOKAHEAD: Final = AUTO_WINDOW_MAX_DURATION + AUTO_COALESCE_LOOKAHEAD_SLOP
# Minimum values accepted by async_register_active_scan. Anything
# shorter would just churn the radio without giving the device time to
# respond on its scan response.
MIN_ACTIVE_SCAN_INTERVAL: Final = 60.0
MIN_ACTIVE_SCAN_DURATION: Final = 5.0
# Defaults used by async_register_active_scan when the caller does
# not specify a cadence. One 10s active window every 5 minutes per
# device covers the typical temperature/humidity/battery sensor case
# without burning the proxy's radio or the sensor's battery; an
# integration that genuinely needs faster updates can pass a smaller
# scan_interval explicitly.
DEFAULT_ACTIVE_SCAN_INTERVAL: Final = 300.0
DEFAULT_ACTIVE_SCAN_DURATION: Final = 10.0
# Default duration for an on-demand sweep triggered by
# BluetoothManager.async_request_active_scan (HA config-flow discovery).
# 10s gives every device on the bus a chance to advertise during the
# window without holding the caller too long.
DEFAULT_ON_DEMAND_SWEEP_DURATION: Final = 10.0
FAILED_ADAPTER_MAC = "00:00:00:00:00:00"
ADV_RSSI_SWITCH_THRESHOLD: Final = 16
# The switch threshold for the rssi value
# to switch to a different adapter for advertisements
# Note that this does not affect the connection
# selection that uses RSSI_SWITCH_THRESHOLD from
# bleak_retry_connector
# Connection parameter constants (units of 1.25ms for intervals)
# Fast connection parameters for initial connection and service discovery
FAST_MIN_CONN_INTERVAL: Final = 0x06 # 6 * 1.25ms = 7.5ms (BLE minimum)
FAST_MAX_CONN_INTERVAL: Final = 0x06 # 6 * 1.25ms = 7.5ms
FAST_CONN_LATENCY: Final = 0 # No latency for fast response
FAST_CONN_TIMEOUT: Final = 1000 # 1000 * 10ms = 10s
# Medium connection parameters for standard operation
# Balanced for stability with WiFi-based BLE proxies
MEDIUM_MIN_CONN_INTERVAL: Final = 0x07 # 7 * 1.25ms = 8.75ms
MEDIUM_MAX_CONN_INTERVAL: Final = 0x09 # 9 * 1.25ms = 11.25ms
MEDIUM_CONN_LATENCY: Final = 0 # No latency
MEDIUM_CONN_TIMEOUT: Final = 800 # 800 * 10ms = 8s
# Bluetooth address types
BDADDR_BREDR: Final = 0x00
BDADDR_LE_PUBLIC: Final = 0x01
BDADDR_LE_RANDOM: Final = 0x02
class ConnectParams(Enum):
"""Connection parameter presets."""
FAST = "fast"
MEDIUM = "medium"
Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/manager.pxd 0000664 0000000 0000000 00000010361 15211177045 0025234 0 ustar 00root root 0000000 0000000 import cython
from .advertisement_tracker cimport AdvertisementTracker
from .auto_scheduler cimport ActiveScanRequest, AutoScanScheduler
from .base_scanner cimport BaseHaScanner
from .models cimport BluetoothServiceInfoBleak
cdef int NO_RSSI_VALUE
cdef int ADV_RSSI_SWITCH_THRESHOLD
cdef double TRACKER_BUFFERING_WOBBLE_SECONDS
cdef double FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
cdef object FILTER_UUIDS
cdef object AdvertisementData
cdef object BLEDevice
cdef bint TYPE_CHECKING
cdef set APPLE_START_BYTES_WANTED
cdef unsigned char APPLE_IBEACON_START_BYTE
cdef unsigned char APPLE_HOMEKIT_START_BYTE
cdef unsigned char APPLE_HOMEKIT_NOTIFY_START_BYTE
cdef unsigned char APPLE_DEVICE_ID_START_BYTE
cdef unsigned char APPLE_FINDMY_START_BYTE
cdef object APPLE_MFR_ID
@cython.locals(uuids=set)
cdef _dispatch_bleak_callback(
BleakCallback bleak_callback,
object device,
object advertisement_data
)
cdef class BleakCallback:
cdef public object callback
cdef public dict filters
cdef class BluetoothManager:
cdef public object _cancel_unavailable_tracking
cdef public AdvertisementTracker _advertisement_tracker
cdef public dict _fallback_intervals
cdef public dict _intervals
cdef public dict _unavailable_callbacks
cdef public dict _connectable_unavailable_callbacks
cdef public set _bleak_callbacks
cdef public dict _all_history
cdef public dict _connectable_history
cdef public dict _name_cache
cdef public set _non_connectable_scanners
cdef public set _connectable_scanners
cdef public dict _adapters
cdef public dict _sources
cdef public object _bluetooth_adapters
cdef public object slot_manager
cdef public bint _debug
cdef public bint shutdown
cdef public object _loop
cdef public object _adapter_refresh_future
cdef public object _recovery_lock
cdef public set _disappeared_callbacks
cdef public dict _allocations_callbacks
cdef public object _cancel_allocation_callbacks
cdef public dict _adapter_sources
cdef public dict _allocations
cdef public dict _scanner_registration_callbacks
cdef public dict _scanner_mode_change_callbacks
cdef public object _subclass_discover_info
cdef public bint has_advertising_side_channel
cdef public dict _side_channel_scanners
cdef public object _mgmt_ctl
# _auto_scheduler stays untyped to avoid a typed cdef field that
# triggers Cython's type-import path during manager init; the hot
# path casts to AutoScanScheduler via cython.locals on
# _scanner_adv_received so the call into on_advertisement is still
# a direct vtable dispatch.
cdef public object _auto_scheduler
@cython.locals(stale_seconds=double)
cdef bint _prefer_previous_adv_from_different_source(
self,
BluetoothServiceInfoBleak old,
BluetoothServiceInfoBleak new
)
@cython.locals(scanner=BaseHaScanner)
cdef bint _should_keep_previous_adv(
self,
BluetoothServiceInfoBleak old_info,
BluetoothServiceInfoBleak new_info
)
@cython.locals(
cached=str,
cached_cf=str,
name_cf=str,
cached_len=Py_ssize_t,
name_len=Py_ssize_t,
)
cdef void _update_name_cache(self, str address, str name)
cdef void _handle_name_cache_miss(
self,
BluetoothServiceInfoBleak service_info,
str cached_name,
)
cpdef void scanner_adv_received(self, BluetoothServiceInfoBleak service_info)
@cython.locals(
old_service_info=BluetoothServiceInfoBleak,
old_connectable_service_info=BluetoothServiceInfoBleak,
source=str,
connectable=bint,
apple_cstr="const unsigned char *",
bleak_callback=BleakCallback,
cached_name=str,
auto_scheduler=AutoScanScheduler,
)
cdef void _scanner_adv_received(self, BluetoothServiceInfoBleak service_info)
cpdef _async_describe_source(self, BluetoothServiceInfoBleak service_info)
cpdef void _unregister_source_callback(
self,
dict callbacks_dict,
object source,
object callback,
) except *
cdef void _dispatch_source_callbacks(
self,
dict callbacks_dict,
object source,
object payload,
str label,
) except *
Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/manager.py 0000664 0000000 0000000 00000170143 15211177045 0025076 0 ustar 00root root 0000000 0000000 """The bluetooth integration."""
from __future__ import annotations
import asyncio
import itertools
import logging
import math
import platform
from dataclasses import asdict
from functools import partial
from typing import TYPE_CHECKING, Any, Final
from bleak_retry_connector import (
NO_RSSI_VALUE,
AllocationChangeEvent,
Allocations,
BleakSlotManager,
)
from bluetooth_adapters import (
ADAPTER_ADDRESS,
ADAPTER_PASSIVE_SCAN,
AdapterDetails,
BluetoothAdapters,
get_adapters,
)
from bluetooth_data_tools import monotonic_time_coarse
from .advertisement_tracker import (
TRACKER_BUFFERING_WOBBLE_SECONDS,
AdvertisementTracker,
)
from .auto_scheduler import ActiveScanRequest, AutoScanScheduler
from .channels.bluez import CONNECTION_ERRORS, MGMTBluetoothCtl
from .const import (
ADV_RSSI_SWITCH_THRESHOLD,
CALLBACK_TYPE,
DEFAULT_ACTIVE_SCAN_DURATION,
DEFAULT_ACTIVE_SCAN_INTERVAL,
DEFAULT_ON_DEMAND_SWEEP_DURATION,
FAILED_ADAPTER_MAC,
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
MIN_ACTIVE_SCAN_DURATION,
MIN_ACTIVE_SCAN_INTERVAL,
UNAVAILABLE_TRACK_SECONDS,
)
from .models import (
BluetoothReachabilityIntent,
BluetoothServiceInfoBleak,
HaBluetoothSlotAllocations,
HaScannerModeChange,
HaScannerRegistration,
HaScannerRegistrationEvent,
)
from .scanner_device import BluetoothScannerDevice
from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher
from .util import async_reset_adapter, coalesce_concurrent_future
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback
from .base_scanner import BaseHaScanner
from .scanner import HaScanner
SYSTEM = platform.system()
IS_LINUX = SYSTEM == "Linux"
FILTER_UUIDS: Final = "UUIDs"
APPLE_MFR_ID: Final = 76
APPLE_IBEACON_START_BYTE: Final = 0x02 # iBeacon (tilt_ble)
APPLE_HOMEKIT_START_BYTE: Final = 0x06 # homekit_controller
APPLE_DEVICE_ID_START_BYTE: Final = 0x10 # bluetooth_le_tracker
APPLE_HOMEKIT_NOTIFY_START_BYTE: Final = 0x11 # homekit_controller
APPLE_FINDMY_START_BYTE: Final = 0x12 # FindMy network advertisements
_str = str
_int = int
_LOGGER = logging.getLogger(__name__)
def _dispatch_bleak_callback(
bleak_callback: BleakCallback,
device: BLEDevice,
advertisement_data: AdvertisementData,
) -> None:
"""Dispatch the callback."""
if (
uuids := bleak_callback.filters.get(FILTER_UUIDS)
) is not None and not uuids.intersection(advertisement_data.service_uuids):
return
try:
bleak_callback.callback(device, advertisement_data)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error in callback: %s", bleak_callback.callback)
class BleakCallback:
"""Bleak callback."""
__slots__ = ("callback", "filters")
def __init__(
self, callback: AdvertisementDataCallback, filters: dict[str, set[str]]
) -> None:
"""Init bleak callback."""
self.callback = callback
self.filters = filters
class BluetoothManager:
"""Manage Bluetooth."""
__slots__ = (
"_adapter_refresh_future",
"_adapter_sources",
"_adapters",
"_advertisement_tracker",
"_all_history",
"_allocations",
"_allocations_callbacks",
"_auto_scheduler",
"_bleak_callbacks",
"_bluetooth_adapters",
"_cancel_allocation_callbacks",
"_cancel_unavailable_tracking",
"_connectable_history",
"_connectable_scanners",
"_connectable_unavailable_callbacks",
"_connection_history",
"_debug",
"_disappeared_callbacks",
"_fallback_intervals",
"_intervals",
"_loop",
"_mgmt_ctl",
"_name_cache",
"_non_connectable_scanners",
"_recovery_lock",
"_scanner_mode_change_callbacks",
"_scanner_registration_callbacks",
"_side_channel_scanners",
"_sources",
"_subclass_discover_info",
"_unavailable_callbacks",
"has_advertising_side_channel",
"shutdown",
"slot_manager",
)
def __init__(
self,
bluetooth_adapters: BluetoothAdapters | None = None,
slot_manager: BleakSlotManager | None = None,
) -> None:
"""Init bluetooth manager."""
self._cancel_unavailable_tracking: asyncio.TimerHandle | None = None
self._advertisement_tracker = AdvertisementTracker()
self._fallback_intervals = self._advertisement_tracker.fallback_intervals
self._intervals = self._advertisement_tracker.intervals
self._unavailable_callbacks: dict[
str, set[Callable[[BluetoothServiceInfoBleak], None]]
] = {}
self._connectable_unavailable_callbacks: dict[
str, set[Callable[[BluetoothServiceInfoBleak], None]]
] = {}
self._bleak_callbacks: set[BleakCallback] = set()
self._all_history: dict[str, BluetoothServiceInfoBleak] = {}
self._connectable_history: dict[str, BluetoothServiceInfoBleak] = {}
# Cross-scanner name cache: address -> best name seen across all
# scanners. Passive scanners typically miss the device name because
# it lives in SCAN_RSP (active-only); the cache lets a name learned
# by an active scanner flow to passive scanners' service_info on
# dispatch. Updates use the case-folded prefix-extension rule: a
# longer name only replaces a shorter cached one when the cached
# one is a case-folded prefix; otherwise the new name is treated
# as a rename and replaces unconditionally.
self._name_cache: dict[str, str] = {}
self._non_connectable_scanners: set[BaseHaScanner] = set()
self._connectable_scanners: set[BaseHaScanner] = set()
self._adapters: dict[str, AdapterDetails] = {}
self._adapter_sources: dict[str, str] = {}
self._allocations: dict[str, HaBluetoothSlotAllocations] = {}
self._sources: dict[str, BaseHaScanner] = {}
self._bluetooth_adapters = bluetooth_adapters or get_adapters()
self.slot_manager = slot_manager or BleakSlotManager()
self._cancel_allocation_callbacks = (
self.slot_manager.register_allocation_callback(
self._async_slot_manager_changed
)
)
self._debug = _LOGGER.isEnabledFor(logging.DEBUG)
self.shutdown = False
self.has_advertising_side_channel = False
self._side_channel_scanners: dict[int, HaScanner] = {}
self._loop: asyncio.AbstractEventLoop | None = None
self._adapter_refresh_future: asyncio.Future[None] | None = None
self._recovery_lock: asyncio.Lock = asyncio.Lock()
self._disappeared_callbacks: set[Callable[[str], None]] = set()
self._allocations_callbacks: dict[
str | None, set[Callable[[HaBluetoothSlotAllocations], None]]
] = {}
self._scanner_registration_callbacks: dict[
str | None, set[Callable[[HaScannerRegistration], None]]
] = {}
self._scanner_mode_change_callbacks: dict[
str | None, set[Callable[[HaScannerModeChange], None]]
] = {}
self._subclass_discover_info = self._discover_service_info
self._mgmt_ctl: MGMTBluetoothCtl | None = None
self._auto_scheduler = AutoScanScheduler(self)
if (
self._discover_service_info.__func__ # type: ignore[attr-defined]
is BluetoothManager._discover_service_info
):
_LOGGER.warning(
"%s: does not implement _discover_service_info, "
"subclasses must implement this method to consume "
"discovery data",
type(self).__name__,
)
@property
def supports_passive_scan(self) -> bool:
"""Return if passive scan is supported."""
return any(adapter[ADAPTER_PASSIVE_SCAN] for adapter in self._adapters.values())
def is_operating_degraded(self) -> bool:
"""
Return if the manager is operating in degraded mode.
On Linux, we're in degraded mode if mgmt control is not available.
This typically means we don't have NET_ADMIN/NET_RAW capabilities.
"""
return IS_LINUX and self._mgmt_ctl is None
def on_scanner_start(self, scanner: BaseHaScanner) -> None:
"""
Called when a scanner starts.
Subclasses can override this to perform custom actions when a scanner starts.
"""
def async_scanner_count(self, connectable: bool = True) -> int:
"""Return the number of scanners."""
if connectable:
return len(self._connectable_scanners)
return len(self._connectable_scanners) + len(self._non_connectable_scanners)
async def async_diagnostics(self) -> dict[str, Any]:
"""Diagnostics for the manager."""
scanner_diagnostics = await asyncio.gather(
*[
scanner.async_diagnostics()
for scanner in itertools.chain(
self._non_connectable_scanners, self._connectable_scanners
)
]
)
return {
"adapters": self._adapters,
"slot_manager": self.slot_manager.diagnostics(),
"allocations": {
source: asdict(allocations)
for source, allocations in self._allocations.items()
},
"scanners": scanner_diagnostics,
"connectable_history": [
service_info.as_dict()
for service_info in self._connectable_history.values()
],
"all_history": [
service_info.as_dict() for service_info in self._all_history.values()
],
"advertisement_tracker": self._advertisement_tracker.async_diagnostics(),
"auto_scheduler": self._auto_scheduler.async_diagnostics(),
}
def _find_adapter_by_address(self, address: str) -> str | None:
for adapter, details in self._adapters.items():
if details[ADAPTER_ADDRESS] == address:
return adapter
return None
def async_scanner_by_source(self, source: str) -> BaseHaScanner | None:
"""Return the scanner for a source."""
return self._sources.get(source)
def async_register_disappeared_callback(
self, callback: Callable[[str], None]
) -> CALLBACK_TYPE:
"""Register a callback to be called when an address disappears."""
self._disappeared_callbacks.add(callback)
return partial(self._disappeared_callbacks.discard, callback)
@coalesce_concurrent_future("_adapter_refresh_future")
async def _async_refresh_adapters(self) -> None:
"""Refresh the adapters."""
await self._bluetooth_adapters.refresh()
self._adapters = self._bluetooth_adapters.adapters
def get_cached_bluetooth_adapters(self) -> dict[str, AdapterDetails] | None:
"""Get cached bluetooth adapters synchronously."""
return self._adapters
async def async_get_bluetooth_adapters(
self, cached: bool = True
) -> dict[str, AdapterDetails]:
"""Get bluetooth adapters."""
if not self._adapters or not cached:
if not cached:
await self._async_refresh_adapters()
self._adapters = self._bluetooth_adapters.adapters
return self._adapters
async def async_get_adapter_from_address(self, address: str) -> str | None:
"""Get adapter from address."""
if adapter := self._find_adapter_by_address(address):
return adapter
await self._async_refresh_adapters()
return self._find_adapter_by_address(address)
async def async_get_adapter_from_address_or_recover(
self, address: str
) -> str | None:
"""Get adapter from address or recover."""
if adapter := self._find_adapter_by_address(address):
return adapter
await self._async_recover_failed_adapters()
return self._find_adapter_by_address(address)
async def _async_recover_failed_adapters(self) -> None:
"""Recover failed adapters."""
if self._recovery_lock.locked():
# Already recovering, no need to
# start another recovery
return
async with self._recovery_lock:
adapters = await self.async_get_bluetooth_adapters()
for adapter in [
adapter
for adapter, details in adapters.items()
if details[ADAPTER_ADDRESS] == FAILED_ADAPTER_MAC
]:
await async_reset_adapter(adapter, FAILED_ADAPTER_MAC, False)
await self._async_refresh_adapters()
async def async_setup(self) -> None:
"""Set up the bluetooth manager."""
# Deferred to avoid the circular import that a top-level
# ``from .central_manager import CentralBluetoothManager``
# would create (central_manager itself imports BluetoothManager
# under TYPE_CHECKING but only this method writes through it).
from .central_manager import CentralBluetoothManager # noqa: PLC0415
if CentralBluetoothManager.manager is None:
CentralBluetoothManager.manager = self
self._loop = asyncio.get_running_loop()
await self._async_refresh_adapters()
install_multiple_bleak_catcher()
self.async_setup_unavailable_tracking()
self._auto_scheduler.start(self._loop)
if not IS_LINUX:
return
self._mgmt_ctl = MGMTBluetoothCtl(10.0, self._side_channel_scanners)
try:
await self._mgmt_ctl.setup()
except PermissionError:
_LOGGER.exception(
"Missing required permissions for Bluetooth management. "
"Automatic adapter recovery is unavailable. "
"Add NET_ADMIN and NET_RAW capabilities to the container to enable it"
)
self._mgmt_ctl = None
except CONNECTION_ERRORS as ex:
_LOGGER.debug("Cannot start Bluetooth Management API: %s", ex)
self._mgmt_ctl = None
else:
self.has_advertising_side_channel = True
def async_stop(self) -> None:
"""Stop the Bluetooth integration at shutdown."""
_LOGGER.debug("Stopping bluetooth manager")
self.shutdown = True
if self._cancel_unavailable_tracking:
self._cancel_unavailable_tracking.cancel()
self._cancel_unavailable_tracking = None
self._auto_scheduler.stop()
uninstall_multiple_bleak_catcher()
self._cancel_allocation_callbacks()
if self._mgmt_ctl:
self._mgmt_ctl.close()
self._mgmt_ctl = None
def async_scanner_devices_by_address(
self, address: str, connectable: bool
) -> list[BluetoothScannerDevice]:
"""Get BluetoothScannerDevice by address."""
if not connectable:
scanners: Iterable[BaseHaScanner] = itertools.chain(
self._connectable_scanners, self._non_connectable_scanners
)
else:
scanners = self._connectable_scanners
return [
BluetoothScannerDevice(scanner, *device_adv)
for scanner in scanners
if (device_adv := scanner.get_discovered_device_advertisement_data(address))
]
def async_discovered_devices(self, connectable: bool) -> list[BLEDevice]:
"""Return all of combined best path to discovered from all the scanners."""
histories = self._connectable_history if connectable else self._all_history
return [history.device for history in histories.values()]
def async_setup_unavailable_tracking(self) -> None:
"""Set up the unavailable tracking."""
self._schedule_unavailable_tracking()
def _schedule_unavailable_tracking(self) -> None:
"""Schedule the unavailable tracking."""
if TYPE_CHECKING:
assert self._loop is not None
loop = self._loop
self._cancel_unavailable_tracking = loop.call_at(
loop.time() + UNAVAILABLE_TRACK_SECONDS, self._async_check_unavailable
)
def _async_check_unavailable(self) -> None: # noqa: C901
"""Watch for unavailable devices and cleanup state history."""
monotonic_now = monotonic_time_coarse()
connectable_history = self._connectable_history
all_history = self._all_history
tracker = self._advertisement_tracker
intervals = tracker.intervals
# Materialize each scanner's discovered_addresses exactly once per
# cycle. For local HaScanner this property rebuilds bleak's
# discovered-devices dict on every access, so the prior two-pass
# iteration paid that cost twice for the connectable scanners.
connectable_addrs: set[str] = set()
for scanner in self._connectable_scanners:
connectable_addrs.update(scanner.discovered_addresses)
all_addrs = connectable_addrs.copy()
for scanner in self._non_connectable_scanners:
all_addrs.update(scanner.discovered_addresses)
for connectable in (True, False):
if connectable:
unavailable_callbacks = self._connectable_unavailable_callbacks
else:
unavailable_callbacks = self._unavailable_callbacks
history = connectable_history if connectable else all_history
disappeared = set(history).difference(
connectable_addrs if connectable else all_addrs
)
for address in disappeared:
if not connectable:
#
# For non-connectable devices we also check the device has exceeded
# the advertising interval before we mark it as unavailable
# since it may have gone to sleep and since we do not need an active
# connection to it we can only determine its availability
# by the lack of advertisements
if advertising_interval := (
intervals.get(address) or self._fallback_intervals.get(address)
):
advertising_interval += TRACKER_BUFFERING_WOBBLE_SECONDS
else:
advertising_interval = (
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
)
time_since_seen = monotonic_now - all_history[address].time
if time_since_seen <= advertising_interval:
continue
# The second loop (connectable=False) is responsible for removing
# the device from all the interval tracking since it is no longer
# available for both connectable and non-connectable
tracker.async_remove_fallback_interval(address)
tracker.async_remove_address(address)
self._name_cache.pop(address, None)
for disappear_callback in self._disappeared_callbacks:
try:
disappear_callback(address)
except Exception:
_LOGGER.exception("Error in disappeared callback")
self._address_disappeared(address)
service_info = history.pop(address)
if not (callbacks := unavailable_callbacks.get(address)):
continue
for callback in callbacks.copy():
try:
callback(service_info)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error in unavailable callback")
self._schedule_unavailable_tracking()
def _address_disappeared(self, address: str) -> None:
"""
Call when an address disappears from the stack.
This method is intended to be overridden by subclasses.
"""
def _should_keep_previous_adv(
self,
old_info: BluetoothServiceInfoBleak,
new_info: BluetoothServiceInfoBleak,
) -> bool:
"""
Return True when ``old_info`` should win over ``new_info``.
Only relevant when ``old_info`` came from a different still-scanning
source. The ``is not / !=`` ordering is a PyObject_RichCompare
short-circuit that dominates this hot path; keep it intact.
"""
return (
new_info.source is not old_info.source
and new_info.source != old_info.source
and (scanner := self._sources.get(old_info.source)) is not None
and scanner.scanning
and self._prefer_previous_adv_from_different_source(old_info, new_info)
)
def _prefer_previous_adv_from_different_source(
self,
old: BluetoothServiceInfoBleak,
new: BluetoothServiceInfoBleak,
) -> bool:
"""Prefer previous advertisement from a different source if it is better."""
if stale_seconds := self._intervals.get(
new.address, self._fallback_intervals.get(new.address, 0)
):
stale_seconds += TRACKER_BUFFERING_WOBBLE_SECONDS
else:
stale_seconds = FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
if new.time - old.time > stale_seconds:
# If the old advertisement is stale, any new advertisement is preferred
if self._debug:
_LOGGER.debug(
"%s (%s): Switching from %s to %s (time elapsed:%s > stale"
" seconds:%s)",
new.name,
new.address,
self._async_describe_source(old),
self._async_describe_source(new),
new.time - old.time,
stale_seconds,
)
return False
if (new.rssi or NO_RSSI_VALUE) - ADV_RSSI_SWITCH_THRESHOLD > (
old.rssi or NO_RSSI_VALUE
):
# If new advertisement is ADV_RSSI_SWITCH_THRESHOLD more,
# the new one is preferred.
if self._debug:
_LOGGER.debug(
"%s (%s): Switching from %s to %s (new rssi:%s - threshold:%s >"
" old rssi:%s)",
new.name,
new.address,
self._async_describe_source(old),
self._async_describe_source(new),
new.rssi,
ADV_RSSI_SWITCH_THRESHOLD,
old.rssi,
)
return False
return True
def get_bluez_mgmt_ctl(self) -> MGMTBluetoothCtl | None:
"""
Get the BlueZ management controller if available.
Returns:
The MGMTBluetoothCtl instance or None if not available
"""
return self._mgmt_ctl
def _handle_name_cache_miss(
self,
service_info: BluetoothServiceInfoBleak,
cached_name: str | None,
) -> None:
"""
Handle the cold path when cached_name is not service_info.name.
Called from _scanner_adv_received only when the cached name and
the incoming name are different str objects (steady-state
identity match is filtered out at the call site). Walks through
three cases:
1. The incoming ad has no real name (empty or the MAC fallback
set by base_scanner): patch service_info from the cache if we
have one; this is the path that lets passive scanners inherit
a name learned by an active scanner.
2. No cached name yet: store the incoming name directly if it is
real; no patch needed since the cache now matches.
3. Cached and incoming are both real but differ: apply the
prefix rule via _update_name_cache and patch service_info
with whatever the cache settled on.
"""
# When we patch service_info.name and service_info.device.name,
# we also clear service_info._advertisement so the lazy rebuild
# in BluetoothServiceInfoBleak._advertisement_internal picks up
# the canonical name and propagates it to bleak callbacks via
# advertisement.local_name. Remote scanners arrive with
# _advertisement = None (see base_scanner.py:657), but
# HaScanner.on_advertisement (scanner.py:331) pre-sets it to
# bleak's AdvertisementData, so without this invalidation a
# local passive scanner whose dispatched view we patch would
# still hand bleak callbacks an AdvertisementData with the
# original (missing) local_name.
if (
not service_info.name
or service_info.name is service_info.address
or service_info.name == service_info.address
):
if cached_name is not None:
service_info.name = cached_name
service_info.device.name = cached_name
service_info._advertisement = None
return
if cached_name is None:
self._name_cache[service_info.address] = service_info.name
return
if cached_name == service_info.name:
return
self._update_name_cache(service_info.address, service_info.name)
cached_name = self._name_cache[service_info.address]
if cached_name is not service_info.name and cached_name != service_info.name:
service_info.name = cached_name
service_info.device.name = cached_name
service_info._advertisement = None
def seed_name_cache(self, address: str, name: str) -> None:
"""
Apply the prefix rule to the cross-scanner name cache.
Python-visible entry point intended for cold paths such as
BaseHaScanner.restore_discovered_devices (called once per scanner
at startup). The hot per-advertisement path does not use this
method; it inlines the steady-state checks and calls the internal
cdef _update_name_cache directly.
"""
self._update_name_cache(address, name)
def _update_name_cache(self, address: str, name: str) -> None:
"""
Update the cross-scanner name cache for an address.
Applies the case-folded prefix-extension rule:
- identical name -> no-op (fastest path; identity check first)
- empty name or name == address -> no-op (never pollute the cache
with the address fallback used by base_scanner)
- cached is None -> store new
- new is a case-folded extension of cached -> store new
(e.g. "Onv" -> "Onvis XXX")
- cached is a case-folded extension of new -> keep cached
(e.g. "Onvis XXX" -> "Onv" is a truncation)
- neither is a case-folded prefix of the other -> rename, store new
(e.g. "Onv" -> "Donkey")
Performance note: after the steady-state identity / equality short
circuits, length-based dispatch ensures we do at most ONE
str.startswith per call (instead of up to two), since a prefix
relationship is only possible when the shorter string could be a
prefix of the longer. Compares casefolded lengths because casefold
can change length for some characters (e.g. German "ß" -> "ss").
"""
cached = self._name_cache.get(address)
if cached is name:
return
if not name or name == address:
return
if cached is None:
self._name_cache[address] = name
return
if cached == name:
return
cached_cf = cached.casefold()
name_cf = name.casefold()
cached_len = len(cached_cf)
name_len = len(name_cf)
if name_len > cached_len:
# New is longer -> only "extension" or "rename" are possible.
# Either way the new name wins (extension upgrades, rename replaces).
self._name_cache[address] = name
return
if name_len < cached_len:
# New is shorter -> "truncation" (keep cached) or "rename" (replace).
if cached_cf.startswith(name_cf):
return
self._name_cache[address] = name
return
# Equal casefolded length, raw not equal -> case-only diff or rename.
if cached_cf == name_cf:
return
self._name_cache[address] = name
def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None:
"""
Handle a new advertisement from any scanner.
Callbacks from all the scanners arrive here.
This is the cpdef entry point for external callers.
Internal callers should use _scanner_adv_received directly
to avoid cpdef virtual dispatch overhead.
"""
self._scanner_adv_received(service_info)
def _scanner_adv_received( # noqa: C901
self, service_info: BluetoothServiceInfoBleak
) -> None:
"""
Handle a new advertisement from any scanner (internal cdef path).
Callbacks from all the scanners arrive here.
"""
# Pre-filter noisy apple devices as they can account for 20-35% of the
# traffic on a typical network.
if (
len(service_info.service_data) == 0
and len(service_info.manufacturer_data) == 1
and (apple_data := service_info.manufacturer_data.get(APPLE_MFR_ID))
):
apple_cstr = apple_data
if apple_cstr[0] not in {
APPLE_IBEACON_START_BYTE,
APPLE_HOMEKIT_START_BYTE,
APPLE_HOMEKIT_NOTIFY_START_BYTE,
APPLE_DEVICE_ID_START_BYTE,
APPLE_FINDMY_START_BYTE,
}:
return
# Cross-scanner name cache. Only the steady-state identity check
# is inlined here because this code runs on every advertisement
# after the Apple pre-filter; the rest is handled in a cdef
# helper to keep this method readable. The hot path is a single
# dict.get plus a pointer compare; the function call to the
# helper only fires when the cached name and the incoming name
# are different str objects, which excludes the dominant case of
# the same scanner re-broadcasting the same name.
cached_name = self._name_cache.get(service_info.address)
if cached_name is not service_info.name:
self._handle_name_cache_miss(service_info, cached_name)
if service_info.connectable:
old_connectable_service_info = self._connectable_history.get(
service_info.address
)
else:
old_connectable_service_info = None
# This logic is complex due to the many combinations of scanners
# that are supported.
#
# We need to handle multiple connectable and non-connectable scanners
# and we need to handle the case where a device is connectable on one scanner
# but not on another.
#
# The device may also be connectable only by a scanner that has worse
# signal strength than a non-connectable scanner.
#
# all_history - the history of all advertisements from all scanners with the
# best advertisement from each scanner
# connectable_history - the history of all connectable advertisements from all
# scanners with the best advertisement from each
# connectable scanner
#
if (
old_service_info := self._all_history.get(service_info.address)
) is not None and self._should_keep_previous_adv(
old_service_info, service_info
):
# If we are rejecting the new advertisement and the device is connectable
# but not in the connectable history or the connectable source is the same
# as the new source, we need to add it to the connectable history
if service_info.connectable:
if old_connectable_service_info is not None and (
# If it's the same as the preferred source, we're done; we know
# we prefer the old advertisement from the check above.
old_connectable_service_info is old_service_info
# Otherwise the old connectable came from a different source;
# re-run the predicate against the connectable history entry.
or self._should_keep_previous_adv(
old_connectable_service_info, service_info
)
):
return
self._connectable_history[service_info.address] = service_info
return
if service_info.connectable:
self._connectable_history[service_info.address] = service_info
self._all_history[service_info.address] = service_info
# Hand the advertisement to the auto-scan scheduler right after
# _all_history is updated. Ownership-flip detection (a different
# scanner taking over a device's source) needs to fire even when
# the advertisement payload is identical to the previous one;
# the data-comparison short-circuit below would otherwise hide
# that flip from the scheduler. Local-typed assignment so
# cython.locals casts to AutoScanScheduler and the call is a
# direct vtable dispatch even though _auto_scheduler is stored
# untyped on BluetoothManager.
auto_scheduler = self._auto_scheduler
auto_scheduler.on_advertisement(service_info)
# Track advertisement intervals to determine when we need to
# switch adapters or mark a device as unavailable
if (
(
last_source := self._advertisement_tracker.sources.get(
service_info.address
)
)
is not None
and last_source is not service_info.source
and last_source != service_info.source
):
# Source changed, remove the old address from the tracker
self._advertisement_tracker.async_remove_address(service_info.address)
if service_info.address not in self._advertisement_tracker.intervals:
self._advertisement_tracker.async_collect(service_info)
# If the advertisement data is the same as the last time we saw it, we
# don't need to do anything else unless its connectable and we are missing
# connectable history for the device so we can make it available again
# after unavailable callbacks.
if (
# Ensure its not a connectable device missing from connectable history
not (service_info.connectable and old_connectable_service_info is None)
# Than check if advertisement data is the same
and old_service_info is not None
# This is a bit complex because we want to skip all the
# PyObject_RichCompare overhead as its can be upwards of
# 65% of the time spent in this method. The common case
# is that its the same object for remote scanners.
and not (
(
service_info.manufacturer_data
is not old_service_info.manufacturer_data
and service_info.manufacturer_data
!= old_service_info.manufacturer_data
)
or (
service_info.service_data is not old_service_info.service_data
and service_info.service_data != old_service_info.service_data
)
or (
service_info.service_uuids is not old_service_info.service_uuids
and service_info.service_uuids != old_service_info.service_uuids
)
or (
service_info.name is not old_service_info.name
and service_info.name != old_service_info.name
)
)
):
return
# A non-connectable scanner may currently be the closest path, but if a
# still-registered connectable scanner also has a path to the device we
# surface this advertisement as connectable so connectable callbacks and
# discovery fire (the BleakClient routes any connection attempt to the
# connectable path). connectable_history is only pruned by the periodic
# unavailable check, so validate the stored entry's source is still
# registered before trusting it as a live connectable path. This lookup is
# deferred to here (after the identical-advertisement short-circuit above)
# so the dominant non-connectable rebroadcast hot path never pays it.
if (
not service_info.connectable
and (
connectable_path := self._connectable_history.get(service_info.address)
)
is not None
and connectable_path.source in self._sources
):
service_info = service_info._as_connectable()
if service_info.connectable and self._bleak_callbacks:
# Bleak callbacks must get a connectable device
advertisement_data = service_info._advertisement_internal()
for bleak_callback in self._bleak_callbacks:
_dispatch_bleak_callback(
bleak_callback, service_info.device, advertisement_data
)
self._subclass_discover_info(service_info)
def async_clear_advertisement_history(self, address: str) -> None:
"""
Clear cached advertisement history for a device.
Causes the next advertisement from this address to be treated as new
data, bypassing both the advertisement-merging logic in scanners and
the change-detection guard. Intended for devices that encode state in
mutually-exclusive service UUIDs.
"""
self._all_history.pop(address, None)
self._connectable_history.pop(address, None)
self._name_cache.pop(address, None)
for scanner in self._sources.values():
scanner._previous_service_info.pop(address, None)
def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None:
"""
Discover a new service info.
This method is intended to be overridden by subclasses.
"""
def _async_describe_source(self, service_info: BluetoothServiceInfoBleak) -> str:
"""Describe a source."""
if scanner := self._sources.get(service_info.source):
description = scanner.name
else:
description = service_info.source
if service_info.connectable:
description += " [connectable]"
return description
def _async_remove_unavailable_callback_internal(
self,
unavailable_callbacks: dict[
str, set[Callable[[BluetoothServiceInfoBleak], None]]
],
address: str,
callbacks: set[Callable[[BluetoothServiceInfoBleak], None]],
callback: Callable[[BluetoothServiceInfoBleak], None],
) -> None:
"""Remove a callback."""
callbacks.remove(callback)
if not callbacks:
del unavailable_callbacks[address]
def async_track_unavailable(
self,
callback: Callable[[BluetoothServiceInfoBleak], None],
address: str,
connectable: bool,
) -> Callable[[], None]:
"""Register a callback."""
if connectable:
unavailable_callbacks = self._connectable_unavailable_callbacks
else:
unavailable_callbacks = self._unavailable_callbacks
callbacks = unavailable_callbacks.setdefault(address, set())
callbacks.add(callback)
return partial(
self._async_remove_unavailable_callback_internal,
unavailable_callbacks,
address,
callbacks,
callback,
)
def async_ble_device_from_address(
self, address: str, connectable: bool
) -> BLEDevice | None:
"""Return the BLEDevice if present."""
histories = self._connectable_history if connectable else self._all_history
if history := histories.get(address):
return history.device
return None
def async_address_present(self, address: str, connectable: bool) -> bool:
"""Return if the address is present."""
histories = self._connectable_history if connectable else self._all_history
return address in histories
def async_discovered_service_info(
self, connectable: bool
) -> Iterable[BluetoothServiceInfoBleak]:
"""Return all the discovered services info."""
histories = self._connectable_history if connectable else self._all_history
return histories.values()
def async_last_service_info(
self, address: str, connectable: bool
) -> BluetoothServiceInfoBleak | None:
"""Return the last service info for an address."""
histories = self._connectable_history if connectable else self._all_history
return histories.get(address)
def async_address_reachability_diagnostics(
self, address: str, intent: BluetoothReachabilityIntent
) -> str:
"""
Return a human-readable explanation of an address's reachability.
Intended for embedding in error and log messages when a device cannot
be found or used. The ``intent`` selects which facts are relevant: a
caller that only consumes advertisements (``PASSIVE_ADVERTISEMENT`` /
``ACTIVE_ADVERTISEMENT``) does not care about connectable paths or
connection slots, while a caller that wants to connect (``CONNECTION``)
does. This is read-only and side-effect free, and is only meant for the
cold error path, not the hot advertisement path.
The returned string is for embedding in human-readable error and log
messages only; its wording and format are not stable and must not be
parsed. The address is not included, callers already have it in context.
"""
now = monotonic_time_coarse()
parts: list[str] = []
# All scanners (connectable and non-connectable) that currently see the
# address. Materialized once; reused for the per-scanner detail below.
devices = self.async_scanner_devices_by_address(address, False)
if intent is BluetoothReachabilityIntent.CONNECTION:
self._append_connection_diagnostics(address, devices, parts)
else:
self._append_advertisement_diagnostics(address, devices, parts)
parts.append(self._scanner_availability_summary())
for device in devices:
scanner = device.scanner
detail = (
f"{scanner.name} (connectable={scanner.connectable}, "
f"rssi={device.advertisement.rssi}"
)
if intent is BluetoothReachabilityIntent.CONNECTION:
detail += (
f", failures={scanner.connection_failures(address)}, "
f"in_progress={scanner.connections_in_progress()}"
)
if (allocations := scanner.get_allocations()) is not None:
detail += f", slots={allocations.free}/{allocations.slots}"
parts.append(detail + ")")
if (info := self._all_history.get(address)) is not None:
if (via_scanner := self._sources.get(info.source)) is not None:
via = via_scanner.name
else:
via = info.source
parts.append(f"last advertisement {now - info.time:.0f}s ago via {via}")
return "; ".join(parts)
def _scanner_availability_summary(self) -> str:
"""
Summarize how many scanners are registered, scanning and connectable.
A scanner pauses scanning while it has a connection in progress, so a
device can disappear from every scanner if they are all busy connecting;
this is called out explicitly because no advertisements can be received
while no scanner is scanning.
"""
scanners = self.async_current_scanners()
total = len(scanners)
scanning = 0
connecting = 0
connectable = 0
# A scanner pauses scanning while it has a connection in progress, so
# in normal operation scanning and connecting_count are mutually
# exclusive. Count them independently anyway so the "all paused
# connecting" advice below stays correct even if that invariant drifts.
for scanner in scanners:
if scanner.connectable:
connectable += 1
if scanner.scanning:
scanning += 1
if scanner.connecting_count:
connecting += 1
summary = (
f"{total} scanner(s) registered, {scanning} scanning, "
f"{connectable} connectable"
)
if connecting:
summary += f", {connecting} paused while connecting"
if total and scanning == 0:
summary += (
"; no scanner is currently scanning so no advertisements can be "
"received"
)
if connecting == total:
summary += (
" (all are paused retrying connections; the available adapters "
"are overloaded, add more Bluetooth adapters or proxies)"
)
return summary
def _append_connection_diagnostics(
self,
address: str,
devices: list[BluetoothScannerDevice],
parts: list[str],
) -> None:
"""Append connectable-path reachability facts for a connect intent."""
if address in self._connectable_history:
parts.append("in connectable history")
elif address in self._all_history:
parts.append("only in non-connectable history (no connectable path)")
else:
parts.append("unknown (never seen by any scanner)")
connectable_devices = [d for d in devices if d.scanner.connectable]
non_connectable_devices = [d for d in devices if not d.scanner.connectable]
if not connectable_devices and non_connectable_devices:
parts.append(
f"seen by {len(non_connectable_devices)} scanner(s) but none with"
" a connectable path"
)
return
# Only consider scanners that actually report slot allocations; a
# scanner returning None (e.g. a local adapter that does not track
# slots) tells us nothing, so it must not suppress or trigger the
# message. We only claim the reporting scanners are full, not that
# every connectable path is exhausted.
reported = [
allocations
for d in connectable_devices
if (allocations := d.scanner.get_allocations()) is not None
and allocations.slots > 0
]
if reported and all(a.free == 0 for a in reported):
parts.append(
"connectable scanner(s) that report slot allocations are all full"
)
def _append_advertisement_diagnostics(
self,
address: str,
devices: list[BluetoothScannerDevice],
parts: list[str],
) -> None:
"""Append advertisement-only reachability facts for an advertisement intent."""
# Advertisement callers only need adverts, so connectable paths and
# connection slots are irrelevant; report only whether the device is
# being seen and by how many scanners. ``_all_history`` outlives any
# single scanner's discovered cache, so an address can be in history
# while no scanner currently has it cached; do not claim it is still
# advertising in that case.
if devices:
parts.append(f"advertising, seen by {len(devices)} scanner(s)")
elif address in self._all_history:
parts.append("previously seen but no scanner currently has it cached")
else:
parts.append("unknown (never seen by any scanner)")
def _async_unregister_scanner_internal(
self,
scanners: set[BaseHaScanner],
scanner: BaseHaScanner,
connection_slots: int | None,
) -> None:
"""Unregister a scanner."""
if scanner not in scanners:
_LOGGER.debug("Scanner %s already unregistered; skipping", scanner.name)
return
_LOGGER.debug("Unregistering scanner %s", scanner.name)
self._advertisement_tracker.async_remove_source(scanner.source)
scanners.discard(scanner)
scanner._clear_connection_history()
self._sources.pop(scanner.source, None)
self._adapter_sources.pop(scanner.adapter, None)
self._allocations.pop(scanner.source, None)
if connection_slots:
self.slot_manager.remove_adapter(scanner.adapter)
if (idx := scanner.adapter_idx) is not None:
self._side_channel_scanners.pop(idx, None)
self._auto_scheduler.remove_scanner(scanner)
self._async_on_scanner_registration(scanner, HaScannerRegistrationEvent.REMOVED)
def async_register_scanner(
self,
scanner: BaseHaScanner,
connection_slots: int | None = None,
) -> CALLBACK_TYPE:
"""Register a new scanner."""
_LOGGER.debug("Registering scanner %s", scanner.name)
if scanner.connectable:
scanners = self._connectable_scanners
else:
scanners = self._non_connectable_scanners
self._allocations[scanner.source] = HaBluetoothSlotAllocations(
source=scanner.source, slots=0, free=0, allocated=[]
)
scanners.add(scanner)
scanner._clear_connection_history()
self._sources[scanner.source] = scanner
self._adapter_sources[scanner.adapter] = scanner.source
if (idx := scanner.adapter_idx) is not None:
self._side_channel_scanners[idx] = scanner # type: ignore[assignment]
if connection_slots:
self.slot_manager.register_adapter(scanner.adapter, connection_slots)
self.async_on_allocation_changed(
self.slot_manager.get_allocations(scanner.adapter)
)
self._auto_scheduler.add_scanner(scanner)
self._async_on_scanner_registration(scanner, HaScannerRegistrationEvent.ADDED)
return partial(
self._async_unregister_scanner_internal, scanners, scanner, connection_slots
)
def async_register_bleak_callback(
self, callback: AdvertisementDataCallback, filters: dict[str, set[str]]
) -> CALLBACK_TYPE:
"""Register a callback."""
callback_entry = BleakCallback(callback, filters)
self._bleak_callbacks.add(callback_entry)
# Replay the history since otherwise we miss devices
# that were already discovered before the callback was registered
# or we are in passive mode
for history in self._connectable_history.values():
_dispatch_bleak_callback(
callback_entry, history.device, history.advertisement
)
return partial(self._bleak_callbacks.remove, callback_entry)
def async_register_active_scan(
self,
address: str,
scan_interval: float | None = None,
scan_duration: float | None = None,
) -> CALLBACK_TYPE:
"""
Declare an on-demand active-scan need for a specific address.
Colon-form MAC addresses are normalized to upper-case to
match BlueZ / ESPHome / Shelly source addresses; UUIDs (no
colons, used by macOS CoreBluetooth) are passed through
as-is since CoreBluetooth preserves case on its source
addresses.
``scan_interval`` / ``scan_duration`` default to
DEFAULT_ACTIVE_SCAN_INTERVAL (300s, 5 min) and
DEFAULT_ACTIVE_SCAN_DURATION (10s); pass smaller values to
get a tighter cadence. The effective window is clamped to
[AUTO_WINDOW_MIN_DURATION, AUTO_WINDOW_MAX_DURATION]
(5s..30s) and coalesced with other due requests for the
scanner; very large ``scan_duration`` values are capped.
``scan_interval`` is measured between window starts (not
between successive windows). ACTIVE / PASSIVE scanners
ignore the request. Returns a cancel callable.
"""
if not address:
msg = "address must be a non-empty string"
raise ValueError(msg)
if scan_interval is None:
scan_interval = DEFAULT_ACTIVE_SCAN_INTERVAL
if scan_duration is None:
scan_duration = DEFAULT_ACTIVE_SCAN_DURATION
# Reject non-finite values explicitly: NaN compared to anything
# returns False, so a NaN would slip past the lower-bound
# checks below and end up in _due_at and call_later as a NaN
# due-time / duration, busy-looping the worker.
if not math.isfinite(scan_interval) or scan_interval < MIN_ACTIVE_SCAN_INTERVAL:
msg = (
f"scan_interval must be a finite number >= "
f"{MIN_ACTIVE_SCAN_INTERVAL:.0f}s"
)
raise ValueError(msg)
if not math.isfinite(scan_duration) or scan_duration < MIN_ACTIVE_SCAN_DURATION:
msg = (
f"scan_duration must be a finite number >= "
f"{MIN_ACTIVE_SCAN_DURATION:.0f}s"
)
raise ValueError(msg)
# MAC addresses (colon-form) get upper-cased to match BlueZ /
# ESPHome conventions; UUIDs (macOS CoreBluetooth) pass
# through as-is.
normalized = address.upper() if ":" in address else address
request = ActiveScanRequest(normalized, scan_interval, scan_duration)
self._auto_scheduler.add_request(request)
return partial(self._auto_scheduler.remove_request, request)
async def async_request_active_scan(self, duration: float | None = None) -> None:
"""
Run an on-demand active sweep across every AUTO scanner.
Intended for HA config-flow discovery: probes the bus
actively without waiting for the 12 h rediscovery cadence,
awaits ``duration`` so the caller can then read
newly-discovered advertisements. Default 10s; clamped to
``[AUTO_WINDOW_MIN_DURATION, AUTO_WINDOW_MAX_DURATION]`` by
the scheduler. Concurrent callers dedupe to one bus-wide
window (a longer request extends the in-flight one); see
``AutoScanScheduler.async_request_active_scan``.
"""
if duration is None:
duration = DEFAULT_ON_DEMAND_SWEEP_DURATION
if not math.isfinite(duration) or duration <= 0.0:
msg = "duration must be a finite positive number"
raise ValueError(msg)
await self._auto_scheduler.async_request_active_scan(duration)
def async_release_connection_slot(self, device: BLEDevice) -> None:
"""Release a connection slot."""
self.slot_manager.release_slot(device)
def async_allocate_connection_slot(self, device: BLEDevice) -> bool:
"""Allocate a connection slot."""
return self.slot_manager.allocate_slot(device)
def async_get_learned_advertising_interval(self, address: str) -> float | None:
"""Get the learned advertising interval for a MAC address."""
return self._intervals.get(address)
def async_get_fallback_availability_interval(self, address: str) -> float | None:
"""Get the fallback availability timeout for a MAC address."""
return self._fallback_intervals.get(address)
def async_set_fallback_availability_interval(
self, address: str, interval: float
) -> None:
"""Override the fallback availability timeout for a MAC address."""
self._fallback_intervals[address] = interval
def _async_slot_manager_changed(self, event: AllocationChangeEvent) -> None:
"""Handle slot manager changes."""
self.async_on_allocation_changed(
self.slot_manager.get_allocations(event.adapter)
)
def _unregister_source_callback(
self,
callbacks_dict: dict[Any, set[Callable[..., None]]],
source: object,
callback: Callable[..., None],
) -> None:
"""Unregister a source-keyed callback."""
if (callbacks := callbacks_dict.get(source)) is not None:
callbacks.discard(callback)
if not callbacks:
del callbacks_dict[source]
def _dispatch_source_callbacks(
self,
callbacks_dict: dict[Any, set[Callable[..., None]]],
source: object,
payload: object,
label: str,
) -> None:
"""Dispatch payload to source-specific and global (None) callbacks."""
for source_key in (source, None):
if not (callbacks := callbacks_dict.get(source_key)):
continue
for callback_ in callbacks.copy():
try:
callback_(payload)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error in %s", label)
def async_on_allocation_changed(self, allocations: Allocations) -> None:
"""Call allocation callbacks."""
source = self._adapter_sources.get(allocations.adapter, allocations.adapter)
ha_slot_allocations = HaBluetoothSlotAllocations(
source=source,
slots=allocations.slots,
free=allocations.free,
allocated=allocations.allocated,
)
self._allocations[source] = ha_slot_allocations
self._dispatch_source_callbacks(
self._allocations_callbacks,
source,
ha_slot_allocations,
"allocation callback",
)
def _async_on_scanner_registration(
self, scanner: BaseHaScanner, event: HaScannerRegistrationEvent
) -> None:
"""Call scanner callbacks."""
self._dispatch_source_callbacks(
self._scanner_registration_callbacks,
scanner.source,
HaScannerRegistration(event, scanner),
"scanner callback",
)
def async_current_allocations(
self, source: str | None = None
) -> list[HaBluetoothSlotAllocations] | None:
"""Return the current allocations."""
if source:
if allocations := self._allocations.get(source):
return [allocations]
return []
return list(self._allocations.values())
def async_register_allocation_callback(
self,
callback: Callable[[HaBluetoothSlotAllocations], None],
source: str | None = None,
) -> CALLBACK_TYPE:
"""Register a callback to be called when an allocations change."""
self._allocations_callbacks.setdefault(source, set()).add(callback)
return partial(
self._unregister_source_callback,
self._allocations_callbacks,
source,
callback,
)
def async_register_scanner_registration_callback(
self, callback: Callable[[HaScannerRegistration], None], source: str | None
) -> CALLBACK_TYPE:
"""Register a callback to be called when a scanner is added or removed."""
self._scanner_registration_callbacks.setdefault(source, set()).add(callback)
return partial(
self._unregister_source_callback,
self._scanner_registration_callbacks,
source,
callback,
)
def async_current_scanners(self) -> list[BaseHaScanner]:
"""Return the current scanners."""
return list(self._sources.values())
def async_register_scanner_mode_change_callback(
self, callback: Callable[[HaScannerModeChange], None], source: str | None
) -> CALLBACK_TYPE:
"""Register a callback to be called when a scanner mode changes."""
self._scanner_mode_change_callbacks.setdefault(source, set()).add(callback)
return partial(
self._unregister_source_callback,
self._scanner_mode_change_callbacks,
source,
callback,
)
def scanner_mode_changed(self, scanner: BaseHaScanner) -> None:
"""Notify callbacks that a scanner's mode has changed."""
self._dispatch_source_callbacks(
self._scanner_mode_change_callbacks,
scanner.source,
HaScannerModeChange(
scanner=scanner,
requested_mode=scanner.requested_mode,
current_mode=scanner.current_mode,
),
"scanner mode change callback",
)
Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/models.pxd 0000664 0000000 0000000 00000001757 15211177045 0025116 0 ustar 00root root 0000000 0000000 import cython
cdef object BLEDevice
cdef object AdvertisementData
cdef object _float
cdef object _int
cdef object _str
cdef object _BluetoothServiceInfoBleakSelfT
cdef object _BluetoothServiceInfoSelfT
cdef object NO_RSSI_VALUE
cdef object TUPLE_NEW
cdef class BluetoothServiceInfo:
"""Prepared info from bluetooth entries."""
cdef public str name
cdef public str address
cdef public int rssi
cdef public dict manufacturer_data
cdef public dict service_data
cdef public list service_uuids
cdef public str source
cdef class BluetoothServiceInfoBleak(BluetoothServiceInfo):
"""BluetoothServiceInfo with bleak data."""
cdef public object device
cdef public object _advertisement
cdef public bint connectable
cdef public double time
cdef public object tx_power
cdef public bytes raw
@cython.locals(new_obj=BluetoothServiceInfoBleak)
cpdef BluetoothServiceInfoBleak _as_connectable(self)
cdef object _advertisement_internal(self)
Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/models.py 0000664 0000000 0000000 00000026565 15211177045 0024757 0 ustar 00root root 0000000 0000000 """Models for bluetooth."""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING, Any, Final, Self
from bleak.backends.scanner import AdvertisementData
from bleak_retry_connector import NO_RSSI_VALUE
if TYPE_CHECKING:
from collections.abc import Callable
from bleak import BaseBleakClient
from bleak.backends.device import BLEDevice
from .base_scanner import BaseHaScanner
SOURCE_LOCAL: Final = "local"
TUPLE_NEW: Final = tuple.__new__
_float = float # avoid cython conversion since we always want a pyfloat
_str = str # avoid cython conversion since we always want a pystr
_int = int # avoid cython conversion since we always want a pyint
@dataclass(slots=True, frozen=True)
class HaBluetoothSlotAllocations:
"""Data for how to allocate slots for BLEDevice connections."""
source: str # Adapter MAC
slots: int # Number of slots
free: int # Number of free slots
allocated: list[str] # Addresses of connected devices
class HaScannerRegistrationEvent(Enum):
"""Events for scanner registration."""
ADDED = "added"
REMOVED = "removed"
UPDATED = "updated"
@dataclass(slots=True, frozen=True)
class HaScannerRegistration:
"""Data for a scanner event."""
event: HaScannerRegistrationEvent
scanner: BaseHaScanner
@dataclass(slots=True, frozen=True)
class HaScannerModeChange:
"""Data for a scanner mode change event."""
scanner: BaseHaScanner
requested_mode: BluetoothScanningMode | None
current_mode: BluetoothScanningMode | None
@dataclass(slots=True)
class HaBluetoothConnector:
"""Data for how to connect a BLEDevice from a given scanner."""
client: type[BaseBleakClient]
source: str
can_connect: Callable[[], bool]
@dataclass(slots=True, frozen=True)
class HaScannerDetails:
"""Details for a scanner."""
source: str
connectable: bool
name: str
adapter: str
scanner_type: HaScannerType
class HaScannerType(Enum):
"""The type of scanner."""
USB = "usb"
UART = "uart"
REMOTE = "remote"
UNKNOWN = "unknown"
class BluetoothScanningMode(Enum):
"""The mode of scanning for bluetooth devices."""
PASSIVE = "passive"
ACTIVE = "active"
# AUTO starts the scanner in PASSIVE and lets the manager promote it to
# ACTIVE on demand via BaseHaScanner.async_request_active_window — used
# for per-callback active windows and the periodic rediscovery sweep.
AUTO = "auto"
class BluetoothReachabilityIntent(Enum):
"""
What a caller needs from a device, for reachability diagnostics.
The intent changes which facts are relevant when explaining why an address
is not usable. A caller that only consumes advertisements does not care
whether a connectable path or a free connection slot exists; a caller that
wants to open a connection does.
"""
# The caller only needs to receive passive advertisements from the device.
PASSIVE_ADVERTISEMENT = "passive_advertisement"
# The caller needs scan response (SCAN_RSP) data, which is only received
# from a scanner that is actively scanning. Treated the same as
# PASSIVE_ADVERTISEMENT for now; kept distinct so the diagnostics can later
# report when no scanner seeing the device is actively scanning.
ACTIVE_ADVERTISEMENT = "active_advertisement"
# The caller needs to open an outbound connection to the device.
CONNECTION = "connection"
class BluetoothServiceInfo:
"""Prepared info from bluetooth entries."""
__slots__ = (
"address",
"manufacturer_data",
"name",
"rssi",
"service_data",
"service_uuids",
"source",
)
def __init__(
self,
name: _str, # may be a pyobjc object
address: _str, # may be a pyobjc object
rssi: _int, # may be a pyobjc object
manufacturer_data: dict[_int, bytes],
service_data: dict[_str, bytes],
service_uuids: list[_str],
source: _str,
) -> None:
"""Initialize a bluetooth service info."""
self.name = name
self.address = address
self.rssi = rssi
self.manufacturer_data = manufacturer_data
self.service_data = service_data
self.service_uuids = service_uuids
self.source = source
@classmethod
def from_advertisement(
cls,
device: BLEDevice,
advertisement_data: AdvertisementData,
source: str,
) -> Self:
"""Create a BluetoothServiceInfo from an advertisement."""
return cls(
advertisement_data.local_name or device.name or device.address,
device.address,
advertisement_data.rssi,
advertisement_data.manufacturer_data,
advertisement_data.service_data,
advertisement_data.service_uuids,
source,
)
@property
def manufacturer(self) -> str | None:
"""Convert manufacturer data to a string."""
# MANUFACTURERS is a multi-kilobyte dict; lazy-load so the
# cost is only paid by the rare caller that asks for the
# manufacturer name (most don't).
from bleak.backends._manufacturers import MANUFACTURERS # noqa: PLC0415
for manufacturer in self.manufacturer_data:
if manufacturer in MANUFACTURERS:
name: str = MANUFACTURERS[manufacturer]
return name
return None
@property
def manufacturer_id(self) -> int | None:
"""Get the first manufacturer id."""
for manufacturer in self.manufacturer_data:
return manufacturer
return None
class BluetoothServiceInfoBleak(BluetoothServiceInfo):
"""
BluetoothServiceInfo with bleak data.
Integrations may need BLEDevice and AdvertisementData
to connect to the device without having bleak trigger
another scan to translate the address to the system's
internal details.
"""
__slots__ = ("_advertisement", "connectable", "device", "raw", "time", "tx_power")
def __init__(
self,
name: _str, # may be a pyobjc object
address: _str, # may be a pyobjc object
rssi: _int, # may be a pyobjc object
manufacturer_data: dict[_int, bytes],
service_data: dict[_str, bytes],
service_uuids: list[_str],
source: _str,
device: BLEDevice,
advertisement: AdvertisementData | None,
connectable: bool,
time: _float,
tx_power: _int | None,
raw: bytes | None = None,
) -> None:
self.name = name
self.address = address
self.rssi = rssi
self.manufacturer_data = manufacturer_data
self.service_data = service_data
self.service_uuids = service_uuids
self.source = source
self.device = device
self._advertisement = advertisement
self.connectable = connectable
self.time = time
self.tx_power = tx_power
self.raw = raw
def __repr__(self) -> str:
"""Return the representation of the object."""
return (
f"<{self.__class__.__name__} "
f"name={self.name} "
f"address={self.address} "
f"rssi={self.rssi} "
f"manufacturer_data={self.manufacturer_data} "
f"service_data={self.service_data} "
f"service_uuids={self.service_uuids} "
f"source={self.source} "
f"connectable={self.connectable} "
f"time={self.time} "
f"tx_power={self.tx_power} "
f"raw={self.raw!r}>"
)
def _advertisement_internal(self) -> AdvertisementData:
"""
Get the advertisement data.
Internal method only to be used by this library.
"""
if self._advertisement is None:
self._advertisement = TUPLE_NEW(
AdvertisementData,
(
None if self.name == "" or self.name == self.address else self.name,
self.manufacturer_data,
self.service_data,
self.service_uuids,
NO_RSSI_VALUE if self.tx_power is None else self.tx_power,
self.rssi,
(),
),
)
return self._advertisement
@property
def advertisement(self) -> AdvertisementData:
"""Get the advertisement data."""
return self._advertisement_internal()
def as_dict(self) -> dict[str, Any]:
"""
Return as dict.
The dataclass asdict method is not used because
it will try to deepcopy pyobjc data which will fail.
"""
return {
"name": self.name,
"address": self.address,
"rssi": self.rssi,
"manufacturer_data": self.manufacturer_data,
"service_data": self.service_data,
"service_uuids": self.service_uuids,
"source": self.source,
"advertisement": self.advertisement,
"device": self.device,
"connectable": self.connectable,
"time": self.time,
"tx_power": self.tx_power,
"raw": self.raw,
}
@classmethod
def from_scan(
cls,
source: str,
device: BLEDevice,
advertisement_data: AdvertisementData,
monotonic_time: _float,
connectable: bool,
) -> Self:
"""Create a BluetoothServiceInfoBleak from a scanner."""
return cls(
advertisement_data.local_name or device.name or device.address,
device.address,
advertisement_data.rssi,
advertisement_data.manufacturer_data,
advertisement_data.service_data,
advertisement_data.service_uuids,
source,
device,
advertisement_data,
connectable,
monotonic_time,
advertisement_data.tx_power,
)
@classmethod
def from_device_and_advertisement_data(
cls,
device: BLEDevice,
advertisement_data: AdvertisementData,
source: str,
time: _float,
connectable: bool,
) -> Self:
"""Create a BluetoothServiceInfoBleak from a device and advertisement."""
return cls(
advertisement_data.local_name or device.name or device.address,
device.address,
advertisement_data.rssi,
advertisement_data.manufacturer_data,
advertisement_data.service_data,
advertisement_data.service_uuids,
source,
device,
advertisement_data,
connectable,
time,
advertisement_data.tx_power,
)
def _as_connectable(self) -> BluetoothServiceInfoBleak:
"""Return a connectable version of this object."""
new_obj = BluetoothServiceInfoBleak.__new__(BluetoothServiceInfoBleak)
new_obj.name = self.name
new_obj.address = self.address
new_obj.rssi = self.rssi
new_obj.manufacturer_data = self.manufacturer_data
new_obj.service_data = self.service_data
new_obj.service_uuids = self.service_uuids
new_obj.source = self.source
new_obj.device = self.device
new_obj._advertisement = self._advertisement
new_obj.connectable = True
new_obj.time = self.time
new_obj.tx_power = self.tx_power
new_obj.raw = self.raw
return new_obj
Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/py.typed 0000664 0000000 0000000 00000000000 15211177045 0024571 0 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/scanner.pxd 0000664 0000000 0000000 00000001723 15211177045 0025255 0 ustar 00root root 0000000 0000000 import cython
from .base_scanner cimport BaseHaScanner
from .models cimport BluetoothServiceInfoBleak
cdef object NO_RSSI_VALUE
cdef object AdvertisementData
cdef object BLEDevice
cdef bint TYPE_CHECKING
cdef object _NEW_SERVICE_INFO
cdef class HaScanner(BaseHaScanner):
cdef public object mac_address
cdef public object _start_stop_lock
cdef public object _background_tasks
cdef public object scanner
cdef public object _start_future
cdef public object _scan_mode_override
cdef public object _active_window_handle
cdef public double _active_window_end
@cython.locals(service_info=BluetoothServiceInfoBleak)
cpdef void _async_detection_callback(
self,
object device,
object advertisement_data
)
cpdef void _async_on_raw_bluez_advertisement(
self,
bytes address,
unsigned short address_type,
short rssi,
unsigned int flags,
bytes data,
) except *
Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/scanner.py 0000664 0000000 0000000 00000115371 15211177045 0025117 0 ustar 00root root 0000000 0000000 """A local bleak scanner."""
from __future__ import annotations
import asyncio
import contextlib
import logging
import math
import platform
from functools import lru_cache
from typing import TYPE_CHECKING, Any, no_type_check
import async_interrupt
import bleak
from bleak import BleakError
from bleak.assigned_numbers import AdvertisementDataType
from bleak_retry_connector import Allocations, restore_discoveries
from bleak_retry_connector.bluez import stop_discovery
from bluetooth_adapters import DEFAULT_ADDRESS
from bluetooth_data_tools import monotonic_time_coarse
from .base_scanner import BaseHaScanner
from .const import (
CALLBACK_TYPE,
SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT,
SOURCE_LOCAL,
START_TIMEOUT,
STOP_TIMEOUT,
)
from .models import BluetoothScanningMode, BluetoothServiceInfoBleak
from .util import async_reset_adapter, is_docker_env
if TYPE_CHECKING:
from collections.abc import Coroutine, Iterable
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback
int_ = int
SYSTEM = platform.system()
IS_LINUX = SYSTEM == "Linux"
IS_MACOS = SYSTEM == "Darwin"
if IS_LINUX:
from bleak.args.bluez import BlueZScannerArgs, OrPattern
from bleak.backends.bluezdbus.advertisement_monitor import (
AdvertisementMonitor,
)
from dbus_fast import InvalidMessageError
from dbus_fast.service import method
# or_patterns is a workaround for the fact that passive scanning
# needs at least one matcher to be set. The below matcher
# will match all devices.
PASSIVE_SCANNER_ARGS = BlueZScannerArgs(
or_patterns=[
OrPattern(0, AdvertisementDataType.FLAGS, b"\x02"),
OrPattern(0, AdvertisementDataType.FLAGS, b"\x06"),
OrPattern(0, AdvertisementDataType.FLAGS, b"\x1a"),
]
)
class HaAdvertisementMonitor(AdvertisementMonitor):
"""Implementation of the org.bluez.AdvertisementMonitor1 D-Bus interface."""
# Method names are dictated by the BlueZ AdvertisementMonitor1
# D-Bus interface; ``dbus_fast`` matches the Python attribute
# name against the interface, so the CamelCase form is required.
@method()
@no_type_check
def DeviceFound(self, device: o): # noqa: F821, N802
"""Device found."""
@method()
@no_type_check
def DeviceLost(self, device: o): # noqa: F821, N802
"""Device lost."""
AdvertisementMonitor.DeviceFound = HaAdvertisementMonitor.DeviceFound
AdvertisementMonitor.DeviceLost = HaAdvertisementMonitor.DeviceLost
else:
class InvalidMessageError(Exception): # type: ignore[no-redef]
"""Invalid message error."""
OriginalBleakScanner = bleak.BleakScanner
_LOGGER = logging.getLogger(__name__)
IN_PROGRESS_ERROR = "org.bluez.Error.InProgress"
# If the adapter is in a stuck state the following errors are raised:
NEED_RESET_ERRORS = [
"org.bluez.Error.Failed",
IN_PROGRESS_ERROR,
"org.bluez.Error.NotReady",
"not found",
]
# When the adapter is still initializing, the scanner will raise an exception
# with org.freedesktop.DBus.Error.UnknownObject
WAIT_FOR_ADAPTER_TO_INIT_ERRORS = ["org.freedesktop.DBus.Error.UnknownObject"]
ADAPTER_INIT_TIME = 1.5
START_ATTEMPTS = 4
SCANNING_MODE_TO_BLEAK = {
BluetoothScanningMode.ACTIVE: "active",
BluetoothScanningMode.PASSIVE: "passive",
}
def _resolve_radio_mode(mode: BluetoothScanningMode) -> BluetoothScanningMode:
"""
Resolve AUTO to the underlying mode the radio actually runs in.
AUTO is a habluetooth scheduling concept, not a radio state. The
backend always runs in either passive or active. current_mode is
supposed to reflect that real state so diagnostics and the manager
callbacks line up with what remote scanners (e.g. ESPHome) already
report; otherwise local adapters look stuck on "auto" forever.
Single source of truth for the AUTO -> radio mapping; both
create_bleak_scanner and the active-window toggle defer here so a
future platform change (or a new platform) only needs to update
this one function.
"""
if mode is BluetoothScanningMode.AUTO:
return (
BluetoothScanningMode.ACTIVE if IS_MACOS else BluetoothScanningMode.PASSIVE
)
return mode
# The minimum number of seconds to know
# the adapter has not had advertisements
# and we already tried to restart the scanner
# without success when the first time the watch
# dog hit the failure path.
SCANNER_WATCHDOG_MULTIPLE = (
SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds()
)
class _AbortStartError(Exception):
"""Error to indicate that the start should be aborted."""
class ScannerStartError(Exception):
"""Error to indicate that the scanner failed to start."""
def create_bleak_scanner(
detection_callback: AdvertisementDataCallback | None,
scanning_mode: BluetoothScanningMode,
adapter: str | None,
) -> bleak.BleakScanner:
"""Create a Bleak scanner."""
# Resolve AUTO before doing anything else so the rest of this
# function only ever sees ACTIVE or PASSIVE; CoreBluetooth has no
# passive mode so AUTO collapses to ACTIVE on macOS (the radio
# just stays in active and async_request_active_window is a no-op
# there), and Linux/other platforms start AUTO in passive with
# the scheduler flipping to active on demand.
scanning_mode = _resolve_radio_mode(scanning_mode)
scanner_kwargs: dict[str, Any] = {
"scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode],
}
if detection_callback:
scanner_kwargs["detection_callback"] = detection_callback
if IS_LINUX:
# Only Linux supports multiple adapters
bluez_args: BlueZScannerArgs = {}
# bleak's passive scanner needs at least one or_pattern matcher
# or it won't start. AUTO has been resolved to PASSIVE above on
# Linux (the scheduler restarts with scan_mode_override=ACTIVE
# to flip to active on demand, which lands here as ACTIVE and
# skips this branch).
if scanning_mode is BluetoothScanningMode.PASSIVE:
bluez_args = dict(PASSIVE_SCANNER_ARGS)
if adapter:
# bleak 3.0 deprecated the top-level ``adapter`` kwarg in favor of
# the ``bluez`` kwarg; this form is supported across bleak 1.x-3.x.
bluez_args["adapter"] = adapter
if bluez_args:
scanner_kwargs["bluez"] = bluez_args
elif IS_MACOS:
# We want mac address on macOS
scanner_kwargs["cb"] = {"use_bdaddr": True}
_LOGGER.debug("Initializing bluetooth scanner with %s", scanner_kwargs)
try:
return OriginalBleakScanner(**scanner_kwargs)
except (FileNotFoundError, BleakError) as ex:
msg = f"Failed to initialize Bluetooth: {ex}"
raise RuntimeError(msg) from ex
def _error_indicates_reset_needed(error_str: str) -> bool:
"""Return if the error indicates a reset is needed."""
return any(
needs_reset_error in error_str for needs_reset_error in NEED_RESET_ERRORS
)
def _error_indicates_wait_for_adapter_to_init(error_str: str) -> bool:
"""Return if the error indicates the adapter is still initializing."""
return any(
wait_error in error_str for wait_error in WAIT_FOR_ADAPTER_TO_INIT_ERRORS
)
@lru_cache(maxsize=512)
def bytes_mac_to_str(mac: bytes) -> str:
"""Convert a MAC address in bytes to a string in big-endian (MSB-first) order."""
return ":".join(f"{b:02X}" for b in reversed(mac))
@lru_cache(maxsize=512)
def make_bluez_details(address: str, adapter: str) -> dict[str, Any]:
"""Make the details for a bluez advertisement."""
base_path = f"/org/bluez/{adapter}"
return {
"path": f"{base_path}/dev_{address.replace(':', '_')}",
"props": {
"Adapter": base_path,
},
}
class HaScanner(BaseHaScanner):
"""
Operate and automatically recover a BleakScanner.
Multiple BleakScanner can be used at the same time
if there are multiple adapters. This is only useful
if the adapters are not located physically next to each other.
Example use cases are usbip, a long extension cable, usb to bluetooth
over ethernet, usb over ethernet, etc.
"""
__slots__ = (
"_active_window_end",
"_active_window_handle",
"_background_tasks",
"_scan_mode_override",
"_start_future",
"_start_stop_lock",
"mac_address",
"scanner",
)
def __init__(
self,
mode: BluetoothScanningMode,
adapter: str,
address: str,
) -> None:
"""Init bluetooth discovery."""
self.mac_address = address
source = address if address != DEFAULT_ADDRESS else adapter or SOURCE_LOCAL
super().__init__(source, adapter, requested_mode=mode)
self.connectable = True
self._start_stop_lock = asyncio.Lock()
self.scanning = False
self._background_tasks: set[asyncio.Task[Any]] = set()
self.scanner: bleak.BleakScanner | None = None
self._start_future: asyncio.Future[None] | None = None
# Set while an on-demand active window (auto-mode) is in flight.
# When set, `_async_start_attempt` uses this mode instead of
# `requested_mode`. `requested_mode` itself stays at AUTO so external
# listeners still see the integration's intent.
self._scan_mode_override: BluetoothScanningMode | None = None
self._active_window_handle: asyncio.TimerHandle | None = None
self._active_window_end: float = 0.0
def _create_background_task(self, coro: Coroutine[Any, Any, None]) -> None:
"""Create a background task and add it to the background tasks set."""
task = asyncio.create_task(coro)
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
if not self.scanner:
return []
return self.scanner.discovered_devices
@property
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Return a list of discovered devices and advertisement data."""
if not self.scanner:
return {}
return self.scanner.discovered_devices_and_advertisement_data
@property
def discovered_addresses(self) -> Iterable[str]:
"""Return an iterable of discovered devices."""
return self.discovered_devices_and_advertisement_data
def get_discovered_device_advertisement_data(
self, address: str
) -> tuple[BLEDevice, AdvertisementData] | None:
"""Return the advertisement data for a discovered device."""
return self.discovered_devices_and_advertisement_data.get(address)
def get_allocations(self) -> Allocations | None:
"""Get current connection slot allocations from BleakSlotManager."""
if self._manager and self._manager.slot_manager:
return self._manager.slot_manager.get_allocations(self.adapter)
return None
def async_setup(self) -> CALLBACK_TYPE:
"""Set up the scanner."""
super().async_setup()
return self._unsetup
async def async_diagnostics(self) -> dict[str, Any]:
"""Return diagnostic information about the scanner."""
base_diag = await super().async_diagnostics()
return base_diag | {"adapter": self.adapter}
def _async_on_raw_bluez_advertisement(
self,
address: bytes,
address_type: int_,
rssi: int_,
flags: int_,
data: bytes,
) -> None:
"""Handle raw advertisement data."""
address_str = bytes_mac_to_str(address)
self._async_on_raw_advertisement(
address_str,
rssi,
data,
make_bluez_details(address_str, self.adapter),
monotonic_time_coarse(),
)
def _async_detection_callback(
self,
device: BLEDevice,
advertisement_data: AdvertisementData,
) -> None:
"""
Call the callback when an advertisement is received.
Currently this is used to feed the callbacks into the
central manager.
"""
callback_time = monotonic_time_coarse()
address = device.address
local_name = advertisement_data.local_name
manufacturer_data = advertisement_data.manufacturer_data
service_data = advertisement_data.service_data
service_uuids = advertisement_data.service_uuids
if local_name or manufacturer_data or service_data or service_uuids:
# Don't count empty advertisements
# as the adapter is in a failure
# state if all the data is empty.
self._last_detection = callback_time
name = local_name or device.name or address
if name is not None and type(name) is not str:
name = str(name)
tx_power = advertisement_data.tx_power
if tx_power is not None and type(tx_power) is not int:
tx_power = int(tx_power)
service_info = BluetoothServiceInfoBleak.__new__(BluetoothServiceInfoBleak)
service_info.name = name
service_info.address = address
service_info.rssi = advertisement_data.rssi
service_info.manufacturer_data = manufacturer_data
service_info.service_data = service_data
service_info.service_uuids = service_uuids
service_info.source = self.source
service_info.device = device
service_info._advertisement = advertisement_data
service_info.connectable = True
service_info.time = callback_time
service_info.tx_power = tx_power
service_info.raw = None # not available in bleak.
self._manager._scanner_adv_received(service_info)
async def async_start(self) -> None:
"""Start bluetooth scanner."""
async with self._start_stop_lock:
await self._async_start()
async def _async_start(self) -> None:
"""Start bluetooth scanner under the lock."""
for attempt in range(1, START_ATTEMPTS + 1):
if await self._async_start_attempt(attempt):
# Everything is fine, break out of the loop
break
await self._async_on_successful_start()
async def _async_on_successful_start(self) -> None:
"""Run when the scanner has successfully started."""
self.scanning = True
self._async_setup_scanner_watchdog()
await restore_discoveries(self.scanner, self.adapter)
def _effective_mode(self) -> BluetoothScanningMode | None:
"""
Mode the scanner should actually start in.
Override beats requested_mode so the scheduler can flip AUTO
to ACTIVE for an on-demand window without losing intent.
"""
return self._scan_mode_override or self.requested_mode
async def _async_start_attempt(self, attempt: int) -> bool: # noqa: C901
"""Start the scanner and handle errors."""
assert ( # noqa: S101
self._loop is not None
), "Loop is not set, call async_setup first"
effective_mode = self._effective_mode()
radio_mode = (
_resolve_radio_mode(effective_mode) if effective_mode is not None else None
)
self.set_current_mode(radio_mode)
# 1st attempt - no auto reset
# 2nd attempt - try to reset the adapter and wait a bit
# 3th attempt - no auto reset
# 4th attempt - fallback to passive if available
if (
IS_LINUX
and attempt == START_ATTEMPTS
and radio_mode is BluetoothScanningMode.ACTIVE
):
_LOGGER.debug(
"%s: Falling back to passive scanning mode "
"after active scanning failed (%s/%s)",
self.name,
attempt,
START_ATTEMPTS,
)
self.set_current_mode(BluetoothScanningMode.PASSIVE)
assert self.current_mode is not None # noqa: S101
self.scanner = create_bleak_scanner(
(
None
if self._manager.has_advertising_side_channel
else self._async_detection_callback
),
self.current_mode,
self.adapter,
)
# If the scanner is already running, trying to start it again
# can result in a deadlock. So we need to stop it first.
# hci0: Opcode 0x200b failed: -110
# hci0: start background scanning failed: -110
# hci0: Controller not accepting commands anymore: ncmd = 0
# hci0: Injecting HCI hardware error event
# hci0: hardware error 0x00
await self._async_force_stop_discovery()
self._log_start_attempt(attempt)
self._start_future = self._loop.create_future()
try:
async with (
asyncio.timeout(START_TIMEOUT),
async_interrupt.interrupt(self._start_future, _AbortStartError, None),
):
await self.scanner.start()
except _AbortStartError as ex:
await self._async_stop_scanner()
self._raise_for_abort_start(ex)
except InvalidMessageError as ex:
await self._async_stop_scanner()
self._raise_for_invalid_dbus_message(ex)
except BrokenPipeError as ex:
await self._async_stop_scanner()
self._raise_for_broken_pipe_error(ex)
except FileNotFoundError as ex:
await self._async_stop_scanner()
self._raise_for_file_not_found_error(ex)
except TimeoutError as ex:
await self._async_stop_scanner()
if attempt == 2:
await self._async_reset_adapter(False)
if attempt < START_ATTEMPTS:
self._log_start_timeout(attempt)
return False
msg = (
f"{self.name}: Timed out starting Bluetooth after"
f" {START_TIMEOUT} seconds; "
"Try power cycling the Bluetooth hardware."
)
raise ScannerStartError(msg) from ex
except BleakError as ex:
await self._async_stop_scanner()
error_str = str(ex)
if IN_PROGRESS_ERROR in error_str:
# If discovery is stuck on, try to force stop it
await self._async_force_stop_discovery()
if attempt == 2 and _error_indicates_reset_needed(error_str):
await self._async_reset_adapter(False)
elif (
attempt != START_ATTEMPTS
and _error_indicates_wait_for_adapter_to_init(error_str)
):
# If we are not out of retry attempts, and the
# adapter is still initializing, wait a bit and try again.
self._log_adapter_init_wait(attempt)
await asyncio.sleep(ADAPTER_INIT_TIME)
if attempt < START_ATTEMPTS:
self._log_start_failed(ex, attempt)
return False
msg = (
f"{self.name}: Failed to start Bluetooth: {ex}; "
"Try power cycling the Bluetooth hardware."
)
raise ScannerStartError(msg) from ex
except BaseException:
await self._async_stop_scanner()
raise
finally:
self._start_future = None
self._log_start_success(attempt, radio_mode)
self._on_start_success()
return True
def _log_adapter_init_wait(self, attempt: int) -> None:
_LOGGER.debug(
"%s: Waiting for adapter to initialize; attempt (%s/%s)",
self.name,
attempt,
START_ATTEMPTS,
)
def _log_start_success(
self, attempt: int, radio_mode: BluetoothScanningMode | None
) -> None:
# Compare against the resolved radio mode we *tried* to start
# in rather than requested_mode: an AUTO scanner mid-active-
# window has requested_mode=AUTO but radio_mode=ACTIVE, and we
# don't want to warn "fell back to passive" when the active
# restart actually succeeded.
if self.current_mode is not radio_mode:
_LOGGER.warning(
"%s: Successful fall-back to passive scanning mode "
"after active scanning failed (%s/%s)",
self.name,
attempt,
START_ATTEMPTS,
)
_LOGGER.debug(
"%s: Success while starting bluetooth; attempt: (%s/%s)",
self.name,
attempt,
START_ATTEMPTS,
)
def _log_start_timeout(self, attempt: int) -> None:
_LOGGER.debug(
"%s: TimeoutError while starting bluetooth; attempt: (%s/%s)",
self.name,
attempt,
START_ATTEMPTS,
)
def _log_start_failed(self, ex: BleakError, attempt: int) -> None:
_LOGGER.debug(
"%s: BleakError while starting bluetooth; attempt: (%s/%s): %s",
self.name,
attempt,
START_ATTEMPTS,
ex,
exc_info=ex,
)
def _log_start_attempt(self, attempt: int) -> None:
_LOGGER.debug(
"%s: Starting bluetooth discovery attempt: (%s/%s)",
self.name,
attempt,
START_ATTEMPTS,
)
def _raise_for_abort_start(self, ex: _AbortStartError) -> None:
_LOGGER.debug(
"%s: Starting bluetooth scanner aborted: %s",
self.name,
ex,
exc_info=ex,
)
msg = f"{self.name}: Starting bluetooth scanner aborted"
raise ScannerStartError(msg) from ex
def _raise_for_file_not_found_error(self, ex: FileNotFoundError) -> None:
_LOGGER.debug(
"%s: FileNotFoundError while starting bluetooth: %s",
self.name,
ex,
exc_info=ex,
)
if is_docker_env():
msg = (
f"{self.name}: DBus service not found; docker config may "
"be missing `-v /run/dbus:/run/dbus:ro`: {ex}"
)
raise ScannerStartError(msg) from ex
msg = (
f"{self.name}: DBus service not found; make sure the DBus socket "
f"is available: {ex}"
)
raise ScannerStartError(msg) from ex
def _raise_for_broken_pipe_error(self, ex: BrokenPipeError) -> None:
"""Raise a ScannerStartError for a BrokenPipeError."""
_LOGGER.debug("%s: DBus connection broken: %s", self.name, ex, exc_info=ex)
if is_docker_env():
msg = (
f"{self.name}: DBus connection broken: {ex}; try restarting "
"`bluetooth`, `dbus`, and finally the docker container"
)
else:
msg = (
f"{self.name}: DBus connection broken: {ex}; try restarting "
"`bluetooth` and `dbus`"
)
raise ScannerStartError(msg) from ex
def _raise_for_invalid_dbus_message(self, ex: InvalidMessageError) -> None:
"""Raise a ScannerStartError for an InvalidMessageError."""
_LOGGER.debug(
"%s: Invalid DBus message received: %s",
self.name,
ex,
exc_info=ex,
)
msg = f"{self.name}: Invalid DBus message received: {ex}; try restarting `dbus`"
raise ScannerStartError(msg) from ex
def _describe_side_channel_state(self) -> str:
"""Summarize where this scanner expects advertisements to come from."""
manager = self._manager
idx = self.adapter_idx
if idx is None:
return "no adapter_idx; bleak detection_callback path"
if not manager.has_advertising_side_channel:
return "MGMT side channel unavailable; bleak detection_callback path"
registered = manager._side_channel_scanners.get(idx)
if registered is None:
return f"MGMT side channel up but hci{idx} unregistered"
if registered is not self:
return f"MGMT side channel at hci{idx} bound to a different scanner"
mgmt_ctl = manager._mgmt_ctl
protocol = getattr(mgmt_ctl, "protocol", None) if mgmt_ctl else None
if protocol is None:
return f"MGMT side channel registered at hci{idx} but protocol down"
if protocol.transport is None:
return f"MGMT side channel registered at hci{idx} but transport closed"
return f"MGMT side channel feeding hci{idx}"
def _async_scanner_watchdog(self) -> None:
"""Check if the scanner is running."""
if not self._async_watchdog_triggered():
return
if self._start_stop_lock.locked():
_LOGGER.debug(
"%s: Scanner is already restarting, deferring restart",
self.name,
)
return
_LOGGER.debug(
"%s: Bluetooth scanner has gone quiet for %ss (%s), restarting",
self.name,
self.time_since_last_detection(),
self._describe_side_channel_state(),
)
# Immediately mark the scanner as not scanning
# since the restart task will have to wait for the lock
self.scanning = False
self._create_background_task(self._async_restart_scanner())
async def _async_restart_scanner(self) -> None:
"""Restart the scanner."""
async with self._start_stop_lock:
# Stop the scanner but not the watchdog
# since we want to try again later if it's still quiet
await self._async_stop_scanner()
# If there have not been any valid advertisements,
# or the watchdog has hit the failure path multiple times,
# do the reset.
if (
self._start_time == self._last_detection
or self.time_since_last_detection() > SCANNER_WATCHDOG_MULTIPLE
):
await self._async_reset_adapter(True)
try:
await self._async_start()
except ScannerStartError:
_LOGGER.exception(
"%s: Failed to restart Bluetooth scanner",
self.name,
)
async def _async_reset_adapter(self, gone_silent: bool) -> None:
"""Reset the adapter."""
# There is currently nothing the user can do to fix this
# so we log at debug level. If we later come up with a repair
# strategy, we will change this to raise a repair issue as well.
_LOGGER.debug("%s: adapter stopped responding; executing reset", self.name)
result = await async_reset_adapter(self.adapter, self.mac_address, gone_silent)
_LOGGER.debug("%s: adapter reset result: %s", self.name, result)
async def async_stop(self) -> None:
"""Stop bluetooth scanner."""
if self._start_future is not None and not self._start_future.done():
self._start_future.set_exception(_AbortStartError())
async with self._start_stop_lock:
self._clear_active_window_state()
self._async_stop_scanner_watchdog()
await self._async_stop_scanner()
def _clear_active_window_state(self) -> None:
"""Reset AUTO active-window state (caller must hold start/stop lock)."""
if self._active_window_handle is not None:
self._active_window_handle.cancel()
self._active_window_handle = None
self._scan_mode_override = None
self._active_window_end = 0.0
def _arm_active_window_timer_if_extends(self, duration: float) -> None:
"""
Re-arm the timer only if the new duration extends the window.
Shorter callers no-op so they can't shrink a window another
caller is depending on.
"""
if TYPE_CHECKING:
assert self._loop is not None
if self._loop.time() + duration > self._active_window_end:
self._arm_active_window_timer(duration)
def _arm_active_window_timer(self, duration: float) -> None:
"""
Schedule the end-of-window callback.
Stores ``_active_window_end`` from ``loop.time()`` at arming
time so it matches the real ``call_later`` fire time (a
pre-restart snapshot would let a shorter follow-up masquerade
as an extension). Cancels any existing handle first to avoid
leaking a pending timer.
"""
if TYPE_CHECKING:
assert self._loop is not None
if self._active_window_handle is not None:
self._active_window_handle.cancel()
self._active_window_end = self._loop.time() + duration
self._active_window_handle = self._loop.call_later(
duration, self._schedule_end_active_window
)
async def async_request_active_window(self, duration: float) -> bool:
"""
Run an active scan for ``duration`` seconds then restore prior mode.
No-op on non-AUTO scanners. On macOS AUTO is permanent active
(no passive mode in CoreBluetooth), so a no-op success there.
Concurrent / repeat callers while a window is open: a longer
follow-up extends the timer; a shorter follow-up is a no-op
on the timer but still returns True. No second restart fires.
Rejects non-finite or non-positive ``duration`` so a stray
NaN/inf can't poison ``loop.call_later`` or the extension
comparison; the scheduler clamps before calling but other
callers (subclasses, tests) may not.
"""
if self.requested_mode is not BluetoothScanningMode.AUTO:
return False
if not math.isfinite(duration) or duration <= 0.0:
_LOGGER.warning(
"%s: refusing active window with invalid duration %r",
self.name,
duration,
)
return False
if IS_MACOS:
return True
if TYPE_CHECKING:
assert self._loop is not None
if self._active_window_handle is not None:
self._arm_active_window_timer_if_extends(duration)
return True
async with self._start_stop_lock:
self._scan_mode_override = BluetoothScanningMode.ACTIVE
# If the scanner is still ACTIVE here, the end-of-window task
# for the previous timer is queued but hasn't run yet (it
# would have cleared current_mode to PASSIVE). Skip the
# restart; same extend-only rule as the lockless fast path.
if self.current_mode is BluetoothScanningMode.ACTIVE:
self._arm_active_window_timer_if_extends(duration)
return True
if IS_LINUX:
entered = await self._async_begin_active_window_via_toggle()
else:
entered = await self._async_begin_active_window_via_restart()
if not entered:
return False
self._arm_active_window_timer(duration)
return True
async def _async_begin_active_window_via_toggle(self) -> bool:
"""
Cheap Linux/BlueZ entry via in-place ``_scanning_mode`` flip.
Caller holds ``_start_stop_lock`` and has set the override.
On failure clears the override and recovers via a full
restart so the scanner isn't left stopped.
"""
try:
flipped = await self._async_toggle_active_window_mode()
except BaseException:
# Any error (CancelledError, SystemExit, leaked BleakError,
# etc.) must not leave the override stuck at ACTIVE for
# the next start. Clear and re-raise.
self._scan_mode_override = None
raise
if not flipped:
return await self._async_abort_active_window()
return True
async def _async_begin_active_window_via_restart(self) -> bool:
"""
Non-Linux entry via full stop+recreate+start in ACTIVE mode.
Caller holds ``_start_stop_lock`` and has set the override so
the fresh BleakScanner is constructed in ACTIVE. On
ScannerStartError or the Linux 4th-attempt PASSIVE fallback
the override is cleared and False is returned.
"""
try:
await self._async_stop_then_start_under_lock()
except ScannerStartError:
return await self._async_abort_active_window()
except BaseException:
self._scan_mode_override = None
raise
if self.current_mode is not BluetoothScanningMode.ACTIVE:
self._scan_mode_override = None
return False
return True
async def _async_abort_active_window(self) -> bool:
"""
Roll back a failed active-window entry.
Clears the ACTIVE override and runs a best-effort
stop+restart so the scanner comes back up in its underlying
AUTO/passive mode rather than being left stopped. Returns
False so callers can ``return await self._async_abort_...``.
"""
self._scan_mode_override = None
with contextlib.suppress(ScannerStartError):
await self._async_stop_then_start_under_lock()
return False
def _schedule_end_active_window(self) -> None:
"""Spawn the end-of-window restart task."""
self._active_window_handle = None
self._create_background_task(self._async_end_active_window())
async def _async_end_active_window(self) -> None:
"""Restore the scanner to its underlying mode after the window ends."""
async with self._start_stop_lock:
if self._active_window_handle is not None:
# A new window took over; let it own the override and timer.
return
self._scan_mode_override = None
if not self.scanning:
return
if IS_LINUX and await self._async_toggle_active_window_mode():
return
# Non-Linux backend, or toggle failed; full restart so we
# don't leave the scanner stuck in ACTIVE.
try:
await self._async_stop_then_start_under_lock()
except ScannerStartError as ex:
_LOGGER.warning(
"%s: Failed to restart scanner after active window: %s",
self.name,
ex,
)
async def _async_stop_then_start_under_lock(self) -> None:
"""
Stop and restart the BleakScanner; caller holds _start_stop_lock.
Full teardown: nulls ``self.scanner`` and constructs a fresh
one. AUTO active-window flips on Linux use
``_async_toggle_active_window_mode`` instead to skip the dbus
setup + ``restore_discoveries`` cost.
"""
await self._async_stop_scanner()
await self._async_start()
async def _async_toggle_active_window_mode(self) -> bool:
"""
Toggle the existing BleakScanner between active and passive.
Stops the live ``self.scanner``, mutates its private
``_backend._scanning_mode`` to the value from
``_effective_mode()``, restarts the same instance. Skips the
new dbus client + ``restore_discoveries`` cost of a fresh
construction; bleak's device cache survives same-instance
stop+start so ``BleakClient(address)`` keeps working.
Linux/BlueZ only — callers must check ``IS_LINUX``. Returns
False if the scanner is gone or stop/start raised (caller
falls back to the full path).
"""
if self.scanner is None:
return False
effective_mode = self._effective_mode()
if TYPE_CHECKING:
assert effective_mode is not None
radio_mode = _resolve_radio_mode(effective_mode)
mode_str = SCANNING_MODE_TO_BLEAK[radio_mode]
try:
async with asyncio.timeout(STOP_TIMEOUT):
await self.scanner.stop()
except (TimeoutError, BleakError) as ex:
_LOGGER.warning(
"%s: Error stopping scanner during active-window flip: %s",
self.name,
ex,
)
# The bleak scanner may be in an undefined state; mark
# the wrapper not-scanning so the caller's fallback path
# treats it as stopped.
self.scanning = False
return False
# Private bleak attribute — no public API for mode change.
# BlueZ reads it on every start; macOS isn't reachable here.
# Guarded so a future bleak refactor that renames/drops the
# attribute can't leave the scanner stopped with no restart;
# caller falls back to the full stop+recreate+start path.
try:
self.scanner._backend._scanning_mode = mode_str
except AttributeError as ex:
_LOGGER.warning(
"%s: bleak _backend._scanning_mode unavailable; "
"cannot toggle in place: %s",
self.name,
ex,
)
self.scanning = False
return False
try:
async with asyncio.timeout(START_TIMEOUT):
await self.scanner.start()
except (TimeoutError, BleakError, ScannerStartError) as ex:
_LOGGER.warning(
"%s: Error starting scanner during active-window flip: %s",
self.name,
ex,
)
# Scanner was stopped above and didn't come back; mark
# not-scanning so it matches reality.
self.scanning = False
return False
self.scanning = True
self.set_current_mode(radio_mode)
return True
async def _async_stop_scanner(self) -> None:
"""Stop bluetooth discovery under the lock."""
self.scanning = False
if self.scanner is None:
_LOGGER.debug("%s: Scanner is already stopped", self.name)
return
_LOGGER.debug("%s: Stopping bluetooth discovery", self.name)
try:
async with asyncio.timeout(STOP_TIMEOUT):
await self.scanner.stop()
except (TimeoutError, BleakError):
# This is not fatal, and they may want to reload
# the config entry to restart the scanner if they
# change the bluetooth dongle.
_LOGGER.exception("%s: Error stopping scanner", self.name)
self.scanner = None
async def _async_force_stop_discovery(self) -> None:
"""Force stop discovery."""
_LOGGER.debug("%s: Force stopping bluetooth discovery", self.name)
try:
async with asyncio.timeout(STOP_TIMEOUT):
await stop_discovery(self.adapter)
except TimeoutError:
_LOGGER.exception("%s: Timeout force stopping scanner", self.name)
except Exception:
# Best-effort BlueZ cleanup; dbus_fast can raise a wide
# variety of errors and we don't want any of them to
# propagate out of the recovery path.
_LOGGER.exception("%s: Failed to force stop scanner", self.name)
Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/scanner_device.py 0000664 0000000 0000000 00000001344 15211177045 0026430 0 ustar 00root root 0000000 0000000 """Base classes for HA Bluetooth scanners for bluetooth."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from .base_scanner import BaseHaScanner
@dataclass(slots=True)
class BluetoothScannerDevice:
"""Data for a bluetooth device from a given scanner."""
scanner: BaseHaScanner
ble_device: BLEDevice
advertisement: AdvertisementData
def score_connection_path(self, rssi_diff: int) -> float:
"""Return a score for the connection path to this device."""
return self.scanner._score_connection_paths(rssi_diff, self)
Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/storage.py 0000664 0000000 0000000 00000025277 15211177045 0025137 0 ustar 00root root 0000000 0000000 """Serialize/Deserialize bluetooth adapter discoveries."""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Final, TypedDict
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
_LOGGER = logging.getLogger(__name__)
@dataclass
class DiscoveredDeviceAdvertisementData:
"""Discovered device advertisement data deserialized from storage."""
connectable: bool
expire_seconds: float
discovered_device_advertisement_datas: dict[
str, tuple[BLEDevice, AdvertisementData]
]
discovered_device_timestamps: dict[str, float]
discovered_device_raw: dict[str, bytes | None] = field(default_factory=dict)
CONNECTABLE: Final = "connectable"
EXPIRE_SECONDS: Final = "expire_seconds"
DISCOVERED_DEVICE_ADVERTISEMENT_DATAS: Final = "discovered_device_advertisement_datas"
DISCOVERED_DEVICE_TIMESTAMPS: Final = "discovered_device_timestamps"
DISCOVERED_DEVICE_RAW: Final = "discovered_device_raw"
class DiscoveredDeviceAdvertisementDataDict(TypedDict):
"""Discovered device advertisement data dict in storage."""
connectable: bool
expire_seconds: float
discovered_device_advertisement_datas: dict[str, DiscoveredDeviceDict]
discovered_device_timestamps: dict[str, float]
discovered_device_raw: dict[str, str | None]
ADDRESS: Final = "address"
NAME: Final = "name"
RSSI: Final = "rssi"
DETAILS: Final = "details"
class BLEDeviceDict(TypedDict):
"""BLEDevice dict."""
address: str
name: str | None
rssi: int | None # Kept for backward compatibility
details: dict[str, Any]
LOCAL_NAME: Final = "local_name"
MANUFACTURER_DATA: Final = "manufacturer_data"
SERVICE_DATA: Final = "service_data"
SERVICE_UUIDS: Final = "service_uuids"
TX_POWER: Final = "tx_power"
PLATFORM_DATA: Final = "platform_data"
class AdvertisementDataDict(TypedDict):
"""AdvertisementData dict."""
local_name: str | None
manufacturer_data: dict[str, str]
service_data: dict[str, str]
service_uuids: list[str]
rssi: int
tx_power: int | None
platform_data: list[Any]
class DiscoveredDeviceDict(TypedDict):
"""Discovered device dict."""
device: BLEDeviceDict
advertisement_data: AdvertisementDataDict
def expire_stale_scanner_discovered_device_advertisement_data(
data_by_scanner: dict[str, DiscoveredDeviceAdvertisementDataDict],
) -> None:
"""Expire stale discovered device advertisement data."""
now = time.time()
expired_scanners: list[str] = []
for scanner, data in data_by_scanner.items():
expire: list[str] = []
expire_seconds = data[EXPIRE_SECONDS]
timestamps = data[DISCOVERED_DEVICE_TIMESTAMPS]
discovered_device_advertisement_datas = data[
DISCOVERED_DEVICE_ADVERTISEMENT_DATAS
]
discovered_device_raw = data.get(DISCOVERED_DEVICE_RAW, {})
for address, timestamp in timestamps.items():
time_diff = now - timestamp
if time_diff > expire_seconds:
expire.append(address)
elif time_diff < 0:
_LOGGER.warning(
"Discarding timestamp %s for %s on "
"scanner %s as it is the future (now = %s)",
timestamp,
address,
scanner,
now,
)
expire.append(address)
for address in expire:
del timestamps[address]
del discovered_device_advertisement_datas[address]
discovered_device_raw.pop(address, None)
if not timestamps:
expired_scanners.append(scanner)
_LOGGER.debug(
"Loaded %s fresh discovered devices for %s", len(timestamps), scanner
)
for scanner in expired_scanners:
del data_by_scanner[scanner]
def discovered_device_advertisement_data_from_dict(
data: DiscoveredDeviceAdvertisementDataDict,
) -> DiscoveredDeviceAdvertisementData | None:
"""Build discovered_device_advertisement_data dict."""
try:
return DiscoveredDeviceAdvertisementData(
data[CONNECTABLE],
data[EXPIRE_SECONDS],
_deserialize_discovered_device_advertisement_datas(
data[DISCOVERED_DEVICE_ADVERTISEMENT_DATAS]
),
_deserialize_discovered_device_timestamps(
data[DISCOVERED_DEVICE_TIMESTAMPS]
),
_deserialize_discovered_device_raw(data.get(DISCOVERED_DEVICE_RAW, {})),
)
except (KeyError, ValueError, TypeError):
_LOGGER.warning(
"Discovery cache shape mismatch, discarding cache; "
"adapter startup will be slow"
)
except Exception: # pylint: disable=broad-except
_LOGGER.exception(
"Unexpected error deserializing discovered_device_advertisement_data, "
"adapter startup will be slow"
)
return None
def discovered_device_advertisement_data_to_dict(
data: DiscoveredDeviceAdvertisementData,
) -> DiscoveredDeviceAdvertisementDataDict:
"""Build discovered_device_advertisement_data dict."""
return DiscoveredDeviceAdvertisementDataDict(
connectable=data.connectable,
expire_seconds=data.expire_seconds,
discovered_device_advertisement_datas=_serialize_discovered_device_advertisement_datas(
data.discovered_device_advertisement_datas
),
discovered_device_timestamps=_serialize_discovered_device_timestamps(
data.discovered_device_timestamps
),
discovered_device_raw=_serialize_discovered_device_raw(
data.discovered_device_raw
),
)
def _serialize_discovered_device_advertisement_datas(
discovered_device_advertisement_datas: dict[
str, tuple[BLEDevice, AdvertisementData]
],
) -> dict[str, DiscoveredDeviceDict]:
"""Serialize discovered_device_advertisement_datas."""
return {
address: DiscoveredDeviceDict(
device=_ble_device_to_dict(device, advertisement_data),
advertisement_data=_advertisement_data_to_dict(advertisement_data),
)
for (
address,
(device, advertisement_data),
) in discovered_device_advertisement_datas.items()
}
def _deserialize_discovered_device_advertisement_datas(
discovered_device_advertisement_datas: dict[str, DiscoveredDeviceDict],
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Deserialize discovered_device_advertisement_datas."""
return {
address: (
_ble_device_from_dict(device_advertisement_data["device"]),
_advertisement_data_from_dict(
device_advertisement_data["advertisement_data"]
),
)
for (
address,
device_advertisement_data,
) in discovered_device_advertisement_datas.items()
}
def _ble_device_from_dict(device_dict: BLEDeviceDict) -> BLEDevice:
"""Deserialize BLEDevice from dict, handling backward compatibility."""
# Remove rssi from dict as BLEDevice no longer accepts it in bleak 1.x
device_data = device_dict.copy()
device_data.pop("rssi", None) # type: ignore[misc] # Remove rssi if present (backward compatibility)
return BLEDevice(**device_data)
def _ble_device_to_dict(
ble_device: BLEDevice, advertisement_data: AdvertisementData
) -> BLEDeviceDict:
"""Serialize ble_device."""
return BLEDeviceDict(
address=ble_device.address,
name=ble_device.name,
rssi=advertisement_data.rssi, # For backward compatibility
details=ble_device.details,
)
def _advertisement_data_from_dict(
advertisement_data: AdvertisementDataDict,
) -> AdvertisementData:
"""Deserialize advertisement_data."""
return AdvertisementData(
local_name=advertisement_data[LOCAL_NAME],
manufacturer_data={
int(manufacturer_id): bytes.fromhex(manufacturer_data)
for manufacturer_id, manufacturer_data in advertisement_data[
MANUFACTURER_DATA
].items()
},
service_data={
service_uuid: bytes.fromhex(service_data)
for service_uuid, service_data in advertisement_data[SERVICE_DATA].items()
},
service_uuids=advertisement_data[SERVICE_UUIDS],
rssi=advertisement_data[RSSI],
tx_power=advertisement_data[TX_POWER],
platform_data=tuple(advertisement_data[PLATFORM_DATA]),
)
def _advertisement_data_to_dict(
advertisement_data: AdvertisementData,
) -> AdvertisementDataDict:
"""Serialize advertisement_data."""
return AdvertisementDataDict(
local_name=advertisement_data.local_name,
manufacturer_data={
str(manufacturer_id): manufacturer_data.hex()
for manufacturer_id, manufacturer_data in advertisement_data.manufacturer_data.items() # noqa: E501
},
service_data={
service_uuid: service_data.hex()
for service_uuid, service_data in advertisement_data.service_data.items()
},
service_uuids=advertisement_data.service_uuids,
rssi=advertisement_data.rssi,
tx_power=advertisement_data.tx_power,
platform_data=list(advertisement_data.platform_data),
)
def _get_monotonic_time_diff() -> float:
"""Get monotonic time diff."""
return time.time() - time.monotonic()
def _deserialize_discovered_device_timestamps(
discovered_device_timestamps: dict[str, float],
) -> dict[str, float]:
"""Deserialize discovered_device_timestamps."""
time_diff = _get_monotonic_time_diff()
return {
address: unix_time - time_diff
for address, unix_time in discovered_device_timestamps.items()
}
def _serialize_discovered_device_timestamps(
discovered_device_timestamps: dict[str, float],
) -> dict[str, float]:
"""Serialize discovered_device_timestamps."""
time_diff = _get_monotonic_time_diff()
return {
address: monotonic_time + time_diff
for address, monotonic_time in discovered_device_timestamps.items()
}
def _deserialize_discovered_device_raw(
discovered_device_raw: dict[str, str | None],
) -> dict[str, bytes | None]:
"""Deserialize discovered_device_timestamps."""
return {
address: None if raw is None else bytes.fromhex(raw)
for address, raw in discovered_device_raw.items()
}
def _serialize_discovered_device_raw(
discovered_device_raw: dict[str, bytes | None],
) -> dict[str, str | None]:
"""Serialize discovered_device_timestamps."""
return {
address: None if raw is None else raw.hex()
for address, raw in discovered_device_raw.items()
}
DiscoveryStorageType = dict[str, DiscoveredDeviceAdvertisementDataDict]
Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/usage.py 0000664 0000000 0000000 00000003376 15211177045 0024573 0 ustar 00root root 0000000 0000000 """bluetooth usage utility to handle multiple instances."""
from __future__ import annotations
from typing import TYPE_CHECKING
import bleak
import bleak_retry_connector
from .wrappers import HaBleakClientWrapper, HaBleakScannerWrapper
if TYPE_CHECKING:
from bleak.backends.service import BleakGATTServiceCollection
ORIGINAL_BLEAK_SCANNER = bleak.BleakScanner
ORIGINAL_BLEAK_CLIENT = bleak.BleakClient
ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT_WITH_SERVICE_CACHE = (
bleak_retry_connector.BleakClientWithServiceCache
)
ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT = bleak_retry_connector.BleakClient
def install_multiple_bleak_catcher() -> None:
"""
Wrap the bleak classes to return the shared instance.
In case multiple instances are detected.
"""
bleak.BleakScanner = HaBleakScannerWrapper
bleak.BleakClient = HaBleakClientWrapper
bleak_retry_connector.BleakClientWithServiceCache = HaBleakClientWithServiceCache
bleak_retry_connector.BleakClient = HaBleakClientWrapper
def uninstall_multiple_bleak_catcher() -> None:
"""Unwrap the bleak classes."""
bleak.BleakScanner = ORIGINAL_BLEAK_SCANNER
bleak.BleakClient = ORIGINAL_BLEAK_CLIENT
bleak_retry_connector.BleakClientWithServiceCache = (
ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT_WITH_SERVICE_CACHE
)
bleak_retry_connector.BleakClient = ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT
class HaBleakClientWithServiceCache(HaBleakClientWrapper):
"""A BleakClient that implements service caching."""
def set_cached_services(self, services: BleakGATTServiceCollection | None) -> None:
"""
Set the cached services.
No longer used since bleak 0.17+ has service caching built-in.
This was only kept for backwards compatibility.
"""
Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/util.py 0000664 0000000 0000000 00000006312 15211177045 0024435 0 ustar 00root root 0000000 0000000 """The bluetooth utilities."""
from __future__ import annotations
import asyncio
import functools
from contextlib import suppress
from functools import cache
from pathlib import Path
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
from bluetooth_auto_recovery import recover_adapter
if TYPE_CHECKING:
from collections.abc import Callable, Coroutine
_P = ParamSpec("_P")
_R = TypeVar("_R")
async def async_reset_adapter(
adapter: str | None, mac_address: str, gone_silent: bool
) -> bool | None:
"""Reset the adapter."""
if adapter and adapter.startswith("hci"):
adapter_id = int(adapter[3:])
return await recover_adapter(adapter_id, mac_address, gone_silent)
return False
@cache
def is_docker_env() -> bool:
"""Return True if we run in a docker env."""
return Path("/.dockerenv").exists()
def coalesce_concurrent_future(
attr: str,
) -> Callable[
[Callable[_P, Coroutine[Any, Any, _R]]],
Callable[_P, Coroutine[Any, Any, _R]],
]:
"""
Coalesce concurrent async method calls onto a single shared future.
Mirrors the home-assistant ``loader.py`` shared-future pattern. The
first caller runs the wrapped coroutine and the result (or exception)
is published on a future stored at ``self.``; concurrent callers
wait on the same future and observe the same outcome. ``asyncio.wait``
is used on the waiter side so a cancelled waiter does not transitively
cancel the shared future and strand the leader or its siblings.
Cancellation contract: if the leader is cancelled the ``CancelledError``
is forwarded as-is to every waiter via ``set_exception`` (matching
``loader.py``); callers that need to be insulated from leader
cancellation should wrap their own call in ``asyncio.shield``.
Pre-condition: ``self.`` must already exist on the instance and
be initialised to ``None`` before the first call. The decorator reads
it via ``getattr`` (no default) and resets it to ``None`` in ``finally``
once the leader completes. Only usable on instance methods, ``self``
is taken from ``args[0]``.
"""
def decorator(
func: Callable[_P, Coroutine[Any, Any, _R]],
) -> Callable[_P, Coroutine[Any, Any, _R]]:
@functools.wraps(func)
async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
self = args[0]
future: asyncio.Future[_R] | None = getattr(self, attr)
if future is not None:
await asyncio.wait((future,))
return future.result()
future = asyncio.get_running_loop().create_future()
setattr(self, attr, future)
try:
result = await func(*args, **kwargs)
except BaseException as ex:
future.set_exception(ex)
# Mark the exception as retrieved so asyncio does not warn
# when no concurrent waiters consume it.
with suppress(BaseException):
future.result()
raise
else:
future.set_result(result)
return result
finally:
setattr(self, attr, None)
return wrapper
return decorator
Bluetooth-Devices-habluetooth-75cbe37/src/habluetooth/wrappers.py 0000664 0000000 0000000 00000061145 15211177045 0025330 0 ustar 00root root 0000000 0000000 """Bleak wrappers for bluetooth."""
from __future__ import annotations
import asyncio
import contextlib
import inspect
import logging
import warnings
from dataclasses import dataclass
from functools import partial
from typing import TYPE_CHECKING, Any, Final, Literal, Self, overload
from bleak import BleakClient, BleakError, normalize_uuid_str
from bleak.backends.client import BaseBleakClient, get_platform_client_backend_type
from bleak.backends.device import BLEDevice
from bleak_retry_connector import (
ble_device_description,
clear_cache,
device_source,
)
from .central_manager import get_manager
from .const import BDADDR_LE_PUBLIC, BDADDR_LE_RANDOM, CALLBACK_TYPE, ConnectParams
from .models import BluetoothReachabilityIntent
FILTER_UUIDS: Final = "UUIDs"
_LOGGER = logging.getLogger(__name__)
def _get_device_address_type(device: BLEDevice) -> int:
"""
Get the address type for a BLE device.
Returns:
BDADDR_LE_RANDOM if the device has a random address, BDADDR_LE_PUBLIC otherwise
"""
details: dict[str, dict[str, Any]] = device.details
return (
BDADDR_LE_RANDOM
if details.get("props", {}).get("AddressType") == "random"
else BDADDR_LE_PUBLIC
)
if TYPE_CHECKING:
from collections.abc import AsyncGenerator, Callable
from bleak.backends import BleakBackend
from bleak.backends.scanner import (
AdvertisementData,
AdvertisementDataCallback,
AdvertisementDataFilter,
)
from .base_scanner import BaseHaScanner
from .manager import BluetoothManager
@dataclass(slots=True)
class _HaWrappedBleakBackend:
"""Wrap bleak backend to make it usable by Home Assistant."""
device: BLEDevice
scanner: BaseHaScanner
client: type[BaseBleakClient]
source: str | None
backend_name: BleakBackend | str
class HaBleakScannerWrapper:
"""A wrapper that uses the single instance."""
def __init__(
self,
*args: Any,
detection_callback: AdvertisementDataCallback | None = None,
service_uuids: list[str] | None = None,
**kwargs: Any,
) -> None:
"""Initialize the BleakScanner."""
self._detection_cancel: CALLBACK_TYPE | None = None
self._mapped_filters: dict[str, set[str]] = {}
self._advertisement_data_callback: AdvertisementDataCallback | None = None
self._background_tasks: set[asyncio.Task[Any]] = set()
self._started = False
remapped_kwargs = {
"detection_callback": detection_callback,
"service_uuids": service_uuids or [],
**kwargs,
}
self._map_filters(*args, **remapped_kwargs)
if detection_callback is not None:
self._advertisement_data_callback = detection_callback
# Callback registered in start(), torn down in stop().
@classmethod
async def find_device_by_address(
cls, device_identifier: str, timeout: float = 10.0, **kwargs: Any
) -> BLEDevice | None:
"""Find a device by address."""
manager = get_manager()
return manager.async_ble_device_from_address(
device_identifier, True
) or manager.async_ble_device_from_address(device_identifier, False)
@classmethod
async def find_device_by_name(
cls, name: str, timeout: float = 10.0, **kwargs: Any
) -> BLEDevice | None:
"""Find a device by name."""
return await cls.find_device_by_filter(
lambda d, ad: ad.local_name == name,
timeout=timeout,
**kwargs,
)
@classmethod
async def find_device_by_filter(
cls,
filterfunc: AdvertisementDataFilter,
timeout: float = 10.0,
**kwargs: Any,
) -> BLEDevice | None:
"""Find a device by filter."""
manager = get_manager()
for info in manager.async_discovered_service_info(False):
if filterfunc(info.device, info.advertisement):
return info.device
return None
@overload
@classmethod
async def discover(
cls, timeout: float = 5.0, *, return_adv: Literal[False] = False, **kwargs: Any
) -> list[BLEDevice]: ...
@overload
@classmethod
async def discover(
cls, timeout: float = 5.0, *, return_adv: Literal[True], **kwargs: Any
) -> dict[str, tuple[BLEDevice, AdvertisementData]]: ...
@classmethod
async def discover(
cls, timeout: float = 5.0, *, return_adv: bool = False, **kwargs: Any
) -> list[BLEDevice] | dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Discover devices."""
infos = get_manager().async_discovered_service_info(True)
if return_adv:
return {info.address: (info.device, info.advertisement) for info in infos}
return [info.device for info in infos]
async def stop(self, *args: Any, **kwargs: Any) -> None:
"""Stop scanning for devices."""
self._started = False
self._cancel_callback()
async def start(self, *args: Any, **kwargs: Any) -> None:
"""Start scanning for devices."""
self._started = True
self._setup_detection_callback()
async def __aenter__(self) -> Self:
"""Enter the context manager."""
await self.start()
return self
async def __aexit__(self, *args: object) -> None:
"""Exit the context manager."""
await self.stop()
def _map_filters(self, *args: Any, **kwargs: Any) -> bool:
"""Map the filters."""
mapped_filters = {}
if filters := kwargs.get("filters"):
if filter_uuids := filters.get(FILTER_UUIDS):
mapped_filters[FILTER_UUIDS] = set(filter_uuids)
else:
_LOGGER.warning("Only %s filters are supported", FILTER_UUIDS)
if service_uuids := kwargs.get("service_uuids"):
mapped_filters[FILTER_UUIDS] = set(service_uuids)
if mapped_filters == self._mapped_filters:
return False
self._mapped_filters = mapped_filters
return True
def set_scanning_filter(self, *args: Any, **kwargs: Any) -> None:
"""Set the filters to use."""
if self._map_filters(*args, **kwargs) and self._started:
self._setup_detection_callback()
def _cancel_callback(self) -> None:
"""Cancel callback."""
if self._detection_cancel:
self._detection_cancel()
self._detection_cancel = None
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
return list(get_manager().async_discovered_devices(True))
@property
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Return a dict of discovered devices and their advertisement data."""
return {
info.address: (info.device, info.advertisement)
for info in get_manager().async_discovered_service_info(True)
}
def register_detection_callback(
self, callback: AdvertisementDataCallback | None
) -> Callable[[], None]:
"""
Register a detection callback (deprecated).
bleak removed this method from ``BleakScanner``; it remains here only
so integrations that have not yet migrated keep working. Pass
``detection_callback`` to the constructor instead. This shim will be
removed in a future habluetooth release.
"""
warnings.warn(
"HaBleakScannerWrapper.register_detection_callback() is deprecated "
"and will be removed in a future release; bleak already removed "
"this method from BleakScanner. Pass detection_callback to the "
"HaBleakScannerWrapper constructor instead.",
DeprecationWarning,
stacklevel=2,
)
_LOGGER.warning(
"HaBleakScannerWrapper.register_detection_callback() is deprecated "
"and will be removed in a future release; bleak already removed "
"this method from BleakScanner. Pass detection_callback to the "
"HaBleakScannerWrapper constructor instead."
)
self._advertisement_data_callback = callback
self._setup_detection_callback()
return self._cancel_callback
async def advertisement_data(
self,
) -> AsyncGenerator[tuple[BLEDevice, AdvertisementData], None]:
"""Yield devices and advertisement data as they are discovered."""
queue: asyncio.Queue[tuple[BLEDevice, AdvertisementData]] = asyncio.Queue()
cancel = get_manager().async_register_bleak_callback(
lambda bd, ad: queue.put_nowait((bd, ad)),
self._mapped_filters,
)
try:
while True:
yield await queue.get()
finally:
cancel()
def _setup_detection_callback(self) -> None:
"""Set up the detection callback."""
if self._advertisement_data_callback is None:
return
callback = self._advertisement_data_callback
self._cancel_callback()
manager = get_manager()
if not inspect.iscoroutinefunction(callback):
detection_callback = callback
else:
def detection_callback(
ble_device: BLEDevice, advertisement_data: AdvertisementData
) -> None:
task = asyncio.create_task(callback(ble_device, advertisement_data))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
self._detection_cancel = manager.async_register_bleak_callback(
detection_callback, self._mapped_filters
)
def __del__(self) -> None:
"""Delete the BleakScanner."""
if self._detection_cancel:
# Nothing to do if event loop is already closed
with contextlib.suppress(RuntimeError):
asyncio.get_running_loop().call_soon_threadsafe(self._detection_cancel)
class HaBleakClientWrapper(BleakClient):
"""
Wrap the BleakClient to ensure it does not shutdown our scanner.
If an address is passed into BleakClient instead of a BLEDevice,
bleak will quietly start a new scanner under the hood to resolve
the address. This can cause a conflict with our scanner. We need
to handle translating the address to the BLEDevice in this case
to avoid the whole stack from getting stuck in an in progress state
when an integration does this.
"""
def __init__( # pylint: disable=super-init-not-called
self,
address_or_ble_device: str | BLEDevice,
disconnected_callback: Callable[[BleakClient], None] | None = None,
services: list[str] | None = None,
*,
timeout: float = 10.0,
pair: bool = False,
**kwargs: Any,
) -> None:
"""Initialize the BleakClient."""
if isinstance(address_or_ble_device, BLEDevice):
self.__address = address_or_ble_device.address
else:
# If we are passed an address we need to make sure
# its not a subclassed str
self.__address = str(address_or_ble_device)
self.__disconnected_callback = disconnected_callback
self.__manager = get_manager()
self.__timeout = timeout
self.__services = services
self._backend: BaseBleakClient | None = None
self._connected_scanner: BaseHaScanner | None = None
self._connected_device: BLEDevice | None = None
self._pair_before_connect = pair
# Check if this client is being created through establish_connection
# by checking for the '_is_retry_client' marker in kwargs
self._is_retry_client = kwargs.pop("_is_retry_client", False)
# bleak 2.0+ BleakClient.backend_id reads self._backend_id, but since
# we skip super().__init__() it is never set. The real backend is not
# chosen until connect(), so seed with "" and update there.
self._backend_id: BleakBackend | str = ""
@property
def is_connected(self) -> bool:
"""Return True if the client is connected to a device."""
return self._backend is not None and self._backend.is_connected
async def clear_cache(self) -> bool:
"""Clear the GATT cache."""
if self._backend is not None and hasattr(self._backend, "clear_cache"):
return await self._backend.clear_cache()
return await clear_cache(self.__address)
async def set_connection_params(
self,
min_interval: int,
max_interval: int,
latency: int,
timeout: int,
) -> None:
"""Set BLE connection parameters on a connected device."""
if self._backend is not None and hasattr(
self._backend, "set_connection_params"
):
await self._backend.set_connection_params(
min_interval, max_interval, latency, timeout
)
return
# BlueZ local path - use mgmt API
if (
self._connected_scanner is not None
and self._connected_device is not None
and (adapter_idx := self._connected_scanner.adapter_idx) is not None
and (mgmt_ctl := self.__manager.get_bluez_mgmt_ctl())
):
mgmt_ctl.load_conn_params_explicit(
adapter_idx,
self._connected_device.address,
_get_device_address_type(self._connected_device),
min_interval,
max_interval,
latency,
timeout,
)
return
if self._backend is not None:
_LOGGER.warning(
"%s: Backend %s does not support setting connection"
" parameters; Upgrade the backend library",
self.__address,
type(self._backend).__name__,
)
def set_disconnected_callback(
self,
callback: Callable[[BleakClient], None] | None,
**kwargs: Any,
) -> None:
"""Set the disconnect callback."""
self.__disconnected_callback = callback
if self._backend:
self._backend.set_disconnected_callback(
self._make_disconnected_callback(callback),
**kwargs,
)
def _make_disconnected_callback(
self, callback: Callable[[BleakClient], None] | None
) -> Callable[[], None] | None:
"""
Make the disconnected callback.
https://github.com/hbldh/bleak/pull/1256
The disconnected callback needs to get the top level
BleakClientWrapper instance, not the backend instance.
The signature of the callback for the backend is:
Callable[[], None]
To make this work we need to wrap the callback in a partial
that passes the BleakClientWrapper instance as the first
argument.
"""
return None if callback is None else partial(callback, self)
async def connect(self, **kwargs: Any) -> None: # noqa: C901
"""Connect to the specified GATT server."""
if self.is_connected:
return
# Warn if not using bleak-retry-connector's establish_connection
if not self._is_retry_client:
_LOGGER.warning(
"%s: BleakClient.connect() called without bleak-retry-connector. "
"For reliable connection establishment, use "
"bleak_retry_connector.establish_connection(). "
"See https://github.com/Bluetooth-Devices/bleak-retry-connector",
self.__address,
)
manager = self.__manager
if manager.shutdown:
msg = "Bluetooth is already shutdown"
raise BleakError(msg)
if debug_logging := _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug("%s: Looking for backend to connect", self.__address)
wrapped_backend = self._async_get_best_available_backend_and_device(manager)
device = wrapped_backend.device
scanner = wrapped_backend.scanner
self._backend_id = wrapped_backend.backend_name
self._backend = wrapped_backend.client(
device,
disconnected_callback=self._make_disconnected_callback(
self.__disconnected_callback
),
services=(
None
if self.__services is None
else set(map(normalize_uuid_str, self.__services))
),
timeout=self.__timeout,
bluez={},
)
description = ""
rssi = None
if debug_logging:
# Only lookup the description if we are going to log it
description = ble_device_description(device)
device_adv = scanner.get_discovered_device_advertisement_data(
device.address
)
if TYPE_CHECKING:
assert device_adv is not None
adv = device_adv[1]
rssi = adv.rssi
backend_name = (
f" [{wrapped_backend.backend_name}]"
if wrapped_backend.backend_name
else ""
)
_LOGGER.debug(
"%s: Connecting via %s%s (last rssi: %s)",
description,
scanner.name,
backend_name,
rssi,
)
# Load fast connection parameters before connecting if mgmt API is available
self._load_conn_params(
scanner,
device,
ConnectParams.FAST,
debug_logging,
description,
)
connected = False
address = device.address
try:
scanner._add_connecting(address)
await super().connect(**kwargs)
connected = True
finally:
scanner._finished_connecting(address, connected)
if not connected:
# Clear backend on any failure path (including BaseException
# such as asyncio.CancelledError) so the wrapper does not hold
# a partially-initialised backend.
self._backend = None
# Local adapters need an explicit slot release on failure;
# remote scanners manage slot accounting on the proxy side.
if not connected and not wrapped_backend.source:
manager.async_release_connection_slot(device)
# Load medium connection parameters after successful connection
if connected:
self._connected_scanner = scanner
self._connected_device = device
self._load_conn_params(
scanner,
device,
ConnectParams.MEDIUM,
debug_logging,
description,
)
if debug_logging:
_LOGGER.debug(
"%s: %s via %s%s (last rssi: %s)",
description,
"Connected" if connected else "Failed to connect",
scanner.name,
backend_name,
rssi,
)
return
def _load_conn_params(
self,
scanner: BaseHaScanner,
device: BLEDevice,
params: ConnectParams,
debug_logging: bool,
description: str,
) -> None:
"""Load connection parameters for a device."""
if (
(adapter_idx := scanner.adapter_idx) is not None
and (mgmt_ctl := self.__manager.get_bluez_mgmt_ctl())
and mgmt_ctl.load_conn_params(
adapter_idx,
device.address,
_get_device_address_type(device),
params,
)
and debug_logging
):
_LOGGER.debug("%s: Loaded %s connection parameters", description, params)
def _async_get_backend_for_ble_device(
self, manager: BluetoothManager, scanner: BaseHaScanner, ble_device: BLEDevice
) -> _HaWrappedBleakBackend | None:
"""Get the backend for a BLEDevice."""
if not (source := device_source(ble_device)):
# If client is not defined in details
# its the client for this platform
if not manager.async_allocate_connection_slot(ble_device):
return None
backend = get_platform_client_backend_type()
# bleak 2.0.0+ returns a tuple (backend_class, backend_id)
if isinstance(backend, tuple):
cls, backend_name = backend
else:
cls = backend
backend_name = type(cls).__name__
return _HaWrappedBleakBackend(
ble_device, scanner, cls, source, backend_name
)
# Make sure the backend can connect to the device
# as some backends have connection limits
if not scanner.connector or not scanner.connector.can_connect():
return None
return _HaWrappedBleakBackend(
ble_device,
scanner,
scanner.connector.client,
source,
type(scanner.connector.client).__name__,
)
def _async_get_best_available_backend_and_device(
self, manager: BluetoothManager
) -> _HaWrappedBleakBackend:
"""
Get a best available backend and device for the given address.
This method will return the backend with the best rssi
that has a free connection slot.
"""
address = self.__address
sorted_devices = sorted(
manager.async_scanner_devices_by_address(self.__address, True),
key=lambda x: x.advertisement.rssi,
reverse=True,
)
rssi_diff = 0 # Default when there's only one device
if len(sorted_devices) > 1:
rssi_diff = (
sorted_devices[0].advertisement.rssi
- sorted_devices[1].advertisement.rssi
)
sorted_devices = sorted(
sorted_devices,
key=lambda device: device.score_connection_path(rssi_diff),
reverse=True,
)
if sorted_devices and _LOGGER.isEnabledFor(logging.INFO):
_LOGGER.info(
"%s - %s: Found %s connection path(s), preferred order: %s",
address,
sorted_devices[0].ble_device.name,
len(sorted_devices),
", ".join(
(
f"{device.scanner.name} "
f"(RSSI={device.advertisement.rssi}) "
f"(failures={device.scanner.connection_failures(address)}) "
f"(in_progress={device.scanner.connections_in_progress()}) "
+ (
f"(slots={allocations.free}/{allocations.slots} free) "
if (allocations := device.scanner.get_allocations())
else ""
)
+ f"(score={device.score_connection_path(rssi_diff)})"
)
for device in sorted_devices
),
)
for device in sorted_devices:
if backend := self._async_get_backend_for_ble_device(
manager, device.scanner, device.ble_device
):
return backend
# Check if all registered scanners are passive-only
if scanners := manager.async_current_scanners():
has_active_capable_scanner = any(
scanner.connectable for scanner in scanners
)
if not has_active_capable_scanner:
scanner_names = [scanner.name for scanner in scanners]
msg = (
f"{address}: No connectable Bluetooth adapters. "
f"Shelly devices are passive-only and cannot connect. "
f"Need local Bluetooth adapter or ESPHome proxy. "
f"Available: {', '.join(scanner_names)}"
)
raise BleakError(msg)
msg = (
"No backend with an available connection slot that can reach address"
f" {address} was found"
)
# Best-effort diagnostics; never let a diagnostics failure mask the
# original "no backend" error we are about to raise.
try:
diagnostics = manager.async_address_reachability_diagnostics(
address, BluetoothReachabilityIntent.CONNECTION
)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error building reachability diagnostics for %s", address)
else:
msg = f"{msg}: {diagnostics}"
raise BleakError(msg)
async def disconnect(self) -> None:
"""Disconnect from the device."""
if self._backend is None:
return
await self._backend.disconnect()
Bluetooth-Devices-habluetooth-75cbe37/templates/ 0000775 0000000 0000000 00000000000 15211177045 0021775 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-75cbe37/templates/CHANGELOG.md.j2 0000664 0000000 0000000 00000001235 15211177045 0024121 0 ustar 00root root 0000000 0000000 # Changelog
{%- for version, release in context.history.released.items() %}
## {{ version.as_tag() }} ({{ release.tagged_date.strftime("%Y-%m-%d") }})
{%- for category, commits in release["elements"].items() %}
{# Category title: Breaking, Fix, Documentation #}
### {{ category | capitalize }}
{# List actual changes in the category #}
{%- for commit in commits %}
{% if commit is not none and commit.descriptions is defined %}
- {{ commit.descriptions[0] | capitalize }} ([`{{ commit.short_hash }}`]({{ commit.hexsha | commit_hash_url }}))
{% endif %}
{%- endfor %}{# for commit #}
{%- endfor %}{# for category, commits #}
{%- endfor %}{# for version, release #}
Bluetooth-Devices-habluetooth-75cbe37/tests/ 0000775 0000000 0000000 00000000000 15211177045 0021141 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-75cbe37/tests/__init__.py 0000664 0000000 0000000 00000021662 15211177045 0023261 0 ustar 00root root 0000000 0000000 import asyncio
import time
import types
from collections.abc import Generator
from contextlib import contextmanager
from datetime import UTC, datetime
from functools import partial
from typing import Any
from unittest.mock import MagicMock, patch
from bleak.backends.scanner import (
AdvertisementData,
AdvertisementDataCallback,
BLEDevice,
)
from bluetooth_data_tools import monotonic_time_coarse
from habluetooth import BaseHaRemoteScanner, get_manager
from habluetooth.models import BluetoothServiceInfoBleak
utcnow = partial(datetime.now, UTC)
HCI0_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:00"
HCI1_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:11"
NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS = "AA:BB:CC:DD:EE:FF"
_MONOTONIC_RESOLUTION = time.get_clock_info("monotonic").resolution
ADVERTISEMENT_DATA_DEFAULTS = {
"local_name": "Unknown",
"manufacturer_data": {},
"service_data": {},
"service_uuids": [],
"rssi": -127,
"platform_data": ((),),
"tx_power": -127,
}
BLE_DEVICE_DEFAULTS = {
"name": None,
"details": None,
}
def generate_advertisement_data(**kwargs: Any) -> AdvertisementData:
"""Generate advertisement data with defaults."""
new = kwargs.copy()
for key, value in ADVERTISEMENT_DATA_DEFAULTS.items():
new.setdefault(key, value)
return AdvertisementData(**new)
def generate_ble_device(
address: str | None = None,
name: str | None = None,
details: Any | None = None,
**kwargs: Any,
) -> BLEDevice:
"""
Generate a BLEDevice with defaults.
Extra kwargs (e.g. legacy ``rssi``) are silently dropped — bleak 3.0
removed those fields from BLEDevice, and passing them now warns.
"""
new: dict[str, Any] = {}
if address is not None:
new["address"] = address
if name is not None:
new["name"] = name
if details is not None:
new["details"] = details
for key, value in BLE_DEVICE_DEFAULTS.items():
new.setdefault(key, value)
# Only forward kwargs BLEDevice still accepts in bleak 3.0+.
for key in ("address", "name", "details"):
if key in kwargs:
new[key] = kwargs[key]
return BLEDevice(**new)
@contextmanager
def patch_bluetooth_time(mock_time: float) -> Generator[Any, None, None]:
"""Patch the bluetooth time."""
with (
patch("habluetooth.base_scanner.monotonic_time_coarse", return_value=mock_time),
patch("habluetooth.manager.monotonic_time_coarse", return_value=mock_time),
patch("habluetooth.scanner.monotonic_time_coarse", return_value=mock_time),
):
yield
def async_fire_time_changed(utc_datetime: datetime) -> None:
timestamp = utc_datetime.timestamp()
loop = asyncio.get_running_loop()
for task in list(loop._scheduled): # type: ignore[attr-defined]
if not isinstance(task, asyncio.TimerHandle):
continue
if task.cancelled():
continue
mock_seconds_into_future = timestamp - time.time()
future_seconds = task.when() - (loop.time() + _MONOTONIC_RESOLUTION)
if mock_seconds_into_future >= future_seconds:
task._run()
task.cancel()
class MockBleakClient:
pass
class MockBleakScanner:
"""
Drop-in fake for ``bleak.BleakScanner`` that satisfies ``HaScanner``.
Provides the four attributes ``HaScanner`` actually touches
(``start`` / ``stop`` / ``discovered_devices`` /
``register_detection_callback``) plus a ``_backend`` namespace
with ``_scanning_mode`` for the active-window toggle path.
Subclass to override individual methods for failure injection
(e.g. ``async def start(self): raise BleakError(...)``); each
instance owns its own ``_backend`` so mutations don't leak
between tests.
"""
def __init__(self) -> None:
# Typed as ``Any`` so subclasses can substitute custom backend
# objects (e.g. for AttributeError injection tests).
self._backend: Any = types.SimpleNamespace(_scanning_mode="passive")
async def start(self) -> None:
"""No-op start."""
async def stop(self) -> None:
"""No-op stop."""
@property
def discovered_devices(self) -> list[BLEDevice]:
"""No devices by default; override for fixture-style fakes."""
return []
@property
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""No discoveries by default; override for fixture-style fakes."""
return {}
def register_detection_callback(self, callback: AdvertisementDataCallback) -> None:
"""No-op detection-callback registration."""
class InjectableRemoteScanner(BaseHaRemoteScanner):
"""
Remote scanner that exposes test-only ``inject_advertisement`` helpers.
Replaces the near-identical ``FakeScanner`` / ``_SeedFakeScanner``
classes that used to live in test_base_scanner / test_name_cache /
test_wrappers. ``device.details`` (when present) is merged into the
advertisement ``details`` dict so callers that route via DBus paths
(e.g. test_wrappers) keep the path key, while callers that pass a
``details``-less device (the default) see only the test marker.
"""
def inject_advertisement(
self,
device: BLEDevice,
advertisement_data: AdvertisementData,
now: float | None = None,
) -> None:
"""Inject an advertisement through the scanner's normal entry point."""
self._async_on_advertisement(
device.address,
advertisement_data.rssi,
device.name,
advertisement_data.service_uuids,
advertisement_data.service_data,
advertisement_data.manufacturer_data,
advertisement_data.tx_power,
(device.details or {}) | {"scanner_specific_data": "test"},
now if now is not None else monotonic_time_coarse(),
)
def inject_raw_advertisement(
self,
address: str,
rssi: int,
adv: bytes,
now: float | None = None,
) -> None:
"""Inject a raw advertisement through the scanner's normal entry point."""
self._async_on_raw_advertisement(
address,
rssi,
adv,
{"scanner_specific_data": "test"},
now if now is not None else monotonic_time_coarse(),
)
def patch_bleak_scanner_factory(factory: Any) -> Any:
"""
Patch ``OriginalBleakScanner`` to call ``factory(*args, **kwargs)``.
Convenience wrapper to avoid the noisy
``patch(..., side_effect=lambda *_a, **_kw: factory())`` ritual
used at every mock-scanner site.
"""
return patch(
"habluetooth.scanner.OriginalBleakScanner",
side_effect=lambda *_a, **_kw: factory(),
)
def inject_advertisement(device: BLEDevice, adv: AdvertisementData) -> None:
"""Inject an advertisement into the manager."""
return inject_advertisement_with_source(device, adv, "local")
def inject_advertisement_with_source(
device: BLEDevice, adv: AdvertisementData, source: str
) -> None:
"""Inject an advertisement into the manager from a specific source."""
inject_advertisement_with_time_and_source(device, adv, time.monotonic(), source)
def inject_advertisement_with_time_and_source(
device: BLEDevice,
adv: AdvertisementData,
time: float,
source: str,
) -> None:
"""Inject an advertisement into the manager from a specific source at a time."""
inject_advertisement_with_time_and_source_connectable(
device, adv, time, source, True
)
def inject_advertisement_with_time_and_source_connectable(
device: BLEDevice,
adv: AdvertisementData,
time: float,
source: str,
connectable: bool,
) -> None:
"""
Inject an advertisement into the manager from a specific source at a time.
As well as and connectable status.
"""
manager = get_manager()
manager.scanner_adv_received(
BluetoothServiceInfoBleak(
name=adv.local_name or device.name or device.address,
address=device.address,
rssi=adv.rssi,
manufacturer_data=adv.manufacturer_data,
service_data=adv.service_data,
service_uuids=adv.service_uuids,
source=source,
device=device,
advertisement=adv,
connectable=connectable,
time=time,
tx_power=adv.tx_power,
)
)
@contextmanager
def patch_discovered_devices(
mock_discovered: list[BLEDevice],
) -> Generator[None, None, None]:
"""Mock the combined best path to discovered devices from all the scanners."""
manager = get_manager()
original_all_history = manager._all_history
original_connectable_history = manager._connectable_history
manager._connectable_history = {}
manager._all_history = {
device.address: MagicMock(device=device) for device in mock_discovered
}
yield
manager._all_history = original_all_history
manager._connectable_history = original_connectable_history
Bluetooth-Devices-habluetooth-75cbe37/tests/channels/ 0000775 0000000 0000000 00000000000 15211177045 0022734 5 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-75cbe37/tests/channels/__init__.py 0000664 0000000 0000000 00000000000 15211177045 0025033 0 ustar 00root root 0000000 0000000 Bluetooth-Devices-habluetooth-75cbe37/tests/channels/test_bluez.py 0000664 0000000 0000000 00000151421 15211177045 0025472 0 ustar 00root root 0000000 0000000 """Tests for the BlueZ management API module."""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from unittest.mock import Mock, patch
import pytest
from btsocket.btmgmt_socket import BluetoothSocketError
from habluetooth.channels.bluez import (
BluetoothMGMTProtocol,
MGMTBluetoothCtl,
)
from habluetooth.const import (
BDADDR_LE_PUBLIC,
BDADDR_LE_RANDOM,
FAST_CONN_LATENCY,
FAST_CONN_TIMEOUT,
FAST_MAX_CONN_INTERVAL,
FAST_MIN_CONN_INTERVAL,
MEDIUM_CONN_LATENCY,
MEDIUM_CONN_TIMEOUT,
MEDIUM_MAX_CONN_INTERVAL,
MEDIUM_MIN_CONN_INTERVAL,
ConnectParams,
)
from habluetooth.scanner import HaScanner
class MockHaScanner(HaScanner):
"""Mock HaScanner for testing with Cython."""
def __init__(self):
"""Initialize without calling parent __init__ to avoid BleakScanner setup."""
self.source = "test"
self.connectable = True
# Mock the method that will be called
self._async_on_raw_bluez_advertisement: Any = Mock()
@pytest.fixture
def event_loop():
"""Create and manage event loop for tests."""
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest.fixture
def mock_scanner() -> MockHaScanner:
"""Create a mock scanner for testing."""
return MockHaScanner()
@pytest.fixture
def mock_transport() -> Mock:
"""Create a mock transport."""
transport = Mock()
transport.write = Mock()
# Create a mock socket for direct writes
mock_socket = Mock()
mock_socket.send = Mock(return_value=6) # Default to successful send
transport.get_extra_info = Mock(return_value=mock_socket)
return transport
def test_connection_made(
event_loop: asyncio.AbstractEventLoop, mock_transport: Mock
) -> None:
"""Test connection_made sets up the protocol correctly."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
protocol.connection_made(mock_transport)
assert protocol.transport is mock_transport
assert future.done()
assert future.result() is None
def test_connection_lost(
event_loop: asyncio.AbstractEventLoop, mock_transport: Mock
) -> None:
"""Test connection_lost handles disconnection."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
protocol.connection_made(mock_transport)
# Test with exception
protocol.connection_lost(Exception("Test error"))
assert protocol.transport is None
on_connection_lost.assert_called_once()
def test_connection_lost_no_exception(
event_loop: asyncio.AbstractEventLoop,
mock_transport: Mock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test connection_lost without exception."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
protocol.connection_made(mock_transport)
# Test without exception
protocol.connection_lost(None)
assert "Bluetooth management socket connection closed" in caplog.text
def test_data_received_device_found(
event_loop: asyncio.AbstractEventLoop, mock_scanner: MockHaScanner
) -> None:
"""Test data_received handles DEVICE_FOUND event."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {0: mock_scanner}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a DEVICE_FOUND event (event_code 0x0012). Header layout is
# event_code (2), controller_idx (2), param_len (2); params layout
# is address (6), address_type (1), rssi (1), flags (4),
# ad_data_len (2), then ad_data.
ad_data = b"\x02\x01\x06" # Simple advertisement data
param_len = 6 + 1 + 1 + 4 + 2 + len(ad_data)
header = b"\x12\x00" # DEVICE_FOUND
header += b"\x00\x00" # controller_idx = 0
header += param_len.to_bytes(2, "little")
params = b"\xaa\xbb\xcc\xdd\xee\xff" # address (reversed)
params += b"\x01" # address_type
params += b"\xc8" # rssi = -56 (200 - 256)
params += b"\x00\x00\x00\x00" # flags
params += len(ad_data).to_bytes(2, "little") # ad_data_len
params += ad_data
protocol.data_received(header + params)
mock_scanner._async_on_raw_bluez_advertisement.assert_called_once_with(
b"\xaa\xbb\xcc\xdd\xee\xff",
1,
-56,
0,
ad_data,
)
def test_data_received_adv_monitor_device_found(
event_loop: asyncio.AbstractEventLoop, mock_scanner: MockHaScanner
) -> None:
"""Test data_received handles ADV_MONITOR_DEVICE_FOUND event."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {0: mock_scanner}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create an ADV_MONITOR_DEVICE_FOUND event (event_code 0x002F)
# Has 2 extra bytes at the beginning of params
ad_data = b"\x02\x01\x06"
param_len = 2 + 6 + 1 + 1 + 4 + 2 + len(ad_data)
header = b"\x2f\x00" # ADV_MONITOR_DEVICE_FOUND
header += b"\x00\x00" # controller_idx = 0
header += param_len.to_bytes(2, "little")
params = b"\x00\x00" # 2 extra bytes
params += b"\xaa\xbb\xcc\xdd\xee\xff" # address
params += b"\x02" # address_type
params += b"\x64" # rssi = 100 (positive, no conversion needed)
params += b"\x00\x00\x00\x00" # flags
params += len(ad_data).to_bytes(2, "little")
params += ad_data
protocol.data_received(header + params)
mock_scanner._async_on_raw_bluez_advertisement.assert_called_once_with(
b"\xaa\xbb\xcc\xdd\xee\xff",
2,
100,
0,
ad_data,
)
def test_data_received_cmd_complete_success(
event_loop: asyncio.AbstractEventLoop,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test data_received handles successful MGMT_EV_CMD_COMPLETE."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a CMD_COMPLETE event for LOAD_CONN_PARAM
header = b"\x01\x00" # MGMT_EV_CMD_COMPLETE
header += b"\x00\x00" # controller_idx = 0
header += b"\x03\x00" # param_len = 3
params = b"\x35\x00" # opcode = MGMT_OP_LOAD_CONN_PARAM
params += b"\x00" # status = 0 (success)
protocol.data_received(header + params)
assert "Connection parameters loaded successfully" in caplog.text
def test_data_received_cmd_complete_failure(
event_loop: asyncio.AbstractEventLoop,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test data_received handles failed MGMT_EV_CMD_COMPLETE."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a CMD_COMPLETE event with failure
header = b"\x01\x00" # MGMT_EV_CMD_COMPLETE
header += b"\x01\x00" # controller_idx = 1
header += b"\x03\x00" # param_len = 3
params = b"\x35\x00" # opcode = MGMT_OP_LOAD_CONN_PARAM
params += b"\x0c" # status = 12 (Not Supported)
protocol.data_received(header + params)
assert "Failed to load conn params: status=12" in caplog.text
def test_data_received_cmd_status(
event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture
) -> None:
"""Test data_received handles MGMT_EV_CMD_STATUS."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a CMD_STATUS event
header = b"\x02\x00" # MGMT_EV_CMD_STATUS
header += b"\x00\x00" # controller_idx = 0
header += b"\x03\x00" # param_len = 3
params = b"\x35\x00" # opcode = MGMT_OP_LOAD_CONN_PARAM
params += b"\x01" # status = 1 (Unknown Command)
protocol.data_received(header + params)
assert "Failed to load conn params: status=1" in caplog.text
def test_data_received_partial_data(
event_loop: asyncio.AbstractEventLoop, mock_scanner: MockHaScanner
) -> None:
"""Test data_received handles partial data correctly."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {0: mock_scanner}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a DEVICE_FOUND event but send it in chunks
ad_data = b"\x02\x01\x06"
param_len = 6 + 1 + 1 + 4 + 2 + len(ad_data)
full_data = b"\x12\x00\x00\x00" + param_len.to_bytes(2, "little")
full_data += b"\xaa\xbb\xcc\xdd\xee\xff\x01\xc8\x00\x00\x00\x00"
full_data += len(ad_data).to_bytes(2, "little") + ad_data
# Send header first
protocol.data_received(full_data[:6])
mock_scanner._async_on_raw_bluez_advertisement.assert_not_called()
# Send rest of data
protocol.data_received(full_data[6:])
mock_scanner._async_on_raw_bluez_advertisement.assert_called_once()
def test_data_received_partial_data_split_in_params(
event_loop: asyncio.AbstractEventLoop, mock_scanner: MockHaScanner
) -> None:
"""Test data_received handles data split in the middle of params."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {0: mock_scanner}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a DEVICE_FOUND event
ad_data = b"\x02\x01\x06\x03\xff\x00\x01" # Longer ad data
param_len = 6 + 1 + 1 + 4 + 2 + len(ad_data)
full_data = b"\x12\x00\x00\x00" + param_len.to_bytes(2, "little")
full_data += b"\xaa\xbb\xcc\xdd\xee\xff\x01\xc8\x00\x00\x00\x00"
full_data += len(ad_data).to_bytes(2, "little") + ad_data
# Split in the middle of the address
protocol.data_received(full_data[:10]) # Header + part of address
mock_scanner._async_on_raw_bluez_advertisement.assert_not_called()
# Send rest of data
protocol.data_received(full_data[10:])
mock_scanner._async_on_raw_bluez_advertisement.assert_called_once_with(
b"\xaa\xbb\xcc\xdd\xee\xff",
1,
-56,
0,
ad_data,
)
def test_data_received_multiple_small_chunks(
event_loop: asyncio.AbstractEventLoop, mock_scanner: MockHaScanner
) -> None:
"""Test data_received handles data sent in many small chunks."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {0: mock_scanner}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a DEVICE_FOUND event
ad_data = b"\x02\x01\x06"
param_len = 6 + 1 + 1 + 4 + 2 + len(ad_data)
full_data = b"\x12\x00\x00\x00" + param_len.to_bytes(2, "little")
full_data += b"\xaa\xbb\xcc\xdd\xee\xff\x01\xc8\x00\x00\x00\x00"
full_data += len(ad_data).to_bytes(2, "little") + ad_data
# Send data byte by byte
for i in range(len(full_data)):
protocol.data_received(full_data[i : i + 1])
if i < len(full_data) - 1:
mock_scanner._async_on_raw_bluez_advertisement.assert_not_called()
# After all bytes are sent, callback should be called once
mock_scanner._async_on_raw_bluez_advertisement.assert_called_once()
def test_data_received_multiple_events_in_one_chunk(
event_loop: asyncio.AbstractEventLoop,
mock_scanner: Mock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test data_received handles multiple events in one data chunk."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {0: mock_scanner}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create two events: a DEVICE_FOUND and a CMD_COMPLETE
ad_data = b"\x02\x01\x06"
param_len1 = 6 + 1 + 1 + 4 + 2 + len(ad_data)
event1 = b"\x12\x00\x00\x00" + param_len1.to_bytes(2, "little")
event1 += b"\xaa\xbb\xcc\xdd\xee\xff\x01\xc8\x00\x00\x00\x00"
event1 += len(ad_data).to_bytes(2, "little") + ad_data
event2 = b"\x01\x00\x00\x00\x03\x00" # CMD_COMPLETE header
event2 += b"\x35\x00\x00" # LOAD_CONN_PARAM success
# Send both events in one chunk
protocol.data_received(event1 + event2)
# Both events should be processed
mock_scanner._async_on_raw_bluez_advertisement.assert_called_once()
assert "Connection parameters loaded successfully" in caplog.text
def test_data_received_partial_then_multiple_events(
event_loop: asyncio.AbstractEventLoop, mock_scanner: MockHaScanner
) -> None:
"""Test partial data followed by multiple complete events."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {0: mock_scanner}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# First event (DEVICE_FOUND)
ad_data1 = b"\x02\x01\x06"
param_len1 = 6 + 1 + 1 + 4 + 2 + len(ad_data1)
event1 = b"\x12\x00\x00\x00" + param_len1.to_bytes(2, "little")
event1 += b"\x11\x22\x33\x44\x55\x66\x01\xc8\x00\x00\x00\x00"
event1 += len(ad_data1).to_bytes(2, "little") + ad_data1
# Second event (ADV_MONITOR_DEVICE_FOUND)
ad_data2 = b"\x03\xff\x00\x01"
param_len2 = 2 + 6 + 1 + 1 + 4 + 2 + len(ad_data2)
event2 = b"\x2f\x00\x00\x00" + param_len2.to_bytes(2, "little")
event2 += b"\x00\x00" # Extra 2 bytes
event2 += b"\x77\x88\x99\xaa\xbb\xcc\x02\x64\x00\x00\x00\x00"
event2 += len(ad_data2).to_bytes(2, "little") + ad_data2
# Send partial first event
protocol.data_received(event1[:15])
mock_scanner._async_on_raw_bluez_advertisement.assert_not_called()
# Send rest of first event + second event
protocol.data_received(event1[15:] + event2)
# Both callbacks should be called
assert mock_scanner._async_on_raw_bluez_advertisement.call_count == 2
calls = mock_scanner._async_on_raw_bluez_advertisement.call_args_list
# First call
assert calls[0][0] == (
b"\x11\x22\x33\x44\x55\x66",
1,
-56,
0,
ad_data1,
)
# Second call
assert calls[1][0] == (
b"\x77\x88\x99\xaa\xbb\xcc",
2,
100,
0,
ad_data2,
)
def test_data_received_cmd_complete_different_opcode(
event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture
) -> None:
"""Test data_received handles CMD_COMPLETE for different opcodes."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a CMD_COMPLETE event for a different opcode (e.g., 0x0004 - Add UUID)
header = b"\x01\x00" # MGMT_EV_CMD_COMPLETE
header += b"\x00\x00" # controller_idx = 0
header += b"\x03\x00" # param_len = 3
params = b"\x04\x00" # opcode = 0x0004 (not MGMT_OP_LOAD_CONN_PARAM)
params += b"\x00" # status = 0 (success)
protocol.data_received(header + params)
# Should not log anything about connection parameters
assert "Connection parameters" not in caplog.text
def test_data_received_cmd_status_different_opcode(
event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture
) -> None:
"""Test data_received handles CMD_STATUS for different opcodes."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a CMD_STATUS event for a different opcode
header = b"\x02\x00" # MGMT_EV_CMD_STATUS
header += b"\x00\x00" # controller_idx = 0
header += b"\x03\x00" # param_len = 3
params = b"\x05\x00" # opcode = 0x0005 (not MGMT_OP_LOAD_CONN_PARAM)
params += b"\x01" # status = 1 (failure)
protocol.data_received(header + params)
# Should not log anything about connection parameters
assert "conn params" not in caplog.text
def test_data_received_cmd_complete_short_params(
event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture
) -> None:
"""Test data_received handles CMD_COMPLETE with param_len < 3."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a CMD_COMPLETE event with param_len < 3
header = b"\x01\x00" # MGMT_EV_CMD_COMPLETE
header += b"\x00\x00" # controller_idx = 0
header += b"\x02\x00" # param_len = 2 (too short to contain opcode + status)
params = b"\x00\x00" # Just 2 bytes
protocol.data_received(header + params)
# Should not log anything (no opcode to check)
assert "conn params" not in caplog.text
def test_data_received_cmd_status_param_len_1(
event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture
) -> None:
"""Test data_received handles CMD_STATUS with param_len = 1."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a CMD_STATUS event with param_len = 1
header = b"\x02\x00" # MGMT_EV_CMD_STATUS
header += b"\x00\x00" # controller_idx = 0
header += b"\x01\x00" # param_len = 1 (too short)
params = b"\x00" # Just 1 byte
protocol.data_received(header + params)
# Should not log anything (no opcode to check)
assert "conn params" not in caplog.text
def test_data_received_cmd_complete_param_len_0(
event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture
) -> None:
"""Test data_received handles CMD_COMPLETE with param_len = 0."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a CMD_COMPLETE event with param_len = 0
header = b"\x01\x00" # MGMT_EV_CMD_COMPLETE
header += b"\x00\x00" # controller_idx = 0
header += b"\x00\x00" # param_len = 0 (no params at all)
protocol.data_received(header)
# Should not log anything (no opcode to check)
assert "conn params" not in caplog.text
def test_data_received_unknown_event(event_loop: asyncio.AbstractEventLoop) -> None:
"""Test data_received ignores unknown events."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create an unknown event
header = b"\xff\x00" # Unknown event code
header += b"\x00\x00" # controller_idx = 0
header += b"\x04\x00" # param_len = 4
params = b"\x00\x00\x00\x00"
# Should not raise any exception
protocol.data_received(header + params)
def test_data_received_no_scanner_for_controller(
event_loop: asyncio.AbstractEventLoop,
) -> None:
"""Test data_received handles missing scanner gracefully."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {} # No scanner for controller 0
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Create a DEVICE_FOUND event for controller 0
ad_data = b"\x02\x01\x06"
param_len = 6 + 1 + 1 + 4 + 2 + len(ad_data)
header = b"\x12\x00\x00\x00" + param_len.to_bytes(2, "little")
params = b"\xaa\xbb\xcc\xdd\xee\xff\x01\xc8\x00\x00\x00\x00"
params += len(ad_data).to_bytes(2, "little") + ad_data
# Should not raise any exception
protocol.data_received(header + params)
@pytest.mark.asyncio
async def test_setup_success() -> None:
"""Test successful setup."""
mock_sock = Mock()
mock_sock.fileno.return_value = 1 # Mock socket file descriptor
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
# Mock the future that gets created and set
loop = asyncio.get_running_loop()
mock_future = loop.create_future()
async def mock_create_connection(*args, **kwargs):
# Set the future result to simulate connection made
mock_future.set_result(None)
return mock_transport, mock_protocol
with (
patch("habluetooth.channels.bluez.btmgmt_socket.open", return_value=mock_sock),
patch.object(
asyncio.get_running_loop(),
"_create_connection_transport",
side_effect=mock_create_connection,
),
patch.object(
asyncio.get_running_loop(),
"create_future",
side_effect=[
mock_future,
loop.create_future(),
], # First for connection, second for on_connection_lost
),
patch.object(
MGMTBluetoothCtl,
"_check_capabilities",
return_value=True, # Mock successful capability check
),
):
ctl = MGMTBluetoothCtl(5.0, {})
await ctl.setup()
assert ctl.sock is mock_sock
assert ctl.protocol is mock_protocol
assert ctl._reconnect_task is not None
# Clean up
ctl._reconnect_task.cancel()
with pytest.raises(asyncio.CancelledError):
await ctl._reconnect_task
@pytest.mark.asyncio
async def test_setup_timeout() -> None:
"""Test setup timeout."""
mock_sock = Mock()
async def slow_connect(*args, **kwargs):
await asyncio.sleep(10)
with (
patch("habluetooth.channels.bluez.btmgmt_socket.open", return_value=mock_sock),
patch.object(
asyncio.get_running_loop(),
"_create_connection_transport",
side_effect=slow_connect,
),
patch("habluetooth.channels.bluez.btmgmt_socket.close") as mock_close,
):
ctl = MGMTBluetoothCtl(0.1, {})
with pytest.raises(TimeoutError):
await ctl.setup()
mock_close.assert_called_once_with(mock_sock)
@pytest.mark.asyncio
async def test_load_conn_params_fast() -> None:
"""Test loading fast connection parameters."""
mock_sock = Mock()
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
# Mock the _write_to_socket method
mock_protocol._write_to_socket = Mock()
ctl = MGMTBluetoothCtl(5.0, {})
ctl.protocol = mock_protocol
ctl.sock = mock_sock
result = ctl.load_conn_params(
0, # adapter_idx
"AA:BB:CC:DD:EE:FF", # address
BDADDR_LE_PUBLIC, # address_type
ConnectParams.FAST,
)
assert result is True
# Verify the command was sent
mock_protocol._write_to_socket.assert_called_once()
call_args = mock_protocol._write_to_socket.call_args[0][0]
# Check header (6 bytes)
assert call_args[0:2] == b"\x35\x00" # MGMT_OP_LOAD_CONN_PARAM
assert call_args[2:4] == b"\x00\x00" # adapter_idx = 0
assert call_args[4:6] == b"\x11\x00" # param_len = 17 (2 + 15)
# Check command data
assert call_args[6:8] == b"\x01\x00" # param_count = 1
assert call_args[8:14] == b"\xff\xee\xdd\xcc\xbb\xaa" # address (reversed)
assert call_args[14] == BDADDR_LE_PUBLIC # address_type
assert call_args[15:17] == FAST_MIN_CONN_INTERVAL.to_bytes(2, "little")
assert call_args[17:19] == FAST_MAX_CONN_INTERVAL.to_bytes(2, "little")
assert call_args[19:21] == FAST_CONN_LATENCY.to_bytes(2, "little")
assert call_args[21:23] == FAST_CONN_TIMEOUT.to_bytes(2, "little")
@pytest.mark.asyncio
async def test_load_conn_params_medium() -> None:
"""Test loading medium connection parameters."""
mock_sock = Mock()
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
# Mock the _write_to_socket method
mock_protocol._write_to_socket = Mock()
ctl = MGMTBluetoothCtl(5.0, {})
ctl.protocol = mock_protocol
ctl.sock = mock_sock
result = ctl.load_conn_params(
1, # adapter_idx
"11:22:33:44:55:66", # address
BDADDR_LE_RANDOM, # address_type
ConnectParams.MEDIUM,
)
assert result is True
# Verify the command was sent
mock_protocol._write_to_socket.assert_called_once()
call_args = mock_protocol._write_to_socket.call_args[0][0]
# Check header
assert call_args[0:2] == b"\x35\x00" # MGMT_OP_LOAD_CONN_PARAM
assert call_args[2:4] == b"\x01\x00" # adapter_idx = 1
# Check parameters
assert call_args[8:14] == b"\x66\x55\x44\x33\x22\x11" # address (reversed)
assert call_args[14] == BDADDR_LE_RANDOM # address_type
assert call_args[15:17] == MEDIUM_MIN_CONN_INTERVAL.to_bytes(2, "little")
assert call_args[17:19] == MEDIUM_MAX_CONN_INTERVAL.to_bytes(2, "little")
assert call_args[19:21] == MEDIUM_CONN_LATENCY.to_bytes(2, "little")
assert call_args[21:23] == MEDIUM_CONN_TIMEOUT.to_bytes(2, "little")
def test_load_conn_params_no_protocol(caplog: pytest.LogCaptureFixture) -> None:
"""Test load_conn_params when protocol is not connected."""
ctl = MGMTBluetoothCtl(5.0, {})
result = ctl.load_conn_params(
0,
"AA:BB:CC:DD:EE:FF",
BDADDR_LE_PUBLIC,
ConnectParams.FAST,
)
assert result is False
assert "Cannot load conn params: no connection" in caplog.text
def test_load_conn_params_invalid_address(caplog: pytest.LogCaptureFixture) -> None:
"""Test load_conn_params with invalid MAC address."""
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
ctl = MGMTBluetoothCtl(5.0, {})
ctl.protocol = mock_protocol
# Test with too short address
result = ctl.load_conn_params(
0,
"AA:BB",
BDADDR_LE_PUBLIC,
ConnectParams.FAST,
)
assert result is False
assert "Invalid MAC address: AA:BB" in caplog.text
def test_load_conn_params_transport_error(caplog: pytest.LogCaptureFixture) -> None:
"""Test load_conn_params with transport write error."""
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_socket = Mock()
mock_socket.send.side_effect = Exception("Transport error")
mock_transport.get_extra_info = Mock(return_value=mock_socket)
mock_protocol.transport = mock_transport
mock_protocol._sock = mock_socket
mock_protocol._write_to_socket = Mock(side_effect=Exception("Transport error"))
ctl = MGMTBluetoothCtl(5.0, {})
ctl.protocol = mock_protocol
result = ctl.load_conn_params(
0,
"AA:BB:CC:DD:EE:FF",
BDADDR_LE_PUBLIC,
ConnectParams.FAST,
)
assert result is False
assert "Failed to load conn params" in caplog.text
@pytest.mark.asyncio
async def test_load_conn_params_explicit() -> None:
"""Test loading explicit connection parameters."""
mock_sock = Mock()
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
mock_protocol._write_to_socket = Mock()
ctl = MGMTBluetoothCtl(5.0, {})
ctl.protocol = mock_protocol
ctl.sock = mock_sock
result = ctl.load_conn_params_explicit(
0, # adapter_idx
"AA:BB:CC:DD:EE:FF", # address
BDADDR_LE_PUBLIC, # address_type
800, # min_interval
800, # max_interval
0, # latency
300, # timeout
)
assert result is True
mock_protocol._write_to_socket.assert_called_once()
call_args = mock_protocol._write_to_socket.call_args[0][0]
# Check header (6 bytes)
assert call_args[0:2] == b"\x35\x00" # MGMT_OP_LOAD_CONN_PARAM
assert call_args[2:4] == b"\x00\x00" # adapter_idx = 0
assert call_args[4:6] == b"\x11\x00" # param_len = 17 (2 + 15)
# Check command data
assert call_args[6:8] == b"\x01\x00" # param_count = 1
assert call_args[8:14] == b"\xff\xee\xdd\xcc\xbb\xaa" # address (reversed)
assert call_args[14] == BDADDR_LE_PUBLIC # address_type
assert call_args[15:17] == (800).to_bytes(2, "little") # min_interval
assert call_args[17:19] == (800).to_bytes(2, "little") # max_interval
assert call_args[19:21] == (0).to_bytes(2, "little") # latency
assert call_args[21:23] == (300).to_bytes(2, "little") # timeout
def test_load_conn_params_explicit_no_protocol(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test load_conn_params_explicit when protocol is not connected."""
ctl = MGMTBluetoothCtl(5.0, {})
result = ctl.load_conn_params_explicit(
0, "AA:BB:CC:DD:EE:FF", BDADDR_LE_PUBLIC, 800, 800, 0, 300
)
assert result is False
assert "Cannot load conn params: no connection" in caplog.text
def test_load_conn_params_explicit_invalid_address(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test load_conn_params_explicit with invalid MAC address."""
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
ctl = MGMTBluetoothCtl(5.0, {})
ctl.protocol = mock_protocol
result = ctl.load_conn_params_explicit(
0, "AA:BB", BDADDR_LE_PUBLIC, 800, 800, 0, 300
)
assert result is False
assert "Invalid MAC address: AA:BB" in caplog.text
def test_load_conn_params_explicit_transport_error(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test load_conn_params_explicit with transport write error."""
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
mock_protocol._write_to_socket = Mock(side_effect=Exception("Transport error"))
ctl = MGMTBluetoothCtl(5.0, {})
ctl.protocol = mock_protocol
result = ctl.load_conn_params_explicit(
0, "AA:BB:CC:DD:EE:FF", BDADDR_LE_PUBLIC, 800, 800, 0, 300
)
assert result is False
assert "Failed to load conn params" in caplog.text
def test_kernel_bug_workaround_send_returns_zero(
event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture
) -> None:
"""Test that the kernel bug workaround handles send returning 0."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
# Create a mock socket that returns 0 (kernel bug behavior)
mock_socket = Mock()
mock_socket.send = Mock(return_value=0)
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_socket
)
# Send some data
test_data = b"\x25\x00\x00\x00\x00\x00"
with caplog.at_level(logging.DEBUG):
protocol._write_to_socket(test_data)
# Verify the send was called and the workaround logged
mock_socket.send.assert_called_once_with(test_data)
assert "kernel bug fix" in caplog.text
def test_kernel_bug_workaround_send_raises_exception(
event_loop: asyncio.AbstractEventLoop, caplog: pytest.LogCaptureFixture
) -> None:
"""Test that _write_to_socket handles and re-raises exceptions."""
future = event_loop.create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
# Create a mock socket that raises an exception
mock_socket = Mock()
mock_socket.send = Mock(side_effect=OSError("Socket error"))
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_socket
)
# Send some data and expect the exception to be re-raised
test_data = b"\x25\x00\x00\x00\x00\x00"
with pytest.raises(OSError, match="Socket error"):
protocol._write_to_socket(test_data)
# Verify the error was logged; the traceback carries the OSError text.
assert "Failed to write to mgmt socket" in caplog.text
assert "Socket error" in caplog.text
mock_socket.send.assert_called_once_with(test_data)
def test_close() -> None:
"""Test close method."""
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
mock_sock = Mock()
mock_reconnect_task = Mock()
ctl = MGMTBluetoothCtl(5.0, {})
ctl.protocol = mock_protocol
ctl.sock = mock_sock
ctl._reconnect_task = mock_reconnect_task
with patch("habluetooth.channels.bluez.btmgmt_socket.close") as mock_close:
ctl.close()
mock_reconnect_task.cancel.assert_called_once()
mock_transport.close.assert_called_once()
mock_close.assert_called_once_with(mock_sock)
assert ctl.protocol is None
def test_close_no_protocol() -> None:
"""Test close when protocol is not set."""
ctl = MGMTBluetoothCtl(5.0, {})
# Should not raise any exception
with patch("habluetooth.channels.bluez.btmgmt_socket.close"):
ctl.close()
@pytest.mark.asyncio
async def test_on_connection_lost() -> None:
"""Test _on_connection_lost callback."""
ctl = MGMTBluetoothCtl(5.0, {})
loop = asyncio.get_running_loop()
ctl._on_connection_lost_future = loop.create_future()
ctl._on_connection_lost()
# _on_connection_lost sets the future to None after setting result
assert ctl._on_connection_lost_future is None
@pytest.mark.asyncio
async def test_on_connection_lost_during_shutdown(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test _on_connection_lost callback during shutdown."""
ctl = MGMTBluetoothCtl(5.0, {})
loop = asyncio.get_running_loop()
ctl._on_connection_lost_future = loop.create_future()
ctl._shutting_down = True
with caplog.at_level(logging.DEBUG):
ctl._on_connection_lost()
# Should log shutdown message
assert "Bluetooth management socket connection lost during shutdown" in caplog.text
# Should not log reconnecting message
assert "reconnecting" not in caplog.text
# _on_connection_lost sets the future to None after setting result
assert ctl._on_connection_lost_future is None
@pytest.mark.asyncio
async def test_reconnect_task() -> None:
"""Test reconnect_task behavior."""
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
establish_count = 0
ctl = MGMTBluetoothCtl(5.0, {})
async def mock_establish_connection() -> None:
nonlocal establish_count
establish_count += 1
if establish_count == 1:
# First call succeeds
ctl.protocol = mock_protocol
ctl._on_connection_lost_future = asyncio.get_running_loop().create_future()
elif establish_count == 2:
# Second call fails
msg = "Test error"
raise BluetoothSocketError(msg)
else:
# Stop the test
raise asyncio.CancelledError
with patch.object(
ctl, "_establish_connection", side_effect=mock_establish_connection
):
# Start the reconnect task
task = asyncio.create_task(ctl.reconnect_task())
# Trigger reconnection by calling _on_connection_lost
await asyncio.sleep(0.1)
ctl._on_connection_lost()
# Wait for reconnection attempt
await asyncio.sleep(1.5)
# Cancel the task
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert establish_count >= 2
@pytest.mark.asyncio
async def test_reconnect_task_timeout() -> None:
"""Test reconnect_task with connection timeout."""
async def mock_establish_connection() -> None:
msg = "Connection timeout"
raise TimeoutError(msg)
ctl = MGMTBluetoothCtl(5.0, {})
ctl._on_connection_lost_future = None
with patch.object(
ctl, "_establish_connection", side_effect=mock_establish_connection
):
# Run reconnect_task briefly
task = asyncio.create_task(ctl.reconnect_task())
await asyncio.sleep(0.1)
# Cancel the task
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
@pytest.mark.asyncio
async def test_reconnect_task_shutdown() -> None:
"""Test reconnect_task exits when shutting down."""
ctl = MGMTBluetoothCtl(5.0, {})
loop = asyncio.get_running_loop()
establish_called = False
async def mock_establish_connection() -> None:
nonlocal establish_called
establish_called = True
# Should not be called since we're shutting down
msg = "Should not be called"
raise AssertionError(msg)
with patch.object(
ctl, "_establish_connection", side_effect=mock_establish_connection
):
# Set up connection lost future
ctl._on_connection_lost_future = loop.create_future()
# Start the reconnect task
task = asyncio.create_task(ctl.reconnect_task())
# Give it a moment to start
await asyncio.sleep(0)
# Simulate shutdown
ctl._shutting_down = True
# Trigger the future to wake up the task
ctl._on_connection_lost_future.set_result(None)
# Task should exit cleanly
await task
# _establish_connection should not have been called
assert not establish_called
@pytest.mark.asyncio
async def test_command_response_context_manager() -> None:
"""Test the command_response context manager."""
future = asyncio.get_running_loop().create_future()
future.set_result(None) # Mark connection as made
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
# Test successful command response
opcode = 0x0015 # MGMT_OP_GET_CONNECTIONS
async with protocol.command_response(opcode) as response_future:
# Verify we got a future
assert response_future is not None
assert isinstance(response_future, asyncio.Future)
# Simulate receiving a response
response_data = (
b"\x01\x00" # MGMT_EV_CMD_COMPLETE
b"\x00\x00" # controller index
b"\x03\x00" # param_len (3 bytes: opcode=2 + status=1)
+ opcode.to_bytes(2, "little") # opcode
+ b"\x00" # status (success)
)
protocol.data_received(response_data)
# Get the result
status, _data = await response_future
assert status == 0 # Success
# After context exits, future should be resolved
assert response_future.done()
@pytest.mark.asyncio
async def test_command_response_cleanup_on_exception() -> None:
"""Test that command_response cleans up even if an exception occurs."""
future = asyncio.get_running_loop().create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
opcode = 0x0015 # MGMT_OP_GET_CONNECTIONS
# Test cleanup on exception
async def _raise_inside_command_response() -> None:
async with protocol.command_response(opcode) as response_future:
assert response_future is not None
msg = "Test exception"
raise ValueError(msg)
with pytest.raises(ValueError, match="Test exception"):
await _raise_inside_command_response()
# The future should still exist after exception
# (cleanup just removes it from internal tracking)
@pytest.mark.asyncio
async def test_get_connections_response_handling() -> None:
"""Test handling of GET_CONNECTIONS command response."""
future = asyncio.get_running_loop().create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
opcode = 0x0015 # MGMT_OP_GET_CONNECTIONS
# Use the command_response context manager to register the command
async with protocol.command_response(opcode) as response_future:
# Test with permission denied status (0x14)
response_data = (
b"\x01\x00" # MGMT_EV_CMD_COMPLETE
b"\x00\x00" # controller index
b"\x03\x00" # param_len
+ opcode.to_bytes(2, "little") # opcode
+ b"\x14" # status (permission denied)
)
protocol.data_received(response_data)
# Verify the future was resolved with the status
status, data = await response_future
assert status == 0x14 # Permission denied
assert data == b"" # No additional data for param_len <= 3
@pytest.mark.asyncio
async def test_get_connections_response_with_data() -> None:
"""Test GET_CONNECTIONS response with additional data."""
future = asyncio.get_running_loop().create_future()
scanners: dict[int, HaScanner] = {}
on_connection_lost = Mock()
is_shutting_down = Mock(return_value=False)
mock_sock = Mock()
protocol = BluetoothMGMTProtocol(
future, scanners, on_connection_lost, is_shutting_down, mock_sock
)
opcode = 0x0015 # MGMT_OP_GET_CONNECTIONS
# Use the command_response context manager to register the command
async with protocol.command_response(opcode) as response_future:
# Test with success status and additional data
extra_data = b"\x01\x02\x03\x04"
response_data = (
b"\x01\x00" # MGMT_EV_CMD_COMPLETE
b"\x00\x00" # controller index
+ (3 + len(extra_data)).to_bytes(
2, "little"
) # param_len (opcode=2 + status=1 + extra_data)
+ opcode.to_bytes(2, "little") # opcode
+ b"\x00" # status (success)
+ extra_data # additional response data
)
protocol.data_received(response_data)
# Verify the future was resolved with status and data
status, data = await response_future
assert status == 0 # Success
assert data == extra_data
@pytest.mark.asyncio
async def test_has_mgmt_capabilities_from_status() -> None:
"""Test _has_mgmt_capabilities_from_status helper function."""
mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={})
# Test permission denied
assert mgmt_ctl._has_mgmt_capabilities_from_status(0x14) is False
# Test success
assert mgmt_ctl._has_mgmt_capabilities_from_status(0x00) is True
# Test invalid index (still has permissions)
assert mgmt_ctl._has_mgmt_capabilities_from_status(0x11) is True
# Test unknown status (assumes no permissions)
assert mgmt_ctl._has_mgmt_capabilities_from_status(0xFF) is False
assert mgmt_ctl._has_mgmt_capabilities_from_status(0x01) is False
assert mgmt_ctl._has_mgmt_capabilities_from_status(0x0D) is False
@pytest.mark.asyncio
async def test_check_capabilities_success() -> None:
"""Test _check_capabilities when permissions are available."""
mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={})
# Mock the protocol and transport
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
mgmt_ctl.protocol = mock_protocol
# Mock command_response to return success
def mock_command_response(opcode: int) -> object:
future = asyncio.get_running_loop().create_future()
future.set_result((0x00, b"")) # Success status
class MockContext:
async def __aenter__(self) -> asyncio.Future[tuple[int, bytes]]:
return future
async def __aexit__(self, *args: object) -> None:
pass
return MockContext()
mock_protocol.command_response = mock_command_response
# Mock the _write_to_socket method
mock_protocol._write_to_socket = Mock()
# Test capability check
result = await mgmt_ctl._check_capabilities()
assert result is True
# Verify the command was sent
mock_protocol._write_to_socket.assert_called_once()
sent_data = mock_protocol._write_to_socket.call_args[0][0]
# Check that it's a GET_CONNECTIONS command (opcode at bytes 0-1)
assert sent_data[0:2] == b"\x15\x00" # MGMT_OP_GET_CONNECTIONS little-endian
@pytest.mark.asyncio
async def test_check_capabilities_permission_denied() -> None:
"""Test _check_capabilities when permissions are denied."""
mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={})
# Mock the protocol and transport
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
mgmt_ctl.protocol = mock_protocol
# Mock command_response to return permission denied
def mock_command_response(opcode: int) -> object:
future = asyncio.get_running_loop().create_future()
future.set_result((0x14, b"")) # Permission denied status
class MockContext:
async def __aenter__(self) -> asyncio.Future[tuple[int, bytes]]:
return future
async def __aexit__(self, *args: object) -> None:
pass
return MockContext()
mock_protocol.command_response = mock_command_response
# Test capability check
result = await mgmt_ctl._check_capabilities()
assert result is False
@pytest.mark.asyncio
async def test_check_capabilities_invalid_index() -> None:
"""Test _check_capabilities with invalid adapter index (still has permissions)."""
mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={})
# Mock the protocol and transport
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
mgmt_ctl.protocol = mock_protocol
# Mock command_response to return invalid index
def mock_command_response(opcode: int) -> object:
future = asyncio.get_running_loop().create_future()
future.set_result((0x11, b"")) # Invalid index
class MockContext:
async def __aenter__(self) -> asyncio.Future[tuple[int, bytes]]:
return future
async def __aexit__(self, *args: object) -> None:
pass
return MockContext()
mock_protocol.command_response = mock_command_response
# Test capability check - invalid index means adapter doesn't exist
# but we still have permissions
result = await mgmt_ctl._check_capabilities()
assert result is True
@pytest.mark.asyncio
async def test_check_capabilities_unknown_status() -> None:
"""Test _check_capabilities with unknown status code."""
mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={})
# Mock the protocol and transport
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
mgmt_ctl.protocol = mock_protocol
# Mock command_response to return unknown status
def mock_command_response(opcode: int) -> object:
future = asyncio.get_running_loop().create_future()
future.set_result((0xFF, b"")) # Unknown status
class MockContext:
async def __aenter__(self) -> asyncio.Future[tuple[int, bytes]]:
return future
async def __aexit__(self, *args: object) -> None:
pass
return MockContext()
mock_protocol.command_response = mock_command_response
# Test capability check - unknown status assumes no permissions
result = await mgmt_ctl._check_capabilities()
assert result is False
@pytest.mark.asyncio
async def test_check_capabilities_timeout() -> None:
"""Test _check_capabilities when command times out."""
mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={})
# Mock the protocol and transport
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
mgmt_ctl.protocol = mock_protocol
# Mock command_response to timeout
def mock_command_response(opcode: int) -> object:
future = asyncio.get_running_loop().create_future()
# Never resolve the future
class MockContext:
async def __aenter__(self) -> asyncio.Future[tuple[int, bytes]]:
return future
async def __aexit__(self, *args: object) -> None:
pass
return MockContext()
mock_protocol.command_response = mock_command_response
# Test capability check with a very short timeout
with patch("habluetooth.channels.bluez.asyncio_timeout") as mock_timeout:
# Make timeout raise immediately
mock_timeout.side_effect = TimeoutError("Test timeout")
result = await mgmt_ctl._check_capabilities()
assert result is False
@pytest.mark.asyncio
async def test_check_capabilities_no_protocol() -> None:
"""Test _check_capabilities when protocol is not set."""
mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={})
# No protocol set
mgmt_ctl.protocol = None
result = await mgmt_ctl._check_capabilities()
assert result is False
@pytest.mark.asyncio
async def test_check_capabilities_no_transport() -> None:
"""Test _check_capabilities when transport is not set."""
mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={})
# Mock protocol with no transport
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_protocol.transport = None
mgmt_ctl.protocol = mock_protocol
result = await mgmt_ctl._check_capabilities()
assert result is False
@pytest.mark.asyncio
async def test_setup_with_failed_capabilities() -> None:
"""Test setup raises PermissionError when capabilities check fails."""
with (
patch("habluetooth.channels.bluez.btmgmt_socket") as mock_btmgmt,
patch.object(MGMTBluetoothCtl, "_establish_connection") as mock_establish,
patch.object(MGMTBluetoothCtl, "_check_capabilities", return_value=False),
):
mock_socket = Mock()
mock_socket.fileno.return_value = 99
mock_btmgmt.open.return_value = mock_socket
mgmt_ctl = MGMTBluetoothCtl(timeout=5.0, scanners={})
# Mock successful connection establishment
mock_establish.return_value = None
# Set the socket on mgmt_ctl
mgmt_ctl.sock = mock_socket
# Mock protocol for close operation
mock_protocol = Mock()
mock_transport = Mock()
mock_protocol.transport = mock_transport
mgmt_ctl.protocol = mock_protocol
# Setup should raise PermissionError
with pytest.raises(PermissionError) as exc_info:
await mgmt_ctl.setup()
assert "Missing NET_ADMIN/NET_RAW capabilities" in str(exc_info.value)
# Verify cleanup
assert mgmt_ctl._shutting_down is True
mock_transport.close.assert_called_once()
mock_btmgmt.close.assert_called_once_with(mock_socket)
Bluetooth-Devices-habluetooth-75cbe37/tests/conftest.py 0000664 0000000 0000000 00000022104 15211177045 0023337 0 ustar 00root root 0000000 0000000 from collections.abc import AsyncGenerator, Generator, Iterable
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from bleak.backends.scanner import AdvertisementData, BLEDevice
from bleak_retry_connector import BleakSlotManager
from bluetooth_adapters import AdapterDetails, BluetoothAdapters
from habluetooth import (
BaseHaRemoteScanner,
BaseHaScanner,
BluetoothManager,
get_manager,
set_manager,
)
from habluetooth import scanner as bluetooth_scanner
class FakeBluetoothAdapters(BluetoothAdapters):
@property
def adapters(self) -> dict[str, AdapterDetails]:
return {}
class FakeScannerMixin:
def get_discovered_device_advertisement_data(
self, address: str
) -> tuple[BLEDevice, AdvertisementData] | None:
"""Return the advertisement data for a discovered device."""
return self.discovered_devices_and_advertisement_data.get(address) # type: ignore[attr-defined]
@property
def discovered_addresses(self) -> Iterable[str]:
"""Return an iterable of discovered devices."""
return self.discovered_devices_and_advertisement_data # type: ignore[attr-defined]
class FakeScanner(FakeScannerMixin, BaseHaScanner):
"""Fake scanner."""
@property
def discovered_devices(self) -> list[BLEDevice]:
"""Return a list of discovered devices."""
return []
@property
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
"""Return a list of discovered devices and their advertisement data."""
return {}
class PatchableBluetoothManager(BluetoothManager):
"""Patchable Bluetooth Manager for testing."""
@pytest_asyncio.fixture(autouse=True)
async def manager() -> AsyncGenerator[None, None]:
slot_manager = BleakSlotManager()
bluetooth_adapters = FakeBluetoothAdapters()
manager = PatchableBluetoothManager(bluetooth_adapters, slot_manager)
set_manager(manager)
await manager.async_setup()
yield
manager.async_stop()
@pytest_asyncio.fixture(name="enable_bluetooth")
async def mock_enable_bluetooth(
mock_bleak_scanner_start: MagicMock,
mock_bluetooth_adapters: None,
) -> AsyncGenerator[None, None]:
"""Fixture to mock starting the bleak scanner."""
manager = get_manager()
assert manager._bluetooth_adapters is not None
await manager.async_setup()
yield
manager._all_history.clear()
manager._connectable_history.clear()
manager._name_cache.clear()
manager._unavailable_callbacks.clear()
manager._connectable_unavailable_callbacks.clear()
manager._bleak_callbacks.clear()
manager._fallback_intervals.clear()
manager._intervals.clear()
manager._adapter_sources.clear()
manager._adapters.clear()
manager._sources.clear()
manager._allocations.clear()
manager._non_connectable_scanners.clear()
manager._connectable_scanners.clear()
@pytest.fixture(scope="session")
def mock_bluetooth_adapters() -> Generator[None, None, None]:
"""Fixture to mock bluetooth adapters."""
with (
patch("bluetooth_auto_recovery.recover_adapter"),
patch("bluetooth_adapters.systems.platform.system", return_value="Linux"),
patch("bluetooth_adapters.systems.linux.LinuxAdapters.refresh"),
patch(
"bluetooth_adapters.systems.linux.LinuxAdapters.adapters",
{
"hci0": {
"address": "00:00:00:00:00:01",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
},
),
):
yield
@pytest.fixture
def mock_bleak_scanner_start() -> Generator[MagicMock, None, None]:
"""Fixture to mock starting the bleak scanner."""
bluetooth_scanner.OriginalBleakScanner.stop = AsyncMock()
with (
patch.object(
bluetooth_scanner.OriginalBleakScanner,
"start",
) as mock_bleak_scanner_start,
patch.object(bluetooth_scanner, "HaScanner"),
):
yield mock_bleak_scanner_start
@pytest.fixture(name="two_adapters")
def two_adapters_fixture():
"""Fixture that mocks two adapters on Linux."""
with (
patch(
"habluetooth.scanner.platform.system",
return_value="Linux",
),
patch("bluetooth_adapters.systems.platform.system", return_value="Linux"),
patch("bluetooth_adapters.systems.linux.LinuxAdapters.refresh"),
patch(
"bluetooth_adapters.systems.linux.LinuxAdapters.adapters",
{
"hci0": {
"address": "00:00:00:00:00:01",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
"connection_slots": 1,
},
"hci1": {
"address": "00:00:00:00:00:02",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": True,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
"connection_slots": 2,
},
},
),
):
yield
@pytest.fixture(name="macos_adapter")
def macos_adapter() -> Generator[None, None, None]:
"""Fixture that mocks the macos adapter."""
with (
patch("bleak.get_platform_scanner_backend_type"),
patch(
"habluetooth.scanner.platform.system",
return_value="Darwin",
),
patch(
"bluetooth_adapters.systems.platform.system",
return_value="Darwin",
),
patch("habluetooth.scanner.SYSTEM", "Darwin"),
):
yield
@pytest.fixture
def register_hci0_scanner() -> Generator[None, None, None]:
"""Register an hci0 scanner."""
hci0_scanner = FakeScanner("AA:BB:CC:DD:EE:00", "hci0")
hci0_scanner.connectable = True
manager = get_manager()
cancel = manager.async_register_scanner(hci0_scanner, connection_slots=5)
yield
cancel()
@pytest.fixture
def register_hci1_scanner() -> Generator[None, None, None]:
"""Register an hci1 scanner."""
hci1_scanner = FakeScanner("AA:BB:CC:DD:EE:11", "hci1")
hci1_scanner.connectable = True
manager = get_manager()
cancel = manager.async_register_scanner(hci1_scanner, connection_slots=5)
yield
cancel()
@pytest.fixture
def register_non_connectable_scanner() -> Generator[None, None, None]:
"""Register an non connectable remote scanner."""
remote_scanner = BaseHaRemoteScanner(
"AA:BB:CC:DD:EE:FF", "non connectable", None, False
)
manager = get_manager()
cancel = manager.async_register_scanner(remote_scanner)
yield
cancel()
class MockBluetoothManagerWithCallbacks(BluetoothManager):
"""Mock bluetooth manager that tracks scanner start callbacks."""
def __init__(self, *args, **kwargs):
"""Initialize the mock manager."""
super().__init__(*args, **kwargs)
self.scanner_start_calls = []
def on_scanner_start(self, scanner):
"""Track scanner start calls."""
self.scanner_start_calls.append(scanner)
super().on_scanner_start(scanner)
@pytest.fixture
def mock_manager_with_scanner_callbacks() -> Generator[
MockBluetoothManagerWithCallbacks, None, None
]:
"""Provide a mock BluetoothManager that tracks scanner start callbacks."""
mock_bluetooth_adapters = FakeBluetoothAdapters()
manager = MockBluetoothManagerWithCallbacks(
mock_bluetooth_adapters,
slot_manager=MagicMock(),
)
# Save the original manager
original_manager = get_manager()
# Set our mock manager as the global manager
set_manager(manager)
try:
yield manager
finally:
# Restore the original manager
set_manager(original_manager)
@pytest_asyncio.fixture
async def async_mock_manager_with_scanner_callbacks() -> AsyncGenerator[
MockBluetoothManagerWithCallbacks, None
]:
"""Provide an async mock BluetoothManager that tracks scanner start callbacks."""
mock_bluetooth_adapters = FakeBluetoothAdapters()
manager = MockBluetoothManagerWithCallbacks(
mock_bluetooth_adapters,
slot_manager=MagicMock(),
)
# Setup the manager
await manager.async_setup()
# Save the original manager
original_manager = get_manager()
# Set our mock manager as the global manager
set_manager(manager)
try:
yield manager
finally:
# Restore the original manager
set_manager(original_manager)
Bluetooth-Devices-habluetooth-75cbe37/tests/test_advertisement_tracker.py 0000664 0000000 0000000 00000006125 15211177045 0027143 0 ustar 00root root 0000000 0000000 """Test that advertising interval tracking is properly cleared when scanner pauses."""
import pytest
from habluetooth.advertisement_tracker import AdvertisementTracker
from habluetooth.base_scanner import BaseHaScanner
from habluetooth.central_manager import get_manager
@pytest.mark.asyncio
async def test_scanner_paused_clears_timing_data():
"""Test timing data is cleared when scanner pauses but intervals are preserved."""
tracker = AdvertisementTracker()
source = "test_scanner"
address = "AA:BB:CC:DD:EE:FF"
# Simulate collecting timing data
tracker.sources[address] = source
tracker._timings[address] = [1.0, 2.0, 3.0] # Some timing data
tracker.intervals[address] = 10.0 # Already learned interval
# Call async_scanner_paused
tracker.async_scanner_paused(source)
# Check that timing data is cleared but interval is preserved
assert address not in tracker._timings
assert tracker.intervals[address] == 10.0 # Interval should still be there
assert tracker.sources[address] == source # Source mapping should still be there
@pytest.mark.asyncio
async def test_scanner_paused_only_affects_matching_source():
"""Test that pausing only affects devices from the matching source."""
tracker = AdvertisementTracker()
source1 = "scanner1"
source2 = "scanner2"
address1 = "AA:BB:CC:DD:EE:01"
address2 = "AA:BB:CC:DD:EE:02"
# Set up data for two sources
tracker.sources[address1] = source1
tracker.sources[address2] = source2
tracker._timings[address1] = [1.0, 2.0]
tracker._timings[address2] = [1.0, 2.0]
tracker.intervals[address1] = 5.0
tracker.intervals[address2] = 6.0
# Pause only source1
tracker.async_scanner_paused(source1)
# Check that only source1 timing is cleared
assert address1 not in tracker._timings
assert address2 in tracker._timings # source2 should still have timing data
assert tracker.intervals[address1] == 5.0 # Intervals preserved
assert tracker.intervals[address2] == 6.0
@pytest.mark.asyncio
async def test_connection_clears_timing_data():
"""Test that timing data is cleared when a connection is initiated."""
# Get the manager that was set up by the fixture
test_manager = get_manager()
# Create actual BaseHaScanner to test the method
real_scanner = BaseHaScanner(
source="test_scanner", adapter="hci0", connectable=True
)
# BaseHaScanner gets the manager internally via get_manager()
# Set up some timing data
address = "AA:BB:CC:DD:EE:FF"
test_manager._advertisement_tracker.sources[address] = real_scanner.source
test_manager._advertisement_tracker._timings[address] = [1.0, 2.0, 3.0]
test_manager._advertisement_tracker.intervals[address] = 10.0
# Call _add_connecting which should clear timing data
real_scanner._add_connecting(address)
# Verify timing data was cleared but interval preserved
assert address not in test_manager._advertisement_tracker._timings
assert test_manager._advertisement_tracker.intervals.get(address) == 10.0
assert address in real_scanner._connect_in_progress
Bluetooth-Devices-habluetooth-75cbe37/tests/test_auto_scheduler.py 0000664 0000000 0000000 00001001050 15211177045 0025555 0 ustar 00root root 0000000 0000000 """Tests for the auto-mode active-window scheduler."""
from __future__ import annotations
import asyncio
import contextlib
import logging
import math
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
import pytest
from freezegun import freeze_time
from habluetooth import (
BaseHaScanner,
BluetoothScanningMode,
get_manager,
)
from habluetooth.auto_scheduler import ActiveScanRequest, _ScanSchedule
from habluetooth.const import (
AUTO_INITIAL_SWEEP_DELAY,
AUTO_REDISCOVERY_INTERVAL,
AUTO_REDISCOVERY_SWEEP_DURATION,
AUTO_WINDOW_MAX_DURATION,
AUTO_WINDOW_MIN_DURATION,
DEFAULT_ACTIVE_SCAN_DURATION,
DEFAULT_ACTIVE_SCAN_INTERVAL,
DEFAULT_ON_DEMAND_SWEEP_DURATION,
)
from . import generate_advertisement_data, generate_ble_device
if TYPE_CHECKING:
from collections.abc import Iterable
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
class _RecordingAutoScanner(BaseHaScanner):
"""BaseHaScanner subclass that records active-window calls."""
__slots__ = ("_block_event", "_return_value", "active_window_calls")
def __init__(
self,
source: str,
mode: BluetoothScanningMode | None,
connectable: bool = True,
) -> None:
super().__init__(source, source, requested_mode=mode)
self.connectable = connectable
self.active_window_calls: list[float] = []
self._block_event: asyncio.Event | None = None
self._return_value = True
async def async_request_active_window(self, duration: float) -> bool:
self.active_window_calls.append(duration)
if self._block_event is not None:
await self._block_event.wait()
return self._return_value
@property
def discovered_devices(self) -> list[BLEDevice]:
return []
@property
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
return {}
def get_discovered_device_advertisement_data(
self, address: str
) -> tuple[BLEDevice, AdvertisementData] | None:
return None
@property
def discovered_addresses(self) -> Iterable[str]:
return ()
def _inject(scanner: _RecordingAutoScanner, address: str) -> None:
"""Drive a fake advertisement through the scanner's normal path."""
adv = generate_advertisement_data(local_name="x")
device = generate_ble_device(address, "x")
scanner._async_on_advertisement(
device.address,
adv.rssi,
device.name or "",
adv.service_uuids,
adv.service_data,
adv.manufacturer_data,
adv.tx_power,
{},
asyncio.get_running_loop().time(),
)
async def _run_worker_tick(scheduler: object, source: str) -> None:
"""Drive one worker through a single tick for deterministic testing."""
worker = scheduler._workers[source] # type: ignore[attr-defined]
await worker._tick()
def _assert_schedule_invariant(sched: object) -> None:
"""
Assert the schedule's three indices are in lock step.
1. ``_due_at`` and ``_owner_by_address`` have the same keyset.
2. Every ``_due_at`` value is a non-empty dict.
3. For each AUTO worker, its ``_owned_due_at`` exactly equals the
addresses the schedule says it owns, and each entry's dict object
is the *same* dict aliased from ``_due_at`` (not a copy).
4. Addresses owned by a non-AUTO source do not appear in any
worker's ``_owned_due_at``.
5. Each worker's hot-path ``_next_event_at`` returns without
raising. An empty bucket in ``_owned_due_at`` would crash with
``ValueError`` from ``min(())``, so calling it here is the
active runtime check for the no-empty-buckets invariant the
production code relies on.
"""
schedule = sched._schedule # type: ignore[attr-defined]
workers = sched._workers # type: ignore[attr-defined]
assert set(schedule._due_at) == set(schedule._owner_by_address), (
f"_due_at keys {set(schedule._due_at)} != "
f"_owner_by_address keys {set(schedule._owner_by_address)}"
)
for address, entries in schedule._due_at.items():
assert entries, f"_due_at[{address}] is empty"
for source, worker in workers.items():
index_owned = {
addr
for addr, owner in schedule._owner_by_address.items()
if owner == source
}
worker_owned = set(worker._owned_due_at)
assert worker_owned == index_owned, (
f"worker {source}: _owned_due_at={worker_owned} "
f"!= index-owned={index_owned}"
)
for addr in worker_owned:
assert worker._owned_due_at[addr] is schedule._due_at[addr], (
f"worker {source}: _owned_due_at[{addr}] is not the "
f"same dict object as _due_at[{addr}]"
)
for address, source in schedule._owner_by_address.items():
if source not in workers:
for worker in workers.values():
assert address not in worker._owned_due_at, (
f"non-AUTO owner {source} leaked into worker "
f"{worker._scanner.source}'s _owned_due_at"
)
# Hot-path sanity: would raise ``ValueError`` from ``min(())`` if
# any owned bucket were empty. This keeps the guard the source
# code removed alive at the test layer.
now = asyncio.get_running_loop().time()
for worker in workers.values():
worker._next_event_at(now)
@pytest.mark.asyncio
async def test_advertisement_starts_tracking() -> None:
"""A matching address advertisement creates a per-(address, request) entry."""
manager = get_manager()
sched = manager._auto_scheduler
cancel = manager.async_register_active_scan(
"11:22:33:44:55:66", scan_interval=120.0, scan_duration=6.0
)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, "11:22:33:44:55:66")
assert "11:22:33:44:55:66" in sched._schedule._due_at
finally:
cancel()
register_cancel()
assert sched._schedule._due_at == {}
@pytest.mark.asyncio
async def test_advertisement_for_unrelated_address_is_ignored() -> None:
"""An advertisement for an unregistered address creates no tracking."""
manager = get_manager()
sched = manager._auto_scheduler
cancel = manager.async_register_active_scan(
"11:22:33:44:55:66", scan_interval=120.0
)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
# The registered address has tracking from add_request; the
# unrelated advertisement must not create its own entry.
_inject(scanner, "AA:AA:AA:AA:AA:AA")
assert "AA:AA:AA:AA:AA:AA" not in sched._schedule._due_at
finally:
cancel()
register_cancel()
@pytest.mark.asyncio
async def test_worker_tick_fires_active_window() -> None:
"""A due tracker entry causes the owning scanner's worker to fire a window."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
cancel = manager.async_register_active_scan(
"11:22:33:44:55:66", scan_interval=120.0, scan_duration=5.0
)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, "11:22:33:44:55:66")
entries = sched._schedule._due_at["11:22:33:44:55:66"]
request = next(iter(entries))
entries[request] = loop.time() - 1.0
await _run_worker_tick(sched, scanner.source)
assert scanner.active_window_calls == [5.0]
assert entries[request] > loop.time()
finally:
cancel()
register_cancel()
@pytest.mark.asyncio
async def test_worker_tick_advances_by_scan_interval_from_window_start() -> None:
"""
Next-due is window_start + scan_interval, not window_end + scan_interval.
scan_interval is documented as the cadence between window *starts*.
The scheduler advances entries from the tick's ``now`` (when the
window starts) so the effective period is exactly ``scan_interval``;
advancing from ``window_end`` instead would make the effective
period ``scan_interval + scan_duration``.
"""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:77"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=15.0
)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
entries = sched._schedule._due_at[address]
request = next(iter(entries))
entries[request] = loop.time() - 1.0
before_tick = loop.time()
await _run_worker_tick(sched, scanner.source)
# entries[request] should be the tick's now + scan_interval ==
# roughly before_tick + 120. Definitely NOT before_tick + 135
# (which is what "scan_interval after window ends" would give).
assert entries[request] == pytest.approx(before_tick + 120.0, abs=0.1)
assert entries[request] < before_tick + 130.0
finally:
cancel()
register_cancel()
@pytest.mark.asyncio
async def test_worker_tick_coalesces_near_future_due_entries() -> None:
"""Staggered registrations sync to one window instead of back-to-back flips."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
addr_a = "11:22:33:44:55:01"
addr_b = "11:22:33:44:55:02"
cancel_a = manager.async_register_active_scan(
addr_a, scan_interval=300.0, scan_duration=10.0
)
cancel_b = manager.async_register_active_scan(
addr_b, scan_interval=300.0, scan_duration=10.0
)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, addr_a)
_inject(scanner, addr_b)
# A is due now; B is due 10s from now (within the lookahead).
# One tick should serve both.
now = loop.time()
entries_a = sched._schedule._due_at[addr_a]
entries_b = sched._schedule._due_at[addr_b]
for req in entries_a:
entries_a[req] = now - 1.0
for req in entries_b:
entries_b[req] = now + 10.0
await _run_worker_tick(sched, scanner.source)
# One window covers both — not two back-to-back.
assert scanner.active_window_calls == [10.0]
# Both advanced from now (~now + 300), so they coalesce again
# next tick rather than staying 10s apart forever.
post_tick_now = loop.time()
a_next = next(iter(entries_a.values()))
b_next = next(iter(entries_b.values()))
assert a_next == pytest.approx(post_tick_now + 300.0, abs=1.0)
assert b_next == pytest.approx(post_tick_now + 300.0, abs=1.0)
assert abs(a_next - b_next) < 0.5
finally:
cancel_a()
cancel_b()
register_cancel()
@pytest.mark.asyncio
async def test_worker_tick_does_not_fire_when_only_soon_due_no_immediate() -> None:
"""Soon-due entries alone do not trigger an early tick."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:66"
cancel = manager.async_register_active_scan(
address, scan_interval=300.0, scan_duration=10.0
)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
entries = sched._schedule._due_at[address]
# Set next_due 5s in the future — within the lookahead but
# not immediately due.
for req in entries:
entries[req] = loop.time() + 5.0
await _run_worker_tick(sched, scanner.source)
assert scanner.active_window_calls == []
finally:
cancel()
register_cancel()
@pytest.mark.asyncio
async def test_worker_tick_coalesces_near_max_window_boundary() -> None:
"""A 30s window pulls in a device due at now+25; lookahead > max window."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
addr_a = "11:22:33:44:55:01"
addr_b = "11:22:33:44:55:02"
cancel_a = manager.async_register_active_scan(
addr_a, scan_interval=300.0, scan_duration=30.0
)
cancel_b = manager.async_register_active_scan(
addr_b, scan_interval=300.0, scan_duration=30.0
)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, addr_a)
_inject(scanner, addr_b)
now = loop.time()
entries_a = sched._schedule._due_at[addr_a]
entries_b = sched._schedule._due_at[addr_b]
for req in entries_a:
entries_a[req] = now - 1.0
for req in entries_b:
entries_b[req] = now + 25.0
await _run_worker_tick(sched, scanner.source)
# One 30s window covers both.
assert scanner.active_window_calls == [30.0]
finally:
cancel_a()
cancel_b()
register_cancel()
@pytest.mark.asyncio
async def test_worker_tick_fallback_dispatch_rides_soon_due_entries() -> None:
"""Soon-due entries coalesce into the connecting-fallback dispatch."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
addr_a = "11:22:33:44:55:01"
addr_b = "11:22:33:44:55:02"
cancel_a = manager.async_register_active_scan(
addr_a, scan_interval=300.0, scan_duration=10.0
)
cancel_b = manager.async_register_active_scan(
addr_b, scan_interval=300.0, scan_duration=10.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:01:01", BluetoothScanningMode.AUTO)
fallback = _DiscoverableAutoScanner("AA:00:00:00:01:02", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_fallback = manager.async_register_scanner(fallback)
try:
_inject_with_rssi(owner, addr_a, rssi=-50)
_inject_with_rssi(owner, addr_b, rssi=-50)
fallback.add_discovered(addr_a, rssi=-70)
fallback.add_discovered(addr_b, rssi=-70)
owner._add_connecting(addr_a)
now = loop.time()
entries_a = sched._schedule._due_at[addr_a]
entries_b = sched._schedule._due_at[addr_b]
for req in entries_a:
entries_a[req] = now - 1.0
for req in entries_b:
entries_b[req] = now + 10.0
await _run_worker_tick(sched, owner.source)
# Owner is mid-connect; fallback gets exactly one coalesced
# call covering both addresses (both scan_duration=10).
assert owner.active_window_calls == []
assert fallback.active_window_calls == [10.0]
# Both addresses advanced by their scan_interval — soon-due
# rode the fallback dispatch instead of firing separately.
post_tick_now = loop.time()
a_new_due = next(iter(entries_a.values()))
b_new_due = next(iter(entries_b.values()))
assert a_new_due == pytest.approx(post_tick_now + 300.0, abs=1.0)
assert b_new_due == pytest.approx(post_tick_now + 300.0, abs=1.0)
finally:
owner._finished_connecting(addr_a, connected=False)
cancel_a()
cancel_b()
c_owner()
c_fallback()
@pytest.mark.asyncio
async def test_worker_tick_sweep_alone_pulls_in_soon_due_entries() -> None:
"""A sweep-only tick pulls in soon-due entries so they don't fire after."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:66"
cancel = manager.async_register_active_scan(
address, scan_interval=300.0, scan_duration=10.0
)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
worker = sched._workers[scanner.source]
# Make the sweep due; per-device entry is soon-due but not
# immediate.
worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0
entries = sched._schedule._due_at[address]
soon_due_at = loop.time() + 5.0
for req in entries:
entries[req] = soon_due_at
await _run_worker_tick(sched, scanner.source)
# Sweep fired; the soon-due entry was advanced so it does
# not trigger a back-to-back flip after the sweep ends.
assert len(scanner.active_window_calls) == 1
post_tick_now = loop.time()
new_due = next(iter(entries.values()))
assert new_due > soon_due_at
assert new_due == pytest.approx(post_tick_now + 300.0, abs=1.0)
finally:
cancel()
register_cancel()
@pytest.mark.asyncio
async def test_worker_tick_coalesces_overlapping_requests() -> None:
"""Multiple requests for the same address coalesce on max scan_duration."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:66"
cancel1 = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
cancel2 = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=10.0
)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
entries = sched._schedule._due_at[address]
for req in list(entries):
entries[req] = loop.time() - 1.0
await _run_worker_tick(sched, scanner.source)
assert scanner.active_window_calls == [10.0]
finally:
cancel1()
cancel2()
register_cancel()
@pytest.mark.asyncio
async def test_multiple_requests_same_address_track_independent_intervals() -> None:
"""Two registrations for the same address fire on their own cadences."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:66"
cancel_fast = manager.async_register_active_scan(
address, scan_interval=60.0, scan_duration=5.0
)
cancel_slow = manager.async_register_active_scan(
address, scan_interval=300.0, scan_duration=7.0
)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
entries = sched._schedule._due_at[address]
assert len(entries) == 2
fast, slow = sorted(entries, key=lambda r: r.scan_interval)
entries[fast] = loop.time() - 1.0
entries[slow] = loop.time() + 200.0
await _run_worker_tick(sched, scanner.source)
assert scanner.active_window_calls == [5.0]
assert entries[fast] > loop.time()
assert entries[slow] > loop.time() + 100
entries[fast] = loop.time() - 1.0
entries[slow] = loop.time() - 1.0
await _run_worker_tick(sched, scanner.source)
assert scanner.active_window_calls == [5.0, 7.0]
finally:
cancel_fast()
cancel_slow()
register_cancel()
@pytest.mark.asyncio
async def test_no_worker_for_non_auto_scanner() -> None:
"""ACTIVE / PASSIVE scanners don't get a worker; their windows are never fired."""
manager = get_manager()
sched = manager._auto_scheduler
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.ACTIVE)
register_cancel = manager.async_register_scanner(scanner)
try:
assert scanner.source not in sched._workers
finally:
register_cancel()
@pytest.mark.asyncio
async def test_global_sweep_runs_on_auto_scanner() -> None:
"""The sweep fires async_request_active_window with SWEEP_DURATION."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
worker = sched._workers[scanner.source]
worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0
await _run_worker_tick(sched, scanner.source)
assert scanner.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION]
assert worker._sweep_last_completed > loop.time() - 1.0
finally:
register_cancel()
@pytest.mark.asyncio
async def test_first_sweeps_stagger_across_scanners() -> None:
"""Concurrently-registered scanners get offset first-sweep times."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
s1 = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
s2 = _RecordingAutoScanner("AA:BB:CC:DD:EE:11", BluetoothScanningMode.AUTO)
s3 = _RecordingAutoScanner("AA:BB:CC:DD:EE:22", BluetoothScanningMode.AUTO)
c1 = manager.async_register_scanner(s1)
c2 = manager.async_register_scanner(s2)
c3 = manager.async_register_scanner(s3)
try:
now = loop.time()
sweep_1 = (
sched._workers[s1.source]._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL
)
sweep_2 = (
sched._workers[s2.source]._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL
)
sweep_3 = (
sched._workers[s3.source]._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL
)
# Each subsequent worker's first sweep is at least one
# sweep-duration later than the previous one's. The delta is
# `SWEEP_DURATION + (loop.time() drift between spawn calls)`,
# so assert the floor rather than equality with a tight
# tolerance — CI registrations can take >10ms between
# _spawn_worker calls and would otherwise flake.
assert sweep_2 - sweep_1 >= AUTO_REDISCOVERY_SWEEP_DURATION
assert sweep_3 - sweep_2 >= AUTO_REDISCOVERY_SWEEP_DURATION
# And the drift component stays small — well under a second.
assert sweep_2 - sweep_1 < AUTO_REDISCOVERY_SWEEP_DURATION + 1.0
assert sweep_3 - sweep_2 < AUTO_REDISCOVERY_SWEEP_DURATION + 1.0
# Roughly the configured initial delay from now.
assert sweep_1 - now == pytest.approx(AUTO_INITIAL_SWEEP_DELAY, abs=1.0)
finally:
c1()
c2()
c3()
@pytest.mark.asyncio
async def test_first_sweep_stagger_wraps_past_window_size() -> None:
"""
Past AUTO_INITIAL_SWEEP_DELAY/SWEEP_DURATION scanners, offsets wrap.
With the modulo cap on the spawn offset, the Nth scanner where
N == AUTO_INITIAL_SWEEP_DELAY/AUTO_REDISCOVERY_SWEEP_DURATION
wraps back to offset 0. This locks in the contract that the
stagger does not grow unboundedly with worker count.
"""
manager = get_manager()
sched = manager._auto_scheduler
wrap_at = int(AUTO_INITIAL_SWEEP_DELAY // AUTO_REDISCOVERY_SWEEP_DURATION)
n = wrap_at + 1 # one past the wrap
cancels = []
try:
for i in range(n):
s = _RecordingAutoScanner(
f"AA:BB:CC:00:00:{i:02x}", BluetoothScanningMode.AUTO
)
cancels.append(manager.async_register_scanner(s))
# The Nth scanner's first sweep is wrap_at scanners' worth of
# offset modulo AUTO_INITIAL_SWEEP_DELAY -> back to 0; the
# first scanner was also at offset 0, so their next-sweep times
# match within a small slack for loop.time() advancing.
workers = list(sched._workers.values())
first_sweep_a = workers[0]._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL
first_sweep_wrap = (
workers[wrap_at]._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL
)
assert abs(first_sweep_wrap - first_sweep_a) < 1.0
finally:
for c in cancels:
c()
@pytest.mark.asyncio
async def test_active_scan_registered_before_auto_scanner_wakes_on_register() -> None:
"""
A request registered before any AUTO scanner exists wakes the right one.
Sequence: async_register_active_scan (request enters
_requests_by_address; no worker exists yet for the device).
Later, an AUTO scanner is registered and starts seeing the device.
The first advertisement on that scanner must wake its worker so
the entry in _due_at is acted upon.
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:88"
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
# Sanity: request is recorded; no worker yet for any source.
assert address in sched._requests_by_address
try:
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:99", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
worker = sched._workers[scanner.source]
worker._wake.clear()
_inject(scanner, address)
assert worker._wake.is_set()
# The address now has a tracked entry on this scanner.
assert address in sched._schedule._due_at
finally:
register_cancel()
finally:
cancel()
@pytest.mark.asyncio
async def test_remove_request_clears_tracking() -> None:
"""Cancelling a registration removes its per-(address, request) entries."""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:66"
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
assert address in sched._schedule._due_at
cancel()
assert address not in sched._schedule._due_at
assert sched._requests_by_address == {}
finally:
register_cancel()
@pytest.mark.asyncio
async def test_failed_sweep_advances_sweep_last_completed() -> None:
"""A False return on a sweep advances the worker's sweep clock."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
scanner._return_value = False
register_cancel = manager.async_register_scanner(scanner)
try:
worker = sched._workers[scanner.source]
worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0
before = worker._sweep_last_completed
await _run_worker_tick(sched, scanner.source)
assert scanner.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION]
# Even on False, the worker's sweep clock advanced so the next
# sweep is one full interval out instead of immediate.
assert worker._sweep_last_completed > before
finally:
register_cancel()
@pytest.mark.asyncio
async def test_stop_cancels_worker_tasks() -> None:
"""Scheduler.stop cancels every worker task."""
manager = get_manager()
sched = manager._auto_scheduler
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
worker = sched._workers[scanner.source]
task = worker._task
assert task is not None
sched.stop()
await asyncio.sleep(0)
assert task.cancelled() or task.done()
assert sched._workers == {}
finally:
register_cancel()
@pytest.mark.asyncio
async def test_dispatch_drops_tracking_for_unseen_address() -> None:
"""An address whose history aged out is pruned on the next worker tick."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "AA:BB:CC:DD:EE:FF"
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
request = next(iter(sched._schedule._due_at[address]))
sched._schedule._due_at[address][request] = loop.time() - 1.0
# Simulate the manager's history aging out under the worker's
# feet — the orphan-prune branch should clean both _due_at and
# _owner_by_address.
manager._all_history.pop(address, None)
manager._connectable_history.pop(address, None)
await _run_worker_tick(sched, scanner.source)
assert address not in sched._schedule._due_at
assert address not in sched._schedule._owner_by_address
assert address not in sched._workers[scanner.source]._owned_due_at
finally:
cancel()
register_cancel()
@pytest.mark.asyncio
async def test_first_sweep_is_delayed_after_scanner_registers() -> None:
"""A newly registered AUTO scanner's first sweep is AUTO_INITIAL_SWEEP_DELAY out."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
worker = sched._workers[scanner.source]
first_sweep_at = worker._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL
now = loop.time()
assert (
AUTO_INITIAL_SWEEP_DELAY - 1.0
<= first_sweep_at - now
<= AUTO_INITIAL_SWEEP_DELAY + 1.0
)
finally:
register_cancel()
@pytest.mark.asyncio
async def test_initial_sweep_fires_4_minutes_after_scanner_registers() -> None:
"""
The initial sweep is suppressed for ~4 minutes after registration.
Literal seconds are used so this test pins the contract that
AUTO_INITIAL_SWEEP_DELAY stays at 4 minutes; the symbolic
relationship between the worker's scheduled time and the
constant is covered by
``test_first_sweep_is_delayed_after_scanner_registers``.
Freezegun patches ``time.monotonic`` which backs
``loop.time()``, so the worker's ``_tick`` sees the advanced
clock. The background ``_run`` task is cancelled up-front so its
own ``wait_for`` timeout (also driven by frozen monotonic) does
not race the explicit ticks below.
"""
manager = get_manager()
sched = manager._auto_scheduler
four_minutes = 4 * 60.0
with freeze_time() as frozen:
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
worker = sched._workers[scanner.source]
worker.stop()
await asyncio.sleep(0)
try:
# 1s before the 4-minute mark; no window should fire.
frozen.tick(four_minutes - 1.0)
await worker._tick()
assert scanner.active_window_calls == []
# Crossing the 4-minute mark; the sweep fires with SWEEP_DURATION.
frozen.tick(2.0)
await worker._tick()
assert scanner.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION]
finally:
register_cancel()
@pytest.mark.asyncio
async def test_remove_scanner_stops_its_worker() -> None:
"""Unregistering a scanner cancels and drops its worker."""
manager = get_manager()
sched = manager._auto_scheduler
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
cancel = manager.async_register_scanner(scanner)
assert scanner.source in sched._workers
worker = sched._workers[scanner.source]
task = worker._task
cancel()
await asyncio.sleep(0)
assert scanner.source not in sched._workers
assert task is not None
assert task.cancelled() or task.done()
@pytest.mark.asyncio
async def test_remove_scanner_prunes_owned_due_at_entries() -> None:
"""
_due_at entries owned by the leaving scanner are pruned at remove.
Without the prune, those entries would sit pinned until the
device either turns up on another scanner (history flips) or
expires from _all_history.
"""
manager = get_manager()
sched = manager._auto_scheduler
address_owned = "AA:00:00:00:00:10"
address_foreign = "AA:00:00:00:00:11"
s_a = _RecordingAutoScanner("AA:00:00:00:00:01", BluetoothScanningMode.AUTO)
s_b = _RecordingAutoScanner("AA:00:00:00:00:02", BluetoothScanningMode.AUTO)
c_a = manager.async_register_scanner(s_a)
c_b = manager.async_register_scanner(s_b)
cancel_owned = manager.async_register_active_scan(
address_owned, scan_interval=60.0, scan_duration=5.0
)
cancel_foreign = manager.async_register_active_scan(
address_foreign, scan_interval=60.0, scan_duration=5.0
)
try:
_inject(s_a, address_owned)
_inject(s_b, address_foreign)
assert address_owned in sched._schedule._due_at
assert address_foreign in sched._schedule._due_at
# Remove s_a. The owned entry must be pruned; the foreign one
# (owned by s_b) must remain.
c_a()
await asyncio.sleep(0)
assert address_owned not in sched._schedule._due_at
assert address_foreign in sched._schedule._due_at
finally:
cancel_owned()
cancel_foreign()
c_b()
@pytest.mark.asyncio
async def test_add_scanner_before_start_defers_worker() -> None:
"""A scanner registered before start() gets its worker on start()."""
manager = get_manager()
sched = manager._auto_scheduler
loop = sched._loop
assert loop is not None
sched._loop = None
sched._running = False
try:
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
sched.add_scanner(scanner)
assert scanner.source not in sched._workers
manager._sources[scanner.source] = scanner
sched.start(loop)
assert scanner.source in sched._workers
sched._workers[scanner.source].stop()
finally:
manager._sources.pop("AA:BB:CC:DD:EE:00", None)
@pytest.mark.asyncio
async def test_stop_is_safe_when_already_idle() -> None:
"""Calling stop() twice in a row is fully idempotent."""
manager = get_manager()
sched = manager._auto_scheduler
sched.stop()
sched.stop()
assert sched._workers == {}
@pytest.mark.asyncio
async def test_stop_clears_loop_so_post_stop_add_request_is_record_only() -> None:
"""
After stop(), add_request and on_advertisement skip _due_at.
Without nulling _loop, post-stop add_request would seed _due_at
with timestamps from the cancelled loop and try to wake a worker
that no longer exists. on_advertisement is similar. Both must
fall back to the record-only / no-op path once stop() runs.
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "AA:BB:CC:DD:EE:90"
scanner = _RecordingAutoScanner("AA:00:00:00:00:33", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address) # seed history
sched.stop()
assert sched._loop is None
# add_request after stop: still tracked in _requests_by_address
# but no _due_at seed (loop is None).
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
try:
assert address in sched._requests_by_address
assert address not in sched._schedule._due_at
# on_advertisement after stop is a no-op on _due_at too.
_inject(scanner, address)
assert address not in sched._schedule._due_at
finally:
cancel()
finally:
register_cancel()
# Restore the scheduler so the conftest teardown isn't surprised
# by a None loop.
sched.start(asyncio.get_running_loop())
@pytest.mark.asyncio
async def test_duration_clamped_to_bounds() -> None:
"""_coalesce_duration clamps the requested duration to the configured range."""
sched = get_manager()._auto_scheduler
def _req(duration: float) -> ActiveScanRequest:
return ActiveScanRequest("AA", 60.0, duration)
assert sched._coalesce_duration([_req(0.01)]) == AUTO_WINDOW_MIN_DURATION
assert sched._coalesce_duration([_req(1000.0)]) == AUTO_WINDOW_MAX_DURATION
assert sched._coalesce_duration([_req(7.5)]) == 7.5
assert sched._coalesce_duration([_req(0.01), _req(7.5)]) == 7.5
assert (
sched._coalesce_duration([_req(7.5), _req(1000.0)]) == AUTO_WINDOW_MAX_DURATION
)
# Empty list falls back to the configured minimum.
assert sched._coalesce_duration([]) == AUTO_WINDOW_MIN_DURATION
@pytest.mark.asyncio
async def test_on_advertisement_early_returns_with_no_requests() -> None:
"""Hot path is a no-op when no active-scan request is registered."""
manager = get_manager()
sched = manager._auto_scheduler
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, "11:22:33:44:55:66")
assert sched._schedule._due_at == {}
assert sched._requests_by_address == {}
finally:
register_cancel()
@pytest.mark.asyncio
async def test_on_advertisement_re_bootstraps_pruned_tracking() -> None:
"""If a tracking entry was pruned, the next ad re-creates it and wakes."""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:66"
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
cancel = manager.async_register_active_scan(address, scan_interval=120.0)
try:
worker = sched._workers[scanner.source]
# No advertisement has been seen yet, so add_request skipped
# the _due_at seed (the prune-on-no-history path). Simulate the
# "pruned" state by ensuring it's not there.
sched._schedule._due_at.pop(address, None)
worker._wake.clear()
_inject(scanner, address)
assert address in sched._schedule._due_at
assert worker._wake.is_set()
finally:
cancel()
register_cancel()
@pytest.mark.asyncio
async def test_register_active_scan_validates_inputs() -> None:
"""scan_interval / scan_duration below the configured minimums raise."""
manager = get_manager()
# scan_interval below 60s.
with pytest.raises(ValueError, match="scan_interval must"):
manager.async_register_active_scan("AA:BB:CC:DD:EE:00", scan_interval=0)
with pytest.raises(ValueError, match="scan_interval must"):
manager.async_register_active_scan("AA:BB:CC:DD:EE:00", scan_interval=30.0)
# scan_duration below 5s.
with pytest.raises(ValueError, match="scan_duration must"):
manager.async_register_active_scan(
"AA:BB:CC:DD:EE:00", scan_interval=60.0, scan_duration=-0.5
)
with pytest.raises(ValueError, match="scan_duration must"):
manager.async_register_active_scan(
"AA:BB:CC:DD:EE:00", scan_interval=60.0, scan_duration=4.5
)
# Empty address.
with pytest.raises(ValueError, match="address must be a non-empty string"):
manager.async_register_active_scan("", scan_interval=60.0)
# Non-finite values must be rejected: NaN compared to anything
# returns False, so without the explicit isfinite() check a NaN
# would slip past the lower-bound validators.
for bad in (math.nan, math.inf, -math.inf):
with pytest.raises(ValueError, match="scan_interval must be a finite number"):
manager.async_register_active_scan("AA:BB:CC:DD:EE:00", scan_interval=bad)
with pytest.raises(ValueError, match="scan_duration must be a finite number"):
manager.async_register_active_scan(
"AA:BB:CC:DD:EE:00", scan_interval=60.0, scan_duration=bad
)
@pytest.mark.asyncio
async def test_register_active_scan_applies_defaults() -> None:
"""Omitting scan_interval/scan_duration uses the configured defaults."""
manager = get_manager()
sched = manager._auto_scheduler
address = "AA:BB:CC:DD:EE:42"
cancel = manager.async_register_active_scan(address)
try:
request = next(iter(sched._requests_by_address[address]))
assert request.scan_interval == DEFAULT_ACTIVE_SCAN_INTERVAL
assert request.scan_duration == DEFAULT_ACTIVE_SCAN_DURATION
finally:
cancel()
@pytest.mark.asyncio
async def test_register_active_scan_uuid_passes_through_unchanged() -> None:
"""
MacOS CoreBluetooth UUIDs are not uppercased.
BlueZ / proxy addresses are colon-form MACs and get normalized
to upper-case; UUIDs (no colons) must pass through unchanged
because CoreBluetooth preserves case on its source addresses.
"""
manager = get_manager()
sched = manager._auto_scheduler
uuid = "abcd1234-5678-90ab-cdef-1234567890ab"
cancel = manager.async_register_active_scan(uuid, scan_interval=60.0)
try:
assert uuid in sched._requests_by_address
assert uuid.upper() not in sched._requests_by_address
request = next(iter(sched._requests_by_address[uuid]))
assert request.address == uuid
finally:
cancel()
@pytest.mark.asyncio
async def test_register_active_scan_normalizes_address_case() -> None:
"""
Lowercase addresses get normalized to the upper-case form.
Matches the upper-case form BlueZ / bleak use for advertisement
source addresses so on_advertisement's dict lookup finds the
request regardless of caller case.
"""
manager = get_manager()
sched = manager._auto_scheduler
upper = "AA:BB:CC:DD:EE:55"
cancel = manager.async_register_active_scan(upper.lower(), scan_interval=60.0)
try:
# Stored under the upper-case form, regardless of caller's case.
assert upper in sched._requests_by_address
assert upper.lower() not in sched._requests_by_address
request = next(iter(sched._requests_by_address[upper]))
assert request.address == upper
finally:
cancel()
@pytest.mark.asyncio
async def test_add_request_without_history_skips_seed() -> None:
"""
add_request skips _due_at when no last_service_info exists yet.
on_advertisement bootstraps tracking instead. The previous
behavior seeded unconditionally and let the next worker tick
prune the orphan entry; skipping the seed avoids that churn.
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "AA:BB:CC:DD:EE:56"
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
try:
# Sanity: history doesn't exist for this address yet.
assert manager.async_last_service_info(address, False) is None
# _due_at was not seeded -> no entry to prune later.
assert address not in sched._schedule._due_at
# But the request IS recorded for on_advertisement to pick up.
assert address in sched._requests_by_address
# First advertisement bootstraps tracking and wakes the
# owner's worker.
worker = sched._workers[scanner.source]
worker._wake.clear()
_inject(scanner, address)
assert address in sched._schedule._due_at
assert worker._wake.is_set()
finally:
cancel()
register_cancel()
@pytest.mark.asyncio
async def test_run_window_swallows_scanner_exception() -> None:
"""An exception from async_request_active_window is logged, not re-raised."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
class _FailingScanner(_RecordingAutoScanner):
async def async_request_active_window(self, duration: float) -> bool:
msg = "boom"
raise RuntimeError(msg)
scanner = _FailingScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
worker = sched._workers[scanner.source]
worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0
await worker._tick()
# The exception was swallowed; sweep state still advanced.
assert worker._sweep_last_completed > loop.time() - 1.0
finally:
register_cancel()
@pytest.mark.asyncio
async def test_repeated_window_failures_log_only_first_traceback(
caplog: pytest.LogCaptureFixture,
) -> None:
"""
Persistently failing scanner gets one exception log then warnings.
Without rate-limiting, a permanently broken scanner would emit a
full traceback every scan_interval (>= 60s). The first failure
still logs the full stack so the root cause is captured; subsequent
failures collapse to a one-line warning to avoid flooding the log.
"""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
class _FailingScanner(_RecordingAutoScanner):
async def async_request_active_window(self, duration: float) -> bool:
msg = "boom"
raise RuntimeError(msg)
scanner = _FailingScanner("AA:BB:CC:DD:EE:11", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
worker = sched._workers[scanner.source]
worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0
with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"):
await worker._tick()
# Trigger a second failure.
worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0
await worker._tick()
records = [
r for r in caplog.records if "error running active window" in r.message
]
assert len(records) == 2
# First has exception info (full traceback), second does not.
assert records[0].exc_info is not None
assert records[1].exc_info is None
finally:
register_cancel()
@pytest.mark.asyncio
async def test_tick_sync_phase_exception_is_logged_and_worker_survives(
caplog: pytest.LogCaptureFixture,
) -> None:
"""
Sync-phase failures in _tick are logged; worker survives.
Stubs async_last_service_info to raise so _collect_due_buckets
blows up; the outer except in _tick catches it and logs.
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:91"
cancel = manager.async_register_active_scan(
address, scan_interval=60.0, scan_duration=5.0
)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:31", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
worker = sched._workers[scanner.source]
original = manager.async_last_service_info
def _boom(_addr: str, _conn: bool) -> None:
msg = "boom in last_service_info"
raise RuntimeError(msg)
manager.async_last_service_info = _boom # type: ignore[assignment,method-assign]
try:
with caplog.at_level(logging.ERROR):
await worker._tick()
assert any(
"unexpected error in auto-window tick" in record.message
for record in caplog.records
)
# Worker is still alive; _window_end was reset.
assert worker._window_end == 0.0
finally:
manager.async_last_service_info = original # type: ignore[method-assign]
finally:
cancel()
register_cancel()
@pytest.mark.asyncio
async def test_mode_switch_unregister_then_register_picks_up_existing_request() -> None:
"""
Scheduler survives a HA-style scanner mode switch on the same source.
HA's UI mode-switch path reloads the config entry: the old
scanner is unregistered, a new one with the same source is
registered with the new mode. The scheduler must (1) prune
_due_at entries the leaving scanner owned via remove_scanner,
(2) keep user-registered ActiveScanRequests in
_requests_by_address, (3) spawn a fresh worker for a new AUTO
scanner via add_scanner, and (4) bootstrap _due_at on the first
advertisement from the new scanner.
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:92"
# Register the active-scan need first.
cancel = manager.async_register_active_scan(
address, scan_interval=60.0, scan_duration=5.0
)
# Start in AUTO, see the device, then "switch to ACTIVE".
auto_scanner = _RecordingAutoScanner(
"AA:BB:CC:DD:EE:32", BluetoothScanningMode.AUTO
)
auto_cancel = manager.async_register_scanner(auto_scanner)
try:
_inject(auto_scanner, address)
assert address in sched._schedule._due_at
assert auto_scanner.source in sched._workers
# Mode switch in UI -> unregister AUTO scanner.
auto_cancel()
assert auto_scanner.source not in sched._workers
assert address not in sched._schedule._due_at
# User's registration is preserved across the switch.
assert address in sched._requests_by_address
# Re-register with the SAME source but PASSIVE mode.
passive_scanner = _RecordingAutoScanner(
"AA:BB:CC:DD:EE:32", BluetoothScanningMode.PASSIVE
)
passive_cancel = manager.async_register_scanner(passive_scanner)
try:
# PASSIVE doesn't get a worker.
assert passive_scanner.source not in sched._workers
# Still no _due_at entry (no AUTO scanner owns it).
assert address not in sched._schedule._due_at
passive_cancel()
# Now switch BACK to AUTO with the same source.
new_auto = _RecordingAutoScanner(
"AA:BB:CC:DD:EE:32", BluetoothScanningMode.AUTO
)
new_auto_cancel = manager.async_register_scanner(new_auto)
try:
assert new_auto.source in sched._workers
# First advertisement on the new AUTO scanner bootstraps
# tracking again from the still-registered request.
_inject(new_auto, address)
assert address in sched._schedule._due_at
finally:
new_auto_cancel()
except BaseException:
passive_cancel()
raise
finally:
cancel()
@pytest.mark.asyncio
async def test_start_replays_pre_start_requests_into_due_at() -> None:
"""
add_request before start() seeds _due_at at start() if history exists.
Also covers the no-history skip path and the
already-in-existing-entries no-op so the replay loop's branches
are all exercised.
"""
manager = get_manager()
sched = manager._auto_scheduler
address_with_history = "11:22:33:44:55:80"
address_no_history = "11:22:33:44:55:81"
# Get history in place for one address only.
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:21", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
_inject(scanner, address_with_history)
try:
saved_loop = sched._loop
assert saved_loop is not None
sched._loop = None
sched._running = False
try:
# Register TWO requests on the with-history address so
# we can pre-populate _due_at with one of them and prove
# start() (a) leaves the pre-existing entry alone and
# (b) inserts a fresh entry for the other.
cancel_with_a = manager.async_register_active_scan(
address_with_history, scan_interval=60.0, scan_duration=5.0
)
cancel_with_b = manager.async_register_active_scan(
address_with_history, scan_interval=120.0, scan_duration=5.0
)
cancel_without = manager.async_register_active_scan(
address_no_history, scan_interval=60.0, scan_duration=5.0
)
try:
assert address_with_history not in sched._schedule._due_at
requests = list(sched._requests_by_address[address_with_history])
pre_existing, to_be_inserted = requests
# Pre-populate _due_at with one request only. The
# sentinel is well above loop.time() + scan_interval
# so the test is robust against the loop being
# freshly-started (CI) or long-lived; we don't care
# about the absolute value, only that start() leaves
# it alone.
sentinel = saved_loop.time() + 1.0e9
sched._schedule._due_at[address_with_history] = {pre_existing: sentinel}
before_start = saved_loop.time()
sched.start(saved_loop)
seeded = sched._schedule._due_at[address_with_history]
# The pre-existing entry was left alone (covers the
# `request not in existing` False branch).
assert seeded[pre_existing] == sentinel
# The other request got freshly inserted (covers the
# insert line in the replay loop).
assert to_be_inserted in seeded
assert seeded[to_be_inserted] == pytest.approx(
before_start + to_be_inserted.scan_interval, abs=0.1
)
# No-history address: skipped by the
# `last_service_info(...) is None` branch.
assert address_no_history not in sched._schedule._due_at
finally:
cancel_with_a()
cancel_with_b()
cancel_without()
finally:
sched._loop = saved_loop
sched._running = True
finally:
register_cancel()
@pytest.mark.asyncio
async def test_start_is_idempotent_when_already_running() -> None:
"""
A second start() call without an intervening stop() is a no-op.
Guards against an accidental double-call binding a different loop
to the same scheduler or re-running the pre-start replay block.
"""
manager = get_manager()
sched = manager._auto_scheduler
# The conftest's async_setup already called start(), so _running
# is True. A second start with a different loop must NOT replace
# _loop or re-run anything.
original_loop = sched._loop
bogus_loop = object()
sched.start(bogus_loop) # type: ignore[arg-type]
assert sched._loop is original_loop
@pytest.mark.asyncio
async def test_dispatch_does_not_resurrect_cancelled_request() -> None:
"""A request cancelled while the window awaits is not re-added to entries."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:66"
cancel = manager.async_register_active_scan(
address, scan_interval=60.0, scan_duration=6.0
)
gate = asyncio.Event()
class _CancelDuringWindow(_RecordingAutoScanner):
async def async_request_active_window(self, duration: float) -> bool:
# Mid-window: caller cancels the registration.
cancel()
gate.set()
return True
scanner = _CancelDuringWindow("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
entries = sched._schedule._due_at[address]
request = next(iter(entries))
entries[request] = loop.time() - 1.0
await sched._workers[scanner.source]._tick()
await gate.wait()
# remove_request emptied the bucket; the tick must not have
# re-added the cancelled request.
assert address not in sched._schedule._due_at
finally:
register_cancel()
@pytest.mark.asyncio
async def test_dispatch_skips_address_owned_by_other_scanner() -> None:
"""An address whose owner is a different scanner is left alone."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:66"
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
owner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
other = _RecordingAutoScanner("AA:BB:CC:DD:EE:11", BluetoothScanningMode.AUTO)
c1 = manager.async_register_scanner(owner)
c2 = manager.async_register_scanner(other)
try:
_inject(owner, address)
entries = sched._schedule._due_at[address]
for req in list(entries):
entries[req] = loop.time() - 1.0
# The "other" scanner runs its tick. The address is owned by
# owner, so other should not fire its window.
await sched._workers[other.source]._tick()
assert other.active_window_calls == []
finally:
cancel()
c1()
c2()
@pytest.mark.asyncio
async def test_next_event_at_returns_current_window_end() -> None:
"""While a window is in flight, next event is its end time."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
worker = sched._workers[scanner.source]
worker._window_end = loop.time() + 42.0
assert worker._next_event_at(loop.time()) == worker._window_end
finally:
register_cancel()
@pytest.mark.asyncio
async def test_next_event_at_returns_earliest_per_device_need() -> None:
"""Per-device entries owned by this scanner influence the next-event time."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:66"
cancel = manager.async_register_active_scan(address, scan_interval=120.0)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
worker = sched._workers[scanner.source]
# Sweep is far in the future (initial delay window). The earliest
# event for the worker is the per-device next-due.
entries = sched._schedule._due_at[address]
request = next(iter(entries))
per_device_at = loop.time() + 5.0
entries[request] = per_device_at
assert worker._next_event_at(loop.time()) == per_device_at
finally:
cancel()
register_cancel()
@pytest.mark.asyncio
async def test_dispatch_per_device_skips_not_yet_due() -> None:
"""Entries with future due times don't fire."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:66"
cancel = manager.async_register_active_scan(address, scan_interval=120.0)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
# Push the due time far in the future.
entries = sched._schedule._due_at[address]
for request in list(entries):
entries[request] = loop.time() + 1000.0
await sched._workers[scanner.source]._tick()
assert scanner.active_window_calls == []
finally:
cancel()
register_cancel()
@pytest.mark.asyncio
async def test_tick_skips_when_sweep_not_due_and_no_per_device() -> None:
"""No-op tick: no per-device work due, sweep not due either."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
worker = sched._workers[scanner.source]
worker._sweep_last_completed = loop.time()
await worker._tick()
assert scanner.active_window_calls == []
finally:
register_cancel()
@pytest.mark.asyncio
async def test_worker_tick_no_op_when_loop_detached() -> None:
"""Worker tick exits cleanly if the scheduler's loop is None."""
manager = get_manager()
sched = manager._auto_scheduler
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
worker = sched._workers[scanner.source]
original_loop = sched._loop
sched._loop = None
try:
await worker._tick()
finally:
sched._loop = original_loop
assert scanner.active_window_calls == []
finally:
register_cancel()
@pytest.mark.asyncio
async def test_tick_no_op_when_already_inside_window() -> None:
"""A tick that arrives while a window is in flight returns early."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
worker = sched._workers[scanner.source]
# Pretend a window is mid-flight; _tick must defer to that
# window and not start a new one.
worker._window_end = loop.time() + 60.0
await worker._tick()
assert scanner.active_window_calls == []
finally:
register_cancel()
async def _replace_worker_task(worker: object) -> None:
"""Cancel the worker's existing task so a fresh _run() can be tested."""
task = worker._task # type: ignore[attr-defined]
if task is not None and not task.done():
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
@pytest.mark.asyncio
async def test_run_exits_when_scheduler_not_running() -> None:
"""The worker's _run loop exits cleanly when _running is False after a wake."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
worker = sched._workers[scanner.source]
await _replace_worker_task(worker)
# Put the sweep clock far in the past so _next_event_at returns
# a time <= now and the wait_for branch is skipped; the loop
# falls straight through to the "not running" check.
worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0
sched._running = False
new_task = loop.create_task(worker._run())
await asyncio.wait_for(new_task, timeout=1.0)
assert new_task.done()
assert not new_task.cancelled()
finally:
sched._running = True
register_cancel()
@pytest.mark.asyncio
async def test_run_exits_when_loop_detached() -> None:
"""The worker's _run loop exits when scheduler._loop becomes None."""
manager = get_manager()
sched = manager._auto_scheduler
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
worker = sched._workers[scanner.source]
await _replace_worker_task(worker)
original_loop = sched._loop
sched._loop = None
new_task = asyncio.get_running_loop().create_task(worker._run())
await asyncio.wait_for(new_task, timeout=1.0)
assert new_task.done()
assert not new_task.cancelled()
sched._loop = original_loop
finally:
register_cancel()
@pytest.mark.asyncio
async def test_add_request_without_history_does_not_wake() -> None:
"""When the address has never been seen, add_request is a pure registry op."""
manager = get_manager()
sched = manager._auto_scheduler
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
worker = sched._workers[scanner.source]
worker._wake.clear()
cancel = manager.async_register_active_scan(
"AA:AA:AA:AA:AA:AA", scan_interval=60.0
)
# No prior advertisement: history is None, so no wake is sent.
assert not worker._wake.is_set()
cancel()
finally:
register_cancel()
@pytest.mark.asyncio
async def test_remove_request_handles_missing_bucket() -> None:
"""remove_request tolerates a request whose bucket is already gone."""
manager = get_manager()
sched = manager._auto_scheduler
request = ActiveScanRequest("AA:BB:CC:DD:EE:99", 60.0, 10.0)
# Bucket was never added; remove_request must be a no-op.
sched.remove_request(request)
assert "AA:BB:CC:DD:EE:99" not in sched._requests_by_address
assert "AA:BB:CC:DD:EE:99" not in sched._schedule._due_at
@pytest.mark.asyncio
async def test_on_advertisement_no_match_no_wake() -> None:
"""An ad whose address has no registered request doesn't add anything."""
manager = get_manager()
sched = manager._auto_scheduler
cancel = manager.async_register_active_scan("11:22:33:44:55:66", scan_interval=60.0)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
worker = sched._workers[scanner.source]
worker._wake.clear()
_inject(scanner, "AA:AA:AA:AA:AA:AA")
assert "AA:AA:AA:AA:AA:AA" not in sched._schedule._due_at
assert not worker._wake.is_set()
finally:
cancel()
register_cancel()
@pytest.mark.asyncio
async def test_on_advertisement_wakes_on_every_ad_for_tracked_address() -> None:
"""
Every ad on a tracked address wakes the source's worker.
The wake is what makes ownership-flip detection work: when this
scanner becomes the new owner mid-sleep, the wake forces the
worker to re-evaluate _next_event_at and pick up the entry that
is now owned by it.
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:66"
cancel1 = manager.async_register_active_scan(address, scan_interval=60.0)
cancel2 = manager.async_register_active_scan(address, scan_interval=120.0)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
worker = sched._workers[scanner.source]
_inject(scanner, address)
worker._wake.clear()
_inject(scanner, address)
# Second inject still wakes; the wake is unconditional now so
# ownership flips on an existing entry are seen by the new
# owner.
assert worker._wake.is_set()
finally:
cancel1()
cancel2()
register_cancel()
@pytest.mark.asyncio
async def test_on_advertisement_with_all_requests_already_tracked() -> None:
"""on_advertisement still wakes when every request is already in _due_at."""
manager = get_manager()
sched = manager._auto_scheduler
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
address = "11:22:33:44:55:66"
cancel_a = manager.async_register_active_scan(address, scan_interval=60.0)
cancel_b = manager.async_register_active_scan(address, scan_interval=120.0)
try:
# First advertisement seeds + assigns + wakes.
_inject(scanner, address)
worker = sched._workers[scanner.source]
entries_before = dict(sched._schedule._due_at[address])
worker._wake.clear()
# Second advertisement: all requests already tracked; assign
# still wakes for ownership-flip detection.
_inject(scanner, address)
assert worker._wake.is_set()
# Sanity: the entries we put in are untouched.
assert sched._schedule._due_at[address] == entries_before
finally:
cancel_a()
cancel_b()
register_cancel()
@pytest.mark.asyncio
async def test_add_request_with_history_wakes_owning_worker() -> None:
"""add_request wakes the worker whose scanner currently sees the address."""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:66"
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
# Populate manager._all_history WITHOUT first registering an
# active scan, so the inject doesn't go through on_advertisement's
# wake-on-added path. add_request then sees the history entry
# and assign fires the worker wake itself.
_inject(scanner, address)
worker = sched._workers[scanner.source]
worker._wake.clear()
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
assert worker._wake.is_set()
cancel()
finally:
register_cancel()
@pytest.mark.asyncio
async def test_next_event_at_skips_per_device_later_than_sweep() -> None:
"""A per-device next-due later than the sweep cadence does not lower next_at."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:66"
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
worker = sched._workers[scanner.source]
sweep_at = worker._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL
# Push per-device need past the sweep cadence so the earliest <
# next_at branch is False inside _next_event_at.
for req in list(sched._schedule._due_at[address]):
sched._schedule._due_at[address][req] = sweep_at + 100.0
assert worker._next_event_at(loop.time()) == sweep_at
finally:
cancel()
register_cancel()
@pytest.mark.asyncio
async def test_start_ignores_non_auto_scanner() -> None:
"""A non-AUTO scanner already on the manager doesn't get a worker on start."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
auto = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
active = _RecordingAutoScanner("AA:BB:CC:DD:EE:11", BluetoothScanningMode.ACTIVE)
c_auto = manager.async_register_scanner(auto)
c_active = manager.async_register_scanner(active)
try:
assert active.source not in sched._workers
# Re-run start() so the False branch (non-AUTO scanner) of the
# `if scanner.requested_mode is AUTO` check inside start() is hit.
# First shut down the worker tasks the existing start() already
# spawned so we don't leak. Also flip _running back to False so
# start()'s idempotency guard lets the re-run through.
for worker in list(sched._workers.values()):
await _replace_worker_task(worker)
sched._workers.clear()
sched._running = False
sched.start(loop)
assert auto.source in sched._workers
assert active.source not in sched._workers
finally:
c_auto()
c_active()
@pytest.mark.asyncio
async def test_coalesce_three_due_uses_max_clamped() -> None:
"""Three due requests on one address fire one window using max duration."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:66"
c1 = manager.async_register_active_scan(
address, scan_interval=60.0, scan_duration=5.0
)
c2 = manager.async_register_active_scan(
address, scan_interval=60.0, scan_duration=7.0
)
c3 = manager.async_register_active_scan(
address, scan_interval=60.0, scan_duration=9.0
)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
entries = sched._schedule._due_at[address]
for req in list(entries):
entries[req] = loop.time() - 1.0
await sched._workers[scanner.source]._tick()
assert scanner.active_window_calls == [9.0]
finally:
c1()
c2()
c3()
register_cancel()
@pytest.mark.asyncio
async def test_coalesce_clamps_oversize_request() -> None:
"""A scan_duration above the max is clamped on dispatch."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:66"
cancel = manager.async_register_active_scan(
address, scan_interval=60.0, scan_duration=AUTO_WINDOW_MAX_DURATION + 50.0
)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
entries = sched._schedule._due_at[address]
for req in list(entries):
entries[req] = loop.time() - 1.0
await sched._workers[scanner.source]._tick()
assert scanner.active_window_calls == [AUTO_WINDOW_MAX_DURATION]
finally:
cancel()
register_cancel()
@pytest.mark.asyncio
async def test_coalesce_only_due_requests_count() -> None:
"""Only the requests that are actually due contribute to coalesced duration."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:66"
c_short = manager.async_register_active_scan(
address, scan_interval=60.0, scan_duration=5.0
)
c_long = manager.async_register_active_scan(
address, scan_interval=300.0, scan_duration=20.0
)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
entries = sched._schedule._due_at[address]
short_req = next(r for r in entries if r.scan_duration == 5.0)
long_req = next(r for r in entries if r.scan_duration == 20.0)
# Only the short request is due; the long one is well in the
# future and must not pull its bigger duration into the window.
entries[short_req] = loop.time() - 1.0
entries[long_req] = loop.time() + 200.0
await sched._workers[scanner.source]._tick()
assert scanner.active_window_calls == [5.0]
finally:
c_short()
c_long()
register_cancel()
@pytest.mark.asyncio
async def test_coalesce_distinct_addresses_share_one_window() -> None:
"""Two due addresses on the same scanner share one max-duration window."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
addr_a = "11:22:33:44:55:01"
addr_b = "11:22:33:44:55:02"
c1 = manager.async_register_active_scan(
addr_a, scan_interval=60.0, scan_duration=6.0
)
c2 = manager.async_register_active_scan(
addr_b, scan_interval=60.0, scan_duration=7.0
)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, addr_a)
_inject(scanner, addr_b)
for address in (addr_a, addr_b):
entries = sched._schedule._due_at[address]
for req in list(entries):
entries[req] = loop.time() - 1.0
await sched._workers[scanner.source]._tick()
# A single ACTIVE flip covers both devices; the window length is
# the max of every due request's duration.
assert scanner.active_window_calls == [7.0]
finally:
c1()
c2()
register_cancel()
@pytest.mark.asyncio
async def test_tick_combines_due_sweep_and_per_device_into_one_window() -> None:
"""A due sweep + due per-device fold into a single window at max duration."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:66"
cancel = manager.async_register_active_scan(
address, scan_interval=60.0, scan_duration=6.0
)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
entries = sched._schedule._due_at[address]
for req in list(entries):
entries[req] = loop.time() - 1.0
worker = sched._workers[scanner.source]
worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0
await worker._tick()
# The sweep duration (15s) beats the per-device duration (3s)
# so the merged window is sized to the sweep.
assert scanner.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION]
# Sweep clock advanced.
assert worker._sweep_last_completed > loop.time() - 1.0
finally:
cancel()
register_cancel()
@pytest.mark.asyncio
async def test_three_inkbirds_share_one_scan() -> None:
"""
Three Inkbirds at the same 5min / 15s cadence share a single window.
Each Inkbird has its own address but all three are owned by the same
scanner and become due at the same time. The worker coalesces every
due request across all addresses into a single 15s active window, so
the radio only stops and restarts once per tick.
"""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
addresses = ["C0:01:01:11:11:11", "C0:01:01:22:22:22", "C0:01:01:33:33:33"]
cancels = [
manager.async_register_active_scan(
addr, scan_interval=300.0, scan_duration=15.0
)
for addr in addresses
]
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
for addr in addresses:
_inject(scanner, addr)
entries = sched._schedule._due_at[addr]
for req in list(entries):
entries[req] = loop.time() - 1.0
await sched._workers[scanner.source]._tick()
# All three addresses fold into one coalesced 15s window.
assert scanner.active_window_calls == [15.0]
# Next-due moved forward by scan_interval for every request.
for addr in addresses:
for due in sched._schedule._due_at[addr].values():
assert due > loop.time() + 250.0
finally:
for cancel in cancels:
cancel()
register_cancel()
@pytest.mark.asyncio
async def test_dispatch_coalesces_different_durations_to_max() -> None:
"""Two addresses with different durations fire one window at the max."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
addr_short = "11:22:33:44:55:01"
addr_long = "11:22:33:44:55:02"
c_short = manager.async_register_active_scan(
addr_short, scan_interval=60.0, scan_duration=6.0
)
c_long = manager.async_register_active_scan(
addr_long, scan_interval=60.0, scan_duration=12.0
)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
for addr in (addr_short, addr_long):
_inject(scanner, addr)
entries = sched._schedule._due_at[addr]
for req in list(entries):
entries[req] = loop.time() - 1.0
await sched._workers[scanner.source]._tick()
# Single window sized to the larger of the two durations.
assert scanner.active_window_calls == [12.0]
finally:
c_short()
c_long()
register_cancel()
@pytest.mark.asyncio
async def test_three_inkbirds_same_address_coalesce_to_one_scan() -> None:
"""
Three Inkbird-style registrations on the same address share one window.
Realistic case: three integrations each register their own callback
for the same Inkbird; the scheduler must NOT fire 3 separate 15s
windows back-to-back. Instead all three requests coalesce into one
single 15s window via _coalesce_duration's max-of-durations.
"""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "C0:01:01:11:11:11"
cancels = [
manager.async_register_active_scan(
address, scan_interval=300.0, scan_duration=15.0
)
for _ in range(3)
]
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
entries = sched._schedule._due_at[address]
assert len(entries) == 3
for req in list(entries):
entries[req] = loop.time() - 1.0
await sched._workers[scanner.source]._tick()
# All three coalesced into a single 15s window.
assert scanner.active_window_calls == [15.0]
# Each request's next-due advanced by its own scan_interval.
for due in entries.values():
assert due > loop.time() + 250.0
finally:
for cancel in cancels:
cancel()
register_cancel()
@pytest.mark.asyncio
async def test_three_inkbirds_window_unchanged_after_removal() -> None:
"""
Removing one of three same-address registrations preserves the window.
All three asked for the same 15s duration so the coalesced window is
15s. Cancelling one of them leaves two requests still asking for
15s; the resulting window must still be 15s, not regress to the
MIN_DURATION floor.
"""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "C0:01:01:11:11:11"
cancels = [
manager.async_register_active_scan(
address, scan_interval=300.0, scan_duration=15.0
)
for _ in range(3)
]
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
entries = sched._schedule._due_at[address]
assert len(entries) == 3
# Cancel one of the three; two should remain in both the registry
# and the _due_at tracker.
cancels.pop()()
assert len(sched._requests_by_address[address]) == 2
entries = sched._schedule._due_at[address]
assert len(entries) == 2
for req in list(entries):
entries[req] = loop.time() - 1.0
await sched._workers[scanner.source]._tick()
# Window duration is unchanged because the remaining two still
# ask for 15s; coalesce takes the max.
assert scanner.active_window_calls == [15.0]
finally:
for cancel in cancels:
cancel()
register_cancel()
@pytest.mark.asyncio
async def test_only_owning_scanner_fires_among_four() -> None:
"""
Of four AUTO scanners, only the one owning the device's history fires.
The device is injected from one specific scanner so the manager's
_all_history points at that source. Every worker's _tick runs;
only the owner produces an active window. The other three scanners
stay PASSIVE.
"""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:66"
cancel = manager.async_register_active_scan(
address, scan_interval=60.0, scan_duration=5.0
)
scanners = [
_RecordingAutoScanner(f"AA:00:00:00:00:0{n}", BluetoothScanningMode.AUTO)
for n in range(4)
]
register_cancels = [manager.async_register_scanner(s) for s in scanners]
try:
owner = scanners[2]
_inject(owner, address)
entries = sched._schedule._due_at[address]
for req in list(entries):
entries[req] = loop.time() - 1.0
for scanner in scanners:
await sched._workers[scanner.source]._tick()
# Only the owning scanner flipped to ACTIVE for the requested 5s.
assert [s.active_window_calls for s in scanners] == [[], [], [5.0], []]
finally:
for c in register_cancels:
c()
cancel()
@pytest.mark.asyncio
async def test_add_request_before_start_does_not_seed_due_at() -> None:
"""If add_request runs before start() the entry is deferred to advertisement."""
manager = get_manager()
sched = manager._auto_scheduler
address = "BB:00:00:00:00:00"
original_loop = sched._loop
sched._loop = None
try:
sched.add_request(ActiveScanRequest(address, 60.0, 10.0))
assert address in sched._requests_by_address
assert address not in sched._schedule._due_at
finally:
sched._loop = original_loop
sched._requests_by_address.pop(address, None)
@pytest.mark.asyncio
async def test_add_request_idempotent_keeps_existing_due() -> None:
"""
Re-adding the same request preserves its existing next-due time.
Also verifies the wake is gated on "actually inserted a new entry":
a re-register (e.g. an HA config-entry reload) is a no-op on the
schedule, so the worker should not be woken.
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "BC:00:00:00:00:00"
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:42", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
request = ActiveScanRequest(address, 60.0, 10.0)
sched.add_request(request)
# Inject so add_request can see history on the second call.
_inject(scanner, address)
sched._schedule._due_at[address][request] = 1234.5
worker = sched._workers[scanner.source]
worker._wake.clear()
sched.add_request(request)
assert sched._schedule._due_at[address][request] == 1234.5
# No new entry → no wake.
assert not worker._wake.is_set()
sched.remove_request(request)
finally:
register_cancel()
@pytest.mark.asyncio
async def test_run_loop_waits_then_ticks() -> None:
"""The _run loop's wait_for + _tick path is exercised end-to-end."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
worker = sched._workers[scanner.source]
await _replace_worker_task(worker)
# Sweep ~1ms in the future so _run's wait_for times out quickly
# and _tick runs once before we shut it down.
worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL + 0.001
task = loop.create_task(worker._run())
await asyncio.sleep(0.05)
assert scanner.active_window_calls == [AUTO_REDISCOVERY_SWEEP_DURATION]
sched._running = False
worker._wake.set()
await asyncio.wait_for(task, timeout=1.0)
finally:
sched._running = True
register_cancel()
@pytest.mark.asyncio
async def test_owner_flip_during_window_does_not_double_fire() -> None:
"""
If ownership flips to a second scanner mid-window, no duplicate fire.
Worker A starts its window for address X. While A awaits the radio,
a new advertisement makes B the owner (B's _all_history.source).
B's worker wakes and ticks. Because A advanced X's next-due BEFORE
starting the await, B's _collect_due_buckets sees the entry as not
yet due and skips it. A finishes alone with one window; B fires
none.
"""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:66"
cancel = manager.async_register_active_scan(
address, scan_interval=60.0, scan_duration=5.0
)
gate = asyncio.Event()
s_a = _RecordingAutoScanner("AA:00:00:00:00:01", BluetoothScanningMode.AUTO)
s_a._block_event = gate
s_b = _RecordingAutoScanner("AA:00:00:00:00:02", BluetoothScanningMode.AUTO)
c_a = manager.async_register_scanner(s_a)
c_b = manager.async_register_scanner(s_b)
try:
_inject(s_a, address)
entries = sched._schedule._due_at[address]
for req in list(entries):
entries[req] = loop.time() - 1.0
# Worker A starts its tick and blocks inside the scanner call.
t_a = asyncio.create_task(sched._workers[s_a.source]._tick())
for _ in range(4):
await asyncio.sleep(0)
assert s_a.active_window_calls == [5.0]
# A advanced entries BEFORE the await; verify that.
for due in entries.values():
assert due > loop.time() + 50.0
# Ownership flips to B (a fresh advertisement on B).
_inject(s_b, address)
# B's worker ticks. Because the entry is already in the future
# it must NOT fire a second window.
await sched._workers[s_b.source]._tick()
assert s_b.active_window_calls == []
gate.set()
await t_a
finally:
gate.set()
c_a()
c_b()
cancel()
def _inject_with_rssi(scanner: _RecordingAutoScanner, address: str, rssi: int) -> None:
"""Drive an advertisement through the scanner with a specific RSSI."""
adv = generate_advertisement_data(local_name="x", rssi=rssi)
device = generate_ble_device(address, "x")
scanner._async_on_advertisement(
device.address,
adv.rssi,
device.name or "",
adv.service_uuids,
adv.service_data,
adv.manufacturer_data,
adv.tx_power,
{},
asyncio.get_running_loop().time(),
)
@pytest.mark.asyncio
async def test_device_migration_between_scanners_fires_on_new_owner() -> None:
"""
Migrating from scanner A to B fires the next window on B, not on A.
Sequence: register active_scan. A sees the device first and becomes
owner. A's worker fires the first window. The device then comes
through B with a much stronger RSSI so the manager's
ADV_RSSI_SWITCH_THRESHOLD flips ownership. Make the entry due
again and tick both workers: B fires the new window, A skips.
"""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:99"
cancel = manager.async_register_active_scan(
address, scan_interval=60.0, scan_duration=5.0
)
s_a = _RecordingAutoScanner("AA:00:00:00:00:01", BluetoothScanningMode.AUTO)
s_b = _RecordingAutoScanner("AA:00:00:00:00:02", BluetoothScanningMode.AUTO)
c_a = manager.async_register_scanner(s_a)
c_b = manager.async_register_scanner(s_b)
try:
# A sees the device first; A becomes owner.
_inject_with_rssi(s_a, address, rssi=-80)
info_a = manager.async_last_service_info(address, False)
assert info_a is not None
assert info_a.source == s_a.source
# Make the existing tracking entry due and fire the first
# window on A.
entries = sched._schedule._due_at[address]
for req in list(entries):
entries[req] = loop.time() - 1.0
await sched._workers[s_a.source]._tick()
assert s_a.active_window_calls == [5.0]
assert s_b.active_window_calls == []
# Device migrates to B with much stronger signal (delta beats
# ADV_RSSI_SWITCH_THRESHOLD). The manager flips
# _all_history.source to B.
_inject_with_rssi(s_b, address, rssi=-30)
info_b = manager.async_last_service_info(address, False)
assert info_b is not None
assert info_b.source == s_b.source
# Force the entry due again and run both workers. B (the new
# owner) fires; A skips because history.source is no longer
# A's source.
for req in list(entries):
entries[req] = loop.time() - 1.0
await sched._workers[s_a.source]._tick()
await sched._workers[s_b.source]._tick()
assert s_a.active_window_calls == [5.0]
assert s_b.active_window_calls == [5.0]
finally:
c_a()
c_b()
cancel()
@pytest.mark.asyncio
async def test_device_migration_wakes_new_owner_worker() -> None:
"""
A fresh advertisement on the new owner wakes its worker.
Without this wake, a worker that became the owner mid-sleep would
sit until its previously-computed _next_event_at (sweep cadence)
even though there's a tracked address whose due time is much
sooner. The wake is on_advertisement's job and must fire even when
the _due_at entry already exists (i.e. the ad doesn't add a new
request, it just notifies us this scanner now sees the device).
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:AA"
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
s_a = _RecordingAutoScanner("AA:00:00:00:00:01", BluetoothScanningMode.AUTO)
s_b = _RecordingAutoScanner("AA:00:00:00:00:02", BluetoothScanningMode.AUTO)
c_a = manager.async_register_scanner(s_a)
c_b = manager.async_register_scanner(s_b)
try:
# A sees the device first.
_inject_with_rssi(s_a, address, rssi=-80)
worker_b = sched._workers[s_b.source]
worker_b._wake.clear()
# B sees the device with stronger RSSI and becomes the new
# owner. B's worker must be woken so it re-evaluates
# _next_event_at and picks up the existing entry.
_inject_with_rssi(s_b, address, rssi=-30)
assert worker_b._wake.is_set()
finally:
c_a()
c_b()
cancel()
@pytest.mark.asyncio
async def test_stop_clears_due_at_so_restart_does_not_reuse_stale_due_times() -> None:
"""
stop() drops _due_at so a later start(new_loop) seeds fresh due-times.
Without this, a restart against a different event loop (whose
``time()`` origin differs) would reuse timestamps from the
cancelled loop and either fire windows immediately or never.
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:CC"
cancel = manager.async_register_active_scan(
address, scan_interval=60.0, scan_duration=5.0
)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:CC", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
assert address in sched._schedule._due_at
original_loop = sched._loop
sched.stop()
# _due_at cleared so stale timestamps from the now-defunct loop
# can't survive into a re-start.
assert sched._schedule._due_at == {}
assert sched._loop is None
assert sched._workers == {}
# _requests_by_address is loop-independent and must persist so
# start() can replay registrations on the new loop.
assert address in sched._requests_by_address
# Restart against the same loop; the request gets re-seeded with
# a fresh due time from the new loop.time() base.
assert original_loop is not None
sched.start(original_loop)
assert address in sched._schedule._due_at
entries = sched._schedule._due_at[address]
expected_due = original_loop.time() + 60.0
assert all(abs(due - expected_due) < 0.5 for due in entries.values())
finally:
cancel()
register_cancel()
class _DiscoverableAutoScanner(_RecordingAutoScanner):
"""Recording scanner that reports a configurable discovered set."""
__slots__ = ("_discovered",)
def __init__(
self,
source: str,
mode: BluetoothScanningMode | None,
connectable: bool = True,
) -> None:
super().__init__(source, mode, connectable)
self._discovered: dict[str, tuple[BLEDevice, AdvertisementData]] = {}
def add_discovered(self, address: str, rssi: int | None = -60) -> None:
"""Mark ``address`` as currently discovered by this scanner."""
device = generate_ble_device(address, "x")
adv = generate_advertisement_data(local_name="x", rssi=rssi)
self._discovered[address] = (device, adv)
def get_discovered_device_advertisement_data(
self, address: str
) -> tuple[BLEDevice, AdvertisementData] | None:
return self._discovered.get(address)
def _make_due(sched: object, address: str) -> None:
"""Make every tracked request for ``address`` due immediately."""
entries = sched._schedule._due_at[address] # type: ignore[attr-defined]
loop = asyncio.get_running_loop()
for req in list(entries):
entries[req] = loop.time() - 1.0
@pytest.mark.asyncio
async def test_worker_tick_delegates_to_fallback_when_owner_is_connecting() -> None:
"""
Owner mid-connect dispatches the active-window scan to fallback.
Owner scanner is in the connect-attempt phase
(``_connections_in_progress() > 0``) so its radio can't service
the active-window flip. A second AUTO scanner also sees the
device. The worker for the owner must call
``async_request_active_window`` on the fallback, not on the owner.
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:01"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=7.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:01:01", BluetoothScanningMode.AUTO)
fallback = _DiscoverableAutoScanner("AA:00:00:00:01:02", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_fallback = manager.async_register_scanner(fallback)
try:
_inject_with_rssi(owner, address, rssi=-50)
info = manager.async_last_service_info(address, False)
assert info is not None
assert info.source == owner.source
fallback.add_discovered(address, rssi=-70)
owner._add_connecting(address)
_make_due(sched, address)
await _run_worker_tick(sched, owner.source)
# Owner can't service the flip; fallback gets the call.
assert owner.active_window_calls == []
assert fallback.active_window_calls == [7.0]
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_fallback()
@pytest.mark.asyncio
async def test_worker_tick_warns_when_no_fallback_available(
caplog: pytest.LogCaptureFixture,
) -> None:
"""No fallback emits a single WARNING; owner is not flipped."""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:02"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:02:01", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
try:
_inject_with_rssi(owner, address, rssi=-50)
owner._add_connecting(address)
_make_due(sched, address)
with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"):
await _run_worker_tick(sched, owner.source)
assert owner.active_window_calls == []
assert any(
"no fallback scanner" in record.message and address in record.message
for record in caplog.records
)
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
@pytest.mark.asyncio
async def test_worker_tick_no_fallback_warning_is_rate_limited(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A second connecting tick with no fallback does not re-warn."""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:03"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:03:01", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
try:
_inject_with_rssi(owner, address, rssi=-50)
owner._add_connecting(address)
_make_due(sched, address)
with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"):
await _run_worker_tick(sched, owner.source)
count_after_first = sum(
1
for record in caplog.records
if "no fallback scanner" in record.message
)
assert count_after_first == 1
_make_due(sched, address)
await _run_worker_tick(sched, owner.source)
count_after_second = sum(
1
for record in caplog.records
if "no fallback scanner" in record.message
)
assert count_after_second == 1
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
@pytest.mark.asyncio
async def test_worker_tick_no_fallback_flag_resets_after_recovery(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Successful fallback dispatch re-arms the no-fallback warning."""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:04"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:04:01", BluetoothScanningMode.AUTO)
fallback = _DiscoverableAutoScanner("AA:00:00:00:04:02", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_fallback = manager.async_register_scanner(fallback)
try:
_inject_with_rssi(owner, address, rssi=-50)
owner._add_connecting(address)
_make_due(sched, address)
with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"):
# No fallback -> warning.
await _run_worker_tick(sched, owner.source)
assert sched._workers[owner.source]._warned_no_fallback is True
# Fallback appears, dispatch succeeds -> flag clears.
fallback.add_discovered(address, rssi=-70)
_make_due(sched, address)
await _run_worker_tick(sched, owner.source)
assert fallback.active_window_calls == [6.0]
assert sched._workers[owner.source]._warned_no_fallback is False
# Fallback disappears again -> warning fires once more.
fallback._discovered.clear()
_make_due(sched, address)
caplog.clear()
await _run_worker_tick(sched, owner.source)
assert any(
"no fallback scanner" in record.message for record in caplog.records
)
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_fallback()
@pytest.mark.asyncio
async def test_worker_tick_active_scanner_covers_address_no_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
"""
ACTIVE-mode scanner seeing the address counts as scan done.
The owner is mid-connect, so it can't service the active-window
flip. Another scanner has ``requested_mode is ACTIVE`` and sees
the address — by definition that scanner is already actively
scanning. The dispatch must drop the request silently: no
warning, no ``async_request_active_window`` call on the ACTIVE
scanner (which would no-op anyway via the ``requested_mode``
guard in ``HaScanner.async_request_active_window``).
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:05"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:05:01", BluetoothScanningMode.AUTO)
active = _DiscoverableAutoScanner("AA:00:00:00:05:03", BluetoothScanningMode.ACTIVE)
c_owner = manager.async_register_scanner(owner)
c_active = manager.async_register_scanner(active)
try:
_inject_with_rssi(owner, address, rssi=-50)
active.add_discovered(address, rssi=-60)
owner._add_connecting(address)
_make_due(sched, address)
with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"):
await _run_worker_tick(sched, owner.source)
assert owner.active_window_calls == []
assert active.active_window_calls == []
assert not any(
"no fallback scanner" in record.message for record in caplog.records
)
assert sched._workers[owner.source]._warned_no_fallback is False
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_active()
@pytest.mark.asyncio
async def test_worker_tick_active_coverage_preferred_over_auto_fallback() -> None:
"""When ACTIVE covers, no AUTO-fallback flip is needed either."""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:0C"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:0C:01", BluetoothScanningMode.AUTO)
auto_fb = _DiscoverableAutoScanner("AA:00:00:00:0C:02", BluetoothScanningMode.AUTO)
active = _DiscoverableAutoScanner("AA:00:00:00:0C:03", BluetoothScanningMode.ACTIVE)
c_owner = manager.async_register_scanner(owner)
c_auto = manager.async_register_scanner(auto_fb)
c_active = manager.async_register_scanner(active)
try:
_inject_with_rssi(owner, address, rssi=-50)
auto_fb.add_discovered(address, rssi=-55)
active.add_discovered(address, rssi=-70)
owner._add_connecting(address)
_make_due(sched, address)
await _run_worker_tick(sched, owner.source)
# Covered by ACTIVE: no flip needed on AUTO fallback either.
assert owner.active_window_calls == []
assert auto_fb.active_window_calls == []
assert active.active_window_calls == []
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_auto()
c_active()
@pytest.mark.asyncio
async def test_worker_tick_passive_only_fallback_warns(
caplog: pytest.LogCaptureFixture,
) -> None:
"""
Only PASSIVE scanners around: no flip possible, must warn.
A PASSIVE scanner refuses
``async_request_active_window`` and isn't actively scanning, so
the active scan is truly deferred until the owner's connect
completes — the warning must fire.
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:0D"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:0D:01", BluetoothScanningMode.AUTO)
passive = _DiscoverableAutoScanner(
"AA:00:00:00:0D:02", BluetoothScanningMode.PASSIVE
)
c_owner = manager.async_register_scanner(owner)
c_passive = manager.async_register_scanner(passive)
try:
_inject_with_rssi(owner, address, rssi=-50)
passive.add_discovered(address, rssi=-60)
owner._add_connecting(address)
_make_due(sched, address)
with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"):
await _run_worker_tick(sched, owner.source)
assert owner.active_window_calls == []
assert passive.active_window_calls == []
assert any(
"no fallback scanner" in record.message and address in record.message
for record in caplog.records
)
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_passive()
@pytest.mark.asyncio
async def test_worker_tick_dispatch_never_calls_same_fallback_twice() -> None:
"""
Per-tick dispatch never calls the same fallback more than once.
Same-tick coalescing guarantees we don't simultaneously trigger
``async_request_active_window`` twice on one scanner from a
single owner's tick. (Cross-tick concurrency between distinct
owner workers delegating to the same fallback is handled inside
the scanner: ``HaScanner.async_request_active_window`` extends an
open active-window timer instead of stopping and restarting the
radio, and the actual stop/start is serialized by
``_start_stop_lock``.)
"""
manager = get_manager()
sched = manager._auto_scheduler
addresses = ("11:22:33:44:55:0E", "11:22:33:44:55:0F", "11:22:33:44:55:10")
cancels = [
manager.async_register_active_scan(addr, scan_interval=120.0, scan_duration=6.0)
for addr in addresses
]
owner = _DiscoverableAutoScanner("AA:00:00:00:0E:01", BluetoothScanningMode.AUTO)
fb = _DiscoverableAutoScanner("AA:00:00:00:0E:02", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_fb = manager.async_register_scanner(fb)
try:
for addr in addresses:
_inject_with_rssi(owner, addr, rssi=-50)
fb.add_discovered(addr, rssi=-60)
owner._add_connecting(addresses[0])
for addr in addresses:
_make_due(sched, addr)
await _run_worker_tick(sched, owner.source)
# One coalesced call regardless of how many addresses route to fb.
assert len(fb.active_window_calls) == 1
assert owner.active_window_calls == []
finally:
owner._finished_connecting(addresses[0], connected=False)
for cancel in cancels:
cancel()
c_owner()
c_fb()
@pytest.mark.asyncio
async def test_worker_tick_active_covers_one_address_warns_for_other(
caplog: pytest.LogCaptureFixture,
) -> None:
"""
Per-address classification: covered for one, warn for another.
Owner is mid-connect with two due addresses. Address A is covered
by an ACTIVE scanner; address B has no fallback. We expect:
silent skip for A, warning for B that names only B.
"""
manager = get_manager()
sched = manager._auto_scheduler
addr_covered = "11:22:33:44:55:11"
addr_orphan = "11:22:33:44:55:12"
c1 = manager.async_register_active_scan(
addr_covered, scan_interval=120.0, scan_duration=6.0
)
c2 = manager.async_register_active_scan(
addr_orphan, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:11:01", BluetoothScanningMode.AUTO)
active = _DiscoverableAutoScanner("AA:00:00:00:11:02", BluetoothScanningMode.ACTIVE)
c_owner = manager.async_register_scanner(owner)
c_active = manager.async_register_scanner(active)
try:
_inject_with_rssi(owner, addr_covered, rssi=-50)
_inject_with_rssi(owner, addr_orphan, rssi=-50)
active.add_discovered(addr_covered, rssi=-60)
# Note: ACTIVE scanner does NOT see addr_orphan.
owner._add_connecting(addr_covered)
_make_due(sched, addr_covered)
_make_due(sched, addr_orphan)
with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"):
await _run_worker_tick(sched, owner.source)
warnings_for_orphan = [
record
for record in caplog.records
if "no fallback scanner" in record.message and addr_orphan in record.message
]
assert len(warnings_for_orphan) == 1
# The covered address must not appear in any no-fallback
# warning text.
assert not any(
"no fallback scanner" in record.message and addr_covered in record.message
for record in caplog.records
)
assert active.active_window_calls == []
assert owner.active_window_calls == []
finally:
owner._finished_connecting(addr_covered, connected=False)
c1()
c2()
c_owner()
c_active()
@pytest.mark.asyncio
async def test_worker_tick_skips_fallback_that_is_also_connecting() -> None:
"""A candidate fallback that's mid-connect is also excluded."""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:06"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:06:01", BluetoothScanningMode.AUTO)
busy_fb = _DiscoverableAutoScanner("AA:00:00:00:06:02", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_busy = manager.async_register_scanner(busy_fb)
try:
_inject_with_rssi(owner, address, rssi=-50)
busy_fb.add_discovered(address, rssi=-55)
owner._add_connecting(address)
busy_fb._add_connecting("AA:BB:CC:DD:EE:FF")
_make_due(sched, address)
await _run_worker_tick(sched, owner.source)
assert owner.active_window_calls == []
assert busy_fb.active_window_calls == []
finally:
owner._finished_connecting(address, connected=False)
busy_fb._finished_connecting("AA:BB:CC:DD:EE:FF", connected=False)
cancel()
c_owner()
c_busy()
@pytest.mark.asyncio
async def test_worker_tick_fallback_picks_highest_rssi() -> None:
"""When multiple AUTO fallbacks see the device, highest RSSI wins."""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:07"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:07:01", BluetoothScanningMode.AUTO)
weak = _DiscoverableAutoScanner("AA:00:00:00:07:02", BluetoothScanningMode.AUTO)
strong = _DiscoverableAutoScanner("AA:00:00:00:07:03", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_weak = manager.async_register_scanner(weak)
c_strong = manager.async_register_scanner(strong)
try:
_inject_with_rssi(owner, address, rssi=-50)
weak.add_discovered(address, rssi=-90)
strong.add_discovered(address, rssi=-40)
owner._add_connecting(address)
_make_due(sched, address)
await _run_worker_tick(sched, owner.source)
assert strong.active_window_calls == [6.0]
assert weak.active_window_calls == []
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_weak()
c_strong()
@pytest.mark.asyncio
async def test_worker_tick_groups_addresses_by_fallback() -> None:
"""Two due addresses sharing one fallback coalesce to one call."""
manager = get_manager()
sched = manager._auto_scheduler
addr_a = "11:22:33:44:55:08"
addr_b = "11:22:33:44:55:09"
cancel_a = manager.async_register_active_scan(
addr_a, scan_interval=120.0, scan_duration=6.0
)
cancel_b = manager.async_register_active_scan(
addr_b, scan_interval=120.0, scan_duration=11.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:08:01", BluetoothScanningMode.AUTO)
fb = _DiscoverableAutoScanner("AA:00:00:00:08:02", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_fb = manager.async_register_scanner(fb)
try:
_inject_with_rssi(owner, addr_a, rssi=-50)
_inject_with_rssi(owner, addr_b, rssi=-50)
fb.add_discovered(addr_a, rssi=-60)
fb.add_discovered(addr_b, rssi=-60)
owner._add_connecting(addr_a)
_make_due(sched, addr_a)
_make_due(sched, addr_b)
await _run_worker_tick(sched, owner.source)
# One coalesced call to the shared fallback with the max duration.
assert fb.active_window_calls == [11.0]
assert owner.active_window_calls == []
finally:
owner._finished_connecting(addr_a, connected=False)
cancel_a()
cancel_b()
c_owner()
c_fb()
@pytest.mark.asyncio
async def test_worker_tick_defers_sweep_when_owner_is_connecting() -> None:
"""
Sweep is per-scanner; defer when connecting rather than spinning.
With no per-device buckets but sweep_due True, the connecting
branch must not call ``async_request_active_window`` on the owner.
It must also advance ``_sweep_last_completed`` so the next worker
tick re-evaluates in roughly ``_AUTO_CONNECTING_DEFER`` seconds
rather than firing immediately.
"""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
owner = _DiscoverableAutoScanner("AA:00:00:00:09:01", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
try:
worker = sched._workers[owner.source]
# Force sweep due: place _sweep_last_completed safely in the
# past so now > _sweep_last_completed + AUTO_REDISCOVERY_INTERVAL.
worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0
owner._add_connecting("11:22:33:44:55:0A")
before = loop.time()
await worker._tick()
assert owner.active_window_calls == []
# Next-due time should be roughly now + _AUTO_CONNECTING_DEFER,
# i.e. _sweep_last_completed + AUTO_REDISCOVERY_INTERVAL >=
# before + (_AUTO_CONNECTING_DEFER - epsilon).
next_sweep_due = worker._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL
assert next_sweep_due >= before + 25.0
assert next_sweep_due <= loop.time() + 60.0
finally:
owner._finished_connecting("11:22:33:44:55:0A", connected=False)
c_owner()
@pytest.mark.asyncio
async def test_worker_tick_skips_fallback_when_owner_is_connected_not_connecting() -> (
None
):
"""A fully-connected (not connecting) owner still fires its own window."""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:0B"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:0B:01", BluetoothScanningMode.AUTO)
fb = _DiscoverableAutoScanner("AA:00:00:00:0B:02", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_fb = manager.async_register_scanner(fb)
try:
_inject_with_rssi(owner, address, rssi=-50)
fb.add_discovered(address, rssi=-60)
# No _add_connecting on the owner: connect has either not
# started or has already completed. The owner is responsible
# for the window; fallback stays silent.
_make_due(sched, address)
await _run_worker_tick(sched, owner.source)
assert owner.active_window_calls == [6.0]
assert fb.active_window_calls == []
finally:
cancel()
c_owner()
c_fb()
@pytest.mark.asyncio
async def test_worker_tick_fallback_exception_does_not_block_others(
caplog: pytest.LogCaptureFixture,
) -> None:
"""
A raising fallback gets logged; remaining fallbacks still run.
Two due addresses route to two different fallbacks. The first
fallback's ``async_request_active_window`` raises. The dispatch
must still call the second fallback and the worker must remain
alive.
"""
class _RaisingScanner(_DiscoverableAutoScanner):
async def async_request_active_window(self, duration: float) -> bool:
self.active_window_calls.append(duration)
msg = "boom"
raise RuntimeError(msg)
manager = get_manager()
sched = manager._auto_scheduler
addr_a = "11:22:33:44:55:13"
addr_b = "11:22:33:44:55:14"
c1 = manager.async_register_active_scan(
addr_a, scan_interval=120.0, scan_duration=6.0
)
c2 = manager.async_register_active_scan(
addr_b, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:13:01", BluetoothScanningMode.AUTO)
fb_bad = _RaisingScanner("AA:00:00:00:13:02", BluetoothScanningMode.AUTO)
fb_good = _DiscoverableAutoScanner("AA:00:00:00:13:03", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_bad = manager.async_register_scanner(fb_bad)
c_good = manager.async_register_scanner(fb_good)
try:
_inject_with_rssi(owner, addr_a, rssi=-50)
_inject_with_rssi(owner, addr_b, rssi=-50)
# Only fb_bad sees addr_a; only fb_good sees addr_b. So each
# address routes to a different fallback.
fb_bad.add_discovered(addr_a, rssi=-60)
fb_good.add_discovered(addr_b, rssi=-60)
owner._add_connecting(addr_a)
_make_due(sched, addr_a)
_make_due(sched, addr_b)
with caplog.at_level(logging.ERROR, logger="habluetooth.auto_scheduler"):
await _run_worker_tick(sched, owner.source)
assert fb_bad.active_window_calls == [6.0]
assert fb_good.active_window_calls == [6.0]
assert any(
"error dispatching fallback active window" in record.message
and fb_bad.name in record.message
for record in caplog.records
)
finally:
owner._finished_connecting(addr_a, connected=False)
c1()
c2()
c_owner()
c_bad()
c_good()
@pytest.mark.asyncio
async def test_worker_tick_sweep_and_per_device_both_handled_when_connecting() -> None:
"""
Mixed tick: per-device dispatched to fallback AND sweep deferred.
Sweep is due AND a per-device window is due AND the owner is
mid-connect. The per-device flip lands on the fallback; the sweep
is deferred (no flip on the owner) — both behaviors coexist.
"""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:15"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:15:01", BluetoothScanningMode.AUTO)
fb = _DiscoverableAutoScanner("AA:00:00:00:15:02", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_fb = manager.async_register_scanner(fb)
try:
_inject_with_rssi(owner, address, rssi=-50)
fb.add_discovered(address, rssi=-60)
owner._add_connecting(address)
worker = sched._workers[owner.source]
worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0
_make_due(sched, address)
before = loop.time()
await worker._tick()
# Per-device went to fallback.
assert fb.active_window_calls == [6.0]
assert owner.active_window_calls == []
# Sweep was deferred (next due roughly now + _AUTO_CONNECTING_DEFER).
next_sweep_due = worker._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL
assert next_sweep_due >= before + 25.0
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_fb()
@pytest.mark.asyncio
async def test_worker_tick_two_different_fallbacks_both_dispatched() -> None:
"""
Two due addresses with distinct fallbacks → both get called.
Confirms that the per-fallback grouping does *not* collapse
different fallbacks into one — each fallback receives its own
coalesced ``async_request_active_window`` call.
"""
manager = get_manager()
sched = manager._auto_scheduler
addr_a = "11:22:33:44:55:16"
addr_b = "11:22:33:44:55:17"
c1 = manager.async_register_active_scan(
addr_a, scan_interval=120.0, scan_duration=6.0
)
c2 = manager.async_register_active_scan(
addr_b, scan_interval=120.0, scan_duration=8.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:16:01", BluetoothScanningMode.AUTO)
fb_a = _DiscoverableAutoScanner("AA:00:00:00:16:02", BluetoothScanningMode.AUTO)
fb_b = _DiscoverableAutoScanner("AA:00:00:00:16:03", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_a = manager.async_register_scanner(fb_a)
c_b = manager.async_register_scanner(fb_b)
try:
_inject_with_rssi(owner, addr_a, rssi=-50)
_inject_with_rssi(owner, addr_b, rssi=-50)
fb_a.add_discovered(addr_a, rssi=-60)
fb_b.add_discovered(addr_b, rssi=-60)
owner._add_connecting(addr_a)
_make_due(sched, addr_a)
_make_due(sched, addr_b)
await _run_worker_tick(sched, owner.source)
assert fb_a.active_window_calls == [6.0]
assert fb_b.active_window_calls == [8.0]
assert owner.active_window_calls == []
finally:
owner._finished_connecting(addr_a, connected=False)
c1()
c2()
c_owner()
c_a()
c_b()
@pytest.mark.asyncio
async def test_worker_tick_advance_pre_dispatch_blocks_double_fire() -> None:
"""
Per-address ``_due_at`` entries are advanced before the dispatch.
The pre-dispatch advance protects against an in-flight ownership
flip causing a duplicate window on a different worker (same
reasoning as the non-connecting path's pre-await advance).
"""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:18"
cancel = manager.async_register_active_scan(
address, scan_interval=90.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:18:01", BluetoothScanningMode.AUTO)
fb = _DiscoverableAutoScanner("AA:00:00:00:18:02", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_fb = manager.async_register_scanner(fb)
try:
_inject_with_rssi(owner, address, rssi=-50)
fb.add_discovered(address, rssi=-60)
owner._add_connecting(address)
_make_due(sched, address)
before = loop.time()
await _run_worker_tick(sched, owner.source)
entries = sched._schedule._due_at[address]
for due in entries.values():
# Advanced to roughly before + 90s, NOT before - 1.0.
assert due == pytest.approx(before + 90.0, abs=0.5)
assert due > loop.time()
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_fb()
@pytest.mark.asyncio
async def test_worker_tick_passive_plus_auto_uses_auto(
caplog: pytest.LogCaptureFixture,
) -> None:
"""
PASSIVE + AUTO mix: AUTO is used, PASSIVE ignored, no warning.
Confirms that a PASSIVE scanner alongside a viable AUTO fallback
doesn't poison the result — we ignore PASSIVE and flip the AUTO.
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:19"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:19:01", BluetoothScanningMode.AUTO)
passive = _DiscoverableAutoScanner(
"AA:00:00:00:19:02", BluetoothScanningMode.PASSIVE
)
auto_fb = _DiscoverableAutoScanner("AA:00:00:00:19:03", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_pass = manager.async_register_scanner(passive)
c_auto = manager.async_register_scanner(auto_fb)
try:
_inject_with_rssi(owner, address, rssi=-50)
# Passive has a much stronger RSSI to confirm we still ignore it.
passive.add_discovered(address, rssi=-30)
auto_fb.add_discovered(address, rssi=-70)
owner._add_connecting(address)
_make_due(sched, address)
with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"):
await _run_worker_tick(sched, owner.source)
assert owner.active_window_calls == []
assert passive.active_window_calls == []
assert auto_fb.active_window_calls == [6.0]
assert not any(
"no fallback scanner" in record.message for record in caplog.records
)
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_pass()
c_auto()
@pytest.mark.asyncio
async def test_worker_tick_passive_plus_active_active_covers(
caplog: pytest.LogCaptureFixture,
) -> None:
"""
PASSIVE + ACTIVE mix: ACTIVE covers, PASSIVE ignored, no warning.
No AUTO fallback exists, but an ACTIVE scanner sees the address —
that's enough for "scan already in progress". The PASSIVE scanner
is irrelevant.
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:1A"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:1A:01", BluetoothScanningMode.AUTO)
passive = _DiscoverableAutoScanner(
"AA:00:00:00:1A:02", BluetoothScanningMode.PASSIVE
)
active = _DiscoverableAutoScanner("AA:00:00:00:1A:03", BluetoothScanningMode.ACTIVE)
c_owner = manager.async_register_scanner(owner)
c_pass = manager.async_register_scanner(passive)
c_active = manager.async_register_scanner(active)
try:
_inject_with_rssi(owner, address, rssi=-50)
passive.add_discovered(address, rssi=-40)
active.add_discovered(address, rssi=-70)
owner._add_connecting(address)
_make_due(sched, address)
with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"):
await _run_worker_tick(sched, owner.source)
assert owner.active_window_calls == []
assert passive.active_window_calls == []
assert active.active_window_calls == []
assert not any(
"no fallback scanner" in record.message for record in caplog.records
)
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_pass()
c_active()
@pytest.mark.asyncio
async def test_worker_tick_all_three_modes_active_wins(
caplog: pytest.LogCaptureFixture,
) -> None:
"""
PASSIVE + ACTIVE + AUTO all present: ACTIVE covers, no flip needed.
The dispatch must short-circuit on the ACTIVE coverage even when
an AUTO fallback is also available. PASSIVE is ignored.
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:1B"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:1B:01", BluetoothScanningMode.AUTO)
passive = _DiscoverableAutoScanner(
"AA:00:00:00:1B:02", BluetoothScanningMode.PASSIVE
)
active = _DiscoverableAutoScanner("AA:00:00:00:1B:03", BluetoothScanningMode.ACTIVE)
auto_fb = _DiscoverableAutoScanner("AA:00:00:00:1B:04", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_pass = manager.async_register_scanner(passive)
c_active = manager.async_register_scanner(active)
c_auto = manager.async_register_scanner(auto_fb)
try:
_inject_with_rssi(owner, address, rssi=-50)
passive.add_discovered(address, rssi=-40)
active.add_discovered(address, rssi=-70)
auto_fb.add_discovered(address, rssi=-55)
owner._add_connecting(address)
_make_due(sched, address)
with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"):
await _run_worker_tick(sched, owner.source)
# ACTIVE covers → no flip on anyone.
assert owner.active_window_calls == []
assert passive.active_window_calls == []
assert active.active_window_calls == []
assert auto_fb.active_window_calls == []
assert not any(
"no fallback scanner" in record.message for record in caplog.records
)
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_pass()
c_active()
c_auto()
@pytest.mark.asyncio
async def test_worker_tick_three_way_mix_per_address(
caplog: pytest.LogCaptureFixture,
) -> None:
"""
Three addresses, three outcomes in one tick: covered, flipped, warned.
* addr_covered: only ACTIVE sees → covered, no flip, no warning.
* addr_flipped: only AUTO sees → flipped on AUTO fallback.
* addr_orphan: no fallback at all → single warning naming addr_orphan.
"""
manager = get_manager()
sched = manager._auto_scheduler
addr_covered = "11:22:33:44:55:1C"
addr_flipped = "11:22:33:44:55:1D"
addr_orphan = "11:22:33:44:55:1E"
c1 = manager.async_register_active_scan(
addr_covered, scan_interval=120.0, scan_duration=6.0
)
c2 = manager.async_register_active_scan(
addr_flipped, scan_interval=120.0, scan_duration=6.0
)
c3 = manager.async_register_active_scan(
addr_orphan, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:1C:01", BluetoothScanningMode.AUTO)
active = _DiscoverableAutoScanner("AA:00:00:00:1C:02", BluetoothScanningMode.ACTIVE)
auto_fb = _DiscoverableAutoScanner("AA:00:00:00:1C:03", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_active = manager.async_register_scanner(active)
c_auto = manager.async_register_scanner(auto_fb)
try:
for addr in (addr_covered, addr_flipped, addr_orphan):
_inject_with_rssi(owner, addr, rssi=-50)
active.add_discovered(addr_covered, rssi=-60)
auto_fb.add_discovered(addr_flipped, rssi=-60)
owner._add_connecting(addr_covered)
for addr in (addr_covered, addr_flipped, addr_orphan):
_make_due(sched, addr)
with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"):
await _run_worker_tick(sched, owner.source)
assert owner.active_window_calls == []
assert active.active_window_calls == []
assert auto_fb.active_window_calls == [6.0]
warnings_for_orphan = [
record
for record in caplog.records
if "no fallback scanner" in record.message and addr_orphan in record.message
]
assert len(warnings_for_orphan) == 1
# Covered/flipped addresses must not appear in any no-fallback warning.
assert not any(
"no fallback scanner" in record.message and addr_covered in record.message
for record in caplog.records
)
assert not any(
"no fallback scanner" in record.message and addr_flipped in record.message
for record in caplog.records
)
finally:
owner._finished_connecting(addr_covered, connected=False)
c1()
c2()
c3()
c_owner()
c_active()
c_auto()
@pytest.mark.asyncio
async def test_worker_tick_owner_connecting_different_address_still_delegates() -> None:
"""
The connecting-phase signal is per-scanner, not per-address.
The owner is in a connect attempt to address X, while the due
per-device window is for address Y. The owner's radio is still
busy with X's connect, so Y must be delegated to a fallback too.
"""
manager = get_manager()
sched = manager._auto_scheduler
addr_due = "11:22:33:44:55:1F"
addr_connecting = "AA:BB:CC:DD:EE:99"
cancel = manager.async_register_active_scan(
addr_due, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:1F:01", BluetoothScanningMode.AUTO)
fb = _DiscoverableAutoScanner("AA:00:00:00:1F:02", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_fb = manager.async_register_scanner(fb)
try:
_inject_with_rssi(owner, addr_due, rssi=-50)
fb.add_discovered(addr_due, rssi=-60)
# Connect-in-progress is for a different address.
owner._add_connecting(addr_connecting)
_make_due(sched, addr_due)
await _run_worker_tick(sched, owner.source)
assert owner.active_window_calls == []
assert fb.active_window_calls == [6.0]
finally:
owner._finished_connecting(addr_connecting, connected=False)
cancel()
c_owner()
c_fb()
@pytest.mark.asyncio
async def test_worker_tick_fallback_returning_false_does_not_warn(
caplog: pytest.LogCaptureFixture,
) -> None:
"""
A fallback returning False from async_request_active_window is silent.
The helper's contract is "True = window armed/extended,
False = refused" — both are terminal answers. We consume the
call without raising and without warning, consistent with the
non-connecting path that also ignores the return value.
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:20"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:20:01", BluetoothScanningMode.AUTO)
fb = _DiscoverableAutoScanner("AA:00:00:00:20:02", BluetoothScanningMode.AUTO)
fb._return_value = False
c_owner = manager.async_register_scanner(owner)
c_fb = manager.async_register_scanner(fb)
try:
_inject_with_rssi(owner, address, rssi=-50)
fb.add_discovered(address, rssi=-60)
owner._add_connecting(address)
_make_due(sched, address)
with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"):
await _run_worker_tick(sched, owner.source)
# Call was made, no warning, no exception escaped.
assert fb.active_window_calls == [6.0]
assert owner.active_window_calls == []
assert not any(
"no fallback scanner" in record.message for record in caplog.records
)
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_fb()
@pytest.mark.asyncio
async def test_worker_tick_non_connectable_auto_fallback_is_eligible() -> None:
"""
A non-connectable AUTO scanner is a valid fallback for scanning.
Fallback selection is about *scanning*, not connecting — a
non-connectable scanner that can see the device is just as good
for an active-window flip as a connectable one.
``async_scanner_devices_by_address(address, False)`` is called
with ``connectable=False`` so both lists are considered.
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:21"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:21:01", BluetoothScanningMode.AUTO)
fb = _DiscoverableAutoScanner(
"AA:00:00:00:21:02", BluetoothScanningMode.AUTO, connectable=False
)
c_owner = manager.async_register_scanner(owner)
c_fb = manager.async_register_scanner(fb)
try:
_inject_with_rssi(owner, address, rssi=-50)
fb.add_discovered(address, rssi=-60)
owner._add_connecting(address)
_make_due(sched, address)
await _run_worker_tick(sched, owner.source)
assert owner.active_window_calls == []
assert fb.active_window_calls == [6.0]
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_fb()
@pytest.mark.asyncio
async def test_worker_tick_connect_starting_after_check_still_runs_locally() -> None:
"""
Race: a connect that starts AFTER the connecting check still hits the owner.
The connecting-state snapshot is taken once at the top of
``_tick``. If a connect begins between that check and the
``async_request_active_window`` await on the owner, the call has
already been committed — we do not re-check mid-dispatch. The
test pins this contract: ``_add_connecting`` after the call has
started must not flip dispatch to a fallback for THIS tick.
The next tick will see the new connecting state.
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:22"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:22:01", BluetoothScanningMode.AUTO)
fb = _DiscoverableAutoScanner("AA:00:00:00:22:02", BluetoothScanningMode.AUTO)
owner._block_event = asyncio.Event()
c_owner = manager.async_register_scanner(owner)
c_fb = manager.async_register_scanner(fb)
try:
_inject_with_rssi(owner, address, rssi=-50)
fb.add_discovered(address, rssi=-60)
# Owner NOT connecting at tick start.
_make_due(sched, address)
tick_task = asyncio.create_task(_run_worker_tick(sched, owner.source))
# Yield so the worker enters its await on owner.async_request_active_window
# (which is blocked on owner._block_event).
await asyncio.sleep(0)
assert owner.active_window_calls == [6.0]
# Race window: connect starts after the check, while the
# owner call is in flight. The mid-flight call must not be
# diverted; the fallback must not be called for THIS tick.
owner._add_connecting(address)
owner._block_event.set()
await tick_task
assert fb.active_window_calls == []
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_fb()
@pytest.mark.asyncio
async def test_worker_tick_connect_finish_during_dispatch_keeps_dispatch() -> None:
"""
Race: owner finishes connecting WHILE the fallback dispatch is awaiting.
The connecting state was True at tick start, so we entered the
fallback branch and already advanced ``_due_at``. The connect
completing mid-await must not cancel the in-flight fallback call
nor cause a duplicate window on the owner.
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:23"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:23:01", BluetoothScanningMode.AUTO)
fb = _DiscoverableAutoScanner("AA:00:00:00:23:02", BluetoothScanningMode.AUTO)
fb._block_event = asyncio.Event()
c_owner = manager.async_register_scanner(owner)
c_fb = manager.async_register_scanner(fb)
try:
_inject_with_rssi(owner, address, rssi=-50)
fb.add_discovered(address, rssi=-60)
owner._add_connecting(address)
_make_due(sched, address)
tick_task = asyncio.create_task(_run_worker_tick(sched, owner.source))
# Yield so the worker enters its await on the blocked fallback.
await asyncio.sleep(0)
assert fb.active_window_calls == [6.0]
# Connect finishes mid-dispatch.
owner._finished_connecting(address, connected=True)
assert owner._connections_in_progress() == 0
# Unblock the fallback so the dispatch can complete.
fb._block_event.set()
await tick_task
# Dispatch completed on the fallback only; owner stayed
# untouched for this tick.
assert fb.active_window_calls == [6.0]
assert owner.active_window_calls == []
finally:
cancel()
c_owner()
c_fb()
@pytest.mark.asyncio
async def test_worker_tick_two_owners_delegate_to_same_fallback_concurrently() -> None:
"""
Cross-tick: two owner workers concurrently delegate to one fallback.
The auto_scheduler doesn't serialize across workers — both
deliveries go through. The scanner-level
``_active_window_handle`` / ``_start_stop_lock`` extend-if-extends
logic is what guarantees the radio doesn't double-flip. Here we
verify the auto_scheduler delivers both calls cleanly without
deadlock or exception.
"""
manager = get_manager()
sched = manager._auto_scheduler
addr_a = "11:22:33:44:55:24"
addr_b = "11:22:33:44:55:25"
c1 = manager.async_register_active_scan(
addr_a, scan_interval=120.0, scan_duration=6.0
)
c2 = manager.async_register_active_scan(
addr_b, scan_interval=120.0, scan_duration=8.0
)
owner_a = _DiscoverableAutoScanner("AA:00:00:00:24:01", BluetoothScanningMode.AUTO)
owner_b = _DiscoverableAutoScanner("AA:00:00:00:24:02", BluetoothScanningMode.AUTO)
fb = _DiscoverableAutoScanner("AA:00:00:00:24:03", BluetoothScanningMode.AUTO)
c_a = manager.async_register_scanner(owner_a)
c_b = manager.async_register_scanner(owner_b)
c_fb = manager.async_register_scanner(fb)
try:
_inject_with_rssi(owner_a, addr_a, rssi=-50)
_inject_with_rssi(owner_b, addr_b, rssi=-50)
fb.add_discovered(addr_a, rssi=-60)
fb.add_discovered(addr_b, rssi=-60)
owner_a._add_connecting(addr_a)
owner_b._add_connecting(addr_b)
_make_due(sched, addr_a)
_make_due(sched, addr_b)
await asyncio.gather(
_run_worker_tick(sched, owner_a.source),
_run_worker_tick(sched, owner_b.source),
)
# Both owners delegated to fb; both calls were delivered.
assert sorted(fb.active_window_calls) == [6.0, 8.0]
assert owner_a.active_window_calls == []
assert owner_b.active_window_calls == []
finally:
owner_a._finished_connecting(addr_a, connected=False)
owner_b._finished_connecting(addr_b, connected=False)
c1()
c2()
c_a()
c_b()
c_fb()
@pytest.mark.asyncio
async def test_worker_tick_ownership_flip_during_dispatch_no_double_fire() -> None:
"""
Race: ownership flips from owner to fallback during the dispatch.
Same protection as the non-connecting migration test: the
pre-await ``_advance_due`` updates ``_due_at`` to ``now +
scan_interval`` before the fallback await, so if RSSI causes
ownership to shift to the fallback mid-dispatch, the fallback's
own next tick sees a future due time and skips — no duplicate
window fires on the new owner.
"""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:26"
cancel = manager.async_register_active_scan(
address, scan_interval=90.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:26:01", BluetoothScanningMode.AUTO)
fb = _DiscoverableAutoScanner("AA:00:00:00:26:02", BluetoothScanningMode.AUTO)
fb._block_event = asyncio.Event()
c_owner = manager.async_register_scanner(owner)
c_fb = manager.async_register_scanner(fb)
try:
_inject_with_rssi(owner, address, rssi=-80)
fb.add_discovered(address, rssi=-60)
owner._add_connecting(address)
_make_due(sched, address)
before = loop.time()
tick_task = asyncio.create_task(_run_worker_tick(sched, owner.source))
await asyncio.sleep(0)
# Confirm fallback call is in flight and entries advanced.
assert fb.active_window_calls == [6.0]
entries = sched._schedule._due_at[address]
for due in entries.values():
assert due == pytest.approx(before + 90.0, abs=0.5)
# Ownership flips to fb mid-dispatch (much stronger RSSI).
_inject_with_rssi(fb, address, rssi=-30)
info = manager.async_last_service_info(address, False)
assert info is not None
assert info.source == fb.source
# fb's own next tick must skip because entries are already advanced.
fb._block_event.set()
await tick_task
await sched._workers[fb.source]._tick()
# Only one call landed on fb; no double-fire.
assert fb.active_window_calls == [6.0]
assert owner.active_window_calls == []
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_fb()
@pytest.mark.asyncio
async def test_worker_tick_resolver_excludes_owner_when_owner_self_reports() -> None:
"""
The owner's own source is skipped even if it appears in the scanner list.
If the owner's ``get_discovered_device_advertisement_data`` returns
non-None for the address (so the manager lists it among the
scanner-devices), the resolver must still skip it via the
``scanner.source == exclude_source`` guard rather than picking the
busy owner as its own fallback.
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:27"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:27:01", BluetoothScanningMode.AUTO)
fb = _DiscoverableAutoScanner("AA:00:00:00:27:02", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_fb = manager.async_register_scanner(fb)
try:
_inject_with_rssi(owner, address, rssi=-50)
# Owner self-reports: would normally be sorted as the highest-RSSI
# candidate, but the resolver must exclude itself.
owner.add_discovered(address, rssi=-30)
fb.add_discovered(address, rssi=-70)
owner._add_connecting(address)
_make_due(sched, address)
await _run_worker_tick(sched, owner.source)
# Owner skipped despite self-reporting; weaker fallback wins.
assert owner.active_window_calls == []
assert fb.active_window_calls == [6.0]
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_fb()
@pytest.mark.asyncio
async def test_worker_tick_dispatch_advances_fallback_sweep_clock() -> None:
"""
Delegating an active window to a fallback advances its sweep clock.
The fallback's radio is actively scanning for ``duration`` seconds,
which subsumes the work its own rediscovery sweep would do. We
bump ``_sweep_last_completed = now`` so the fallback doesn't
immediately schedule another sweep window on top of the one we
just triggered.
"""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:28"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:28:01", BluetoothScanningMode.AUTO)
fb = _DiscoverableAutoScanner("AA:00:00:00:28:02", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_fb = manager.async_register_scanner(fb)
try:
_inject_with_rssi(owner, address, rssi=-50)
fb.add_discovered(address, rssi=-60)
# Make fb's own sweep imminent so we can detect the advance.
fb_worker = sched._workers[fb.source]
fb_worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0
sweep_before = fb_worker._sweep_last_completed
owner._add_connecting(address)
_make_due(sched, address)
before = loop.time()
await _run_worker_tick(sched, owner.source)
assert fb.active_window_calls == [6.0]
# Sweep clock advanced to ~now so fb won't immediately resweep.
assert fb_worker._sweep_last_completed > sweep_before
assert fb_worker._sweep_last_completed >= before
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_fb()
@pytest.mark.asyncio
async def test_worker_tick_dispatch_sets_fallback_window_end() -> None:
"""
Delegation marks the fallback worker as in-window for ``duration``.
With ``fb._window_end > now``, the fallback's own ``_tick`` and
``_next_event_at`` short-circuit during the delegated window so
the fallback doesn't redundantly tick on its own due work for the
duration of the active scan it is already running.
"""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:29"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:29:01", BluetoothScanningMode.AUTO)
fb = _DiscoverableAutoScanner("AA:00:00:00:29:02", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_fb = manager.async_register_scanner(fb)
try:
_inject_with_rssi(owner, address, rssi=-50)
fb.add_discovered(address, rssi=-60)
owner._add_connecting(address)
_make_due(sched, address)
before = loop.time()
await _run_worker_tick(sched, owner.source)
fb_worker = sched._workers[fb.source]
# Window-end bumped roughly to before + duration.
assert fb_worker._window_end >= before + 5.0
assert fb_worker._window_end <= loop.time() + 7.0
# A fb tick while _window_end > now must short-circuit
# (no async_request_active_window call recorded).
calls_before = list(fb.active_window_calls)
await fb_worker._tick()
assert fb.active_window_calls == calls_before
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_fb()
@pytest.mark.asyncio
async def test_worker_tick_dispatch_does_not_shrink_existing_fallback_window() -> None:
"""
A larger pre-existing ``_window_end`` on the fallback is preserved.
If the fallback is already running a longer window when we
delegate (e.g., a much earlier delegation from another owner
extended its own ``_window_end``), our shorter delegation must
not shrink it back.
"""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:2A"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=5.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:2A:01", BluetoothScanningMode.AUTO)
fb = _DiscoverableAutoScanner("AA:00:00:00:2A:02", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_fb = manager.async_register_scanner(fb)
try:
_inject_with_rssi(owner, address, rssi=-50)
fb.add_discovered(address, rssi=-60)
fb_worker = sched._workers[fb.source]
# Pre-seed a longer pending window on the fallback.
existing_window_end = loop.time() + 60.0
fb_worker._window_end = existing_window_end
owner._add_connecting(address)
_make_due(sched, address)
await _run_worker_tick(sched, owner.source)
# The shorter (5s) delegation must not have shrunk the
# existing 60s window.
assert fb_worker._window_end == existing_window_end
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_fb()
@pytest.mark.asyncio
async def test_worker_tick_three_addresses_same_fallback_coalesce_to_max() -> None:
"""
Three due addresses with distinct durations on one fallback coalesce to max.
Confirms per-fallback coalescing picks the max
``scan_duration`` over all grouped requests, not the first.
"""
manager = get_manager()
sched = manager._auto_scheduler
addr_a = "11:22:33:44:55:2B"
addr_b = "11:22:33:44:55:2C"
addr_c = "11:22:33:44:55:2D"
c1 = manager.async_register_active_scan(
addr_a, scan_interval=120.0, scan_duration=6.0
)
c2 = manager.async_register_active_scan(
addr_b, scan_interval=120.0, scan_duration=11.0
)
c3 = manager.async_register_active_scan(
addr_c, scan_interval=120.0, scan_duration=8.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:2B:01", BluetoothScanningMode.AUTO)
fb = _DiscoverableAutoScanner("AA:00:00:00:2B:02", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_fb = manager.async_register_scanner(fb)
try:
for addr in (addr_a, addr_b, addr_c):
_inject_with_rssi(owner, addr, rssi=-50)
fb.add_discovered(addr, rssi=-60)
owner._add_connecting(addr_a)
for addr in (addr_a, addr_b, addr_c):
_make_due(sched, addr)
await _run_worker_tick(sched, owner.source)
# Single call to the shared fallback at max(6, 11, 8) = 11.
assert fb.active_window_calls == [11.0]
assert owner.active_window_calls == []
finally:
owner._finished_connecting(addr_a, connected=False)
c1()
c2()
c3()
c_owner()
c_fb()
@pytest.mark.asyncio
async def test_worker_tick_three_addresses_three_fallbacks_each_own_duration() -> None:
"""Three due addresses on three fallbacks, each call uses its own duration."""
manager = get_manager()
sched = manager._auto_scheduler
addr_a = "11:22:33:44:55:2E"
addr_b = "11:22:33:44:55:2F"
addr_c = "11:22:33:44:55:30"
c1 = manager.async_register_active_scan(
addr_a, scan_interval=120.0, scan_duration=6.0
)
c2 = manager.async_register_active_scan(
addr_b, scan_interval=180.0, scan_duration=9.0
)
c3 = manager.async_register_active_scan(
addr_c, scan_interval=240.0, scan_duration=12.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:2E:01", BluetoothScanningMode.AUTO)
fb_a = _DiscoverableAutoScanner("AA:00:00:00:2E:02", BluetoothScanningMode.AUTO)
fb_b = _DiscoverableAutoScanner("AA:00:00:00:2E:03", BluetoothScanningMode.AUTO)
fb_c = _DiscoverableAutoScanner("AA:00:00:00:2E:04", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_a = manager.async_register_scanner(fb_a)
c_b = manager.async_register_scanner(fb_b)
c_c = manager.async_register_scanner(fb_c)
try:
_inject_with_rssi(owner, addr_a, rssi=-50)
_inject_with_rssi(owner, addr_b, rssi=-50)
_inject_with_rssi(owner, addr_c, rssi=-50)
fb_a.add_discovered(addr_a, rssi=-60)
fb_b.add_discovered(addr_b, rssi=-60)
fb_c.add_discovered(addr_c, rssi=-60)
owner._add_connecting(addr_a)
for addr in (addr_a, addr_b, addr_c):
_make_due(sched, addr)
await _run_worker_tick(sched, owner.source)
assert fb_a.active_window_calls == [6.0]
assert fb_b.active_window_calls == [9.0]
assert fb_c.active_window_calls == [12.0]
assert owner.active_window_calls == []
finally:
owner._finished_connecting(addr_a, connected=False)
c1()
c2()
c3()
c_owner()
c_a()
c_b()
c_c()
@pytest.mark.asyncio
async def test_worker_tick_three_addresses_no_fallback_advance_by_defer() -> None:
"""
No-fallback advance uses ``_AUTO_CONNECTING_DEFER``, not scan_interval.
Three addresses with very different ``scan_interval``s
(120/600/3600s) must all be advanced to ~now + 30s so the next
tick retries shortly after the connect completes.
"""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
addr_a = "11:22:33:44:55:31"
addr_b = "11:22:33:44:55:32"
addr_c = "11:22:33:44:55:33"
c1 = manager.async_register_active_scan(
addr_a, scan_interval=120.0, scan_duration=6.0
)
c2 = manager.async_register_active_scan(
addr_b, scan_interval=600.0, scan_duration=9.0
)
c3 = manager.async_register_active_scan(
addr_c, scan_interval=3600.0, scan_duration=12.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:31:01", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
try:
for addr in (addr_a, addr_b, addr_c):
_inject_with_rssi(owner, addr, rssi=-50)
_make_due(sched, addr)
owner._add_connecting(addr_a)
before = loop.time()
await _run_worker_tick(sched, owner.source)
for addr in (addr_a, addr_b, addr_c):
entries = sched._schedule._due_at[addr]
for due in entries.values():
assert due == pytest.approx(before + 30.0, abs=0.5)
assert due < before + 60.0
finally:
owner._finished_connecting(addr_a, connected=False)
c1()
c2()
c3()
c_owner()
@pytest.mark.asyncio
async def test_note_window_dispatched_preserves_more_recent_sweep() -> None:
"""
``note_window_dispatched`` does not move ``_sweep_last_completed`` backwards.
Covers the False branch of ``if self._sweep_last_completed < now``
when the fallback's sweep clock is already further in the future
than the ``now`` we're passing in.
"""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:38"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:38:01", BluetoothScanningMode.AUTO)
fb = _DiscoverableAutoScanner("AA:00:00:00:38:02", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_fb = manager.async_register_scanner(fb)
try:
_inject_with_rssi(owner, address, rssi=-50)
fb.add_discovered(address, rssi=-60)
fb_worker = sched._workers[fb.source]
future_sweep = loop.time() + 600.0
fb_worker._sweep_last_completed = future_sweep
owner._add_connecting(address)
_make_due(sched, address)
await _run_worker_tick(sched, owner.source)
# Sweep clock not moved backwards by our note_window_dispatched.
assert fb_worker._sweep_last_completed == future_sweep
assert fb.active_window_calls == [6.0]
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_fb()
@pytest.mark.asyncio
async def test_worker_tick_dispatch_handles_missing_fallback_worker() -> None:
"""
Dispatch tolerates ``workers.get(fb.source) is None``.
Covers the False branch of ``if fb_worker is not None``. Reachable
when a fallback scanner is unregistered between resolution and the
per-fallback iteration (sim: drop the worker entry between
registration and tick to force the lookup miss). The dispatch
should still call ``async_request_active_window`` on the fallback
even with no worker available to receive ``note_window_dispatched``.
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:37"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:37:01", BluetoothScanningMode.AUTO)
fb = _DiscoverableAutoScanner("AA:00:00:00:37:02", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_fb = manager.async_register_scanner(fb)
try:
_inject_with_rssi(owner, address, rssi=-50)
fb.add_discovered(address, rssi=-60)
owner._add_connecting(address)
_make_due(sched, address)
# Drop fb's worker so workers.get(fb.source) is None during dispatch.
# The fb scanner is still registered (so resolve still picks it)
# and async_scanner_devices_by_address still returns it.
sched._workers.pop(fb.source)
await _run_worker_tick(sched, owner.source)
# Dispatch still happened on the fallback's scanner even though
# we couldn't notify the worker.
assert fb.active_window_calls == [6.0]
assert owner.active_window_calls == []
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_fb()
@pytest.mark.asyncio
async def test_worker_tick_three_addresses_mixed_outcomes_advance_correctly() -> None:
"""
Three addresses, three outcomes, each advanced per its outcome.
covered (ACTIVE) -> ``scan_interval`` (full cadence).
AUTO fallback -> ``scan_interval`` (full cadence).
no fallback -> ``_AUTO_CONNECTING_DEFER`` (short retry).
"""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
addr_covered = "11:22:33:44:55:34"
addr_flipped = "11:22:33:44:55:35"
addr_orphan = "11:22:33:44:55:36"
c1 = manager.async_register_active_scan(
addr_covered, scan_interval=120.0, scan_duration=6.0
)
c2 = manager.async_register_active_scan(
addr_flipped, scan_interval=240.0, scan_duration=9.0
)
c3 = manager.async_register_active_scan(
addr_orphan, scan_interval=600.0, scan_duration=12.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:34:01", BluetoothScanningMode.AUTO)
active = _DiscoverableAutoScanner("AA:00:00:00:34:02", BluetoothScanningMode.ACTIVE)
fb = _DiscoverableAutoScanner("AA:00:00:00:34:03", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_active = manager.async_register_scanner(active)
c_fb = manager.async_register_scanner(fb)
try:
for addr in (addr_covered, addr_flipped, addr_orphan):
_inject_with_rssi(owner, addr, rssi=-50)
_make_due(sched, addr)
active.add_discovered(addr_covered, rssi=-60)
fb.add_discovered(addr_flipped, rssi=-60)
owner._add_connecting(addr_covered)
before = loop.time()
await _run_worker_tick(sched, owner.source)
for due in sched._schedule._due_at[addr_covered].values():
assert due == pytest.approx(before + 120.0, abs=0.5)
for due in sched._schedule._due_at[addr_flipped].values():
assert due == pytest.approx(before + 240.0, abs=0.5)
for due in sched._schedule._due_at[addr_orphan].values():
assert due == pytest.approx(before + 30.0, abs=0.5)
assert fb.active_window_calls == [9.0]
assert active.active_window_calls == []
assert owner.active_window_calls == []
finally:
owner._finished_connecting(addr_covered, connected=False)
c1()
c2()
c3()
c_owner()
c_active()
c_fb()
@pytest.mark.asyncio
async def test_worker_tick_fallback_with_none_rssi_is_still_dispatched() -> None:
"""
A fallback whose last advertisement has ``rssi is None`` is usable.
Pins Kōan blocker #1: ``_resolve_fallback_for_address`` must not
crash on ``None`` RSSI (would raise ``TypeError`` on ``None >
-10_000``). The defensive ``rssi or NO_RSSI_VALUE`` normalization
keeps the scanner in the candidate pool with the sentinel score.
Here the single fallback has ``rssi=None`` and the dispatch must
still fire on it.
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:39"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:39:01", BluetoothScanningMode.AUTO)
fb = _DiscoverableAutoScanner("AA:00:00:00:39:02", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_fb = manager.async_register_scanner(fb)
try:
_inject_with_rssi(owner, address, rssi=-50)
fb.add_discovered(address, rssi=None)
owner._add_connecting(address)
_make_due(sched, address)
await _run_worker_tick(sched, owner.source)
assert owner.active_window_calls == []
assert fb.active_window_calls == [6.0]
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_fb()
@pytest.mark.asyncio
async def test_worker_tick_fallback_with_rssi_loses_to_better_fallback() -> None:
"""
A ``None``-RSSI fallback loses to a fallback with a real RSSI.
With ``None`` normalized to ``NO_RSSI_VALUE`` (-127), any
real-world RSSI beats it, so the ``None``-RSSI scanner is only
picked when nothing else is available.
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:3A"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:3A:01", BluetoothScanningMode.AUTO)
fb_none = _DiscoverableAutoScanner("AA:00:00:00:3A:02", BluetoothScanningMode.AUTO)
fb_real = _DiscoverableAutoScanner("AA:00:00:00:3A:03", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_n = manager.async_register_scanner(fb_none)
c_r = manager.async_register_scanner(fb_real)
try:
_inject_with_rssi(owner, address, rssi=-50)
fb_none.add_discovered(address, rssi=None)
fb_real.add_discovered(address, rssi=-90)
owner._add_connecting(address)
_make_due(sched, address)
await _run_worker_tick(sched, owner.source)
# Real RSSI (-90) beats the normalized None (-127).
assert fb_real.active_window_calls == [6.0]
assert fb_none.active_window_calls == []
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_n()
c_r()
@pytest.mark.asyncio
async def test_worker_tick_failed_fallback_advances_entries_by_full_interval() -> None:
"""
Failed fallback dispatch still advances entries by ``scan_interval``.
Pins Kōan suggestion #2 / the documented "advance on failure"
semantics: when ``fb.async_request_active_window`` raises, the
per-address entries have already been advanced by ``scan_interval``
(NOT reset to ``retry_at``) and the fallback worker's
``_window_end`` / ``_sweep_last_completed`` bumps from
``note_window_dispatched`` are preserved. A failing fallback is
treated like a successful one to avoid busy-looping on a stuck
scanner.
"""
class _RaisingScanner(_DiscoverableAutoScanner):
async def async_request_active_window(self, duration: float) -> bool:
self.active_window_calls.append(duration)
msg = "boom"
raise RuntimeError(msg)
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:3B"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:3B:01", BluetoothScanningMode.AUTO)
fb = _RaisingScanner("AA:00:00:00:3B:02", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_fb = manager.async_register_scanner(fb)
try:
_inject_with_rssi(owner, address, rssi=-50)
fb.add_discovered(address, rssi=-60)
owner._add_connecting(address)
_make_due(sched, address)
fb_worker = sched._workers[fb.source]
sweep_before = fb_worker._sweep_last_completed
before = loop.time()
await _run_worker_tick(sched, owner.source)
# Entries advanced by full scan_interval, NOT retry_at.
for due in sched._schedule._due_at[address].values():
assert due == pytest.approx(before + 120.0, abs=0.5)
assert due > before + 60.0 # well past the 30s retry_at
# fb_worker bumps from note_window_dispatched are preserved.
assert fb_worker._sweep_last_completed > sweep_before
assert fb_worker._sweep_last_completed >= before
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_fb()
@pytest.mark.asyncio
async def test_worker_tick_fallback_with_rssi_zero_is_strongest() -> None:
"""
A fallback with ``rssi == 0`` beats a fallback with negative RSSI.
Pins the explicit ``rssi is None`` check (rather than ``rssi or
NO_RSSI_VALUE``): an RSSI of 0 is a valid very-strong signal and
must not be coerced to the missing-RSSI sentinel via ``0 or X``
falsiness.
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:3D"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:3D:01", BluetoothScanningMode.AUTO)
fb_zero = _DiscoverableAutoScanner("AA:00:00:00:3D:02", BluetoothScanningMode.AUTO)
fb_neg = _DiscoverableAutoScanner("AA:00:00:00:3D:03", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_zero = manager.async_register_scanner(fb_zero)
c_neg = manager.async_register_scanner(fb_neg)
try:
_inject_with_rssi(owner, address, rssi=-50)
fb_zero.add_discovered(address, rssi=0)
fb_neg.add_discovered(address, rssi=-50)
owner._add_connecting(address)
_make_due(sched, address)
await _run_worker_tick(sched, owner.source)
# rssi=0 beats rssi=-50; the falsy-or pattern would have
# incorrectly normalised 0 to NO_RSSI_VALUE and lost.
assert fb_zero.active_window_calls == [6.0]
assert fb_neg.active_window_calls == []
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_zero()
c_neg()
@pytest.mark.asyncio
async def test_worker_tick_dispatch_short_window_still_resets_full_sweep() -> None:
"""
A 5s delegated window resets the fallback's full 12h sweep cadence.
Pins the documented best-effort caveat: ``note_window_dispatched``
advances ``_sweep_last_completed`` to ``now`` regardless of how
short the delegated window is. With min duration (5s, well below
``AUTO_REDISCOVERY_SWEEP_DURATION`` of 15s), the next sweep is
still pushed out a full ``AUTO_REDISCOVERY_INTERVAL`` (12h).
"""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:3C"
# Request duration well under MIN; coalesce_duration clamps to
# _AUTO_WINDOW_MIN_DURATION (5.0).
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=5.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:3C:01", BluetoothScanningMode.AUTO)
fb = _DiscoverableAutoScanner("AA:00:00:00:3C:02", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_fb = manager.async_register_scanner(fb)
try:
_inject_with_rssi(owner, address, rssi=-50)
fb.add_discovered(address, rssi=-60)
# Place fb's sweep clock far in the past so we can see the bump.
fb_worker = sched._workers[fb.source]
fb_worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL / 2
owner._add_connecting(address)
_make_due(sched, address)
before = loop.time()
await _run_worker_tick(sched, owner.source)
# Delegated window was 5s; fb's next sweep was pushed to
# roughly now + 12h regardless of the short window.
next_sweep_due = fb_worker._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL
assert next_sweep_due == pytest.approx(
before + AUTO_REDISCOVERY_INTERVAL, abs=1
)
assert fb.active_window_calls == [5.0]
finally:
owner._finished_connecting(address, connected=False)
cancel()
c_owner()
c_fb()
@pytest.mark.asyncio
async def test_worker_tick_per_device_window_satisfies_sweep_floor() -> None:
"""
A per-device active window advances ``_sweep_last_completed``.
The rediscovery sweep is a floor: scanners that haven't
active-scanned in 12 h get a 15 s sweep. A scanner that just ran
a per-device active window has already actively scanned, so its
next sweep is pushed out a full ``AUTO_REDISCOVERY_INTERVAL``
even when ``sweep_due`` was False at tick time.
"""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:3E"
cancel = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=6.0
)
scanner = _DiscoverableAutoScanner("AA:00:00:00:3E:01", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject_with_rssi(scanner, address, rssi=-50)
worker = sched._workers[scanner.source]
# Place sweep clock recent enough that sweep is NOT due — we
# want to prove the per-device window still advances it.
recent_sweep = loop.time() - 60.0
worker._sweep_last_completed = recent_sweep
_make_due(sched, address)
before = loop.time()
await _run_worker_tick(sched, scanner.source)
assert scanner.active_window_calls == [6.0]
# Per-device window pushed sweep clock from "60s ago" to "now",
# demonstrating any active scan satisfies the sweep floor.
assert worker._sweep_last_completed > recent_sweep
assert worker._sweep_last_completed >= before
finally:
cancel()
register_cancel()
@pytest.mark.asyncio
async def test_worker_tick_dispatch_samples_time_per_fallback() -> None:
"""
Each fallback's ``window_end`` is anchored to its dispatch time.
Each ``await fb.async_request_active_window(duration)`` can take
seconds in production (scanner stop/restart on Linux). Reusing
the owner's tick-start ``now`` for every fallback's
``note_window_dispatched`` would leave later fallbacks'
``_window_end`` in the past — defeating the suppression. Use a
first fallback that ``asyncio.sleep``s during its dispatch so the
second fallback's ``loop.time()`` is strictly later than the
owner's tick-start ``now``, then verify the second fallback's
``_window_end`` reflects its own dispatch time.
"""
class _SlowScanner(_DiscoverableAutoScanner):
async def async_request_active_window(self, duration: float) -> bool:
self.active_window_calls.append(duration)
await asyncio.sleep(0.1)
return self._return_value
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
addr_a = "11:22:33:44:55:3F"
addr_b = "11:22:33:44:55:40"
c1 = manager.async_register_active_scan(
addr_a, scan_interval=120.0, scan_duration=6.0
)
c2 = manager.async_register_active_scan(
addr_b, scan_interval=120.0, scan_duration=6.0
)
owner = _DiscoverableAutoScanner("AA:00:00:00:3F:01", BluetoothScanningMode.AUTO)
fb_slow = _SlowScanner("AA:00:00:00:3F:02", BluetoothScanningMode.AUTO)
fb_late = _DiscoverableAutoScanner("AA:00:00:00:3F:03", BluetoothScanningMode.AUTO)
c_owner = manager.async_register_scanner(owner)
c_s = manager.async_register_scanner(fb_slow)
c_l = manager.async_register_scanner(fb_late)
try:
_inject_with_rssi(owner, addr_a, rssi=-50)
_inject_with_rssi(owner, addr_b, rssi=-50)
fb_slow.add_discovered(addr_a, rssi=-60)
fb_late.add_discovered(addr_b, rssi=-60)
owner._add_connecting(addr_a)
_make_due(sched, addr_a)
_make_due(sched, addr_b)
tick_start = loop.time()
await _run_worker_tick(sched, owner.source)
# Both fallbacks were called.
assert fb_slow.active_window_calls == [6.0]
assert fb_late.active_window_calls == [6.0]
# fb_late was dispatched AFTER fb_slow's 0.1s sleep, so its
# _window_end is anchored to dispatch_now ≈ tick_start + 0.1,
# i.e. > tick_start + duration (6.0). The owner's tick-start
# ``now`` would have given tick_start + 6.0 ≈ tick_start + 6.0
# exactly, which is < tick_start + 6.0 + 0.05.
fb_late_worker = sched._workers[fb_late.source]
assert fb_late_worker._window_end > tick_start + 6.0 + 0.05
finally:
owner._finished_connecting(addr_a, connected=False)
c1()
c2()
c_owner()
c_s()
c_l()
@pytest.mark.asyncio
async def test_async_diagnostics() -> None:
"""Diagnostics expose per-worker sweep timing and per-address requests."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:66"
cancel1 = manager.async_register_active_scan(
address, scan_interval=120.0, scan_duration=5.0
)
cancel2 = manager.async_register_active_scan(
address, scan_interval=240.0, scan_duration=10.0
)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
diagnostics = sched.async_diagnostics()
assert diagnostics["running"] is True
assert diagnostics["monotonic_time"] == pytest.approx(loop.time(), abs=0.5)
workers = diagnostics["workers"]
assert set(workers) == {scanner.source}
worker_diag = workers[scanner.source]
assert worker_diag["name"] == scanner.name
assert worker_diag["window_end"] == 0.0
assert worker_diag["failed_window"] is False
assert worker_diag["warned_no_fallback"] is False
assert worker_diag["next_sweep_at"] == pytest.approx(
worker_diag["sweep_last_completed"] + AUTO_REDISCOVERY_INTERVAL
)
assert worker_diag["next_event_at"] > 0.0
requests = diagnostics["requests"]
assert set(requests) == {address}
entries = requests[address]
assert len(entries) == 2
pairs = sorted(
(entry["scan_interval"], entry["scan_duration"]) for entry in entries
)
assert pairs == [(120.0, 5.0), (240.0, 10.0)]
for entry in entries:
assert entry["owner_source"] == scanner.source
assert entry["next_due"] is not None
assert entry["next_due"] > loop.time()
finally:
cancel1()
cancel2()
register_cancel()
# After cancellation the address falls out of both indexes.
post = sched.async_diagnostics()
assert post["requests"] == {}
@contextlib.asynccontextmanager
async def _no_real_sleep():
"""
Replace ``asyncio.sleep`` with an immediate fake-time advance.
Sweeps clamp duration to AUTO_WINDOW_MIN_DURATION (5s); stubbing
the sleep keeps tests fast while preserving the call shape so we
can still observe what duration was requested. Each mocked sleep
also advances ``loop.time()`` by ``duration`` so the on-demand
sweep's sleep-until-end loop (which re-reads
``_on_demand_sweep_end`` on each wake) terminates instead of
spinning forever against a frozen clock.
"""
loop = asyncio.get_running_loop()
real_time = loop.time
fake_advance = [0.0]
async def _instant(duration: float) -> None:
fake_advance[0] += duration
def _fake_time() -> float:
return real_time() + fake_advance[0]
with (
patch("asyncio.sleep", new=_instant),
patch.object(loop, "time", _fake_time),
):
yield
@pytest.mark.asyncio
async def test_async_request_active_scan_fires_active_window_on_each_auto_scanner() -> (
None
):
"""A sweep flips every AUTO scanner into ACTIVE for the duration."""
manager = get_manager()
a = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
b = _RecordingAutoScanner("AA:BB:CC:DD:EE:01", BluetoothScanningMode.AUTO)
c_a = manager.async_register_scanner(a)
c_b = manager.async_register_scanner(b)
try:
async with _no_real_sleep():
await manager.async_request_active_scan(duration=7.0)
assert a.active_window_calls == [7.0]
assert b.active_window_calls == [7.0]
finally:
c_a()
c_b()
@pytest.mark.asyncio
async def test_async_request_active_scan_skips_connecting_scanner() -> None:
"""A scanner mid-connect is skipped; non-connecting peers still flip."""
manager = get_manager()
busy = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
free = _RecordingAutoScanner("AA:BB:CC:DD:EE:01", BluetoothScanningMode.AUTO)
c_busy = manager.async_register_scanner(busy)
c_free = manager.async_register_scanner(free)
busy._add_connecting("11:22:33:44:55:66")
try:
async with _no_real_sleep():
await manager.async_request_active_scan(duration=5.0)
assert busy.active_window_calls == []
assert free.active_window_calls == [5.0]
finally:
busy._finished_connecting("11:22:33:44:55:66", connected=False)
c_busy()
c_free()
@pytest.mark.asyncio
async def test_async_request_active_scan_resets_next_sweep_time() -> None:
"""A sweep advances each flipped worker's _sweep_last_completed to now."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
worker = sched._workers[scanner.source]
# Backdate so we can observe the bump.
worker._sweep_last_completed = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0
try:
before = loop.time()
async with _no_real_sleep():
await manager.async_request_active_scan(duration=5.0)
assert worker._sweep_last_completed >= before
assert worker._sweep_last_completed <= loop.time() + 0.1
finally:
register_cancel()
@pytest.mark.asyncio
async def test_async_request_active_scan_mixed_durations_extends_to_longest() -> None:
"""
Concurrent callers asking for (10, 15, 5, 20) all wait until T0+20.
The first caller to win the check-and-set runs a 10s sweep; the 15s
and 20s callers extend the in-flight window (re-flipping the radio
with the longer remaining duration); the 5s caller fits within the
already-extended end and does not flip again. The scanner records
each flip's duration so we can verify the extension chain.
"""
manager = get_manager()
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
async with _no_real_sleep():
await asyncio.gather(
manager.async_request_active_scan(duration=10.0),
manager.async_request_active_scan(duration=15.0),
manager.async_request_active_scan(duration=5.0),
manager.async_request_active_scan(duration=20.0),
)
# Exactly three flip durations: leader's 10s + two extensions
# (approximately 15s and 20s, with sub-second drift from
# task-start jitter — pytest.approx absorbs the drift, and
# the ordering pins the chain.
assert len(scanner.active_window_calls) == 3
assert scanner.active_window_calls[0] == 10.0
assert scanner.active_window_calls[1] == pytest.approx(15.0, abs=1.0)
assert scanner.active_window_calls[2] == pytest.approx(20.0, abs=1.0)
assert (
scanner.active_window_calls[0]
< scanner.active_window_calls[1]
< scanner.active_window_calls[2]
)
# The future and end are cleared once the leader finishes.
assert manager._auto_scheduler._on_demand_sweep_future is None
assert manager._auto_scheduler._on_demand_sweep_end == 0.0
finally:
register_cancel()
@pytest.mark.asyncio
async def test_async_request_active_scan_dedupes_concurrent_callers() -> None:
"""N concurrent sweep calls share one window; the bus flips once."""
manager = get_manager()
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
async with _no_real_sleep():
# Three concurrent callers, mirroring HA integrations each
# opening their own config flow at the same time.
await asyncio.gather(
manager.async_request_active_scan(duration=5.0),
manager.async_request_active_scan(duration=5.0),
manager.async_request_active_scan(duration=5.0),
)
# Only one active window despite three callers.
assert scanner.active_window_calls == [5.0]
# The deduped future is cleared once the sweep finishes.
assert manager._auto_scheduler._on_demand_sweep_future is None
finally:
register_cancel()
@pytest.mark.asyncio
async def test_async_request_active_scan_default_duration_is_10s() -> None:
"""Calling without a duration uses DEFAULT_ON_DEMAND_SWEEP_DURATION (10s)."""
manager = get_manager()
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
async with _no_real_sleep():
await manager.async_request_active_scan()
assert scanner.active_window_calls == [DEFAULT_ON_DEMAND_SWEEP_DURATION]
finally:
register_cancel()
@pytest.mark.asyncio
async def test_async_request_active_scan_clamps_to_window_bounds() -> None:
"""Out-of-range durations are clamped to [MIN, MAX]."""
manager = get_manager()
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
async with _no_real_sleep():
await manager.async_request_active_scan(duration=0.5) # below MIN
await manager.async_request_active_scan(duration=999.0) # above MAX
assert scanner.active_window_calls == [
AUTO_WINDOW_MIN_DURATION,
AUTO_WINDOW_MAX_DURATION,
]
finally:
register_cancel()
@pytest.mark.asyncio
async def test_async_request_active_scan_rejects_invalid_duration() -> None:
"""NaN, inf, zero, and negative durations raise ValueError."""
manager = get_manager()
for bad in (float("nan"), float("inf"), float("-inf"), 0.0, -1.0):
with pytest.raises(ValueError, match="finite positive"):
await manager.async_request_active_scan(duration=bad)
@pytest.mark.asyncio
async def test_async_request_active_scan_no_op_when_scheduler_stopped() -> None:
"""After stop() the scheduler has no loop; the sweep returns immediately."""
manager = get_manager()
manager._auto_scheduler.stop()
await manager.async_request_active_scan(duration=5.0)
@pytest.mark.asyncio
async def test_async_request_active_scan_no_op_without_auto_scanners() -> None:
"""With no AUTO workers the sweep returns immediately, no sleep."""
manager = get_manager()
loop = asyncio.get_running_loop()
async def _fail_on_sleep(delay: float) -> None:
# Surface a regression as a clean assertion instead of a
# pytest-timeout: if the leader's sleep loop runs at all,
# fail now rather than spin / block on the patched sleep.
pytest.fail(f"async_request_active_scan slept for {delay}s on NOOP")
before = loop.time()
with patch("asyncio.sleep", new=_fail_on_sleep):
await manager.async_request_active_scan(duration=5.0)
# Bounded wall time confirms we did not block on the 5s duration.
assert loop.time() - before < 0.5
assert manager._auto_scheduler._on_demand_sweep_future is None
assert manager._auto_scheduler._on_demand_sweep_end == 0.0
@pytest.mark.asyncio
async def test_async_request_active_scan_no_op_when_all_scanners_connecting() -> None:
"""Every AUTO scanner mid-connect is the same NOOP; no sleep, fast-return."""
manager = get_manager()
loop = asyncio.get_running_loop()
busy_a = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
busy_b = _RecordingAutoScanner("AA:BB:CC:DD:EE:01", BluetoothScanningMode.AUTO)
c_a = manager.async_register_scanner(busy_a)
c_b = manager.async_register_scanner(busy_b)
busy_a._add_connecting("11:22:33:44:55:66")
busy_b._add_connecting("11:22:33:44:55:77")
async def _fail_on_sleep(delay: float) -> None:
pytest.fail(f"async_request_active_scan slept for {delay}s on NOOP")
try:
before = loop.time()
with patch("asyncio.sleep", new=_fail_on_sleep):
await manager.async_request_active_scan(duration=5.0)
assert busy_a.active_window_calls == []
assert busy_b.active_window_calls == []
assert loop.time() - before < 0.5
assert manager._auto_scheduler._on_demand_sweep_future is None
assert manager._auto_scheduler._on_demand_sweep_end == 0.0
finally:
busy_a._finished_connecting("11:22:33:44:55:66", connected=False)
busy_b._finished_connecting("11:22:33:44:55:77", connected=False)
c_a()
c_b()
@pytest.mark.asyncio
async def test_async_request_active_scan_awaits_the_full_duration() -> None:
"""
The sweep awaits ``duration`` so the caller can read advertisements.
Freezegun patches ``time.monotonic`` (and thus ``loop.time``);
advancing the frozen clock by the requested duration lets the
scheduler's internal ``asyncio.sleep`` complete and the task
finish. The scanner is registered inside the freeze so the
worker's ``_sweep_last_completed`` is anchored to the frozen
clock; the worker's background ``_run`` task is cancelled so it
cannot tick during the on-demand window.
"""
manager = get_manager()
sched = manager._auto_scheduler
with freeze_time() as frozen:
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
worker = sched._workers[scanner.source]
worker.stop()
await asyncio.sleep(0)
try:
task = asyncio.create_task(manager.async_request_active_scan(duration=5.0))
# Let the task start, flip the radio, and enter asyncio.sleep.
for _ in range(5):
await asyncio.sleep(0)
assert scanner.active_window_calls == [5.0]
assert not task.done()
# Advance past the sweep duration; the sleep wakes up.
frozen.tick(5.1)
await task
assert task.done()
finally:
register_cancel()
@pytest.mark.asyncio
async def test_async_request_active_scan_extension_reverts_end_when_no_targets() -> (
None
):
"""
A joiner extension that finds every worker busy reverts the end push.
Leader dispatches successfully and parks in its sleep loop. The
only scanner then goes mid-connect; the joiner's extension flip
has no eligible targets and returns False. The eager
``_on_demand_sweep_end`` push must be reverted to the leader's
original end so the leader does not sleep past the in-flight
radio window for nothing.
"""
manager = get_manager()
sched = manager._auto_scheduler
with freeze_time() as frozen:
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
worker = sched._workers[scanner.source]
worker.stop()
await asyncio.sleep(0)
try:
leader = asyncio.create_task(
manager.async_request_active_scan(duration=5.0)
)
for _ in range(5):
await asyncio.sleep(0)
assert scanner.active_window_calls == [5.0]
leader_end = sched._on_demand_sweep_end
# Mark the only worker busy before the joiner extension fires.
scanner._add_connecting("11:22:33:44:55:66")
try:
joiner = asyncio.create_task(
manager.async_request_active_scan(duration=20.0)
)
for _ in range(5):
await asyncio.sleep(0)
# Joiner's extension dispatched nothing; only the leader's
# flip is recorded and the eager push was reverted.
assert scanner.active_window_calls == [5.0]
assert sched._on_demand_sweep_end == leader_end
finally:
scanner._finished_connecting("11:22:33:44:55:66", connected=False)
# Advance past the leader's end; leader and joiner both wake.
frozen.tick(5.1)
await leader
await joiner
assert sched._on_demand_sweep_future is None
finally:
register_cancel()
@pytest.mark.asyncio
async def test_async_request_active_scan_logs_per_scanner_flip_failures(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A scanner whose flip raises is logged; the sweep still completes."""
manager = get_manager()
class _FailingScanner(_RecordingAutoScanner):
async def async_request_active_window(self, duration: float) -> bool:
msg = "boom"
raise RuntimeError(msg)
bad = _FailingScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
good = _RecordingAutoScanner("AA:BB:CC:DD:EE:01", BluetoothScanningMode.AUTO)
c_bad = manager.async_register_scanner(bad)
c_good = manager.async_register_scanner(good)
try:
with caplog.at_level(logging.WARNING, logger="habluetooth.auto_scheduler"):
async with _no_real_sleep():
await manager.async_request_active_scan(duration=5.0)
assert good.active_window_calls == [5.0]
assert any(
"on-demand active window" in record.message and "boom" in record.message
for record in caplog.records
)
finally:
c_bad()
c_good()
@pytest.mark.asyncio
async def test_async_request_active_scan_no_op_when_every_dispatch_declines() -> None:
"""
All-False / all-raise from dispatched scanners is the same NOOP as no targets.
The flip dispatches but every scanner declines or raises so no
radio window actually opens; the leader must skip the sleep loop
rather than block on a window that never opened.
"""
manager = get_manager()
loop = asyncio.get_running_loop()
class _DecliningScanner(_RecordingAutoScanner):
async def async_request_active_window(self, duration: float) -> bool:
self.active_window_calls.append(duration)
return False
class _FailingScanner(_RecordingAutoScanner):
async def async_request_active_window(self, duration: float) -> bool:
self.active_window_calls.append(duration)
msg = "boom"
raise RuntimeError(msg)
decliner = _DecliningScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
raiser = _FailingScanner("AA:BB:CC:DD:EE:01", BluetoothScanningMode.AUTO)
c_dec = manager.async_register_scanner(decliner)
c_raise = manager.async_register_scanner(raiser)
async def _fail_on_sleep(delay: float) -> None:
pytest.fail(f"async_request_active_scan slept for {delay}s on NOOP")
sched = manager._auto_scheduler
dec_worker = sched._workers[decliner.source]
raise_worker = sched._workers[raiser.source]
try:
before = loop.time()
with patch("asyncio.sleep", new=_fail_on_sleep):
await manager.async_request_active_scan(duration=5.0)
# Both scanners were dispatched to; neither opened a window.
assert decliner.active_window_calls == [5.0]
assert raiser.active_window_calls == [5.0]
assert loop.time() - before < 0.5
assert manager._auto_scheduler._on_demand_sweep_future is None
assert manager._auto_scheduler._on_demand_sweep_end == 0.0
# _window_end was pre-bumped for each worker but reverted post-result
# so the worker is not locked out of its own ticks. New workers start
# with _window_end == 0.0 and the flip should leave them there.
assert dec_worker._window_end == 0.0
assert raise_worker._window_end == 0.0
finally:
c_dec()
c_raise()
@pytest.mark.asyncio
async def test_async_request_active_scan_revert_skipped_on_concurrent_push() -> None:
"""A concurrent _window_end push past our bump is not clobbered on revert."""
manager = get_manager()
sched = manager._auto_scheduler
holder: list[Any] = []
class _MutatingScanner(_RecordingAutoScanner):
async def async_request_active_window(self, duration: float) -> bool:
self.active_window_calls.append(duration)
# Simulate a concurrent extension pushing _window_end out
# past the on-demand bump between pre-bump and result; the
# revert guard must observe the mismatch and leave it alone.
holder[0]._window_end = 1.0e12
return False
scanner = _MutatingScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
holder.append(sched._workers[scanner.source])
try:
async with _no_real_sleep():
await manager.async_request_active_scan(duration=5.0)
# The concurrent push survived our revert (guarded by exact
# equality on the value we set).
assert holder[0]._window_end == 1.0e12
finally:
register_cancel()
@pytest.mark.asyncio
async def test_async_request_active_scan_window_end_kept_for_succeeding() -> None:
"""
Mixed True/False results: the True scanner keeps its bumped _window_end.
A decliner runs alongside a scanner that returns True. The
decliner's pre-bumped _window_end is reverted while the True
scanner keeps the bump so its periodic worker tick stays
suppressed for the duration of the real radio window.
"""
manager = get_manager()
sched = manager._auto_scheduler
class _DecliningScanner(_RecordingAutoScanner):
async def async_request_active_window(self, duration: float) -> bool:
self.active_window_calls.append(duration)
return False
decliner = _DecliningScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
good = _RecordingAutoScanner("AA:BB:CC:DD:EE:01", BluetoothScanningMode.AUTO)
c_dec = manager.async_register_scanner(decliner)
c_good = manager.async_register_scanner(good)
dec_worker = sched._workers[decliner.source]
good_worker = sched._workers[good.source]
try:
async with _no_real_sleep():
await manager.async_request_active_scan(duration=5.0)
assert decliner.active_window_calls == [5.0]
assert good.active_window_calls == [5.0]
# Decliner reverted to 0.0; succeeding scanner retains the bump.
assert dec_worker._window_end == 0.0
assert good_worker._window_end > 0.0
finally:
c_dec()
c_good()
@pytest.mark.asyncio
async def test_async_request_active_scan_leader_honors_joiner_success_on_decline() -> (
None
):
"""
Leader's all-declined flip must not wipe state while a joiner has opened a window.
Sequence:
1. Leader flips X (eligible, blocks on its window call); Y is
mid-connect and skipped.
2. While leader is parked in its gather, Y finishes connecting
and a joiner arrives wanting a longer window. The joiner
extends ``_on_demand_sweep_end`` and re-flips; Y returns
True, the joiner does not revert, then parks on the shared
future.
3. X unblocks and returns False; the leader's flip therefore
returns False (only X was dispatched, X declined).
4. ``_on_demand_sweep_end`` was pushed past the leader's
``desired_end`` by the joiner, so the leader must NOT
fast-return: it falls through to the sleep loop and sleeps
until the joiner's end, otherwise the joiner is cut short
and the radio window is orphaned from scheduler bookkeeping.
"""
manager = get_manager()
sched = manager._auto_scheduler
with freeze_time() as frozen:
x = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
y = _RecordingAutoScanner("AA:BB:CC:DD:EE:01", BluetoothScanningMode.AUTO)
x._return_value = False
x._block_event = asyncio.Event()
c_x = manager.async_register_scanner(x)
c_y = manager.async_register_scanner(y)
wx = sched._workers[x.source]
wy = sched._workers[y.source]
# Silence each worker's own tick so it cannot pollute
# active_window_calls during the on-demand sweep.
wx.stop()
wy.stop()
await asyncio.sleep(0)
# Y starts mid-connect so the leader's flip skips it entirely.
y._add_connecting("11:22:33:44:55:66")
try:
leader = asyncio.create_task(
manager.async_request_active_scan(duration=5.0)
)
for _ in range(3):
await asyncio.sleep(0)
# Leader is now blocked inside X's flip; Y was skipped.
assert x.active_window_calls == [5.0]
assert y.active_window_calls == []
# Free Y so a joiner can dispatch it.
y._finished_connecting("11:22:33:44:55:66", connected=False)
joiner = asyncio.create_task(
manager.async_request_active_scan(duration=20.0)
)
for _ in range(3):
await asyncio.sleep(0)
# Joiner extended end and dispatched Y; Y opened a window.
assert y.active_window_calls == [20.0]
joiner_end = sched._on_demand_sweep_end
assert joiner_end > 0.0
# Unblock X; leader's flip resolves with all-declined.
x._block_event.set()
for _ in range(5):
await asyncio.sleep(0)
# Without the joiner-extension guard, the leader would
# have fast-returned and zeroed _on_demand_sweep_end /
# resolved the shared future. With the guard, both tasks
# are still parked and the end is intact.
assert not leader.done()
assert not joiner.done()
assert sched._on_demand_sweep_end == joiner_end
# Advance past the joiner's end; both tasks complete.
frozen.tick(20.1)
await leader
await joiner
assert sched._on_demand_sweep_future is None
finally:
c_x()
c_y()
@pytest.mark.asyncio
async def test_async_request_active_scan_joiner_cancel_during_extension() -> None:
"""
Joiner cancelled mid-extension still finishes the all-or-nothing re-flip.
Without ``asyncio.shield`` on the extension flip, a joiner
cancelled while extending would leave ``_on_demand_sweep_end``
pushed out past a partial re-flip, so the leader (and other
joiners) would sleep until the extended end believing every
scanner was active when some were not. With the shield, the
re-flip runs to completion; the scanner records both the
leader's original flip duration and the extension duration
even after the joiner is cancelled.
"""
manager = get_manager()
sched = manager._auto_scheduler
with freeze_time() as frozen:
# Register inside freeze + stop the worker so the worker's
# own periodic sweep does not fire and pollute the recorded
# active_window_calls.
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
worker = sched._workers[scanner.source]
worker.stop()
await asyncio.sleep(0)
try:
# Block scanner flips so the leader stays in its first
# flip await while the joiner extends and is cancelled.
scanner._block_event = asyncio.Event()
leader = asyncio.create_task(
manager.async_request_active_scan(duration=5.0)
)
for _ in range(3):
await asyncio.sleep(0)
joiner = asyncio.create_task(
manager.async_request_active_scan(duration=20.0)
)
# Yield enough for the joiner to enter its shielded
# extension flip (which is now also blocked on the
# scanner's block_event).
for _ in range(3):
await asyncio.sleep(0)
assert len(scanner.active_window_calls) == 2
joiner.cancel()
with contextlib.suppress(asyncio.CancelledError):
await joiner
# Release the scanner; leader's flip returns, joiner's
# shielded flip also completes as an orphan task.
scanner._block_event.set()
frozen.tick(20.1)
await leader
# Both leader's 5s flip and joiner's ~20s extension
# recorded despite the cancel.
assert scanner.active_window_calls[0] == 5.0
assert scanner.active_window_calls[1] == pytest.approx(20.0, abs=1.0)
assert sched._on_demand_sweep_future is None
finally:
register_cancel()
@pytest.mark.asyncio
async def test_async_request_active_scan_joiner_cancel_keeps_siblings() -> None:
"""
Cancelling one joiner must not cancel the shared future.
Without ``asyncio.shield`` on the joiner's await, a cancelled
joiner would cancel the underlying future, which then propagates
``CancelledError`` to sibling joiners and makes the leader's
``finally`` raise ``InvalidStateError`` on ``set_result``.
"""
manager = get_manager()
sched = manager._auto_scheduler
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
with freeze_time() as frozen:
scanner._block_event = asyncio.Event()
leader = asyncio.create_task(
manager.async_request_active_scan(duration=5.0)
)
joiner_a = asyncio.create_task(
manager.async_request_active_scan(duration=5.0)
)
joiner_b = asyncio.create_task(
manager.async_request_active_scan(duration=5.0)
)
for _ in range(5):
await asyncio.sleep(0)
# Cancel one joiner; siblings and leader must continue.
joiner_a.cancel()
with contextlib.suppress(asyncio.CancelledError):
await joiner_a
scanner._block_event.set()
frozen.tick(5.1)
# Leader and the surviving joiner both complete normally.
await leader
await joiner_b
assert joiner_b.result() is None
assert sched._on_demand_sweep_future is None
finally:
register_cancel()
@pytest.mark.asyncio
async def test_async_request_active_scan_leader_cancellation_releases_joiners() -> None:
"""
Cancelling the leader still resolves the future so joiners do not hang.
Joiners see ``None`` (no propagated ``CancelledError``) and benefit
from whatever radio activity already happened; a subsequent sweep can
run because ``_on_demand_sweep_future`` is cleared.
"""
manager = get_manager()
sched = manager._auto_scheduler
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
with freeze_time():
# Block the leader inside its scanner-flip await so we know
# we're past the gather and inside the leader's sleep when
# we cancel.
scanner._block_event = asyncio.Event()
leader = asyncio.create_task(
manager.async_request_active_scan(duration=5.0)
)
joiner = asyncio.create_task(
manager.async_request_active_scan(duration=5.0)
)
for _ in range(5):
await asyncio.sleep(0)
# Both tasks are now waiting; joiner has latched onto the
# leader's future.
assert not leader.done()
assert not joiner.done()
scanner._block_event.set()
leader.cancel()
with contextlib.suppress(asyncio.CancelledError):
await leader
# Joiner completes normally with no exception.
await joiner
assert joiner.result() is None
# Future is cleared so a fresh sweep can start.
assert sched._on_demand_sweep_future is None
finally:
register_cancel()
@pytest.mark.asyncio
async def test_async_request_active_scan_declined_does_not_advance_sweep_floor() -> (
None
):
"""A False flip leaves _sweep_last_completed alone so the periodic sweep retries."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
scanner._return_value = False
register_cancel = manager.async_register_scanner(scanner)
worker = sched._workers[scanner.source]
# Backdate so an erroneous bump would be visible.
original = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0
worker._sweep_last_completed = original
try:
async with _no_real_sleep():
await manager.async_request_active_scan(duration=5.0)
assert scanner.active_window_calls == [5.0]
assert worker._sweep_last_completed == original
finally:
register_cancel()
@pytest.mark.asyncio
async def test_async_request_active_scan_exception_does_not_advance_sweep_floor() -> (
None
):
"""A flip that raises also leaves the sweep floor alone for that scanner."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
class _FailingScanner(_RecordingAutoScanner):
async def async_request_active_window(self, duration: float) -> bool:
msg = "boom"
raise RuntimeError(msg)
bad = _FailingScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
good = _RecordingAutoScanner("AA:BB:CC:DD:EE:01", BluetoothScanningMode.AUTO)
c_bad = manager.async_register_scanner(bad)
c_good = manager.async_register_scanner(good)
bad_worker = sched._workers[bad.source]
good_worker = sched._workers[good.source]
original = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0
bad_worker._sweep_last_completed = original
good_worker._sweep_last_completed = original
try:
async with _no_real_sleep():
await manager.async_request_active_scan(duration=5.0)
# bad raised; floor stays put. good returned True; floor advances.
assert bad_worker._sweep_last_completed == original
assert good_worker._sweep_last_completed > original
finally:
c_bad()
c_good()
@pytest.mark.asyncio
async def test_async_request_active_scan_cancelled_flip_logged_distinctly(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A CancelledError result is logged as cancelled, not as a False decline."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
class _CancellingScanner(_RecordingAutoScanner):
async def async_request_active_window(self, duration: float) -> bool:
raise asyncio.CancelledError
cancelling = _CancellingScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(cancelling)
worker = sched._workers[cancelling.source]
original = loop.time() - AUTO_REDISCOVERY_INTERVAL - 1.0
worker._sweep_last_completed = original
try:
with caplog.at_level(logging.DEBUG, logger="habluetooth.auto_scheduler"):
async with _no_real_sleep():
await manager.async_request_active_scan(duration=5.0)
assert worker._sweep_last_completed == original
assert any(
"cancelled during on-demand active window" in record.message
for record in caplog.records
)
assert not any(
"declined on-demand active window" in record.message
for record in caplog.records
)
finally:
register_cancel()
@pytest.mark.asyncio
async def test_async_request_active_scan_does_not_shrink_existing_window_end() -> None:
"""A pre-existing longer _window_end is not shrunk by a new flip."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
worker = sched._workers[scanner.source]
pre_existing = loop.time() + 9999.0
worker._window_end = pre_existing
try:
async with _no_real_sleep():
await manager.async_request_active_scan(duration=5.0)
assert worker._window_end == pre_existing
finally:
register_cancel()
@pytest.mark.asyncio
async def test_async_request_active_scan_does_not_move_sweep_floor_backwards() -> None:
"""A pre-existing later _sweep_last_completed is not moved backwards."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
worker = sched._workers[scanner.source]
pre_existing = loop.time() + 9999.0
worker._sweep_last_completed = pre_existing
try:
async with _no_real_sleep():
await manager.async_request_active_scan(duration=5.0)
assert worker._sweep_last_completed == pre_existing
finally:
register_cancel()
@pytest.mark.asyncio
async def test_stop_resolves_in_flight_on_demand_sweep_future() -> None:
"""stop() resolves the future, frees joiners, leader's finally tolerates it."""
manager = get_manager()
sched = manager._auto_scheduler
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
worker = sched._workers[scanner.source]
worker.stop()
await asyncio.sleep(0)
try:
scanner._block_event = asyncio.Event()
leader = asyncio.create_task(manager.async_request_active_scan(duration=5.0))
joiner = asyncio.create_task(manager.async_request_active_scan(duration=5.0))
for _ in range(5):
await asyncio.sleep(0)
assert sched._on_demand_sweep_future is not None
assert not joiner.done()
sched.stop()
# Future resolved, state cleared, joiner wakes up to None.
assert manager._auto_scheduler._on_demand_sweep_future is None
assert manager._auto_scheduler._on_demand_sweep_end == 0.0
await joiner
assert joiner.result() is None
# Release the leader so its finally runs; the done() guard
# absorbs ``stop()``'s already-resolved future.
scanner._block_event.set()
await asyncio.wait_for(leader, timeout=1.0)
assert leader.result() is None
finally:
register_cancel()
@pytest.mark.asyncio
async def test_stop_is_safe_when_on_demand_future_already_done() -> None:
"""stop() does not raise InvalidStateError on an already-resolved future."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
done_future: asyncio.Future[None] = loop.create_future()
done_future.set_result(None)
sched._on_demand_sweep_future = done_future
sched.stop()
assert manager._auto_scheduler._on_demand_sweep_future is None
assert manager._auto_scheduler._on_demand_sweep_end == 0.0
@pytest.mark.asyncio
async def test_stop_is_safe_without_in_flight_on_demand_sweep() -> None:
"""``stop()`` with no active sweep is a no-op for the sweep state."""
manager = get_manager()
sched = manager._auto_scheduler
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
assert sched._on_demand_sweep_future is None
sched.stop()
assert manager._auto_scheduler._on_demand_sweep_future is None
assert manager._auto_scheduler._on_demand_sweep_end == 0.0
finally:
register_cancel()
@pytest.mark.asyncio
async def test_orphan_leader_does_not_clobber_fresh_sweep_state() -> None:
"""A leader orphaned by stop()+start() does not clear a fresh future."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
sched._workers[scanner.source].stop()
await asyncio.sleep(0)
try:
# Start L1 and block it inside its flip await.
scanner._block_event = asyncio.Event()
l1 = asyncio.create_task(manager.async_request_active_scan(duration=5.0))
for _ in range(5):
await asyncio.sleep(0)
assert sched._on_demand_sweep_future is not None
# Tear down and re-arm against the same loop, then install
# fresh sweep state as if L2 had won the next dedup check.
# Stop the freshly-spawned worker so its periodic tick does
# not interfere with the assertion below.
sched.stop()
sched.start(loop)
sched._workers[scanner.source].stop()
await asyncio.sleep(0)
fresh_future: asyncio.Future[None] = loop.create_future()
sched._on_demand_sweep_future = fresh_future
# Past-end so L1's sleep loop exits immediately and runs
# its finally; the fresh state below is what we check.
sched._on_demand_sweep_end = loop.time() - 1.0
# Release L1 so its finally runs; it must leave the fresh
# future alone (identity check on ``_on_demand_sweep_future``)
# and must not call ``set_result`` on the already-done L1
# future (the ``done()`` guard).
scanner._block_event.set()
await asyncio.wait_for(l1, timeout=1.0)
assert sched._on_demand_sweep_future is fresh_future
assert not fresh_future.done()
finally:
if not fresh_future.done():
fresh_future.set_result(None)
sched._on_demand_sweep_future = None
sched._on_demand_sweep_end = 0.0
register_cancel()
@pytest.mark.asyncio
async def test_owned_due_at_populated_for_owner_on_add_request_with_history() -> None:
"""add_request hooks the entry into the owner's _owned_due_at view."""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:66"
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
# First seed history via an advertisement so add_request's
# history-gating finds it.
_inject(scanner, address)
# Drop the entry add_request seeded so we can re-add through
# add_request and observe its _schedule.assign side-effect.
sched._schedule._due_at.pop(address, None)
sched._schedule._owner_by_address.pop(address, None)
sched._workers[scanner.source]._owned_due_at.pop(address, None)
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
try:
owned = sched._workers[scanner.source]._owned_due_at
assert address in owned
# Inner dict must be the SAME object aliased between
# _due_at and _owned_due_at so _advance_due mutations apply
# to both views.
assert owned[address] is sched._schedule._due_at[address]
assert sched._schedule._owner_by_address[address] == scanner.source
finally:
cancel()
finally:
register_cancel()
@pytest.mark.asyncio
async def test_add_request_without_history_leaves_owned_due_at_empty() -> None:
"""add_request without history defers seeding to on_advertisement."""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:66"
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
try:
assert address not in sched._schedule._due_at
assert address not in sched._schedule._owner_by_address
assert address not in sched._workers[scanner.source]._owned_due_at
finally:
cancel()
finally:
register_cancel()
@pytest.mark.asyncio
async def test_on_advertisement_bootstraps_owned_due_at() -> None:
"""First advertisement for a tracked address populates owner's view."""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:66"
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
try:
_inject(scanner, address)
owned = sched._workers[scanner.source]._owned_due_at
assert address in owned
assert owned[address] is sched._schedule._due_at[address]
assert sched._schedule._owner_by_address[address] == scanner.source
finally:
cancel()
finally:
register_cancel()
@pytest.mark.asyncio
async def test_ownership_flip_moves_entry_between_owned_due_at() -> None:
"""On migration, the entry moves from old owner's view to new owner's view."""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:99"
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
s_a = _RecordingAutoScanner("AA:00:00:00:00:01", BluetoothScanningMode.AUTO)
s_b = _RecordingAutoScanner("AA:00:00:00:00:02", BluetoothScanningMode.AUTO)
c_a = manager.async_register_scanner(s_a)
c_b = manager.async_register_scanner(s_b)
try:
_inject_with_rssi(s_a, address, rssi=-80)
worker_a = sched._workers[s_a.source]
worker_b = sched._workers[s_b.source]
assert address in worker_a._owned_due_at
assert address not in worker_b._owned_due_at
assert sched._schedule._owner_by_address[address] == s_a.source
# B with much stronger RSSI triggers ownership flip in the
# manager; scheduler's on_advertisement reassigns owner.
_inject_with_rssi(s_b, address, rssi=-30)
assert address not in worker_a._owned_due_at
assert address in worker_b._owned_due_at
assert worker_b._owned_due_at[address] is sched._schedule._due_at[address]
assert sched._schedule._owner_by_address[address] == s_b.source
finally:
c_a()
c_b()
cancel()
@pytest.mark.asyncio
async def test_remove_request_clears_owner_when_bucket_empties() -> None:
"""The last remove_request for an address clears owner and _owned_due_at."""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:66"
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
_inject(scanner, address)
worker = sched._workers[scanner.source]
assert address in worker._owned_due_at
cancel()
assert address not in sched._schedule._due_at
assert address not in sched._schedule._owner_by_address
assert address not in worker._owned_due_at
finally:
register_cancel()
@pytest.mark.asyncio
async def test_remove_request_preserves_owner_when_other_requests_remain() -> None:
"""Removing one of N requests on an address keeps the owner mapping intact."""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:66"
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
cancel1 = manager.async_register_active_scan(address, scan_interval=60.0)
cancel2 = manager.async_register_active_scan(address, scan_interval=120.0)
_inject(scanner, address)
worker = sched._workers[scanner.source]
assert len(sched._schedule._due_at[address]) == 2
cancel1()
assert address in sched._schedule._due_at
assert sched._schedule._owner_by_address[address] == scanner.source
assert address in worker._owned_due_at
cancel2()
assert address not in sched._schedule._due_at
assert address not in sched._schedule._owner_by_address
assert address not in worker._owned_due_at
finally:
register_cancel()
@pytest.mark.asyncio
async def test_remove_scanner_clears_owner_and_due_at() -> None:
"""Removing the owning scanner drops the entry from _due_at and the owner index."""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:66"
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
assert address in sched._schedule._due_at
assert sched._schedule._owner_by_address[address] == scanner.source
register_cancel()
assert address not in sched._schedule._due_at
assert address not in sched._schedule._owner_by_address
assert scanner.source not in sched._workers
finally:
cancel()
@pytest.mark.asyncio
async def test_stop_clears_all_owned_due_at_state() -> None:
"""stop() drains _due_at, _owner_by_address, and per-worker _owned_due_at."""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:66"
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
worker = sched._workers[scanner.source]
assert address in worker._owned_due_at
sched.stop()
assert sched._schedule._due_at == {}
assert sched._schedule._owner_by_address == {}
# Worker dropped from registry; the cleared dict is on the
# detached instance still held by the local. Confirm both.
assert sched._workers == {}
assert worker._owned_due_at == {}
finally:
cancel()
# register_cancel() may double-call but is idempotent on the
# manager side; guard against AttributeError nonetheless.
with contextlib.suppress(KeyError, ValueError):
register_cancel()
@pytest.mark.asyncio
async def test_start_replay_populates_owned_due_at() -> None:
"""A request registered before start() is hooked into the worker's view on start."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:66"
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
# Seed history via a real injection so the replay loop's
# last_service_info check sees a source.
_inject(scanner, address)
# Wipe scheduler state and stop the running scheduler so we
# can drive start() ourselves with pre-registered requests.
sched._workers[scanner.source].stop()
await asyncio.sleep(0)
sched.stop()
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
try:
# Pre-start: only _requests_by_address is populated.
assert address not in sched._schedule._due_at
assert address not in sched._schedule._owner_by_address
sched.start(loop)
try:
# After start, the replay seeded _due_at and assigned
# the owner.
worker = sched._workers[scanner.source]
assert address in sched._schedule._due_at
assert sched._schedule._owner_by_address[address] == scanner.source
assert address in worker._owned_due_at
assert worker._owned_due_at[address] is sched._schedule._due_at[address]
finally:
# Stop the spawned worker tasks so they do not leak.
for w in list(sched._workers.values()):
w.stop()
await asyncio.sleep(0)
finally:
cancel()
finally:
register_cancel()
@pytest.mark.asyncio
async def test_spawn_worker_picks_up_preassigned_owner() -> None:
"""A scanner registering after on_advertisement gets the entry hooked up."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:66"
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
source = "AA:BB:CC:DD:EE:00"
# Forge an owner mapping for an unregistered source — the same
# state that arises when on_advertisement seeds before its scanner
# registers as AUTO. We bypass the manager here because plumbing a
# synthetic history entry is more wiring than the invariant needs.
request = next(iter(sched._requests_by_address[address]))
sched._schedule._due_at[address] = {request: loop.time()}
sched._schedule._owner_by_address[address] = source
try:
scanner = _RecordingAutoScanner(source, BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
worker = sched._workers[source]
assert address in worker._owned_due_at
assert worker._owned_due_at[address] is sched._schedule._due_at[address]
finally:
register_cancel()
finally:
cancel()
@pytest.mark.asyncio
async def test_next_event_at_skips_other_workers_entries() -> None:
"""An entry owned by scanner A doesn't lower scanner B's next event."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:66"
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
s_a = _RecordingAutoScanner("AA:00:00:00:00:01", BluetoothScanningMode.AUTO)
s_b = _RecordingAutoScanner("AA:00:00:00:00:02", BluetoothScanningMode.AUTO)
c_a = manager.async_register_scanner(s_a)
c_b = manager.async_register_scanner(s_b)
try:
_inject(s_a, address)
# Drive the entry's due time well below A's sweep so any leak
# of foreign entries would change B's next_at.
entries = sched._schedule._due_at[address]
for req in list(entries):
entries[req] = loop.time() + 1.0
worker_b = sched._workers[s_b.source]
sweep_at_b = worker_b._sweep_last_completed + AUTO_REDISCOVERY_INTERVAL
assert worker_b._next_event_at(loop.time()) == sweep_at_b
finally:
c_a()
c_b()
cancel()
@pytest.mark.asyncio
async def test_next_event_at_makes_no_history_calls() -> None:
"""The hot path no longer pays a per-entry async_last_service_info lookup."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:66"
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
worker = sched._workers[scanner.source]
with patch.object(
manager, "async_last_service_info", side_effect=AssertionError
):
# Must not consult the manager's history; iterates owned
# entries exclusively.
worker._next_event_at(loop.time())
finally:
cancel()
register_cancel()
@pytest.mark.asyncio
async def test_collect_due_buckets_resyncs_owned_view_on_drift() -> None:
"""If owned view disagrees with manager history, _collect_due_buckets resyncs."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "11:22:33:44:55:66"
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
s_a = _RecordingAutoScanner("AA:00:00:00:00:01", BluetoothScanningMode.AUTO)
s_b = _RecordingAutoScanner("AA:00:00:00:00:02", BluetoothScanningMode.AUTO)
c_a = manager.async_register_scanner(s_a)
c_b = manager.async_register_scanner(s_b)
try:
_inject(s_a, address)
worker_a = sched._workers[s_a.source]
worker_b = sched._workers[s_b.source]
# Force a drift: manager's history says B owns the address,
# scheduler still has it parked under A's view. (Simulates
# any future code path that mutates history without notifying
# on_advertisement.)
info = manager.async_last_service_info(address, False)
assert info is not None
info.source = s_b.source
entries = sched._schedule._due_at[address]
for req in list(entries):
entries[req] = loop.time() - 1.0
await worker_a._tick()
# A skipped (foreign), reassigned ownership to B.
assert s_a.active_window_calls == []
assert address not in worker_a._owned_due_at
assert address in worker_b._owned_due_at
assert sched._schedule._owner_by_address[address] == s_b.source
finally:
c_a()
c_b()
cancel()
@pytest.mark.asyncio
async def test_assign_owner_noop_when_source_unchanged() -> None:
"""Repeated on_advertisement from the same scanner is a no-op."""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:66"
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
worker = sched._workers[scanner.source]
entries = sched._schedule._due_at[address]
owned_before = worker._owned_due_at[address]
# Second injection from the same scanner: owner unchanged,
# owned-view aliasing preserved (same dict object).
_inject(scanner, address)
assert sched._schedule._owner_by_address[address] == scanner.source
assert worker._owned_due_at[address] is owned_before
assert sched._schedule._due_at[address] is entries
finally:
cancel()
register_cancel()
@pytest.mark.asyncio
async def test_spawn_worker_skips_foreign_preassigned_owners() -> None:
"""_spawn_worker only attaches entries owned by the new scanner's source."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address_a = "11:22:33:44:55:66"
address_b = "77:88:99:AA:BB:CC"
cancel_a = manager.async_register_active_scan(address_a, scan_interval=60.0)
cancel_b = manager.async_register_active_scan(address_b, scan_interval=60.0)
foreign_source = "AA:BB:CC:DD:EE:FF"
request_b = next(iter(sched._requests_by_address[address_b]))
sched._schedule._due_at[address_b] = {request_b: loop.time()}
sched._schedule._owner_by_address[address_b] = foreign_source
try:
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
worker = sched._workers[scanner.source]
# Foreign-owned address must not appear in this worker's view.
assert address_b not in worker._owned_due_at
finally:
register_cancel()
finally:
cancel_a()
cancel_b()
@pytest.mark.asyncio
async def test_ownership_index_clear_source_no_match() -> None:
"""clear_source over an index with no addresses for the source is a no-op."""
manager = get_manager()
sched = manager._auto_scheduler
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
worker = sched._workers[scanner.source]
sched._schedule.clear_source(scanner.source)
assert sched._schedule._owner_by_address == {}
assert worker._owned_due_at == {}
finally:
register_cancel()
def test_ownership_index_clear_no_workers() -> None:
"""clear() over an index with no workers leaves state empty."""
workers: dict[str, Any] = {}
idx = _ScanSchedule(workers)
idx._owner_by_address["AA:00:00:00:00:99"] = "ghost"
idx.clear()
assert idx._owner_by_address == {}
@pytest.mark.asyncio
async def test_ownership_assign_records_non_auto_owner() -> None:
"""assign() records ownership for a non-AUTO source despite no worker."""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:66"
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
# ACTIVE scanner does not get an AUTO worker; advertising from it
# exercises the ``new_worker is None`` branch in assign().
passive = _RecordingAutoScanner("AA:BB:CC:DD:EE:01", BluetoothScanningMode.PASSIVE)
register_cancel = manager.async_register_scanner(passive)
try:
_inject(passive, address)
assert sched._schedule._owner_by_address[address] == passive.source
assert passive.source not in sched._workers
finally:
cancel()
register_cancel()
@pytest.mark.asyncio
async def test_remove_non_auto_scanner_clears_its_owned_addresses() -> None:
"""clear_source's non-AUTO fallback prunes addresses owned by a PASSIVE source."""
manager = get_manager()
sched = manager._auto_scheduler
own_addr = "11:22:33:44:55:66"
other_addr = "11:22:33:44:55:77"
cancel_own = manager.async_register_active_scan(own_addr, scan_interval=60.0)
cancel_other = manager.async_register_active_scan(other_addr, scan_interval=60.0)
passive = _RecordingAutoScanner("AA:BB:CC:DD:EE:01", BluetoothScanningMode.PASSIVE)
auto = _RecordingAutoScanner("AA:BB:CC:DD:EE:02", BluetoothScanningMode.AUTO)
c_passive = manager.async_register_scanner(passive)
c_auto = manager.async_register_scanner(auto)
try:
# PASSIVE owns own_addr; AUTO owns other_addr. Both end up in
# _owner_by_address, so the non-AUTO clear loop iterates past
# other_addr without matching.
_inject(passive, own_addr)
_inject(auto, other_addr)
assert sched._schedule._owner_by_address[own_addr] == passive.source
assert sched._schedule._owner_by_address[other_addr] == auto.source
c_passive()
assert own_addr not in sched._schedule._owner_by_address
assert own_addr not in sched._schedule._due_at
# The AUTO-owned address is untouched.
assert sched._schedule._owner_by_address[other_addr] == auto.source
finally:
cancel_own()
cancel_other()
c_auto()
@pytest.mark.asyncio
async def test_ownership_flip_from_non_auto_to_auto_owner() -> None:
"""assign() handles flipping from a non-AUTO owner whose worker is absent."""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:66"
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
passive = _RecordingAutoScanner("AA:BB:CC:DD:EE:01", BluetoothScanningMode.PASSIVE)
auto = _RecordingAutoScanner("AA:BB:CC:DD:EE:02", BluetoothScanningMode.AUTO)
c_passive = manager.async_register_scanner(passive)
c_auto = manager.async_register_scanner(auto)
try:
# PASSIVE first with weak RSSI; records owner mapping with no worker.
_inject_with_rssi(passive, address, rssi=-80)
assert sched._schedule._owner_by_address[address] == passive.source
# AUTO flip with stronger RSSI: old_source has no worker (PASSIVE),
# so the detach branch in assign() hits ``old_worker is None``.
_inject_with_rssi(auto, address, rssi=-30)
assert sched._schedule._owner_by_address[address] == auto.source
assert address in sched._workers[auto.source]._owned_due_at
finally:
cancel()
c_passive()
c_auto()
@pytest.mark.asyncio
async def test_invariant_through_full_lifecycle() -> None:
"""Schedule invariant holds at every step of a typical request lifecycle."""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:66"
_assert_schedule_invariant(sched)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
_assert_schedule_invariant(sched)
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
_assert_schedule_invariant(sched)
_inject(scanner, address)
_assert_schedule_invariant(sched)
assert address in sched._schedule._due_at
assert sched._schedule._owner_by_address[address] == scanner.source
cancel()
_assert_schedule_invariant(sched)
assert address not in sched._schedule._due_at
assert address not in sched._schedule._owner_by_address
register_cancel()
_assert_schedule_invariant(sched)
@pytest.mark.asyncio
async def test_invariant_through_ownership_flips() -> None:
"""Schedule invariant holds through a sequence of RSSI-driven flips."""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:66"
s_a = _RecordingAutoScanner("AA:00:00:00:00:01", BluetoothScanningMode.AUTO)
s_b = _RecordingAutoScanner("AA:00:00:00:00:02", BluetoothScanningMode.AUTO)
s_c = _RecordingAutoScanner("AA:00:00:00:00:03", BluetoothScanningMode.AUTO)
c_a = manager.async_register_scanner(s_a)
c_b = manager.async_register_scanner(s_b)
c_c = manager.async_register_scanner(s_c)
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
try:
_assert_schedule_invariant(sched)
# Each step is a >= 40 dB jump (monotonically stronger) so the
# manager flips ownership on every inject.
for scanner, rssi in (
(s_a, -100),
(s_b, -60),
(s_c, -20),
):
_inject_with_rssi(scanner, address, rssi=rssi)
_assert_schedule_invariant(sched)
assert sched._schedule._owner_by_address[address] == scanner.source
# Same scanner re-advertises: same owner; invariant holds.
_inject_with_rssi(s_c, address, rssi=-15)
_assert_schedule_invariant(sched)
assert sched._schedule._owner_by_address[address] == s_c.source
finally:
cancel()
c_a()
c_b()
c_c()
_assert_schedule_invariant(sched)
@pytest.mark.asyncio
async def test_invariant_through_mixed_mode_flips() -> None:
"""
Schedule invariant holds when ownership flips across all scanner modes.
Mix of ACTIVE, PASSIVE, and AUTO scanners; ownership migrates among
them as RSSI climbs. Only the AUTO scanner has a worker, so the
invariant exercises both the "owner has worker" and "owner has no
worker" branches in the same scenario.
"""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:66"
active = _RecordingAutoScanner("AA:00:00:00:00:0A", BluetoothScanningMode.ACTIVE)
passive = _RecordingAutoScanner("AA:00:00:00:00:0B", BluetoothScanningMode.PASSIVE)
auto = _RecordingAutoScanner("AA:00:00:00:00:0C", BluetoothScanningMode.AUTO)
c_active = manager.async_register_scanner(active)
c_passive = manager.async_register_scanner(passive)
c_auto = manager.async_register_scanner(auto)
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
try:
_assert_schedule_invariant(sched)
# Workers exist only for AUTO scanners.
assert active.source not in sched._workers
assert passive.source not in sched._workers
assert auto.source in sched._workers
auto_worker = sched._workers[auto.source]
# ACTIVE first (no worker for the owner; address is in
# _owner_by_address but not in any worker's _owned_due_at).
_inject_with_rssi(active, address, rssi=-100)
_assert_schedule_invariant(sched)
assert sched._schedule._owner_by_address[address] == active.source
assert address not in auto_worker._owned_due_at
# PASSIVE with stronger RSSI: another non-AUTO owner.
_inject_with_rssi(passive, address, rssi=-60)
_assert_schedule_invariant(sched)
assert sched._schedule._owner_by_address[address] == passive.source
assert address not in auto_worker._owned_due_at
# AUTO with stronger RSSI: ownership flips to the worker-having
# source; the address now appears in the worker's owned view.
_inject_with_rssi(auto, address, rssi=-20)
_assert_schedule_invariant(sched)
assert sched._schedule._owner_by_address[address] == auto.source
assert address in auto_worker._owned_due_at
# Flip BACK to PASSIVE by unregistering the AUTO scanner: its
# owned-view entry is pruned via clear_source. The address goes
# away from the schedule entirely (no other scanner advertised
# since the AUTO claim).
c_auto()
_assert_schedule_invariant(sched)
assert address not in sched._schedule._owner_by_address
# A subsequent PASSIVE advertisement re-bootstraps ownership;
# back to non-AUTO-owned state without a worker view.
_inject_with_rssi(passive, address, rssi=-50)
_assert_schedule_invariant(sched)
assert sched._schedule._owner_by_address[address] == passive.source
finally:
cancel()
c_active()
c_passive()
_assert_schedule_invariant(sched)
@pytest.mark.asyncio
async def test_invariant_through_orphan_prune() -> None:
"""Schedule invariant holds after a tick prunes an aged-out address."""
manager = get_manager()
sched = manager._auto_scheduler
loop = asyncio.get_running_loop()
address = "AA:BB:CC:DD:EE:FF"
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
_inject(scanner, address)
_assert_schedule_invariant(sched)
# Force the entry due so the tick actually inspects history.
request = next(iter(sched._schedule._due_at[address]))
sched._schedule._due_at[address][request] = loop.time() - 1.0
# History ages out under the worker's feet; the tick's orphan
# branch should clean every index in lock step.
manager._all_history.pop(address, None)
manager._connectable_history.pop(address, None)
await _run_worker_tick(sched, scanner.source)
_assert_schedule_invariant(sched)
assert address not in sched._schedule._due_at
assert address not in sched._schedule._owner_by_address
finally:
cancel()
register_cancel()
@pytest.mark.asyncio
async def test_invariant_through_many_addresses_and_scanners() -> None:
"""Schedule invariant holds when many scanners and addresses interleave."""
manager = get_manager()
sched = manager._auto_scheduler
scanners = [
_RecordingAutoScanner(f"AA:00:00:00:00:{i:02X}", BluetoothScanningMode.AUTO)
for i in range(4)
]
register_cancels = [manager.async_register_scanner(s) for s in scanners]
cancels = [
manager.async_register_active_scan(
f"BB:00:00:00:00:{i:02X}", scan_interval=60.0
)
for i in range(16)
]
addresses = [f"BB:00:00:00:00:{i:02X}" for i in range(16)]
try:
_assert_schedule_invariant(sched)
# Round-robin each address to a scanner, ramping RSSI by 30 dB
# per round so every later inject flips ownership.
for round_ix, scanner in enumerate(scanners):
rssi = -100 + 30 * round_ix
for address in addresses:
_inject_with_rssi(scanner, address, rssi=rssi)
_assert_schedule_invariant(sched)
# All addresses now owned by the last scanner (strongest RSSI).
last = scanners[-1]
for address in addresses:
assert sched._schedule._owner_by_address[address] == last.source
# Removing the owner scanner prunes the addresses it owned;
# the schedule cleans them out without surfacing them anywhere.
register_cancels[-1]()
_assert_schedule_invariant(sched)
for address in addresses:
assert address not in sched._schedule._due_at
finally:
for c in cancels:
c()
for rc in register_cancels[:-1]:
rc()
_assert_schedule_invariant(sched)
@pytest.mark.asyncio
async def test_unown_fails_fast_on_missing_due_at() -> None:
"""Pin the fail-fast contract: KeyError if ``_due_at`` lacks the address."""
manager = get_manager()
sched = manager._auto_scheduler
sched._schedule._owner_by_address["AA:00:00:00:00:99"] = "ghost"
try:
with pytest.raises(KeyError):
sched._schedule.unown("AA:00:00:00:00:99")
finally:
sched._schedule._owner_by_address.pop("AA:00:00:00:00:99", None)
@pytest.mark.asyncio
async def test_unown_fails_fast_on_missing_owner() -> None:
"""Pin the fail-fast contract: KeyError if ``_owner_by_address`` lacks address."""
manager = get_manager()
sched = manager._auto_scheduler
sched._schedule._due_at["AA:00:00:00:00:99"] = {}
try:
with pytest.raises(KeyError):
sched._schedule.unown("AA:00:00:00:00:99")
finally:
sched._schedule._due_at.pop("AA:00:00:00:00:99", None)
@pytest.mark.asyncio
async def test_detach_owned_fails_fast_on_missing_address() -> None:
"""Pin the fail-fast contract: KeyError if worker doesn't own the address."""
manager = get_manager()
sched = manager._auto_scheduler
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
worker = sched._workers[scanner.source]
with pytest.raises(KeyError):
worker._detach_owned("AA:00:00:00:00:99")
finally:
register_cancel()
@pytest.mark.asyncio
async def test_next_event_at_fails_fast_on_empty_owned_bucket() -> None:
"""Pin the fail-fast contract: ValueError on an empty owned bucket."""
manager = get_manager()
sched = manager._auto_scheduler
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
try:
worker = sched._workers[scanner.source]
worker._owned_due_at["AA:00:00:00:00:99"] = {}
try:
with pytest.raises(ValueError, match="min"):
worker._next_event_at(asyncio.get_running_loop().time())
finally:
worker._owned_due_at.pop("AA:00:00:00:00:99", None)
finally:
register_cancel()
@pytest.mark.asyncio
async def test_invariant_through_mode_switches() -> None:
"""
Schedule invariant holds across in-place scanner mode switches.
HA's UI mode switch is implemented as unregister-old + register-new
with the same source. The scheduler has to (a) drop the worker when
AUTO leaves, (b) spawn a fresh worker when AUTO arrives, and (c)
keep ``_requests_by_address`` intact across the gap so the new
worker can be re-bootstrapped. Walk through every transition with
invariant checks at each step.
"""
manager = get_manager()
sched = manager._auto_scheduler
source = "AA:BB:CC:DD:EE:32"
address = "11:22:33:44:55:99"
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
_assert_schedule_invariant(sched)
try:
# 1. PASSIVE first (entering as non-AUTO).
passive = _RecordingAutoScanner(source, BluetoothScanningMode.PASSIVE)
c_passive = manager.async_register_scanner(passive)
_assert_schedule_invariant(sched)
assert source not in sched._workers
_inject(passive, address)
_assert_schedule_invariant(sched)
assert sched._schedule._owner_by_address[address] == source
# 2. Switch INTO AUTO: unregister PASSIVE, register AUTO at same source.
c_passive()
_assert_schedule_invariant(sched)
# PASSIVE's clear_source removed the owner mapping.
assert address not in sched._schedule._owner_by_address
# User's registration survived.
assert address in sched._requests_by_address
auto = _RecordingAutoScanner(source, BluetoothScanningMode.AUTO)
c_auto = manager.async_register_scanner(auto)
_assert_schedule_invariant(sched)
assert source in sched._workers
# 3. AUTO sees the device, becomes owner with a worker attached.
_inject(auto, address)
_assert_schedule_invariant(sched)
assert sched._schedule._owner_by_address[address] == source
assert address in sched._workers[source]._owned_due_at
# 4. Switch OUT of AUTO: unregister AUTO, register ACTIVE same source.
c_auto()
_assert_schedule_invariant(sched)
assert source not in sched._workers
assert address not in sched._schedule._owner_by_address
active = _RecordingAutoScanner(source, BluetoothScanningMode.ACTIVE)
c_active = manager.async_register_scanner(active)
_assert_schedule_invariant(sched)
assert source not in sched._workers
# 5. ACTIVE sees the device; back to a non-AUTO-owned state.
_inject(active, address)
_assert_schedule_invariant(sched)
assert sched._schedule._owner_by_address[address] == source
c_active()
_assert_schedule_invariant(sched)
finally:
cancel()
_assert_schedule_invariant(sched)
@pytest.mark.asyncio
async def test_invariant_through_stop_and_restart() -> None:
"""Schedule invariant holds across stop + start replay."""
manager = get_manager()
sched = manager._auto_scheduler
address = "11:22:33:44:55:66"
scanner = _RecordingAutoScanner("AA:BB:CC:DD:EE:00", BluetoothScanningMode.AUTO)
register_cancel = manager.async_register_scanner(scanner)
cancel = manager.async_register_active_scan(address, scan_interval=60.0)
try:
_inject(scanner, address)
_assert_schedule_invariant(sched)
sched.stop()
_assert_schedule_invariant(sched)
assert sched._schedule._due_at == {}
assert sched._schedule._owner_by_address == {}
# Give the previously running worker task a chance to fully exit
# so the post-restart spawn doesn't share its source slot.
await asyncio.sleep(0)
loop = asyncio.get_running_loop()
sched.start(loop)
_assert_schedule_invariant(sched)
# start() replayed the request and re-assigned via history.
assert address in sched._schedule._due_at
assert sched._schedule._owner_by_address[address] == scanner.source
finally:
cancel()
register_cancel()
_assert_schedule_invariant(sched)
Bluetooth-Devices-habluetooth-75cbe37/tests/test_base_scanner.py 0000664 0000000 0000000 00000143412 15211177045 0025202 0 ustar 00root root 0000000 0000000 """Tests for the Bluetooth base scanner models."""
from __future__ import annotations
import asyncio
import time
from datetime import timedelta
from typing import Any
from unittest.mock import ANY, MagicMock, Mock, patch
import pytest
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from bleak_retry_connector import Allocations
from bluetooth_data_tools import monotonic_time_coarse
from habluetooth import (
BaseHaRemoteScanner,
BaseHaScanner,
BluetoothManager,
BluetoothScannerDevice,
BluetoothScanningMode,
HaBluetoothConnector,
HaScannerDetails,
HaScannerModeChange,
HaScannerType,
get_manager,
set_manager,
)
from habluetooth.const import (
CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT,
)
from habluetooth.storage import (
DiscoveredDeviceAdvertisementData,
)
from . import (
HCI0_SOURCE_ADDRESS,
MockBleakClient,
async_fire_time_changed,
generate_advertisement_data,
generate_ble_device,
patch_bluetooth_time,
utcnow,
)
from . import (
InjectableRemoteScanner as FakeScanner,
)
from .conftest import FakeBluetoothAdapters, MockBluetoothManagerWithCallbacks
@pytest.mark.parametrize("name_2", [None, "w"])
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_remote_scanner(name_2: str | None) -> None:
"""Test the remote scanner base class merges advertisement_data."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={1: b"\x01"},
rssi=-100,
)
switchbot_device_2 = generate_ble_device(
"44:44:33:11:23:45",
name_2,
{},
rssi=-100,
)
switchbot_device_adv_2 = generate_advertisement_data(
local_name=name_2,
service_uuids=["00000001-0000-1000-8000-00805f9b34fb"],
service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={1: b"\x01", 2: b"\x02"},
rssi=-100,
)
switchbot_device_3 = generate_ble_device(
"44:44:33:11:23:45",
"wohandlonger",
{},
rssi=-100,
)
switchbot_device_adv_3 = generate_advertisement_data(
local_name="wohandlonger",
service_uuids=["00000001-0000-1000-8000-00805f9b34fb"],
service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={1: b"\x01", 2: b"\x02"},
rssi=-100,
)
switchbot_device_adv_4 = generate_advertisement_data(
local_name="wohandlonger",
service_uuids=["00000001-0000-1000-8000-00805f9b34fb"],
service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={1: b"\x04", 2: b"\x02", 3: b"\x03"},
rssi=-100,
)
switchbot_device_adv_5 = generate_advertisement_data(
local_name="wohandlonger",
service_uuids=["00000001-0000-1000-8000-00805f9b34fb"],
service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={1: b"\x04", 2: b"\x01"},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
details = scanner.details
assert details == HaScannerDetails(
source=scanner.source,
connectable=scanner.connectable,
name=scanner.name,
adapter=scanner.adapter,
scanner_type=HaScannerType.REMOTE,
)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
scanner.inject_advertisement(switchbot_device, switchbot_device_adv)
data = scanner.discovered_devices_and_advertisement_data
discovered_device, discovered_adv_data = data[switchbot_device.address]
assert discovered_device.address == switchbot_device.address
assert discovered_device.name == switchbot_device.name
assert (
discovered_adv_data.manufacturer_data == switchbot_device_adv.manufacturer_data
)
assert discovered_adv_data.service_data == switchbot_device_adv.service_data
assert discovered_adv_data.service_uuids == switchbot_device_adv.service_uuids
scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_2)
data = scanner.discovered_devices_and_advertisement_data
discovered_device, discovered_adv_data = data[switchbot_device.address]
assert discovered_device.address == switchbot_device.address
assert discovered_device.name == switchbot_device.name
assert discovered_adv_data.manufacturer_data == {1: b"\x01", 2: b"\x02"}
assert discovered_adv_data.service_data == {
"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff",
"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff",
}
assert set(discovered_adv_data.service_uuids) == {
"050a021a-0000-1000-8000-00805f9b34fb",
"00000001-0000-1000-8000-00805f9b34fb",
}
# The longer name should be used
scanner.inject_advertisement(switchbot_device_3, switchbot_device_adv_3)
assert discovered_device.name == switchbot_device_3.name
# Inject the shorter name / None again to make
# sure we always keep the longer name
scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_2)
assert discovered_device.name == switchbot_device_3.name
scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_4)
assert scanner.discovered_devices_and_advertisement_data[
switchbot_device_2.address
][1].manufacturer_data == {1: b"\x04", 2: b"\x02", 3: b"\x03"}
scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_5)
assert scanner.discovered_devices_and_advertisement_data[
switchbot_device_2.address
][1].manufacturer_data == {1: b"\x04", 2: b"\x01", 3: b"\x03"}
assert (
"00090401-0052-036b-3206-ff0a050a021a"
not in scanner.discovered_devices_and_advertisement_data[
switchbot_device_2.address
][1].service_data
)
scanner.inject_raw_advertisement(
switchbot_device_2.address,
0,
b"\x12\x21\x1a\x02\n\x05\n\xff\x062k\x03R\x00\x01\x04\t\x00\x04",
)
assert (
"00090401-0052-036b-3206-ff0a050a021a"
in scanner.discovered_devices_and_advertisement_data[
switchbot_device_2.address
][1].service_data
)
assert scanner.serialize_discovered_devices() == DiscoveredDeviceAdvertisementData(
connectable=True,
expire_seconds=195,
discovered_device_advertisement_datas={"44:44:33:11:23:45": ANY},
discovered_device_timestamps={"44:44:33:11:23:45": ANY},
discovered_device_raw={
"44:44:33:11:23:45": b"\x12!\x1a\x02"
b"\n\x05\n\xff"
b"\x062k\x03"
b"R\x00\x01\x04"
b"\t\x00\x04"
},
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_raw_advertisement_fast_path_unchanged() -> None:
"""Test that sending the same raw bytes twice uses the fast path."""
manager = get_manager()
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
raw_adv = b"\x12\x21\x1a\x02\n\x05\n\xff\x062k\x03R\x00\x01\x04\t\x00\x04"
address = "44:44:33:11:23:45"
# First raw advertisement — takes the slow path (parse + merge)
scanner.inject_raw_advertisement(address, -60, raw_adv, 1.0)
info1 = scanner._previous_service_info[address]
assert info1.rssi == -60
assert info1.raw == raw_adv
# Second raw advertisement with same bytes — takes the fast path
scanner.inject_raw_advertisement(address, -50, raw_adv, 2.0)
info2 = scanner._previous_service_info[address]
assert info2.rssi == -50
assert info2.time == 2.0
# Fast path reuses parsed data from prev_info
assert info2.manufacturer_data is info1.manufacturer_data
assert info2.service_data is info1.service_data
assert info2.service_uuids is info1.service_uuids
assert info2.name is info1.name
assert info2.device is info1.device
assert info2.raw is info1.raw
# Third raw advertisement with different bytes — takes the slow path
raw_adv_changed = b"\x12\x21\x1a\x02\n\x05\n\xff\x062k\x03R\x00\x01\x04\t\x00\x05"
scanner.inject_raw_advertisement(address, -40, raw_adv_changed, 3.0)
info3 = scanner._previous_service_info[address]
assert info3.rssi == -40
assert info3.raw == raw_adv_changed
# Slow path re-parsed, so objects may differ
assert info3.raw is not info1.raw
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_remote_scanner_expires_connectable() -> None:
"""Test the remote scanner expires stale connectable data."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=[],
manufacturer_data={1: b"\x01"},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
start_time_monotonic = time.monotonic()
scanner.inject_advertisement(switchbot_device, switchbot_device_adv)
devices = scanner.discovered_devices
assert len(scanner.discovered_devices) == 1
assert len(scanner.discovered_devices_and_advertisement_data) == 1
assert devices[0].name == "wohand"
expire_monotonic = (
start_time_monotonic
+ CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
+ 1
)
expire_utc = utcnow() + timedelta(
seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1
)
with patch_bluetooth_time(expire_monotonic):
async_fire_time_changed(expire_utc)
await asyncio.sleep(0)
devices = scanner.discovered_devices
assert len(scanner.discovered_devices) == 0
assert len(scanner.discovered_devices_and_advertisement_data) == 0
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_remote_scanner_expires_non_connectable() -> None:
"""Test the remote scanner expires stale non connectable data."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=[],
manufacturer_data={1: b"\x01"},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
start_time_monotonic = time.monotonic()
scanner.inject_advertisement(switchbot_device, switchbot_device_adv)
devices = scanner.discovered_devices
assert len(scanner.discovered_devices) == 1
assert len(scanner.discovered_devices_and_advertisement_data) == 1
assert len(scanner.discovered_device_timestamps) == 1
dev_adv = scanner.get_discovered_device_advertisement_data(switchbot_device.address)
assert dev_adv is not None
dev, adv = dev_adv
assert dev.name == "wohand"
assert adv.local_name == "wohand"
assert adv.manufacturer_data == switchbot_device_adv.manufacturer_data
assert devices[0].name == "wohand"
assert (
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
> CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
)
# The connectable timeout is used for all devices
# as the manager takes care of availability and the scanner
# if only concerned about making a connection
expire_monotonic = (
start_time_monotonic
+ CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
+ 1
)
expire_utc = utcnow() + timedelta(
seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1
)
with patch_bluetooth_time(expire_monotonic):
async_fire_time_changed(expire_utc)
await asyncio.sleep(0)
assert len(scanner.discovered_devices) == 0
assert len(scanner.discovered_devices_and_advertisement_data) == 0
assert len(scanner.discovered_device_timestamps) == 0
assert (
scanner.get_discovered_device_advertisement_data(switchbot_device.address)
is None
)
expire_monotonic = (
start_time_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1
)
expire_utc = utcnow() + timedelta(
seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1
)
with patch_bluetooth_time(expire_monotonic):
async_fire_time_changed(expire_utc)
await asyncio.sleep(0)
assert len(scanner.discovered_devices) == 0
assert len(scanner.discovered_devices_and_advertisement_data) == 0
assert len(scanner.discovered_device_timestamps) == 0
assert (
scanner.get_discovered_device_advertisement_data(switchbot_device.address)
is None
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_remote_scanner_discovered_device_timestamps_deprecated() -> None:
"""The leading-underscore shim still returns timestamps but warns."""
manager = get_manager()
switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand", {}, rssi=-100)
switchbot_device_adv = generate_advertisement_data(
local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-100
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
scanner.inject_advertisement(switchbot_device, switchbot_device_adv)
with pytest.warns(
FutureWarning, match=r"BaseHaScanner\._discovered_device_timestamps"
):
legacy = scanner._discovered_device_timestamps
assert legacy == scanner.discovered_device_timestamps
assert len(legacy) == 1
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_base_scanner_connecting_behavior() -> None:
"""Test the default behavior is to mark the scanner as not scanning on connect."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=[],
manufacturer_data={1: b"\x01"},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
with scanner.connecting():
assert scanner.scanning is False
# We should still accept new advertisements while connecting
# since advertisements are delivered asynchronously and
# we don't want to miss any even when we are willing to
# accept advertisements from another scanner in the brief window
# between when we start connecting and when we stop scanning
scanner.inject_advertisement(switchbot_device, switchbot_device_adv)
devices = scanner.discovered_devices
assert len(scanner.discovered_devices) == 1
assert len(scanner.discovered_devices_and_advertisement_data) == 1
assert devices[0].name == "wohand"
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_scanner_stops_responding() -> None:
"""Test we mark a scanner are not scanning when it stops responding."""
manager = get_manager()
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner(
"esp32",
"esp32",
connector,
True,
current_mode=BluetoothScanningMode.ACTIVE,
requested_mode=BluetoothScanningMode.ACTIVE,
)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
start_time_monotonic = time.monotonic()
assert scanner.scanning is True
failure_reached_time = (
start_time_monotonic
+ SCANNER_WATCHDOG_TIMEOUT
+ SCANNER_WATCHDOG_INTERVAL.total_seconds()
)
# We hit the timer with no detections,
# so we reset the adapter and restart the scanner
with patch_bluetooth_time(failure_reached_time):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert scanner.scanning is False
bparasite_device = generate_ble_device( # type: ignore[unreachable]
"44:44:33:11:23:45",
"bparasite",
{},
rssi=-100,
)
bparasite_device_adv = generate_advertisement_data(
local_name="bparasite",
service_uuids=[],
manufacturer_data={1: b"\x01"},
rssi=-100,
)
failure_reached_time += 1
with patch_bluetooth_time(failure_reached_time):
scanner.inject_advertisement(
bparasite_device, bparasite_device_adv, failure_reached_time
)
# As soon as we get a detection, we know the scanner is working again
assert scanner.scanning is True
assert scanner.requested_mode == BluetoothScanningMode.ACTIVE
assert scanner.current_mode == BluetoothScanningMode.ACTIVE
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_merge_manufacturer_data_history_existing() -> None:
"""Test merging manufacturer data history."""
manager = get_manager()
sensor_push_device = generate_ble_device(
"44:44:33:11:23:45",
"",
{},
rssi=-60,
)
sensor_push_device_adv = generate_advertisement_data(
local_name="",
rssi=-60,
manufacturer_data={
64256: b"B\r.\xa9\xb6",
31488: b"\x98\xfa\xb6\x91\xb6",
},
service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
service_data={},
)
sensor_push_adv_2 = generate_advertisement_data(
local_name="",
service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
service_data={},
manufacturer_data={
31488: b"\x98\xfa\xb6\x91\xb6",
},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
details = scanner.details
assert details == HaScannerDetails(
source=scanner.source,
connectable=scanner.connectable,
name=scanner.name,
adapter=scanner.adapter,
scanner_type=HaScannerType.REMOTE,
)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
scanner.inject_advertisement(sensor_push_device, sensor_push_device_adv)
data = scanner.discovered_devices_and_advertisement_data
discovered_device, discovered_adv_data = data[sensor_push_device.address]
assert discovered_device.address == sensor_push_device.address
assert discovered_device.name == sensor_push_device.name
assert (
discovered_adv_data.manufacturer_data
== sensor_push_device_adv.manufacturer_data
)
assert discovered_adv_data.service_data == sensor_push_device_adv.service_data
assert discovered_adv_data.service_uuids == sensor_push_device_adv.service_uuids
scanner.inject_advertisement(sensor_push_device, sensor_push_adv_2)
data = scanner.discovered_devices_and_advertisement_data
discovered_device, discovered_adv_data = data[sensor_push_device.address]
assert discovered_device.address == sensor_push_device.address
assert discovered_device.name == sensor_push_device.name
assert discovered_adv_data.manufacturer_data == {
**sensor_push_device_adv.manufacturer_data,
**sensor_push_adv_2.manufacturer_data,
}
assert discovered_adv_data.service_data == {}
assert set(discovered_adv_data.service_uuids) == {
*sensor_push_device_adv.service_uuids
}
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_merge_manufacturer_data_history_new() -> None:
"""Test merging manufacturer data history."""
manager = get_manager()
sensor_push_device = generate_ble_device(
"44:44:33:11:23:45",
"",
{},
rssi=-60,
)
sensor_push_device_adv = generate_advertisement_data(
local_name="",
rssi=-60,
manufacturer_data={
64256: b"B\r.\xa9\xb6",
31488: b"\x98\xfa\xb6\x91\xb6",
},
service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
service_data={},
)
sensor_push_adv_2 = generate_advertisement_data(
local_name="",
service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
service_data={},
manufacturer_data={
21248: b"\xb9\xe9\xe1\xb9\xb6",
},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
details = scanner.details
assert details == HaScannerDetails(
source=scanner.source,
connectable=scanner.connectable,
name=scanner.name,
adapter=scanner.adapter,
scanner_type=HaScannerType.REMOTE,
)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
scanner.inject_advertisement(sensor_push_device, sensor_push_device_adv)
data = scanner.discovered_devices_and_advertisement_data
discovered_device, discovered_adv_data = data[sensor_push_device.address]
assert discovered_device.address == sensor_push_device.address
assert discovered_device.name == sensor_push_device.name
assert (
discovered_adv_data.manufacturer_data
== sensor_push_device_adv.manufacturer_data
)
assert discovered_adv_data.service_data == sensor_push_device_adv.service_data
assert discovered_adv_data.service_uuids == sensor_push_device_adv.service_uuids
scanner.inject_advertisement(sensor_push_device, sensor_push_adv_2)
data = scanner.discovered_devices_and_advertisement_data
discovered_device, discovered_adv_data = data[sensor_push_device.address]
assert discovered_device.address == sensor_push_device.address
assert discovered_device.name == sensor_push_device.name
assert discovered_adv_data.manufacturer_data == {
**sensor_push_device_adv.manufacturer_data,
**sensor_push_adv_2.manufacturer_data,
}
assert discovered_adv_data.service_data == {}
assert set(discovered_adv_data.service_uuids) == {
*sensor_push_device_adv.service_uuids
}
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_filter_apple_data() -> None:
"""Test filtering apple data accepts bytes that start with 01."""
manager = get_manager()
device = generate_ble_device(
"44:44:33:11:23:45",
"",
{},
rssi=-60,
)
device_adv = generate_advertisement_data(
local_name="",
rssi=-60,
manufacturer_data={
76: b"\x01\r.\xa9\xb6",
},
service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
service_data={},
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
details = scanner.details
assert details == HaScannerDetails(
source=scanner.source,
connectable=scanner.connectable,
name=scanner.name,
adapter=scanner.adapter,
scanner_type=HaScannerType.REMOTE,
)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
scanner.inject_advertisement(device, device_adv)
data = scanner.discovered_devices_and_advertisement_data
discovered_device, discovered_adv_data = data[device.address]
assert discovered_device.address == device.address
assert discovered_device.name == device.name
assert discovered_adv_data.manufacturer_data == device_adv.manufacturer_data
unsetup()
cancel()
@pytest.mark.usefixtures("register_hci0_scanner")
def test_connection_history_count_in_progress() -> None:
"""Test connection history in process counting."""
manager = get_manager()
device1_address = "44:44:33:11:23:12"
device2_address = "44:44:33:11:23:13"
hci0_scanner = manager.async_scanner_by_source(HCI0_SOURCE_ADDRESS)
assert hci0_scanner is not None
hci0_scanner._add_connecting(device1_address)
assert hci0_scanner._connections_in_progress() == 1
hci0_scanner._add_connecting(device1_address)
hci0_scanner._add_connecting(device2_address)
assert hci0_scanner._connections_in_progress() == 3
hci0_scanner._finished_connecting(device1_address, True)
assert hci0_scanner._connections_in_progress() == 2
hci0_scanner._finished_connecting(device1_address, False)
assert hci0_scanner._connections_in_progress() == 1
hci0_scanner._finished_connecting(device2_address, False)
assert hci0_scanner._connections_in_progress() == 0
@pytest.mark.usefixtures("register_hci0_scanner")
def test_connection_history_failure_count(caplog: pytest.LogCaptureFixture) -> None:
"""Test connection history failure count."""
manager = get_manager()
device1_address = "44:44:33:11:23:12"
device2_address = "44:44:33:11:23:13"
hci0_scanner = manager.async_scanner_by_source(HCI0_SOURCE_ADDRESS)
assert hci0_scanner is not None
hci0_scanner._add_connecting(device1_address)
hci0_scanner._finished_connecting(device1_address, False)
assert hci0_scanner._connection_failures(device1_address) == 1
hci0_scanner._add_connecting(device1_address)
hci0_scanner._add_connecting(device2_address)
hci0_scanner._finished_connecting(device1_address, False)
assert hci0_scanner._connection_failures(device1_address) == 2
hci0_scanner._finished_connecting(device2_address, False)
assert hci0_scanner._connection_failures(device2_address) == 1
hci0_scanner._add_connecting(device1_address)
hci0_scanner._finished_connecting(device1_address, True)
# On success, we should reset the failure count
assert hci0_scanner._connection_failures(device1_address) == 0
assert "Removing a non-existing connecting" not in caplog.text
hci0_scanner._finished_connecting(device1_address, True)
assert "Removing a non-existing connecting" in caplog.text
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_scanner_mode_changes() -> None:
"""Test scanner mode change methods notify the manager."""
manager = get_manager()
# Track mode changes
mode_changes: list[HaScannerModeChange] = []
def mode_callback(change: HaScannerModeChange) -> None:
"""Track mode changes."""
mode_changes.append(change)
cancel = manager.async_register_scanner_mode_change_callback(mode_callback, None)
# Create a scanner with initial modes
scanner = FakeScanner(
HCI0_SOURCE_ADDRESS,
"hci0",
connectable=True,
requested_mode=BluetoothScanningMode.PASSIVE,
current_mode=BluetoothScanningMode.PASSIVE,
)
# Set up the scanner
unsetup = scanner.async_setup()
# Test changing requested mode
scanner.set_requested_mode(BluetoothScanningMode.ACTIVE)
assert len(mode_changes) == 1
assert mode_changes[0].scanner == scanner
assert mode_changes[0].requested_mode == BluetoothScanningMode.ACTIVE
assert mode_changes[0].current_mode == BluetoothScanningMode.PASSIVE
assert scanner.requested_mode == BluetoothScanningMode.ACTIVE
# Test changing current mode
scanner.set_current_mode(BluetoothScanningMode.ACTIVE)
assert len(mode_changes) == 2
assert mode_changes[1].scanner == scanner
assert mode_changes[1].requested_mode == BluetoothScanningMode.ACTIVE
assert mode_changes[1].current_mode == BluetoothScanningMode.ACTIVE
assert scanner.current_mode == BluetoothScanningMode.ACTIVE
# Test no notification when mode doesn't change
scanner.set_current_mode(BluetoothScanningMode.ACTIVE)
assert len(mode_changes) == 2 # No new notification
# Test setting to None
scanner.set_requested_mode(None)
assert len(mode_changes) == 3
assert mode_changes[2].requested_mode is None
assert scanner.requested_mode is None
scanner.set_current_mode(None) # type: ignore[unreachable]
assert len(mode_changes) == 4
assert mode_changes[3].current_mode is None
assert scanner.current_mode is None
# Clean up
unsetup()
cancel()
def test_remote_scanner_type() -> None:
"""Test that remote scanners have REMOTE type."""
class TestRemoteScanner(BaseHaRemoteScanner):
"""Test remote scanner implementation."""
scanner = TestRemoteScanner("test_source", "test_adapter")
assert scanner.details.scanner_type is HaScannerType.REMOTE
def test_base_scanner_with_connector() -> None:
"""Test BaseHaScanner with connector and adapter type."""
manager = get_manager()
mock_adapters: dict[str, dict[str, Any]] = {
"test_adapter": {
"address": "00:1A:7D:DA:71:04",
"adapter_type": "usb",
}
}
connector = HaBluetoothConnector(
client=MagicMock, source="test_source", can_connect=lambda: True
)
with patch.object(manager, "_adapters", mock_adapters):
scanner = BaseHaScanner(
source="test_source",
adapter="test_adapter",
connector=connector,
connectable=True,
)
assert scanner.details.scanner_type is HaScannerType.USB
class TestScanner(BaseHaScanner):
"""Test scanner without slots for mocking."""
__test__ = False
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._test_allocations = None
def get_allocations(self):
"""Override to return test allocations."""
return self._test_allocations
def test_score_with_no_allocations():
"""Test scoring when no allocation info is available."""
scanner = TestScanner(
source="test_source",
adapter="test_adapter",
connectable=True,
)
ble_device = BLEDevice(
address="00:11:22:33:44:55",
name="Test Device",
details={},
)
advertisement = AdvertisementData(
local_name="Test Device",
manufacturer_data={},
service_data={},
service_uuids=[],
rssi=-50,
platform_data=(),
tx_power=None,
)
scanner_device = BluetoothScannerDevice(
scanner=scanner,
ble_device=ble_device,
advertisement=advertisement,
)
# No allocation info available, should just use RSSI
score = scanner._score_connection_paths(10, scanner_device)
assert score == -50
def test_score_with_all_slots_free():
"""Test scoring when all slots are free."""
scanner = TestScanner(
source="test_source",
adapter="test_adapter",
connectable=True,
)
# Set test allocations to return all slots free
scanner._test_allocations = Allocations(
adapter="test_adapter",
slots=5,
free=5,
allocated=[],
)
ble_device = BLEDevice(
address="00:11:22:33:44:55",
name="Test Device",
details={},
)
advertisement = AdvertisementData(
local_name="Test Device",
manufacturer_data={},
service_data={},
service_uuids=[],
rssi=-50,
platform_data=(),
tx_power=None,
)
scanner_device = BluetoothScannerDevice(
scanner=scanner,
ble_device=ble_device,
advertisement=advertisement,
)
# All slots free, no penalty
score = scanner._score_connection_paths(10, scanner_device)
assert score == -50
def test_score_with_one_slot_remaining():
"""Test scoring when only one slot remains."""
scanner = TestScanner(
source="test_source",
adapter="test_adapter",
connectable=True,
)
# Set test allocations to return only 1 slot free
scanner._test_allocations = Allocations(
adapter="test_adapter",
slots=5,
free=1,
allocated=[
"AA:BB:CC:DD:EE:01",
"AA:BB:CC:DD:EE:02",
"AA:BB:CC:DD:EE:03",
"AA:BB:CC:DD:EE:04",
],
)
ble_device = BLEDevice(
address="00:11:22:33:44:55",
name="Test Device",
details={},
)
advertisement = AdvertisementData(
local_name="Test Device",
manufacturer_data={},
service_data={},
service_uuids=[],
rssi=-50,
platform_data=(),
tx_power=None,
)
scanner_device = BluetoothScannerDevice(
scanner=scanner,
ble_device=ble_device,
advertisement=advertisement,
)
# One slot remaining, small penalty (rssi_diff * 0.76)
score = scanner._score_connection_paths(10, scanner_device)
assert score == -50 - (10 * 0.76)
assert score == -57.6
def test_score_with_no_slots_available():
"""Test scoring when no slots are available."""
scanner = TestScanner(
source="test_source",
adapter="test_adapter",
connectable=True,
)
# Set test allocations to return no slots free
scanner._test_allocations = Allocations(
adapter="test_adapter",
slots=5,
free=0,
allocated=[
"AA:BB:CC:DD:EE:01",
"AA:BB:CC:DD:EE:02",
"AA:BB:CC:DD:EE:03",
"AA:BB:CC:DD:EE:04",
"AA:BB:CC:DD:EE:05",
],
)
ble_device = BLEDevice(
address="00:11:22:33:44:55",
name="Test Device",
details={},
)
advertisement = AdvertisementData(
local_name="Test Device",
manufacturer_data={},
service_data={},
service_uuids=[],
rssi=-50,
platform_data=(),
tx_power=None,
)
scanner_device = BluetoothScannerDevice(
scanner=scanner,
ble_device=ble_device,
advertisement=advertisement,
)
# No slots available, returns NO_RSSI_VALUE (-127)
score = scanner._score_connection_paths(10, scanner_device)
assert score == -127
def test_score_comparison_with_different_slot_availability():
"""Test that scanners with more free slots score better."""
# Scanner with all slots free
scanner_all_free = TestScanner(
source="adapter1",
adapter="adapter1",
connectable=True,
)
# Scanner with one slot remaining
scanner_one_slot = TestScanner(
source="adapter2",
adapter="adapter2",
connectable=True,
)
# Scanner with no slots
scanner_no_slots = TestScanner(
source="adapter3",
adapter="adapter3",
connectable=True,
)
ble_device = BLEDevice(
address="00:11:22:33:44:55",
name="Test Device",
details={},
)
advertisement = AdvertisementData(
local_name="Test Device",
manufacturer_data={},
service_data={},
service_uuids=[],
rssi=-50,
platform_data=(),
tx_power=None,
)
# Create scanner devices for each scanner
device_all_free = BluetoothScannerDevice(
scanner=scanner_all_free,
ble_device=ble_device,
advertisement=advertisement,
)
device_one_slot = BluetoothScannerDevice(
scanner=scanner_one_slot,
ble_device=ble_device,
advertisement=advertisement,
)
device_no_slots = BluetoothScannerDevice(
scanner=scanner_no_slots,
ble_device=ble_device,
advertisement=advertisement,
)
# Set allocations for each scanner
scanner_all_free._test_allocations = Allocations(
adapter="adapter1",
slots=5,
free=5,
allocated=[],
)
scanner_one_slot._test_allocations = Allocations(
adapter="adapter2",
slots=5,
free=1,
allocated=[
"AA:BB:CC:DD:EE:01",
"AA:BB:CC:DD:EE:02",
"AA:BB:CC:DD:EE:03",
"AA:BB:CC:DD:EE:04",
],
)
scanner_no_slots._test_allocations = Allocations(
adapter="adapter3",
slots=5,
free=0,
allocated=[
"AA:BB:CC:DD:EE:01",
"AA:BB:CC:DD:EE:02",
"AA:BB:CC:DD:EE:03",
"AA:BB:CC:DD:EE:04",
"AA:BB:CC:DD:EE:05",
],
)
score_all_free = scanner_all_free._score_connection_paths(10, device_all_free)
score_one_slot = scanner_one_slot._score_connection_paths(10, device_one_slot)
score_no_slots = scanner_no_slots._score_connection_paths(10, device_no_slots)
# Verify the scoring order: all_free > one_slot > no_slots
assert score_all_free > score_one_slot
assert score_one_slot > score_no_slots
# Verify specific values
assert score_all_free == -50
assert score_one_slot == -57.6
assert score_no_slots == -127 # NO_RSSI_VALUE
def test_score_with_connections_in_progress_and_slots():
"""Test that both connection progress and slot availability are considered."""
scanner = TestScanner(
source="test_source",
adapter="test_adapter",
connectable=True,
)
# Add a connection in progress
scanner._add_connecting("FF:EE:DD:CC:BB:AA")
# Set test allocations to return only 1 slot free
scanner._test_allocations = Allocations(
adapter="test_adapter",
slots=5,
free=1,
allocated=[
"AA:BB:CC:DD:EE:01",
"AA:BB:CC:DD:EE:02",
"AA:BB:CC:DD:EE:03",
"AA:BB:CC:DD:EE:04",
],
)
ble_device = BLEDevice(
address="00:11:22:33:44:55",
name="Test Device",
details={},
)
advertisement = AdvertisementData(
local_name="Test Device",
manufacturer_data={},
service_data={},
service_uuids=[],
rssi=-50,
platform_data=(),
tx_power=None,
)
scanner_device = BluetoothScannerDevice(
scanner=scanner,
ble_device=ble_device,
advertisement=advertisement,
)
# Both penalties should apply
score = scanner._score_connection_paths(10, scanner_device)
# -50 (RSSI) - 10.1 (connection in progress) - 7.6 (last slot)
assert score == -50 - 10.1 - 7.6
assert score == -67.7
@pytest.mark.asyncio
async def test_on_scanner_start_callback_remote_scanner(
async_mock_manager_with_scanner_callbacks: MockBluetoothManagerWithCallbacks,
) -> None:
"""Test that on_scanner_start is called when a remote scanner starts."""
manager = async_mock_manager_with_scanner_callbacks
# Create a fake remote scanner
scanner = FakeScanner(
source="esp32_proxy",
adapter="esp32_proxy",
connector=None,
connectable=True,
)
# Simulate scanner start success
scanner._on_start_success()
# Verify the callback was called
assert len(manager.scanner_start_calls) == 1
assert manager.scanner_start_calls[0] is scanner
@pytest.mark.asyncio
async def test_on_scanner_start_multiple_scanners(
async_mock_manager_with_scanner_callbacks: MockBluetoothManagerWithCallbacks,
) -> None:
"""Test that on_scanner_start is called for multiple scanners."""
manager = async_mock_manager_with_scanner_callbacks
# Create multiple scanners
scanner1 = FakeScanner(
source="scanner1",
adapter="scanner1",
connector=None,
connectable=True,
)
scanner2 = FakeScanner(
source="scanner2",
adapter="scanner2",
connector=None,
connectable=True,
)
# Simulate both scanners starting
scanner1._on_start_success()
scanner2._on_start_success()
# Verify both callbacks were called
assert len(manager.scanner_start_calls) == 2
assert scanner1 in manager.scanner_start_calls
assert scanner2 in manager.scanner_start_calls
@pytest.mark.asyncio
async def test_scanner_without_manager() -> None:
"""Test that _on_start_success handles scanner without manager gracefully."""
# Set a temporary manager for scanner creation
mock_bluetooth_adapters = FakeBluetoothAdapters()
temp_manager = BluetoothManager(
mock_bluetooth_adapters,
slot_manager=Mock(),
)
original_manager = get_manager()
set_manager(temp_manager)
try:
# Create a scanner
scanner = FakeScanner(
source="test",
adapter="test",
connector=None,
connectable=True,
)
# Clear the manager to simulate no manager scenario
scanner._manager = None # type: ignore[assignment]
# Should not raise an exception
scanner._on_start_success()
finally:
# Restore original manager
set_manager(original_manager)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_remote_scanner_restore_discovered_devices() -> None:
"""Test serialize→restore round-trip on a BaseHaRemoteScanner."""
manager = get_manager()
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
# First scanner: receives advertisements, then serializes.
src_scanner = FakeScanner("esp32", "esp32", connector, True)
src_unsetup = src_scanner.async_setup()
src_cancel = manager.async_register_scanner(src_scanner)
address = "44:44:33:11:23:45"
device = generate_ble_device(address, "wohand", {})
adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={1: b"\x01"},
rssi=-60,
tx_power=4,
)
now = monotonic_time_coarse()
src_scanner.inject_advertisement(device, adv, now=now)
raw_adv = b"\x12\x21\x1a\x02\n\x05\n\xff\x062k\x03R\x00\x01\x04\t\x00\x04"
src_scanner.inject_raw_advertisement(address, -55, raw_adv, now=now + 1.0)
history = src_scanner.serialize_discovered_devices()
assert history.discovered_device_raw[address] == raw_adv
assert history.discovered_device_timestamps[address] == now + 1.0
# Second scanner: fresh instance, restore from serialized blob.
dst_scanner = FakeScanner("esp32-replay", "esp32-replay", connector, True)
dst_unsetup = dst_scanner.async_setup()
dst_cancel = manager.async_register_scanner(dst_scanner)
assert dst_scanner.discovered_devices == []
dst_scanner.restore_discovered_devices(history)
restored = dst_scanner.discovered_devices_and_advertisement_data
assert set(restored) == {address}
rest_device, rest_adv = restored[address]
assert rest_device.address == address
assert rest_device.name == "wohand"
assert rest_adv.manufacturer_data == adv.manufacturer_data
assert rest_adv.service_uuids == adv.service_uuids
# Restored source overrides the previous scanner's source — the device
# belongs to the scanner that owns it now.
assert dst_scanner._previous_service_info[address].source == "esp32-replay"
# Raw advertisement bytes survive the round-trip.
assert dst_scanner._previous_service_info[address].raw == raw_adv
src_cancel()
src_unsetup()
dst_cancel()
dst_unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_remote_scanner_async_diagnostics() -> None:
"""Test BaseHaRemoteScanner.async_diagnostics shape."""
manager = get_manager()
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = FakeScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
address = "44:44:33:11:23:45"
device = generate_ble_device(address, "wohand", {})
adv = generate_advertisement_data(
local_name="wohand",
service_uuids=[],
service_data={},
manufacturer_data={1: b"\x01"},
rssi=-60,
)
now = monotonic_time_coarse()
scanner.inject_advertisement(device, adv, now=now)
raw = b"\x02\x01\x06"
scanner.inject_raw_advertisement(address, -55, raw, now=now + 1.0)
diagnostics = await scanner.async_diagnostics()
# Remote scanner adds these three keys on top of the base diagnostics.
assert diagnostics["raw_advertisement_data"] == {address: raw}
assert diagnostics["discovered_device_timestamps"] == {address: now + 1.0}
assert set(diagnostics["time_since_last_device_detection"]) == {address}
# Base keys still present.
assert diagnostics["source"] == "esp32"
assert diagnostics["connectable"] is True
# Connection counters start empty.
assert diagnostics["connect_in_progress"] == {}
assert diagnostics["connect_failures"] == {}
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_diagnostics_surface_connect_state() -> None:
"""async_diagnostics should expose connect-in-progress and failure counts."""
manager = get_manager()
scanner = FakeScanner(
source="diag", adapter="diag", connector=None, connectable=True
)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
addr_a = "AA:BB:CC:DD:EE:01"
addr_b = "AA:BB:CC:DD:EE:02"
# Two attempts in flight for addr_a, one for addr_b.
scanner._add_connecting(addr_a)
scanner._add_connecting(addr_a)
scanner._add_connecting(addr_b)
# addr_a then succeeds (clears its failures), addr_b fails twice.
scanner._finished_connecting(addr_a, connected=True)
scanner._finished_connecting(addr_b, connected=False)
scanner._add_connecting(addr_b)
scanner._finished_connecting(addr_b, connected=False)
diagnostics = await scanner.async_diagnostics()
# addr_a still has one in-flight attempt, addr_b is fully drained.
assert diagnostics["connect_in_progress"] == {addr_a: 1}
assert diagnostics["connect_failures"] == {addr_b: 2}
# Lifetime counters: one success (addr_a), two failures (addr_b twice).
assert diagnostics["connect_completed_total"] == 1
assert diagnostics["connect_failed_total"] == 2
assert diagnostics["last_connect_completed_time"] > 0.0
# Returned dicts must be copies — mutating them doesn't poison scanner state.
diagnostics["connect_in_progress"].clear()
diagnostics["connect_failures"].clear()
assert scanner._connect_in_progress == {addr_a: 1}
assert scanner._connect_failures == {addr_b: 2}
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_lifetime_connect_counters_reset_on_history_clear() -> None:
"""Lifetime counters should reset whenever connection history is cleared."""
manager = get_manager()
scanner = FakeScanner(
source="counters", adapter="counters", connector=None, connectable=True
)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
addr = "AA:BB:CC:DD:EE:99"
# Three successes, one failure → totals reflect lifetime, not per-address state.
for _ in range(3):
scanner._add_connecting(addr)
scanner._finished_connecting(addr, connected=True)
scanner._add_connecting(addr)
scanner._finished_connecting(addr, connected=False)
assert scanner._connect_completed_total == 3
assert scanner._connect_failed_total == 1
assert scanner._last_connect_completed_time > 0.0
# Per-address failure count survives because success preceded the last failure.
assert scanner._connect_failures == {addr: 1}
scanner._clear_connection_history()
assert scanner._connect_completed_total == 0
assert scanner._connect_failed_total == 0
assert scanner._last_connect_completed_time == 0.0
assert scanner._connect_failures == {}
assert scanner._connect_in_progress == {}
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_set_mode_noop_when_unchanged() -> None:
"""set_requested_mode and set_current_mode should be no-ops on same value."""
manager = get_manager()
scanner = FakeScanner(
source="modes",
adapter="modes",
connector=None,
connectable=True,
requested_mode=BluetoothScanningMode.ACTIVE,
)
with patch.object(manager, "scanner_mode_changed") as notify:
# Same value → setter is a no-op, no notify.
scanner.set_requested_mode(BluetoothScanningMode.ACTIVE)
notify.assert_not_called()
# Different value → notify fires once.
scanner.set_requested_mode(BluetoothScanningMode.PASSIVE)
assert notify.call_count == 1
# current_mode was None at init; setting None is a no-op.
scanner.set_current_mode(None)
assert notify.call_count == 1
# Different value → notify fires again.
scanner.set_current_mode(BluetoothScanningMode.PASSIVE)
assert notify.call_count == 2
# Repeating same value is a no-op.
scanner.set_current_mode(BluetoothScanningMode.PASSIVE)
assert notify.call_count == 2
Bluetooth-Devices-habluetooth-75cbe37/tests/test_benchmark_auto_scheduler.py 0000664 0000000 0000000 00000022702 15211177045 0027575 0 ustar 00root root 0000000 0000000 """Benchmarks for the auto-scan scheduler hot paths."""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING
import pytest
from habluetooth import (
BaseHaScanner,
BluetoothScanningMode,
get_manager,
)
from . import generate_advertisement_data, generate_ble_device
if TYPE_CHECKING:
from collections.abc import Iterable
from bleak.backends.scanner import AdvertisementData, BLEDevice
from pytest_codspeed import BenchmarkFixture
from habluetooth.const import CALLBACK_TYPE
pytestmark = pytest.mark.timeout(60)
class _AutoScanner(BaseHaScanner):
"""Minimal AUTO-mode scanner that exposes nothing to the discovery cache."""
# Mirrors the _RecordingAutoScanner used by test_auto_scheduler.py but
# without window-call tracking; the scheduler hot paths under benchmark
# never enter the scanner's active-window path.
__slots__ = ()
def __init__(self, source: str) -> None:
super().__init__(source, source, requested_mode=BluetoothScanningMode.AUTO)
self.connectable = True
async def async_request_active_window(self, duration: float) -> bool:
return True
@property
def discovered_devices(self) -> list[BLEDevice]:
return []
@property
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
return {}
def get_discovered_device_advertisement_data(
self, address: str
) -> tuple[BLEDevice, AdvertisementData] | None:
return None
@property
def discovered_addresses(self) -> Iterable[str]:
return ()
def _make_address(i: int) -> str:
return f"AA:BB:CC:{(i >> 16) & 0xFF:02X}:{(i >> 8) & 0xFF:02X}:{i & 0xFF:02X}"
def _make_source(i: int) -> str:
# Distinct source MACs so each scanner registers as its own worker.
return f"DD:EE:FF:{(i >> 16) & 0xFF:02X}:{(i >> 8) & 0xFF:02X}:{i & 0xFF:02X}"
def _inject(scanner: _AutoScanner, address: str, now: float) -> None:
"""Drive a fake advertisement through the scanner's normal entry point."""
adv = generate_advertisement_data(local_name="x")
device = generate_ble_device(address, "x")
scanner._async_on_advertisement(
device.address,
adv.rssi,
device.name or "",
adv.service_uuids,
adv.service_data,
adv.manufacturer_data,
adv.tx_power,
{},
now,
)
def _setup_scheduler(
num_scanners: int, num_devices: int
) -> tuple[list[_AutoScanner], list[CALLBACK_TYPE], list[CALLBACK_TYPE]]:
"""
Register ``num_scanners`` AUTO scanners and ``num_devices`` scan requests.
Each address is owned by exactly one scanner via a round-robin
advertisement injection that populates manager history and
auto_scheduler._due_at (and the per-worker _owned_due_at view on
branches that have it).
"""
manager = get_manager()
loop = asyncio.get_running_loop()
now = loop.time()
scanners: list[_AutoScanner] = []
scanner_cancels: list[CALLBACK_TYPE] = []
for i in range(num_scanners):
scanner = _AutoScanner(_make_source(i))
scanners.append(scanner)
scanner_cancels.append(manager.async_register_scanner(scanner))
request_cancels: list[CALLBACK_TYPE] = []
for i in range(num_devices):
address = _make_address(i)
request_cancels.append(
manager.async_register_active_scan(address, scan_interval=120.0)
)
# Inject through the round-robin owner so manager history points
# back to that scanner's source. The scheduler picks ownership
# from history.source on both the old and new code paths.
_inject(scanners[i % num_scanners], address, now)
return scanners, scanner_cancels, request_cancels
def _teardown_scheduler(
scanner_cancels: list[CALLBACK_TYPE],
request_cancels: list[CALLBACK_TYPE],
) -> None:
"""Release scanner and active-scan registrations from ``_setup_scheduler``."""
for cancel in request_cancels:
cancel()
for cancel in scanner_cancels:
cancel()
@pytest.mark.asyncio
async def test_next_event_at_single_worker_8_scanners_200_devices(
benchmark: BenchmarkFixture,
) -> None:
"""
One worker computing its next wake among 8 scanners and 200 tracked devices.
Prior to the per-worker owned-needs optimization (PR #508 / issue #506),
every wake iterated the global ``_due_at`` map (200 entries) and called
``async_last_service_info`` on each to filter by ownership. The
optimization narrows the iteration to the ~25 entries the worker owns
and removes the per-entry history lookup. This benchmark exercises the
single-worker hot path so any regression in ``_next_event_at`` cost
shows up immediately.
"""
_, scanner_cancels, request_cancels = _setup_scheduler(
num_scanners=8, num_devices=200
)
manager = get_manager()
scheduler = manager._auto_scheduler
worker = next(iter(scheduler._workers.values()))
loop = asyncio.get_running_loop()
@benchmark
def run() -> None:
worker._next_event_at(loop.time())
_teardown_scheduler(scanner_cancels, request_cancels)
@pytest.mark.asyncio
async def test_next_event_at_burst_8_scanners_200_devices(
benchmark: BenchmarkFixture,
) -> None:
"""
All 8 workers compute their next wake — the burst scenario from issue #506.
When an advertisement burst wakes every worker, the old code did
O(K·N) work (K=8 workers each scanning N=200 entries). The
optimization makes the total work O(N) because each worker only
visits its owned subset. This benchmark captures the headline win.
"""
_, scanner_cancels, request_cancels = _setup_scheduler(
num_scanners=8, num_devices=200
)
manager = get_manager()
scheduler = manager._auto_scheduler
workers = list(scheduler._workers.values())
loop = asyncio.get_running_loop()
@benchmark
def run() -> None:
now = loop.time()
for worker in workers:
worker._next_event_at(now)
_teardown_scheduler(scanner_cancels, request_cancels)
@pytest.mark.asyncio
async def test_collect_due_buckets_single_worker_8_scanners_200_devices(
benchmark: BenchmarkFixture,
) -> None:
"""
One worker collecting due buckets among 8 scanners and 200 devices.
``_collect_due_buckets`` shares the same iteration-scope problem as
``_next_event_at``: pre-#508 it iterated the global ``_due_at`` and
called ``async_last_service_info`` on every address to skip foreign
owners; post-#508 it iterates the per-worker owned view directly.
With entries scheduled well into the future, this exercises the
no-dispatch read path that runs on every tick.
"""
_, scanner_cancels, request_cancels = _setup_scheduler(
num_scanners=8, num_devices=200
)
manager = get_manager()
scheduler = manager._auto_scheduler
worker = next(iter(scheduler._workers.values()))
loop = asyncio.get_running_loop()
@benchmark
def run() -> None:
worker._collect_due_buckets(loop.time())
_teardown_scheduler(scanner_cancels, request_cancels)
@pytest.mark.asyncio
async def test_collect_due_buckets_burst_8_scanners_200_devices(
benchmark: BenchmarkFixture,
) -> None:
"""All 8 workers collect due buckets — burst variant of the read path."""
_, scanner_cancels, request_cancels = _setup_scheduler(
num_scanners=8, num_devices=200
)
manager = get_manager()
scheduler = manager._auto_scheduler
workers = list(scheduler._workers.values())
loop = asyncio.get_running_loop()
@benchmark
def run() -> None:
now = loop.time()
for worker in workers:
worker._collect_due_buckets(now)
_teardown_scheduler(scanner_cancels, request_cancels)
@pytest.mark.asyncio
async def test_on_advertisement_steady_state_8_scanners_200_devices(
benchmark: BenchmarkFixture,
) -> None:
"""
Ingestion hot path: re-deliver an advertisement for every tracked address.
``AutoScanScheduler.on_advertisement`` runs once per advertisement
for any address that has an active-scan request, fed from
``BluetoothManager._scanner_adv_received``. The existing benchmarks
cover the timer side (``_next_event_at`` / ``_collect_due_buckets``);
this one covers the per-advertisement ingestion side that the timer
benchmarks never touch.
The scenario is steady state — the address is already seeded and
owned by the delivering scanner — so each call exercises the dominant
real-world cost: a ``_requests_by_address`` lookup, a no-op
``_seed_requests`` pass (every request already present, so
``_DueSchedule.seed`` short-circuits), and a same-source
``_DueSchedule.assign`` that skips the owner reattach and only fires
the worker's ``wake``. Delivering the cached ``service_info`` objects
directly isolates the scheduler cost from the manager's dispatch and
scoring path.
"""
_, scanner_cancels, request_cancels = _setup_scheduler(
num_scanners=8, num_devices=200
)
manager = get_manager()
scheduler = manager._auto_scheduler
# Cached service_info objects carry the round-robin owner as
# ``.source``, so each delivery hits the same-owner steady-state path.
service_infos = list(manager._all_history.values())
@benchmark
def run() -> None:
for service_info in service_infos:
scheduler.on_advertisement(service_info)
_teardown_scheduler(scanner_cancels, request_cancels)
Bluetooth-Devices-habluetooth-75cbe37/tests/test_benchmark_base_scanner.py 0000664 0000000 0000000 00000112154 15211177045 0027213 0 ustar 00root root 0000000 0000000 """Benchmarks for the base scanner."""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
import pytest
from bluetooth_data_tools import monotonic_time_coarse
from habluetooth import BaseHaRemoteScanner, HaBluetoothConnector, get_manager
from habluetooth.channels.bluez import BluetoothMGMTProtocol
from habluetooth.models import BluetoothScanningMode, BluetoothServiceInfoBleak
from habluetooth.scanner import HaScanner
from . import (
MockBleakClient,
generate_advertisement_data,
generate_ble_device,
)
if TYPE_CHECKING:
from bleak.backends.scanner import AdvertisementData
from pytest_codspeed import BenchmarkFixture
pytestmark = pytest.mark.timeout(60)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_inject_100_simple_advertisements(benchmark: BenchmarkFixture) -> None:
"""Test injecting 100 simple advertisements."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={1: b"\x01"},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
_address = switchbot_device.address
_name = switchbot_device.name
_service_uuids = switchbot_device_adv.service_uuids
_service_data = switchbot_device_adv.service_data
_manufacturer_data = switchbot_device_adv.manufacturer_data
_tx_power = switchbot_device_adv.tx_power
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
@benchmark
def run():
for _ in range(100):
scanner._async_on_advertisement(
_address,
-100, # rssi
_name,
_service_uuids,
_service_data,
_manufacturer_data,
_tx_power,
_details,
_now,
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_inject_100_complex_advertisements(benchmark: BenchmarkFixture) -> None:
"""Test injecting 100 complex advertisements."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data=dict.fromkeys(range(100), b"\x01"),
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
_address = switchbot_device.address
_name = switchbot_device.name
_service_uuids = switchbot_device_adv.service_uuids
_service_data = switchbot_device_adv.service_data
_manufacturer_data = switchbot_device_adv.manufacturer_data
_tx_power = switchbot_device_adv.tx_power
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
@benchmark
def run():
for _ in range(100):
scanner._async_on_advertisement(
_address,
-100, # rssi
_name,
_service_uuids,
_service_data,
_manufacturer_data,
_tx_power,
_details,
_now,
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_inject_100_different_advertisements(benchmark: BenchmarkFixture) -> None:
"""Test injecting 100 different advertisements."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
advs: list[AdvertisementData] = []
for i in range(100):
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={i: b"\x01"},
rssi=-100,
)
advs.append(switchbot_device_adv)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
_address = switchbot_device.address
_name = switchbot_device.name
_service_uuids = switchbot_device_adv.service_uuids
_service_data = switchbot_device_adv.service_data
_tx_power = switchbot_device_adv.tx_power
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
@benchmark
def run():
for adv in advs:
scanner._async_on_advertisement(
_address,
-100, # rssi
_name,
_service_uuids,
_service_data,
adv.manufacturer_data,
_tx_power,
_details,
_now,
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_inject_100_different_manufacturer_data(
benchmark: BenchmarkFixture,
) -> None:
"""Test injecting 100 different manufacturer_data."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
advs: list[AdvertisementData] = []
for i in range(100):
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={1: b"\x01", 3: bytes((i,) * 20)},
rssi=-100,
)
advs.append(switchbot_device_adv)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
_address = switchbot_device.address
_name = switchbot_device.name
_service_uuids = switchbot_device_adv.service_uuids
_service_data = switchbot_device_adv.service_data
_tx_power = switchbot_device_adv.tx_power
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
@benchmark
def run():
for adv in advs:
scanner._async_on_advertisement(
_address,
-100, # rssi
_name,
_service_uuids,
_service_data,
adv.manufacturer_data,
_tx_power,
_details,
_now,
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_inject_100_different_service_data(
benchmark: BenchmarkFixture,
) -> None:
"""Test injecting 100 different service_data."""
manager = get_manager()
switchbot_device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
advs: list[AdvertisementData] = []
for i in range(100):
switchbot_device_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
service_data={"050a021a-0000-1000-8000-00805f9b34fb": bytes((i,) * 20)},
manufacturer_data={1: b"\x01"},
rssi=-100,
)
advs.append(switchbot_device_adv)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
_address = switchbot_device.address
_name = switchbot_device.name
_service_uuids = switchbot_device_adv.service_uuids
_service_data = switchbot_device_adv.service_data
_tx_power = switchbot_device_adv.tx_power
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
@benchmark
def run():
for adv in advs:
scanner._async_on_advertisement(
_address,
-100, # rssi
_name,
_service_uuids,
_service_data,
adv.manufacturer_data,
_tx_power,
_details,
_now,
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_inject_100_rotating_manufacturer_data(
benchmark: BenchmarkFixture,
) -> None:
"""Test injecting 100 different manufacturer_data to mimic a sensor push device."""
manager = get_manager()
sensor_push_device = generate_ble_device(
"44:44:33:11:23:45",
"",
{},
rssi=-60,
)
sensor_push_device_adv = generate_advertisement_data(
local_name="",
rssi=-60,
manufacturer_data={
17667: b"\xad\x00\x01\x00\x00",
1280: b"\xe7\xb4\xe1\xaf\xb6",
2304: b"7\xe1:\xb7\xb6",
55552: b"#\xc7$\xad\xb6",
58624: b";\x01%\x9d\xb6",
44288: b"\xa2|'x\xb6",
64000: b";\xad\xdc\xa7\xb6",
28672: b"\xdb\xe8\\\xa2\xb6",
7168: b"\xb5\xbd\xe0\xaf\xb6",
11264: b"\x00S}\xae\xb6",
4096: b"\xe9\xef\x8e\xba\xb6",
44800: b"\x85\xa2=\xb5\xb6",
32768: b"\x86b\xe9\xc1\xb6",
37376: b"\x8bS<\xc1\xb6",
25344: b"\xb4\xb2\xe7\xbb\xb6",
51200: b"\xae\xdc\xc8\x97\xb6",
49152: b"O\x80O\xc7\xb6",
17664: b"\x0e\xb7q\xa0\xb6",
34816: b"\x9a\xf6\xf8\xc3\xb6",
21760: b"G\xd9\xd6\xa7\xb6",
512: b"\xaa\x14M\xc5\xb6",
41984: b"\xfd\xb4\xd7\xa5\xb6",
16640: b"\x9b\xdd\xd9\xa5\xb6",
33024: b"\x99\xdbB\xb9\xb6",
25088: b"\xee\xec\xea\xbf\xb6",
24576: b"\xc3G\x16\x99\xb6",
50176: b"\x88Q\x9d\xc4\xb6",
57856: b"~\x1a\xb0\x87\xb6",
2816: b"08\xa2\xc4\xb6",
19712: b",\xf1u\x9a\xb6",
26880: b"\x8f\x0f(\xa9\xb6",
54528: b"U\xbe\x1c\x9b\xb6",
7936: b"\x01\x1e\x93\xbc\xb6",
52992: b"R\x19\xb9\x91\xb6",
9472: b"\x0f\xb9\x9a\x87\xb6",
47360: b"A\xe16\xb5\xb6",
14080: b"r\x82S\xc7\xb6",
60416: b"#A\xc5v\xb6",
19968: b"\xf5=\x80\xa0\xb6",
30976: b"\r\x99\x13\x91\xb6",
9216: b'\x08">\xbd\xb6',
16896: b'"\x94L\xc7\xb6',
54784: b"\xae\xce%\x9d\xb6",
21248: b"\xb9\xe9\xe1\xb9\xb6",
40960: b"\x15}\xda\xbb\xb6",
16128: b"s\xe9\xf7\xc5\xb6",
36608: b"\xad\xd6\x8f\xc0\xb6",
1536: b"\x1a\xd1\x8c\xb0\xb6",
30720: b"\xf4`\x93\xb4\xb6",
17920: b"mIi\xae\xb6",
30464: b"\x8c}\x19\x99\xb6",
61952: b"\xb4{\xec\xbd\xb6",
30208: b'\xa8\xac"\x9b\xb6',
27904: b"D\xcb8\xb5\xb6",
45568: b"\xfc\xb5\xdf\xa9\xb6",
12288: b"\xe9\x11\xa7\x8f\xb6",
6400: b"\\\xcf\xe0\xb7\xb6",
10496: b"P_\xe1\xbb\xb6",
52736: b"fv\xd3\xa1\xb6",
37888: b"\xb1\x7f'\xaf\xb6",
6656: b"\x80Wh\x90\xb6",
15872: b"\xd7\x91\xe0\xb7\xb6",
28160: b"P<\xc5\x95\xb6",
37632: b"NN\xc7x\xb6",
11776: b"\x03z0\xab\xb6",
48896: b"B\x9e\xaa\xc8\xb6",
65280: b"w\xb1\xee\xb9\xb6",
56320: b"\xb1\xfa\x1f\x99\xb6",
59136: b"_\xd5\x1c\x97\xb6",
26368: b"\xbe\x82\xbd\x93\xb6",
7424: b"A\xc8\x19\x99\xb6",
49408: b"\xef\xda\x91\xb4\xb6",
24832: b"l\xc03\xbd\xb6",
48128: b"Vs4\xa9\xb6",
48384: b"\xack;\xbb\xb6",
20224: b"\xd8O\xe5\xb9\xb6",
35840: b"Nj\xe1\xbb\xb6",
51712: b"\x96\xba\xcc\x9b\xb6",
23296: b"\xda\\v\x9c\xb6",
39168: b"0j\xe3\xb3\xb6",
29440: b"\xf9\xc9J\xc3\xb6",
54016: b"\xe9\x1c\x88\xa6\xb6",
62208: b"\x1b\x0f\xe3\xbf\xb6",
33280: b"\xc2s\x83\xa2\xb6",
20480: b"\xa9\xc5\xc4\x95\xb6",
50688: b"\xd5O\xe5\xb7\xb6",
19456: b"T }\x9e\xb6",
27136: b"\xd3\n\xda\xbb\xb6",
34304: b"\x10\x164\xb7\xb6",
3328: b'"\xb1\x1c\x99\xb6',
50944: b"it\xbf\x91\xb6",
29952: b"\xd7\xc5\xb8\x93\xb6",
46592: b"\x14-\xbc\x95\xb6",
60928: b"|\xcd\xb8\x8d\xb6",
16384: b"4\x95\xce\x9b\xb6",
23040: b"\x99\xca\x9f\x8d\xb6",
58112: b"P\xcc;\xb7\xb6",
22784: b"\x8a4L\xc5\xb6",
12800: b"el\xe0\xad\xb6",
8960: b"xe\x8e\xb8\xb6",
13568: b"\xec2\x8f\xb8\xb6",
36864: b"\r\xde1\xb5\xb6",
64512: b"\xf7\xf8\x17n\xb6",
39424: b"?\xbc\x87\xa8\xb6",
8448: b"\xfa\x8c\xa6\x8f\xb6",
53760: b"\xf3\x92\xdd\xb3\xb6",
23552: b"A\xb5A\xc3\xb6",
51968: b"\xb6\xc9\xa5^\xb6",
9728: b"\xff\xa1\x7f\xa6\xb6",
18944: b"\xc0\xddI\xc1\xb6",
46848: b"\x05t\xea\xb9\xb6",
33792: b"\xdb\xa8\xd9\xa3\xb6",
6144: b"+\xcb?\xb9\xb6",
10752: b";\x93:\xb9\xb6",
40704: b"\x8e\x85p\x96\xb6",
58368: b"\x91\xf2\xd0\x9d\xb6",
32512: b"\xec\x80\x85\xa4\xb6",
55808: b"-\x98\x80\xb0\xb6",
25856: b"\x90\xd5\x85\xaa\xb6",
58880: b":J\x81\xba\xb6",
31232: b"\x80\xfe\xdd\xa5\xb6",
55040: b"o13\xa9\xb6",
50432: b"t\xe5I\xc3\xb6",
37120: b"\xd3\x05\x89\xa6\xb6",
12544: b"\x06\x00<\xbd\xb6",
59904: b"\xddb\xbe\x93\xb6",
27392: b"OB\x0f\x8f\xb6",
61696: b"\x1d\xe8\x18\x97\xb6",
29696: b"#\xcc\xde\xbd\xb6",
32000: b"X}3\xb5\xb6",
44544: b"\xb8\xa1\x1e\x99\xb6",
7680: b"\xe7Qr\x98\xb6",
45312: b"\xfbI\x10\x8f\xb6",
63488: b"\xc6\xda(\xa5\xb6",
25600: b"O\xda\xe2\xb7\xb6",
24320: b"r\x14n\x98\xb6",
62464: b"\xb0\x87\xf4\xc1\xb6",
63744: b"\x96\xd6\x14\x95\xb6",
21504: b"[\x85\x0f\x93\xb6",
8192: b"\xb7\x84\xd3\xa5\xb6",
29184: b"\xbf\xdfg\x90\xb6",
64768: b"\xa2\x84\xe5\xbf\xb6",
57088: b"9\t\x8a\xb4\xb6",
22272: b"r~(\x9f\xb6",
55296: b".\x03\xc6\x97\xb6",
34560: b"\xb5r\x7f\xa0\xb6",
52224: b"\xe2\xc3\x1c\xa3\xb6",
13824: b"8>\xe6\xb5\xb6",
46080: b"Y\x7f@\xb9\xb6",
34048: b"/_k\x96\xb6",
4608: b"9\x95K\xc3\xb6",
62720: b"K-;\x84\xb6",
44032: b"\xd5\xd0\xa7\xc6\xb6",
35584: b"?}D\xcd\xb6",
43008: b"2\x8f\x8a\xae\xb6",
47104: b"\xa1\xff\xe6\xbb\xb6",
61184: b"\xa3\x7f%\xa5\xb6",
59648: b"\xf1\xb8\x8d\xb4\xb6",
57344: b"\x88\xee2\xb7\xb6",
36096: b"\\\xd5\x9c\xc0\xb6",
38912: b"n\x12_\x90\xb6",
56832: b"$gG\xc3\xb6",
18176: b"\xf9\x96\xfc\xc5\xb6",
18432: b"b\xcdA\xbf\xb6",
57600: b":\x19@\xbf\xb6",
18688: b"$\xe2\xcb\x99\xb6",
38656: b"\x0cA-\xb9\xb6",
48640: b"V\x8c\xda\xab\xb6",
46336: b'"WL]\xb6',
9984: b"\xa8\xab\xa2\xd2\xb6",
42496: b"4\x0b\x1f\x9b\xb6",
41216: b")M%\xab\xb6",
49664: b"M%6\xa9\xb6",
42240: b",\x1e\x86\xb6\xb6",
20992: b"\xab\x052\xbd\xb6",
53504: b"\x8a\xf6\x84\xaa\xb6",
56064: b"\xda\xbf\xa4`\xb6",
53248: b"\x18:\xc8\x99\xb6",
19200: b"\xb1\xb4\x89\xbc\xb6",
38400: b"\xba=\x1f\x99\xb6",
41728: b"\xe4\xa3+\xb1\xb6",
5376: b"z\xd6\x94\xb2\xb6",
47616: b"\x88\x1f\xe3\xb9\xb6",
60672: b"\x9c\x85{\xb4\xb6",
3584: b"\xe7\xdc\xa8\xc6\xb6",
28416: b"\xdc\xddT\x90\xb6",
14336: b"\x87\xa6\xf2\xc5\xb6",
43776: b"9y\x8a\xae\xb6",
39936: b"\xe2\x8cSa\xb6",
5632: b"\xa5_0\xab\xb6",
14592: b"\xbf\xa9\x80\xae\xb6",
63232: b"\xd6A\xc5\x99\xb6",
13312: b"=\xcdL\xc3\xb6",
8704: b"\xf9\xd1'\xa1\xb6",
11008: b"\xdc\xed\xf6\xc5\xb6",
26624: b"\x9b\x81\xc2\x99\xb6",
13056: b"\x88@\xda\xab\xb6",
5888: b"p\xea\x85\xaa\xb6",
12032: b"L\xdb\xe9\xb9\xb6",
3072: b"$\x1e\x83\xac\xb6",
31744: b"\xcb\xe60\xad\xb6",
14848: b"\xee\x9d\xe8\xc9\xb6",
45824: b"Mo\x8e\xb2\xb6",
768: b"\xa6\x8d=\xb5\xb6",
56576: b"\x02\xba\x8d\xb0\xb6",
49920: b"\xa3\xac$\x9d\xb6",
41472: b"\x9dM\xe0\xab\xb6",
65024: b"\x89!\xf2\xbf\xb6",
1024: b"\x89\xbf\x8d\xb4\xb6",
0: b"\xb6\xc5\xc2\x97\xb6",
61440: b"\xad\xa8s\x98\xb6",
17408: b"\xc2\x99?\xbb\xb6",
42752: b"R\xf81\xa9\xb6",
38144: b'\x83\x89"\x9d\xb6',
43520: b"\xb7\xa2'\x9f\xb6",
35328: b";d\xa2\xd0\xb6",
51456: b"\xa4\x85h\x90\xb6",
35072: b"\xfb\x90@\xbf\xb6",
39680: b"\xf5\xcb\x04\xa1\xb6",
4352: b"j\xd0e\x92\xb6",
32256: b"\xcc\x99\xbf\x95\xb6",
3840: b"\xd0\xdd\xc7\x99\xb6",
45056: b"U\xf2\xf0\xc3\xb6",
47872: b'\xdc\x07"\x9b\xb6',
60160: b"0\x8a\xdf\xbb\xb6",
28928: b"\xe7\xa8\xdc\xaf\xb6",
54272: b"c\x15\x85\xb4\xb6",
17152: b"\xc0Q\x7f\xa0\xb6",
5120: b"B@w\x9a\xb6",
43264: b"rC\x85\xaa\xb6",
23808: b"[\xe3=\xb7\xb6",
256: b"\x9c\x9f\x90\xb2\xb6",
6912: b"\xf2\x18\xdc\xab\xb6",
15616: b"\x1b,/\xb5\xb6",
15104: b"{=\xbf\x91\xb6",
4864: b"g?\xe3\xb9\xb6",
36352: b"\x88\xac2\xad\xb6",
22016: b"O\x91\x18\x95\xb6",
52480: b"q\x8dS\xc9\xb6",
62976: b"ZX\x7f\xa2\xb6",
59392: b"\xef\xdc\xa5\xc4\xb6",
15360: b"\xd0\x9aD\xc1\xb6",
10240: b"a\x92\x92\xb0\xb6",
2048: b" /]\x9a\xb6",
20736: b"\x9d\xdek\x94\xb6",
2560: b"\xf5z=\xb3\xb6",
22528: b"j@\xe2\xad\xb6",
26112: b"\x18\x1f\xc5x\xb6",
40448: b"\xdf\xfe=\xbb\xb6",
11520: b"2\xf7<\xbb\xb6",
1792: b"$\n\x1c\x99\xb6",
40192: b"\xaa\x88\xff\xc9\xb6",
27648: b"\x87\xac\xb8\x8d\xb6",
33536: b"{:\x1b\x97\xb6",
64256: b"B\r.\xa9\xb6",
31488: b"\x98\xfa\xb6\x91\xb6",
},
service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
service_data={},
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
scanner._async_on_advertisement(
sensor_push_device.address,
-100, # rssi
sensor_push_device.name,
sensor_push_device_adv.service_uuids,
sensor_push_device_adv.service_data,
sensor_push_device_adv.manufacturer_data,
sensor_push_device_adv.tx_power,
{"scanner_specific_data": "test"},
monotonic_time_coarse(),
)
advs: list[AdvertisementData] = []
for i in range(100):
sensorpush_device_adv = generate_advertisement_data(
local_name="",
service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
service_data={},
manufacturer_data={i: bytes((i,) * 20)},
rssi=-(i),
)
advs.append(sensorpush_device_adv)
_address = sensor_push_device.address
_name = sensor_push_device.name
_service_uuids = sensorpush_device_adv.service_uuids
_service_data = sensorpush_device_adv.service_data
_tx_power = sensorpush_device_adv.tx_power
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
@benchmark
def run():
for adv in advs:
scanner._async_on_advertisement(
_address,
-100, # rssi
_name,
_service_uuids,
_service_data,
adv.manufacturer_data,
_tx_power,
_details,
_now,
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_filter_unwanted_apple_advs(benchmark: BenchmarkFixture) -> None:
"""Test filtering unwanted apple data."""
manager = get_manager()
device = generate_ble_device(
"44:44:33:11:23:45",
"beacon",
{},
rssi=-100,
)
device_adv = generate_advertisement_data(
local_name="beacon",
service_uuids=[],
service_data={},
manufacturer_data={76: b"\xff"},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
_address = device.address
_name = device.name
_service_uuids = device_adv.service_uuids
_service_data = device_adv.service_data
_manufacturer_data = device_adv.manufacturer_data
_tx_power = device_adv.tx_power
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
@benchmark
def run():
for _ in range(100):
scanner._async_on_advertisement(
_address,
-100, # rssi
_name,
_service_uuids,
_service_data,
_manufacturer_data,
_tx_power,
_details,
_now,
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_filter_wanted_apple_advs(benchmark: BenchmarkFixture) -> None:
"""Test filtering wanted apple data."""
manager = get_manager()
device = generate_ble_device(
"44:44:33:11:23:45",
"beacon",
{},
rssi=-100,
)
device_adv = generate_advertisement_data(
local_name="beacon",
service_uuids=[],
service_data={},
manufacturer_data={76: b"\x02"},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
_address = device.address
_name = device.name
_service_uuids = device_adv.service_uuids
_service_data = device_adv.service_data
_manufacturer_data = device_adv.manufacturer_data
_tx_power = device_adv.tx_power
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
@benchmark
def run():
for _ in range(100):
scanner._async_on_advertisement(
_address,
-100, # rssi
_name,
_service_uuids,
_service_data,
_manufacturer_data,
_tx_power,
_details,
_now,
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_inject_100_raw_unchanged_advertisements(
benchmark: BenchmarkFixture,
) -> None:
"""Test injecting 100 raw unchanged advertisements (BlueZ raw path)."""
manager = get_manager()
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
_address = "44:44:33:11:23:45"
_raw = b"\x12\x21\x1a\x02\n\x05\n\xff\x062k\x03R\x00\x01\x04\t\x00\x04"
_details = {"scanner_specific_data": "test"}
_now = monotonic_time_coarse()
# Seed the first advertisement
scanner._async_on_raw_advertisement(_address, -100, _raw, _details, _now)
@benchmark
def run():
for _ in range(100):
scanner._async_on_raw_advertisement(
_address,
-100,
_raw,
_details,
_now,
)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_inject_100_bleak_unchanged_advertisements(
benchmark: BenchmarkFixture,
) -> None:
"""Test injecting 100 unchanged advertisements via Bleak/HaScanner path."""
manager = get_manager()
device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={1: b"\x01"},
rssi=-100,
)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
_now = monotonic_time_coarse()
# Seed the first advertisement through the manager
service_info = BluetoothServiceInfoBleak(
name=adv.local_name or device.name or device.address,
address=device.address,
rssi=adv.rssi,
manufacturer_data=adv.manufacturer_data,
service_data=adv.service_data,
service_uuids=adv.service_uuids,
source="esp32",
device=device,
advertisement=adv,
connectable=True,
time=_now,
tx_power=adv.tx_power,
)
manager.scanner_adv_received(service_info)
@benchmark
def run():
for _ in range(100):
info = BluetoothServiceInfoBleak(
name=adv.local_name or device.name or device.address,
address=device.address,
rssi=adv.rssi,
manufacturer_data=adv.manufacturer_data,
service_data=adv.service_data,
service_uuids=adv.service_uuids,
source="esp32",
device=device,
advertisement=adv,
connectable=True,
time=_now,
tx_power=adv.tx_power,
)
manager.scanner_adv_received(info)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_inject_100_bleak_changed_advertisements(
benchmark: BenchmarkFixture,
) -> None:
"""Test injecting 100 changed advertisements via Bleak/HaScanner path."""
manager = get_manager()
device = generate_ble_device(
"44:44:33:11:23:45",
"wohand",
{},
rssi=-100,
)
advs: list[AdvertisementData] = []
for i in range(100):
adv = generate_advertisement_data(
local_name="wohand",
service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"},
manufacturer_data={1: bytes((i,))},
rssi=-100,
)
advs.append(adv)
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
_now = monotonic_time_coarse()
@benchmark
def run():
for adv in advs:
info = BluetoothServiceInfoBleak.__new__(BluetoothServiceInfoBleak)
info.name = adv.local_name or device.name or device.address
info.address = device.address
info.rssi = adv.rssi
info.manufacturer_data = adv.manufacturer_data
info.service_data = adv.service_data
info.service_uuids = adv.service_uuids
info.source = "esp32"
info.device = device
info._advertisement = adv
info.connectable = True
info.time = _now
info.tx_power = adv.tx_power
info.raw = None
manager.scanner_adv_received(info)
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_inject_100_bluez_raw_end_to_end_unchanged(
benchmark: BenchmarkFixture,
) -> None:
"""Test 100 unchanged advertisements through full BlueZ MGMT protocol path."""
manager = get_manager()
loop = asyncio.get_running_loop()
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
scanners: dict[int, HaScanner] = {0: scanner}
future: asyncio.Future[None] = loop.create_future()
mock_sock = MagicMock()
protocol = BluetoothMGMTProtocol(
future, scanners, lambda: None, lambda: False, mock_sock
)
# Build a DEVICE_FOUND MGMT packet
# AD data: flags + local name + manufacturer data
ad_data = (
b"\x02\x01\x06" # Flags
b"\x08\x09TestDev" # Complete Local Name = "TestDev"
b"\x04\xff\x01\x00\xaa" # Manufacturer data: company 0x0001, data 0xaa
)
param_len = 6 + 1 + 1 + 4 + 2 + len(ad_data)
packet = (
b"\x12\x00" # DEVICE_FOUND event
b"\x00\x00" # controller_idx = 0
+ param_len.to_bytes(2, "little")
+ b"\xaa\xbb\xcc\xdd\xee\xff" # address
+ b"\x01" # address_type
+ b"\xc4" # rssi = -60
+ b"\x00\x00\x00\x00" # flags
+ len(ad_data).to_bytes(2, "little")
+ ad_data
)
# Seed first advertisement
protocol.data_received(packet)
@benchmark
def run():
for _ in range(100):
protocol.data_received(packet)
cancel()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_inject_100_bluez_raw_end_to_end_changed(
benchmark: BenchmarkFixture,
) -> None:
"""Test 100 changed advertisements through full BlueZ MGMT protocol path."""
manager = get_manager()
loop = asyncio.get_running_loop()
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
scanners: dict[int, HaScanner] = {0: scanner}
future: asyncio.Future[None] = loop.create_future()
mock_sock = MagicMock()
protocol = BluetoothMGMTProtocol(
future, scanners, lambda: None, lambda: False, mock_sock
)
# Build 100 different DEVICE_FOUND MGMT packets with varying manufacturer data
packets: list[bytes] = []
for i in range(100):
ad_data = (
b"\x02\x01\x06" # Flags
b"\x08\x09TestDev" # Complete Local Name = "TestDev"
b"\x04\xff\x01\x00" # Manufacturer data header: company 0x0001
+ bytes((i,)) # Varying data byte
)
param_len = 6 + 1 + 1 + 4 + 2 + len(ad_data)
packet = (
b"\x12\x00" # DEVICE_FOUND event
b"\x00\x00" # controller_idx = 0
+ param_len.to_bytes(2, "little")
+ b"\xaa\xbb\xcc\xdd\xee\xff" # address
+ b"\x01" # address_type
+ b"\xc4" # rssi = -60
+ b"\x00\x00\x00\x00" # flags
+ len(ad_data).to_bytes(2, "little")
+ ad_data
)
packets.append(packet)
@benchmark
def run():
for packet in packets:
protocol.data_received(packet)
cancel()
# ---------------------------------------------------------------------------
# seed_name_cache benchmarks
#
# The per-advertisement cost of the name cache in production is dominated by
# the inlined fast path inside _scanner_adv_received (covered end-to-end by
# the test_inject_100_* benchmarks above); the cdef _update_name_cache helper
# itself only runs on genuine cache writes. We cannot call that cdef directly
# from Python, so these microbenchmarks measure it through the
# seed_name_cache wrapper. That adds a Python-level call layer on top, so
# absolute numbers here include some wrapper overhead, but the relative
# numbers across the prefix-rule paths still catch regressions in the body.
# Covered paths:
# 1. identity - same Python str object as cached (typical steady state)
# 2. equality - cached and incoming compare equal but are different objects
# (e.g. names rebuilt by different deserialization paths)
# 3. cold - address not yet in cache (first ad for a device)
# 4. fallback - name equals the address (the no-op for nameless ads)
# A fifth benchmark exercises the actual prefix-rule write paths.
# ---------------------------------------------------------------------------
@pytest.mark.usefixtures("enable_bluetooth")
def test_seed_name_cache_steady_state_identity(benchmark: BenchmarkFixture) -> None:
"""Hot path: same name object as cached. Should be a dict.get + pointer compare."""
manager = get_manager()
address = "44:44:33:11:23:60"
name = "Onvis XXX"
manager.seed_name_cache(address, name)
assert manager._name_cache[address] is name
@benchmark
def run():
for _ in range(1000):
manager.seed_name_cache(address, name)
@pytest.mark.usefixtures("enable_bluetooth")
def test_seed_name_cache_steady_state_equality(benchmark: BenchmarkFixture) -> None:
"""Hot path: cached name equals incoming but different str objects (no identity)."""
manager = get_manager()
address = "44:44:33:11:23:61"
cached = b"Onvis XXX".decode()
manager.seed_name_cache(address, cached)
# Force a different object with the same value via a separate bytes.decode.
incoming = b"Onvis XXX".decode()
assert incoming is not cached
assert incoming == cached
@benchmark
def run():
for _ in range(1000):
manager.seed_name_cache(address, incoming)
@pytest.mark.usefixtures("enable_bluetooth")
def test_seed_name_cache_address_fallback(benchmark: BenchmarkFixture) -> None:
"""Hot path: passive scanner with no name (name == address). Must short-circuit."""
manager = get_manager()
address = "44:44:33:11:23:62"
@benchmark
def run():
for _ in range(1000):
manager.seed_name_cache(address, address)
assert address not in manager._name_cache
@pytest.mark.usefixtures("enable_bluetooth")
def test_seed_name_cache_cold_first_name(benchmark: BenchmarkFixture) -> None:
"""First name observed for an address. Pops the entry every iteration."""
manager = get_manager()
address = "44:44:33:11:23:63"
name = "Onvis XXX"
@benchmark
def run():
for _ in range(1000):
manager._name_cache.pop(address, None)
manager.seed_name_cache(address, name)
@pytest.mark.usefixtures("enable_bluetooth")
def test_seed_name_cache_prefix_rule_paths(benchmark: BenchmarkFixture) -> None:
"""
Mixed write paths: extension, truncation, and rename.
Each iteration exercises the casefold and length-dispatch logic.
"""
manager = get_manager()
address = "44:44:33:11:23:64"
short = "Onv"
long = "Onvis XXX"
other = "Donkey XX" # same length as long, not a prefix-extension
manager.seed_name_cache(address, long) # seed
@benchmark
def run():
for _ in range(1000):
# rename: same length, different -> replace
manager.seed_name_cache(address, other)
# extension: name is longer than cached -> replace
manager.seed_name_cache(address, long)
# truncation: name shorter than cached, prefix match -> keep
manager.seed_name_cache(address, short)
Bluetooth-Devices-habluetooth-75cbe37/tests/test_benchmark_manager.py 0000664 0000000 0000000 00000022546 15211177045 0026207 0 ustar 00root root 0000000 0000000 """Benchmarks for the BluetoothManager hot paths."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from bluetooth_data_tools import monotonic_time_coarse
from habluetooth import (
BaseHaRemoteScanner,
BaseHaScanner,
HaBluetoothConnector,
get_manager,
)
from habluetooth.models import BluetoothServiceInfoBleak
from . import (
MockBleakClient,
generate_advertisement_data,
generate_ble_device,
)
if TYPE_CHECKING:
from collections.abc import Iterable
from bleak.backends.scanner import AdvertisementData, BLEDevice
from pytest_codspeed import BenchmarkFixture
pytestmark = pytest.mark.timeout(60)
class _LocalScannerLike(BaseHaScanner):
"""Stand-in for HaScanner that rebuilds its discovered-dict each access."""
# The real HaScanner.discovered_addresses delegates to
# bleak.BleakScanner.discovered_devices_and_advertisement_data which
# walks bleak's backend cache and constructs a new dict each call. This
# fake reproduces that allocation pattern so the benchmarks reflect the
# redundant-rebuild cost that issue #505 targets — without depending on
# a live BlueZ stack.
def __init__(self, source: str, adapter: str, addresses: list[str]) -> None:
super().__init__(source, adapter, connectable=True)
self._addresses = addresses
self._device = generate_ble_device(
addresses[0] if addresses else "00:00:00:00:00:00", "x", {}
)
self._adv = generate_advertisement_data(local_name="x")
@property
def discovered_devices(self) -> list[BLEDevice]:
return [self._device for _ in self._addresses]
@property
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
# Rebuild the dict on every access, matching bleak's behavior.
return dict.fromkeys(self._addresses, (self._device, self._adv))
@property
def discovered_addresses(self) -> Iterable[str]:
# Match HaScanner: dict iteration yields keys, but the dict is
# rebuilt on every access.
return self.discovered_devices_and_advertisement_data
def get_discovered_device_advertisement_data(
self, address: str
) -> tuple[BLEDevice, AdvertisementData] | None:
return self.discovered_devices_and_advertisement_data.get(address)
def _make_address(i: int) -> str:
return f"AA:BB:CC:{(i >> 16) & 0xFF:02X}:{(i >> 8) & 0xFF:02X}:{i & 0xFF:02X}"
def _seed_history(num_devices: int, source: str) -> list[str]:
"""Populate manager history with ``num_devices`` devices from ``source``."""
manager = get_manager()
now = monotonic_time_coarse()
addresses: list[str] = []
for i in range(num_devices):
address = _make_address(i)
addresses.append(address)
device = generate_ble_device(address, f"dev{i}", {})
adv = generate_advertisement_data(
local_name=f"dev{i}",
manufacturer_data={1: bytes((i & 0xFF,))},
service_uuids=[],
rssi=-60,
)
manager.scanner_adv_received(
BluetoothServiceInfoBleak(
name=adv.local_name,
address=address,
rssi=adv.rssi,
manufacturer_data=adv.manufacturer_data,
service_data=adv.service_data,
service_uuids=adv.service_uuids,
source=source,
device=device,
advertisement=adv,
connectable=True,
time=now,
tx_power=adv.tx_power,
)
)
return addresses
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_check_unavailable_steady_state_remote(
benchmark: BenchmarkFixture,
) -> None:
"""Steady-state _async_check_unavailable with one remote scanner and 200 devices."""
# Nothing has disappeared — every history address is still in the scanner's
# discovered_addresses. This is the dominant production path: each cycle
# runs the difference twice (connectable + non-connectable loops).
manager = get_manager()
connector = HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: True)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, connectable=True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
addresses = _seed_history(200, "esp32")
# Inject through the remote scanner's normal entry point so its
# discovered_addresses (== _previous_service_info) is populated too.
now = monotonic_time_coarse()
for i, address in enumerate(addresses):
scanner._async_on_advertisement(
address,
-60,
f"dev{i}",
[],
{},
{1: bytes((i & 0xFF,))},
None,
{"scanner_specific_data": "test"},
now,
)
@benchmark
def run() -> None:
manager._async_check_unavailable()
cancel()
unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_check_unavailable_steady_state_local_like(
benchmark: BenchmarkFixture,
) -> None:
"""Steady-state _async_check_unavailable with one rebuilding local-like scanner."""
# The local-like scanner rebuilds its discovered_addresses dict on every
# property access. This isolates the cost issue #505 calls out:
# _async_check_unavailable invokes discovered_addresses on every
# connectable scanner twice per cycle, and for local HaScanner each
# access rebuilds bleak's discovered-devices dict.
manager = get_manager()
addresses = _seed_history(200, "hci0")
scanner = _LocalScannerLike("hci0", "hci0", addresses)
cancel = manager.async_register_scanner(scanner)
@benchmark
def run() -> None:
manager._async_check_unavailable()
cancel()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_check_unavailable_many_scanners_local_like(
benchmark: BenchmarkFixture,
) -> None:
"""Steady-state _async_check_unavailable: four local-like + one remote scanner."""
# 200 devices total. Multi-scanner deployments amplify the redundant-rebuild
# cost: every connectable scanner's discovered_addresses is materialized
# twice per cycle (issue #505).
manager = get_manager()
addresses = _seed_history(200, "hci0")
scanners: list[BaseHaScanner] = []
cancels = []
for idx, adapter in enumerate(("hci0", "hci1", "hci2", "hci3")):
# Each local-like scanner sees a non-empty overlapping slice so the
# set difference work mirrors a real multi-adapter install.
slice_size = max(1, len(addresses) // (idx + 1))
local = _LocalScannerLike(adapter, adapter, addresses[:slice_size])
scanners.append(local)
cancels.append(manager.async_register_scanner(local))
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
remote = BaseHaRemoteScanner("esp32_nc", "esp32_nc", connector, connectable=False)
remote_unsetup = remote.async_setup()
cancels.append(manager.async_register_scanner(remote))
@benchmark
def run() -> None:
manager._async_check_unavailable()
for c in cancels:
c()
remote_unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_check_unavailable_all_disappeared(
benchmark: BenchmarkFixture,
) -> None:
"""Worst case: every history address has disappeared from every scanner."""
# Exercises the disappear-callback and tracker-cleanup branches of the
# inner loop. Re-seeds history each iteration so the benchmark measures
# the dispatch work, not a one-shot drain. 50 devices is small on purpose
# to keep the re-seed overhead from dominating.
manager = get_manager()
connector = HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: True)
scanner = BaseHaRemoteScanner("esp32", "esp32", connector, connectable=True)
unsetup = scanner.async_setup()
cancel = manager.async_register_scanner(scanner)
template_addresses = [_make_address(i) for i in range(50)]
def reseed() -> None:
manager._all_history.clear()
manager._connectable_history.clear()
now = monotonic_time_coarse() - 10_000 # ensure beyond stale threshold
for i, address in enumerate(template_addresses):
device = generate_ble_device(address, f"dev{i}", {})
adv = generate_advertisement_data(
local_name=f"dev{i}",
manufacturer_data={1: bytes((i & 0xFF,))},
service_uuids=[],
rssi=-60,
)
manager.scanner_adv_received(
BluetoothServiceInfoBleak(
name=adv.local_name,
address=address,
rssi=adv.rssi,
manufacturer_data=adv.manufacturer_data,
service_data=adv.service_data,
service_uuids=adv.service_uuids,
source="esp32",
device=device,
advertisement=adv,
connectable=True,
time=now,
tx_power=adv.tx_power,
)
)
@benchmark
def run() -> None:
reseed()
manager._async_check_unavailable()
cancel()
unsetup()
Bluetooth-Devices-habluetooth-75cbe37/tests/test_central_manager.py 0000664 0000000 0000000 00000002230 15211177045 0025671 0 ustar 00root root 0000000 0000000 """Tests for habluetooth.central_manager."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from habluetooth.central_manager import (
CentralBluetoothManager,
get_manager,
set_manager,
)
if TYPE_CHECKING:
from collections.abc import Iterator
@pytest.fixture
def preserve_manager() -> Iterator[None]:
"""Save and restore the CentralBluetoothManager singleton around a test."""
original = CentralBluetoothManager.manager
try:
yield
finally:
CentralBluetoothManager.manager = original
def test_get_manager_raises_when_unset(preserve_manager: None) -> None:
"""get_manager() raises RuntimeError when no manager has been set."""
CentralBluetoothManager.manager = None
with pytest.raises(RuntimeError, match="BluetoothManager has not been set"):
get_manager()
def test_set_manager_replaces_singleton(preserve_manager: None) -> None:
"""set_manager() stores the instance on the central holder."""
sentinel = object()
set_manager(sentinel) # type: ignore[arg-type]
assert CentralBluetoothManager.manager is sentinel
assert get_manager() is sentinel
Bluetooth-Devices-habluetooth-75cbe37/tests/test_init.py 0000664 0000000 0000000 00000040777 15211177045 0023534 0 ustar 00root root 0000000 0000000 from unittest.mock import ANY, MagicMock
import pytest
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from habluetooth import (
BaseHaRemoteScanner,
BaseHaScanner,
BluetoothScanningMode,
HaBluetoothConnector,
HaScanner,
get_manager,
)
from habluetooth.models import BluetoothServiceInfoBleak
class MockBleakClient:
pass
def test_create_scanner():
connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True)
class MockScanner(BaseHaScanner):
@property
def discovered_devices_and_advertisement_data(self):
return []
@property
def discovered_devices(self):
return []
scanner = MockScanner("any", "any", connector)
assert isinstance(scanner, BaseHaScanner)
def test_create_remote_scanner():
connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True)
scanner = BaseHaRemoteScanner("any", "any", connector, True)
assert isinstance(scanner, BaseHaRemoteScanner)
def test__async_on_advertisement():
connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True)
scanner = BaseHaRemoteScanner("any", "any", connector, True)
details = scanner._details | {}
scanner._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-88,
"name",
["service_uuid"],
{"service_uuid": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"},
{32: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"},
-88,
details,
1.0,
)
scanner._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-21,
"name",
["service_uuid2"],
{"service_uuid2": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"},
{21: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"},
-88,
details,
1.0,
)
ble_device = BLEDevice(
"AA:BB:CC:DD:EE:FF",
"name",
details,
)
first_device = scanner.discovered_devices[0]
assert first_device.address == ble_device.address
assert first_device.details == ble_device.details
assert first_device.name == ble_device.name
assert "AA:BB:CC:DD:EE:FF" in scanner.discovered_devices_and_advertisement_data
adv = scanner.discovered_devices_and_advertisement_data["AA:BB:CC:DD:EE:FF"][1]
assert set(adv.service_data) == {"service_uuid", "service_uuid2"}
assert adv == AdvertisementData(
local_name="name",
manufacturer_data={
32: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b",
21: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b",
},
service_data={
"service_uuid": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b",
"service_uuid2": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b",
},
service_uuids=ANY,
tx_power=-88,
rssi=-21,
platform_data=(),
)
assert len(scanner.discovered_devices) == 1
assert scanner.discovered_devices[0].address == "AA:BB:CC:DD:EE:FF"
assert len(scanner.discovered_devices_and_advertisement_data) == 1
# BLEDevice no longer has rssi attribute in bleak 1.0+
# rssi is only available in AdvertisementData
assert (
scanner.discovered_devices_and_advertisement_data["AA:BB:CC:DD:EE:FF"][1].rssi
== -21
)
assert "AA:BB:CC:DD:EE:FF" in scanner.discovered_addresses
device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF")
assert device_adv is not None
assert device_adv[1] == adv
def test__async_on_advertisement_first():
connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True)
scanner = BaseHaRemoteScanner("any", "any", connector, True)
details = scanner._details | {}
scanner._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-88,
"name",
["service_uuid"],
{"service_uuid": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"},
{32: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b"},
-88,
details,
1.0,
)
device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF")
assert device_adv is not None
device, adv = device_adv
assert device is not None
assert adv is not None
assert device.address == "AA:BB:CC:DD:EE:FF"
assert adv.rssi == -88
assert adv.service_uuids == ["service_uuid"]
assert adv.service_data == {
"service_uuid": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b"
}
assert adv.manufacturer_data == {
32: b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b"
}
assert adv.service_uuids == ANY
assert adv.tx_power == -88
assert adv.rssi == -88
assert adv.platform_data == ()
assert device.name == "name"
assert device.details == details
def test__async_on_advertisement_prefers_longest_local_name():
connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True)
scanner = BaseHaRemoteScanner("any", "any", connector, True)
details = scanner._details | {}
scanner._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-88,
"shortname",
[],
{},
{},
-88,
details,
1.0,
)
device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF")
assert device_adv is not None
device, adv = device_adv
assert device is not None
assert adv is not None
assert device.name == "shortname"
assert adv.local_name == "shortname"
scanner._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-88,
"tinyname",
[],
{},
{},
-88,
details,
1.0,
)
device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF")
assert device_adv is not None
device, adv = device_adv
assert device is not None
assert adv is not None
assert device.name == "shortname"
assert adv.local_name == "shortname"
scanner._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-88,
"longername",
[],
{},
{},
-88,
details,
1.0,
)
device_adv = scanner.get_discovered_device_advertisement_data("AA:BB:CC:DD:EE:FF")
assert device_adv is not None
device, adv = device_adv
assert device is not None
assert adv is not None
assert device.name == "longername"
assert adv.local_name == "longername"
def test_create_ha_scanner():
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
assert isinstance(scanner, HaScanner)
@pytest.mark.asyncio
async def test_dedup_unchanged_same_source():
"""Test that unchanged data from the same source is deduped (skipped)."""
manager = get_manager()
connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True)
scanner = BaseHaRemoteScanner("source1", "source1", connector, True)
cancel = manager.async_register_scanner(scanner)
details = scanner._details | {}
# First advertisement — seeds _all_history
scanner._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-88,
"name",
["service_uuid"],
{"service_uuid": b"\x01"},
{1: b"\x01"},
-88,
details,
1.0,
)
mock_discover = MagicMock()
manager._subclass_discover_info = mock_discover
# Second identical advertisement — same source, so dedup should skip dispatch
scanner._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-88,
"name",
["service_uuid"],
{"service_uuid": b"\x01"},
{1: b"\x01"},
-88,
details,
2.0,
)
# Dedup should have returned early — _subclass_discover_info not called
mock_discover.assert_not_called()
cancel()
@pytest.mark.asyncio
async def test_dedup_unchanged_different_source():
"""Test unchanged data from a different source dispatches when data changes."""
manager = get_manager()
connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True)
scanner1 = BaseHaRemoteScanner("source1", "source1", connector, True)
cancel1 = manager.async_register_scanner(scanner1)
scanner2 = BaseHaRemoteScanner("source2", "source2", connector, True)
cancel2 = manager.async_register_scanner(scanner2)
details: dict[str, str] = {}
# Scanner 1 sends advertisement — seeds _all_history with source1
scanner1._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-50,
"name",
["svc"],
{"svc": b"\x01"},
{1: b"\x01"},
-88,
details,
1.0,
)
# Scanner 2 sends first adv — seeds scanner2's _previous_service_info.
# _all_history switches to source2 (stale time diff).
scanner2._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-40,
"name",
["svc"],
{"svc": b"\x01"},
{1: b"\x01"},
-88,
details,
1000.0,
)
assert manager._all_history["AA:BB:CC:DD:EE:FF"].source == "source2"
# Scanner 1 sends again — seeds scanner1's _previous_service_info with
# same data. _all_history now has source2.
scanner1._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-50,
"name",
["svc"],
{"svc": b"\x01"},
{1: b"\x01"},
-88,
details,
2001.0,
)
# _all_history switches back to source1 (stale time diff)
assert manager._all_history["AA:BB:CC:DD:EE:FF"].source == "source1"
mock_discover = MagicMock()
manager._subclass_discover_info = mock_discover
# Scanner 1 sends SAME data again — unchanged from scanner1's perspective,
# dedup via field comparison detects no change → returns early.
scanner1._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-50,
"name",
["svc"],
{"svc": b"\x01"},
{1: b"\x01"},
-88,
details,
2002.0,
)
# Same data, same source — dedup should skip dispatch
mock_discover.assert_not_called()
# Scanner 2 sends same data — source switches (stale), but field comparison
# detects no data change, so dedup still skips dispatch on main.
scanner2._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-40,
"name",
["svc"],
{"svc": b"\x01"},
{1: b"\x01"},
-88,
details,
3001.0,
)
# On main, field-level dedup returns early even with source change
mock_discover.assert_not_called()
# But _all_history still tracks the source switch
assert manager._all_history["AA:BB:CC:DD:EE:FF"].source == "source2"
# Now scanner 2 sends CHANGED data — should dispatch
scanner2._async_on_advertisement(
"AA:BB:CC:DD:EE:FF",
-40,
"name",
["svc"],
{"svc": b"\x02"},
{1: b"\x02"},
-88,
details,
3002.0,
)
mock_discover.assert_called_once()
cancel1()
cancel2()
@pytest.mark.asyncio
async def test_dedup_same_data_via_scanner_adv_received():
"""Test that scanner_adv_received deduplicates same data via field comparison."""
manager = get_manager()
connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True)
scanner = BaseHaRemoteScanner("source1", "source1", connector, True)
cancel = manager.async_register_scanner(scanner)
device = BLEDevice("AA:BB:CC:DD:EE:FF", "name", {})
mfr_data = {1: b"\x01"}
svc_data = {"service_uuid": b"\x01"}
svc_uuids = ["service_uuid"]
# First advertisement — seeds _all_history
info1 = BluetoothServiceInfoBleak(
name="name",
address="AA:BB:CC:DD:EE:FF",
rssi=-88,
manufacturer_data=mfr_data,
service_data=svc_data,
service_uuids=svc_uuids,
source="source1",
device=device,
advertisement=None,
connectable=True,
time=1.0,
tx_power=-88,
)
manager.scanner_adv_received(info1)
# Second advertisement with same data
info2 = BluetoothServiceInfoBleak(
name="name",
address="AA:BB:CC:DD:EE:FF",
rssi=-88,
manufacturer_data=mfr_data,
service_data=svc_data,
service_uuids=svc_uuids,
source="source1",
device=device,
advertisement=None,
connectable=True,
time=2.0,
tx_power=-88,
)
mock_discover = MagicMock()
manager._subclass_discover_info = mock_discover
manager.scanner_adv_received(info2)
# Same data — field comparison should detect no change and dedup
mock_discover.assert_not_called()
cancel()
@pytest.mark.asyncio
async def test_async_clear_advertisement_history():
"""Test clearing advertisement history allows same data to trigger callbacks."""
manager = get_manager()
connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True)
scanner = BaseHaRemoteScanner("source1", "source1", connector, True)
cancel = manager.async_register_scanner(scanner)
address = "AA:BB:CC:DD:EE:FF"
device = BLEDevice(address, "name", {})
mfr_data = {1: b"\x01"}
svc_data = {"service_uuid": b"\x01"}
svc_uuids = ["service_uuid"]
# First advertisement — seeds history
info1 = BluetoothServiceInfoBleak(
name="name",
address=address,
rssi=-88,
manufacturer_data=mfr_data,
service_data=svc_data,
service_uuids=svc_uuids,
source="source1",
device=device,
advertisement=None,
connectable=True,
time=1.0,
tx_power=-88,
)
manager.scanner_adv_received(info1)
mock_discover = MagicMock()
manager._subclass_discover_info = mock_discover
# Same data again — should be deduped
info2 = BluetoothServiceInfoBleak(
name="name",
address=address,
rssi=-88,
manufacturer_data=mfr_data,
service_data=svc_data,
service_uuids=svc_uuids,
source="source1",
device=device,
advertisement=None,
connectable=True,
time=2.0,
tx_power=-88,
)
manager.scanner_adv_received(info2)
mock_discover.assert_not_called()
# Clear history — next advertisement should be treated as new
manager.async_clear_advertisement_history(address)
info3 = BluetoothServiceInfoBleak(
name="name",
address=address,
rssi=-88,
manufacturer_data=mfr_data,
service_data=svc_data,
service_uuids=svc_uuids,
source="source1",
device=device,
advertisement=None,
connectable=True,
time=3.0,
tx_power=-88,
)
manager.scanner_adv_received(info3)
mock_discover.assert_called_once()
cancel()
@pytest.mark.asyncio
async def test_async_clear_advertisement_history_clears_scanner_merging():
"""Test that clearing history resets UUID merging in scanners."""
manager = get_manager()
connector = HaBluetoothConnector(MockBleakClient, "any", lambda: True)
scanner = BaseHaRemoteScanner("source1", "source1", connector, True)
cancel = manager.async_register_scanner(scanner)
address = "AA:BB:CC:DD:EE:FF"
# Seed scanner's _previous_service_info with state A UUID
info_a = BluetoothServiceInfoBleak(
name="name",
address=address,
rssi=-88,
manufacturer_data={},
service_data={},
service_uuids=["0000e800-0000-1000-8000-00805f9b34fb"],
source="source1",
device=BLEDevice(address, "name", {}),
advertisement=None,
connectable=True,
time=1.0,
tx_power=-88,
)
manager.scanner_adv_received(info_a)
scanner._previous_service_info[address] = info_a
# Seed with state B UUID — simulates merged set
info_ab = BluetoothServiceInfoBleak(
name="name",
address=address,
rssi=-88,
manufacturer_data={},
service_data={},
service_uuids=[
"0000e800-0000-1000-8000-00805f9b34fb",
"0000e000-0000-1000-8000-00805f9b34fb",
],
source="source1",
device=BLEDevice(address, "name", {}),
advertisement=None,
connectable=True,
time=2.0,
tx_power=-88,
)
manager.scanner_adv_received(info_ab)
scanner._previous_service_info[address] = info_ab
# Clear history
manager.async_clear_advertisement_history(address)
# Verify scanner's _previous_service_info is cleared
assert address not in scanner._previous_service_info
# Verify manager histories are cleared
assert address not in manager._all_history
assert address not in manager._connectable_history
cancel()
Bluetooth-Devices-habluetooth-75cbe37/tests/test_manager.py 0000664 0000000 0000000 00000250213 15211177045 0024167 0 ustar 00root root 0000000 0000000 """Tests for the manager."""
import asyncio
import logging
import time
from collections.abc import Iterable
from datetime import timedelta
from typing import Any
from unittest.mock import ANY, AsyncMock, Mock, PropertyMock, patch
import pytest
from bleak.backends.scanner import AdvertisementData, BLEDevice
from bleak_retry_connector import AllocationChange, Allocations, BleakSlotManager
from bluetooth_adapters import ADAPTER_ADDRESS, ADAPTER_PASSIVE_SCAN
from bluetooth_adapters.systems.linux import LinuxAdapters
from freezegun import freeze_time
from habluetooth import (
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
TRACKER_BUFFERING_WOBBLE_SECONDS,
UNAVAILABLE_TRACK_SECONDS,
BluetoothManager,
BluetoothReachabilityIntent,
BluetoothScanningMode,
BluetoothServiceInfoBleak,
HaBluetoothSlotAllocations,
HaScannerModeChange,
HaScannerRegistration,
HaScannerRegistrationEvent,
get_manager,
set_manager,
)
from habluetooth.central_manager import CentralBluetoothManager
from . import (
HCI0_SOURCE_ADDRESS,
HCI1_SOURCE_ADDRESS,
NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS,
InjectableRemoteScanner,
async_fire_time_changed,
generate_advertisement_data,
generate_ble_device,
inject_advertisement_with_source,
inject_advertisement_with_time_and_source,
inject_advertisement_with_time_and_source_connectable,
patch_bluetooth_time,
utcnow,
)
from .conftest import FakeBluetoothAdapters, FakeScanner
SOURCE_LOCAL = "local"
@pytest.mark.asyncio
@pytest.mark.skipif("platform.system() == 'Windows'")
async def test_async_recover_failed_adapters() -> None:
"""Return the BluetoothManager instance."""
attempt = 0
class MockLinuxAdapters(LinuxAdapters):
@property
def adapters(self) -> dict[str, Any]:
nonlocal attempt
attempt += 1
if attempt == 1:
return {
"hci0": {
"address": "00:00:00:00:00:01",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
"hci1": {
"address": "00:00:00:00:00:00",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
"hci2": {
"address": "00:00:00:00:00:00",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
}
return {
"hci0": {
"address": "00:00:00:00:00:01",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
"hci1": {
"address": "00:00:00:00:00:02",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
"hci2": {
"address": "00:00:00:00:00:03",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"sw_version": "homeassistant",
"manufacturer": "ACME",
"product": "Bluetooth Adapter 5.0",
"product_id": "aa01",
"vendor_id": "cc01",
},
}
with (
patch("habluetooth.manager.async_reset_adapter") as mock_async_reset_adapter,
):
adapters = MockLinuxAdapters()
slot_manager = BleakSlotManager()
manager = BluetoothManager(adapters, slot_manager)
await manager.async_setup()
set_manager(manager)
adapter = await manager.async_get_adapter_from_address_or_recover(
"00:00:00:00:00:03"
)
assert adapter == "hci2"
adapter = await manager.async_get_adapter_from_address_or_recover(
"00:00:00:00:00:02"
)
assert adapter == "hci1"
adapter = await manager.async_get_adapter_from_address_or_recover(
"00:00:00:00:00:01"
)
assert adapter == "hci0"
assert mock_async_reset_adapter.call_count == 2
assert mock_async_reset_adapter.call_args_list == [
(("hci1", "00:00:00:00:00:00", False),),
(("hci2", "00:00:00:00:00:00", False),),
]
@pytest.mark.asyncio
async def test_create_manager() -> None:
"""Return the BluetoothManager instance."""
adapters = FakeBluetoothAdapters()
slot_manager = BleakSlotManager()
manager = BluetoothManager(adapters, slot_manager)
set_manager(manager)
assert manager
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_async_register_disappeared_callback(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test bluetooth async_register_disappeared_callback handles failures."""
manager = get_manager()
assert manager._loop is not None
address = "44:44:33:11:23:12"
switchbot_device_signal_100 = generate_ble_device(
address, "wohand_signal_100", rssi=-100
)
switchbot_adv_signal_100 = generate_advertisement_data(
local_name="wohand_signal_100", service_uuids=[]
)
inject_advertisement_with_source(
switchbot_device_signal_100, switchbot_adv_signal_100, "hci0"
)
failed_disappeared: list[str] = []
def _failing_callback(_address: str) -> None:
"""Failing callback."""
failed_disappeared.append(_address)
msg = "This is a test"
raise ValueError(msg)
ok_disappeared: list[str] = []
def _ok_callback(_address: str) -> None:
"""Ok callback."""
ok_disappeared.append(_address)
cancel1 = manager.async_register_disappeared_callback(_failing_callback)
# Make sure the second callback still works if the first one fails and
# raises an exception
cancel2 = manager.async_register_disappeared_callback(_ok_callback)
switchbot_adv_signal_100 = generate_advertisement_data(
local_name="wohand_signal_100",
manufacturer_data={123: b"abc"},
service_uuids=[],
rssi=-80,
)
inject_advertisement_with_source(
switchbot_device_signal_100, switchbot_adv_signal_100, "hci1"
)
future_time = utcnow() + timedelta(seconds=3600)
future_monotonic_time = time.monotonic() + 3600
with (
freeze_time(future_time),
patch(
"habluetooth.manager.monotonic_time_coarse",
return_value=future_monotonic_time,
),
):
manager._async_check_unavailable()
async_fire_time_changed(future_time)
assert len(ok_disappeared) == 1
assert ok_disappeared[0] == address
assert len(failed_disappeared) == 1
assert failed_disappeared[0] == address
cancel1()
cancel2()
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_check_unavailable_materializes_each_scanner_once() -> None:
"""
_async_check_unavailable must hit each scanner's discovered_addresses once.
Regression for https://github.com/Bluetooth-Devices/habluetooth/issues/505:
the prior two-pass loop accessed every connectable scanner twice per
cycle, and ``HaScanner.discovered_addresses`` rebuilds bleak's
discovered-devices dict on every access.
"""
manager = get_manager()
address = "44:44:33:11:23:12"
connectable_calls = 0
non_connectable_calls = 0
class CountingConnectable(FakeScanner):
@property
def discovered_addresses(self) -> Iterable[str]:
nonlocal connectable_calls
connectable_calls += 1
return (address,)
class CountingNonConnectable(FakeScanner):
@property
def discovered_addresses(self) -> Iterable[str]:
nonlocal non_connectable_calls
non_connectable_calls += 1
return (address,)
connectable = CountingConnectable("hci0", "hci0", connectable=True)
non_connectable = CountingNonConnectable("hci1", "hci1", connectable=False)
cancel_c = manager.async_register_scanner(connectable)
cancel_n = manager.async_register_scanner(non_connectable)
manager._async_check_unavailable()
assert connectable_calls == 1
assert non_connectable_calls == 1
cancel_c()
cancel_n()
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_async_register_allocation_callback(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test bluetooth async_register_allocation_callback handles failures."""
manager = get_manager()
assert manager._loop is not None
address = "44:44:33:11:23:12"
switchbot_device_signal_100 = generate_ble_device(
address, "wohand_signal_100", rssi=-100
)
switchbot_adv_signal_100 = generate_advertisement_data(
local_name="wohand_signal_100", service_uuids=[]
)
inject_advertisement_with_source(
switchbot_device_signal_100, switchbot_adv_signal_100, "hci0"
)
failed_allocations: list[HaBluetoothSlotAllocations] = []
def _failing_callback(allocations: HaBluetoothSlotAllocations) -> None:
"""Failing callback."""
failed_allocations.append(allocations)
msg = "This is a test"
raise ValueError(msg)
ok_allocations: list[HaBluetoothSlotAllocations] = []
def _ok_callback(allocations: HaBluetoothSlotAllocations) -> None:
"""Ok callback."""
ok_allocations.append(allocations)
cancel1 = manager.async_register_allocation_callback(_failing_callback)
# Make sure the second callback still works if the first one fails and
# raises an exception
cancel2 = manager.async_register_allocation_callback(_ok_callback)
switchbot_adv_signal_100 = generate_advertisement_data(
local_name="wohand_signal_100",
manufacturer_data={123: b"abc"},
service_uuids=[],
rssi=-80,
)
inject_advertisement_with_source(
switchbot_device_signal_100, switchbot_adv_signal_100, "hci1"
)
assert manager.async_current_allocations() == [
HaBluetoothSlotAllocations(
source="AA:BB:CC:DD:EE:00", slots=5, free=5, allocated=[]
),
HaBluetoothSlotAllocations(
source="AA:BB:CC:DD:EE:11", slots=5, free=5, allocated=[]
),
]
manager.async_on_allocation_changed(
Allocations(
"AA:BB:CC:DD:EE:00",
5,
4,
["44:44:33:11:23:12"],
)
)
assert len(ok_allocations) == 1
assert ok_allocations[0] == HaBluetoothSlotAllocations(
"AA:BB:CC:DD:EE:00",
5,
4,
["44:44:33:11:23:12"],
)
assert len(failed_allocations) == 1
assert failed_allocations[0] == HaBluetoothSlotAllocations(
"AA:BB:CC:DD:EE:00",
5,
4,
["44:44:33:11:23:12"],
)
with patch.object(
manager.slot_manager,
"get_allocations",
return_value=Allocations(
adapter="hci0",
slots=5,
free=4,
allocated=["44:44:33:11:23:12"],
),
):
manager.slot_manager._call_callbacks(
AllocationChange.ALLOCATED, "/org/bluez/hci0/dev_44_44_33_11_23_12"
)
assert len(ok_allocations) == 2
assert manager.async_current_allocations() == [
HaBluetoothSlotAllocations("AA:BB:CC:DD:EE:00", 5, 4, ["44:44:33:11:23:12"]),
HaBluetoothSlotAllocations(
source="AA:BB:CC:DD:EE:11", slots=5, free=5, allocated=[]
),
]
assert manager.async_current_allocations("AA:BB:CC:DD:EE:00") == [
HaBluetoothSlotAllocations("AA:BB:CC:DD:EE:00", 5, 4, ["44:44:33:11:23:12"]),
]
cancel1()
cancel2()
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_async_register_allocation_callback_non_connectable(
register_non_connectable_scanner: None,
) -> None:
"""Test async_current_allocations for a non-connectable scanner."""
manager = get_manager()
assert manager._loop is not None
assert manager.async_current_allocations() == [
HaBluetoothSlotAllocations(
source="AA:BB:CC:DD:EE:FF",
slots=0,
free=0,
allocated=[],
),
]
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_async_register_scanner_registration_callback(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test bluetooth async_register_scanner_registration_callback handles failures."""
manager = get_manager()
assert manager._loop is not None
scanners = manager.async_current_scanners()
assert len(scanners) == 2
sources = {scanner.source for scanner in scanners}
assert sources == {"AA:BB:CC:DD:EE:00", "AA:BB:CC:DD:EE:11"}
failed_scanner_callbacks: list[HaScannerRegistration] = []
def _failing_callback(scanner_registration: HaScannerRegistration) -> None:
"""Failing callback."""
failed_scanner_callbacks.append(scanner_registration)
msg = "This is a test"
raise ValueError(msg)
ok_scanner_callbacks: list[HaScannerRegistration] = []
def _ok_callback(scanner_registration: HaScannerRegistration) -> None:
"""Ok callback."""
ok_scanner_callbacks.append(scanner_registration)
cancel1 = manager.async_register_scanner_registration_callback(
_failing_callback, None
)
# Make sure the second callback still works if the first one fails and
# raises an exception
cancel2 = manager.async_register_scanner_registration_callback(_ok_callback, None)
hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3")
hci3_scanner.connectable = True
manager = get_manager()
cancel = manager.async_register_scanner(hci3_scanner, connection_slots=5)
assert len(ok_scanner_callbacks) == 1
assert ok_scanner_callbacks[0] == HaScannerRegistration(
HaScannerRegistrationEvent.ADDED, hci3_scanner
)
assert len(failed_scanner_callbacks) == 1
cancel()
assert len(ok_scanner_callbacks) == 2
assert ok_scanner_callbacks[1] == HaScannerRegistration(
HaScannerRegistrationEvent.REMOVED, hci3_scanner
)
cancel1()
cancel2()
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_async_register_scanner_mode_change_callback(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test bluetooth async_register_scanner_mode_change_callback handles failures."""
manager = get_manager()
assert manager._loop is not None
scanners = manager.async_current_scanners()
assert len(scanners) == 2
scanner = scanners[0]
failed_mode_callbacks: list[HaScannerModeChange] = []
def _failing_callback(mode_change: HaScannerModeChange) -> None:
"""Failing callback."""
failed_mode_callbacks.append(mode_change)
msg = "This is a test"
raise ValueError(msg)
ok_mode_callbacks: list[HaScannerModeChange] = []
def _ok_callback(mode_change: HaScannerModeChange) -> None:
"""Ok callback."""
ok_mode_callbacks.append(mode_change)
cancel1 = manager.async_register_scanner_mode_change_callback(
_failing_callback, None
)
# Make sure the second callback still works if the first one fails and
# raises an exception
cancel2 = manager.async_register_scanner_mode_change_callback(_ok_callback, None)
# Test specific source callback
source_specific_callbacks: list[HaScannerModeChange] = []
def _source_specific_callback(mode_change: HaScannerModeChange) -> None:
"""Source specific callback."""
source_specific_callbacks.append(mode_change)
cancel3 = manager.async_register_scanner_mode_change_callback(
_source_specific_callback, scanner.source
)
# Change requested mode
scanner.set_requested_mode(BluetoothScanningMode.ACTIVE)
assert len(ok_mode_callbacks) == 1
assert ok_mode_callbacks[0].scanner == scanner
assert ok_mode_callbacks[0].requested_mode == BluetoothScanningMode.ACTIVE
assert ok_mode_callbacks[0].current_mode == scanner.current_mode
assert len(failed_mode_callbacks) == 1
assert len(source_specific_callbacks) == 1
# Change current mode
scanner.set_current_mode(BluetoothScanningMode.ACTIVE)
assert len(ok_mode_callbacks) == 2
assert ok_mode_callbacks[1].scanner == scanner
assert ok_mode_callbacks[1].current_mode == BluetoothScanningMode.ACTIVE
assert len(failed_mode_callbacks) == 2
assert len(source_specific_callbacks) == 2
# No change when setting the same mode
scanner.set_current_mode(BluetoothScanningMode.ACTIVE)
assert len(ok_mode_callbacks) == 2
cancel1()
cancel2()
cancel3()
@pytest.mark.asyncio
async def test_async_register_scanner_with_connection_slots() -> None:
"""Test registering a scanner with connection slots."""
manager = get_manager()
assert manager._loop is not None
scanners = manager.async_current_scanners()
assert len(scanners) == 0
hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3")
hci3_scanner.connectable = True
manager = get_manager()
cancel = manager.async_register_scanner(hci3_scanner, connection_slots=5)
assert manager.async_current_allocations(hci3_scanner.source) == [
HaBluetoothSlotAllocations(hci3_scanner.source, 5, 5, [])
]
cancel()
@pytest.mark.asyncio
async def test_async_unregister_scanner_is_idempotent(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Double-invoking the cancel callback must not raise."""
manager = get_manager()
hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3")
hci3_scanner.connectable = True
cancel = manager.async_register_scanner(hci3_scanner, connection_slots=5)
cancel()
assert hci3_scanner not in manager.async_current_scanners()
with caplog.at_level("DEBUG", logger="habluetooth.manager"):
cancel()
assert any("already unregistered" in record.message for record in caplog.records)
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_diagnostics(register_hci0_scanner: None) -> None:
"""Test bluetooth diagnostics."""
manager = get_manager()
assert manager._loop is not None
manager.async_on_allocation_changed(
Allocations(
"AA:BB:CC:DD:EE:00",
5,
4,
["44:44:33:11:23:12"],
)
)
diagnostics = await manager.async_diagnostics()
assert diagnostics == {
"adapters": {},
"advertisement_tracker": ANY,
"all_history": ANY,
"allocations": {
"AA:BB:CC:DD:EE:00": {
"allocated": ["44:44:33:11:23:12"],
"free": 4,
"slots": 5,
"source": "AA:BB:CC:DD:EE:00",
}
},
"auto_scheduler": ANY,
"connectable_history": ANY,
"scanners": [
{
"connect_failures": {},
"connect_in_progress": {},
"connect_completed_total": 0,
"connect_failed_total": 0,
"last_connect_completed_time": 0.0,
"discovered_devices_and_advertisement_data": [],
"connectable": True,
"current_mode": None,
"requested_mode": None,
"last_detection": 0.0,
"monotonic_time": ANY,
"name": "hci0 (AA:BB:CC:DD:EE:00)",
"scanning": True,
"source": "AA:BB:CC:DD:EE:00",
"start_time": 0.0,
"type": "FakeScanner",
}
],
"slot_manager": {
"adapter_slots": {"hci0": 5},
"allocations_by_adapter": {"hci0": []},
"manager": False,
},
}
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_advertisements_do_not_switch_adapters_for_no_reason(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test we only switch adapters when needed."""
address = "44:44:33:11:23:12"
switchbot_device_signal_100 = generate_ble_device(
address, "wohand_signal_100", rssi=-100
)
switchbot_adv_signal_100 = generate_advertisement_data(
local_name="wohand_signal_100", service_uuids=[]
)
inject_advertisement_with_source(
switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_signal_100
)
switchbot_device_signal_99 = generate_ble_device(
address, "wohand_signal_99", rssi=-99
)
switchbot_adv_signal_99 = generate_advertisement_data(
local_name="wohand_signal_99", service_uuids=[]
)
inject_advertisement_with_source(
switchbot_device_signal_99, switchbot_adv_signal_99, HCI0_SOURCE_ADDRESS
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_signal_99
)
switchbot_device_signal_98 = generate_ble_device(
address, "wohand_good_signal", rssi=-98
)
switchbot_adv_signal_98 = generate_advertisement_data(
local_name="wohand_good_signal", service_uuids=[]
)
inject_advertisement_with_source(
switchbot_device_signal_98, switchbot_adv_signal_98, HCI1_SOURCE_ADDRESS
)
# should not switch to hci1
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_signal_99
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_switching_adapters_based_on_rssi(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test switching adapters based on rssi."""
address = "44:44:33:11:23:45"
switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal")
switchbot_adv_poor_signal = generate_advertisement_data(
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
)
inject_advertisement_with_source(
switchbot_device_poor_signal,
switchbot_adv_poor_signal,
HCI0_SOURCE_ADDRESS,
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal
)
switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal")
switchbot_adv_good_signal = generate_advertisement_data(
local_name="wohand_good_signal", service_uuids=[], rssi=-60
)
inject_advertisement_with_source(
switchbot_device_good_signal,
switchbot_adv_good_signal,
HCI1_SOURCE_ADDRESS,
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
inject_advertisement_with_source(
switchbot_device_good_signal,
switchbot_adv_poor_signal,
HCI0_SOURCE_ADDRESS,
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
# We should not switch adapters unless the signal hits the threshold
switchbot_device_similar_signal = generate_ble_device(
address, "wohand_similar_signal"
)
switchbot_adv_similar_signal = generate_advertisement_data(
local_name="wohand_similar_signal", service_uuids=[], rssi=-62
)
inject_advertisement_with_source(
switchbot_device_similar_signal,
switchbot_adv_similar_signal,
HCI0_SOURCE_ADDRESS,
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_switching_adapters_based_on_zero_rssi(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test switching adapters based on zero rssi."""
address = "44:44:33:11:23:45"
switchbot_device_no_rssi = generate_ble_device(address, "wohand_poor_signal")
switchbot_adv_no_rssi = generate_advertisement_data(
local_name="wohand_no_rssi", service_uuids=[], rssi=0
)
inject_advertisement_with_source(
switchbot_device_no_rssi, switchbot_adv_no_rssi, HCI0_SOURCE_ADDRESS
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_no_rssi
)
switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal")
switchbot_adv_good_signal = generate_advertisement_data(
local_name="wohand_good_signal", service_uuids=[], rssi=-60
)
inject_advertisement_with_source(
switchbot_device_good_signal,
switchbot_adv_good_signal,
HCI1_SOURCE_ADDRESS,
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
inject_advertisement_with_source(
switchbot_device_good_signal, switchbot_adv_no_rssi, HCI0_SOURCE_ADDRESS
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
# We should not switch adapters unless the signal hits the threshold
switchbot_device_similar_signal = generate_ble_device(
address, "wohand_similar_signal"
)
switchbot_adv_similar_signal = generate_advertisement_data(
local_name="wohand_similar_signal", service_uuids=[], rssi=-62
)
inject_advertisement_with_source(
switchbot_device_similar_signal,
switchbot_adv_similar_signal,
HCI0_SOURCE_ADDRESS,
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_switching_adapters_based_on_stale(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test switching adapters based on the previous advertisement being stale."""
address = "44:44:33:11:23:41"
start_time_monotonic = 50.0
switchbot_device_poor_signal_hci0 = generate_ble_device(
address, "wohand_poor_signal_hci0"
)
switchbot_adv_poor_signal_hci0 = generate_advertisement_data(
local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100
)
inject_advertisement_with_time_and_source(
switchbot_device_poor_signal_hci0,
switchbot_adv_poor_signal_hci0,
start_time_monotonic,
HCI0_SOURCE_ADDRESS,
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal_hci0
)
switchbot_device_poor_signal_hci1 = generate_ble_device(
address, "wohand_poor_signal_hci1"
)
switchbot_adv_poor_signal_hci1 = generate_advertisement_data(
local_name="wohand_poor_signal_hci1", service_uuids=[], rssi=-99
)
inject_advertisement_with_time_and_source(
switchbot_device_poor_signal_hci1,
switchbot_adv_poor_signal_hci1,
start_time_monotonic,
HCI1_SOURCE_ADDRESS,
)
# Should not switch adapters until the advertisement is stale
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal_hci0
)
# Should switch to hci1 since the previous advertisement is stale
# even though the signal is poor because the device is now
# likely unreachable via hci0
inject_advertisement_with_time_and_source(
switchbot_device_poor_signal_hci1,
switchbot_adv_poor_signal_hci1,
start_time_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1,
"hci1",
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal_hci1
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_switching_adapters_based_on_stale_with_discovered_interval(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test switching with discovered interval."""
address = "44:44:33:11:23:41"
start_time_monotonic = 50.0
switchbot_device_poor_signal_hci0 = generate_ble_device(
address, "wohand_poor_signal_hci0"
)
switchbot_adv_poor_signal_hci0 = generate_advertisement_data(
local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100
)
inject_advertisement_with_time_and_source(
switchbot_device_poor_signal_hci0,
switchbot_adv_poor_signal_hci0,
start_time_monotonic,
HCI0_SOURCE_ADDRESS,
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal_hci0
)
get_manager().async_set_fallback_availability_interval(address, 10)
switchbot_device_poor_signal_hci1 = generate_ble_device(
address, "wohand_poor_signal_hci1"
)
switchbot_adv_poor_signal_hci1 = generate_advertisement_data(
local_name="wohand_poor_signal_hci1", service_uuids=[], rssi=-99
)
inject_advertisement_with_time_and_source(
switchbot_device_poor_signal_hci1,
switchbot_adv_poor_signal_hci1,
start_time_monotonic,
HCI1_SOURCE_ADDRESS,
)
# Should not switch adapters until the advertisement is stale
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal_hci0
)
inject_advertisement_with_time_and_source(
switchbot_device_poor_signal_hci1,
switchbot_adv_poor_signal_hci1,
start_time_monotonic + 10 + 1,
HCI1_SOURCE_ADDRESS,
)
# Should not switch yet since we are not within the
# wobble period
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal_hci0
)
inject_advertisement_with_time_and_source(
switchbot_device_poor_signal_hci1,
switchbot_adv_poor_signal_hci1,
start_time_monotonic + 10 + TRACKER_BUFFERING_WOBBLE_SECONDS + 1,
HCI1_SOURCE_ADDRESS,
)
# Should switch to hci1 since the previous advertisement is stale
# even though the signal is poor because the device is now
# likely unreachable via hci0
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal_hci1
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_switching_adapters_based_on_rssi_connectable_to_non_connectable(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""Test switching adapters based on rssi from connectable to non connectable."""
address = "44:44:33:11:23:45"
now = time.monotonic()
switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal")
switchbot_adv_poor_signal = generate_advertisement_data(
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
)
inject_advertisement_with_time_and_source_connectable(
switchbot_device_poor_signal,
switchbot_adv_poor_signal,
now,
HCI0_SOURCE_ADDRESS,
True,
)
assert (
get_manager().async_ble_device_from_address(address, False)
is switchbot_device_poor_signal
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal
)
switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal")
switchbot_adv_good_signal = generate_advertisement_data(
local_name="wohand_good_signal", service_uuids=[], rssi=-60
)
inject_advertisement_with_time_and_source_connectable(
switchbot_device_good_signal,
switchbot_adv_good_signal,
now,
"hci1",
False,
)
assert (
get_manager().async_ble_device_from_address(address, False)
is switchbot_device_good_signal
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal
)
inject_advertisement_with_time_and_source_connectable(
switchbot_device_good_signal,
switchbot_adv_poor_signal,
now,
"hci0",
False,
)
assert (
get_manager().async_ble_device_from_address(address, False)
is switchbot_device_good_signal
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal
)
switchbot_device_excellent_signal = generate_ble_device(
address, "wohand_excellent_signal"
)
switchbot_adv_excellent_signal = generate_advertisement_data(
local_name="wohand_excellent_signal", service_uuids=[], rssi=-25
)
inject_advertisement_with_time_and_source_connectable(
switchbot_device_excellent_signal,
switchbot_adv_excellent_signal,
now,
"hci2",
False,
)
assert (
get_manager().async_ble_device_from_address(address, False)
is switchbot_device_excellent_signal
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_connectable_advertisement_can_be_retrieved_best_path_is_non_connectable(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""
Test we can still get a connectable BLEDevice when the best path is non-connectable.
In this case the device is closer to a non-connectable scanner, but the
at least one connectable scanner has the device in range.
"""
address = "44:44:33:11:23:45"
now = time.monotonic()
switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal")
switchbot_adv_good_signal = generate_advertisement_data(
local_name="wohand_good_signal", service_uuids=[], rssi=-60
)
inject_advertisement_with_time_and_source_connectable(
switchbot_device_good_signal,
switchbot_adv_good_signal,
now,
HCI1_SOURCE_ADDRESS,
False,
)
assert (
get_manager().async_ble_device_from_address(address, False)
is switchbot_device_good_signal
)
assert get_manager().async_ble_device_from_address(address, True) is None
switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal")
switchbot_adv_poor_signal = generate_advertisement_data(
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
)
inject_advertisement_with_time_and_source_connectable(
switchbot_device_poor_signal,
switchbot_adv_poor_signal,
now,
HCI0_SOURCE_ADDRESS,
True,
)
assert (
get_manager().async_ble_device_from_address(address, False)
is switchbot_device_good_signal
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_switching_adapters_when_one_goes_away(
register_hci0_scanner: None,
) -> None:
"""Test switching adapters when one goes away."""
cancel_hci2 = get_manager().async_register_scanner(FakeScanner("hci2", "hci2"))
address = "44:44:33:11:23:45"
switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal")
switchbot_adv_good_signal = generate_advertisement_data(
local_name="wohand_good_signal", service_uuids=[], rssi=-60
)
inject_advertisement_with_source(
switchbot_device_good_signal, switchbot_adv_good_signal, "hci2"
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal")
switchbot_adv_poor_signal = generate_advertisement_data(
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
)
inject_advertisement_with_source(
switchbot_device_poor_signal,
switchbot_adv_poor_signal,
HCI0_SOURCE_ADDRESS,
)
# We want to prefer the good signal when we have options
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
cancel_hci2()
inject_advertisement_with_source(
switchbot_device_poor_signal,
switchbot_adv_poor_signal,
HCI0_SOURCE_ADDRESS,
)
# Now that hci2 is gone, we should prefer the poor signal
# since no poor signal is better than no signal
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal
)
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_switching_adapters_when_one_stop_scanning(
register_hci0_scanner: None,
) -> None:
"""Test switching adapters when stops scanning."""
hci2_scanner = FakeScanner("hci2", "hci2")
cancel_hci2 = get_manager().async_register_scanner(hci2_scanner)
address = "44:44:33:11:23:45"
switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal")
switchbot_adv_good_signal = generate_advertisement_data(
local_name="wohand_good_signal", service_uuids=[], rssi=-60
)
inject_advertisement_with_source(
switchbot_device_good_signal, switchbot_adv_good_signal, "hci2"
)
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal")
switchbot_adv_poor_signal = generate_advertisement_data(
local_name="wohand_poor_signal", service_uuids=[], rssi=-100
)
inject_advertisement_with_source(
switchbot_device_poor_signal,
switchbot_adv_poor_signal,
HCI0_SOURCE_ADDRESS,
)
# We want to prefer the good signal when we have options
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_good_signal
)
hci2_scanner.scanning = False
inject_advertisement_with_source(
switchbot_device_poor_signal,
switchbot_adv_poor_signal,
HCI0_SOURCE_ADDRESS,
)
# Now that hci2 has stopped scanning, we should prefer the poor signal
# since poor signal is better than no signal
assert (
get_manager().async_ble_device_from_address(address, True)
is switchbot_device_poor_signal
)
cancel_hci2()
@pytest.mark.usefixtures("enable_bluetooth", "macos_adapter")
@pytest.mark.asyncio
async def test_set_fallback_interval_small() -> None:
"""Test we can set the fallback advertisement interval."""
assert (
get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12")
is None
)
get_manager().async_set_fallback_availability_interval("44:44:33:11:23:12", 2.0)
assert (
get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12")
== 2.0
)
start_monotonic_time = time.monotonic()
switchbot_device = generate_ble_device("44:44:33:11:23:12", "wohand")
switchbot_adv = generate_advertisement_data(
local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
switchbot_device_went_unavailable = False
inject_advertisement_with_time_and_source(
switchbot_device,
switchbot_adv,
start_monotonic_time,
SOURCE_LOCAL,
)
def _switchbot_device_unavailable_callback(
_address: BluetoothServiceInfoBleak,
) -> None:
"""Switchbot device unavailable callback."""
nonlocal switchbot_device_went_unavailable
switchbot_device_went_unavailable = True
assert (
get_manager().async_get_learned_advertising_interval("44:44:33:11:23:12")
is None
)
switchbot_device_unavailable_cancel = get_manager().async_track_unavailable(
_switchbot_device_unavailable_callback,
switchbot_device.address,
connectable=False,
)
monotonic_now = start_monotonic_time + 2
with patch_bluetooth_time(
monotonic_now + UNAVAILABLE_TRACK_SECONDS,
):
async_fire_time_changed(utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS))
await asyncio.sleep(0)
assert switchbot_device_went_unavailable is True
switchbot_device_unavailable_cancel()
# We should forget fallback interval after it expires
assert (
get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12")
is None
)
@pytest.mark.usefixtures("enable_bluetooth", "macos_adapter")
@pytest.mark.asyncio
async def test_set_fallback_interval_big() -> None:
"""Test we can set the fallback advertisement interval."""
assert (
get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12")
is None
)
# Force the interval to be really big and check it doesn't expire using
# the default timeout of 900 seconds.
get_manager().async_set_fallback_availability_interval(
"44:44:33:11:23:12", 604800.0
)
assert (
get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12")
== 604800.0
)
start_monotonic_time = time.monotonic()
switchbot_device = generate_ble_device("44:44:33:11:23:12", "wohand")
switchbot_adv = generate_advertisement_data(
local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
switchbot_device_went_unavailable = False
inject_advertisement_with_time_and_source(
switchbot_device,
switchbot_adv,
start_monotonic_time,
SOURCE_LOCAL,
)
def _switchbot_device_unavailable_callback(
_address: BluetoothServiceInfoBleak,
) -> None:
"""Switchbot device unavailable callback."""
nonlocal switchbot_device_went_unavailable
switchbot_device_went_unavailable = True
assert (
get_manager().async_get_learned_advertising_interval("44:44:33:11:23:12")
is None
)
switchbot_device_unavailable_cancel = get_manager().async_track_unavailable(
_switchbot_device_unavailable_callback,
switchbot_device.address,
connectable=False,
)
# Check that device hasn't expired after a day
monotonic_now = start_monotonic_time + 86400
with patch_bluetooth_time(
monotonic_now + UNAVAILABLE_TRACK_SECONDS,
):
async_fire_time_changed(utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS))
await asyncio.sleep(0)
assert switchbot_device_went_unavailable is False
# Try again after it has expired
monotonic_now = start_monotonic_time + 604800
with patch_bluetooth_time(
monotonic_now + UNAVAILABLE_TRACK_SECONDS,
):
async_fire_time_changed(utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS))
await asyncio.sleep(0)
assert switchbot_device_went_unavailable is True
switchbot_device_unavailable_cancel() # type: ignore[unreachable]
# We should forget fallback interval after it expires
assert (
get_manager().async_get_fallback_availability_interval("44:44:33:11:23:12")
is None
)
@pytest.mark.asyncio
async def test_subclassing_bluetooth_manager(caplog: pytest.LogCaptureFixture) -> None:
"""Test subclassing BluetoothManager."""
slot_manager = BleakSlotManager()
bluetooth_adapters = FakeBluetoothAdapters()
class TestBluetoothManager(BluetoothManager):
"""
Test class for BluetoothManager.
This class implements _discover_service_info.
"""
def _discover_service_info(
self, service_info: BluetoothServiceInfoBleak
) -> None:
"""
Discover a new service info.
This method is intended to be overridden by subclasses.
"""
TestBluetoothManager(bluetooth_adapters, slot_manager)
assert "does not implement _discover_service_info" not in caplog.text
class TestBluetoothManager2(BluetoothManager):
"""
Test class for BluetoothManager.
This class does not implement _discover_service_info.
"""
TestBluetoothManager2(bluetooth_adapters, slot_manager)
assert "does not implement _discover_service_info" in caplog.text
@pytest.mark.asyncio
async def test_is_operating_degraded_on_linux_with_mgmt() -> None:
"""Test is_operating_degraded returns False on Linux with mgmt control."""
mock_bluetooth_adapters = FakeBluetoothAdapters()
manager = BluetoothManager(
mock_bluetooth_adapters,
slot_manager=Mock(),
)
with (
patch("habluetooth.manager.IS_LINUX", True),
patch.object(manager, "_mgmt_ctl", Mock()),
):
# Mock mgmt_ctl being available
assert manager.is_operating_degraded() is False
@pytest.mark.asyncio
async def test_is_operating_degraded_on_linux_without_mgmt() -> None:
"""Test is_operating_degraded returns True on Linux without mgmt control."""
mock_bluetooth_adapters = FakeBluetoothAdapters()
manager = BluetoothManager(
mock_bluetooth_adapters,
slot_manager=Mock(),
)
with patch("habluetooth.manager.IS_LINUX", True):
# mgmt_ctl is None by default
assert manager._mgmt_ctl is None
assert manager.is_operating_degraded() is True
@pytest.mark.asyncio
async def test_is_operating_degraded_on_non_linux() -> None:
"""Test is_operating_degraded returns False on non-Linux systems."""
mock_bluetooth_adapters = FakeBluetoothAdapters()
manager = BluetoothManager(
mock_bluetooth_adapters,
slot_manager=Mock(),
)
with patch("habluetooth.manager.IS_LINUX", False):
# Should return False regardless of mgmt_ctl state
assert manager.is_operating_degraded() is False
# Even with mgmt_ctl set
manager._mgmt_ctl = Mock()
assert manager.is_operating_degraded() is False
@pytest.mark.asyncio
async def test_is_operating_degraded_after_permission_error() -> None:
"""Test is_operating_degraded after mgmt setup fails with permission error."""
mock_bluetooth_adapters = FakeBluetoothAdapters()
manager = BluetoothManager(
mock_bluetooth_adapters,
slot_manager=Mock(),
)
with (
patch("habluetooth.manager.IS_LINUX", True),
patch("habluetooth.manager.MGMTBluetoothCtl") as mock_mgmt_class,
):
# Make setup fail with permission error
mock_mgmt_instance = Mock()
mock_mgmt_instance.setup = AsyncMock(
side_effect=PermissionError("No permission")
)
mock_mgmt_class.return_value = mock_mgmt_instance
# Setup should handle the error and set mgmt_ctl to None
await manager.async_setup()
# Should be in degraded mode
assert manager._mgmt_ctl is None
assert manager.is_operating_degraded() is True
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_async_scanner_count_includes_non_connectable(
register_hci0_scanner: None,
register_non_connectable_scanner: None,
) -> None:
"""Connectable count excludes non-connectable; full count includes both."""
manager = get_manager()
assert manager.async_scanner_count(connectable=True) == 1
assert manager.async_scanner_count(connectable=False) == 2
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_async_address_present_non_connectable_history(
register_non_connectable_scanner: None,
) -> None:
"""async_address_present(connectable=False) reads the all-history map."""
manager = get_manager()
address = "44:44:33:11:23:99"
device = generate_ble_device(address, "wohand")
adv = generate_advertisement_data(local_name="wohand", service_uuids=[])
inject_advertisement_with_time_and_source_connectable(
device, adv, time.monotonic(), "AA:BB:CC:DD:EE:FF", False
)
assert manager.async_address_present(address, connectable=False) is True
assert manager.async_address_present(address, connectable=True) is False
missing = "00:00:00:00:00:00"
assert manager.async_address_present(missing, connectable=False) is False
@pytest.mark.asyncio
async def test_async_track_unavailable_connectable_branch() -> None:
"""connectable=True routes the callback to the connectable callback map."""
manager = get_manager()
def _cb(_info: BluetoothServiceInfoBleak) -> None:
return
address = "11:22:33:44:55:66"
cancel = manager.async_track_unavailable(_cb, address, connectable=True)
try:
assert _cb in manager._connectable_unavailable_callbacks[address]
assert address not in manager._unavailable_callbacks
finally:
cancel()
assert address not in manager._connectable_unavailable_callbacks
@pytest.mark.asyncio
async def test_async_current_allocations_unknown_source_returns_empty() -> None:
"""Querying an unknown source returns [] rather than None."""
manager = get_manager()
assert manager.async_current_allocations("not-a-real-source") == []
@pytest.mark.asyncio
async def test_async_recover_failed_adapters_skips_when_lock_held() -> None:
"""If recovery is already in flight, a concurrent call is a no-op."""
manager = get_manager()
with patch.object(
manager, "async_get_bluetooth_adapters", new=AsyncMock()
) as mock_get:
await manager._recovery_lock.acquire()
try:
await manager._async_recover_failed_adapters()
finally:
manager._recovery_lock.release()
mock_get.assert_not_called()
@pytest.mark.asyncio
async def test_async_get_bluetooth_adapters_cached_false_triggers_refresh() -> None:
"""cached=False forces a refresh of the underlying adapter source."""
manager = get_manager()
assert manager._bluetooth_adapters is not None
with patch.object(
manager._bluetooth_adapters, "refresh", new=AsyncMock()
) as mock_refresh:
await manager.async_get_bluetooth_adapters(cached=False)
mock_refresh.assert_awaited_once()
@pytest.mark.asyncio
async def test_async_refresh_adapters_propagates_exception_to_waiters() -> None:
"""Concurrent callers must see the refresh exception, not silent success."""
manager = get_manager()
assert manager._bluetooth_adapters is not None
refresh_started = asyncio.Event()
release_refresh = asyncio.Event()
async def slow_failing_refresh() -> None:
refresh_started.set()
await release_refresh.wait()
msg = "boom"
raise RuntimeError(msg)
with patch.object(manager._bluetooth_adapters, "refresh", new=slow_failing_refresh):
leader = asyncio.create_task(manager._async_refresh_adapters())
await refresh_started.wait()
waiter_a = asyncio.create_task(manager._async_refresh_adapters())
waiter_b = asyncio.create_task(manager._async_refresh_adapters())
# Yield so waiters register on the shared future.
await asyncio.sleep(0)
release_refresh.set()
with pytest.raises(RuntimeError, match="boom"):
await leader
with pytest.raises(RuntimeError, match="boom"):
await waiter_a
with pytest.raises(RuntimeError, match="boom"):
await waiter_b
# Shared future must be cleared so the next call refreshes again.
assert manager._adapter_refresh_future is None
@pytest.mark.asyncio
async def test_async_refresh_adapters_success_resolves_waiters() -> None:
"""Concurrent callers all see success and share the same refresh call."""
manager = get_manager()
assert manager._bluetooth_adapters is not None
refresh_started = asyncio.Event()
release_refresh = asyncio.Event()
call_count = 0
async def slow_refresh() -> None:
nonlocal call_count
call_count += 1
refresh_started.set()
await release_refresh.wait()
with patch.object(manager._bluetooth_adapters, "refresh", new=slow_refresh):
leader = asyncio.create_task(manager._async_refresh_adapters())
await refresh_started.wait()
waiter_a = asyncio.create_task(manager._async_refresh_adapters())
waiter_b = asyncio.create_task(manager._async_refresh_adapters())
await asyncio.sleep(0)
release_refresh.set()
await leader
await waiter_a
await waiter_b
assert call_count == 1
assert manager._adapter_refresh_future is None
@pytest.mark.asyncio
async def test_async_refresh_adapters_leader_cancellation_does_not_silently_succeed(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Leader cancellation must not let waiters proceed as if refresh succeeded."""
manager = get_manager()
assert manager._bluetooth_adapters is not None
refresh_started = asyncio.Event()
async def hanging_refresh() -> None:
refresh_started.set()
await asyncio.Event().wait() # never resolves
with patch.object(manager._bluetooth_adapters, "refresh", new=hanging_refresh):
leader = asyncio.create_task(manager._async_refresh_adapters())
await refresh_started.wait()
waiter = asyncio.create_task(manager._async_refresh_adapters())
await asyncio.sleep(0)
leader.cancel()
with pytest.raises(asyncio.CancelledError):
await leader
# Waiter must observe a CancelledError, not silently complete.
with pytest.raises(asyncio.CancelledError):
await waiter
assert manager._adapter_refresh_future is None
@pytest.mark.asyncio
async def test_async_refresh_adapters_waiter_cancellation_does_not_break_leader() -> (
None
):
"""Cancelling one waiter must not strand the leader or other siblings."""
manager = get_manager()
assert manager._bluetooth_adapters is not None
refresh_started = asyncio.Event()
release_refresh = asyncio.Event()
async def slow_refresh() -> None:
refresh_started.set()
await release_refresh.wait()
with patch.object(manager._bluetooth_adapters, "refresh", new=slow_refresh):
leader = asyncio.create_task(manager._async_refresh_adapters())
await refresh_started.wait()
waiter_a = asyncio.create_task(manager._async_refresh_adapters())
waiter_b = asyncio.create_task(manager._async_refresh_adapters())
await asyncio.sleep(0)
waiter_a.cancel()
with pytest.raises(asyncio.CancelledError):
await waiter_a
# Leader and surviving waiter must still complete normally.
release_refresh.set()
await leader
await waiter_b
assert manager._adapter_refresh_future is None
@pytest.mark.asyncio
async def test_async_refresh_adapters_adapters_property_failure_propagates() -> None:
"""Property access failure after refresh() must not strand waiters."""
manager = get_manager()
assert manager._bluetooth_adapters is not None
refresh_started = asyncio.Event()
release_refresh = asyncio.Event()
async def slow_refresh() -> None:
refresh_started.set()
await release_refresh.wait()
failing_adapters = PropertyMock(side_effect=RuntimeError("adapters boom"))
with (
patch.object(manager._bluetooth_adapters, "refresh", new=slow_refresh),
patch.object(type(manager._bluetooth_adapters), "adapters", failing_adapters),
):
leader = asyncio.create_task(manager._async_refresh_adapters())
await refresh_started.wait()
waiter = asyncio.create_task(manager._async_refresh_adapters())
await asyncio.sleep(0)
release_refresh.set()
with pytest.raises(RuntimeError, match="adapters boom"):
await leader
with pytest.raises(RuntimeError, match="adapters boom"):
await waiter
assert manager._adapter_refresh_future is None
@pytest.mark.asyncio
async def test_async_refresh_adapters_recovers_after_prior_failure() -> None:
"""Sequential call after a failed refresh must start fresh and succeed."""
manager = get_manager()
assert manager._bluetooth_adapters is not None
call_count = 0
async def flaky_refresh() -> None:
nonlocal call_count
call_count += 1
if call_count == 1:
msg = "first boom"
raise RuntimeError(msg)
with patch.object(manager._bluetooth_adapters, "refresh", new=flaky_refresh):
with pytest.raises(RuntimeError, match="first boom"):
await manager._async_refresh_adapters()
assert manager._adapter_refresh_future is None
# Second call must start a fresh refresh, not reuse stale future state.
await manager._async_refresh_adapters()
assert call_count == 2
assert manager._adapter_refresh_future is None
@pytest.mark.asyncio
async def test_address_reachability_diagnostics_connectable() -> None:
"""A connectable device in range reports its connectable scanner."""
manager = get_manager()
address = "44:44:33:11:23:45"
scanner = InjectableRemoteScanner(
"AA:BB:CC:DD:EE:FF", "Living Room Proxy", None, True
)
cancel = manager.async_register_scanner(scanner)
device = generate_ble_device(address, "wohand")
adv = generate_advertisement_data(local_name="wohand", rssi=-50)
scanner.inject_advertisement(device, adv)
diag = manager.async_address_reachability_diagnostics(
address, BluetoothReachabilityIntent.CONNECTION
)
# The address is intentionally not embedded; callers already have it.
assert address not in diag
assert "in connectable history" in diag
assert "1 scanner(s) registered, 1 scanning, 1 connectable" in diag
assert "Living Room Proxy (AA:BB:CC:DD:EE:FF) (connectable=True, rssi=-50" in diag
# The "via" source resolves to the scanner name rather than a bare address.
assert "last advertisement" in diag
assert "via Living Room Proxy (AA:BB:CC:DD:EE:FF)" in diag
cancel()
@pytest.mark.asyncio
async def test_address_reachability_diagnostics_non_connectable_only() -> None:
"""A device only seen by a non-connectable scanner has no connectable path."""
manager = get_manager()
address = "44:44:33:11:23:46"
connectable = InjectableRemoteScanner("hci0", "hci0", None, True)
cancel_c = manager.async_register_scanner(connectable)
non_connectable = InjectableRemoteScanner("proxy", "proxy", None, False)
cancel_n = manager.async_register_scanner(non_connectable)
device = generate_ble_device(address, "wohand")
adv = generate_advertisement_data(local_name="wohand", rssi=-70)
non_connectable.inject_advertisement(device, adv)
diag = manager.async_address_reachability_diagnostics(
address, BluetoothReachabilityIntent.CONNECTION
)
assert "only in non-connectable history (no connectable path)" in diag
assert "seen by 1 scanner(s) but none with a connectable path" in diag
assert "proxy (connectable=False, rssi=-70" in diag
cancel_c()
cancel_n()
@pytest.mark.asyncio
async def test_address_reachability_diagnostics_advertisement_intent() -> None:
"""An advertisement intent ignores connectable paths and slots."""
manager = get_manager()
address = "44:44:33:11:23:4a"
non_connectable = InjectableRemoteScanner("proxy", "proxy", None, False)
cancel = manager.async_register_scanner(non_connectable)
device = generate_ble_device(address, "wohand")
adv = generate_advertisement_data(local_name="wohand", rssi=-70)
non_connectable.inject_advertisement(device, adv)
diag = manager.async_address_reachability_diagnostics(
address, BluetoothReachabilityIntent.PASSIVE_ADVERTISEMENT
)
assert "advertising, seen by 1 scanner(s)" in diag
assert "no connectable path" not in diag
assert "slots" not in diag
# ACTIVE_ADVERTISEMENT is treated the same as PASSIVE_ADVERTISEMENT for now.
assert diag == manager.async_address_reachability_diagnostics(
address, BluetoothReachabilityIntent.ACTIVE_ADVERTISEMENT
)
cancel()
@pytest.mark.parametrize(
"intent",
[
BluetoothReachabilityIntent.CONNECTION,
BluetoothReachabilityIntent.PASSIVE_ADVERTISEMENT,
BluetoothReachabilityIntent.ACTIVE_ADVERTISEMENT,
],
)
@pytest.mark.asyncio
async def test_address_reachability_diagnostics_unknown(
intent: BluetoothReachabilityIntent,
) -> None:
"""An address never seen reports as unknown for every intent."""
manager = get_manager()
diag = manager.async_address_reachability_diagnostics("44:44:33:11:23:47", intent)
assert "unknown (never seen by any scanner)" in diag
@pytest.mark.asyncio
async def test_address_reachability_diagnostics_no_connectable_scanners() -> None:
"""With only a non-connectable scanner the connectable count is zero."""
manager = get_manager()
address = "44:44:33:11:23:48"
non_connectable = InjectableRemoteScanner("proxy", "proxy", None, False)
cancel = manager.async_register_scanner(non_connectable)
device = generate_ble_device(address, "wohand")
adv = generate_advertisement_data(local_name="wohand", rssi=-70)
non_connectable.inject_advertisement(device, adv)
diag = manager.async_address_reachability_diagnostics(
address, BluetoothReachabilityIntent.CONNECTION
)
assert "1 scanner(s) registered, 1 scanning, 0 connectable" in diag
cancel()
@pytest.mark.asyncio
async def test_address_reachability_diagnostics_out_of_slots() -> None:
"""A connectable scanner with no free slots is reported as full."""
manager = get_manager()
address = "44:44:33:11:23:49"
scanner = InjectableRemoteScanner("esphome_proxy", "esphome_proxy", None, True)
cancel = manager.async_register_scanner(scanner)
device = generate_ble_device(address, "wohand")
adv = generate_advertisement_data(local_name="wohand", rssi=-50)
scanner.inject_advertisement(device, adv)
with patch.object(
scanner,
"get_allocations",
return_value=Allocations("esphome_proxy", 3, 0, []),
):
diag = manager.async_address_reachability_diagnostics(
address, BluetoothReachabilityIntent.CONNECTION
)
assert "connectable scanner(s) that report slot allocations are all full" in diag
assert "slots=0/3" in diag
cancel()
@pytest.mark.asyncio
async def test_address_reachability_diagnostics_in_history_no_scanner() -> None:
"""An address in history but cached by no scanner is not called advertising."""
manager = get_manager()
address = "44:44:33:11:23:4c"
device = generate_ble_device(address, "wohand")
adv = generate_advertisement_data(local_name="wohand", rssi=-70)
# Injected from a source with no registered scanner; lands in history but
# no scanner currently has it cached.
inject_advertisement_with_source(device, adv, "ghost")
diag = manager.async_address_reachability_diagnostics(
address, BluetoothReachabilityIntent.PASSIVE_ADVERTISEMENT
)
assert "previously seen but no scanner currently has it cached" in diag
assert "advertising" not in diag
@pytest.mark.asyncio
async def test_address_reachability_diagnostics_all_scanners_connecting() -> None:
"""When every scanner is paused connecting, the device cannot be seen."""
manager = get_manager()
address = "44:44:33:11:23:4b"
scanner = InjectableRemoteScanner("esphome_proxy", "esphome_proxy", None, True)
cancel = manager.async_register_scanner(scanner)
with scanner.connecting():
assert scanner.scanning is False
diag = manager.async_address_reachability_diagnostics(
address, BluetoothReachabilityIntent.CONNECTION
)
assert "1 scanner(s) registered, 0 scanning, 1 connectable" in diag
assert "1 paused while connecting" in diag
assert "no scanner is currently scanning" in diag
assert "add more Bluetooth adapters or proxies" in diag
cancel()
@pytest.mark.asyncio
async def test_address_reachability_diagnostics_scanner_stopped_not_connecting() -> (
None
):
"""A stopped scanner (not connecting) reports no scanning without the advice."""
manager = get_manager()
scanner = InjectableRemoteScanner("esphome_proxy", "esphome_proxy", None, True)
cancel = manager.async_register_scanner(scanner)
scanner.scanning = False
diag = manager.async_address_reachability_diagnostics(
"44:44:33:11:23:4d", BluetoothReachabilityIntent.CONNECTION
)
assert "1 scanner(s) registered, 0 scanning, 1 connectable" in diag
assert "no scanner is currently scanning" in diag
assert "paused while connecting" not in diag
assert "add more Bluetooth adapters or proxies" not in diag
cancel()
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_bleak_callback_exception_is_logged_and_isolated(
register_hci0_scanner: None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A raising bleak callback is caught and logged; siblings still fire."""
manager = get_manager()
address = "44:44:33:11:23:01"
device = generate_ble_device(address, "wohand")
adv = generate_advertisement_data(local_name="wohand", service_uuids=[], rssi=-40)
received: list[Any] = []
def _failing(_device: Any, _adv: Any) -> None:
msg = "boom"
raise ValueError(msg)
def _ok(_device: Any, _adv: Any) -> None:
received.append(_device)
cancel_fail = manager.async_register_bleak_callback(_failing, {})
cancel_ok = manager.async_register_bleak_callback(_ok, {})
try:
# A single advertisement dispatch fans out to both callbacks in one
# loop; the failing one must not stop the ok one from firing.
inject_advertisement_with_source(device, adv, "hci0")
assert received # the ok callback fired despite the sibling raising
assert "Error in callback" in caplog.text
finally:
cancel_fail()
cancel_ok()
@pytest.mark.asyncio
async def test_supports_passive_scan_reflects_adapter_capability() -> None:
"""supports_passive_scan is True iff any adapter advertises passive scan."""
manager = BluetoothManager(FakeBluetoothAdapters(), Mock())
manager._adapters = {"hci0": {ADAPTER_PASSIVE_SCAN: False}}
assert manager.supports_passive_scan is False
manager._adapters = {
"hci0": {ADAPTER_PASSIVE_SCAN: False},
"hci1": {ADAPTER_PASSIVE_SCAN: True},
}
assert manager.supports_passive_scan is True
@pytest.mark.asyncio
async def test_get_bluetooth_adapters_cached_with_empty_cache() -> None:
"""cached=True still populates when the adapter cache is empty (no refresh)."""
adapters = FakeBluetoothAdapters()
manager = BluetoothManager(adapters, Mock())
with patch("habluetooth.manager.IS_LINUX", False):
await manager.async_setup()
try:
manager._adapters = {}
# cached=True with an empty cache repopulates straight from the backend
# without taking the refresh path.
with patch.object(adapters, "refresh", wraps=adapters.refresh) as spy:
result = await manager.async_get_bluetooth_adapters(cached=True)
spy.assert_not_called()
assert result == adapters.adapters
finally:
manager.async_stop()
@pytest.mark.asyncio
async def test_get_adapter_from_address_refreshes_when_not_found() -> None:
"""A miss triggers a refresh, then a second lookup."""
adapters = FakeBluetoothAdapters()
manager = BluetoothManager(adapters, Mock())
with patch("habluetooth.manager.IS_LINUX", False):
await manager.async_setup()
try:
manager._adapters = {}
# Unknown address: first lookup misses, a refresh runs, second lookup
# still misses against the empty fake backend.
with patch.object(adapters, "refresh", wraps=adapters.refresh) as spy:
assert (
await manager.async_get_adapter_from_address("00:00:00:00:00:09")
is None
)
spy.assert_called_once() # the miss forced a refresh
# Known address resolves on the first lookup.
manager._adapters = {"hci7": {ADAPTER_ADDRESS: "00:00:00:00:00:07"}}
assert (
await manager.async_get_adapter_from_address("00:00:00:00:00:07") == "hci7"
)
finally:
manager.async_stop()
@pytest.mark.asyncio
async def test_async_setup_assigns_central_manager_when_unset() -> None:
"""async_setup claims the central singleton when it is unset."""
original = CentralBluetoothManager.manager
manager = BluetoothManager(FakeBluetoothAdapters(), Mock())
try:
CentralBluetoothManager.manager = None
with patch("habluetooth.manager.IS_LINUX", False):
await manager.async_setup()
assert CentralBluetoothManager.manager is manager
finally:
CentralBluetoothManager.manager = original
manager.async_stop()
@pytest.mark.asyncio
async def test_async_setup_returns_early_on_non_linux() -> None:
"""On non-Linux, setup skips mgmt control entirely."""
manager = BluetoothManager(FakeBluetoothAdapters(), Mock())
with patch("habluetooth.manager.IS_LINUX", False):
await manager.async_setup()
# Inside the non-Linux patch, setup returned before touching mgmt.
assert manager._mgmt_ctl is None
assert manager.is_operating_degraded() is False
manager.async_stop()
@pytest.mark.asyncio
async def test_async_setup_handles_connection_error() -> None:
"""A CONNECTION_ERRORS failure during mgmt setup degrades gracefully."""
manager = BluetoothManager(FakeBluetoothAdapters(), Mock())
with (
patch("habluetooth.manager.IS_LINUX", True),
patch("habluetooth.manager.MGMTBluetoothCtl") as mock_mgmt_class,
):
mock_instance = Mock()
mock_instance.setup = AsyncMock(side_effect=OSError("no socket"))
mock_mgmt_class.return_value = mock_instance
await manager.async_setup()
try:
assert manager._mgmt_ctl is None
assert manager.has_advertising_side_channel is False
finally:
manager.async_stop()
@pytest.mark.asyncio
async def test_async_stop_without_unavailable_tracking() -> None:
"""async_stop is a no-op for unavailable tracking when none is scheduled."""
manager = BluetoothManager(FakeBluetoothAdapters(), Mock())
with patch("habluetooth.manager.IS_LINUX", False):
await manager.async_setup()
manager._cancel_unavailable_tracking = None
# Should not raise even though there is no tracking handle to cancel.
manager.async_stop()
assert manager.shutdown is True
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_unavailable_callback_exception_isolated(
register_hci0_scanner: None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A raising unavailable callback is logged; a sibling still fires."""
manager = get_manager()
address = "44:44:33:11:23:02"
start = time.monotonic()
device = generate_ble_device(address, "wohand")
adv = generate_advertisement_data(local_name="wohand", service_uuids=[], rssi=-60)
inject_advertisement_with_time_and_source_connectable(
device, adv, start, HCI0_SOURCE_ADDRESS, False
)
ok_calls: list[Any] = []
def _failing(_info: BluetoothServiceInfoBleak) -> None:
msg = "boom"
raise ValueError(msg)
def _ok(_info: BluetoothServiceInfoBleak) -> None:
ok_calls.append(_info)
cancel_fail = manager.async_track_unavailable(_failing, address, connectable=False)
cancel_ok = manager.async_track_unavailable(_ok, address, connectable=False)
try:
# Push the clock well past the fallback staleness window so the device
# is considered unavailable.
with patch_bluetooth_time(start + 100_000):
manager._async_check_unavailable()
assert len(ok_calls) == 1
assert "Error in unavailable callback" in caplog.text
finally:
cancel_fail()
cancel_ok()
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_remove_unavailable_callback_keeps_siblings(
register_hci0_scanner: None,
) -> None:
"""Cancelling one unavailable callback leaves the address entry in place."""
manager = get_manager()
address = "44:44:33:11:23:03"
def _cb_a(_info: BluetoothServiceInfoBleak) -> None:
return
def _cb_b(_info: BluetoothServiceInfoBleak) -> None:
return
cancel_a = manager.async_track_unavailable(_cb_a, address, connectable=False)
cancel_b = manager.async_track_unavailable(_cb_b, address, connectable=False)
try:
cancel_a()
# One callback remains, so the address bucket is not deleted.
assert address in manager._unavailable_callbacks
assert _cb_b in manager._unavailable_callbacks[address]
finally:
cancel_b()
assert address not in manager._unavailable_callbacks
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_unregister_source_callback_keeps_siblings(
register_hci0_scanner: None,
) -> None:
"""Cancelling one source-keyed callback leaves the source bucket in place."""
manager = get_manager()
def _cb_a(_change: HaScannerModeChange) -> None:
return
def _cb_b(_change: HaScannerModeChange) -> None:
return
cancel_a = manager.async_register_scanner_mode_change_callback(_cb_a, None)
cancel_b = manager.async_register_scanner_mode_change_callback(_cb_b, None)
try:
cancel_a()
# One callback remains under the None source, so it is not deleted.
assert None in manager._scanner_mode_change_callbacks
assert _cb_b in manager._scanner_mode_change_callbacks[None]
finally:
cancel_b()
assert None not in manager._scanner_mode_change_callbacks
# Cancelling again once the source bucket is gone is a no-op.
cancel_b()
assert None not in manager._scanner_mode_change_callbacks
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_should_keep_previous_adv_logs_when_debug_enabled(
register_hci0_scanner: None,
register_hci1_scanner: None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""With debug on, the keep-previous decision logs its switch reasons."""
manager = get_manager()
manager._debug = True
address = "44:44:33:11:23:04"
device = generate_ble_device(address, "wohand")
start = time.monotonic()
weak = generate_advertisement_data(local_name="wohand", service_uuids=[], rssi=-40)
inject_advertisement_with_time_and_source(device, weak, start, HCI0_SOURCE_ADDRESS)
with caplog.at_level(logging.DEBUG, logger="habluetooth.manager"):
# A clearly stronger reading from a second still-scanning source wins
# on RSSI (RSSI-switch debug branch).
strong = generate_advertisement_data(
local_name="wohand", service_uuids=[], rssi=-20
)
inject_advertisement_with_time_and_source(
device, strong, start + 1, HCI1_SOURCE_ADDRESS
)
assert "new rssi" in caplog.text
caplog.clear()
# A far-future reading makes the previous one stale, so any new
# advertisement wins regardless of RSSI (stale-switch debug branch).
inject_advertisement_with_time_and_source(
device, weak, start + 100_000, HCI0_SOURCE_ADDRESS
)
assert "time elapsed" in caplog.text
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_non_connectable_advertisement_rejected_in_favour_of_previous(
register_hci0_scanner: None,
register_hci1_scanner: None,
) -> None:
"""A weaker non-connectable reading is rejected without re-adding history."""
manager = get_manager()
address = "44:44:33:11:23:05"
device = generate_ble_device(address, "wohand")
start = time.monotonic()
strong = generate_advertisement_data(
local_name="wohand", service_uuids=[], rssi=-30
)
inject_advertisement_with_time_and_source_connectable(
device, strong, start, HCI0_SOURCE_ADDRESS, False
)
# A weaker, non-connectable reading from a second still-scanning source is
# rejected; the stronger hci0 reading stays in history.
weak = generate_advertisement_data(local_name="wohand", service_uuids=[], rssi=-95)
inject_advertisement_with_time_and_source_connectable(
device, weak, start + 1, HCI1_SOURCE_ADDRESS, False
)
kept = manager.async_last_service_info(address, connectable=False)
assert kept is not None
assert kept.source == HCI0_SOURCE_ADDRESS
assert kept.rssi == -30
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_should_keep_previous_adv_switches_without_debug_logging(
register_hci0_scanner: None,
register_hci1_scanner: None,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Switch decisions are silent when debug logging is disabled."""
manager = get_manager()
manager._debug = False
address = "44:44:33:11:23:06"
device = generate_ble_device(address, "wohand")
start = time.monotonic()
weak = generate_advertisement_data(local_name="wohand", service_uuids=[], rssi=-40)
inject_advertisement_with_time_and_source(device, weak, start, HCI0_SOURCE_ADDRESS)
with caplog.at_level(logging.DEBUG, logger="habluetooth.manager"):
# RSSI switch: a stronger second source wins.
strong = generate_advertisement_data(
local_name="wohand", service_uuids=[], rssi=-20
)
inject_advertisement_with_time_and_source(
device, strong, start + 1, HCI1_SOURCE_ADDRESS
)
# Stale switch: a far-future reading wins.
inject_advertisement_with_time_and_source(
device, weak, start + 100_000, HCI0_SOURCE_ADDRESS
)
# The switch happened, but nothing was logged.
latest = manager.async_last_service_info(address, connectable=True)
assert latest is not None
assert latest.source == HCI0_SOURCE_ADDRESS
assert "Switching from" not in caplog.text
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_non_connectable_adv_promoted_when_connectable_path_registered(
register_hci0_scanner: None,
) -> None:
"""
A changed non-connectable adv is promoted when a connectable path is live.
Regression test for #534: a connectable scanner has a path to the device, but
the current best advertisement arrives from a non-connectable source. The
service_info must surface as connectable so connectable callbacks and discovery
fire, otherwise Home Assistant believes there is no connectable path.
"""
manager = get_manager()
address = "44:44:33:11:23:45"
now = time.monotonic()
discovered: list[BluetoothServiceInfoBleak] = []
manager._subclass_discover_info = Mock(side_effect=discovered.append)
bleak_devices: list[BLEDevice] = []
def _on_bleak(dev: BLEDevice, _adv: AdvertisementData) -> None:
bleak_devices.append(dev)
# Register up front so the connectable_history replay on registration cannot
# be mistaken for a promotion dispatch.
cancel = manager.async_register_bleak_callback(_on_bleak, {})
try:
# Connectable adv from the registered hci0 scanner populates
# connectable_history.
device = generate_ble_device(address, "wohand")
connectable_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=[],
manufacturer_data={1: b"\x01"},
rssi=-60,
)
inject_advertisement_with_time_and_source_connectable(
device, connectable_adv, now, HCI0_SOURCE_ADDRESS, True
)
discovered.clear()
bleak_devices.clear()
# A stronger non-connectable adv from another source wins the best-path
# comparison and carries changed data so the identical-adv short-circuit
# does not skip dispatch.
non_connectable_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=[],
manufacturer_data={1: b"\x02"},
rssi=-20,
)
inject_advertisement_with_time_and_source_connectable(
device,
non_connectable_adv,
now,
NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS,
False,
)
finally:
cancel()
assert discovered
assert discovered[-1].connectable is True
assert bleak_devices
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_non_connectable_adv_not_promoted_after_connectable_scanner_unregisters() -> ( # noqa: E501
None
):
"""
A lingering connectable_history entry does not promote once its source is gone.
connectable_history is only pruned by the periodic unavailable check, so an
unregistered connectable scanner can leave a stale entry behind. The promotion
must verify the stored source is still registered before claiming a live path.
"""
manager = get_manager()
address = "44:44:33:11:23:46"
now = time.monotonic()
discovered: list[BluetoothServiceInfoBleak] = []
manager._subclass_discover_info = Mock(side_effect=discovered.append)
bleak_devices: list[BLEDevice] = []
def _on_bleak(dev: BLEDevice, _adv: AdvertisementData) -> None:
bleak_devices.append(dev)
# Register up front so the connectable_history replay on registration cannot
# be mistaken for a promotion dispatch.
cancel = manager.async_register_bleak_callback(_on_bleak, {})
try:
connectable_scanner = FakeScanner(HCI0_SOURCE_ADDRESS, "hci0")
connectable_scanner.connectable = True
cancel_scanner = manager.async_register_scanner(connectable_scanner)
device = generate_ble_device(address, "wohand")
connectable_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=[],
manufacturer_data={1: b"\x01"},
rssi=-60,
)
inject_advertisement_with_time_and_source_connectable(
device, connectable_adv, now, HCI0_SOURCE_ADDRESS, True
)
# Unregister the only connectable scanner; the connectable_history entry
# intentionally lingers since unregister does not prune it.
cancel_scanner()
assert HCI0_SOURCE_ADDRESS not in manager._sources
assert address in manager._connectable_history
discovered.clear()
bleak_devices.clear()
non_connectable_adv = generate_advertisement_data(
local_name="wohand",
service_uuids=[],
manufacturer_data={1: b"\x02"},
rssi=-20,
)
inject_advertisement_with_time_and_source_connectable(
device,
non_connectable_adv,
now,
NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS,
False,
)
finally:
cancel()
assert discovered
assert discovered[-1].connectable is False
assert not bleak_devices
@pytest.mark.asyncio
@pytest.mark.usefixtures("enable_bluetooth")
async def test_connectable_adv_still_dispatches_to_bleak_callbacks(
register_hci0_scanner: None,
) -> None:
"""A normal connectable adv still dispatches to bleak callbacks unchanged."""
manager = get_manager()
address = "44:44:33:11:23:47"
device = generate_ble_device(address, "wohand")
adv = generate_advertisement_data(local_name="wohand", service_uuids=[], rssi=-40)
bleak_devices: list[BLEDevice] = []
def _on_bleak(dev: BLEDevice, _adv: AdvertisementData) -> None:
bleak_devices.append(dev)
cancel = manager.async_register_bleak_callback(_on_bleak, {})
try:
inject_advertisement_with_time_and_source_connectable(
device, adv, time.monotonic(), HCI0_SOURCE_ADDRESS, True
)
finally:
cancel()
assert bleak_devices == [device]
Bluetooth-Devices-habluetooth-75cbe37/tests/test_models.py 0000664 0000000 0000000 00000023357 15211177045 0024047 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import time
from habluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak
from . import generate_advertisement_data, generate_ble_device
SOURCE_LOCAL = "local"
def test_model():
service_info = BluetoothServiceInfo(
name="Test",
address="00:00:00:00:00:00",
rssi=0,
manufacturer_data={97: b"\x00\x00\x00\x00\x00\x00"},
service_data={},
service_uuids=[],
source=SOURCE_LOCAL,
)
assert service_info.manufacturer == "RDA Microelectronics"
assert service_info.manufacturer_id == 97
service_info = BluetoothServiceInfo(
name="Test",
address="00:00:00:00:00:00",
rssi=0,
manufacturer_data={954547: b"\x00\x00\x00\x00\x00\x00"},
service_data={},
service_uuids=[],
source=SOURCE_LOCAL,
)
assert service_info.manufacturer is None
assert service_info.manufacturer_id == 954547
def test_model_from_bleak():
switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand", {})
switchbot_adv = generate_advertisement_data(
local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
service_info = BluetoothServiceInfo.from_advertisement(
switchbot_device, switchbot_adv, SOURCE_LOCAL
)
assert service_info.service_uuids == ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
assert service_info.name == "wohand"
assert service_info.source == SOURCE_LOCAL
assert service_info.manufacturer is None
assert service_info.manufacturer_id is None
def test_model_from_scanner():
switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand", {})
switchbot_adv = generate_advertisement_data(
local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
now = time.monotonic()
service_info = BluetoothServiceInfoBleak.from_scan(
SOURCE_LOCAL, switchbot_device, switchbot_adv, now, True
)
assert service_info.service_uuids == ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
assert service_info.name == "wohand"
assert service_info.source == SOURCE_LOCAL
assert service_info.manufacturer is None
assert service_info.manufacturer_id is None
assert service_info.time == now
assert service_info.connectable is True
safe_as_dict = service_info.as_dict()
assert safe_as_dict == {
"address": "44:44:33:11:23:45",
"advertisement": switchbot_adv,
"device": switchbot_device,
"connectable": True,
"manufacturer_data": {},
"name": "wohand",
"raw": None,
"rssi": -127,
"service_data": {},
"service_uuids": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
"source": "local",
"time": now,
"tx_power": -127,
}
def test_construct_service_info_bleak():
switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand", {})
switchbot_adv = generate_advertisement_data(
local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
now = time.monotonic()
service_info = BluetoothServiceInfoBleak(
name="wohand",
address="44:44:33:11:23:45",
rssi=-127,
manufacturer_data=switchbot_adv.manufacturer_data,
service_data=switchbot_adv.service_data,
service_uuids=switchbot_adv.service_uuids,
source=SOURCE_LOCAL,
device=switchbot_device,
advertisement=switchbot_adv,
connectable=False,
time=now,
tx_power=1,
)
assert service_info.service_uuids == ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
assert service_info.name == "wohand"
assert service_info.source == SOURCE_LOCAL
assert service_info.manufacturer is None
assert service_info.manufacturer_id is None
assert service_info.time == now
assert service_info.connectable is False
safe_as_dict = service_info.as_dict()
assert safe_as_dict == {
"address": "44:44:33:11:23:45",
"advertisement": switchbot_adv,
"device": switchbot_device,
"connectable": False,
"raw": None,
"manufacturer_data": {},
"name": "wohand",
"rssi": -127,
"service_data": {},
"service_uuids": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
"source": "local",
"time": now,
"tx_power": 1,
}
def test_from_device_and_advertisement_data():
"""
Test creating a BluetoothServiceInfoBleak.
From a BLEDevice and AdvertisementData.
"""
switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand", {})
switchbot_adv = generate_advertisement_data(
local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
now_monotonic = time.monotonic()
service_info = BluetoothServiceInfoBleak.from_device_and_advertisement_data(
switchbot_device, switchbot_adv, SOURCE_LOCAL, now_monotonic, True
)
assert service_info.service_uuids == ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
assert service_info.name == "wohand"
assert service_info.source == SOURCE_LOCAL
assert service_info.manufacturer is None
assert service_info.manufacturer_id is None
safe_as_dict = service_info.as_dict()
assert safe_as_dict == {
"address": "44:44:33:11:23:45",
"advertisement": switchbot_adv,
"device": switchbot_device,
"connectable": True,
"manufacturer_data": {},
"name": "wohand",
"raw": None,
"rssi": -127,
"service_data": {},
"service_uuids": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
"source": "local",
"time": now_monotonic,
"tx_power": -127,
}
assert str(service_info) == (
""
)
def test_pyobjc_compat():
# pyobjc-style snake_case names intentionally mirror the runtime
# types we receive from CoreBluetooth so the coercion paths are
# exercised with realistic class identities.
class pyobjc_str(str): # noqa: N801
__slots__ = ()
class pyobjc_int(int): # noqa: N801
__slots__ = ()
name = pyobjc_str("wohand")
address = pyobjc_str("44:44:33:11:23:45")
rssi = pyobjc_int(-127)
assert name == "wohand"
assert address == "44:44:33:11:23:45"
assert rssi == -127
switchbot_device = generate_ble_device(address, name, {})
switchbot_adv = generate_advertisement_data(
local_name=name, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
now = time.monotonic()
service_info = BluetoothServiceInfoBleak(
name=str(name),
address=str(address),
rssi=rssi,
manufacturer_data=switchbot_adv.manufacturer_data,
service_data=switchbot_adv.service_data,
service_uuids=switchbot_adv.service_uuids,
source=SOURCE_LOCAL,
device=switchbot_device,
advertisement=switchbot_adv,
connectable=False,
time=now,
tx_power=1,
)
assert service_info.service_uuids == ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
assert service_info.name == "wohand"
assert service_info.source == SOURCE_LOCAL
assert service_info.manufacturer is None
assert service_info.manufacturer_id is None
assert service_info.time == now
assert service_info.connectable is False
safe_as_dict = service_info.as_dict()
assert safe_as_dict == {
"address": "44:44:33:11:23:45",
"advertisement": switchbot_adv,
"device": switchbot_device,
"connectable": False,
"manufacturer_data": {},
"name": "wohand",
"raw": None,
"rssi": -127,
"service_data": {},
"service_uuids": ["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
"source": "local",
"time": now,
"tx_power": 1,
}
def test_as_connectable():
switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand", {})
switchbot_adv = generate_advertisement_data(
local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"]
)
now = time.monotonic()
service_info = BluetoothServiceInfoBleak(
name="wohand",
address="44:44:33:11:23:45",
rssi=-127,
manufacturer_data=switchbot_adv.manufacturer_data,
service_data=switchbot_adv.service_data,
service_uuids=switchbot_adv.service_uuids,
source=SOURCE_LOCAL,
device=switchbot_device,
advertisement=switchbot_adv,
connectable=False,
time=now,
tx_power=1,
raw=b"\x00\x00\x00\x00\x00\x00",
)
connectable_service_info = service_info._as_connectable()
assert connectable_service_info.connectable is True
assert service_info.connectable is False
assert connectable_service_info is not service_info
assert service_info.name == connectable_service_info.name
assert service_info.address == connectable_service_info.address
assert service_info.rssi == connectable_service_info.rssi
assert service_info.manufacturer_data == connectable_service_info.manufacturer_data
assert service_info.service_data == connectable_service_info.service_data
assert service_info.service_uuids == connectable_service_info.service_uuids
assert service_info.source == connectable_service_info.source
assert service_info.device == connectable_service_info.device
assert service_info.advertisement == connectable_service_info.advertisement
assert service_info.time == connectable_service_info.time
assert service_info.tx_power == connectable_service_info.tx_power
assert service_info.raw == connectable_service_info.raw
Bluetooth-Devices-habluetooth-75cbe37/tests/test_name_cache.py 0000664 0000000 0000000 00000044361 15211177045 0024625 0 ustar 00root root 0000000 0000000 """Tests for the cross-scanner name cache on BluetoothManager."""
import time
from datetime import timedelta
from unittest.mock import patch
import pytest
from freezegun import freeze_time
from habluetooth import HaBluetoothConnector, get_manager
from . import (
InjectableRemoteScanner as _SeedFakeScanner,
)
from . import (
MockBleakClient,
generate_advertisement_data,
generate_ble_device,
inject_advertisement_with_source,
utcnow,
)
# ---------------------------------------------------------------------------
# Unit tests for the prefix-extension policy
# ---------------------------------------------------------------------------
def test_name_cache_empty_to_name() -> None:
"""First non-empty name observed is stored."""
manager = get_manager()
address = "AA:BB:CC:DD:EE:01"
manager.seed_name_cache(address, "Onv")
assert manager._name_cache[address] == "Onv"
def test_name_cache_extension_replaces_truncation() -> None:
"""A new name that extends the cached short name replaces it."""
manager = get_manager()
address = "AA:BB:CC:DD:EE:02"
manager.seed_name_cache(address, "Onv")
manager.seed_name_cache(address, "Onvis XXX")
assert manager._name_cache[address] == "Onvis XXX"
def test_name_cache_truncation_keeps_cached() -> None:
"""A new name that is a truncation of the cached complete name is rejected."""
manager = get_manager()
address = "AA:BB:CC:DD:EE:03"
manager.seed_name_cache(address, "Onvis XXX")
manager.seed_name_cache(address, "Onv")
assert manager._name_cache[address] == "Onvis XXX"
def test_name_cache_rename_replaces() -> None:
"""A completely different name (not prefix-related) replaces the cached name."""
manager = get_manager()
address = "AA:BB:CC:DD:EE:04"
manager.seed_name_cache(address, "Onv")
manager.seed_name_cache(address, "Donkey")
assert manager._name_cache[address] == "Donkey"
def test_name_cache_same_name_noop() -> None:
"""Re-broadcasting the same name does not allocate a new cache entry."""
manager = get_manager()
address = "AA:BB:CC:DD:EE:05"
manager.seed_name_cache(address, "Onv")
cached_first = manager._name_cache[address]
manager.seed_name_cache(address, "Onv")
# Same string compares equal; identity may or may not match depending on
# interning but the value must be unchanged.
assert manager._name_cache[address] == cached_first
def test_name_cache_empty_name_noop() -> None:
"""An empty name never overwrites the cached value."""
manager = get_manager()
address = "AA:BB:CC:DD:EE:06"
manager.seed_name_cache(address, "Onv")
manager.seed_name_cache(address, "")
assert manager._name_cache[address] == "Onv"
def test_name_cache_address_fallback_not_stored() -> None:
"""
Address fallback (name == address) must not pollute the cache.
base_scanner sets info.name = address when no local_name is present.
"""
manager = get_manager()
address = "AA:BB:CC:DD:EE:07"
manager.seed_name_cache(address, address)
assert address not in manager._name_cache
def test_name_cache_address_fallback_does_not_overwrite() -> None:
"""The address-fallback no-op must not replace an existing cached name."""
manager = get_manager()
address = "AA:BB:CC:DD:EE:08"
manager.seed_name_cache(address, "Onv")
manager.seed_name_cache(address, address)
assert manager._name_cache[address] == "Onv"
def test_name_cache_case_folded_extension() -> None:
"""The extension rule is case-folded: 'onv' is a prefix of 'ONVIS XXX'."""
manager = get_manager()
address = "AA:BB:CC:DD:EE:09"
manager.seed_name_cache(address, "onv")
manager.seed_name_cache(address, "ONVIS XXX")
assert manager._name_cache[address] == "ONVIS XXX"
def test_name_cache_case_folded_truncation_keeps_cached() -> None:
"""Case-folded truncation: 'ONV' is a truncation of 'Onvis XXX'."""
manager = get_manager()
address = "AA:BB:CC:DD:EE:0A"
manager.seed_name_cache(address, "Onvis XXX")
manager.seed_name_cache(address, "ONV")
assert manager._name_cache[address] == "Onvis XXX"
def test_name_cache_equal_value_different_objects_noop() -> None:
"""Two str objects with identical value hit the cached == name no-op path."""
manager = get_manager()
address = "AA:BB:CC:DD:EE:0B"
first = b"Onvis XXX".decode()
second = b"Onvis XXX".decode()
assert first is not second
manager.seed_name_cache(address, first)
manager.seed_name_cache(address, second)
# Value unchanged; the original object is still cached (no rewrite).
assert manager._name_cache[address] is first
def test_name_cache_equal_length_case_only_diff_keeps_cached() -> None:
"""Equal-length casefolded names differing only in case keep the cached value."""
manager = get_manager()
address = "AA:BB:CC:DD:EE:0C"
manager.seed_name_cache(address, "Onv")
manager.seed_name_cache(address, "ONV")
assert manager._name_cache[address] == "Onv"
# ---------------------------------------------------------------------------
# Cross-scanner integration: passive scanner gains name from active scanner
# ---------------------------------------------------------------------------
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_passive_scanner_gains_name_from_active_scanner() -> None:
"""
Passive scanner inherits the name learned by an active scanner.
Uses real BaseHaRemoteScanner injection so the lazy AdvertisementData
construction path in models._advertisement_internal is exercised.
"""
manager = get_manager()
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
active_scanner = _SeedFakeScanner("esp32-active", "esp32-active", connector, True)
active_unsetup = active_scanner.async_setup()
active_cancel = manager.async_register_scanner(active_scanner)
passive_scanner = _SeedFakeScanner(
"esp32-passive", "esp32-passive", connector, True
)
passive_unsetup = passive_scanner.async_setup()
passive_cancel = manager.async_register_scanner(passive_scanner)
address = "44:44:33:11:23:50"
# Active scanner reports the device with its full SCAN_RSP-derived name.
active_device = generate_ble_device(address, "Onvis XXX", {}, rssi=-60)
active_adv = generate_advertisement_data(local_name="Onvis XXX", rssi=-60)
active_scanner.inject_advertisement(active_device, active_adv)
assert manager._name_cache[address] == "Onvis XXX"
assert manager._all_history[address].name == "Onvis XXX"
# Passive scanner reports the same address without a name, with stronger
# RSSI so it wins the source-preference comparison.
passive_device = generate_ble_device(address, None, {}, rssi=-30)
passive_adv = generate_advertisement_data(
local_name=None,
# Distinct manufacturer data to force past the fast-path early-return
# so we actually exercise the patch path.
manufacturer_data={99: b"\x99"},
rssi=-30,
)
passive_scanner.inject_advertisement(passive_device, passive_adv)
# The patched view in _all_history must carry the active scanner's name.
patched = manager._all_history[address]
assert patched.name == "Onvis XXX"
assert patched.device.name == "Onvis XXX"
# And the AdvertisementData built on-demand for bleak callbacks must
# carry it too.
assert patched.advertisement.local_name == "Onvis XXX"
active_cancel()
active_unsetup()
passive_cancel()
passive_unsetup()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_active_scanner_extension_propagates_across_sources() -> None:
"""
An active scanner's longer name upgrades the cache populated by a passive one.
Order: passive sees the short name first, then active sees the full
SCAN_RSP-derived name. The cache must upgrade and the dispatched view
must carry the longer name.
"""
manager = get_manager()
address = "44:44:33:11:23:51"
# Passive scanner sees the shortened name first.
passive_device = generate_ble_device(address, "Onv", {}, rssi=-80)
passive_adv = generate_advertisement_data(local_name="Onv", rssi=-80)
inject_advertisement_with_source(passive_device, passive_adv, "passive-source")
assert manager._name_cache[address] == "Onv"
# Active scanner sees the full SCAN_RSP-derived name.
active_device = generate_ble_device(address, "Onvis XXX", {}, rssi=-70)
active_adv = generate_advertisement_data(
local_name="Onvis XXX",
manufacturer_data={1: b"\x01"},
rssi=-70,
)
inject_advertisement_with_source(active_device, active_adv, "active-source")
assert manager._name_cache[address] == "Onvis XXX"
assert manager._all_history[address].name == "Onvis XXX"
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_second_scanner_same_name_different_object_noop() -> None:
"""
Second scanner reporting the same name (different str object) is a no-op.
Exercises the cached_name == service_info.name early-return inside
_handle_name_cache_miss: identity mismatch but value match.
"""
manager = get_manager()
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
scanner_a = _SeedFakeScanner("esp32-a", "esp32-a", connector, True)
scanner_b = _SeedFakeScanner("esp32-b", "esp32-b", connector, True)
cancels = [
scanner_a.async_setup(),
manager.async_register_scanner(scanner_a),
scanner_b.async_setup(),
manager.async_register_scanner(scanner_b),
]
address = "44:44:33:11:23:56"
# Use bytes.decode() so each scanner gets a distinct str object.
device_a = generate_ble_device(address, b"Onvis XXX".decode(), {}, rssi=-60)
adv_a = generate_advertisement_data(local_name=b"Onvis XXX".decode(), rssi=-60)
scanner_a.inject_advertisement(device_a, adv_a)
cached_first = manager._name_cache[address]
device_b = generate_ble_device(address, b"Onvis XXX".decode(), {}, rssi=-50)
adv_b = generate_advertisement_data(
local_name=b"Onvis XXX".decode(),
manufacturer_data={1: b"\x01"},
rssi=-50,
)
scanner_b.inject_advertisement(device_b, adv_b)
# Cache value unchanged; original object is preserved (no rewrite).
assert manager._name_cache[address] is cached_first
for c in cancels:
c()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_local_passive_scanner_advertisement_rebuilt_with_cached_name() -> None:
"""
Local passive scanner's AdvertisementData is rebuilt with the cached name.
HaScanner.on_advertisement (scanner.py) pre-sets service_info._advertisement
to bleak's AdvertisementData, so without invalidation the patched
service_info.name would not propagate to advertisement.local_name for
bleak callbacks. Mimics that producer shape via inject_advertisement_with_source
(which also pre-sets _advertisement) and verifies the dispatch path
rebuilds the AdvertisementData with the patched name.
"""
manager = get_manager()
address = "44:44:33:11:23:58"
# Seed the cache from an active scanner.
active_device = generate_ble_device(address, "Onvis XXX", {}, rssi=-60)
active_adv = generate_advertisement_data(local_name="Onvis XXX", rssi=-60)
inject_advertisement_with_source(active_device, active_adv, "active-source")
assert manager._name_cache[address] == "Onvis XXX"
# Local passive scanner sees the same device without a name; its
# AdvertisementData has local_name = None and is pre-built on
# service_info._advertisement (matches HaScanner.on_advertisement).
passive_device = generate_ble_device(address, None, {}, rssi=-30)
passive_adv = generate_advertisement_data(
local_name=None,
manufacturer_data={123: b"\xde\xad"},
rssi=-30,
)
inject_advertisement_with_source(passive_device, passive_adv, "passive-source")
patched = manager._all_history[address]
assert patched.name == "Onvis XXX"
assert patched.device.name == "Onvis XXX"
# _advertisement was invalidated on patch, so the lazy rebuild picks
# up the canonical name; bleak callbacks now see it.
assert patched.advertisement.local_name == "Onvis XXX"
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_truncation_from_second_scanner_patched_back() -> None:
"""
Scanner reporting a truncated name has its service_info patched back.
Exercises the post-update patch branch in _handle_name_cache_miss:
_update_name_cache decides to keep the longer cached value, then the
incoming service_info is patched back to that cached value so the
dispatch carries the canonical name.
"""
manager = get_manager()
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
active = _SeedFakeScanner("esp32-full", "esp32-full", connector, True)
secondary = _SeedFakeScanner("esp32-trunc", "esp32-trunc", connector, True)
cancels = [
active.async_setup(),
manager.async_register_scanner(active),
secondary.async_setup(),
manager.async_register_scanner(secondary),
]
address = "44:44:33:11:23:57"
active_device = generate_ble_device(address, "Onvis XXX", {}, rssi=-60)
active_adv = generate_advertisement_data(local_name="Onvis XXX", rssi=-60)
active.inject_advertisement(active_device, active_adv)
assert manager._name_cache[address] == "Onvis XXX"
# Secondary scanner sees only the truncated name (e.g. no SCAN_RSP yet).
trunc_device = generate_ble_device(address, "Onv", {}, rssi=-50)
trunc_adv = generate_advertisement_data(
local_name="Onv", manufacturer_data={1: b"\x01"}, rssi=-50
)
secondary.inject_advertisement(trunc_device, trunc_adv)
# Cache keeps the longer name and the dispatched view is patched back.
assert manager._name_cache[address] == "Onvis XXX"
assert manager._all_history[address].name == "Onvis XXX"
assert manager._all_history[address].device.name == "Onvis XXX"
for c in cancels:
c()
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_rename_replaces_across_sources() -> None:
"""A genuine rename (not a prefix relationship) replaces the cached name."""
manager = get_manager()
address = "44:44:33:11:23:52"
device_a = generate_ble_device(address, "Onv", {}, rssi=-60)
adv_a = generate_advertisement_data(local_name="Onv", rssi=-60)
inject_advertisement_with_source(device_a, adv_a, "source-a")
assert manager._name_cache[address] == "Onv"
device_b = generate_ble_device(address, "Donkey", {}, rssi=-60)
adv_b = generate_advertisement_data(
local_name="Donkey",
manufacturer_data={1: b"\x01"},
rssi=-60,
)
inject_advertisement_with_source(device_b, adv_b, "source-b")
assert manager._name_cache[address] == "Donkey"
assert manager._all_history[address].name == "Donkey"
# ---------------------------------------------------------------------------
# Eviction
# ---------------------------------------------------------------------------
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_async_clear_advertisement_history_evicts_cache() -> None:
"""async_clear_advertisement_history removes the name cache entry."""
manager = get_manager()
address = "44:44:33:11:23:53"
device = generate_ble_device(address, "Onvis XXX", {}, rssi=-60)
adv = generate_advertisement_data(local_name="Onvis XXX", rssi=-60)
inject_advertisement_with_source(device, adv, "source")
assert address in manager._name_cache
manager.async_clear_advertisement_history(address)
assert address not in manager._name_cache
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_disappearance_evicts_cache() -> None:
"""A device that disappears via _async_check_unavailable evicts its cache entry."""
manager = get_manager()
address = "44:44:33:11:23:54"
device = generate_ble_device(address, "Onvis XXX", {}, rssi=-60)
adv = generate_advertisement_data(local_name="Onvis XXX", rssi=-60)
inject_advertisement_with_source(device, adv, "source")
assert address in manager._name_cache
future_time = utcnow() + timedelta(seconds=3600)
future_monotonic_time = time.monotonic() + 3600
with (
freeze_time(future_time),
patch(
"habluetooth.manager.monotonic_time_coarse",
return_value=future_monotonic_time,
),
):
manager._async_check_unavailable()
assert address not in manager._name_cache
# ---------------------------------------------------------------------------
# Seed on load from per-scanner persisted state
# ---------------------------------------------------------------------------
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_restore_discovered_devices_seeds_cache() -> None:
"""
Restoring a scanner's persisted history seeds the shared name cache.
Subsequent passive-scanner ads on the same address inherit the
previously-known name without waiting for an active scanner to
re-observe it.
"""
manager = get_manager()
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
src_scanner = _SeedFakeScanner("esp32-src", "esp32-src", connector, True)
src_unsetup = src_scanner.async_setup()
src_cancel = manager.async_register_scanner(src_scanner)
address = "44:44:33:11:23:55"
device = generate_ble_device(address, "Onvis XXX", {}, rssi=-60)
adv = generate_advertisement_data(local_name="Onvis XXX", rssi=-60)
src_scanner.inject_advertisement(device, adv)
history = src_scanner.serialize_discovered_devices()
# Fresh cache state; restore should re-populate it.
manager._name_cache.pop(address, None)
assert address not in manager._name_cache
dst_scanner = _SeedFakeScanner("esp32-dst", "esp32-dst", connector, True)
dst_unsetup = dst_scanner.async_setup()
dst_cancel = manager.async_register_scanner(dst_scanner)
dst_scanner.restore_discovered_devices(history)
assert manager._name_cache[address] == "Onvis XXX"
src_cancel()
src_unsetup()
dst_cancel()
dst_unsetup()
Bluetooth-Devices-habluetooth-75cbe37/tests/test_scanner.py 0000664 0000000 0000000 00000333720 15211177045 0024213 0 ustar 00root root 0000000 0000000 """Tests for the Bluetooth integration scanners."""
import asyncio
import logging
import platform
import time
from collections.abc import Generator, Iterable
from datetime import timedelta
from typing import Any
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch
import pytest
from bleak import BleakError
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback
from bleak_retry_connector import Allocations, BleakSlotManager
from habluetooth import (
SCANNER_WATCHDOG_INTERVAL,
SCANNER_WATCHDOG_TIMEOUT,
BaseHaScanner,
BluetoothManager,
BluetoothScanningMode,
BluetoothServiceInfoBleak,
HaScanner,
HaScannerType,
ScannerStartError,
get_manager,
scanner,
set_manager,
)
from habluetooth.channels.bluez import (
BluetoothMGMTProtocol,
MGMTBluetoothCtl,
)
from habluetooth.scanner import (
InvalidMessageError,
bytes_mac_to_str,
create_bleak_scanner,
make_bluez_details,
)
from . import (
MockBleakScanner,
async_fire_time_changed,
generate_advertisement_data,
generate_ble_device,
patch_bleak_scanner_factory,
patch_bluetooth_time,
utcnow,
)
from .conftest import FakeBluetoothAdapters, MockBluetoothManagerWithCallbacks
DEVICE_FOUND = 0x0012
ADV_MONITOR_DEVICE_FOUND = 0x002F
IS_WINDOWS = 'os.name == "nt"'
IS_POSIX = 'os.name == "posix"'
NOT_POSIX = 'os.name != "posix"'
# or_patterns is a workaround for the fact that passive scanning
# needs at least one matcher to be set. The below matcher
# will match all devices.
if platform.system() == "Linux":
# On Linux, use the real BlueZScannerArgs to avoid mocking issues
from bleak.args.bluez import BlueZScannerArgs, OrPattern
from bleak.assigned_numbers import AdvertisementDataType
scanner.PASSIVE_SCANNER_ARGS = BlueZScannerArgs(
or_patterns=[
OrPattern(0, AdvertisementDataType.FLAGS, b"\x02"),
OrPattern(0, AdvertisementDataType.FLAGS, b"\x06"),
OrPattern(0, AdvertisementDataType.FLAGS, b"\x1a"),
]
)
else:
# On other platforms ``bleak.args.bluez`` may not be importable. Use a
# non-empty real mapping that mimics the Linux shape so the production
# code's ``if bluez_args:`` truthy check still adds the ``bluez`` kwarg.
scanner.PASSIVE_SCANNER_ARGS = {"or_patterns": [(0, 0x01, b"\x06")]}
# If the adapter is in a stuck state the following errors are raised:
NEED_RESET_ERRORS = [
"org.bluez.Error.Failed",
"org.bluez.Error.InProgress",
"org.bluez.Error.NotReady",
"not found",
]
@pytest.fixture(autouse=True, scope="module")
def disable_stop_discovery():
"""Disable stop discovery."""
with (
patch("habluetooth.scanner.stop_discovery"),
patch("habluetooth.scanner.restore_discoveries"),
):
yield
@pytest.fixture
def force_linux_scanner_mode() -> Generator[None, None, None]:
"""
Force scanner.IS_LINUX=True / IS_MACOS=False for AUTO-flow tests.
Lets the active-window toggle path run on any host: the toggle
is gated on IS_LINUX (BlueZ-only private attribute), and AUTO
on macOS short-circuits to permanent active.
"""
with (
patch("habluetooth.scanner.IS_LINUX", True),
patch("habluetooth.scanner.IS_MACOS", False),
):
yield
@pytest.fixture(autouse=True, scope="module")
def manager():
"""Return the BluetoothManager instance."""
adapters = FakeBluetoothAdapters()
slot_manager = BleakSlotManager()
manager = BluetoothManager(adapters, slot_manager)
set_manager(manager)
return manager
@pytest.fixture
def mock_btmgmt_socket():
"""Mock the btmgmt_socket module."""
with patch("habluetooth.channels.bluez.btmgmt_socket") as mock_btmgmt:
mock_socket = Mock()
# Make the socket look like a real socket with a file descriptor
mock_socket.fileno.return_value = 99
mock_btmgmt.open.return_value = mock_socket
yield mock_btmgmt
def test_bytes_mac_to_str() -> None:
"""Test bytes_mac_to_str."""
assert bytes_mac_to_str(b"\xff\xee\xdd\xcc\xbb\xaa") == "AA:BB:CC:DD:EE:FF"
assert bytes_mac_to_str(b"\xff\xee\xdd\xcc\xbb\xaa") == "AA:BB:CC:DD:EE:FF"
def test_make_bluez_details() -> None:
"""Test make_bluez_details."""
assert make_bluez_details("AA:BB:CC:DD:EE:FF", "hci0") == {
"path": "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF",
"props": {"Adapter": "/org/bluez/hci0"},
}
def test_create_bleak_scanner_linux_no_adapter_active() -> None:
"""Linux + no adapter + active: ``bluez`` kwarg must be absent."""
with (
patch.object(scanner, "IS_LINUX", True),
patch.object(scanner, "IS_MACOS", False),
patch("habluetooth.scanner.OriginalBleakScanner") as mock_scanner,
):
create_bleak_scanner(None, BluetoothScanningMode.ACTIVE, None)
kwargs = mock_scanner.call_args.kwargs
assert "bluez" not in kwargs
assert "adapter" not in kwargs
def test_create_bleak_scanner_linux_no_adapter_passive() -> None:
"""Linux + no adapter + passive: ``bluez`` carries passive args only."""
with (
patch.object(scanner, "IS_LINUX", True),
patch.object(scanner, "IS_MACOS", False),
patch("habluetooth.scanner.OriginalBleakScanner") as mock_scanner,
):
create_bleak_scanner(None, BluetoothScanningMode.PASSIVE, None)
bluez = mock_scanner.call_args.kwargs["bluez"]
assert "adapter" not in bluez
# PASSIVE args are copied in — the production dict must not be mutated.
assert bluez == dict(scanner.PASSIVE_SCANNER_ARGS)
def test_create_bleak_scanner_linux_adapter_active() -> None:
"""Linux + adapter + active: ``bluez`` carries adapter only."""
with (
patch.object(scanner, "IS_LINUX", True),
patch.object(scanner, "IS_MACOS", False),
patch("habluetooth.scanner.OriginalBleakScanner") as mock_scanner,
):
create_bleak_scanner(None, BluetoothScanningMode.ACTIVE, "hci2")
bluez = mock_scanner.call_args.kwargs["bluez"]
assert bluez == {"adapter": "hci2"}
def test_create_bleak_scanner_linux_adapter_passive() -> None:
"""Linux + adapter + passive: ``bluez`` merges adapter and passive args."""
with (
patch.object(scanner, "IS_LINUX", True),
patch.object(scanner, "IS_MACOS", False),
patch("habluetooth.scanner.OriginalBleakScanner") as mock_scanner,
):
create_bleak_scanner(None, BluetoothScanningMode.PASSIVE, "hci1")
bluez = mock_scanner.call_args.kwargs["bluez"]
assert bluez.get("adapter") == "hci1"
# The production code must copy PASSIVE_SCANNER_ARGS — assert the source
# was not mutated by the adapter insertion.
assert "adapter" not in scanner.PASSIVE_SCANNER_ARGS
@pytest.mark.asyncio
async def test_empty_data_no_scanner() -> None:
"""Test we handle empty data."""
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
assert scanner.discovered_devices == []
assert scanner.discovered_devices_and_advertisement_data == {}
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_dbus_socket_missing_in_container(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test we handle dbus being missing in the container."""
with (
patch("habluetooth.scanner.is_docker_env", return_value=True),
patch(
"habluetooth.scanner.OriginalBleakScanner.start",
side_effect=FileNotFoundError,
),
patch(
"habluetooth.scanner.OriginalBleakScanner.stop",
) as mock_stop,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
with pytest.raises(
ScannerStartError,
match="DBus service not found; docker config may be missing",
):
await scanner.async_start()
assert mock_stop.called
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_dbus_socket_missing(caplog: pytest.LogCaptureFixture) -> None:
"""Test we handle dbus being missing."""
with (
patch("habluetooth.scanner.is_docker_env", return_value=False),
patch(
"habluetooth.scanner.OriginalBleakScanner.start",
side_effect=FileNotFoundError,
),
patch(
"habluetooth.scanner.OriginalBleakScanner.stop",
) as mock_stop,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
with pytest.raises(
ScannerStartError,
match="DBus service not found; make sure the DBus socket is available",
):
await scanner.async_start()
assert mock_stop.called
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_handle_cancellation(caplog: pytest.LogCaptureFixture) -> None:
"""Test cancellation stops."""
with (
patch("habluetooth.scanner.is_docker_env", return_value=False),
patch(
"habluetooth.scanner.OriginalBleakScanner.start",
side_effect=asyncio.CancelledError,
),
patch(
"habluetooth.scanner.OriginalBleakScanner.stop",
) as mock_stop,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
with pytest.raises(asyncio.CancelledError):
await scanner.async_start()
assert mock_stop.called
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_handle_stop_while_starting(caplog: pytest.LogCaptureFixture) -> None:
"""Test stop while starting."""
async def _start(*args, **kwargs):
await asyncio.sleep(1000)
with (
patch("habluetooth.scanner.is_docker_env", return_value=False),
patch("habluetooth.scanner.OriginalBleakScanner.start", _start),
patch(
"habluetooth.scanner.OriginalBleakScanner.stop",
) as mock_stop,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
task = asyncio.create_task(scanner.async_start())
await asyncio.sleep(0)
await asyncio.sleep(0)
await scanner.async_stop()
with pytest.raises(
ScannerStartError, match="Starting bluetooth scanner aborted"
):
await task
assert mock_stop.called
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_dbus_broken_pipe_in_container(caplog: pytest.LogCaptureFixture) -> None:
"""Test we handle dbus broken pipe in the container."""
with (
patch("habluetooth.scanner.is_docker_env", return_value=True),
patch(
"habluetooth.scanner.OriginalBleakScanner.start",
side_effect=BrokenPipeError,
),
patch(
"habluetooth.scanner.OriginalBleakScanner.stop",
) as mock_stop,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
with pytest.raises(ScannerStartError, match="DBus connection broken"):
await scanner.async_start()
assert mock_stop.called
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_dbus_broken_pipe(caplog: pytest.LogCaptureFixture) -> None:
"""Test we handle dbus broken pipe."""
with (
patch("habluetooth.scanner.is_docker_env", return_value=False),
patch(
"habluetooth.scanner.OriginalBleakScanner.start",
side_effect=BrokenPipeError,
),
patch(
"habluetooth.scanner.OriginalBleakScanner.stop",
) as mock_stop,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
with pytest.raises(ScannerStartError, match="DBus connection broken:"):
await scanner.async_start()
assert mock_stop.called
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_invalid_dbus_message(caplog: pytest.LogCaptureFixture) -> None:
"""Test we handle invalid dbus message."""
with patch(
"habluetooth.scanner.OriginalBleakScanner.start",
side_effect=InvalidMessageError,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
with pytest.raises(ScannerStartError, match="Invalid DBus message received"):
await scanner.async_start()
@pytest.mark.asyncio
@pytest.mark.skipif(IS_WINDOWS)
@pytest.mark.parametrize("error", NEED_RESET_ERRORS)
async def test_adapter_needs_reset_at_start(
caplog: pytest.LogCaptureFixture, error: str
) -> None:
"""Test we cycle the adapter when it needs a restart."""
called_start = 0
called_stop = 0
_callback = None
mock_discovered: list[Any] = []
class _DBusRecoveryScanner(MockBleakScanner):
async def start(self, *args: object, **kwargs: object) -> None:
nonlocal called_start
called_start += 1
if called_start < 3:
raise BleakError(error)
async def stop(self, *args: object, **kwargs: object) -> None:
nonlocal called_stop
called_stop += 1
@property
def discovered_devices(self):
return mock_discovered
def register_detection_callback(
self, callback: AdvertisementDataCallback
) -> None:
nonlocal _callback
_callback = callback
mock_scanner = _DBusRecoveryScanner()
with (
patch("habluetooth.scanner.OriginalBleakScanner", return_value=mock_scanner),
patch(
"habluetooth.util.recover_adapter", return_value=True
) as mock_recover_adapter,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert len(mock_recover_adapter.mock_calls) == 1
await scanner.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(IS_WINDOWS)
async def test_recovery_from_dbus_restart() -> None:
"""Test we can recover when DBus gets restarted out from under us."""
called_start = 0
called_stop = 0
_callback = None
mock_discovered: list[Any] = []
class _CallbackCapturingScanner(MockBleakScanner):
def __init__(
self,
detection_callback: AdvertisementDataCallback,
*args: object,
**kwargs: object,
) -> None:
super().__init__()
nonlocal _callback
_callback = detection_callback
async def start(self, *args: object, **kwargs: object) -> None:
nonlocal called_start
called_start += 1
async def stop(self, *args: object, **kwargs: object) -> None:
nonlocal called_stop
called_stop += 1
@property
def discovered_devices(self):
return mock_discovered
with patch(
"habluetooth.scanner.OriginalBleakScanner",
_CallbackCapturingScanner,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert called_start == 1
start_time_monotonic = time.monotonic()
mock_discovered = [MagicMock()]
# Ensure we don't restart the scanner if we don't need to
with patch_bluetooth_time(
start_time_monotonic + 10,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
assert called_start == 1
# Fire a callback to reset the timer
with patch_bluetooth_time(
start_time_monotonic,
):
_callback( # type: ignore[misc]
generate_ble_device("44:44:33:11:23:42", "any_name"),
generate_advertisement_data(local_name="any_name"),
)
# Ensure we don't restart the scanner if we don't need to
with patch_bluetooth_time(
start_time_monotonic + 20,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert called_start == 1
# We hit the timer, so we restart the scanner
with patch_bluetooth_time(
start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + 20,
):
async_fire_time_changed(
utcnow() + SCANNER_WATCHDOG_INTERVAL + timedelta(seconds=20)
)
await asyncio.sleep(0)
assert called_start == 2
await scanner.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(IS_WINDOWS)
async def test_adapter_recovery() -> None:
"""Test we can recover when the adapter stops responding."""
called_start = 0
called_stop = 0
_callback = None
mock_discovered: list[Any] = []
class _AdapterRecoveryScanner(MockBleakScanner):
async def start(self, *args: object, **kwargs: object) -> None:
nonlocal called_start
called_start += 1
async def stop(self, *args: object, **kwargs: object) -> None:
nonlocal called_stop
called_stop += 1
@property
def discovered_devices(self):
return mock_discovered
def register_detection_callback(
self, callback: AdvertisementDataCallback
) -> None:
nonlocal _callback
_callback = callback
mock_scanner = _AdapterRecoveryScanner()
start_time_monotonic = time.monotonic()
with (
patch_bluetooth_time(
start_time_monotonic,
),
patch(
"habluetooth.scanner.OriginalBleakScanner",
return_value=mock_scanner,
),
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert called_start == 1
mock_discovered = [MagicMock()]
# Ensure we don't restart the scanner if we don't need to
with patch_bluetooth_time(
start_time_monotonic + 10,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert called_start == 1
# Ensure we don't restart the scanner if we don't need to
with patch_bluetooth_time(
start_time_monotonic + 20,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert called_start == 1
# We hit the timer with no detections, so we
# reset the adapter and restart the scanner
with (
patch_bluetooth_time(
start_time_monotonic
+ SCANNER_WATCHDOG_TIMEOUT
+ SCANNER_WATCHDOG_INTERVAL.total_seconds(),
),
patch(
"habluetooth.util.recover_adapter", return_value=True
) as mock_recover_adapter,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert len(mock_recover_adapter.mock_calls) == 1
assert mock_recover_adapter.call_args_list[0][0] == (
0,
"AA:BB:CC:DD:EE:FF",
True,
)
assert called_start == 2
await scanner.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(IS_WINDOWS)
async def test_adapter_scanner_fails_to_start_first_time() -> None:
"""
Test we can recover when the adapter stops responding.
The first recovery fails.
"""
called_start = 0
called_stop = 0
_callback = None
mock_discovered: list[Any] = []
class _RestartFailScanner(MockBleakScanner):
async def start(self, *args: object, **kwargs: object) -> None:
nonlocal called_start
called_start += 1
if called_start == 1:
return
if called_start < 4:
msg = "Failed to start"
raise BleakError(msg)
async def stop(self, *args: object, **kwargs: object) -> None:
nonlocal called_stop
called_stop += 1
@property
def discovered_devices(self):
return mock_discovered
def register_detection_callback(
self, callback: AdvertisementDataCallback
) -> None:
nonlocal _callback
_callback = callback
mock_scanner = _RestartFailScanner()
start_time_monotonic = time.monotonic()
with (
patch_bluetooth_time(
start_time_monotonic,
),
patch(
"habluetooth.scanner.OriginalBleakScanner",
return_value=mock_scanner,
),
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert called_start == 1
mock_discovered = [MagicMock()]
# Ensure we don't restart the scanner if we don't need to
with patch_bluetooth_time(
start_time_monotonic + 10,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert called_start == 1
# Ensure we don't restart the scanner if we don't need to
with patch_bluetooth_time(
start_time_monotonic + 20,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert called_start == 1
# We hit the timer with no detections,
# so we reset the adapter and restart the scanner
with (
patch_bluetooth_time(
start_time_monotonic
+ SCANNER_WATCHDOG_TIMEOUT
+ SCANNER_WATCHDOG_INTERVAL.total_seconds(),
),
patch(
"habluetooth.util.recover_adapter", return_value=True
) as mock_recover_adapter,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert len(mock_recover_adapter.mock_calls) == 1
assert called_start == 4
assert scanner.scanning is True
now_monotonic = time.monotonic()
# We hit the timer again the previous start call failed, make sure
# we try again
with (
patch_bluetooth_time(
now_monotonic
+ SCANNER_WATCHDOG_TIMEOUT * 2
+ SCANNER_WATCHDOG_INTERVAL.total_seconds(),
),
patch(
"habluetooth.util.recover_adapter", return_value=True
) as mock_recover_adapter,
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
assert len(mock_recover_adapter.mock_calls) == 1
assert called_start == 5
await scanner.async_stop()
@pytest.mark.asyncio
async def test_adapter_fails_to_start_and_takes_a_bit_to_init(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test we can recover the adapter at startup and we wait for Dbus to init."""
called_start = 0
called_stop = 0
_callback = None
mock_discovered: list[Any] = []
class _DBusInProgressScanner(MockBleakScanner):
async def start(self, *args: object, **kwargs: object) -> None:
nonlocal called_start
called_start += 1
if called_start == 1:
msg = "org.freedesktop.DBus.Error.UnknownObject"
raise BleakError(msg)
if called_start == 2:
msg = "org.bluez.Error.InProgress"
raise BleakError(msg)
if called_start == 3:
msg = "org.bluez.Error.InProgress"
raise BleakError(msg)
async def stop(self, *args: object, **kwargs: object) -> None:
nonlocal called_stop
called_stop += 1
@property
def discovered_devices(self):
return mock_discovered
def register_detection_callback(
self, callback: AdvertisementDataCallback
) -> None:
nonlocal _callback
_callback = callback
mock_scanner = _DBusInProgressScanner()
start_time_monotonic = time.monotonic()
with (
patch(
"habluetooth.scanner.ADAPTER_INIT_TIME",
0,
),
patch_bluetooth_time(
start_time_monotonic,
),
patch(
"habluetooth.scanner.OriginalBleakScanner",
return_value=mock_scanner,
),
patch(
"habluetooth.util.recover_adapter", return_value=True
) as mock_recover_adapter,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert called_start == 4
assert len(mock_recover_adapter.mock_calls) == 1
assert "Waiting for adapter to initialize" in caplog.text
await scanner.async_stop()
@pytest.mark.asyncio
async def test_restart_takes_longer_than_watchdog_time(
caplog: pytest.LogCaptureFixture,
) -> None:
"""
Test we do not try to recover the adapter again.
If the restart is still in progress.
"""
release_start_event = asyncio.Event()
called_start = 0
class _ReleaseGatedScanner(MockBleakScanner):
async def start(self, *args: object, **kwargs: object) -> None:
nonlocal called_start
called_start += 1
if called_start == 1:
return
await release_start_event.wait()
mock_scanner = _ReleaseGatedScanner()
start_time_monotonic = time.monotonic()
with (
patch(
"habluetooth.scanner.ADAPTER_INIT_TIME",
0,
),
patch_bluetooth_time(
start_time_monotonic,
),
patch(
"habluetooth.scanner.OriginalBleakScanner",
return_value=mock_scanner,
),
patch("habluetooth.util.recover_adapter", return_value=True),
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert called_start == 1
# Now force a recover adapter 2x
for _ in range(2):
with patch_bluetooth_time(
start_time_monotonic
+ SCANNER_WATCHDOG_TIMEOUT
+ SCANNER_WATCHDOG_INTERVAL.total_seconds(),
):
async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
await asyncio.sleep(0)
# Now release the start event
release_start_event.set()
assert "already restarting" in caplog.text
await scanner.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif("platform.system() != 'Darwin'")
async def test_setup_and_stop_macos() -> None:
"""Test we enable use_bdaddr on MacOS."""
init_kwargs = None
class _KwargsCapturingScanner(MockBleakScanner):
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__()
nonlocal init_kwargs
init_kwargs = kwargs
with patch(
"habluetooth.scanner.OriginalBleakScanner",
_KwargsCapturingScanner,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert init_kwargs == {
"detection_callback": ANY,
"scanning_mode": "active",
"cb": {"use_bdaddr": True},
}
await scanner.async_stop()
@pytest.mark.asyncio
async def test_adapter_init_fails_fallback_to_passive(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test we fallback to passive when adapter init fails."""
called_start = 0
called_stop = 0
_callback = None
mock_discovered: list[Any] = []
class _InProgressFatalScanner(MockBleakScanner):
async def start(self, *args: object, **kwargs: object) -> None:
nonlocal called_start
called_start += 1
if called_start == 1:
msg = "org.freedesktop.DBus.Error.UnknownObject"
raise BleakError(msg)
if called_start == 2:
msg = "org.bluez.Error.InProgress"
raise BleakError(msg)
if called_start == 3:
msg = "org.bluez.Error.InProgress"
raise BleakError(msg)
async def stop(self, *args: object, **kwargs: object) -> None:
nonlocal called_stop
called_stop += 1
@property
def discovered_devices(self):
return mock_discovered
def register_detection_callback(
self, callback: AdvertisementDataCallback
) -> None:
nonlocal _callback
_callback = callback
mock_scanner = _InProgressFatalScanner()
start_time_monotonic = time.monotonic()
with (
patch(
"habluetooth.scanner.IS_LINUX",
True,
),
patch(
"habluetooth.scanner.ADAPTER_INIT_TIME",
0,
),
patch_bluetooth_time(
start_time_monotonic,
),
patch(
"habluetooth.scanner.OriginalBleakScanner",
return_value=mock_scanner,
),
patch(
"habluetooth.util.recover_adapter", return_value=True
) as mock_recover_adapter,
):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert called_start == 4
assert len(mock_recover_adapter.mock_calls) == 1
assert "Waiting for adapter to initialize" in caplog.text
assert (
"Successful fall-back to passive scanning mode after active scanning failed"
in caplog.text
)
assert await scanner.async_diagnostics() == {
"adapter": "hci0",
"connect_failures": {},
"connect_in_progress": {},
"connect_completed_total": 0,
"connect_failed_total": 0,
"last_connect_completed_time": 0.0,
"connectable": True,
"current_mode": BluetoothScanningMode.PASSIVE,
"discovered_devices_and_advertisement_data": [],
"last_detection": ANY,
"monotonic_time": ANY,
"name": "hci0 (AA:BB:CC:DD:EE:FF)",
"requested_mode": BluetoothScanningMode.ACTIVE,
"scanning": True,
"source": "AA:BB:CC:DD:EE:FF",
"start_time": ANY,
"type": "HaScanner",
}
await scanner.async_stop()
assert await scanner.async_diagnostics() == {
"adapter": "hci0",
"connect_failures": {},
"connect_in_progress": {},
"connect_completed_total": 0,
"connect_failed_total": 0,
"last_connect_completed_time": 0.0,
"connectable": True,
"current_mode": BluetoothScanningMode.PASSIVE,
"discovered_devices_and_advertisement_data": [],
"last_detection": ANY,
"monotonic_time": ANY,
"name": "hci0 (AA:BB:CC:DD:EE:FF)",
"requested_mode": BluetoothScanningMode.ACTIVE,
"scanning": False,
"source": "AA:BB:CC:DD:EE:FF",
"start_time": ANY,
"type": "HaScanner",
}
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_scanner_with_bluez_mgmt_side_channel(mock_btmgmt_socket: Mock) -> None:
"""Test scanner receiving advertisements via BlueZ management side channel."""
# Mock capability check for the entire test
with patch.object(MGMTBluetoothCtl, "_check_capabilities", return_value=True):
# Create a custom manager that tracks discovered devices
class TestBluetoothManager(BluetoothManager):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.discovered_infos = []
def _discover_service_info(
self, service_info: BluetoothServiceInfoBleak
) -> None:
"""Track discovered service info."""
self.discovered_infos.append(service_info)
# Create manager and setup mgmt controller
adapters = FakeBluetoothAdapters()
slot_manager = BleakSlotManager()
manager = TestBluetoothManager(adapters, slot_manager)
set_manager(manager)
# Set up the manager first
await manager.async_setup()
# Create and setup the mgmt controller with the manager's side channel scanners
mgmt_ctl = MGMTBluetoothCtl(
timeout=5.0, scanners=manager._side_channel_scanners
)
# Mock the protocol setup
mock_protocol = Mock(spec=BluetoothMGMTProtocol)
mock_transport = Mock()
mock_protocol.transport = mock_transport
async def mock_setup():
mgmt_ctl.protocol = mock_protocol
mgmt_ctl._on_connection_lost_future = (
asyncio.get_running_loop().create_future()
)
mgmt_ctl.setup = mock_setup # type: ignore[method-assign]
# Inject mgmt controller into manager
manager._mgmt_ctl = mgmt_ctl
manager.has_advertising_side_channel = True
# Verify get_bluez_mgmt_ctl returns our controller
assert manager.get_bluez_mgmt_ctl() is mgmt_ctl
# Register scanner
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
manager.async_register_scanner(scanner, connection_slots=2)
# Start scanner - should be created without detection callback
with patch("habluetooth.scanner.OriginalBleakScanner") as mock_scanner_class:
mock_scanner = Mock()
mock_scanner.start = AsyncMock()
mock_scanner.stop = AsyncMock()
mock_scanner.discovered_devices = []
mock_scanner_class.return_value = mock_scanner
await scanner.async_start()
# Verify scanner was created without detection callback
# since side channel is available
mock_scanner_class.assert_called_once()
call_kwargs = mock_scanner_class.call_args[1]
assert (
"detection_callback" not in call_kwargs
or call_kwargs["detection_callback"] is None
)
# Now simulate advertisement data coming through the mgmt protocol
# The manager should have registered the scanner with mgmt_ctl
assert 0 in mgmt_ctl.scanners # hci0 is index 0
assert mgmt_ctl.scanners[0] is scanner
# Simulate the protocol calling the scanner's raw advertisement handler
test_address = b"\xaa\xbb\xcc\xdd\xee\xff"
test_rssi = -60
test_flags = 0x06
# Create valid advertisement data with flags
# Each AD structure is: length (1 byte), type (1 byte), data
test_data = (
b"\x02\x01\x06" # Length=2, Type=0x01 (Flags), Data=0x06
# Length=8, Type=0x09 (Complete Local Name), Data="TestDev"
b"\x08\x09TestDev"
)
# Call the method that the protocol would call
scanner._async_on_raw_bluez_advertisement(
test_address,
1, # address_type: BDADDR_LE_PUBLIC
test_rssi,
test_flags,
test_data,
)
# Allow time for processing
await asyncio.sleep(0)
# Verify the device was discovered in the base scanner
assert len(scanner._previous_service_info) == 1
assert "FF:EE:DD:CC:BB:AA" in scanner._previous_service_info
service_info = scanner._previous_service_info["FF:EE:DD:CC:BB:AA"]
assert service_info.address == "FF:EE:DD:CC:BB:AA"
assert service_info.rssi == test_rssi
assert service_info.name == "TestDev"
# Verify the manager also received the advertisement
assert len(manager.discovered_infos) == 1
assert manager.discovered_infos[0] is service_info
await scanner.async_stop()
manager.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_scanner_without_bluez_mgmt_side_channel() -> None:
"""Test scanner uses normal detection callback when side channel unavailable."""
# Create manager without BlueZ mgmt support
class TestBluetoothManager(BluetoothManager):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.discovered_infos = []
def _discover_service_info(
self, service_info: BluetoothServiceInfoBleak
) -> None:
"""Track discovered service info."""
self.discovered_infos.append(service_info)
adapters = FakeBluetoothAdapters()
slot_manager = BleakSlotManager()
manager = TestBluetoothManager(adapters, slot_manager)
set_manager(manager)
# Setup without mgmt controller
await manager.async_setup()
assert manager.has_advertising_side_channel is False
# Register scanner
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
manager.async_register_scanner(scanner, connection_slots=2)
# Start scanner - should be created with detection callback
with patch("habluetooth.scanner.OriginalBleakScanner") as mock_scanner_class:
mock_scanner = Mock()
mock_scanner.start = AsyncMock()
mock_scanner.stop = AsyncMock()
mock_scanner.discovered_devices = []
mock_scanner_class.return_value = mock_scanner
await scanner.async_start()
# Verify scanner was created with detection callback since no side channel
mock_scanner_class.assert_called_once()
call_kwargs = mock_scanner_class.call_args[1]
assert "detection_callback" in call_kwargs
assert call_kwargs["detection_callback"] is not None
assert call_kwargs["detection_callback"] == scanner._async_detection_callback
await scanner.async_stop()
manager.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_bluez_mgmt_protocol_data_flow(mock_btmgmt_socket: Mock) -> None:
"""Test data flow from BlueZ protocol through manager to scanner."""
# Mock capability check for the entire test
with patch.object(MGMTBluetoothCtl, "_check_capabilities", return_value=True):
# Create manager
class TestBluetoothManager(BluetoothManager):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.discovered_infos = []
def _discover_service_info(
self, service_info: BluetoothServiceInfoBleak
) -> None:
"""Track discovered service info."""
self.discovered_infos.append(service_info)
adapters = FakeBluetoothAdapters()
slot_manager = BleakSlotManager()
manager = TestBluetoothManager(adapters, slot_manager)
set_manager(manager)
# Set up manager first
await manager.async_setup()
# Create mgmt controller with the manager's side channel scanners dictionary
mgmt_ctl = MGMTBluetoothCtl(
timeout=5.0, scanners=manager._side_channel_scanners
)
# We'll capture the protocol when it's created
captured_protocol: BluetoothMGMTProtocol | None = None
async def mock_create_connection(sock, protocol_factory, *args, **kwargs):
nonlocal captured_protocol
captured_protocol = protocol_factory()
mock_transport = Mock()
captured_protocol.connection_made(mock_transport)
return mock_transport, captured_protocol
with patch.object(
asyncio.get_running_loop(),
"_create_connection_transport",
mock_create_connection,
):
await mgmt_ctl.setup()
# Set mgmt controller on manager
manager._mgmt_ctl = mgmt_ctl
manager.has_advertising_side_channel = True
# Register scanners for hci0 and hci1
scanner0 = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:00")
scanner0.async_setup()
manager.async_register_scanner(scanner0, connection_slots=2)
scanner1 = HaScanner(BluetoothScanningMode.ACTIVE, "hci1", "AA:BB:CC:DD:EE:01")
scanner1.async_setup()
manager.async_register_scanner(scanner1, connection_slots=2)
# Start scanners
with patch("habluetooth.scanner.OriginalBleakScanner") as mock_scanner_class:
mock_scanner = Mock()
mock_scanner.start = AsyncMock()
mock_scanner.stop = AsyncMock()
mock_scanner.discovered_devices = []
mock_scanner_class.return_value = mock_scanner
await scanner0.async_start()
await scanner1.async_start()
# Verify scanners are registered in mgmt_ctl
assert 0 in mgmt_ctl.scanners
assert 1 in mgmt_ctl.scanners
assert mgmt_ctl.scanners[0] is scanner0
assert mgmt_ctl.scanners[1] is scanner1
# Test DEVICE_FOUND event for hci0
test_address = b"\x11\x22\x33\x44\x55\x66"
rssi_byte = b"\xc4" # -60 in signed byte
event_data = (
test_address
+ b"\x01" # address_type
+ rssi_byte
+ b"\x06\x00\x00\x00" # flags
+ b"\x03\x00" # data_len
+ b"\x02\x01\x06" # minimal adv data
)
packet = (
DEVICE_FOUND.to_bytes(2, "little")
+ b"\x00\x00" # controller_idx 0 (hci0)
+ len(event_data).to_bytes(2, "little")
+ event_data
)
# Feed packet to protocol
assert captured_protocol is not None
captured_protocol.data_received(packet)
# Verify device discovered on scanner0 only
assert len(scanner0._previous_service_info) == 1
assert "66:55:44:33:22:11" in scanner0._previous_service_info
assert len(scanner1._previous_service_info) == 0
# Test ADV_MONITOR_DEVICE_FOUND event for hci1
test_address2 = b"\xaa\xbb\xcc\xdd\xee\x02"
monitor_handle = b"\x01\x00"
rssi_byte2 = b"\xba" # -70 in signed byte
event_data2 = (
monitor_handle
+ test_address2
+ b"\x02" # address_type (random)
+ rssi_byte2
+ b"\x06\x00\x00\x00" # flags
+ b"\x03\x00" # data_len
+ b"\x02\x01\x06" # minimal adv data
)
packet2 = (
ADV_MONITOR_DEVICE_FOUND.to_bytes(2, "little")
+ b"\x01\x00" # controller_idx 1 (hci1)
+ len(event_data2).to_bytes(2, "little")
+ event_data2
)
assert captured_protocol is not None
captured_protocol.data_received(packet2)
# Verify device discovered on scanner1 only
assert len(scanner0._previous_service_info) == 1 # Still just the first device
assert len(scanner1._previous_service_info) == 1
assert "02:EE:DD:CC:BB:AA" in scanner1._previous_service_info
# Verify RSSI values
info0 = scanner0._previous_service_info["66:55:44:33:22:11"]
assert info0.rssi == -60
info1 = scanner1._previous_service_info["02:EE:DD:CC:BB:AA"]
assert info1.rssi == -70
await scanner0.async_stop()
await scanner1.async_stop()
manager.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_mgmt_permission_error_fallback() -> None:
"""Test that permission errors in MGMT setup fall back to BlueZ-only mode."""
# Create manager
class TestBluetoothManager(BluetoothManager):
def _discover_service_info(
self, service_info: BluetoothServiceInfoBleak
) -> None:
"""Track discovered service info."""
adapters = FakeBluetoothAdapters()
slot_manager = BleakSlotManager()
manager = TestBluetoothManager(adapters, slot_manager)
# Mock MGMTBluetoothCtl setup to raise PermissionError
with (
patch("habluetooth.manager.MGMTBluetoothCtl") as mock_mgmt_cls,
patch("habluetooth.manager.IS_LINUX", True),
):
mock_mgmt = Mock()
mock_mgmt.setup = AsyncMock(
side_effect=PermissionError(
"Missing NET_ADMIN/NET_RAW capabilities for Bluetooth management"
)
)
mock_mgmt_cls.return_value = mock_mgmt
# Setup should complete without raising the exception
await manager.async_setup()
# Verify MGMT was attempted but then set to None
mock_mgmt.setup.assert_called_once()
assert manager._mgmt_ctl is None
assert manager.has_advertising_side_channel is False
def test_usb_scanner_type() -> None:
"""Test that USB adapters get USB scanner type."""
manager = get_manager()
# Mock cached adapters with USB adapter
mock_adapters: dict[str, dict[str, Any]] = {
"hci0": {
"address": "00:1A:7D:DA:71:04",
"adapter_type": "usb",
"manufacturer": "TestManufacturer",
"product": "USB Bluetooth Adapter",
}
}
with patch.object(manager, "_adapters", mock_adapters):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04")
assert scanner.details.scanner_type is HaScannerType.USB
def test_uart_scanner_type() -> None:
"""Test that UART adapters get UART scanner type."""
manager = get_manager()
# Mock cached adapters with UART adapter
mock_adapters: dict[str, dict[str, Any]] = {
"hci0": {
"address": "00:1A:7D:DA:71:04",
"adapter_type": "uart",
"manufacturer": "TestManufacturer",
"product": "UART Bluetooth Module",
}
}
with patch.object(manager, "_adapters", mock_adapters):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04")
assert scanner.details.scanner_type is HaScannerType.UART
def test_unknown_scanner_type_no_cached_adapters() -> None:
"""Test that scanners get UNKNOWN type when no adapter info is cached."""
manager = get_manager()
# No cached adapters
with patch.object(manager, "_adapters", None):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04")
assert scanner.details.scanner_type is HaScannerType.UNKNOWN
def test_unknown_scanner_type_adapter_not_found() -> None:
"""Test that scanners get UNKNOWN type when adapter is not in cache."""
manager = get_manager()
# Cached adapters but not the one we're looking for
mock_adapters: dict[str, dict[str, Any]] = {
"hci1": {
"address": "11:22:33:44:55:66",
"adapter_type": "usb",
}
}
with patch.object(manager, "_adapters", mock_adapters):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04")
assert scanner.details.scanner_type is HaScannerType.UNKNOWN
def test_unknown_scanner_type_no_adapter_type() -> None:
"""Test that scanners get UNKNOWN type when adapter_type is None."""
manager = get_manager()
# Cached adapter without adapter_type field
mock_adapters: dict[str, dict[str, Any]] = {
"hci0": {
"address": "00:1A:7D:DA:71:04",
"adapter_type": None,
"manufacturer": "TestManufacturer",
}
}
with patch.object(manager, "_adapters", mock_adapters):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04")
assert scanner.details.scanner_type is HaScannerType.UNKNOWN
@pytest.mark.asyncio
async def test_scanner_type_with_real_adapter_data() -> None:
"""Test scanner type detection with realistic adapter data."""
# Create a custom manager for this test
manager = BluetoothManager(bluetooth_adapters=MagicMock())
set_manager(manager)
# Simulate real USB adapter data from Linux
usb_adapter_data: dict[str, dict[str, Any]] = {
"hci0": {
"address": "00:1A:7D:DA:71:04",
"sw_version": "homeassistant",
"hw_version": "usb:v1D6Bp0246d053F",
"passive_scan": False,
"manufacturer": "XTech",
"product": "Bluetooth 4.0 USB Adapter",
"vendor_id": "0a12",
"product_id": "0001",
"adapter_type": "usb",
}
}
manager._adapters = usb_adapter_data
# Create USB scanner
usb_scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04")
assert usb_scanner.details.scanner_type is HaScannerType.USB
assert usb_scanner.details.adapter == "hci0"
# Simulate real UART adapter data
uart_adapter_data: dict[str, dict[str, Any]] = {
"hci1": {
"address": "AA:BB:CC:DD:EE:FF",
"sw_version": "homeassistant",
"hw_version": "uart:ttyUSB0",
"passive_scan": False,
"manufacturer": "cyber-blue(HK)Ltd",
"product": "Bluetooth 4.0 UART Module",
"vendor_id": None,
"product_id": None,
"adapter_type": "uart",
}
}
manager._adapters = uart_adapter_data
# Create UART scanner
uart_scanner = HaScanner(BluetoothScanningMode.PASSIVE, "hci1", "AA:BB:CC:DD:EE:FF")
assert uart_scanner.details.scanner_type is HaScannerType.UART
assert uart_scanner.details.adapter == "hci1"
# Test with macOS/Windows adapter (no adapter_type)
macos_adapter_data = {
"Core Bluetooth": {
"address": "00:00:00:00:00:00",
"passive_scan": False,
"sw_version": "18.7.0",
"manufacturer": "Apple",
"product": "Unknown MacOS Model",
"vendor_id": "Unknown",
"product_id": "Unknown",
"adapter_type": None,
}
}
manager._adapters = macos_adapter_data
# Create scanner with unknown adapter type
macos_scanner = HaScanner(
BluetoothScanningMode.ACTIVE, "Core Bluetooth", "00:00:00:00:00:00"
)
assert macos_scanner.details.scanner_type is HaScannerType.UNKNOWN
@pytest.mark.asyncio
async def test_scanner_type_updates_after_adapter_refresh() -> None:
"""Test scanner type is UNKNOWN initially, determined after adapters load."""
# Create a custom manager for this test
manager = BluetoothManager(bluetooth_adapters=MagicMock())
set_manager(manager)
# Initially no adapters cached
manager._adapters = None # type: ignore[assignment]
# Create scanner - should be UNKNOWN
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04")
assert scanner.details.scanner_type is HaScannerType.UNKNOWN
# Now simulate adapter data becoming available
manager._adapters = {
"hci0": {
"address": "00:1A:7D:DA:71:04",
"adapter_type": "usb",
"manufacturer": "TestManufacturer",
}
}
# Create a new scanner with the same adapter - should now be USB
scanner2 = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04")
assert scanner2.details.scanner_type is HaScannerType.USB
# Note: The first scanner still has UNKNOWN since scanner_type is set at init
assert scanner.details.scanner_type is HaScannerType.UNKNOWN
def test_multiple_scanner_types_simultaneously() -> None:
"""Test that multiple scanners can have different types at the same time."""
manager = get_manager()
# Set up adapters with different types
mock_adapters = {
"hci0": {
"address": "00:1A:7D:DA:71:04",
"adapter_type": "usb",
},
"hci1": {
"address": "AA:BB:CC:DD:EE:FF",
"adapter_type": "uart",
},
"hci2": {
"address": "11:22:33:44:55:66",
"adapter_type": None,
},
}
with patch.object(manager, "_adapters", mock_adapters):
# Create scanners of different types
usb_scanner = HaScanner(
BluetoothScanningMode.ACTIVE, "hci0", "00:1A:7D:DA:71:04"
)
uart_scanner = HaScanner(
BluetoothScanningMode.ACTIVE, "hci1", "AA:BB:CC:DD:EE:FF"
)
unknown_scanner = HaScanner(
BluetoothScanningMode.ACTIVE, "hci2", "11:22:33:44:55:66"
)
# Verify each has the correct type
assert usb_scanner.details.scanner_type is HaScannerType.USB
assert uart_scanner.details.scanner_type is HaScannerType.UART
assert unknown_scanner.details.scanner_type is HaScannerType.UNKNOWN
# Verify they all have different types
types = {
usb_scanner.details.scanner_type,
uart_scanner.details.scanner_type,
unknown_scanner.details.scanner_type,
}
assert len(types) == 3 # All different
def test_ha_scanner_get_allocations_no_slot_manager() -> None:
"""Test HaScanner.get_allocations returns None when manager has no slot_manager."""
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
manager = get_manager()
# Mock slot_manager as None
with patch.object(manager, "slot_manager", None):
assert scanner.get_allocations() is None
def test_ha_scanner_get_allocations_with_slot_manager() -> None:
"""Test HaScanner.get_allocations returns allocation info from BleakSlotManager."""
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
manager = get_manager()
# Create mock allocations
mock_allocations = Allocations(
adapter="hci0",
slots=5,
free=3,
allocated=["11:22:33:44:55:66", "AA:BB:CC:DD:EE:FF"],
)
# Mock slot_manager
mock_slot_manager = Mock(spec=BleakSlotManager)
mock_slot_manager.get_allocations.return_value = mock_allocations
with patch.object(manager, "slot_manager", mock_slot_manager):
allocations = scanner.get_allocations()
assert allocations is not None
assert allocations == mock_allocations
mock_slot_manager.get_allocations.assert_called_once_with("hci0")
def test_ha_scanner_get_allocations_updates_dynamically() -> None:
"""Test that HaScanner.get_allocations returns current values as they change."""
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
manager = get_manager()
# Mock slot_manager
mock_slot_manager = Mock(spec=BleakSlotManager)
# Initial state - 3 free slots
mock_slot_manager.get_allocations.return_value = Allocations(
adapter="hci0", slots=3, free=3, allocated=[]
)
with patch.object(manager, "slot_manager", mock_slot_manager):
# Check initial state
allocations = scanner.get_allocations()
assert allocations is not None
assert allocations.free == 3
assert allocations.allocated == []
# Update mock to simulate connection made
mock_slot_manager.get_allocations.return_value = Allocations(
adapter="hci0", slots=3, free=2, allocated=["11:22:33:44:55:66"]
)
# Check updated state
allocations = scanner.get_allocations()
assert allocations is not None
assert allocations.free == 2
assert allocations.allocated == ["11:22:33:44:55:66"]
# Update mock to simulate another connection
mock_slot_manager.get_allocations.return_value = Allocations(
adapter="hci0",
slots=3,
free=1,
allocated=["11:22:33:44:55:66", "AA:BB:CC:DD:EE:FF"],
)
# Check final state
allocations = scanner.get_allocations()
assert allocations is not None
assert allocations.free == 1
assert len(allocations.allocated) == 2
@pytest.mark.asyncio
async def test_on_scanner_start_callback(
async_mock_manager_with_scanner_callbacks: MockBluetoothManagerWithCallbacks,
) -> None:
"""Test that on_scanner_start is called when a local scanner starts."""
manager = async_mock_manager_with_scanner_callbacks
# Create a local scanner (it will get the manager from get_manager())
scanner = HaScanner(
mode=BluetoothScanningMode.ACTIVE,
adapter="hci0",
address="00:00:00:00:00:00",
)
# Register scanner with manager
manager.async_register_scanner(scanner)
# Setup the scanner
scanner.async_setup()
# Directly call _on_start_success to test the callback
# (In real usage, this is called by HaScanner._async_start_attempt
# after successful start)
scanner._on_start_success()
# Verify the callback was called
assert len(manager.scanner_start_calls) == 1
assert manager.scanner_start_calls[0] is scanner
@pytest.mark.asyncio
async def test_async_request_active_window_rejected_when_not_auto() -> None:
"""Non-AUTO scanners ignore active-window requests and return False."""
scanner = HaScanner(BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
assert await scanner.async_request_active_window(1.0) is False
assert scanner._scan_mode_override is None
@pytest.mark.asyncio
@pytest.mark.parametrize("duration", [float("nan"), float("inf"), -1.0, 0.0])
async def test_async_request_active_window_rejects_invalid_duration(
duration: float,
) -> None:
"""
NaN/inf/non-positive durations are refused at the entry point.
A bad duration would poison ``loop.call_later`` (which raises on
NaN) and the extension comparison (NaN ordering is always False,
inf would lock the window open). Guard the public entry so a
misbehaving subclass / direct caller can't corrupt the scheduler
state.
"""
scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
assert await scanner.async_request_active_window(duration) is False
assert scanner._scan_mode_override is None
assert scanner._active_window_handle is None
@pytest.mark.usefixtures("force_linux_scanner_mode")
@pytest.mark.asyncio
async def test_async_request_active_window_restarts_scanner_in_active_mode() -> None:
"""An AUTO scanner flips to ACTIVE and schedules a return to the prior mode."""
starts: list[str] = []
def _factory(*_args, **kwargs):
starts.append(kwargs["scanning_mode"])
return MockBleakScanner()
with patch("habluetooth.scanner.OriginalBleakScanner", side_effect=_factory):
scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
# Initial construction: AUTO maps to passive in bleak's
# scanning_mode. The active-window toggle path reuses
# this single BleakScanner instance and just mutates
# _backend._scanning_mode instead of constructing again.
assert starts == ["passive"]
backend = scanner.scanner._backend # type: ignore[union-attr]
backend._scanning_mode = "passive"
# Tiny duration so call_later fires on the next loop turn.
# async_request_active_window rejects 0/NaN/inf at the boundary,
# so we use the smallest positive value that round-trips through
# the timer arithmetic.
assert await scanner.async_request_active_window(1e-9) is True
# The toggle flipped the existing instance to active.
assert backend._scanning_mode == "active"
assert scanner._scan_mode_override is BluetoothScanningMode.ACTIVE
assert scanner._active_window_handle is not None
# Let the call_later fire and the background restart task complete.
for _ in range(6):
await asyncio.sleep(0)
# End-of-window toggled the same instance back to passive.
assert backend._scanning_mode == "passive"
assert scanner._scan_mode_override is None
assert scanner._active_window_handle is None # type: ignore[unreachable]
await scanner.async_stop()
@pytest.mark.asyncio
async def test_active_window_restart_does_not_log_fallback_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
"""
A successful active-window restart on an AUTO scanner must not warn.
Regression: the start-success log compared current_mode against
requested_mode. For an AUTO scanner mid-active-window,
requested_mode is AUTO but current_mode is ACTIVE (because the
restart was triggered by the scheduler with
_scan_mode_override=ACTIVE), so the previous code logged a
spurious "fell back to passive" warning on every active-window
restart. The check now uses effective_mode (the mode we tried to
start in) so it only triggers on a real fallback.
"""
with patch(
"habluetooth.scanner.OriginalBleakScanner",
side_effect=lambda *_a, **_kw: MockBleakScanner(),
):
scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
caplog.clear()
with caplog.at_level(logging.WARNING):
assert await scanner.async_request_active_window(10.0) is True
assert not any(
"fall-back to passive" in record.message for record in caplog.records
)
await scanner.async_stop()
@pytest.mark.usefixtures("force_linux_scanner_mode")
@pytest.mark.asyncio
async def test_async_toggle_active_window_mode_returns_false_when_no_scanner() -> None:
"""The toggle helper bails when the scanner instance is gone."""
scanner_obj = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF")
scanner_obj.async_setup()
assert scanner_obj.scanner is None
assert await scanner_obj._async_toggle_active_window_mode() is False
@pytest.mark.usefixtures("force_linux_scanner_mode")
@pytest.mark.asyncio
async def test_async_toggle_active_window_mode_returns_false_on_stop_error() -> None:
"""The toggle helper logs and bails when scanner.stop() raises."""
class StopErrorMockBleakScanner(MockBleakScanner):
async def stop(self) -> None:
msg = "simulated stop failure"
raise BleakError(msg)
with patch_bleak_scanner_factory(StopErrorMockBleakScanner):
scanner_obj = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF")
scanner_obj.async_setup()
await scanner_obj.async_start()
scanner_obj._scan_mode_override = BluetoothScanningMode.ACTIVE
assert scanner_obj.scanning is True
assert await scanner_obj._async_toggle_active_window_mode() is False
# scanner.stop() raised so the bleak scanner is in an
# undefined state; the wrapper must reflect that as not-
# scanning so the caller's fallback path treats it correctly.
assert scanner_obj.scanning is False
@pytest.mark.usefixtures("force_linux_scanner_mode")
@pytest.mark.asyncio
async def test_async_toggle_active_window_mode_marks_not_scanning_on_start_error() -> (
None
):
"""
Toggle's start-error path also clears self.scanning.
The stop succeeded but the post-mode-flip start raised, so the
bleak scanner is stopped. self.scanning must follow.
"""
starts = 0
class StartErrorMockBleakScanner(MockBleakScanner):
async def start(self) -> None:
nonlocal starts
starts += 1
# First start (initial async_start) succeeds; the
# post-flip start (second call) raises.
if starts > 1:
msg = "simulated start failure"
raise BleakError(msg)
with patch_bleak_scanner_factory(StartErrorMockBleakScanner):
scanner_obj = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF")
scanner_obj.async_setup()
await scanner_obj.async_start()
scanner_obj._scan_mode_override = BluetoothScanningMode.ACTIVE
assert scanner_obj.scanning is True
assert await scanner_obj._async_toggle_active_window_mode() is False
assert scanner_obj.scanning is False
@pytest.mark.usefixtures("force_linux_scanner_mode")
@pytest.mark.asyncio
async def test_async_toggle_active_window_mode_attribute_error_marks_not_scanning() -> (
None
):
"""
Toggle gracefully handles bleak refactoring away ``_scanning_mode``.
If a future bleak version drops or renames ``_backend._scanning_mode``,
the mutation raises AttributeError. The stop has already completed,
so without a guard the scanner would be left stopped and the caller
would have no signal to fall back to the full path. The guard logs,
clears ``self.scanning``, and returns False so the caller can
recover via the full restart path.
"""
class MockBackend:
@property
def _scanning_mode(self) -> str:
msg = "simulated bleak refactor — attribute removed"
raise AttributeError(msg)
@_scanning_mode.setter
def _scanning_mode(self, value: str) -> None:
msg = "simulated bleak refactor — attribute removed"
raise AttributeError(msg)
class AttrErrorMockBleakScanner(MockBleakScanner):
def __init__(self) -> None:
self._backend = MockBackend()
with patch_bleak_scanner_factory(AttrErrorMockBleakScanner):
scanner_obj = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF")
scanner_obj.async_setup()
await scanner_obj.async_start()
scanner_obj._scan_mode_override = BluetoothScanningMode.ACTIVE
assert scanner_obj.scanning is True
assert await scanner_obj._async_toggle_active_window_mode() is False
assert scanner_obj.scanning is False
@pytest.mark.usefixtures("force_linux_scanner_mode")
@pytest.mark.asyncio
async def test_arm_active_window_timer_cancels_existing_handle() -> None:
"""
_arm_active_window_timer cancels any prior handle before arming.
Regression for the concurrent-callers race noted in PR review:
two concurrent ``async_request_active_window`` calls could both
reach _arm_active_window_timer without the second cancelling the
first's TimerHandle, leaking a pending timer that would later fire
an extra _async_end_active_window. Today only the scheduler drives
the public method (and _tick serializes per worker) so the race
isn't reachable through normal callers, but the contract on
_arm_active_window_timer must defend against it.
"""
with patch(
"habluetooth.scanner.OriginalBleakScanner",
side_effect=lambda *_a, **_kw: MockBleakScanner(),
):
scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
# Arm a window so there's a handle to potentially leak.
assert await scanner.async_request_active_window(100.0) is True
first_handle = scanner._active_window_handle
assert first_handle is not None
# Directly call _arm again (simulating the race-path second
# caller). The first handle must be cancelled, not leaked.
scanner._arm_active_window_timer(50.0)
assert first_handle.cancelled()
assert scanner._active_window_handle is not first_handle
await scanner.async_stop()
@pytest.mark.usefixtures("force_linux_scanner_mode")
@pytest.mark.asyncio
async def test_async_request_active_window_extends_existing_window() -> None:
"""A second request inside an active window extends the timer in place."""
starts: list[str] = []
def _factory(*_args, **kwargs):
starts.append(kwargs["scanning_mode"])
return MockBleakScanner()
with patch("habluetooth.scanner.OriginalBleakScanner", side_effect=_factory):
scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
assert await scanner.async_request_active_window(100.0) is True
first_handle = scanner._active_window_handle
first_end = scanner._active_window_end
# A longer request extends the existing window without a second restart.
assert await scanner.async_request_active_window(200.0) is True
assert scanner._active_window_handle is not first_handle
assert scanner._active_window_end > first_end
# Only one BleakScanner construction happened (the initial
# passive one). The active-window flip toggles the existing
# instance's _backend._scanning_mode instead of creating a
# new scanner.
assert starts == ["passive"]
assert scanner.current_mode is BluetoothScanningMode.ACTIVE
# A shorter follow-up is a no-op on the timer.
kept_end = scanner._active_window_end
assert await scanner.async_request_active_window(0.001) is True
assert scanner._active_window_end == kept_end
await scanner.async_stop()
@pytest.mark.usefixtures("force_linux_scanner_mode")
@pytest.mark.asyncio
async def test_async_request_active_window_end_time_matches_real_timer() -> None:
"""
_active_window_end reflects the post-restart loop.time() + duration.
Regression: a slow stop/restart cycle previously left
``_active_window_end`` set to ``loop.time() + duration`` captured
*before* the restart, so it lagged the real ``call_later`` fire
time by the restart duration. The fix moved the
``_active_window_end`` computation inside
``_arm_active_window_timer`` so it always matches when the timer
will actually fire.
Uses an asyncio.Event to gate the restart-in-progress
deterministically rather than relying on asyncio.sleep precision,
which can fire slightly early on busy CI runners.
"""
duration = 10.0
restart_started = asyncio.Event()
gate = asyncio.Event()
class GatedMockBleakScanner(MockBleakScanner):
_first_start_done = False
async def start(self) -> None:
if not type(self)._first_start_done:
type(self)._first_start_done = True
return
restart_started.set()
await gate.wait()
with patch_bleak_scanner_factory(GatedMockBleakScanner):
scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
loop = asyncio.get_running_loop()
before = loop.time()
task = asyncio.create_task(scanner.async_request_active_window(duration))
await restart_started.wait()
# Provably advance loop.time() past `before` before the restart
# completes; the exact amount doesn't matter for the assertion
# below as long as loop.time() has visibly moved.
await asyncio.sleep(0.05)
elapsed = loop.time() - before
gate.set()
assert await task is True
# Contract: _active_window_end matches loop.time() + duration
# measured AFTER the restart, not before. Pre-fix it would be
# before + duration. Allow generous tolerance for the small
# gap between arming and reading.
now = loop.time()
assert scanner._active_window_end == pytest.approx(now + duration, abs=0.1)
# Reject pre-fix value (before + duration) explicitly with a
# margin well above asyncio scheduling jitter: the stored end
# is at least ``elapsed`` ahead of before + duration.
assert scanner._active_window_end - before - duration >= elapsed / 2
first_handle = scanner._active_window_handle
# A follow-up whose new_end lands between the pre-fix stored
# end and the real fire time must NOT be treated as an
# extension. With the fix this is rejected; without it the
# live timer would be cancelled and armed shorter.
target_new_end = before + duration + elapsed / 2
shorter_duration = target_new_end - loop.time()
assert await scanner.async_request_active_window(shorter_duration) is True
assert scanner._active_window_handle is first_handle
await scanner.async_stop()
@pytest.mark.usefixtures("force_linux_scanner_mode")
@pytest.mark.asyncio
async def test_async_request_active_window_skips_restart_if_still_active() -> None:
"""
Re-arm the timer instead of restarting if the scanner is still ACTIVE.
A new request arriving after the end-of-window timer fires but
before the bg task runs reuses the in-flight ACTIVE mode and just
arms a new timer.
"""
starts: list[str] = []
def _factory(*_args, **kwargs):
starts.append(kwargs["scanning_mode"])
return MockBleakScanner()
with patch("habluetooth.scanner.OriginalBleakScanner", side_effect=_factory):
scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
# Single construction (passive); toggle reuses the instance.
assert starts == ["passive"]
backend = scanner.scanner._backend # type: ignore[union-attr]
backend._scanning_mode = "passive"
assert await scanner.async_request_active_window(100.0) is True
# Toggle flipped the existing instance to active.
assert backend._scanning_mode == "active"
# Simulate the timer firing but the end-window task not having
# run yet: clear the handle (like _schedule_end_active_window
# does) but leave _scan_mode_override / current_mode == ACTIVE.
handle = scanner._active_window_handle
assert handle is not None
handle.cancel()
scanner._active_window_handle = None
# Scanner is still ACTIVE; a longer follow-up re-arms the
# timer without flipping the radio again. A shorter follow-up
# would no-op the timer (covered by
# test_async_request_active_window_still_active_does_not_shrink).
assert await scanner.async_request_active_window(200.0) is True
assert scanner._active_window_handle is not None
# Mode unchanged: no toggle happened on the still-ACTIVE path.
assert backend._scanning_mode == "active" # type: ignore[unreachable]
# Still only one BleakScanner construction.
assert starts == ["passive"]
await scanner.async_stop()
@pytest.mark.usefixtures("force_linux_scanner_mode")
@pytest.mark.asyncio
async def test_async_request_active_window_still_active_does_not_shrink() -> None:
"""
Concurrent shorter caller into the still-ACTIVE locked branch is a no-op.
Regression: the locked early-return at the top of
``async_request_active_window``'s lock block re-armed the timer
unconditionally when ``current_mode is ACTIVE``. A second caller
with a shorter duration could shrink an in-flight window someone
else asked for. Guarded with the same
``loop.time() + duration > _active_window_end`` check the
lockless fast-path uses.
"""
with patch(
"habluetooth.scanner.OriginalBleakScanner",
side_effect=lambda *_, **__: MockBleakScanner(),
):
scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
# Open a long window so _active_window_handle is set and
# current_mode is ACTIVE.
assert await scanner.async_request_active_window(100.0) is True
long_end = scanner._active_window_end
long_handle = scanner._active_window_handle
# Simulate the timer firing without _async_end_active_window
# running yet: clear the handle so the locked branch is
# reachable (lockless fast path needs handle is not None).
assert long_handle is not None
long_handle.cancel()
scanner._active_window_handle = None
# Concurrent shorter caller now hits the locked
# current_mode-is-ACTIVE branch. Pre-fix this would re-arm
# at end = now + 5 (shrinking the live window); post-fix the
# stored end-time stays put and the timer isn't re-armed.
assert await scanner.async_request_active_window(5.0) is True
assert scanner._active_window_end == long_end
await scanner.async_stop()
@pytest.mark.usefixtures("force_linux_scanner_mode")
@pytest.mark.asyncio
async def test_async_stop_clears_active_window_state() -> None:
"""Stopping mid-window cancels the timer and clears the override."""
with patch(
"habluetooth.scanner.OriginalBleakScanner",
side_effect=lambda *_, **__: MockBleakScanner(),
):
scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
await scanner.async_request_active_window(100.0)
assert scanner._active_window_handle is not None
await scanner.async_stop()
assert scanner._active_window_handle is None
assert scanner._scan_mode_override is None # type: ignore[unreachable]
assert scanner._active_window_end == 0.0
@pytest.mark.usefixtures("force_linux_scanner_mode")
@pytest.mark.asyncio
async def test_async_request_active_window_recovers_on_start_failure() -> None:
"""If the ACTIVE restart raises, recovery brings the scanner back up."""
call_count = 0
fail_until = 0
class _CountingFailScanner(MockBleakScanner):
async def start(self) -> None:
nonlocal call_count
call_count += 1
if call_count <= fail_until:
msg = "simulated start failure"
raise BleakError(msg)
with patch_bleak_scanner_factory(_CountingFailScanner):
scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
before = call_count
# Fail the next 4 start attempts so the ACTIVE swap raises;
# then succeed so the recovery restart can come back up.
fail_until = call_count + 4
result = await scanner.async_request_active_window(1.0)
assert result is False
assert scanner._scan_mode_override is None
# Recovery restart happened after the failure path.
assert call_count > before + 4
await scanner.async_stop()
@pytest.mark.usefixtures("force_linux_scanner_mode")
@pytest.mark.asyncio
async def test_async_request_active_window_clears_override_on_unexpected_error() -> (
None
):
"""
An unexpected exception from the restart clears _scan_mode_override.
Regression: only ScannerStartError was caught explicitly, so any
other exception propagating from _async_stop_then_start_under_lock
would leave _scan_mode_override = ACTIVE. The next
_async_start_attempt would then see effective_mode = ACTIVE
instead of AUTO, poisoning subsequent starts.
"""
start_count = 0
class _UnexpectedErrorAfterFirstScanner(MockBleakScanner):
async def start(self) -> None:
nonlocal start_count
start_count += 1
# First start (initial async_start) succeeds; second start
# (the ACTIVE restart from async_request_active_window)
# raises a non-ScannerStartError so we exercise the
# broad-except cleanup path.
if start_count > 1:
msg = "simulated unexpected error"
raise RuntimeError(msg)
with patch_bleak_scanner_factory(_UnexpectedErrorAfterFirstScanner):
scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
with pytest.raises(RuntimeError, match="simulated unexpected error"):
await scanner.async_request_active_window(1.0)
# The override must be cleared even though the exception
# wasn't a ScannerStartError.
assert scanner._scan_mode_override is None
await scanner.async_stop()
@pytest.mark.asyncio
async def test_base_scanner_default_active_window_is_noop(
caplog: pytest.LogCaptureFixture,
) -> None:
"""BaseHaScanner.async_request_active_window default returns False."""
class _PlainScanner(BaseHaScanner):
@property
def discovered_devices(self) -> list[BLEDevice]:
return []
@property
def discovered_devices_and_advertisement_data(
self,
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
return {}
def get_discovered_device_advertisement_data(
self, address: str
) -> tuple[BLEDevice, AdvertisementData] | None:
return None
@property
def discovered_addresses(self) -> Iterable[str]:
return ()
scanner = _PlainScanner("AA:BB:CC:DD:EE:FF", "plain")
with caplog.at_level(logging.DEBUG, logger="habluetooth"):
result = await scanner.async_request_active_window(1.0)
assert result is False
assert any(
"does not support on-demand active windows" in record.message
for record in caplog.records
)
@pytest.mark.usefixtures("force_linux_scanner_mode")
@pytest.mark.asyncio
async def test_async_end_active_window_defers_to_new_window() -> None:
"""If a new window armed the timer, the end-window task returns early."""
with patch(
"habluetooth.scanner.OriginalBleakScanner",
side_effect=lambda *_, **__: MockBleakScanner(),
):
scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
await scanner.async_request_active_window(3600.0)
# Simulate a new window taking over by leaving the handle in place
# and call _async_end_active_window directly; it must short-circuit.
assert scanner._active_window_handle is not None
await scanner._async_end_active_window()
# Override and handle untouched because we deferred to the new window.
assert scanner._scan_mode_override == BluetoothScanningMode.ACTIVE
assert scanner._active_window_handle is not None
await scanner.async_stop()
@pytest.mark.asyncio
async def test_async_end_active_window_skips_when_not_scanning() -> None:
"""If the scanner was stopped during the window the restart is skipped."""
with patch(
"habluetooth.scanner.OriginalBleakScanner",
side_effect=lambda *_, **__: MockBleakScanner(),
):
scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
await scanner.async_request_active_window(3600.0)
# Pretend the end-window timer just fired (handle cleared) and the
# scanner was stopped in the meantime.
scanner._active_window_handle = None
scanner.scanning = False
# Should be a quick no-op: clears override, sees not scanning, returns.
await scanner._async_end_active_window()
assert scanner._scan_mode_override is None
scanner.scanning = True
await scanner.async_stop()
@pytest.mark.usefixtures("force_linux_scanner_mode")
@pytest.mark.asyncio
async def test_async_request_active_window_passive_fallback_on_linux() -> None:
"""If the swap restart falls back to PASSIVE on Linux, request returns False."""
starts = 0
class _PassiveFallbackScanner(MockBleakScanner):
async def start(self) -> None:
nonlocal starts
starts += 1
# Fail the first three attempts so the 4th-attempt PASSIVE
# fallback inside _async_start_attempt kicks in.
if 2 <= starts <= 4:
msg = "simulated active failure"
raise BleakError(msg)
with (
patch("habluetooth.scanner.IS_LINUX", True),
patch_bleak_scanner_factory(_PassiveFallbackScanner),
patch("habluetooth.scanner.async_reset_adapter", AsyncMock()),
patch("habluetooth.scanner.ADAPTER_INIT_TIME", 0),
):
scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
result = await scanner.async_request_active_window(1.0)
# The swap ran through to the 4th attempt and fell back to PASSIVE;
# the request reports False because the scanner is not ACTIVE.
assert result is False
assert scanner._scan_mode_override is None
await scanner.async_stop()
@pytest.mark.usefixtures("force_linux_scanner_mode")
@pytest.mark.asyncio
async def test_async_end_active_window_handles_start_error(
caplog: pytest.LogCaptureFixture,
) -> None:
"""ScannerStartError during the end-of-window restart logs a warning."""
starts = 0
fail_until = 0
class _FailUntilThresholdScanner(MockBleakScanner):
async def start(self) -> None:
nonlocal starts
starts += 1
if starts <= fail_until:
msg = "simulated end-window failure"
raise BleakError(msg)
with patch_bleak_scanner_factory(_FailUntilThresholdScanner):
scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
try:
# Open a long active window then drive end-of-window
# with the bleak start mocked to fail.
await scanner.async_request_active_window(3600.0)
assert scanner._active_window_handle is not None
# Fail enough start() calls that BOTH the toggle attempt
# and every retry in the fallback _async_start cycle
# raise, so we exercise the "Failed to restart scanner
# after active window" warning.
fail_until = starts + 100
scanner._active_window_handle.cancel()
scanner._active_window_handle = None
caplog.clear()
with caplog.at_level(logging.WARNING):
await scanner._async_end_active_window()
assert any(
"Failed to restart scanner after active window" in record.message
for record in caplog.records
)
finally:
# Allow the fallback restart to succeed for teardown,
# then stop the scanner so we don't leak the watchdog
# timer / background tasks into later tests.
fail_until = 0
await scanner.async_stop()
@pytest.mark.parametrize("exc", [FileNotFoundError("no dbus"), BleakError("nope")])
def test_create_bleak_scanner_wraps_init_error(exc: Exception) -> None:
"""``create_bleak_scanner`` wraps FileNotFoundError/BleakError as RuntimeError."""
with (
patch.object(scanner, "IS_LINUX", True),
patch.object(scanner, "IS_MACOS", False),
patch(
"habluetooth.scanner.OriginalBleakScanner",
side_effect=exc,
),
pytest.raises(RuntimeError, match="Failed to initialize Bluetooth"),
):
create_bleak_scanner(None, BluetoothScanningMode.ACTIVE, "hci0")
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
@pytest.mark.parametrize("exc", [TimeoutError("slow"), BleakError("nope")])
async def test_async_stop_scanner_logs_when_scanner_stop_raises(
caplog: pytest.LogCaptureFixture, exc: Exception
) -> None:
"""``_async_stop_scanner`` logs and clears the scanner when ``.stop()`` raises."""
mock_scanner = MagicMock()
mock_scanner.start = AsyncMock()
mock_scanner.stop = AsyncMock(side_effect=exc)
with patch("habluetooth.scanner.OriginalBleakScanner", return_value=mock_scanner):
scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
scanner.async_setup()
await scanner.async_start()
caplog.clear()
await scanner.async_stop()
assert "Error stopping scanner" in caplog.text
assert scanner.scanner is None
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_async_force_stop_discovery_logs_on_timeout(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Force-stop logs an error when ``stop_discovery`` times out."""
ha_scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
ha_scanner.async_setup()
with patch("habluetooth.scanner.stop_discovery", side_effect=TimeoutError("slow")):
await ha_scanner._async_force_stop_discovery()
assert "Timeout force stopping scanner" in caplog.text
await ha_scanner.async_stop()
@pytest.mark.asyncio
@pytest.mark.skipif(NOT_POSIX)
async def test_async_force_stop_discovery_logs_on_unexpected_error(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Force-stop logs an error when ``stop_discovery`` raises an unexpected error."""
ha_scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
ha_scanner.async_setup()
with patch("habluetooth.scanner.stop_discovery", side_effect=BleakError("boom")):
await ha_scanner._async_force_stop_discovery()
assert "Failed to force stop scanner" in caplog.text
await ha_scanner.async_stop()
@pytest.mark.asyncio
async def test_get_allocations_returns_none_without_slot_manager() -> None:
"""``HaScanner.get_allocations`` returns None when manager has no slot manager."""
ha_scanner = HaScanner(BluetoothScanningMode.ACTIVE, "hci0", "AA:BB:CC:DD:EE:FF")
ha_scanner.async_setup()
with patch.object(get_manager(), "slot_manager", None):
assert ha_scanner.get_allocations() is None
await ha_scanner.async_stop()
@pytest.mark.asyncio
async def test_discovered_properties_delegate_when_scanner_attached() -> None:
"""Discovered* delegate to the underlying bleak scanner when one is attached."""
device = generate_ble_device("AA:BB:CC:DD:EE:01", "x")
adv = generate_advertisement_data(local_name="x")
class _DiscoveredScanner(MockBleakScanner):
@property
def discovered_devices(self):
return [device]
@property
def discovered_devices_and_advertisement_data(self):
return {device.address: (device, adv)}
with patch_bleak_scanner_factory(_DiscoveredScanner):
ha_scanner = HaScanner(
BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:FF"
)
ha_scanner.async_setup()
await ha_scanner.async_start()
try:
assert ha_scanner.discovered_devices == [device]
assert ha_scanner.discovered_devices_and_advertisement_data == {
device.address: (device, adv)
}
assert ha_scanner.get_discovered_device_advertisement_data(
device.address
) == (device, adv)
assert device.address in list(ha_scanner.discovered_addresses)
finally:
await ha_scanner.async_stop()
@pytest.mark.asyncio
async def test_detection_callback_coerces_non_str_name_and_non_int_tx_power() -> None:
"""
Defensive coercion: bleak occasionally returns non-str names / non-int tx_power.
The advertisement path normalizes both so downstream code can rely
on plain Python str / int rather than bytes-likes or numpy ints.
Inspects ``manager._all_history`` (populated by
``_scanner_adv_received``) since the cython method itself isn't
monkey-patchable.
"""
ha_scanner = HaScanner(BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:FF")
ha_scanner.async_setup()
class _StrSubclass(str):
"""str subclass — `type() is not str` so coercion fires."""
__slots__ = ()
class _IntLike:
"""Non-int that ``int()`` accepts (e.g. numpy.int64 stand-in)."""
def __int__(self) -> int:
return -7
address = "AA:BB:CC:DD:EE:F0"
device = generate_ble_device(address, None)
adv = generate_advertisement_data(
local_name=_StrSubclass("weird"),
tx_power=_IntLike(),
)
ha_scanner._async_detection_callback(device, adv)
info = get_manager()._all_history[address]
# Both fields were coerced to the canonical Python type.
assert type(info.name) is str
assert info.name == "weird"
assert type(info.tx_power) is int
assert info.tx_power == -7
await ha_scanner.async_stop()
@pytest.mark.asyncio
async def test_start_attempt_timeout_resets_then_raises_on_exhaustion(
caplog: pytest.LogCaptureFixture,
) -> None:
"""
Persistent start TimeoutError: attempt 2 resets the adapter, attempt 4 raises.
Covers the in-between `attempt < START_ATTEMPTS` return-False branch
(logged as a retry warning) and the `attempt == START_ATTEMPTS` raise.
Subclasses HaScanner because the cython type is immutable so a
bare patch.object can't override its methods.
"""
reset_calls: list[bool] = []
class _RecordingResetScanner(HaScanner):
async def _async_reset_adapter(self, gone_silent: bool) -> None:
reset_calls.append(gone_silent)
class TimeoutMockBleakScanner(MockBleakScanner):
async def start(self) -> None:
msg = "simulated start timeout"
raise TimeoutError(msg)
with patch_bleak_scanner_factory(TimeoutMockBleakScanner):
ha_scanner = _RecordingResetScanner(
BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:FF"
)
ha_scanner.async_setup()
with (
caplog.at_level(logging.DEBUG, logger="habluetooth.scanner"),
pytest.raises(ScannerStartError, match="Timed out starting Bluetooth"),
):
await ha_scanner.async_start()
# Attempt 2 (gone_silent=False) triggered exactly one adapter reset.
assert reset_calls == [False]
await ha_scanner.async_stop()
@pytest.mark.asyncio
async def test_async_restart_scanner_logs_when_start_raises(
caplog: pytest.LogCaptureFixture,
) -> None:
"""
Watchdog restart swallows + logs ``ScannerStartError`` from ``_async_start``.
Covers the ``except ScannerStartError`` branch in
``_async_restart_scanner`` so a single restart failure doesn't
propagate out of the background task. Uses a subclass to override
methods because cython types are immutable.
"""
class _RaisingRestartScanner(HaScanner):
async def _async_start(self) -> None:
msg = "simulated restart failure"
raise ScannerStartError(msg)
async def _async_stop_scanner(self) -> None:
pass
async def _async_reset_adapter(self, gone_silent: bool) -> None:
pass
ha_scanner = _RaisingRestartScanner(
BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:FF"
)
ha_scanner.async_setup()
with caplog.at_level(logging.ERROR, logger="habluetooth.scanner"):
await ha_scanner._async_restart_scanner()
assert "Failed to restart Bluetooth scanner" in caplog.text
assert "simulated restart failure" in caplog.text
@pytest.fixture
def force_non_linux_non_macos_scanner_mode() -> Generator[None, None, None]:
"""Force the non-Linux, non-macOS branch of the active-window entry."""
with (
patch("habluetooth.scanner.IS_LINUX", False),
patch("habluetooth.scanner.IS_MACOS", False),
):
yield
@pytest.mark.usefixtures("force_non_linux_non_macos_scanner_mode")
@pytest.mark.asyncio
async def test_async_request_active_window_restart_path_happy() -> None:
"""Non-Linux active-window entry: full stop+restart leaves scanner in ACTIVE."""
with patch_bleak_scanner_factory(MockBleakScanner):
ha_scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:F1")
ha_scanner.async_setup()
await ha_scanner.async_start()
try:
assert await ha_scanner.async_request_active_window(0.5) is True
assert ha_scanner.current_mode is BluetoothScanningMode.ACTIVE
assert ha_scanner._scan_mode_override is BluetoothScanningMode.ACTIVE
assert ha_scanner._active_window_handle is not None
finally:
await ha_scanner.async_stop()
@pytest.mark.usefixtures("force_non_linux_non_macos_scanner_mode")
@pytest.mark.asyncio
async def test_async_request_active_window_restart_path_scanner_start_error() -> None:
"""
Non-Linux entry: a ``ScannerStartError`` triggers the abort recovery path.
``_async_begin_active_window_via_restart`` catches the error and
routes through ``_async_abort_active_window`` so the scanner comes
back up in its underlying mode instead of being left stopped.
"""
starts = 0
class AlwaysFailAfterFirstMockBleakScanner(MockBleakScanner):
async def start(self) -> None:
nonlocal starts
starts += 1
# First start (initial async_start) succeeds; every
# subsequent start raises so all 4 retries of the
# restart attempt exhaust, raising ScannerStartError,
# which the abort path then suppresses.
if starts > 1:
msg = "simulated start failure"
raise BleakError(msg)
class _NoResetScanner(HaScanner):
async def _async_reset_adapter(self, gone_silent: bool) -> None:
pass
with patch_bleak_scanner_factory(AlwaysFailAfterFirstMockBleakScanner):
ha_scanner = _NoResetScanner(
BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:F2"
)
ha_scanner.async_setup()
await ha_scanner.async_start()
try:
assert await ha_scanner.async_request_active_window(0.5) is False
# Abort cleared the override; no end-of-window timer armed.
assert ha_scanner._scan_mode_override is None
assert ha_scanner._active_window_handle is None
finally:
await ha_scanner.async_stop()
@pytest.mark.usefixtures("force_non_linux_non_macos_scanner_mode")
@pytest.mark.asyncio
async def test_async_request_active_window_restart_path_unexpected_error() -> None:
"""
Non-Linux entry: unexpected exceptions clear the override and re-raise.
Mirrors the toggle-path test for the restart-path
``except BaseException`` branch in
``_async_begin_active_window_via_restart``: a non-ScannerStartError
must not poison ``_scan_mode_override`` for the next start.
"""
raise_on_restart = False
class _MaybeRaiseRestartScanner(HaScanner):
async def _async_stop_then_start_under_lock(self) -> None:
if raise_on_restart:
msg = "simulated unexpected error"
raise RuntimeError(msg)
with patch_bleak_scanner_factory(MockBleakScanner):
ha_scanner = _MaybeRaiseRestartScanner(
BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:F4"
)
ha_scanner.async_setup()
await ha_scanner.async_start()
try:
raise_on_restart = True
with pytest.raises(RuntimeError, match="simulated unexpected error"):
await ha_scanner.async_request_active_window(0.5)
assert ha_scanner._scan_mode_override is None
finally:
raise_on_restart = False
await ha_scanner.async_stop()
@pytest.mark.asyncio
async def test_detection_callback_skips_last_detection_for_empty_advertisement() -> (
None
):
"""
Empty advertisements don't bump ``_last_detection``.
Bleak occasionally hands us a callback with no name / data /
service info, which we treat as a heartbeat from a failing
adapter rather than a real ping.
"""
ha_scanner = HaScanner(BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:FF")
ha_scanner.async_setup()
ha_scanner._last_detection = -1.0
device = generate_ble_device("AA:BB:CC:DD:EE:F5", None)
# All four fields explicitly empty so the truthy-check skips the
# _last_detection bump (generate_advertisement_data defaults
# local_name to "Unknown", which would otherwise trip it).
adv = generate_advertisement_data(
local_name=None,
manufacturer_data={},
service_data={},
service_uuids=[],
)
ha_scanner._async_detection_callback(device, adv)
assert ha_scanner._last_detection == -1.0
await ha_scanner.async_stop()
@pytest.mark.usefixtures("force_non_linux_non_macos_scanner_mode")
@pytest.mark.asyncio
async def test_async_request_active_window_restart_path_mode_mismatch() -> None:
"""
Non-Linux entry: a restart that doesn't land in ACTIVE clears the override.
Simulates a backend that ignores ``_scan_mode_override`` by patching
``_async_stop_then_start_under_lock`` at the class level so it
leaves ``current_mode`` unchanged at PASSIVE. The branch must clear
the override and return False so the public method can report
failure to the scheduler.
"""
noop_restart = False
class _NoopRestartScanner(HaScanner):
async def _async_stop_then_start_under_lock(self) -> None:
if not noop_restart:
await HaScanner._async_stop_then_start_under_lock(self)
with patch_bleak_scanner_factory(MockBleakScanner):
ha_scanner = _NoopRestartScanner(
BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:F3"
)
ha_scanner.async_setup()
await ha_scanner.async_start()
# Pretend a previous start left current_mode at PASSIVE.
ha_scanner.set_current_mode(BluetoothScanningMode.PASSIVE)
try:
noop_restart = True
assert await ha_scanner.async_request_active_window(0.5) is False
assert ha_scanner._scan_mode_override is None
assert ha_scanner._active_window_handle is None
finally:
noop_restart = False
await ha_scanner.async_stop()
@pytest.mark.asyncio
async def test_describe_side_channel_state_no_adapter_idx() -> None:
"""Non-hci adapter names have no MGMT index so report the fallback path."""
ha_scanner = HaScanner(
BluetoothScanningMode.PASSIVE, "Core Bluetooth", "AA:BB:CC:DD:EE:01"
)
assert (
ha_scanner._describe_side_channel_state()
== "no adapter_idx; bleak detection_callback path"
)
@pytest.mark.asyncio
async def test_describe_side_channel_state_no_side_channel() -> None:
"""When the manager never brought MGMT up we fall back to bleak's callback."""
manager = get_manager()
manager.has_advertising_side_channel = False
ha_scanner = HaScanner(BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:02")
assert (
ha_scanner._describe_side_channel_state()
== "MGMT side channel unavailable; bleak detection_callback path"
)
@pytest.mark.asyncio
async def test_describe_side_channel_state_unregistered() -> None:
"""Side channel is alive but the scanner isn't in _side_channel_scanners."""
manager = get_manager()
manager.has_advertising_side_channel = True
manager._side_channel_scanners.clear()
ha_scanner = HaScanner(BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:03")
assert (
ha_scanner._describe_side_channel_state()
== "MGMT side channel up but hci0 unregistered"
)
@pytest.mark.asyncio
async def test_describe_side_channel_state_bound_to_other_scanner() -> None:
"""Another scanner owns the hciN slot in _side_channel_scanners."""
manager = get_manager()
manager.has_advertising_side_channel = True
other = HaScanner(BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:04")
manager._side_channel_scanners[0] = other
ha_scanner = HaScanner(BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:05")
try:
assert (
ha_scanner._describe_side_channel_state()
== "MGMT side channel at hci0 bound to a different scanner"
)
finally:
manager._side_channel_scanners.clear()
@pytest.mark.asyncio
async def test_describe_side_channel_state_protocol_down() -> None:
"""Scanner is registered but the MGMT ctl is gone — socket died."""
manager = get_manager()
manager.has_advertising_side_channel = True
ha_scanner = HaScanner(BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:06")
manager._side_channel_scanners[0] = ha_scanner
manager._mgmt_ctl = None
try:
assert (
ha_scanner._describe_side_channel_state()
== "MGMT side channel registered at hci0 but protocol down"
)
finally:
manager._side_channel_scanners.clear()
@pytest.mark.asyncio
async def test_describe_side_channel_state_transport_closed() -> None:
"""Scanner is registered, protocol exists, but the transport is gone."""
manager = get_manager()
manager.has_advertising_side_channel = True
ha_scanner = HaScanner(BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:07")
manager._side_channel_scanners[0] = ha_scanner
mgmt_ctl = Mock()
mgmt_ctl.protocol = Mock(spec=BluetoothMGMTProtocol)
mgmt_ctl.protocol.transport = None
manager._mgmt_ctl = mgmt_ctl
try:
assert (
ha_scanner._describe_side_channel_state()
== "MGMT side channel registered at hci0 but transport closed"
)
finally:
manager._side_channel_scanners.clear()
manager._mgmt_ctl = None
@pytest.mark.asyncio
async def test_describe_side_channel_state_feeding() -> None:
"""Happy path: scanner registered, protocol and transport both live."""
manager = get_manager()
manager.has_advertising_side_channel = True
ha_scanner = HaScanner(BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:08")
manager._side_channel_scanners[0] = ha_scanner
mgmt_ctl = Mock()
mgmt_ctl.protocol = Mock(spec=BluetoothMGMTProtocol)
mgmt_ctl.protocol.transport = Mock()
manager._mgmt_ctl = mgmt_ctl
try:
assert (
ha_scanner._describe_side_channel_state()
== "MGMT side channel feeding hci0"
)
finally:
manager._side_channel_scanners.clear()
manager._mgmt_ctl = None
@pytest.mark.asyncio
async def test_scanner_watchdog_log_includes_side_channel_state(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The 'gone quiet' restart log line carries the side-channel diagnostic."""
manager = get_manager()
manager.has_advertising_side_channel = True
class _NoRestartScanner(HaScanner):
# Skip the actual restart so the test just exercises the log path.
def _create_background_task(self, coro):
coro.close()
with patch_bleak_scanner_factory(MockBleakScanner):
ha_scanner = _NoRestartScanner(
BluetoothScanningMode.PASSIVE, "hci0", "AA:BB:CC:DD:EE:09"
)
ha_scanner.async_setup()
await ha_scanner.async_start()
try:
# Force the watchdog over its timeout without producing any
# advertisements, and clear out the side-channel registration
# to make the diagnostic message distinctive.
manager._side_channel_scanners.clear()
ha_scanner._last_detection = (
ha_scanner._last_detection - SCANNER_WATCHDOG_TIMEOUT - 1.0
)
with caplog.at_level(logging.DEBUG, logger="habluetooth.scanner"):
ha_scanner._async_scanner_watchdog()
assert "MGMT side channel up but hci0 unregistered" in caplog.text
finally:
await ha_scanner.async_stop()
@pytest.mark.usefixtures("force_linux_scanner_mode")
@pytest.mark.asyncio
async def test_auto_scanner_current_mode_reports_passive_on_linux() -> None:
"""
AUTO must surface as PASSIVE in current_mode on Linux.
AUTO is a habluetooth scheduling concept; the BlueZ radio is
actually running in passive until the scheduler opens an active
window. Remote scanners (ESPHome) already report the real radio
state, and local adapters used to report AUTO and look stuck.
"""
with patch_bleak_scanner_factory(MockBleakScanner):
ha_scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:A0")
ha_scanner.async_setup()
await ha_scanner.async_start()
try:
assert ha_scanner.requested_mode is BluetoothScanningMode.AUTO
assert ha_scanner.current_mode is BluetoothScanningMode.PASSIVE
finally:
await ha_scanner.async_stop()
@pytest.fixture
def force_macos_scanner_mode() -> Generator[None, None, None]:
"""Force scanner.IS_LINUX=False / IS_MACOS=True for the AUTO->ACTIVE path."""
with (
patch("habluetooth.scanner.IS_LINUX", False),
patch("habluetooth.scanner.IS_MACOS", True),
):
yield
@pytest.mark.usefixtures("force_macos_scanner_mode")
@pytest.mark.asyncio
async def test_auto_scanner_current_mode_reports_active_on_macos() -> None:
"""
AUTO must surface as ACTIVE in current_mode on macOS.
CoreBluetooth has no passive mode so AUTO collapses to permanent
active; current_mode should reflect that, not the AUTO scheduling
label.
"""
with patch_bleak_scanner_factory(MockBleakScanner):
ha_scanner = HaScanner(
BluetoothScanningMode.AUTO, "Core Bluetooth", "AA:BB:CC:DD:EE:A1"
)
ha_scanner.async_setup()
await ha_scanner.async_start()
try:
assert ha_scanner.requested_mode is BluetoothScanningMode.AUTO
assert ha_scanner.current_mode is BluetoothScanningMode.ACTIVE
finally:
await ha_scanner.async_stop()
@pytest.mark.usefixtures("force_linux_scanner_mode")
@pytest.mark.asyncio
async def test_auto_scanner_current_mode_active_during_active_window() -> None:
"""
Opening an AUTO active window flips current_mode to ACTIVE.
Split from the post-window assertion to avoid mypy narrowing
``current_mode`` to a single literal across both transitions.
"""
with patch(
"habluetooth.scanner.OriginalBleakScanner",
side_effect=lambda *_a, **_kw: MockBleakScanner(),
):
ha_scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:A2")
ha_scanner.async_setup()
await ha_scanner.async_start()
try:
assert await ha_scanner.async_request_active_window(10.0) is True
assert ha_scanner.current_mode is BluetoothScanningMode.ACTIVE
assert ha_scanner._scan_mode_override is BluetoothScanningMode.ACTIVE
finally:
await ha_scanner.async_stop()
@pytest.mark.usefixtures("force_linux_scanner_mode")
@pytest.mark.asyncio
async def test_auto_scanner_current_mode_passive_after_active_window() -> None:
"""
After an AUTO active window ends current_mode returns to PASSIVE.
The end-of-window toggle restores the real radio state, which on
Linux/BlueZ is passive. current_mode must follow the radio rather
than reverting to the AUTO label.
"""
with patch(
"habluetooth.scanner.OriginalBleakScanner",
side_effect=lambda *_a, **_kw: MockBleakScanner(),
):
ha_scanner = HaScanner(BluetoothScanningMode.AUTO, "hci0", "AA:BB:CC:DD:EE:A3")
ha_scanner.async_setup()
await ha_scanner.async_start()
try:
assert await ha_scanner.async_request_active_window(1e-9) is True
for _ in range(6):
await asyncio.sleep(0)
assert ha_scanner.current_mode is BluetoothScanningMode.PASSIVE
assert ha_scanner._scan_mode_override is None
finally:
await ha_scanner.async_stop()
Bluetooth-Devices-habluetooth-75cbe37/tests/test_storage.py 0000664 0000000 0000000 00000044350 15211177045 0024224 0 ustar 00root root 0000000 0000000 import time
from unittest.mock import ANY
import pytest
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from habluetooth.storage import (
DiscoveredDeviceAdvertisementData,
DiscoveredDeviceAdvertisementDataDict,
discovered_device_advertisement_data_from_dict,
discovered_device_advertisement_data_to_dict,
expire_stale_scanner_discovered_device_advertisement_data,
)
def test_discovered_device_advertisement_data_to_dict():
"""Test discovered device advertisement data to dict."""
result = discovered_device_advertisement_data_to_dict(
DiscoveredDeviceAdvertisementData(
True,
100,
{
"AA:BB:CC:DD:EE:FF": (
BLEDevice(
address="AA:BB:CC:DD:EE:FF",
name="Test Device",
details={"details": "test"},
),
AdvertisementData(
local_name="Test Device",
manufacturer_data={0x004C: b"\x02\x15\xaa\xbb\xcc\xdd\xee\xff"},
tx_power=50,
service_data={
"0000180d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00"
},
service_uuids=["0000180d-0000-1000-8000-00805f9b34fb"],
platform_data=("Test Device", ""),
rssi=-50,
),
)
},
{"AA:BB:CC:DD:EE:FF": 100000},
)
)
assert result == {
"connectable": True,
"discovered_device_advertisement_datas": {
"AA:BB:CC:DD:EE:FF": {
"advertisement_data": {
"local_name": "Test Device",
"manufacturer_data": {"76": "0215aabbccddeeff"},
"rssi": -50,
"service_data": {
"0000180d-0000-1000-8000-00805f9b34fb": "00000000"
},
"service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"],
"tx_power": 50,
"platform_data": ["Test Device", ""],
},
"device": {
"address": "AA:BB:CC:DD:EE:FF",
"details": {"details": "test"},
"name": "Test Device",
"rssi": -50, # Now included for backward compatibility
},
}
},
"discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": ANY},
"expire_seconds": 100,
"discovered_device_raw": {},
}
def test_discovered_device_advertisement_data_from_dict():
now = time.time()
result = discovered_device_advertisement_data_from_dict(
{
"connectable": True,
"discovered_device_advertisement_datas": {
"AA:BB:CC:DD:EE:FF": {
"advertisement_data": {
"local_name": "Test Device",
"manufacturer_data": {"76": "0215aabbccddeeff"},
"rssi": -50,
"service_data": {
"0000180d-0000-1000-8000-00805f9b34fb": "00000000"
},
"service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"],
"tx_power": 50,
"platform_data": ["Test Device", ""],
},
"device": {
"address": "AA:BB:CC:DD:EE:FF",
"details": {"details": "test"},
"name": "Test Device",
}, # type: ignore[typeddict-item]
}
},
"discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": now},
"expire_seconds": 100,
"discovered_device_raw": {
"AA:BB:CC:DD:EE:FF": "0215aabbccddeeff",
},
}
)
expected_ble_device = BLEDevice(
address="AA:BB:CC:DD:EE:FF",
name="Test Device",
details={"details": "test"},
)
expected_advertisement_data = AdvertisementData(
local_name="Test Device",
manufacturer_data={0x004C: b"\x02\x15\xaa\xbb\xcc\xdd\xee\xff"},
tx_power=50,
service_data={"0000180d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00"},
service_uuids=["0000180d-0000-1000-8000-00805f9b34fb"],
platform_data=("Test Device", ""),
rssi=-50,
)
assert result is not None
out_ble_device = result.discovered_device_advertisement_datas["AA:BB:CC:DD:EE:FF"][
0
]
out_advertisement_data = result.discovered_device_advertisement_datas[
"AA:BB:CC:DD:EE:FF"
][1]
assert out_ble_device.address == expected_ble_device.address
assert out_ble_device.name == expected_ble_device.name
assert out_ble_device.details == expected_ble_device.details
# BLEDevice no longer has rssi attribute in bleak 1.0+
# rssi is only available in AdvertisementData
assert out_advertisement_data == expected_advertisement_data
assert result == DiscoveredDeviceAdvertisementData(
connectable=True,
expire_seconds=100,
discovered_device_advertisement_datas={
"AA:BB:CC:DD:EE:FF": (
ANY,
expected_advertisement_data,
)
},
discovered_device_timestamps={"AA:BB:CC:DD:EE:FF": ANY},
discovered_device_raw={
"AA:BB:CC:DD:EE:FF": b"\x02\x15\xaa\xbb\xcc\xdd\xee\xff"
},
)
def test_expire_stale_scanner_discovered_device_advertisement_data():
"""Test expire_stale_scanner_discovered_device_advertisement_data."""
now = time.time()
data = {
"myscanner": DiscoveredDeviceAdvertisementDataDict(
{
"connectable": True,
"discovered_device_advertisement_datas": {
"AA:BB:CC:DD:EE:FF": {
"advertisement_data": {
"local_name": "Test Device",
"manufacturer_data": {"76": "0215aabbccddeeff"},
"rssi": -50,
"service_data": {
"0000180d-0000-1000-8000-00805f9b34fb": "00000000"
},
"service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"],
"tx_power": 50,
"platform_data": ["Test Device", ""],
},
"device": {
"address": "AA:BB:CC:DD:EE:FF",
"details": {"details": "test"},
"name": "Test Device",
}, # type: ignore[typeddict-item]
},
"CC:DD:EE:FF:AA:BB": {
"advertisement_data": {
"local_name": "Test Device Expired",
"manufacturer_data": {"76": "0215aabbccddeeff"},
"rssi": -50,
"service_data": {
"0000180d-0000-1000-8000-00805f9b34fb": "00000000"
},
"service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"],
"tx_power": 50,
"platform_data": ["Test Device", ""],
},
"device": {
"address": "CC:DD:EE:FF:AA:BB",
"details": {"details": "test"},
"name": "Test Device Expired",
}, # type: ignore[typeddict-item]
},
},
"discovered_device_raw": {},
"discovered_device_timestamps": {
"AA:BB:CC:DD:EE:FF": now,
"CC:DD:EE:FF:AA:BB": now - 101,
},
"expire_seconds": 100,
}
),
"all_expired": DiscoveredDeviceAdvertisementDataDict(
{
"connectable": True,
"discovered_device_advertisement_datas": {
"CC:DD:EE:FF:AA:BB": {
"advertisement_data": {
"local_name": "Test Device Expired",
"manufacturer_data": {"76": "0215aabbccddeeff"},
"rssi": -50,
"service_data": {
"0000180d-0000-1000-8000-00805f9b34fb": "00000000"
},
"service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"],
"tx_power": 50,
"platform_data": ["Test Device", ""],
},
"device": {
"address": "CC:DD:EE:FF:AA:BB",
"details": {"details": "test"},
"name": "Test Device Expired",
}, # type: ignore[typeddict-item]
}
},
"discovered_device_raw": {},
"discovered_device_timestamps": {"CC:DD:EE:FF:AA:BB": now - 101},
"expire_seconds": 100,
}
),
}
expire_stale_scanner_discovered_device_advertisement_data(data)
assert len(data["myscanner"]["discovered_device_advertisement_datas"]) == 1
assert (
"CC:DD:EE:FF:AA:BB"
not in data["myscanner"]["discovered_device_advertisement_datas"]
)
assert "all_expired" not in data
def test_expire_future_discovered_device_advertisement_data(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test test_expire_future_discovered_device_advertisement_data."""
now = time.time()
data = {
"myscanner": DiscoveredDeviceAdvertisementDataDict(
{
"connectable": True,
"discovered_device_advertisement_datas": {
"AA:BB:CC:DD:EE:FF": {
"advertisement_data": {
"local_name": "Test Device",
"manufacturer_data": {"76": "0215aabbccddeeff"},
"rssi": -50,
"service_data": {
"0000180d-0000-1000-8000-00805f9b34fb": "00000000"
},
"service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"],
"tx_power": 50,
"platform_data": ["Test Device", ""],
},
"device": {
"address": "AA:BB:CC:DD:EE:FF",
"details": {"details": "test"},
"name": "Test Device",
}, # type: ignore[typeddict-item]
},
"CC:DD:EE:FF:AA:BB": {
"advertisement_data": {
"local_name": "Test Device Expired",
"manufacturer_data": {"76": "0215aabbccddeeff"},
"rssi": -50,
"service_data": {
"0000180d-0000-1000-8000-00805f9b34fb": "00000000"
},
"service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"],
"tx_power": 50,
"platform_data": ["Test Device", ""],
},
"device": {
"address": "CC:DD:EE:FF:AA:BB",
"details": {"details": "test"},
"name": "Test Device Expired",
}, # type: ignore[typeddict-item]
},
},
"discovered_device_timestamps": {
"AA:BB:CC:DD:EE:FF": now,
"CC:DD:EE:FF:AA:BB": now - 101,
},
"discovered_device_raw": {},
"expire_seconds": 100,
}
),
"all_future": DiscoveredDeviceAdvertisementDataDict(
{
"connectable": True,
"discovered_device_advertisement_datas": {
"CC:DD:EE:FF:AA:BB": {
"advertisement_data": {
"local_name": "Test Device Expired",
"manufacturer_data": {"76": "0215aabbccddeeff"},
"rssi": -50,
"service_data": {
"0000180d-0000-1000-8000-00805f9b34fb": "00000000"
},
"service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"],
"tx_power": 50,
"platform_data": ["Test Device", ""],
},
"device": {
"address": "CC:DD:EE:FF:AA:BB",
"details": {"details": "test"},
"name": "Test Device Expired",
}, # type: ignore[typeddict-item]
}
},
"discovered_device_timestamps": {"CC:DD:EE:FF:AA:BB": now + 1000000},
"discovered_device_raw": {},
"expire_seconds": 100,
}
),
}
expire_stale_scanner_discovered_device_advertisement_data(data)
assert len(data["myscanner"]["discovered_device_advertisement_datas"]) == 1
assert (
"CC:DD:EE:FF:AA:BB"
not in data["myscanner"]["discovered_device_advertisement_datas"]
)
assert "all_future" not in data
assert (
"for CC:DD:EE:FF:AA:BB on scanner all_future as it is the future" in caplog.text
)
def test_discovered_device_advertisement_data_from_dict_corrupt(caplog):
"""Shape mismatches log a WARNING and discard the cache without a traceback."""
now = time.time()
result = discovered_device_advertisement_data_from_dict(
{
"connectable": True,
"discovered_device_advertisement_datas": {
"AA:BB:CC:DD:EE:FF": {
"advertisement_data": {
"local_name": "Test Device",
"manufacturer_data": {"76": "0215aabbccddeeff"},
"service_data": {
"0000180d-0000-1000-8000-00805f9b34fb": "00000000"
},
"service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"],
},
"device": { # type: ignore[typeddict-item]
"address": "AA:BB:CC:DD:EE:FF",
"details": {"details": "test"},
},
}
},
"discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": now},
"expire_seconds": 100,
}
)
assert result is None
assert "Discovery cache shape mismatch" in caplog.text
# The shape-mismatch path is logged at WARNING without a traceback so
# operators can distinguish it from genuinely unexpected failures.
records = [
r for r in caplog.records if "Discovery cache shape mismatch" in r.getMessage()
]
assert len(records) == 1
assert records[0].levelname == "WARNING"
assert records[0].exc_info is None
def test_discovered_device_advertisement_data_from_dict_unexpected_error(
caplog, monkeypatch
):
"""Unexpected errors keep the full traceback and are logged at ERROR."""
def boom(_data):
msg = "boom"
raise RuntimeError(msg)
monkeypatch.setattr(
"habluetooth.storage._deserialize_discovered_device_advertisement_datas",
boom,
)
result = discovered_device_advertisement_data_from_dict(
{
"connectable": True,
"discovered_device_advertisement_datas": {},
"discovered_device_timestamps": {},
"expire_seconds": 100,
"discovered_device_raw": {},
}
)
assert result is None
records = [
r for r in caplog.records if "Unexpected error deserializing" in r.getMessage()
]
assert len(records) == 1
assert records[0].levelname == "ERROR"
assert records[0].exc_info is not None
def test_backward_compatibility_rssi_in_device_dict():
"""Test that devices with RSSI in storage can still be loaded."""
now = time.time()
# Simulate old storage format where RSSI was stored in the device dict
result = discovered_device_advertisement_data_from_dict(
{
"connectable": True,
"discovered_device_advertisement_datas": {
"AA:BB:CC:DD:EE:FF": {
"advertisement_data": {
"local_name": "Test Device",
"manufacturer_data": {"76": "0215aabbccddeeff"},
"rssi": -50,
"service_data": {
"0000180d-0000-1000-8000-00805f9b34fb": "00000000"
},
"service_uuids": ["0000180d-0000-1000-8000-00805f9b34fb"],
"tx_power": 50,
"platform_data": ["Test Device", ""],
},
"device": {
"address": "AA:BB:CC:DD:EE:FF",
"details": {"details": "test"},
"name": "Test Device",
"rssi": -50, # Old format included RSSI here
},
}
},
"discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": now},
"expire_seconds": 100,
"discovered_device_raw": {},
}
)
# Should successfully deserialize without errors
assert result is not None
assert result.connectable is True
assert result.expire_seconds == 100
# Check that the device was properly created
ble_device, adv_data = result.discovered_device_advertisement_datas[
"AA:BB:CC:DD:EE:FF"
]
assert ble_device.address == "AA:BB:CC:DD:EE:FF"
assert ble_device.name == "Test Device"
assert adv_data.rssi == -50
Bluetooth-Devices-habluetooth-75cbe37/tests/test_util.py 0000664 0000000 0000000 00000014211 15211177045 0023526 0 ustar 00root root 0000000 0000000 """Tests for habluetooth.util."""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, patch
import pytest
from habluetooth.util import (
async_reset_adapter,
coalesce_concurrent_future,
is_docker_env,
)
if TYPE_CHECKING:
from collections.abc import Iterator
@pytest.fixture
def mock_recover_adapter() -> Iterator[AsyncMock]:
"""Patch habluetooth.util.recover_adapter with an AsyncMock."""
mock = AsyncMock()
with patch("habluetooth.util.recover_adapter", mock):
yield mock
@pytest.mark.asyncio
async def test_async_reset_adapter_with_hci_adapter(
mock_recover_adapter: AsyncMock,
) -> None:
"""An hciN adapter delegates to bluetooth_auto_recovery.recover_adapter."""
mock_recover_adapter.return_value = True
assert await async_reset_adapter("hci3", "AA:BB:CC:DD:EE:FF", True) is True
mock_recover_adapter.assert_awaited_once_with(3, "AA:BB:CC:DD:EE:FF", True)
@pytest.mark.asyncio
async def test_async_reset_adapter_returns_false_when_adapter_is_none(
mock_recover_adapter: AsyncMock,
) -> None:
"""No adapter → recover_adapter is not invoked, returns False."""
assert await async_reset_adapter(None, "AA:BB:CC:DD:EE:FF", False) is False
mock_recover_adapter.assert_not_called()
@pytest.mark.asyncio
async def test_async_reset_adapter_returns_false_for_non_hci_adapter(
mock_recover_adapter: AsyncMock,
) -> None:
"""A non-hci adapter (e.g. CoreBluetooth) returns False without recovery."""
assert (
await async_reset_adapter("Core Bluetooth", "AA:BB:CC:DD:EE:FF", False) is False
)
mock_recover_adapter.assert_not_called()
def test_is_docker_env_true(monkeypatch: pytest.MonkeyPatch) -> None:
"""/.dockerenv present → True."""
is_docker_env.cache_clear()
monkeypatch.setattr("habluetooth.util.Path.exists", lambda self: True)
assert is_docker_env() is True
is_docker_env.cache_clear()
def test_is_docker_env_false(monkeypatch: pytest.MonkeyPatch) -> None:
"""/.dockerenv missing → False."""
is_docker_env.cache_clear()
monkeypatch.setattr("habluetooth.util.Path.exists", lambda self: False)
assert is_docker_env() is False
is_docker_env.cache_clear()
class _Coalesced:
"""Test fixture instance for the coalesce_concurrent_future decorator."""
def __init__(self) -> None:
self._fut: asyncio.Future[int] | None = None
self.call_count = 0
self.started = asyncio.Event()
self.release = asyncio.Event()
self.return_value = 42
self.raise_exc: BaseException | None = None
@coalesce_concurrent_future("_fut")
async def work(self) -> int:
self.call_count += 1
self.started.set()
await self.release.wait()
if self.raise_exc is not None:
raise self.raise_exc
return self.return_value
@pytest.mark.asyncio
async def test_coalesce_concurrent_future_single_call_returns_result() -> None:
"""A lone caller runs the wrapped coroutine and gets its result."""
obj = _Coalesced()
obj.release.set()
assert await obj.work() == 42
assert obj.call_count == 1
assert obj._fut is None
@pytest.mark.asyncio
async def test_coalesce_concurrent_future_concurrent_callers_share_one_call() -> None:
"""Concurrent callers share a single underlying invocation."""
obj = _Coalesced()
leader = asyncio.create_task(obj.work())
await obj.started.wait()
waiter_a = asyncio.create_task(obj.work())
waiter_b = asyncio.create_task(obj.work())
await asyncio.sleep(0)
obj.release.set()
assert await leader == 42
assert await waiter_a == 42
assert await waiter_b == 42
assert obj.call_count == 1
assert obj._fut is None
@pytest.mark.asyncio
async def test_coalesce_concurrent_future_propagates_exception_to_waiters() -> None:
"""Leader exception is observed by every concurrent waiter."""
obj = _Coalesced()
obj.raise_exc = RuntimeError("boom")
leader = asyncio.create_task(obj.work())
await obj.started.wait()
waiter = asyncio.create_task(obj.work())
await asyncio.sleep(0)
obj.release.set()
with pytest.raises(RuntimeError, match="boom"):
await leader
with pytest.raises(RuntimeError, match="boom"):
await waiter
assert obj._fut is None
@pytest.mark.asyncio
async def test_coalesce_concurrent_future_leader_cancellation_surfaces_to_waiters() -> (
None
):
"""Leader cancellation propagates to waiters, future is cleared."""
obj = _Coalesced()
leader = asyncio.create_task(obj.work())
await obj.started.wait()
waiter = asyncio.create_task(obj.work())
await asyncio.sleep(0)
leader.cancel()
with pytest.raises(asyncio.CancelledError):
await leader
with pytest.raises(asyncio.CancelledError):
await waiter
assert obj._fut is None
@pytest.mark.asyncio
async def test_coalesce_concurrent_future_waiter_cancel_does_not_strand_leader() -> (
None
):
"""Cancelling a waiter does not poison the shared future."""
obj = _Coalesced()
leader = asyncio.create_task(obj.work())
await obj.started.wait()
waiter_a = asyncio.create_task(obj.work())
waiter_b = asyncio.create_task(obj.work())
await asyncio.sleep(0)
waiter_a.cancel()
with pytest.raises(asyncio.CancelledError):
await waiter_a
obj.release.set()
assert await leader == 42
assert await waiter_b == 42
assert obj._fut is None
@pytest.mark.asyncio
async def test_coalesce_concurrent_future_resets_between_sequential_calls() -> None:
"""Future state resets so a later call runs fresh."""
obj = _Coalesced()
obj.release.set()
assert await obj.work() == 42
assert obj._fut is None
obj.return_value = 7
assert await obj.work() == 7
assert obj.call_count == 2
@pytest.mark.asyncio
async def test_coalesce_concurrent_future_requires_attribute_initialised() -> None:
"""Missing attribute on the instance raises AttributeError."""
class _Missing:
@coalesce_concurrent_future("_fut")
async def work(self) -> int:
return 1
with pytest.raises(AttributeError):
await _Missing().work()
Bluetooth-Devices-habluetooth-75cbe37/tests/test_wrappers.py 0000664 0000000 0000000 00000274167 15211177045 0024436 0 ustar 00root root 0000000 0000000 """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